Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f0bdb7515 | ||
|
|
c4fdf374e1 | ||
|
|
3501f620a3 | ||
|
|
c51c01607d | ||
|
|
d8d92b7b71 | ||
|
|
161cd26519 | ||
|
|
dd1ec9d462 | ||
|
|
22072c5f4b | ||
|
|
ca7d82629d | ||
|
|
f2f23f8978 | ||
|
|
9358b2c549 | ||
|
|
4f0e2dde98 | ||
|
|
68af6b277c | ||
|
|
79a7dcc46c | ||
|
|
b311e01ce0 | ||
|
|
ae3493a00c | ||
|
|
1cf6d7648f | ||
|
|
5ffd30c4a8 | ||
|
|
358e4e2fcd | ||
|
|
7424631a27 | ||
|
|
2291b672c4 | ||
|
|
a944e1c908 | ||
|
|
e4f4d1f9e7 | ||
|
|
43d485dd76 | ||
|
|
d878c81ce8 | ||
|
|
8a0c7592cc | ||
|
|
882ca0a216 | ||
|
|
5d1c4f15b7 | ||
|
|
745b53698a | ||
|
|
b938b63e8a | ||
|
|
b76e5e6ba4 | ||
|
|
bbcc35e209 | ||
|
|
bede05356d | ||
|
|
031e73bbe6 | ||
|
|
9ea1f0065a | ||
|
|
8267bb22bd | ||
|
|
0c6753002a | ||
|
|
7555242661 | ||
|
|
c5a8fbc157 | ||
|
|
fbccf910d4 | ||
|
|
b02193f341 | ||
|
|
097f77cbcf | ||
|
|
8995a68b07 | ||
|
|
ebe6ddc7aa | ||
|
|
b27f80131a | ||
|
|
e3d68645c1 |
@@ -191,7 +191,24 @@ Writers go through `setNodeStatus`, `updatePosition`, `updateTelemetry` (which d
|
||||
|
||||
### Eviction
|
||||
|
||||
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly.
|
||||
Every code path that drops a node from the header table must also evict the satellites. The single chokepoint is `eraseNodeSatellites(NodeNum)`; it's already called from `getOrCreateMeshNode`'s oldest-boring eviction, `demoteOldestHotNodesToWarm` (the over-cap warm-tier migration), `removeNodeByNum`, both branches of `resetNodes`, `cleanupMeshDB`, `addFromContact`'s ignored-branch, and `AdminModule`'s `set_ignored_node`. Add new eviction sites here, not by calling `.erase()` directly. (Note: `enforceSatelliteCaps`/`evictSatelliteOverCap` call `.erase()` directly on purpose — that's a satellite-only cap trim where the node _stays_ in the header, a different operation from this chokepoint.)
|
||||
|
||||
### Warm tier (long-tail identity)
|
||||
|
||||
On every arch except STM32WL and bare nRF52832 (`WARM_NODE_COUNT > 0`), a node evicted from the header table is not forgotten outright: `WarmNodeStore` (`src/mesh/WarmNodeStore.{h,cpp}`) keeps a 40 B `{num, last_heard, public_key}` record per evicted node — primarily so PKI DMs to/from a long-tail node keep decrypting without re-running a NodeInfo exchange (the rest of `NodeInfoLite` rebuilds from traffic in seconds).
|
||||
|
||||
- **Write:** `getOrCreateMeshNode`'s eviction and `demoteOldestHotNodesToWarm` (the over-cap boot migration) call `warmStore.absorb(num, last_heard, key)` _before_ the node leaves the header.
|
||||
- **Read-back:** `getOrCreateMeshNode` calls `warmStore.take()` to rehydrate `last_heard` + key when a warm node is re-admitted; `copyPublicKey()` falls back to the warm tier so the PKI send path finds keys for evicted peers.
|
||||
- **Persistence:** nRF52840 uses a 12 KB raw-flash record-ring at `0xEA000` (below LittleFS; append + replay + compact-on-rotate, link-guarded by `nrf52840_s140_v7.ld` and `extra_scripts/nrf52_warm_region.py`). Everywhere else: a `/prefs/warm.dat` snapshot flushed by `saveIfDirty()` on the node-DB save cadence.
|
||||
- **Tunables** (`mesh-pb-constants.h`): `WARM_NODE_COUNT` (per-arch; `0` disables the tier) and `MAX_NUM_NODES` (hot cap — 120 on nRF52840/generic ESP32 to fit the 28 KB LittleFS; ESP32-S3 keeps its flash-scaled 100/200/250, portduino 250). Verbose migration/self-care tracing routes through `LOG_MIGRATION`, gated by `MESHTASTIC_NODEDB_MIGRATION_VERBOSE`.
|
||||
|
||||
### Satellite caps
|
||||
|
||||
Only the freshest `MAX_SATELLITE_NODES` nodes keep satellite payloads; the rest of the header table carries just the `NodeInfoLite`. The cap is **per-platform**: 40 on RAM-constrained parts (nRF52840, generic ESP32) since the four maps live in internal SRAM (not PSRAM, ~408 B/node across the four), and 250 on flash-rich hosts (ESP32-S3, portduino) so every hot node can carry rich data as before the cap existed. `enforceSatelliteCaps()` trims each map to the cap on load (returns whether it trimmed); `evictSatelliteOverCap()` trims before each insert. Eviction is by the owning node's hot `last_heard` (stalest first, demoted/absent nodes rank as `last_heard==0`); self is never trimmed.
|
||||
|
||||
### On-boot self-care
|
||||
|
||||
`NodeDB::nodeDBSelfCare()` runs once identity is established (the constructor after key (re)gen, and `reloadFromDisk()` — _not_ inside `loadFromDisk`, where `getNodeNum()` is still 0). It confirms self is present (warns if a non-empty DB is missing us — a foreign/over-cap file), pins self to index 0, demotes/trims only **non-self** overflow into the warm tier, then rewrites `nodes.proto` **once** and only if it healed something — and never while encrypted storage is locked (it would persist placeholder defaults). `loadFromDisk` deliberately leaves the loaded store untrimmed for this pass.
|
||||
|
||||
### Sync flow: thin NodeInfo + post-COMPLETE_ID replay (no opt-in)
|
||||
|
||||
|
||||
@@ -37,13 +37,33 @@ jobs:
|
||||
sed -i -e "s#${PWD}#.#" coverage_base.info # Make paths relative.
|
||||
|
||||
- name: Integration test
|
||||
# Cap the whole step: if the simulator ever fails to exit (e.g. the
|
||||
# exit_simulator admin path regresses again) the job must fail fast,
|
||||
# not run to GitHub's 6-hour limit.
|
||||
timeout-minutes: 5
|
||||
run: |
|
||||
.pio/build/coverage/meshtasticd -s &
|
||||
PID=$!
|
||||
trap 'kill "$PID" 2>/dev/null || true' EXIT
|
||||
timeout 20 bash -c "until ls -al /proc/$PID/fd | grep socket; do sleep 1; done"
|
||||
echo "Simulator started, launching python test..."
|
||||
python3 -c 'from meshtastic.test import testSimulator; testSimulator()'
|
||||
wait
|
||||
# The Python harness sends exit_simulator and exits; the simulator is
|
||||
# expected to terminate on its own. Give it a moment, then verify.
|
||||
# If it is still alive the exit handshake is broken — fail loudly and
|
||||
# do NOT fall through to `wait`, which would otherwise block until the
|
||||
# job's hard timeout.
|
||||
for i in $(seq 1 10); do
|
||||
kill -0 "$PID" 2>/dev/null || break
|
||||
sleep 1
|
||||
done
|
||||
if kill -0 "$PID" 2>/dev/null; then
|
||||
echo "::error title=Simulator did not exit::meshtasticd ignored exit_simulator and is still running after the integration test. The exit_simulator admin path is broken (see AdminModule::handleReceivedProtobuf, ARCH_PORTDUINO bypass). Killing it to avoid a 6-hour CI overrun."
|
||||
kill -9 "$PID" 2>/dev/null || true
|
||||
wait "$PID" 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
wait "$PID" 2>/dev/null || true
|
||||
|
||||
- name: Capture coverage information
|
||||
if: always() # run this step even if previous step failed
|
||||
@@ -141,6 +161,14 @@ jobs:
|
||||
name: platformio-test-report-${{ steps.version.outputs.long }}
|
||||
merge-multiple: true
|
||||
|
||||
- name: Drop no-status testsuites from the report
|
||||
# PlatformIO emits a self-closing <testsuite tests="0"/> row for every test_* dir
|
||||
# crossed with every hardware variant it cannot run on the native host (~4900 rows).
|
||||
# They carry no pass/fail/skip status and bury the suites that actually ran. Strip
|
||||
# them so the Test Report lists only suites with a real status. Only the copy the
|
||||
# reporter renders is trimmed; the uploaded artifact keeps the full XML.
|
||||
run: sed -i -E 's#<testsuite [^>]*tests="0"[^>]*/>##g' testreport.xml
|
||||
|
||||
- name: Test Report
|
||||
uses: dorny/test-reporter@v3.0.0
|
||||
with:
|
||||
|
||||
+32
@@ -10,3 +10,35 @@
|
||||
## Reporting a Vulnerability
|
||||
|
||||
We support the private reporting of potential security vulnerabilities. Please go to the Security tab to file a report with a description of the potential vulnerability and reproduction scripts (preferred) or steps, and our developers will review.
|
||||
|
||||
Before filing, please read the Security Model below. Behavior whose only precondition is local API access to a node, or possession of a channel's pre-shared key, is intended by design and is not considered a vulnerability.
|
||||
|
||||
## Security Model
|
||||
|
||||
Meshtastic is an off-grid mesh protocol that runs on constrained microcontrollers within a 256 byte LoRa packet limit. These constraints shape its security design and rule out the heavier schemes used by IP-based protocols. This section summarizes what the firmware protects, the assumptions it rests on, and its known limits. Fuller write-ups are in the documentation:
|
||||
|
||||
- Encryption overview: https://meshtastic.org/docs/overview/encryption/
|
||||
- Technical reference: https://meshtastic.org/docs/development/reference/encryption-technical/
|
||||
- Known limitations and future work: https://meshtastic.org/docs/about/overview/encryption/limitations/
|
||||
|
||||
### Cryptographic mechanisms
|
||||
|
||||
- Channels are encrypted with a pre-shared key (PSK) using AES256-CTR. Channel traffic is encrypted but not authenticated, so anyone holding the PSK can read channel messages and can send messages as any node on that channel.
|
||||
- Direct messages and admin messages use public key cryptography (x25519 key exchange with AES-CCM), providing confidentiality, authentication, and integrity between nodes on 2.5.0 or newer that have exchanged keys.
|
||||
- Admin sessions use short-lived session IDs to limit replay of control messages.
|
||||
|
||||
### Local trust boundary
|
||||
|
||||
A client connected to a node over Bluetooth, USB serial, WiFi, or Ethernet has full local API access. From that connection it can read decrypted traffic, send messages as the node, change configuration (subject to managed mode), and read the node's private key for backup. This is intended behavior. The firmware trusts the local link the same way a phone or laptop trusts a directly attached device, and anything within reach of that connection (a shared LAN, a USB cable to an untrusted host, a paired phone) should be treated as part of the node itself.
|
||||
|
||||
### Node identity (Trust On First Use)
|
||||
|
||||
There is no central authority to sign node keys. The first public key a node hears for a given node number is the one it binds to that node number, a Trust On First Use (TOFU) model that is a hard requirement of a decentralized mesh. Clients and firmware reduce the impact of this by keeping favorited nodes from rolling out of the node database and by flagging public-key changes in the client UI.
|
||||
|
||||
Firmware 2.8.X adds XEdDSA packet signing to further secure node identity claims and the authenticity of subsequent messages. It reuses each node's existing x25519 key pair to produce signatures, so a receiver can verify that a packet came from the holder of the bound key. Once a node has been seen signing, unsigned packets claiming that identity can be rejected.
|
||||
|
||||
### Known limitations
|
||||
|
||||
- No perfect forward secrecy. Traffic captured today can be decrypted later if a key is compromised, for example through a lost node or a mishandled channel key.
|
||||
- Channel messages are not authenticated, as noted above. Although as of 2.8, channel messages will be xedDSA signed as a means of verification that is non-breaking.
|
||||
- Setting WiFi credentials, or performing any other local administration, on an ESP32 over an untrusted network exposes that traffic, including the credentials, to the network. Provision and administer nodes over a trusted channel instead: Bluetooth, USB serial, or remote admin over the mesh. There is no current roadmap item to secure local administration over untrusted WiFi, though it may be addressed in a future release.
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Meshtastic Ethernet OTA Upload Tool
|
||||
|
||||
Uploads firmware to RP2350-based Meshtastic devices via Ethernet (W5500).
|
||||
Compresses firmware with GZIP and sends it over TCP using the MOTA protocol.
|
||||
Authenticates using SHA256 challenge-response with a pre-shared key (PSK).
|
||||
|
||||
Usage:
|
||||
python bin/eth-ota-upload.py --host 192.168.1.100 firmware.bin
|
||||
python bin/eth-ota-upload.py --host 192.168.1.100 --psk mySecretKey firmware.bin
|
||||
python bin/eth-ota-upload.py --host 192.168.1.100 --psk-hex 6d65736874... firmware.bin
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import gzip
|
||||
import hashlib
|
||||
import socket
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
# Default PSK matching the firmware default: "meshtastic_ota_default_psk_v1!!!"
|
||||
DEFAULT_PSK = b"meshtastic_ota_default_psk_v1!!!"
|
||||
|
||||
|
||||
def crc32(data: bytes) -> int:
|
||||
"""Compute CRC32 matching ErriezCRC32 (standard CRC32 with final XOR)."""
|
||||
import binascii
|
||||
|
||||
return binascii.crc32(data) & 0xFFFFFFFF
|
||||
|
||||
|
||||
def load_firmware(path: str) -> bytes:
|
||||
"""Load firmware file, compressing with GZIP if not already compressed."""
|
||||
# Reject UF2 files — OTA requires raw .bin firmware
|
||||
if path.lower().endswith(".uf2"):
|
||||
bin_path = path.rsplit(".", 1)[0] + ".bin"
|
||||
print(f"ERROR: UF2 files cannot be used for OTA updates.")
|
||||
print(f" The Updater/picoOTA expects raw .bin firmware.")
|
||||
print(f" Try: {bin_path}")
|
||||
sys.exit(1)
|
||||
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
|
||||
# Check if already GZIP compressed (magic bytes 1f 8b)
|
||||
if data[:2] == b"\x1f\x8b":
|
||||
print(f"Firmware already GZIP compressed: {len(data):,} bytes")
|
||||
return data
|
||||
|
||||
print(f"Firmware raw size: {len(data):,} bytes")
|
||||
compressed = gzip.compress(data, compresslevel=9)
|
||||
ratio = len(compressed) / len(data) * 100
|
||||
print(f"GZIP compressed: {len(compressed):,} bytes ({ratio:.1f}%)")
|
||||
return compressed
|
||||
|
||||
|
||||
def authenticate(sock: socket.socket, psk: bytes) -> bool:
|
||||
"""Perform SHA256 challenge-response authentication with the device."""
|
||||
# Receive 32-byte nonce from server
|
||||
nonce = b""
|
||||
while len(nonce) < 32:
|
||||
chunk = sock.recv(32 - len(nonce))
|
||||
if not chunk:
|
||||
print("ERROR: Connection closed during authentication")
|
||||
return False
|
||||
nonce += chunk
|
||||
|
||||
# Compute SHA256(nonce || PSK)
|
||||
h = hashlib.sha256()
|
||||
h.update(nonce)
|
||||
h.update(psk)
|
||||
response = h.digest()
|
||||
|
||||
# Send 32-byte response
|
||||
sock.sendall(response)
|
||||
|
||||
# Wait for auth result (1 byte)
|
||||
result = sock.recv(1)
|
||||
if not result:
|
||||
print("ERROR: No authentication response")
|
||||
return False
|
||||
|
||||
if result[0] == 0x06: # ACK
|
||||
print("Authentication successful.")
|
||||
return True
|
||||
elif result[0] == 0x07: # OTA_ERR_AUTH
|
||||
print("ERROR: Authentication failed — wrong PSK")
|
||||
return False
|
||||
else:
|
||||
print(f"ERROR: Unexpected auth response 0x{result[0]:02X}")
|
||||
return False
|
||||
|
||||
|
||||
def upload_firmware(host: str, port: int, firmware: bytes, psk: bytes, timeout: float) -> bool:
|
||||
"""Upload firmware over TCP using the MOTA protocol with PSK authentication."""
|
||||
fw_crc = crc32(firmware)
|
||||
fw_size = len(firmware)
|
||||
|
||||
print(f"Connecting to {host}:{port}...")
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
sock.settimeout(timeout)
|
||||
|
||||
try:
|
||||
sock.connect((host, port))
|
||||
print("Connected.")
|
||||
|
||||
# Step 1: Authenticate
|
||||
print("Authenticating...")
|
||||
if not authenticate(sock, psk):
|
||||
return False
|
||||
|
||||
# Step 2: Send 12-byte MOTA header: magic(4) + size(4) + crc32(4)
|
||||
header = struct.pack("<4sII", b"MOTA", fw_size, fw_crc)
|
||||
sock.sendall(header)
|
||||
print(f"Header sent: size={fw_size:,}, CRC32=0x{fw_crc:08X}")
|
||||
|
||||
# Wait for ACK (1 byte)
|
||||
ack = sock.recv(1)
|
||||
if not ack or ack[0] != 0x06:
|
||||
error_codes = {
|
||||
0x02: "Size error",
|
||||
0x04: "Invalid magic",
|
||||
0x05: "Update.begin() failed",
|
||||
}
|
||||
code = ack[0] if ack else 0xFF
|
||||
msg = error_codes.get(code, f"Unknown error 0x{code:02X}")
|
||||
print(f"ERROR: Server rejected header: {msg}")
|
||||
return False
|
||||
|
||||
print("Header accepted. Uploading firmware...")
|
||||
|
||||
# Send firmware in 1KB chunks
|
||||
chunk_size = 1024
|
||||
sent = 0
|
||||
start_time = time.time()
|
||||
|
||||
while sent < fw_size:
|
||||
end = min(sent + chunk_size, fw_size)
|
||||
chunk = firmware[sent:end]
|
||||
sock.sendall(chunk)
|
||||
sent = end
|
||||
|
||||
# Progress bar
|
||||
pct = sent * 100 // fw_size
|
||||
bar_len = 40
|
||||
filled = bar_len * sent // fw_size
|
||||
bar = "█" * filled + "░" * (bar_len - filled)
|
||||
elapsed = time.time() - start_time
|
||||
speed = sent / elapsed if elapsed > 0 else 0
|
||||
sys.stdout.write(f"\r [{bar}] {pct:3d}% {sent:,}/{fw_size:,} ({speed/1024:.1f} KB/s)")
|
||||
sys.stdout.flush()
|
||||
|
||||
elapsed = time.time() - start_time
|
||||
print(f"\n Transfer complete in {elapsed:.1f}s")
|
||||
|
||||
# Wait for final result (1 byte)
|
||||
print("Waiting for verification...")
|
||||
result = sock.recv(1)
|
||||
if not result:
|
||||
print("ERROR: No response from device")
|
||||
return False
|
||||
|
||||
result_codes = {
|
||||
0x00: "OK — Update staged, device rebooting",
|
||||
0x01: "CRC mismatch",
|
||||
0x02: "Size error",
|
||||
0x03: "Write error",
|
||||
0x04: "Magic mismatch",
|
||||
0x05: "Updater.begin() failed",
|
||||
0x07: "Auth failed",
|
||||
0x08: "Timeout",
|
||||
}
|
||||
code = result[0]
|
||||
msg = result_codes.get(code, f"Unknown result 0x{code:02X}")
|
||||
|
||||
if code == 0x00:
|
||||
print(f"SUCCESS: {msg}")
|
||||
return True
|
||||
else:
|
||||
print(f"ERROR: {msg}")
|
||||
return False
|
||||
|
||||
except socket.timeout:
|
||||
print("ERROR: Connection timed out")
|
||||
return False
|
||||
except ConnectionRefusedError:
|
||||
print(f"ERROR: Connection refused by {host}:{port}")
|
||||
return False
|
||||
except OSError as e:
|
||||
print(f"ERROR: {e}")
|
||||
return False
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Upload firmware to Meshtastic RP2350 devices via Ethernet OTA"
|
||||
)
|
||||
parser.add_argument("firmware", help="Path to firmware .bin or .bin.gz file")
|
||||
parser.add_argument("--host", required=True, help="Device IP address")
|
||||
parser.add_argument(
|
||||
"--port", type=int, default=4243, help="OTA port (default: 4243)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=float,
|
||||
default=60.0,
|
||||
help="Socket timeout in seconds (default: 60)",
|
||||
)
|
||||
psk_group = parser.add_mutually_exclusive_group()
|
||||
psk_group.add_argument(
|
||||
"--psk",
|
||||
type=str,
|
||||
help="Pre-shared key as UTF-8 string (default: meshtastic_ota_default_psk_v1!!!)",
|
||||
)
|
||||
psk_group.add_argument(
|
||||
"--psk-hex",
|
||||
type=str,
|
||||
help="Pre-shared key as hex string (e.g., 6d65736874...)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Resolve PSK
|
||||
if args.psk:
|
||||
psk = args.psk.encode("utf-8")
|
||||
elif args.psk_hex:
|
||||
try:
|
||||
psk = bytes.fromhex(args.psk_hex)
|
||||
except ValueError:
|
||||
print("ERROR: Invalid hex string for --psk-hex")
|
||||
sys.exit(1)
|
||||
else:
|
||||
psk = DEFAULT_PSK
|
||||
|
||||
print("Meshtastic Ethernet OTA Upload")
|
||||
print("=" * 40)
|
||||
|
||||
firmware = load_firmware(args.firmware)
|
||||
|
||||
if upload_firmware(args.host, args.port, firmware, psk, args.timeout):
|
||||
print("\nDevice is rebooting with new firmware.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\nUpload failed.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable
+249
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run native PlatformIO unit tests and emit a single, unambiguous RED/AMBER/GREEN verdict.
|
||||
#
|
||||
# Why this exists: PlatformIO reports failures three different ways ([FAILED], :FAIL:,
|
||||
# [ERRORED]) and an all-pass run prints "N succeeded" with NO "0 failed" clause — so naive
|
||||
# greps produce false greens (see .notes/test-passfail-filter.md). This script encodes the
|
||||
# correct logic once, and cross-checks the number of suites that actually ran against the
|
||||
# canonical set in test/ so a suite silently going missing shows up as AMBER, not green.
|
||||
#
|
||||
# Usage:
|
||||
# ./bin/run-tests.sh # run all suites, full RAG + count cross-check
|
||||
# ./bin/run-tests.sh -f test_utf8 # run one suite (no count cross-check)
|
||||
# ./bin/run-tests.sh -e native # override env (default: coverage)
|
||||
# ./bin/run-tests.sh --quiet # only print the final RESULT line
|
||||
#
|
||||
# Exit codes: 0 = GREEN, 1 = RED, 2 = AMBER.
|
||||
#
|
||||
# The final line is machine-readable, e.g.:
|
||||
# RESULT: GREEN 19/19 suites passed
|
||||
# RESULT: AMBER 17/19 suites ran (missing: test_radio test_serial) — all that ran passed
|
||||
# RESULT: RED test_traffic_management: 1 failed (or: build/crash error)
|
||||
# RESULT: RED sanitizer fault — SUMMARY: AddressSanitizer: 1272 byte(s) leaked (tests may have
|
||||
# all passed; the coverage build aborts at exit on an ASan/LSan fault — often shown only
|
||||
# as [ERRORED]/SIGHUP. The script names it and points at running the binary bare.)
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$ROOT_DIR"
|
||||
|
||||
ENV="coverage"
|
||||
FILTER=""
|
||||
QUIET=false
|
||||
PASSTHRU=()
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
-f)
|
||||
FILTER="$2"
|
||||
PASSTHRU+=("-f" "$2")
|
||||
shift 2
|
||||
;;
|
||||
-e)
|
||||
ENV="$2"
|
||||
shift 2
|
||||
;;
|
||||
--quiet)
|
||||
QUIET=true
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
PASSTHRU+=("$1")
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Locate pio (PATH, then the standard PlatformIO venv).
|
||||
PIO="$(command -v pio || command -v platformio || echo "$HOME/.platformio/penv/bin/pio")"
|
||||
if [[ ! -x $PIO ]] && ! command -v "$PIO" >/dev/null 2>&1; then
|
||||
echo "RESULT: RED pio not found (looked in PATH and ~/.platformio/penv/bin)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LOG="$(mktemp -t meshtest.XXXXXX.log)"
|
||||
MARKER=""
|
||||
PROGRESS_PID=""
|
||||
trap 'rm -f "$LOG" "${MARKER:-}"; [[ -n ${PROGRESS_PID:-} ]] && kill "$PROGRESS_PID" 2>/dev/null' EXIT
|
||||
|
||||
# Canonical suite set = the directories in test/. This is the source of truth for
|
||||
# "what should run"; a filtered run only expects its filtered suite.
|
||||
mapfile -t ALL_SUITES < <(find test -maxdepth 1 -type d -name 'test_*' -printf '%f\n' | sort)
|
||||
EXPECTED_COUNT=${#ALL_SUITES[@]}
|
||||
|
||||
# Cached object-count for this env, written after each completed build (in the gitignored build
|
||||
# dir). Used as the progress denominator: accurate for a full rebuild (every object recompiles),
|
||||
# only a rough upper bound for an incremental run.
|
||||
BASELINE_FILE=".pio/build/${ENV}/.runtests-objcount"
|
||||
|
||||
# Progress trail file (gitignored build dir). ALWAYS written so a backgrounded/piped run can be
|
||||
# checked mid-build with `tail -f` — that's the whole point: don't fly blind on a 20-min rebuild.
|
||||
PROGRESS_FILE=".pio/build/${ENV}/.runtests-progress"
|
||||
|
||||
# --- Progress heartbeat ------------------------------------------------------
|
||||
# Emit ONE status line every few seconds: build = objects (re)compiled this run / cached total +
|
||||
# best-effort ETA; test = suites finished / expected. Appends to $PROGRESS_FILE always (tail it to
|
||||
# check on a backgrounded run); also live-updates the tty when $5=1 (interactive --quiet). Never
|
||||
# touches $LOG, which is parsed for the verdict, so piped/CI captures stay clean.
|
||||
progress_monitor() {
|
||||
local marker="$1" objtotal="$2" testtotal="$3" pfile="$4" totty="$5" start now el done ran eta line
|
||||
start=$(date +%s)
|
||||
while :; do
|
||||
now=$(date +%s)
|
||||
el=$((now - start))
|
||||
if grep -q 'Testing\.\.\.' "$LOG" 2>/dev/null; then
|
||||
ran=$(grep -cE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" 2>/dev/null)
|
||||
line=$(printf '[test] %s/%s suites done — %dm%02ds' "$ran" "$testtotal" $((el / 60)) $((el % 60)))
|
||||
else
|
||||
done=$(find ".pio/build/${ENV}" -name '*.o' -newer "$marker" 2>/dev/null | wc -l)
|
||||
if ((objtotal > 0 && done > 0)); then
|
||||
eta=$((objtotal > done ? (objtotal - done) * el / done : 0))
|
||||
line=$(printf '[build] %d/%d objs — %dm%02ds — ETA ~%dm%02ds' \
|
||||
"$done" "$objtotal" $((el / 60)) $((el % 60)) $((eta / 60)) $((eta % 60)))
|
||||
else
|
||||
# done==0 (incremental: nothing to rebuild yet) or no cached baseline — no ETA yet.
|
||||
line=$(printf '[build] %d objs compiled — %dm%02ds' "$done" $((el / 60)) $((el % 60)))
|
||||
fi
|
||||
fi
|
||||
printf '%s\n' "$line" >>"$pfile" 2>/dev/null # file trail (always)
|
||||
[[ $totty == 1 ]] && printf '\r\033[K%s' "$line" >/dev/tty 2>/dev/null # live line (human)
|
||||
sleep 4
|
||||
done
|
||||
}
|
||||
|
||||
# Launch the heartbeat for every run. It writes the progress file unconditionally; the live tty
|
||||
# line only when interactive AND --quiet (where pio's own output is hidden — otherwise pio's
|
||||
# streamed compile lines already show progress and a \r line would just fight them).
|
||||
mkdir -p ".pio/build/${ENV}" 2>/dev/null || true
|
||||
: >"$PROGRESS_FILE" 2>/dev/null || true
|
||||
MARKER="$(mktemp -t meshtest-mark.XXXXXX)"
|
||||
TOTTY=0
|
||||
{ $QUIET && [[ -t 1 ]]; } && TOTTY=1
|
||||
progress_monitor "$MARKER" "$(cat "$BASELINE_FILE" 2>/dev/null || echo 0)" \
|
||||
"$([[ -n $FILTER ]] && echo 1 || echo "$EXPECTED_COUNT")" "$PROGRESS_FILE" "$TOTTY" &
|
||||
PROGRESS_PID=$!
|
||||
|
||||
if ! $QUIET; then
|
||||
echo "Running: $PIO test -e $ENV ${PASSTHRU[*]-} (expecting $EXPECTED_COUNT suites)"
|
||||
fi
|
||||
echo "progress: tail -f $PROGRESS_FILE" >&2
|
||||
|
||||
# Run pio, tee to log. PIPESTATUS[0] is pio's real exit (NOT tee's).
|
||||
if $QUIET; then
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" >"$LOG" 2>&1
|
||||
else
|
||||
"$PIO" test -e "$ENV" "${PASSTHRU[@]}" 2>&1 | tee "$LOG"
|
||||
fi
|
||||
PIO_RC=${PIPESTATUS[0]}
|
||||
|
||||
# Stop the heartbeat, clear its line, and cache this build's object total for next time.
|
||||
if [[ -n $PROGRESS_PID ]]; then
|
||||
kill "$PROGRESS_PID" 2>/dev/null
|
||||
wait "$PROGRESS_PID" 2>/dev/null
|
||||
PROGRESS_PID=""
|
||||
# Clear the live line only if we were writing one — opening /dev/tty when there is none is
|
||||
# itself a redirect-open error the trailing 2>/dev/null cannot suppress.
|
||||
[[ $TOTTY == 1 ]] && printf '\r\033[K' >/dev/tty 2>/dev/null
|
||||
fi
|
||||
[[ -d ".pio/build/${ENV}" ]] && find ".pio/build/${ENV}" -name '*.o' 2>/dev/null | wc -l >"$BASELINE_FILE" 2>/dev/null || true
|
||||
|
||||
# --- Outcome detection -------------------------------------------------------
|
||||
# The SAME outcome is spelled differently depending on which layer emitted the line — this is
|
||||
# the trap that produces false greens (grepping ":PASS" misses pio's "[PASSED]", grepping
|
||||
# "[FAILED]" misses Unity's ":FAIL:"). So every regex below matches BOTH spellings:
|
||||
# pass: Unity per-assertion ":PASS" | pio per-suite "[PASSED]" | summary "N succeeded"
|
||||
# fail: Unity per-assertion ":FAIL:" | pio per-suite "[FAILED]" | summary "M failed"
|
||||
# error: pio build/crash "[ERRORED]" | Unity "M Failures" | compiler "error:"
|
||||
# Match \b after :PASS/:FAIL so ":PASSED"/":FAILED" forms are also caught either way.
|
||||
FAIL_RE=':FAIL\b|\[FAILED\]|\[ERRORED\]|[1-9][0-9]* failed|[0-9]+ Tests [1-9][0-9]* Failures|error:|undefined reference|Segmentation fault|terminate called|SIGHUP|SIGSEGV|SIGABRT'
|
||||
# Positive proof tests actually ran & passed (absence != success). Accept any pass spelling:
|
||||
# the per-test/per-suite tokens OR a success summary line.
|
||||
PASS_RE=':PASS\b|\[PASSED\]|test cases: *[0-9]+ succeeded|[0-9]+ Tests 0 Failures'
|
||||
# Sanitizer (ASan/LSan/UBSan/TSan) fault signatures. The coverage build is sanitizer-instrumented
|
||||
# and aborts NON-ZERO at exit on a fault — most often a LeakSanitizer leak — AFTER every test has
|
||||
# already printed [PASSED]. pio then reports [ERRORED]/SIGHUP with no :FAIL: anywhere, so it
|
||||
# masquerades as a phantom "N-1 of N succeeded". See .notes/test-passfail-filter.md.
|
||||
# Match only real FAULT lines, never the benign "AddressSanitizer: failed to intercept '...'"
|
||||
# startup noise that prints on every sanitizer run (it'd mislabel a normal [FAILED] as a leak).
|
||||
# Formats per LLVM/Google sanitizer docs: ASan/LSan emit "==PID==ERROR: <San>: ...", UBSan emits
|
||||
# "file:line:col: runtime error: ...", TSan emits "WARNING: ThreadSanitizer: ..."; all close with
|
||||
# a "SUMMARY: <San>: ..." line (LSan-under-ASan reports its SUMMARY as "AddressSanitizer").
|
||||
SAN_RE='(ERROR|WARNING): (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|SUMMARY: (Address|Leak|Thread|UndefinedBehavior)Sanitizer:|Direct leak of|Indirect leak of|detected memory leaks|heap-use-after-free|heap-buffer-overflow|stack-buffer-overflow|attempting double-free|LeakSanitizer has encountered a fatal error|runtime error:'
|
||||
|
||||
# Suites that produced a per-suite verdict. pio emits "coverage:test_x [PASSED|FAILED|ERRORED]";
|
||||
# a SKIPPED suite (hardware-only on native) is "accounted for" too, so it doesn't read as missing.
|
||||
mapfile -t RAN_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+ \[(PASSED|FAILED|ERRORED)\]" "$LOG" |
|
||||
sed -E "s/^${ENV}:(test_[a-z0-9_]+) .*/\1/" | sort -u)
|
||||
RAN_COUNT=${#RAN_SUITES[@]}
|
||||
# Suites pio explicitly skipped (don't count these as "missing" in the canonical cross-check).
|
||||
mapfile -t SKIPPED_SUITES < <(grep -oE "${ENV}:test_[a-z0-9_]+.*\bSKIPPED\b" "$LOG" |
|
||||
grep -oE "test_[a-z0-9_]+" | sort -u)
|
||||
|
||||
verdict_red() {
|
||||
local detail bin
|
||||
detail="$(grep -nE '\[FAILED\]|:FAIL:|\[ERRORED\]' "$LOG" | head -3 | sed 's/^/ /')"
|
||||
echo ""
|
||||
echo "RED — failures detected:"
|
||||
[[ -n $detail ]] && echo "$detail"
|
||||
grep -E 'test cases:' "$LOG" | tail -1 | sed 's/^/ /'
|
||||
|
||||
# Path to the test binary for the "run it bare" hint. For native/coverage the test program is
|
||||
# the env executable (e.g. .pio/build/coverage/meshtasticd), NOT a file named 'program'.
|
||||
bin="$(find ".pio/build/${ENV}" -maxdepth 1 -type f -executable ! -name '*.so' 2>/dev/null | head -1)"
|
||||
[[ -z $bin ]] && bin=".pio/build/${ENV}/<program> (build it first: $PIO test -e ${ENV} ${FILTER:+-f $FILTER} --without-testing)"
|
||||
|
||||
# Sanitizer fault (ASan/LSan/UBSan/TSan): name the real cause instead of "build/crash error".
|
||||
if grep -qE "$SAN_RE" "$LOG"; then
|
||||
grep -nE "$SAN_RE" "$LOG" | head -4 | sed 's/^/ /'
|
||||
echo " -> sanitizer fault: if every test above is PASS, this is an exit-time abort, not a failed assertion."
|
||||
echo " -> read the full report by running the binary BARE (gdb hides it via ptrace): ./$bin 2>&1 | tail -40"
|
||||
echo "RESULT: RED sanitizer fault — $(grep -ohE 'SUMMARY: [A-Za-z]+Sanitizer:.*' "$LOG" | tail -1 || echo 'see report above')"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# All tests passed but the process still aborted at EXIT (ERRORED/SIGHUP/SIGABRT) and the
|
||||
# sanitizer report was swallowed by the runner (often surfaced only as SIGHUP). Almost always a
|
||||
# sanitizer fault — point at how to surface it rather than calling it a generic crash.
|
||||
if grep -qE "$PASS_RE" "$LOG" && grep -qE '\[ERRORED\]|SIGHUP|SIGABRT' "$LOG" && ! grep -qE ':FAIL\b|\[FAILED\]' "$LOG"; then
|
||||
echo " -> all tests passed but the process aborted at EXIT — likely an ASan/LSan fault whose report"
|
||||
echo " the runner swallowed (commonly shown as SIGHUP). Run the binary BARE to see it: ./$bin 2>&1 | tail -40"
|
||||
echo "RESULT: RED exit-time abort (tests passed; likely sanitizer — see hint above)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "RESULT: RED $(grep -oE '[0-9]+ failed' "$LOG" | tail -1 || echo 'build/crash error')"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# RED: pio non-zero, any failure marker, or no positive summary at all (build died early).
|
||||
if [[ $PIO_RC -ne 0 ]] || grep -qE "$FAIL_RE" "$LOG"; then
|
||||
verdict_red
|
||||
fi
|
||||
if ! grep -qE "$PASS_RE" "$LOG"; then
|
||||
echo ""
|
||||
echo "RESULT: RED no success summary found (build error / no tests ran?) — see log"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# AMBER: everything that ran passed, but (full run only) a canonical suite neither ran NOR was
|
||||
# explicitly skipped — i.e. it silently went missing. SKIPPED suites are accounted for.
|
||||
ACCOUNTED_COUNT=$((RAN_COUNT + ${#SKIPPED_SUITES[@]}))
|
||||
if [[ -z $FILTER && $ACCOUNTED_COUNT -lt $EXPECTED_COUNT ]]; then
|
||||
missing=()
|
||||
for s in "${ALL_SUITES[@]}"; do
|
||||
printf '%s\n' "${RAN_SUITES[@]}" "${SKIPPED_SUITES[@]}" | grep -qx "$s" || missing+=("$s")
|
||||
done
|
||||
echo ""
|
||||
echo "RESULT: AMBER ${RAN_COUNT}/${EXPECTED_COUNT} suites ran (missing: ${missing[*]}) — all that ran passed"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# GREEN.
|
||||
if [[ -n $FILTER ]]; then
|
||||
echo "RESULT: GREEN ${RAN_COUNT} suite(s) passed (filtered: $FILTER)"
|
||||
else
|
||||
echo "RESULT: GREEN ${RAN_COUNT}/${EXPECTED_COUNT} suites passed"
|
||||
fi
|
||||
exit 0
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52840_s140_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52840_XXAA",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x4405"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x239A", "0x0071"]
|
||||
],
|
||||
"usb_product": "HT-n5262",
|
||||
"mcu": "nrf52840",
|
||||
"variant": "heltec_mesh_tower_v2",
|
||||
"variants_dir": "variants",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS140",
|
||||
"sd_name": "s140",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B6"
|
||||
},
|
||||
"bootloader": {
|
||||
"settings_addr": "0xFF000"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52840_xxAA",
|
||||
"onboard_tools": ["jlink"],
|
||||
"svd_path": "nrf52840.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino"],
|
||||
"name": "Heltec MeshTower V2 (Adafruit BSP)",
|
||||
"upload": {
|
||||
"maximum_ram_size": 248832,
|
||||
"maximum_size": 815104,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"],
|
||||
"use_1200bps_touch": true,
|
||||
"require_upload_port": true,
|
||||
"wait_for_upload_port": true
|
||||
},
|
||||
"url": "https://heltec.org",
|
||||
"vendor": "Heltec"
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
{
|
||||
"build": {
|
||||
"arduino": {
|
||||
"ldscript": "nrf52832_s132_v6.ld"
|
||||
},
|
||||
"core": "nRF5",
|
||||
"cpu": "cortex-m4",
|
||||
"extra_flags": "-DNRF52832_XXAA -DNRF52",
|
||||
"f_cpu": "64000000L",
|
||||
"hwids": [
|
||||
["0x239A", "0x8029"],
|
||||
["0x239A", "0x0029"],
|
||||
["0x239A", "0x002A"],
|
||||
["0x239A", "0x802A"]
|
||||
],
|
||||
"usb_product": "Feather nRF52832 Express",
|
||||
"mcu": "nrf52832",
|
||||
"variant": "WisCore_RAK4600_Board",
|
||||
"bsp": {
|
||||
"name": "adafruit"
|
||||
},
|
||||
"softdevice": {
|
||||
"sd_flags": "-DS132",
|
||||
"sd_name": "s132",
|
||||
"sd_version": "6.1.1",
|
||||
"sd_fwid": "0x00B7"
|
||||
},
|
||||
"zephyr": {
|
||||
"variant": "nrf52_adafruit_feather"
|
||||
}
|
||||
},
|
||||
"connectivity": ["bluetooth"],
|
||||
"debug": {
|
||||
"jlink_device": "nRF52832_xxAA",
|
||||
"svd_path": "nrf52.svd",
|
||||
"openocd_target": "nrf52840-mdk-rs"
|
||||
},
|
||||
"frameworks": ["arduino", "zephyr"],
|
||||
"name": "Adafruit Bluefruit nRF52832 Feather",
|
||||
"upload": {
|
||||
"maximum_ram_size": 65536,
|
||||
"maximum_size": 524288,
|
||||
"require_upload_port": true,
|
||||
"speed": 115200,
|
||||
"protocol": "nrfutil",
|
||||
"protocols": ["jlink", "nrfjprog", "nrfutil", "stlink"]
|
||||
},
|
||||
"url": "https://www.adafruit.com/product/3406",
|
||||
"vendor": "Adafruit"
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
# LoRa Region → Preset Compatibility — Client Implementation Spec
|
||||
|
||||
**Status:** Draft for 2.8 · **Audience:** Meshtastic client app developers (Android first,
|
||||
Apple second, then web/python) · **Firmware side:** implemented in `firmware`
|
||||
(`FromRadio.region_presets`, see below).
|
||||
|
||||
> This document lives in the firmware repo while the feature is developed. It is meant to
|
||||
> graduate to `meshtastic/protobufs` (and/or the docs site) alongside the upstream protobuf
|
||||
> PR that reserves `FromRadio` field **19**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Why this exists
|
||||
|
||||
For 2.8 the LoRa regions and modem presets were reworked. **Not every modem preset is legal
|
||||
in every region** — narrow EU SRD bands, the EU 868 "narrow" band, amateur/ham bands, and
|
||||
the 2.4 GHz band each accept only a specific subset of presets. The firmware already
|
||||
enforces this internally (it clamps or rejects illegal combinations), but until now a client
|
||||
had no way to _know_ the rules, so a user could pick an illegal region+preset pair in the UI
|
||||
and only discover the problem after the device silently corrected it.
|
||||
|
||||
This feature has the firmware **declare the legal region→preset combinations** to the client
|
||||
during the `want_config` handshake, so the client UI can constrain the preset picker to the
|
||||
valid set for the currently selected region (and warn about licensed-only bands). It is
|
||||
purely advisory metadata — the firmware remains the source of truth and still
|
||||
validates/clamps on its own.
|
||||
|
||||
---
|
||||
|
||||
## 2. Protocol additions
|
||||
|
||||
Three new messages in `meshtastic/mesh.proto`, plus one new `FromRadio` oneof variant.
|
||||
|
||||
### 2.1 `FromRadio.region_presets` (field 19)
|
||||
|
||||
```proto
|
||||
message FromRadio {
|
||||
uint32 id = 1;
|
||||
oneof payload_variant {
|
||||
// ... fields 2..18 unchanged ...
|
||||
LoRaRegionPresetMap region_presets = 19;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 Messages
|
||||
|
||||
```proto
|
||||
// A distinct set of legal modem presets shared by one or more LoRa regions.
|
||||
message LoRaPresetGroup {
|
||||
repeated Config.LoRaConfig.ModemPreset presets = 1; // legal presets for this group
|
||||
Config.LoRaConfig.ModemPreset default_preset = 2; // always one of `presets`
|
||||
bool licensed_only = 3; // ham/amateur band → warn/gate
|
||||
}
|
||||
|
||||
// Associates a single LoRa region with its preset group (by index).
|
||||
message LoRaRegionPresets {
|
||||
Config.LoRaConfig.RegionCode region = 1;
|
||||
uint32 group_index = 2; // index into LoRaRegionPresetMap.groups
|
||||
}
|
||||
|
||||
// The full map, delivered grouped to fit one FromRadio packet.
|
||||
message LoRaRegionPresetMap {
|
||||
repeated LoRaPresetGroup groups = 1; // each distinct preset list
|
||||
repeated LoRaRegionPresets region_groups = 2; // every known region → a group index
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 Why grouped (and the size envelope clients should respect)
|
||||
|
||||
A `FromRadio` packet is capped at **512 bytes** (`MAX_TO_FROM_RADIO_SIZE`). Most regions
|
||||
share one identical preset list (the "standard" 9-preset list), so the map is delivered
|
||||
**grouped**: `groups` holds each _distinct_ preset list once, and `region_groups` maps every
|
||||
known region to one of those groups by index. This keeps the encoded size additive
|
||||
(`groups` + `region_groups`) rather than multiplicative, well under the cap.
|
||||
|
||||
nanopb (firmware) array bounds — clients do **not** need to enforce these, but they bound
|
||||
what you can receive:
|
||||
|
||||
| field | max_count |
|
||||
| ----------------------------------- | ------------------------------------ |
|
||||
| `LoRaRegionPresetMap.groups` | 8 |
|
||||
| `LoRaRegionPresetMap.region_groups` | 38 (= number of `RegionCode` values) |
|
||||
| `LoRaPresetGroup.presets` | 11 |
|
||||
|
||||
---
|
||||
|
||||
## 3. When it is delivered
|
||||
|
||||
`region_presets` is sent **once** during the `want_config` handshake, as a single
|
||||
`FromRadio` message, in this position:
|
||||
|
||||
```text
|
||||
my_info → (deviceuiConfig) → node_info(self) → metadata → region_presets → channel… → config… → moduleConfig… → node_info(others)… → fileInfo… → config_complete_id → (live packets)
|
||||
```
|
||||
|
||||
i.e. **immediately after `metadata` and before the first `channel`**.
|
||||
|
||||
- It is included for a normal full `want_config` and for the **config-only** nonce.
|
||||
- It is **omitted** for the **nodes-only** nonce (that path skips metadata/config entirely).
|
||||
- A client must **not** assume it always arrives (see §5).
|
||||
|
||||
---
|
||||
|
||||
## 4. Decoding into a usable lookup
|
||||
|
||||
Flatten the grouped wire form into `Map<RegionCode, RegionPresetInfo>`:
|
||||
|
||||
```text
|
||||
struct RegionPresetInfo { Set<ModemPreset> presets; ModemPreset default; bool licensedOnly }
|
||||
|
||||
fun decode(map: LoRaRegionPresetMap): Map<RegionCode, RegionPresetInfo> {
|
||||
result = {}
|
||||
for (rg in map.region_groups) {
|
||||
if (rg.group_index >= map.groups.size) continue // defensive: malformed/forward data
|
||||
g = map.groups[rg.group_index]
|
||||
result[rg.region] = RegionPresetInfo(
|
||||
presets = g.presets.toSet(),
|
||||
default = g.default_preset,
|
||||
licensedOnly = g.licensed_only)
|
||||
}
|
||||
return result
|
||||
}
|
||||
```
|
||||
|
||||
Persist this map alongside the rest of the downloaded config so the LoRa config screen can
|
||||
read it synchronously.
|
||||
|
||||
---
|
||||
|
||||
## 5. Semantics & rules (the load-bearing part)
|
||||
|
||||
These rules are what keep the UX correct across firmware versions. Implement all of them.
|
||||
|
||||
1. **Absent region ⇒ no constraint.** If a `RegionCode` does not appear in `region_groups`,
|
||||
the client has _no_ compatibility info for it and **must not restrict** its preset
|
||||
choices (fall back to allowing the full `ModemPreset` list). This happens for a handful
|
||||
of `RegionCode` enum values that have no firmware band table entry (today: `EU_874`,
|
||||
`EU_917`, `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`).
|
||||
|
||||
2. **Absent message ⇒ no constraint.** Firmware older than 2.8 never sends `region_presets`.
|
||||
New clients **must** tolerate the message being absent entirely and keep their existing
|
||||
(unconstrained) behavior. Do not block the config screen waiting for it.
|
||||
|
||||
3. **`default_preset`** is always a member of that group's `presets`. Use it to pre-select a
|
||||
preset when the user switches to a region whose valid set does not include the currently
|
||||
selected preset (instead of leaving an illegal selection or guessing).
|
||||
|
||||
4. **`licensed_only`** marks ham/amateur bands. Surface a warning or gate (the firmware also
|
||||
requires the operator's `is_licensed` flag for these regions; coordinate the two so the
|
||||
user isn't allowed to pick a licensed band without acknowledging licensing).
|
||||
|
||||
5. **EU region auto-swap caveat.** The firmware treats the EU sibling regions
|
||||
(`EU_868` / `EU_866` / `EU_N_868`) specially: if the user is in one of them and selects a
|
||||
preset that belongs to a sibling's list, the firmware **swaps the region** rather than
|
||||
rejecting the preset. Consequence for clients: **do not assume the region is immutable
|
||||
across a preset change** — after an admin config write, re-read the resulting
|
||||
`LoRaConfig` and reflect the (possibly changed) region back into the UI.
|
||||
|
||||
6. **Use it as a UI guard, not a validator of truth.** The firmware still validates/clamps
|
||||
on its own. The map exists to prevent the user from _selecting_ an illegal combo; it is
|
||||
not a security or correctness boundary.
|
||||
|
||||
---
|
||||
|
||||
## 6. UI/UX recommendations
|
||||
|
||||
- In the LoRa config screen, when a region is selected, **filter/enable the modem-preset
|
||||
picker to that region's `presets`** (when `use_preset`/`use_modem_preset` is on).
|
||||
- If the current preset is not in the newly selected region's set, switch the selection to
|
||||
that region's `default_preset`.
|
||||
- Show a **licensed badge / confirmation** for regions where `licensed_only == true`.
|
||||
- If a region is absent from the map (rule §5.1) or the whole message is absent (§5.2),
|
||||
render the full preset list as before — never show an empty picker.
|
||||
|
||||
---
|
||||
|
||||
## 7. Forward / backward compatibility
|
||||
|
||||
- **Old clients, new firmware:** an unknown `FromRadio` oneof variant (field 19) is ignored
|
||||
by protobuf/nanopb decoders; the relative ordering of the known messages is unchanged, so
|
||||
existing apps are unaffected.
|
||||
- **New clients, old firmware:** message simply never arrives → treat as "no constraints"
|
||||
(§5.2).
|
||||
- **Enum growth:** new `RegionCode`/`ModemPreset` values may appear over time. Decoders
|
||||
should pass through unknown enum values rather than crashing; an unknown region in
|
||||
`region_groups` is harmless (the client just won't have a localized name for it).
|
||||
|
||||
---
|
||||
|
||||
## 8. Platform notes
|
||||
|
||||
> Verified against the `main` branch of each repo. Both have been refactored away from
|
||||
> older layouts; re-pin file paths against a specific commit if you need them durable.
|
||||
|
||||
### 8.1 Android — `meshtastic/Meshtastic-Android` (Kotlin / Compose, KMP)
|
||||
|
||||
- **Protobufs are a published Maven artifact, _not_ a submodule.** Declared in
|
||||
`gradle/libs.versions.toml` (`org.meshtastic:protobufs`, currently `2.7.25`); generated
|
||||
package is **`org.meshtastic.proto`**. **A `region_presets`-aware build requires a new
|
||||
published `org.meshtastic:protobufs` release**, then bumping that one version string.
|
||||
- **The protobufs are Wire-generated**, so the `FromRadio` oneof is **not** a
|
||||
`payloadVariantCase` enum — each arm is a **nullable field**. Handle the new variant in
|
||||
`FromRadioPacketHandlerImpl.handleFromRadio(...)`
|
||||
(`core/data/.../manager/FromRadioPacketHandlerImpl.kt`) by adding a
|
||||
`regionPresets != null -> …` arm to the existing `when { … }`, delegating to a handler
|
||||
(mirror `handleLocalMetadata` / `handleConfigComplete`).
|
||||
- **State holder:** expose the decoded map from `RadioConfigRepository` /
|
||||
`RadioConfigRepositoryImpl` as a `Flow` (mirroring `localConfigFlow`/`channelSetFlow`),
|
||||
consumed by `feature/settings/.../radio/RadioConfigViewModel.kt`.
|
||||
- **UI:** the region & preset dropdowns are `DropDownPreference`s in
|
||||
`feature/settings/.../radio/component/LoRaConfigItemList.kt` (public composable
|
||||
`LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected
|
||||
`RegionInfo`'s entry in the map.
|
||||
|
||||
### 8.2 Apple — `meshtastic/Meshtastic-Apple` (Swift / SwiftUI)
|
||||
|
||||
- **Protobufs are vendored** into a local Swift package `MeshtasticProtobufs`
|
||||
(`MeshtasticProtobufs/Sources/meshtastic/*.pb.swift`), generated from the `protobufs` git
|
||||
submodule via `scripts/gen_protos.sh`. **To get field 19:** advance the `protobufs`
|
||||
submodule, run `scripts/gen_protos.sh`, commit the regenerated `.pb.swift` + submodule
|
||||
pointer. (No published-artifact dependency — Apple can regenerate from any commit.)
|
||||
- **Dispatch:** `AccessoryManager.processFromRadio(_:)`
|
||||
(`Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift`) is a real
|
||||
`switch decodedInfo.payloadVariant { … }` — add a `.regionPresets` case, with the handler
|
||||
in `AccessoryManager+FromRadio.swift` (mirror `handleConfig` / `handleMetadata`).
|
||||
- **Persistence:** config is **SwiftData** (`@Model` entities), upserted via
|
||||
`MeshPackets`/`UpdateSwiftData.swift`. Store the decoded map (e.g. on a settings/connection
|
||||
model) so the LoRa view can read it.
|
||||
- **UI:** `Meshtastic/Views/Settings/Config/LoRaConfig.swift` (`struct LoRaConfig: View`)
|
||||
has the `Picker("Region", …)` (`RegionCodes.userSelectable`) and `Picker("Presets", …)`
|
||||
(`ModemPresets.userSelectable`, gated on `usePreset`). Filter the presets picker by the
|
||||
selected region's entry. Enums live in `Meshtastic/Enums/LoraConfigEnums.swift`.
|
||||
|
||||
### 8.3 Other clients
|
||||
|
||||
- **python (`meshtastic` / Meshtastic-python)** and **web** consume the published protobufs;
|
||||
they will see `region_presets` once their protobuf dependency includes field 19, and can
|
||||
ignore it until then (it decodes as an unknown field).
|
||||
|
||||
---
|
||||
|
||||
## 9. Reference payload (current firmware table)
|
||||
|
||||
For decoder unit tests. With the 2.8 region table, the firmware emits **6 groups**. Group
|
||||
indices are assigned in region-table order (first region to use a profile creates its group),
|
||||
so they are stable as listed here:
|
||||
|
||||
| group_index | default_preset | licensed_only | presets |
|
||||
| ----------------------- | -------------- | ------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 (standard) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE, SHORT_TURBO, LONG_TURBO |
|
||||
| 1 (EU 868) | `LONG_FAST` | false | LONG_FAST, LONG_SLOW, MEDIUM_SLOW, MEDIUM_FAST, SHORT_SLOW, SHORT_FAST, LONG_MODERATE |
|
||||
| 2 (EU 866 SRD / "lite") | `LITE_FAST` | false | LITE_FAST, LITE_SLOW |
|
||||
| 3 (EU 868 narrow) | `NARROW_SLOW` | false | NARROW_FAST, NARROW_SLOW |
|
||||
| 4 (ham 20 kHz) | `TINY_FAST` | **true** | TINY_FAST, TINY_SLOW |
|
||||
| 5 (ham 100 kHz) | `NARROW_SLOW` | **true** | NARROW_FAST, NARROW_SLOW |
|
||||
|
||||
`region_groups` (region → group_index):
|
||||
|
||||
| group | regions |
|
||||
| ----- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 0 | US, EU_433, CN, JP, ANZ, ANZ_433, RU, KR, TW, IN, NZ_865, TH, UA_433, UA_868, MY_433, MY_919, SG_923, PH_433, PH_868, PH_915, KZ_433, KZ_863, NP_865, BR_902, LORA_24 |
|
||||
| 1 | EU_868 |
|
||||
| 2 | EU_866 |
|
||||
| 3 | EU_N_868 |
|
||||
| 4 | ITU1_2M, ITU2_2M, ITU3_2M |
|
||||
| 5 | ITU2_125CM |
|
||||
|
||||
> Note groups **3** and **5** carry the same preset list (NARROW\_\*) but are distinct groups
|
||||
> because they differ in `licensed_only`. Decoders must key on the group, not on the preset
|
||||
> list, to preserve the licensing flag.
|
||||
>
|
||||
> Regions **absent** from the table (no constraint info; see §5.1): `EU_874`, `EU_917`,
|
||||
> `ITU1_70CM`, `ITU2_70CM`, `ITU3_70CM`.
|
||||
|
||||
This table is generated from the firmware's region table at runtime; treat the firmware as
|
||||
authoritative and these values as the expected snapshot for the 2.8 table.
|
||||
@@ -0,0 +1,456 @@
|
||||
# NextHop direct-message reliability on dense meshes — findings & plan
|
||||
|
||||
**Status:** Implemented — mitigations and tests in `PR3-tmm-nexthop`
|
||||
**Date:** 2026-06-13
|
||||
**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`)
|
||||
**Constraint:** No over-the-air / wire-format changes — `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only.
|
||||
|
||||
This document captures the analysis and the proposed mitigations so the work can be
|
||||
continued on this branch by anyone. It is intentionally code-grounded (file:line
|
||||
references throughout) and standalone — you should not need the original investigation
|
||||
context to pick it up.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR
|
||||
|
||||
NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline
|
||||
cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte
|
||||
(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that
|
||||
two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50–100. But
|
||||
there are **other, equally important issues**: that single byte is trusted blindly at
|
||||
five different code sites, learned routes **never decay**, routes are learned from the
|
||||
**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious
|
||||
rebroadcasts **amplify congestion** exactly when the mesh is busy.
|
||||
|
||||
Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't
|
||||
trust a byte that doesn't map to a unique reachable neighbor — flood instead") plus
|
||||
**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four
|
||||
mitigations, M1–M4, all RAM-only. The net behavioral change: on dense/mobile meshes a
|
||||
DM that today silently misroutes or black-holes instead falls back to managed flooding
|
||||
(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are
|
||||
unchanged.
|
||||
|
||||
---
|
||||
|
||||
## How NextHop routing works today (mechanics)
|
||||
|
||||
Inheritance chain: `Router` → `FloodingRouter` → `NextHopRouter` → `ReliableRouter`.
|
||||
|
||||
**The single-byte identifiers.** Both routing bytes come from one helper:
|
||||
|
||||
```cpp
|
||||
// src/mesh/NodeDB.h:255
|
||||
uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); }
|
||||
```
|
||||
|
||||
It projects a 32-bit node number onto 255 values (`0x00` is remapped to `0xFF` so it
|
||||
never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELAY_NODE`,
|
||||
`src/mesh/MeshTypes.h:44-46`). `next_hop` and `relay_node` in the packet header are
|
||||
`uint8_t` (`src/mesh/mesh.pb.h`, comments "Last byte of the node number…"). The learned
|
||||
route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte
|
||||
(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`).
|
||||
|
||||
**Sending a DM** — `NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`):
|
||||
|
||||
1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer).
|
||||
2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`):
|
||||
look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer
|
||||
byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood).
|
||||
|
||||
**Relaying** — `NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`):
|
||||
rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or**
|
||||
`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop)
|
||||
(`:147`). Each node only ever compares against **its own** byte.
|
||||
|
||||
**Learning** — `NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an
|
||||
ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of
|
||||
the original packet (validated via `PacketHistory::checkRelayers`), set
|
||||
`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned
|
||||
from the **reverse** path's relayer.
|
||||
|
||||
**Retransmission / fallback** — `NextHopRouter::doRetransmissions`
|
||||
(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial
|
||||
|
||||
- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry
|
||||
(`numRetransmissions==1`) it resets `next_hop` to `NO_NEXT_HOP_PREFERENCE` on the packet
|
||||
**and** clears `sentTo->next_hop` in NodeDB, then floods (`:313-321`). Retransmit timing
|
||||
comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel
|
||||
utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`).
|
||||
|
||||
**Dedup / relayer history** — `PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded
|
||||
ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by
|
||||
`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in
|
||||
`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against
|
||||
that array.
|
||||
|
||||
---
|
||||
|
||||
## Root-cause analysis
|
||||
|
||||
### 1. The single byte is trusted blindly at five sites (the birthday problem)
|
||||
|
||||
| # | Site | File:line | Failure on collision |
|
||||
| --- | -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. |
|
||||
| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. |
|
||||
| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. |
|
||||
| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins — non-deterministic; can preserve hops for the wrong relay (hop leak). |
|
||||
| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. |
|
||||
|
||||
Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes,
|
||||
|
||||
> 99% by ~75 nodes. Dense meshes are squarely in the "always colliding" regime.
|
||||
|
||||
### 2. Stale routes never decay
|
||||
|
||||
The learned `next_hop` byte is cleared only on the **current DM's** last retry
|
||||
(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is
|
||||
still trusted on the **next** DM's first attempt — which on a congested mesh is also the
|
||||
slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget
|
||||
drains, then a late flood. Intermediate nodes hold stale routes indefinitely.
|
||||
|
||||
### 3. Reverse-path (asymmetric-link) learning
|
||||
|
||||
`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) — the
|
||||
**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can
|
||||
be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad
|
||||
hop, so the route **flaps** back to the bad value even after a failure reset.
|
||||
|
||||
### 4. Congestion amplification
|
||||
|
||||
Collision-driven impostor rebroadcasts (issue 1) add airtime; the contention window
|
||||
grows with channel utilization, so retransmit intervals **lengthen** exactly when the
|
||||
mesh is busy. The 3-try reliable budget can then expire before delivery. On dense
|
||||
meshes, efficiency _is_ reliability.
|
||||
|
||||
### Note: pubkey-derived node numbers (develop / 2.8) — does not change the plan
|
||||
|
||||
develop derives the node number from the public key:
|
||||
`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key
|
||||
change in `createNewIdentity()` (`src/mesh/NodeDB.cpp:3113`). This **reinforces** the
|
||||
plan rather than changing it:
|
||||
|
||||
- **Birthday problem unchanged and now textbook-exact.** CRC32 mixes well → the last
|
||||
byte is uniformly distributed over 256 values. Derivation adds no wire bits.
|
||||
- **Node numbers are now immutable / identity-bound.** Pre-2.8 `pickNewNodeNum()` could
|
||||
renumber a node to dodge a conflict; now the number is fixed by the key, so a last-byte
|
||||
collision **cannot be resolved operationally by renumbering** → M1/M2/M3 become _more_
|
||||
necessary.
|
||||
- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one
|
||||
identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking
|
||||
it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's
|
||||
candidate gate already skips — so key rotation can't pollute resolution.
|
||||
- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot
|
||||
recover which full node number a colliding value meant — so "detect ambiguity → flood"
|
||||
remains the correct strategy.
|
||||
|
||||
---
|
||||
|
||||
## Proposed mitigations
|
||||
|
||||
Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's
|
||||
direct neighbors / plausible relays, not the whole mesh.** That candidate set is small
|
||||
(typically 5–15), so a byte usually resolves unambiguously there; when it doesn't, fall
|
||||
back to the _safe_ behavior (flood / decrement / don't-learn).
|
||||
|
||||
### M1 — Ambiguity-aware last-byte resolution (new NodeDB primitive)
|
||||
|
||||
New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp`
|
||||
(near `getMeshNode`, ~2936):
|
||||
|
||||
```cpp
|
||||
enum class LastByteResolution : uint8_t { None, Unique, Ambiguous };
|
||||
struct ResolvedNode { LastByteResolution status = LastByteResolution::None; NodeNum num = 0; };
|
||||
|
||||
// Resolve a single on-wire last-byte to a unique full NodeNum among relevant candidates.
|
||||
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
|
||||
// Convenience: true iff exactly one relevant candidate (Ambiguous and None both -> false = SAFE).
|
||||
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
|
||||
```
|
||||
|
||||
- **One linear pass** over `meshNodes`, reusing `getNumMeshNodes()`/`getMeshNodeByIndex()`,
|
||||
the bitfield helpers (`nodeInfoLiteIsFavorite/HasUser/IsIgnored`), `sinceLastSeen()`,
|
||||
and `getLastByteOfNodeNum()`. **Early-exit** on the 2nd match (return `Ambiguous`).
|
||||
- **Guard:** `if (lastByte == 0) return {None, 0};` (covers `NO_RELAY_NODE` / MQTT-invalid).
|
||||
- **Candidate gate** (skip): `num == getNodeNum()` (never resolve to ourselves), `num == 0`,
|
||||
`num == NODENUM_BROADCAST`, `nodeInfoLiteIsIgnored`. Then match
|
||||
`getLastByteOfNodeNum(node->num) == lastByte` (cheapest test last, mirroring `Router.cpp:119`).
|
||||
- **Relevance gate:**
|
||||
- `requireDirectNeighbor == true` (strict, for SEND): `has_hops_away && hops_away == 0`
|
||||
**and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`.
|
||||
- `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct
|
||||
neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}.
|
||||
- **No tie-break.** A collision must return `Ambiguous` — picking "best SNR" would
|
||||
resurrect the silent-misroute bug. (Deliberate non-goal; document in code.)
|
||||
|
||||
New constant in `src/mesh/MeshTypes.h` (near line 44):
|
||||
`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`).
|
||||
|
||||
### M2 — Only route on bytes that resolve to a unique, reachable neighbor
|
||||
|
||||
In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon
|
||||
check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique,
|
||||
currently-fresh direct neighbor**; else flood:
|
||||
|
||||
```cpp
|
||||
if (node->next_hop != relay_node) {
|
||||
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
|
||||
if (r.status == LastByteResolution::Unique) return node->next_hop;
|
||||
LOG_WARN("Next hop 0x%x for 0x%x %s -> flood", node->next_hop, to,
|
||||
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "no longer a neighbor");
|
||||
return std::nullopt;
|
||||
}
|
||||
```
|
||||
|
||||
This self-heals when a neighbor goes away (unicast-into-a-void becomes a flood). It
|
||||
applies to originating, relaying, and retrying, since all route through `getNextHop`.
|
||||
|
||||
Apply M1's safe fallback at the other sites:
|
||||
|
||||
- **Learning** (`NextHopRouter.cpp:111-114`): gate `origTx->next_hop = p->relay_node` on
|
||||
`resolveUniqueLastByte(p->relay_node, /*direct=*/false)`. Ambiguous/unknown → don't
|
||||
learn (leave route unset → flood).
|
||||
- **Favorite-router preservation** (`Router.cpp:120-145`): replace the "first match wins"
|
||||
loop with `resolveUniqueLastByte(p->relay_node, /*direct=*/false)` + a re-check that the
|
||||
resolved node is favorite/has_user/router. Ambiguous/none/not-favorite → **decrement**
|
||||
(safe). Net: removes one full DB scan, adds one resolver scan (wash).
|
||||
|
||||
**Left unchanged, by design (document why in code):**
|
||||
|
||||
- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks
|
||||
(`ReliableRouter.cpp:127`): a node matches its **own** byte — no DB resolution helps. A
|
||||
remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2
|
||||
shrink the blast radius by reducing how often an ambiguous byte is ever stored or
|
||||
originated; a true fix needs a wider field (out of scope). **This is the one residual
|
||||
the plan cannot fully close.**
|
||||
- **Site 3** `wasRelayer`/`checkRelayers` (`PacketHistory.cpp:490-538`): intentionally
|
||||
byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened.
|
||||
Add a one-line comment; do not change.
|
||||
|
||||
### M3 — Route freshness / failure memory (RAM table on NextHopRouter)
|
||||
|
||||
A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s
|
||||
reuse-oldest discipline (not an unbounded map) to cap RAM.
|
||||
|
||||
`src/mesh/NextHopRouter.h` (near `pending`, line 99):
|
||||
|
||||
```cpp
|
||||
struct RouteHealth {
|
||||
NodeNum dest = 0; // 0 == empty slot
|
||||
uint32_t learnedAtMsec = 0; // millis() at last (re)learn; rollover-aware
|
||||
uint8_t consecutiveFailures = 0;
|
||||
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; // byte this health refers to
|
||||
};
|
||||
static constexpr uint8_t ROUTE_HEALTH_MAX = 32; // ~384B; drop to 16 if RAM-tight
|
||||
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
|
||||
// Helpers take `now` (pure/testable): findRouteHealth, getOrAllocRouteHealth,
|
||||
// noteRouteLearned, noteRouteSuccess, noteRouteFailure, isRouteStale, clearRouteHealth
|
||||
```
|
||||
|
||||
Policy:
|
||||
|
||||
| Constant | Value | Rationale |
|
||||
| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. |
|
||||
| `ROUTE_FAILURE_THRESHOLD` | 3 | 1–2 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). |
|
||||
|
||||
`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`.
|
||||
All age math uses **unsigned subtraction** (rollover-safe, matching
|
||||
`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now".
|
||||
|
||||
Wiring (as built — `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`):
|
||||
|
||||
- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear
|
||||
`node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No
|
||||
record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor
|
||||
gate still applies.
|
||||
- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then
|
||||
`noteRouteLearned(p->from, p->relay_node, millis())` — resets `consecutiveFailures`
|
||||
**only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes
|
||||
`learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK
|
||||
merely passing through us is not proof that _we_ delivered, and resetting failures there
|
||||
would reintroduce the asymmetric flap.)
|
||||
- `doRetransmissions`: on the last-retransmission branch (`numRetransmissions == 1`, the
|
||||
point a directed delivery has gone un-ACKed for both originator and intermediate) →
|
||||
`noteRouteFailure(to)`, then the existing NodeDB `next_hop` reset + flood. We deliberately
|
||||
do **not** `clearRouteHealth` here: keeping the record is what lets the failure count
|
||||
accumulate across DMs so a flapping reverse-path-relearned dead hop eventually ages out.
|
||||
- `ReliableRouter::sniffReceived` ACK path → `noteRouteSuccess(getFrom(p), millis())`
|
||||
(an end-to-end ACK addressed to us is genuine forward-delivery proof; clears failures and
|
||||
refreshes freshness). `noteRouteSuccess`/`noteRouteFailure` are no-ops when no record
|
||||
exists, so flood-only destinations never pollute the table.
|
||||
|
||||
**Reconciliation (no double-handling):** `doRetransmissions` owns _in-flight_ failure of
|
||||
the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure counter);
|
||||
`getNextHop` owns _between-DM_ staleness (TTL or failure-threshold → flood + clear). The
|
||||
only place that erases a health record is the `getNextHop` decay path; the retransmission
|
||||
path leaves it intact so the counter survives a reverse-path re-learn.
|
||||
|
||||
### M4 — Earlier flood for unverified routes (gated, off by default)
|
||||
|
||||
Compile-gated so healthy sparse meshes are untouched. **Default is off** — the define
|
||||
lives in `NextHopRouter.h` and must be flipped to measure:
|
||||
`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`.
|
||||
|
||||
In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified**
|
||||
(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and
|
||||
flood on this attempt instead of spending another directed try. A **verified** route
|
||||
(record present, `consecutiveFailures == 0`, within TTL — i.e. recently ACKed) takes the
|
||||
unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off:
|
||||
airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on
|
||||
one we already distrust. Off by default precisely so it can be A/B-measured on the
|
||||
simulator before broad enable.
|
||||
|
||||
---
|
||||
|
||||
## Files to modify
|
||||
|
||||
| File | Change |
|
||||
| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `src/mesh/MeshTypes.h` | `NEXTHOP_NEIGHBOR_FRESH_SECS`, `ROUTE_TTL_MSEC`, `ROUTE_FAILURE_THRESHOLD`, `NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED` |
|
||||
| `src/mesh/NodeDB.h` / `src/mesh/NodeDB.cpp` | `LastByteResolution`, `ResolvedNode`, `resolveLastByte`, `resolveUniqueLastByte` |
|
||||
| `src/mesh/NextHopRouter.h` | `RouteHealth` + array + helpers; `#ifdef PIO_UNIT_TESTING public:` for helpers and `getNextHop` |
|
||||
| `src/mesh/NextHopRouter.cpp` | `getNextHop` (M2 gate + M3 decay); `sniffReceived` (learn gate + health seed + success); `doRetransmissions` (failure counting + M4); comment site 1 |
|
||||
| `src/mesh/Router.cpp` | `shouldDecrementHopLimit` → resolver + favorite/router re-check |
|
||||
| `src/mesh/ReliableRouter.cpp` | ACK path → `noteRouteSuccess` |
|
||||
| `test/test_nexthop_routing/test_main.cpp` | **new** unit suite (auto-built under `[env:native]`) |
|
||||
|
||||
**Reuse, don't reinvent:** `getLastByteOfNodeNum`, `sinceLastSeen`, the bitfield helpers,
|
||||
`getMeshNodeByIndex`/`getNumMeshNodes`, PacketHistory's reuse-oldest eviction shape, and
|
||||
`MockNodeDB::addTestNode` (from `test/test_hop_scaling/test_main.cpp`).
|
||||
|
||||
---
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **`0x00`↔`0xFF` projection:** the resolver compares via `getLastByteOfNodeNum` on both
|
||||
sides, so a `…00` node and a `…FF` node correctly collide on `0xFF` → `Ambiguous`. Test
|
||||
explicitly.
|
||||
- **MQTT packets:** `relay_node`/`next_hop` are forced invalid when `hop_start == 0`
|
||||
(`src/mesh/RadioLibInterface.cpp:603-605`) → byte 0 → resolver `None` → don't learn
|
||||
(correct).
|
||||
- **`has_hops_away == false`** nodes are excluded from the strict gate (never fabricate a
|
||||
Unique neighbor for M2); admitted to the lenient gate only via favorite/router role.
|
||||
Safe; self-corrects once `hops_away` is learned.
|
||||
- **Self / broadcast:** the resolver skips `getNodeNum()` and `NODENUM_BROADCAST`;
|
||||
`getNextHop` already early-returns for broadcast.
|
||||
- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd
|
||||
match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a
|
||||
future 256-entry last-byte index is the optimization (not now — RAM).
|
||||
|
||||
---
|
||||
|
||||
## Verification (all tiers)
|
||||
|
||||
### 1. Native unit tests — new `test/test_nexthop_routing/test_main.cpp`
|
||||
|
||||
`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`.
|
||||
Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is
|
||||
testable without a clock mock.
|
||||
|
||||
- **Resolver:** None / Unique / **Ambiguous (birthday collision)** / strict-excludes-stale /
|
||||
strict-excludes-far / lenient-includes-favorite-router / lenient-collision / skips-self /
|
||||
skips-ignored / **`0x00`↔`0xFF` collision** / early-exit.
|
||||
- **`getNextHop`:** unique→byte, **ambiguous→nullopt**, stale-neighbor→nullopt,
|
||||
split-horizon (relay==next_hop)→nullopt, broadcast→nullopt.
|
||||
- **RouteHealth:** TTL boundary, **rollover** (learn near `0xFFFFFFFF`, check after wrap),
|
||||
failure threshold, success-resets, **re-learn-same-hop keeps fails (anti-flap)**,
|
||||
re-learn-new-hop resets, LRU eviction bound, clear.
|
||||
- **Site-4:** preserve on unique favorite router; **decrement on two colliding favorites**;
|
||||
decrement when the resolved node is not a favorite.
|
||||
- **Sparse-mesh regression:** all-distinct last bytes → every resolve Unique, `getNextHop`
|
||||
returns the stored byte unchanged (proves no happy-path change).
|
||||
- Re-run `test_packet_history` and `test_hop_scaling` for no regression.
|
||||
|
||||
### 2. portduino SimRadio simulator
|
||||
|
||||
`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node**
|
||||
path the 2-device bench can't reach. Line topology A — B — C: establish A→C (B learns a
|
||||
directed route), stop B relaying that dest, confirm A re-discovers via flood within
|
||||
`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible
|
||||
via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4
|
||||
(attempts-to-delivery, total airtime).
|
||||
|
||||
### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop)
|
||||
|
||||
- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` — **the multi-hop validator
|
||||
for this work** (added on this branch). Self-discovers an A — relay — C line, asserts a
|
||||
directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts
|
||||
delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true
|
||||
multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range).
|
||||
- `mcp-server/tests/mesh/test_direct_with_ack.py` — happy-path regression: a fresh/unique
|
||||
route still delivers a want_ack DM on the first/second try (M4's gate must keep this
|
||||
green).
|
||||
- `mcp-server/tests/mesh/test_peer_offline_recovery.py` — 2-device recovery validator: peer
|
||||
off mid-conversation then back. Must stay green and ideally recover in fewer attempts.
|
||||
|
||||
### 4. Build / format sanity
|
||||
|
||||
native-macos **and** Docker both ways; trunk clang-format@16.0.3; a release `pio run` to
|
||||
confirm the `#ifdef PIO_UNIT_TESTING` visibility widening does **not** leak into
|
||||
production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B table.
|
||||
|
||||
---
|
||||
|
||||
## Verification status (as built on `nexthop-redux`)
|
||||
|
||||
| Tier | What ran | Result |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------- | ------------------- |
|
||||
| Unit (native-macos) | `test_nexthop_routing` (31 cases) | ✅ 31/31 |
|
||||
| Unit (Docker / Linux, CI parity) | `test_nexthop_routing` | ✅ 31/31 |
|
||||
| Regression | `test_packet_history`, `test_hop_scaling`, `test_mqtt`, `test_traffic_management` | ✅ 105/105 |
|
||||
| Build | `pio run -e native-macos` (M4 off) and with `-DNEXTHOP_EARLY_FLOOD_ON_UNVERIFIED=1` | ✅ both link |
|
||||
| Format | trunk `clang-format@16.0.3` | ✅ no issues |
|
||||
| Simulator (CI `simulator-tests`) | `meshtasticd -s` + `meshtastic.test.testSimulator()` on native-macos | ✅ exit 0, no crash |
|
||||
|
||||
**Pending (environment-blocked, not yet run):**
|
||||
|
||||
- **Multi-hop A–B–C recovery sim** — the `simulator/` broker hub is **not git-tracked**
|
||||
(only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other
|
||||
without it. The intermediate-node failure-count path and the M4 A/B therefore have unit
|
||||
coverage of their logic but no end-to-end multi-node run yet.
|
||||
- **Hardware / multi-hop tier** — a committable bench test now exists:
|
||||
`mcp-server/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real
|
||||
multi-hop pair (A — relay — C), asserts a directed DM is delivered across the relay, and
|
||||
asserts delivery recovers after the relay is power-cycled (the M3 path). It
|
||||
`pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF
|
||||
range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the
|
||||
NextHop path is genuinely exercised. Collected + verified to skip without hardware;
|
||||
not yet run on a bench. `test_direct_with_ack.py` / `test_peer_offline_recovery.py`
|
||||
remain the 2-device happy-path/recovery regressions.
|
||||
|
||||
---
|
||||
|
||||
## Risks & limitations
|
||||
|
||||
- **Site-1 impostor rebroadcast** is unfixable without a wider field — documented; M1/M2
|
||||
only shrink its frequency.
|
||||
- **Dense meshes flood DMs more often** — intended (a flooded DM arrives; a mis-unicast one
|
||||
black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on
|
||||
very dense meshes.
|
||||
- **M4 airtime** if the gate is too loose → default conservative + compile-gated +
|
||||
simulator A/B before broad enable.
|
||||
- **RAM** ~384 B (32 slots); 16 slots (~192 B) with graceful LRU degradation if tight.
|
||||
- **Asymmetric flap** not fully closed (a _new_ bad hop resets the counter); the TTL
|
||||
backstop bounds it. Per-hop failure history is future work (more RAM).
|
||||
|
||||
---
|
||||
|
||||
## How to continue this work (commit sequencing)
|
||||
|
||||
Each step is independently testable; land them as separate commits.
|
||||
|
||||
1. **M1 resolver + unit tests** — `NodeDB` only; no behavior change until wired. Lands the
|
||||
`resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix.
|
||||
2. **M2 + wiring + tests** — `getNextHop` strict gate, learning gate, favorite-router
|
||||
preservation rewrite. Adds the `getNextHop` and site-4 tests.
|
||||
3. **M3 health table + decay + tests** — RAM `RouteHealth` table, decay-on-read, failure/
|
||||
success accounting, reconciliation with the existing last-retry reset. Adds the
|
||||
route-health unit tests and the simulator recovery check.
|
||||
4. **M4 gated tuning** — early-flood-on-unverified behind the compile flag; simulator A/B
|
||||
and hardware regression.
|
||||
|
||||
Reference plan (with the same content) was developed at
|
||||
`~/.claude/plans/nexthop-routing-for-direct-lexical-shell.md` on the author's machine; this
|
||||
in-repo doc is the canonical handoff copy.
|
||||
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
# trunk-ignore-all(ruff/F821)
|
||||
# trunk-ignore-all(flake8/F821): For SConstruct imports
|
||||
#
|
||||
# Post-link guard for the warm-node-store raw-flash region on nRF52840.
|
||||
#
|
||||
# The 3 app-region pages below LittleFS (0xEA000-0xED000, reclaimed by whole-image
|
||||
# LTO) are reserved for the WarmNodeStore record-ring (see WarmNodeStore.h). Our
|
||||
# linker scripts (nrf52840_s140_v6.ld and nrf52840_s140_v7.ld) cap the image at
|
||||
# 0xEA000, but boards on the framework-default script (FLASH ending at 0xED000) could
|
||||
# silently place code in those pages — the first warm-store save would then brick the
|
||||
# device. This turns that into a build failure.
|
||||
#
|
||||
# Image flash end = __etext + sizeof(.data) (loaded at LMA __etext); symbols from
|
||||
# the framework's nrf52_common.ld.
|
||||
import os
|
||||
|
||||
Import("env")
|
||||
|
||||
WARM_REGION_BASE = 0xEA000 # keep in sync with WARM_FLASH_REGION_BASE in WarmNodeStore.h (3 x 4 KB record-ring)
|
||||
|
||||
_tc = env.PioPlatform().get_package_dir("toolchain-gccarmnoneeabi") or ""
|
||||
_NM = os.path.join(_tc, "bin", "arm-none-eabi-nm")
|
||||
if not os.path.isfile(_NM):
|
||||
_NM = "arm-none-eabi-nm" # fall back to PATH
|
||||
|
||||
|
||||
def _assert_warm_region_clear(source, target, env):
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
elf = env.subst("$BUILD_DIR/${PROGNAME}.elf")
|
||||
out = subprocess.check_output([_NM, elf], universal_newlines=True)
|
||||
except Exception as exc: # tooling hiccup: warn loudly, don't wedge the build
|
||||
print("nrf52_warm_region: WARNING - guard skipped (nm failed: %s)" % exc)
|
||||
return
|
||||
|
||||
syms = {}
|
||||
for line in out.split("\n"):
|
||||
f = line.split()
|
||||
if len(f) >= 3 and f[-1] in ("__etext", "__data_start__", "__data_end__"):
|
||||
syms[f[-1]] = int(f[0], 16)
|
||||
if len(syms) != 3:
|
||||
print("nrf52_warm_region: WARNING - guard skipped (linker symbols not found)")
|
||||
return
|
||||
|
||||
flash_end = syms["__etext"] + (syms["__data_end__"] - syms["__data_start__"])
|
||||
if flash_end > WARM_REGION_BASE:
|
||||
sys.stderr.write(
|
||||
"\n*** nrf52 warm-region guard: image ends at 0x%X, past the reserved "
|
||||
"warm-store region at 0x%X ***\n"
|
||||
"The 12 KB region at 0xEA000 holds the WarmNodeStore record-ring; a warm-store\n"
|
||||
"save would overwrite this firmware's tail. Shrink the image, or shrink/move\n"
|
||||
"the region (WARM_FLASH_REGION_BASE in src/mesh/WarmNodeStore.h, the FLASH\n"
|
||||
"LENGTH in src/platform/nrf52/nrf52840_s140_v6.ld and _v7.ld, and this guard).\n\n"
|
||||
% (flash_end, WARM_REGION_BASE)
|
||||
)
|
||||
from SCons.Script import Exit
|
||||
|
||||
Exit(1)
|
||||
print(
|
||||
"nrf52_warm_region: guard OK -- image ends at 0x%X, %d KB clear of the warm region"
|
||||
% (flash_end, (WARM_REGION_BASE - flash_end) // 1024)
|
||||
)
|
||||
|
||||
|
||||
# Attach to the phony "buildprog" alias (not the .elf node) so the guard runs
|
||||
# on incremental relinks too -- same reasoning as nrf52_lto.py's guard.
|
||||
env.AddPostAction("buildprog", _assert_warm_region_clear)
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Multi-hop NextHop directed-message delivery + relay-recovery (bench test).
|
||||
|
||||
This is the hardware/tier-3 validator for the NextHop DM reliability work
|
||||
(see `docs/nexthop-routing-reliability.md`). The unit suite
|
||||
`test/test_nexthop_routing` covers the routing *logic* exhaustively; this test
|
||||
covers the *end-to-end* multi-hop behavior that only a real (or RF-separated)
|
||||
mesh exercises:
|
||||
|
||||
* a directed DM that must traverse a relay is delivered (next_hop routing +
|
||||
the M1/M2 ambiguity gate + M3 route learning all engage), and
|
||||
* when the established relay drops and returns, delivery recovers rather than
|
||||
black-holing (the M3 stale-route decay / re-learn path).
|
||||
|
||||
TOPOLOGY REQUIREMENT — why this usually SKIPS:
|
||||
A NextHop relay only happens when the two endpoints are NOT direct neighbors.
|
||||
Three co-located radios all hear each other, so A→C is a single direct hop and
|
||||
next_hop never engages. To run this test the bench must be a *line* — A — B — C
|
||||
— with the endpoints out of each other's direct RF range (physical distance or
|
||||
attenuators). The `multihop_topology` fixture detects this automatically: it
|
||||
warms the mesh, looks for a pair that is ≥1 hop apart, confirms the relay via
|
||||
traceroute, and `pytest.skip`s cleanly when the bench is all-direct. So this
|
||||
file is safe to commit and run anywhere — it only *asserts* when the topology
|
||||
genuinely requires a relay.
|
||||
|
||||
REQUIREMENTS:
|
||||
* ≥3 baked devices. The default hub profile is 2 roles (nrf52, esp32s3); add a
|
||||
third via `--hub-profile=path/to/hub.yaml` (see conftest `hub_profile`).
|
||||
* The relay-recovery test additionally needs uhubctl + a power-controllable
|
||||
relay port (same gate the other power tests use).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from meshtastic_mcp.connection import connect
|
||||
from tests import _power
|
||||
from tests._port_discovery import resolve_port_by_role
|
||||
|
||||
from ._receive import ReceiveCollector, nudge_nodeinfo, nudge_nodeinfo_port
|
||||
|
||||
|
||||
def _hops_away(rec: dict[str, Any]) -> int | None:
|
||||
"""Read a node's hop distance from a `nodesByNum` entry, tolerating either
|
||||
the camelCase (`hopsAway`) or snake_case (`hops_away`) spelling depending on
|
||||
the meshtastic-python version."""
|
||||
for key in ("hopsAway", "hops_away"):
|
||||
val = rec.get(key)
|
||||
if isinstance(val, int):
|
||||
return val
|
||||
return None
|
||||
|
||||
|
||||
def _warm_mesh(ports: list[str], rounds: int = 2, settle: float = 6.0) -> None:
|
||||
"""Flood a fresh NodeInfo from every node so the whole mesh (including
|
||||
multi-hop pairs, reached via relayed broadcasts) populates pubkeys and hop
|
||||
distances. Best-effort — a single node failing to nudge shouldn't abort."""
|
||||
for _ in range(rounds):
|
||||
for port in ports:
|
||||
try:
|
||||
nudge_nodeinfo_port(port)
|
||||
except Exception: # noqa: BLE001 — warmup is best-effort
|
||||
pass
|
||||
time.sleep(0.5)
|
||||
time.sleep(settle)
|
||||
|
||||
|
||||
def _wait_for_pubkey(
|
||||
tx_iface: Any, rx_num: int, rx_port: str, deadline_s: float = 90.0
|
||||
) -> bool:
|
||||
"""Block until `tx_iface` holds `rx_num`'s public key (directed PKI sends
|
||||
NAK without it). Re-nudges both sides periodically; multi-hop warmup is
|
||||
slower than the 2-device case because NodeInfo must be relayed, hence the
|
||||
longer default deadline."""
|
||||
deadline = time.monotonic() + deadline_s
|
||||
last_nudge = time.monotonic()
|
||||
while time.monotonic() < deadline:
|
||||
rec = (tx_iface.nodesByNum or {}).get(rx_num, {})
|
||||
if rec.get("user", {}).get("publicKey"):
|
||||
return True
|
||||
if time.monotonic() - last_nudge > 20.0:
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
nudge_nodeinfo(tx_iface)
|
||||
last_nudge = time.monotonic()
|
||||
time.sleep(1.0)
|
||||
return False
|
||||
|
||||
|
||||
def _traceroute_route(tx_port: str, rx_num: int, rx_port: str) -> list[int] | None:
|
||||
"""Run a traceroute TX→RX and return the forward `route` (list of relay node
|
||||
numbers), or None if it couldn't be obtained. Mirrors test_traceroute's
|
||||
request/PKI/retry pattern."""
|
||||
from meshtastic.mesh_interface import MeshInterface
|
||||
|
||||
with ReceiveCollector(tx_port, topic="meshtastic.receive.traceroute") as tx:
|
||||
nudge_nodeinfo_port(rx_port)
|
||||
tx.broadcast_nodeinfo_ping()
|
||||
if not _wait_for_pubkey(tx._iface, rx_num, rx_port, 60.0):
|
||||
return None
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
tx._iface.sendTraceRoute(dest=rx_num, hopLimit=5)
|
||||
break
|
||||
except MeshInterface.MeshInterfaceError:
|
||||
time.sleep(5.0)
|
||||
else:
|
||||
return None
|
||||
pkt = tx.wait_for(lambda p: p.get("from") == rx_num, timeout=8.0)
|
||||
if pkt is None:
|
||||
return None
|
||||
tr = (pkt.get("decoded", {}) or {}).get("traceroute") or {}
|
||||
return [int(n) for n in (tr.get("route") or [])]
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def multihop_topology(baked_mesh: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Discover a real multi-hop pier (tx → relay → rx) on the bench, or skip.
|
||||
|
||||
Returns {tx_role, tx_port, rx_role, rx_port, rx_num, relay_role, relay_num}.
|
||||
"""
|
||||
roles = sorted(baked_mesh)
|
||||
if len(roles) < 3:
|
||||
pytest.skip(
|
||||
"multi-hop NextHop test needs ≥3 baked devices arranged as a line "
|
||||
"(endpoints out of direct RF range). Add a third role via "
|
||||
f"--hub-profile. Detected roles: {roles}"
|
||||
)
|
||||
|
||||
by_role = {r: (baked_mesh[r]["port"], baked_mesh[r]["my_node_num"]) for r in roles}
|
||||
if any(num is None for _, num in by_role.values()):
|
||||
pytest.skip("a baked device is missing my_node_num; can't map the topology")
|
||||
|
||||
_warm_mesh([port for port, _ in by_role.values()])
|
||||
|
||||
# Find an ordered pair that is ≥1 hop apart, using each node's own nodeDB
|
||||
# (cheap — no traceroute yet). On an all-direct bench nothing qualifies.
|
||||
multihop_pair: tuple[str, str] | None = None
|
||||
for a_role in roles:
|
||||
a_port, _ = by_role[a_role]
|
||||
try:
|
||||
with connect(port=a_port) as a_iface:
|
||||
nodes = a_iface.nodesByNum or {}
|
||||
except Exception: # noqa: BLE001
|
||||
continue
|
||||
for c_role in roles:
|
||||
if c_role == a_role:
|
||||
continue
|
||||
_, c_num = by_role[c_role]
|
||||
hops = _hops_away(nodes.get(c_num, {}))
|
||||
if hops is not None and hops >= 1:
|
||||
multihop_pair = (a_role, c_role)
|
||||
break
|
||||
if multihop_pair:
|
||||
break
|
||||
|
||||
if not multihop_pair:
|
||||
pytest.skip(
|
||||
"no multi-hop pair found — every device appears to be a direct "
|
||||
"neighbor. Arrange the bench as a line (A — B — C) with the "
|
||||
"endpoints out of direct RF range (distance or attenuators) so a "
|
||||
"relay is actually required, then re-run."
|
||||
)
|
||||
|
||||
a_role, c_role = multihop_pair
|
||||
a_port, _ = by_role[a_role]
|
||||
c_port, c_num = by_role[c_role]
|
||||
|
||||
route = _traceroute_route(a_port, c_num, c_port)
|
||||
if not route:
|
||||
pytest.skip(
|
||||
f"{a_role}→{c_role} looked multi-hop but traceroute returned no "
|
||||
"intermediate relay; can't identify the relay node to drive the "
|
||||
"recovery test"
|
||||
)
|
||||
|
||||
relay_num = route[0]
|
||||
relay_role = next((r for r in roles if by_role[r][1] == relay_num), None)
|
||||
return {
|
||||
"tx_role": a_role,
|
||||
"tx_port": a_port,
|
||||
"rx_role": c_role,
|
||||
"rx_port": c_port,
|
||||
"rx_num": c_num,
|
||||
"relay_role": relay_role,
|
||||
"relay_num": relay_num,
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.timeout(300)
|
||||
def test_multihop_dm_delivers(multihop_topology: dict[str, Any]) -> None:
|
||||
"""A directed wantAck DM that must traverse the relay is delivered.
|
||||
|
||||
Exercises the NextHop routing path end-to-end: TX picks a next hop toward
|
||||
RX (M2 gate), the relay resolves the next_hop byte and forwards (M1), and
|
||||
the route is learned from the returning ACK (M3). Retries absorb transient
|
||||
LoRa loss; the assertion is on eventual delivery.
|
||||
"""
|
||||
tx_port = multihop_topology["tx_port"]
|
||||
rx_port = multihop_topology["rx_port"]
|
||||
rx_num = multihop_topology["rx_num"]
|
||||
tx_role = multihop_topology["tx_role"]
|
||||
rx_role = multihop_topology["rx_role"]
|
||||
relay_role = multihop_topology["relay_role"]
|
||||
|
||||
unique = f"nexthop-mh-{tx_role}-to-{rx_role}-{int(time.time())}"
|
||||
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
|
||||
pytest.skip(
|
||||
f"{tx_role} never learned {rx_role}'s pubkey over the relay; "
|
||||
"multi-hop PKI warmup didn't complete"
|
||||
)
|
||||
got = None
|
||||
for _attempt in range(3):
|
||||
pkt = tx_iface.sendText(unique, destinationId=rx_num, wantAck=True)
|
||||
assert pkt is not None
|
||||
got = rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == unique,
|
||||
timeout=45,
|
||||
)
|
||||
if got is not None:
|
||||
break
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(5.0)
|
||||
|
||||
assert got is not None, (
|
||||
f"multi-hop directed DM {tx_role}→{rx_role} via relay "
|
||||
f"{relay_role!r} never landed — NextHop multi-hop delivery is broken"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.timeout(600)
|
||||
def test_multihop_relay_recovery(
|
||||
multihop_topology: dict[str, Any],
|
||||
power_cycle, # noqa: ARG001 — forces the uhubctl-availability skip
|
||||
) -> None:
|
||||
"""Delivery recovers after the established relay drops and returns.
|
||||
|
||||
Establishes a baseline DM (route via relay learned), powers the relay OFF
|
||||
(confirming TX survives sending across a downed relay), then powers it back
|
||||
ON and asserts directed delivery resumes — the M3 stale-route decay /
|
||||
re-learn path. With a strict A — B — C line there is no path while B is down,
|
||||
so we only assert TX doesn't crash during the outage; the delivery assertion
|
||||
is after B returns.
|
||||
"""
|
||||
relay_role = multihop_topology["relay_role"]
|
||||
if not relay_role:
|
||||
pytest.skip(
|
||||
"relay node isn't one of the baked hub roles, so it can't be "
|
||||
"power-cycled; recovery test needs a controllable relay"
|
||||
)
|
||||
|
||||
tx_port = multihop_topology["tx_port"]
|
||||
rx_port = multihop_topology["rx_port"]
|
||||
rx_num = multihop_topology["rx_num"]
|
||||
tx_role = multihop_topology["tx_role"]
|
||||
rx_role = multihop_topology["rx_role"]
|
||||
|
||||
base = f"mh-recover-base-{int(time.time())}"
|
||||
post = f"mh-recover-post-{int(time.time())}"
|
||||
|
||||
# Baseline: confirm delivery works (so the route via the relay is learned)
|
||||
# before we perturb anything — otherwise a later failure is ambiguous.
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
if not _wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0):
|
||||
pytest.skip("multi-hop PKI warmup failed; can't run recovery test")
|
||||
tx_iface.sendText(base, destinationId=rx_num, wantAck=True)
|
||||
assert (
|
||||
rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == base, timeout=45
|
||||
)
|
||||
is not None
|
||||
), "baseline multi-hop delivery failed — skipping recovery to avoid a false result"
|
||||
|
||||
# Power the relay OFF.
|
||||
try:
|
||||
_power.power_off(relay_role)
|
||||
_power.wait_for_absence(relay_role, timeout_s=15.0)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
try:
|
||||
_power.power_on(relay_role)
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
pytest.skip(f"can't power-control relay {relay_role!r}: {exc}")
|
||||
|
||||
# With the only relay down there's no path; we just confirm TX accepts the
|
||||
# send and survives its internal retries (it must not crash / wedge).
|
||||
try:
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
pkt = tx_iface.sendText(
|
||||
f"mh-while-down-{int(time.time())}",
|
||||
destinationId=rx_num,
|
||||
wantAck=True,
|
||||
)
|
||||
assert pkt is not None
|
||||
time.sleep(8.0) # let retransmissions + route decay run
|
||||
except Exception as exc: # noqa: BLE001 — restore bench state before failing
|
||||
_power.power_on(relay_role)
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
raise AssertionError(
|
||||
f"TX crashed sending across a downed relay: {exc}"
|
||||
) from exc
|
||||
|
||||
# Power the relay back ON and let it re-enumerate + boot.
|
||||
_power.power_on(relay_role)
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
resolve_port_by_role(relay_role, timeout_s=30.0)
|
||||
except Exception: # noqa: BLE001 — relay port isn't one we connect to directly
|
||||
pass
|
||||
time.sleep(8.0)
|
||||
_warm_mesh([tx_port, rx_port], rounds=1) # re-flood so the relay re-learns
|
||||
|
||||
# Delivery should resume once the relay is back (M3 re-learn / decay path).
|
||||
got = None
|
||||
with ReceiveCollector(rx_port, topic="meshtastic.receive.text") as rx:
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
with connect(port=tx_port) as tx_iface:
|
||||
nudge_nodeinfo(tx_iface)
|
||||
_wait_for_pubkey(tx_iface, rx_num, rx_port, 90.0)
|
||||
for _attempt in range(4):
|
||||
pkt = tx_iface.sendText(post, destinationId=rx_num, wantAck=True)
|
||||
assert pkt is not None
|
||||
got = rx.wait_for(
|
||||
lambda p: p.get("decoded", {}).get("text") == post,
|
||||
timeout=45,
|
||||
)
|
||||
if got is not None:
|
||||
break
|
||||
rx.broadcast_nodeinfo_ping()
|
||||
nudge_nodeinfo(tx_iface)
|
||||
time.sleep(6.0)
|
||||
|
||||
assert got is not None, (
|
||||
f"after relay {relay_role!r} returned, multi-hop DM {tx_role}→{rx_role} "
|
||||
"never resumed — stale-route recovery (M3) may be broken"
|
||||
)
|
||||
@@ -44,7 +44,7 @@ _ESP32_ARCHES = {
|
||||
"esp32-c6",
|
||||
"esp32c6",
|
||||
}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840", "nrf52832"}
|
||||
_NRF52_ARCHES = {"nrf52", "nrf52840"}
|
||||
|
||||
|
||||
def _wait_port_free(port: str, *, timeout_s: float = 15.0, role: str = "") -> None:
|
||||
|
||||
+11
-11
@@ -103,7 +103,7 @@ build_unflags =
|
||||
-std=gnu++11
|
||||
build_flags = ${env.build_flags} -Os
|
||||
-std=gnu++17
|
||||
build_src_filter = ${env.build_src_filter} -<platform/portduino/> -<graphics/niche/>
|
||||
build_src_filter = ${env.build_src_filter} -<platform/> +<platform/extra_variants/> -<graphics/niche/>
|
||||
|
||||
; Common libs for communicating over TCP/IP networks such as MQTT
|
||||
[networking_base]
|
||||
@@ -200,6 +200,16 @@ lib_deps =
|
||||
https://github.com/adafruit/Adafruit_TSL2561/archive/refs/tags/1.1.3.zip
|
||||
# renovate: datasource=github-tags depName=BH1750_WE packageName=wollewald/BH1750_WE
|
||||
https://github.com/wollewald/BH1750_WE/archive/refs/tags/1.1.10.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
|
||||
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
|
||||
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Common environmental sensor libraries (not included in native / portduino)
|
||||
[environmental_extra_common]
|
||||
@@ -218,16 +228,6 @@ lib_deps =
|
||||
closedcube/ClosedCube OPT3001@1.1.2
|
||||
# renovate: datasource=git-refs depName=meshtastic-DFRobot_LarkWeatherStation packageName=https://github.com/meshtastic/DFRobot_LarkWeatherStation gitBranch=master
|
||||
https://github.com/meshtastic/DFRobot_LarkWeatherStation/archive/4de3a9cadef0f6a5220a8a906cf9775b02b0040d.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion Core packageName=sensirion/arduino-core
|
||||
https://github.com/Sensirion/arduino-core/archive/refs/tags/0.7.3.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD4x packageName=sensirion/arduino-i2c-scd4x
|
||||
https://github.com/Sensirion/arduino-i2c-scd4x/archive/refs/tags/1.1.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SFA3x packageName=sensirion/arduino-i2c-sfa3x
|
||||
https://github.com/Sensirion/arduino-i2c-sfa3x/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=Sensirion I2C SCD30 packageName=sensirion/arduino-i2c-scd30
|
||||
https://github.com/Sensirion/arduino-i2c-scd30/archive/refs/tags/1.0.0.zip
|
||||
# renovate: datasource=github-tags depName=arduino-sht packageName=sensirion/arduino-sht
|
||||
https://github.com/Sensirion/arduino-sht/archive/refs/tags/v1.2.6.zip
|
||||
|
||||
; Environmental sensors with BSEC2 (Bosch proprietary IAQ)
|
||||
[environmental_extra]
|
||||
|
||||
+1
-1
Submodule protobufs updated: 485ede7422...03314e6395
+2
-2
@@ -14,8 +14,8 @@
|
||||
#define FILE_O_READ "r"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL)
|
||||
// STM32WL
|
||||
#if defined(ARCH_STM32)
|
||||
// STM32
|
||||
#include "LittleFS.h"
|
||||
#define FSCom InternalFS
|
||||
#define FSBegin() FSCom.begin()
|
||||
|
||||
@@ -354,6 +354,7 @@ void MessageStore::clearAllMessages()
|
||||
resetMessagePool();
|
||||
|
||||
#ifdef FSCom
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
SafeFile f(filename.c_str(), false);
|
||||
uint8_t count = 0;
|
||||
f.write(&count, 1); // write "0 messages"
|
||||
|
||||
+6
-6
@@ -48,7 +48,7 @@
|
||||
#include "concurrency/LockGuard.h"
|
||||
#endif
|
||||
|
||||
#if defined(ARCH_STM32WL) && defined(BATTERY_PIN)
|
||||
#if defined(ARCH_STM32) && defined(BATTERY_PIN)
|
||||
#include "stm32yyxx_ll_adc.h"
|
||||
|
||||
/* Analog read resolution */
|
||||
@@ -431,7 +431,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
float scaled = 0;
|
||||
|
||||
battery_adcEnable();
|
||||
#ifdef ARCH_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
// STM32 ADC with VREFINT runtime calibration
|
||||
Vref = __LL_ADC_CALC_VREFANALOG_VOLTAGE(analogRead(AVREF), LL_ADC_RESOLUTION);
|
||||
raw = analogRead(BATTERY_PIN);
|
||||
@@ -608,7 +608,7 @@ class AnalogBatteryLevel : public HasBatteryLevel
|
||||
bool initial_read_done = false;
|
||||
float last_read_value = (OCV[NUM_OCV_POINTS - 1] * NUM_CELLS);
|
||||
uint32_t last_read_time_ms = 0;
|
||||
#ifdef ARCH_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
// 3300mV placeholder for STM32 errata where VREFINT factory calibration may be missing
|
||||
// (e.g. STM32U0, see DS14756 Rev 3 §2.4.1 "VREFINT offset")
|
||||
uint32_t Vref = 3300;
|
||||
@@ -718,7 +718,7 @@ bool Power::analogInit()
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 10
|
||||
#endif
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
#ifdef ARCH_STM32
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#elif defined(ARCH_ESP32) // ESP32 needs special analog stuff
|
||||
adc_oneshot_unit_init_cfg_t init_config = {
|
||||
@@ -749,7 +749,7 @@ bool Power::analogInit()
|
||||
|
||||
// NRF52 ADC init moved to powerHAL_init in nrf52 platform
|
||||
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32WL)
|
||||
#if !defined(ARCH_ESP32) && !defined(ARCH_STM32)
|
||||
analogReadResolution(BATTERY_SENSE_RESOLUTION_BITS);
|
||||
#endif
|
||||
|
||||
@@ -838,7 +838,7 @@ void Power::reboot()
|
||||
}
|
||||
LOG_DEBUG("final reboot!");
|
||||
::reboot();
|
||||
#elif defined(ARCH_STM32WL)
|
||||
#elif defined(ARCH_STM32)
|
||||
HAL_NVIC_SystemReset();
|
||||
#else
|
||||
rebootAtMsec = -1;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
#include "memGet.h"
|
||||
#include "mesh/generated/meshtastic/mesh.pb.h"
|
||||
#include <assert.h>
|
||||
#include <atomic>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
@@ -20,6 +21,22 @@
|
||||
#if HAS_NETWORKING
|
||||
extern meshtastic::Syslog syslog;
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
std::atomic<bool> serialHalLogSuppressed{false};
|
||||
}
|
||||
|
||||
void RedirectablePrint::setSerialHalLogSuppressed(bool suppressed)
|
||||
{
|
||||
serialHalLogSuppressed.store(suppressed);
|
||||
}
|
||||
|
||||
bool RedirectablePrint::isSerialHalLogSuppressed()
|
||||
{
|
||||
return serialHalLogSuppressed.load();
|
||||
}
|
||||
|
||||
void RedirectablePrint::rpInit()
|
||||
{
|
||||
#ifdef HAS_FREE_RTOS
|
||||
@@ -281,6 +298,10 @@ meshtastic_LogRecord_Level RedirectablePrint::getLogLevel(const char *logLevel)
|
||||
void RedirectablePrint::log(const char *logLevel, const char *format, ...)
|
||||
{
|
||||
|
||||
if (isSerialHalLogSuppressed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// append \n to format
|
||||
size_t len = strlen(format);
|
||||
auto newFormat = std::unique_ptr<char[]>(new char[len + 2]);
|
||||
|
||||
@@ -24,6 +24,11 @@ class RedirectablePrint : public Print
|
||||
public:
|
||||
explicit RedirectablePrint(Print *_dest) : dest(_dest) {}
|
||||
|
||||
/// Suppress all log output while a SerialHal transaction is in progress.
|
||||
// Unclear if this is necessary, but it seems to help with response speeds.
|
||||
static void setSerialHalLogSuppressed(bool suppressed);
|
||||
static bool isSerialHalLogSuppressed();
|
||||
|
||||
/**
|
||||
* Set a new destination
|
||||
*/
|
||||
|
||||
+32
-9
@@ -179,6 +179,13 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef USE_KCT8103L_PA_ONLY
|
||||
#if defined(HELTEC_MESH_TOWER_V2)
|
||||
#define NUM_PA_POINTS 22
|
||||
#define TX_GAIN_LORA 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10, 10, 10, 10, 10, 10, 10, 10, 9, 8, 7
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef RAK13302
|
||||
#define NUM_PA_POINTS 22
|
||||
#define TX_GAIN_LORA 7, 8, 8, 8, 8, 8, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 8
|
||||
@@ -576,9 +583,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// -----------------------------------------------------------------------------
|
||||
// MESHTASTIC_LOCKDOWN — runtime, client-toggleable hardening (nRF52 only)
|
||||
//
|
||||
// There is NO build flag to turn lockdown on or off. On nRF52 (CC310 hardware
|
||||
// crypto) the lockdown machinery is ALWAYS compiled in; whether it is ACTIVE
|
||||
// is decided entirely at runtime by EncryptedStorage::isLockdownActive()
|
||||
// Lockdown/protect support is opt-in at build time. Builds that need it pass
|
||||
// -DMESHTASTIC_ENABLE_LOCKDOWN=1. When enabled on nRF52 (CC310 hardware
|
||||
// crypto), whether it is ACTIVE is decided entirely at runtime by
|
||||
// EncryptedStorage::isLockdownActive()
|
||||
// (== a passphrase has been provisioned, i.e. /prefs/.dek exists). A device
|
||||
// that has never been provisioned — or that the operator disabled from the
|
||||
// client app — behaves exactly like stock firmware: plaintext storage, no
|
||||
@@ -594,11 +602,10 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// reboots into normal mode. APPROTECT is the one thing that
|
||||
// does NOT revert (see below).
|
||||
//
|
||||
// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker, auto-defined for
|
||||
// nRF52. It gates the UI bits (lock screen, pairing-PIN handling). It is NOT
|
||||
// something a variant sets. Flash-constrained nRF52 variants that genuinely
|
||||
// cannot afford the ~tens-of-KB of crypto + access-control code may opt OUT
|
||||
// with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
|
||||
// MESHTASTIC_LOCKDOWN here is an INTERNAL capability marker. It gates the UI
|
||||
// bits (lock screen, pairing-PIN handling). Flash-constrained nRF52 variants
|
||||
// that genuinely cannot afford the ~tens-of-KB of crypto + access-control code
|
||||
// may also opt out with -DMESHTASTIC_EXCLUDE_LOCKDOWN=1.
|
||||
//
|
||||
// MESHTASTIC_PHONEAPI_ACCESS_CONTROL — per-connection auth + redaction,
|
||||
// gated at runtime on isLockdownActive()
|
||||
@@ -615,7 +622,22 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
// -DMESHTASTIC_LOCKDOWN_DEBUG=1 keeps the irreversible APPROTECT burn disabled
|
||||
// even when provisioned — for development so dev boards never lose SWD.
|
||||
// -----------------------------------------------------------------------------
|
||||
#if defined(ARCH_NRF52) && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
|
||||
#if defined(ARCH_NRF52)
|
||||
#ifndef MESHTASTIC_ENABLE_LOCKDOWN
|
||||
#define MESHTASTIC_ENABLE_LOCKDOWN 0
|
||||
#endif
|
||||
|
||||
#if !MESHTASTIC_ENABLE_LOCKDOWN
|
||||
#undef MESHTASTIC_LOCKDOWN
|
||||
#undef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
#undef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#undef MESHTASTIC_ENABLE_APPROTECT
|
||||
#ifndef MESHTASTIC_EXCLUDE_LOCKDOWN
|
||||
#define MESHTASTIC_EXCLUDE_LOCKDOWN 1
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if MESHTASTIC_ENABLE_LOCKDOWN && !defined(MESHTASTIC_EXCLUDE_LOCKDOWN)
|
||||
#define MESHTASTIC_LOCKDOWN 1
|
||||
#define MESHTASTIC_PHONEAPI_ACCESS_CONTROL 1
|
||||
#define MESHTASTIC_ENCRYPTED_STORAGE 1
|
||||
@@ -623,6 +645,7 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#define MESHTASTIC_ENABLE_APPROTECT 1
|
||||
#endif
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifdef MESHTASTIC_LOCKDOWN
|
||||
|
||||
|
||||
@@ -37,15 +37,15 @@ ScanI2C::FoundDevice ScanI2C::firstKeyboard() const
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAccelerometer() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
|
||||
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P};
|
||||
return firstOfOrNONE(11, types);
|
||||
ScanI2C::DeviceType types[] = {MPU6050, LIS3DH, BMA423, LSM6DS3, BMX160, STK8BAXX,
|
||||
ICM20948, QMA6100P, BMM150, BMI270, ICM42607P, ISM330DHCX};
|
||||
return firstOfOrNONE(12, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstMagnetometer() const
|
||||
{
|
||||
ScanI2C::DeviceType types[] = {MMC5983MA};
|
||||
return firstOfOrNONE(1, types);
|
||||
ScanI2C::DeviceType types[] = {MMC5983MA, IIS2MDCTR};
|
||||
return firstOfOrNONE(2, types);
|
||||
}
|
||||
|
||||
ScanI2C::FoundDevice ScanI2C::firstAQI() const
|
||||
|
||||
@@ -99,6 +99,8 @@ class ScanI2C
|
||||
CW2015,
|
||||
SCD30,
|
||||
ADS1115,
|
||||
IIS2MDCTR,
|
||||
ISM330DHCX,
|
||||
} DeviceType;
|
||||
|
||||
// typedef uint8_t DeviceAddress;
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#include "linux/LinuxHardwareI2C.h"
|
||||
#endif
|
||||
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32WL)
|
||||
#if !defined(ARCH_PORTDUINO) && !defined(ARCH_STM32)
|
||||
#include "meshUtils.h" // vformat
|
||||
|
||||
#endif
|
||||
@@ -584,6 +584,9 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
if (registerValue == 0x6A) {
|
||||
type = LSM6DS3;
|
||||
logFoundDevice("LSM6DS3", (uint8_t)addr.address);
|
||||
} else if (registerValue == 0x6B) {
|
||||
type = ISM330DHCX;
|
||||
logFoundDevice("ISM330DHCX", (uint8_t)addr.address);
|
||||
} else {
|
||||
type = QMI8658;
|
||||
logFoundDevice("QMI8658", (uint8_t)addr.address);
|
||||
@@ -591,7 +594,17 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize)
|
||||
break;
|
||||
|
||||
SCAN_SIMPLE_CASE(QMC5883L_ADDR, QMC5883L, "QMC5883L", (uint8_t)addr.address)
|
||||
SCAN_SIMPLE_CASE(HMC5883L_ADDR, HMC5883L, "HMC5883L", (uint8_t)addr.address)
|
||||
case HMC5883L_ADDR:
|
||||
registerValue = getRegisterValue(ScanI2CTwoWire::RegisterLocation(addr, 0x4FU), 1); // get ID
|
||||
if (registerValue == 0x40) {
|
||||
type = IIS2MDCTR;
|
||||
logFoundDevice("IIS2MDCTR", (uint8_t)addr.address);
|
||||
break;
|
||||
} else {
|
||||
type = HMC5883L;
|
||||
logFoundDevice("HMC5883L", (uint8_t)addr.address);
|
||||
break;
|
||||
}
|
||||
#ifdef HAS_QMA6100P
|
||||
SCAN_SIMPLE_CASE(QMA6100P_ADDR, QMA6100P, "QMA6100P", (uint8_t)addr.address)
|
||||
#else
|
||||
|
||||
+80
-24
@@ -47,7 +47,7 @@ template <typename T, std::size_t N> std::size_t array_count(const T (&)[N])
|
||||
|
||||
#if defined(ARCH_NRF52)
|
||||
Uart *GPS::_serial_gps = &GPS_SERIAL_PORT;
|
||||
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
|
||||
#elif defined(ARCH_ESP32) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
|
||||
HardwareSerial *GPS::_serial_gps = &GPS_SERIAL_PORT;
|
||||
#elif defined(ARCH_RP2040)
|
||||
SerialUART *GPS::_serial_gps = &GPS_SERIAL_PORT;
|
||||
@@ -80,6 +80,12 @@ namespace
|
||||
constexpr uint32_t GPS_PROBE_CACHE_MAGIC = 0x47504348UL; // "GPCH"
|
||||
constexpr uint16_t GPS_PROBE_CACHE_VERSION = 1;
|
||||
constexpr const char *GPS_PROBE_CACHE_FILE = "/prefs/gps_probe_cache.dat";
|
||||
constexpr int MIN_PLAUSIBLE_GPS_YEAR = 2020;
|
||||
constexpr int MAX_PLAUSIBLE_GPS_YEAR = 2100;
|
||||
#ifdef TRACKER_T1000_E
|
||||
constexpr uint32_t T1000_E_AIROHA_WAKE_MS = 1000;
|
||||
constexpr uint32_t T1000_E_AIROHA_WAKE_INTERVAL_MS = 40;
|
||||
#endif
|
||||
|
||||
struct GPSProbeCacheRecord {
|
||||
uint32_t magic;
|
||||
@@ -101,6 +107,45 @@ bool isValidProbeBaud(uint32_t baud)
|
||||
return baud >= 1200 && baud <= 921600;
|
||||
}
|
||||
|
||||
template <typename T> void wakeAirohaForActiveProbe(T *serialGps)
|
||||
{
|
||||
#ifdef TRACKER_T1000_E
|
||||
digitalWrite(PIN_GPS_EN, GPS_EN_ACTIVE);
|
||||
digitalWrite(GPS_RTC_INT, HIGH);
|
||||
delay(3);
|
||||
digitalWrite(GPS_RTC_INT, LOW);
|
||||
delay(50);
|
||||
|
||||
const uint32_t start = millis();
|
||||
do {
|
||||
serialGps->write("$PAIR382,1*2E\r\n");
|
||||
delay(T1000_E_AIROHA_WAKE_INTERVAL_MS);
|
||||
} while (Throttle::isWithinTimespanMs(start, T1000_E_AIROHA_WAKE_MS));
|
||||
#elif defined(GNSS_AIROHA)
|
||||
serialGps->write("$PAIR382,1*2E\r\n");
|
||||
delay(20);
|
||||
#else
|
||||
(void)serialGps;
|
||||
#endif
|
||||
}
|
||||
|
||||
bool isPlausibleNmeaTime(const struct tm &t)
|
||||
{
|
||||
const int year = t.tm_year + 1900;
|
||||
if (year < MIN_PLAUSIBLE_GPS_YEAR || year > MAX_PLAUSIBLE_GPS_YEAR) {
|
||||
return false;
|
||||
}
|
||||
|
||||
#ifdef BUILD_EPOCH
|
||||
const int64_t candidate = static_cast<int64_t>(gm_mktime(&t));
|
||||
const int64_t minEpoch = static_cast<int64_t>(BUILD_EPOCH);
|
||||
const int64_t maxEpoch = minEpoch + static_cast<int64_t>(FORTY_YEARS);
|
||||
return candidate >= minEpoch && candidate <= maxEpoch;
|
||||
#else
|
||||
return true;
|
||||
#endif
|
||||
}
|
||||
|
||||
template <typename T> bool sawNmeaSentenceAtBaud(T *serialGps, uint32_t timeoutMs)
|
||||
{
|
||||
// Lightweight passive check: look for at least one complete
|
||||
@@ -642,7 +687,7 @@ bool GPS::verifyCachedProbePresence()
|
||||
return false;
|
||||
}
|
||||
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
|
||||
_serial_gps->end();
|
||||
_serial_gps->begin(cachedProbeBaud);
|
||||
#elif defined(ARCH_RP2040)
|
||||
@@ -689,6 +734,7 @@ bool GPS::verifyCachedProbePresence()
|
||||
case GNSS_MODEL_AG3352:
|
||||
if (cachedProbeModel == GNSS_MODEL_AG3352)
|
||||
cachedProbeModelName = "AG3352";
|
||||
wakeAirohaForActiveProbe(_serial_gps);
|
||||
_serial_gps->write("$PAIR021*39\r\n");
|
||||
present = (getACK("$PAIR021,", 900) == GNSS_RESPONSE_OK);
|
||||
break;
|
||||
@@ -741,7 +787,6 @@ bool GPS::verifyCachedProbePresence()
|
||||
if (!present) {
|
||||
LOG_WARN("Cached GPS probe is stale (%s @ %d), clearing cache", cachedProbeModelName, cachedProbeBaud);
|
||||
clearProbeCache();
|
||||
cachedProbeFailedThisBoot = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -762,13 +807,6 @@ bool GPS::setup()
|
||||
{
|
||||
if (!didSerialInit) {
|
||||
int msglen = 0;
|
||||
if (cachedProbeFailedThisBoot) {
|
||||
// If cached verification failed, suppress further probing until
|
||||
// reboot.
|
||||
didSerialInit = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tx_gpio && gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
if (!hasProbeCache && !triedProbeCache) {
|
||||
(void)loadProbeCache();
|
||||
@@ -777,12 +815,13 @@ bool GPS::setup()
|
||||
if (hasProbeCache && !triedProbeCache) {
|
||||
triedProbeCache = true;
|
||||
if (!verifyCachedProbePresence()) {
|
||||
// Cache was stale and got wiped; skip scanning this boot
|
||||
// and let next boot do a full probe.
|
||||
didSerialInit = true;
|
||||
return true;
|
||||
currentStep = 0;
|
||||
speedSelect = 0;
|
||||
probeTries = 0;
|
||||
}
|
||||
} else if (probeTries < GPS_PROBETRIES) {
|
||||
}
|
||||
|
||||
if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries < GPS_PROBETRIES) {
|
||||
// No usable cache: walk common baud rates first.
|
||||
gnssModel = probe(serialSpeeds[speedSelect]);
|
||||
if (gnssModel != GNSS_MODEL_UNKNOWN) {
|
||||
@@ -794,7 +833,7 @@ bool GPS::setup()
|
||||
}
|
||||
// Rare Serial Speeds
|
||||
#ifndef CONFIG_IDF_TARGET_ESP32C6
|
||||
else if (probeTries == GPS_PROBETRIES) {
|
||||
else if (gnssModel == GNSS_MODEL_UNKNOWN && probeTries == GPS_PROBETRIES) {
|
||||
// Then try less common baud rates before giving up.
|
||||
gnssModel = probe(rareSerialSpeeds[speedSelect]);
|
||||
if (gnssModel != GNSS_MODEL_UNKNOWN) {
|
||||
@@ -1121,10 +1160,22 @@ void GPS::setPowerState(GPSPowerState newState, uint32_t sleepTime)
|
||||
switch (newState) {
|
||||
case GPS_ACTIVE:
|
||||
case GPS_IDLE:
|
||||
if (oldState == GPS_ACTIVE || oldState == GPS_IDLE) // If hardware already awake, no changes needed
|
||||
if (oldState == GPS_ACTIVE)
|
||||
break;
|
||||
gotTime = false;
|
||||
if (oldState == GPS_IDLE) // If hardware already awake, no changes needed
|
||||
break;
|
||||
if (oldState != GPS_ACTIVE && oldState != GPS_IDLE) // If hardware just waking now, clear buffer
|
||||
clearBuffer();
|
||||
#ifdef TRACKER_T1000_E
|
||||
pinMode(GPS_VRTC_EN, OUTPUT);
|
||||
digitalWrite(GPS_VRTC_EN, HIGH);
|
||||
pinMode(GPS_SLEEP_INT, OUTPUT);
|
||||
digitalWrite(GPS_SLEEP_INT, HIGH);
|
||||
pinMode(GPS_RTC_INT, OUTPUT);
|
||||
digitalWrite(GPS_RTC_INT, LOW);
|
||||
pinMode(GPS_RESETB_OUT, INPUT_PULLUP);
|
||||
#endif
|
||||
powerMon->setState(meshtastic_PowerMon_State_GPS_Active); // Report change for power monitoring (during testing)
|
||||
writePinEN(true); // Power (EN pin): on
|
||||
setPowerPMU(true); // Power (PMU): on
|
||||
@@ -1383,9 +1434,8 @@ int32_t GPS::runOnce()
|
||||
if (!setup())
|
||||
return currentDelay; // Setup failed, re-run in two seconds
|
||||
|
||||
if (cachedProbeFailedThisBoot || gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
LOG_WARN("GPS not detected at cached settings; marked not present "
|
||||
"for this boot");
|
||||
if (gnssModel == GNSS_MODEL_UNKNOWN) {
|
||||
LOG_WARN("GPS not detected; marked not present for this boot");
|
||||
return disable();
|
||||
}
|
||||
|
||||
@@ -1437,8 +1487,7 @@ int32_t GPS::runOnce()
|
||||
// if gps_update_interval is <=10s, GPS never goes off, so we treat that differently
|
||||
uint32_t updateInterval = Default::getConfiguredOrDefaultMs(config.position.gps_update_interval);
|
||||
|
||||
// 1. Got a time for the first time
|
||||
bool gotTime = (getRTCQuality() >= RTCQualityGPS);
|
||||
// 1. Got a time for the first time this cycle
|
||||
if (!gotTime && lookForTime()) { // Note: we count on this && short-circuiting and not resetting the RTC time
|
||||
gotTime = true;
|
||||
}
|
||||
@@ -1564,7 +1613,7 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
|
||||
switch (currentStep) {
|
||||
case 0: {
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL)
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32)
|
||||
_serial_gps->end();
|
||||
_serial_gps->begin(serialSpeed);
|
||||
#elif defined(ARCH_RP2040)
|
||||
@@ -1585,6 +1634,9 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
digitalWrite(PIN_GPS_RESET, GPS_RESET_MODE); // assert for 10ms
|
||||
delay(10);
|
||||
digitalWrite(PIN_GPS_RESET, !GPS_RESET_MODE);
|
||||
#ifdef TRACKER_T1000_E
|
||||
delay(100);
|
||||
#endif
|
||||
|
||||
// attempt to detect the chip based on boot messages
|
||||
std::vector<ChipInfo> passive_detect = {
|
||||
@@ -1637,6 +1689,7 @@ GnssModel_t GPS::probe(int serialSpeed)
|
||||
}
|
||||
case 3: {
|
||||
/* Airoha (Mediatek) AG3335A/M/S, A3352Q, Quectel L89 2.0, SimCom SIM65M */
|
||||
wakeAirohaForActiveProbe(_serial_gps);
|
||||
_serial_gps->write("$PAIR062,2,0*3C\r\n"); // GSA OFF to reduce volume
|
||||
_serial_gps->write("$PAIR062,3,0*3D\r\n"); // GSV OFF to reduce volume
|
||||
_serial_gps->write("$PAIR513*3D\r\n"); // save configuration
|
||||
@@ -1921,7 +1974,7 @@ std::unique_ptr<GPS> GPS::createGps()
|
||||
#elif defined(ARCH_NRF52)
|
||||
_serial_gps->setPins(new_gps->rx_gpio, new_gps->tx_gpio);
|
||||
_serial_gps->begin(GPS_BAUDRATE);
|
||||
#elif defined(ARCH_STM32WL)
|
||||
#elif defined(ARCH_STM32)
|
||||
_serial_gps->setTx(new_gps->tx_gpio);
|
||||
_serial_gps->setRx(new_gps->rx_gpio);
|
||||
_serial_gps->begin(GPS_BAUDRATE);
|
||||
@@ -1968,6 +2021,9 @@ The Unix epoch (or Unix time or POSIX time or Unix timestamp) is the number of s
|
||||
t.tm_year = d.year() - 1900;
|
||||
t.tm_isdst = false;
|
||||
if (t.tm_mon > -1) {
|
||||
if (!isPlausibleNmeaTime(t)) {
|
||||
return false;
|
||||
}
|
||||
if (perhapsSetRTC(RTCQualityGPS, t) == RTCSetResultSuccess) {
|
||||
LOG_DEBUG("NMEA GPS time set %02d-%02d-%02d %02d:%02d:%02d age %d", d.year(), d.month(), t.tm_mday, t.tm_hour,
|
||||
t.tm_min, t.tm_sec, ti.age());
|
||||
|
||||
+1
-2
@@ -174,6 +174,7 @@ class GPS : private concurrency::OSThread
|
||||
uint32_t lastChecksumFailCount = 0;
|
||||
uint8_t currentStep = 0;
|
||||
int32_t currentDelay = 2000;
|
||||
bool gotTime = false;
|
||||
|
||||
#ifndef TINYGPS_OPTION_NO_CUSTOM_FIELDS
|
||||
// (20210908) TinyGps++ can only read the GPGSA "FIX TYPE" field
|
||||
@@ -193,8 +194,6 @@ class GPS : private concurrency::OSThread
|
||||
bool hasProbeCache = false;
|
||||
// Ensures cached probe is attempted once per boot.
|
||||
bool triedProbeCache = false;
|
||||
// Latched when cached presence check fails
|
||||
bool cachedProbeFailedThisBoot = false;
|
||||
|
||||
/**
|
||||
* hasValidLocation - indicates that the position variables contain a complete
|
||||
|
||||
+2
-2
@@ -219,8 +219,8 @@ RTCSetResult perhapsSetRTC(RTCQuality q, const struct timeval *tv, bool forceUpd
|
||||
} else if (q == RTCQualityGPS) {
|
||||
shouldSet = true;
|
||||
LOG_DEBUG("Reapply GPS time: %ld secs", printableEpoch);
|
||||
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (12 * 60 * 60 * 1000UL))) {
|
||||
// Every 12 hrs we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
|
||||
} else if (q == RTCQualityNTP && !Throttle::isWithinTimespanMs(lastSetMsec, (30 * 60 * 1000UL))) {
|
||||
// Every 30 minutes we will slam in a new NTP or Phone GPS / NTP time, to correct for local RTC clock drift
|
||||
shouldSet = true;
|
||||
LOG_DEBUG("Reapply external time to correct clock drift %ld secs", printableEpoch);
|
||||
} else {
|
||||
|
||||
+26
-23
@@ -233,6 +233,16 @@ static inline float wrapHeading360(float heading)
|
||||
return heading;
|
||||
}
|
||||
|
||||
static inline float wrapDelta180(float delta)
|
||||
{
|
||||
if (delta > 180.0f) {
|
||||
delta -= 360.0f;
|
||||
} else if (delta < -180.0f) {
|
||||
delta += 360.0f;
|
||||
}
|
||||
return delta;
|
||||
}
|
||||
|
||||
void Screen::setHeading(float heading)
|
||||
{
|
||||
const float wrappedHeading = wrapHeading360(heading);
|
||||
@@ -244,37 +254,30 @@ void Screen::setHeading(float heading)
|
||||
}
|
||||
|
||||
// Interpolate using shortest-path angular delta to avoid jumps around 0/360.
|
||||
float delta = wrappedHeading - compassHeading;
|
||||
if (delta > 180.0f) {
|
||||
delta -= 360.0f;
|
||||
} else if (delta < -180.0f) {
|
||||
delta += 360.0f;
|
||||
}
|
||||
float delta = wrapDelta180(wrappedHeading - compassHeading);
|
||||
|
||||
// Adaptive filtering:
|
||||
// - Strong damping for tiny deltas (jitter)
|
||||
// - Faster response for larger turns
|
||||
const float absDelta = (delta >= 0.0f) ? delta : -delta;
|
||||
if (absDelta < 1.0f) {
|
||||
return;
|
||||
}
|
||||
if (absDelta >= 1.0f) {
|
||||
float alpha = 0.35f;
|
||||
if (absDelta > 25.0f) {
|
||||
alpha = 0.85f;
|
||||
} else if (absDelta > 10.0f) {
|
||||
alpha = 0.65f;
|
||||
}
|
||||
|
||||
float alpha = 0.35f;
|
||||
if (absDelta > 25.0f) {
|
||||
alpha = 0.85f;
|
||||
} else if (absDelta > 10.0f) {
|
||||
alpha = 0.65f;
|
||||
}
|
||||
float step = delta * alpha;
|
||||
const float maxStep = 12.0f;
|
||||
if (step > maxStep) {
|
||||
step = maxStep;
|
||||
} else if (step < -maxStep) {
|
||||
step = -maxStep;
|
||||
}
|
||||
|
||||
float step = delta * alpha;
|
||||
const float maxStep = 12.0f;
|
||||
if (step > maxStep) {
|
||||
step = maxStep;
|
||||
} else if (step < -maxStep) {
|
||||
step = -maxStep;
|
||||
compassHeading = wrapHeading360(compassHeading + step);
|
||||
}
|
||||
|
||||
compassHeading = wrapHeading360(compassHeading + step);
|
||||
}
|
||||
|
||||
// ==============================
|
||||
|
||||
@@ -1533,8 +1533,7 @@ bool TFTDisplay::hasTouch(void)
|
||||
{
|
||||
#ifdef RAK14014
|
||||
return true;
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
|
||||
!defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
return tft->touch() != nullptr;
|
||||
#else
|
||||
return false;
|
||||
@@ -1553,8 +1552,7 @@ bool TFTDisplay::getTouch(int16_t *x, int16_t *y)
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && \
|
||||
!defined(HELTEC_MESH_NODE_T1)
|
||||
#elif !defined(M5STACK) && !defined(HACKADAY_COMMUNICATOR) && !defined(HELTEC_MESH_NODE_T096) && !defined(HELTEC_MESH_NODE_T1)
|
||||
return tft->getTouch(x, y);
|
||||
#else
|
||||
return false;
|
||||
|
||||
@@ -1572,6 +1572,7 @@ void menuHandler::manageNodeMenu()
|
||||
nodeDB->set_favorite(false, menuHandler::pickedNodeNum);
|
||||
} else {
|
||||
LOG_INFO("Adding node %08X to favorites", menuHandler::pickedNodeNum);
|
||||
// set_favorite() already logs PROTECTED_CAP_WARN_FMT on a cap refusal; don't double-log here.
|
||||
nodeDB->set_favorite(true, menuHandler::pickedNodeNum);
|
||||
}
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
||||
@@ -1615,15 +1616,23 @@ void menuHandler::manageNodeMenu()
|
||||
return;
|
||||
}
|
||||
|
||||
bool changed = false;
|
||||
if (nodeInfoLiteIsIgnored(n)) {
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, false);
|
||||
LOG_INFO("Unignoring node %08X", menuHandler::pickedNodeNum);
|
||||
} else {
|
||||
nodeInfoLiteSetBit(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||
changed = true;
|
||||
} else if (nodeDB->setProtectedFlag(n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
|
||||
LOG_INFO("Ignoring node %08X", menuHandler::pickedNodeNum);
|
||||
changed = true;
|
||||
} else {
|
||||
LOG_WARN(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", menuHandler::pickedNodeNum, MAX_NUM_NODES - 2);
|
||||
}
|
||||
// Only persist/notify when the ignore bit actually moved; a cap
|
||||
// refusal changed nothing and shouldn't trigger a prefs save.
|
||||
if (changed) {
|
||||
nodeDB->notifyObservers(true);
|
||||
nodeDB->saveToDisk();
|
||||
}
|
||||
nodeDB->notifyObservers(true);
|
||||
nodeDB->saveToDisk();
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,7 @@ enum MenuAction {
|
||||
// Administration
|
||||
RESET_NODEDB_ALL,
|
||||
RESET_NODEDB_KEEP_FAVORITES,
|
||||
WIPE_MESSAGES_ALL,
|
||||
};
|
||||
|
||||
} // namespace NicheGraphics::InkHUD
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#include "GPS.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "MessageStore.h"
|
||||
#include "RTC.h"
|
||||
#include "Router.h"
|
||||
#include "airtime.h"
|
||||
@@ -1013,6 +1014,13 @@ void InkHUD::MenuApplet::execute(MenuItem item)
|
||||
rebootAtMsec = millis() + DEFAULT_REBOOT_SECONDS * 1000;
|
||||
break;
|
||||
|
||||
case WIPE_MESSAGES_ALL:
|
||||
LOG_INFO("Wiping all messages from menu");
|
||||
messageStore.clearAllMessages();
|
||||
inkhud->persistence->loadLatestMessage();
|
||||
inkhud->forceUpdate(Drivers::EInk::UpdateTypes::FULL, true);
|
||||
break;
|
||||
|
||||
default:
|
||||
LOG_WARN("Action not implemented");
|
||||
}
|
||||
@@ -1130,6 +1138,7 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
// Administration Section
|
||||
items.push_back(MenuItem::Header("Administration"));
|
||||
items.push_back(MenuItem("Reset NodeDB", MenuPage::NODE_CONFIG_ADMIN_RESET));
|
||||
items.push_back(MenuItem("Wipe Messages", MenuPage::NODE_CONFIG_ADMIN_MESSAGES));
|
||||
|
||||
// Exit
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
@@ -1534,6 +1543,13 @@ void InkHUD::MenuApplet::showPage(MenuPage page)
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
case NODE_CONFIG_ADMIN_MESSAGES:
|
||||
previousPage = MenuPage::NODE_CONFIG;
|
||||
items.push_back(MenuItem("Back", previousPage));
|
||||
items.push_back(MenuItem("Wipe All Messages", MenuAction::WIPE_MESSAGES_ALL, MenuPage::EXIT));
|
||||
items.push_back(MenuItem("Exit", MenuPage::EXIT));
|
||||
break;
|
||||
|
||||
// Exit
|
||||
case EXIT:
|
||||
sendToBackground(); // Menu applet dismissed, allow normal behavior to resume
|
||||
|
||||
@@ -36,6 +36,7 @@ enum MenuPage : uint8_t {
|
||||
NODE_CONFIG_BLUETOOTH,
|
||||
NODE_CONFIG_POSITION,
|
||||
NODE_CONFIG_ADMIN_RESET,
|
||||
NODE_CONFIG_ADMIN_MESSAGES,
|
||||
TIMEZONE,
|
||||
APPLETS,
|
||||
AUTOSHOW,
|
||||
|
||||
@@ -22,6 +22,8 @@ void InkHUD::Persistence::loadSettings()
|
||||
// are immediately available to applets (DMApplet, AllMessageApplet, NotificationApplet).
|
||||
void InkHUD::Persistence::loadLatestMessage()
|
||||
{
|
||||
latestMessage = LatestMessage();
|
||||
|
||||
int lastBroadcastPos = -1, lastDMPos = -1, pos = 0;
|
||||
for (const StoredMessage &m : messageStore.getLiveMessages()) {
|
||||
if (m.type == MessageType::BROADCAST) {
|
||||
@@ -75,4 +77,4 @@ void InkHUD::Persistence::printSettings(Settings *settings)
|
||||
}
|
||||
*/
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
+9
-6
@@ -1056,8 +1056,12 @@ void setup()
|
||||
#endif
|
||||
#endif
|
||||
|
||||
auto rIf = initLoRa();
|
||||
|
||||
std::unique_ptr<RadioInterface> rIf;
|
||||
if (!config.lora.serial_hal_only) {
|
||||
rIf = initLoRa();
|
||||
} else {
|
||||
LOG_INFO("skipping LoRa radio init, for serialHal");
|
||||
}
|
||||
lateInitVariant(); // Do board specific init (see extra_variants/README.md for documentation)
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_MQTT
|
||||
@@ -1101,10 +1105,9 @@ void setup()
|
||||
|
||||
// Start airtime logger thread.
|
||||
airTime = new AirTime();
|
||||
|
||||
if (!rIf)
|
||||
if (!rIf && !config.lora.serial_hal_only)
|
||||
RECORD_CRITICALERROR(meshtastic_CriticalErrorCode_NO_RADIO);
|
||||
else {
|
||||
else if (rIf) {
|
||||
// Log bit rate to debug output
|
||||
LOG_DEBUG("LoRA bitrate = %f bytes / sec", (float(meshtastic_Constants_DATA_PAYLOAD_LEN) /
|
||||
(float(rIf->getPacketTime(meshtastic_Constants_DATA_PAYLOAD_LEN)))) *
|
||||
@@ -1191,7 +1194,7 @@ extern meshtastic_DeviceMetadata getDeviceMetadata()
|
||||
// No bluetooth on these targets (yet):
|
||||
// Pico W / 2W may get it at some point
|
||||
// Portduino and ESP32-C6 are excluded because we don't have a working bluetooth stacks integrated yet.
|
||||
#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32WL) || defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
#if defined(ARCH_RP2040) || defined(ARCH_PORTDUINO) || defined(ARCH_STM32) || defined(CONFIG_IDF_TARGET_ESP32C6)
|
||||
deviceMetadata.excluded_modules |= meshtastic_ExcludedModules_BLUETOOTH_CONFIG;
|
||||
#endif
|
||||
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
#include <malloc.h>
|
||||
#include <unistd.h> // sbrk
|
||||
|
||||
#ifdef ARCH_STM32WL
|
||||
#if defined(ARCH_STM32)
|
||||
// Returns the uncommitted sbrk headroom: addressable space between the current heap
|
||||
// break and the stack pointer that has not yet been committed to the arena.
|
||||
static uint32_t sbrkHeadroom()
|
||||
|
||||
@@ -404,6 +404,60 @@ bool Channels::isDefaultChannel(ChannelIndex chIndex)
|
||||
return false;
|
||||
}
|
||||
|
||||
bool cryptoKeyIsPublic(const CryptoKey &key)
|
||||
{
|
||||
if (key.length == 0)
|
||||
return true; // encryption disabled
|
||||
// Match the defaultpsk family ignoring its last byte (getKey() bumps only that byte per 1-byte index).
|
||||
if (key.length == (int)sizeof(defaultpsk) && memcmp(key.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Channels::usesPublicKey(ChannelIndex chIndex)
|
||||
{
|
||||
const meshtastic_Channel &ch = getByIndex(chIndex);
|
||||
if (!ch.has_settings || ch.role == meshtastic_Channel_Role_DISABLED)
|
||||
return false;
|
||||
|
||||
const auto &psk = ch.settings.psk;
|
||||
if (psk.size == 0) {
|
||||
// Secondary channels inherit the primary key when unset; primary size==0 means encryption disabled.
|
||||
if (ch.role == meshtastic_Channel_Role_SECONDARY) {
|
||||
// Guard against malformed configs with no PRIMARY channel (primaryIndex could point back to us).
|
||||
if (primaryIndex == chIndex)
|
||||
return true; // fail closed: treat as public
|
||||
return usesPublicKey(primaryIndex);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (psk.size == 1) {
|
||||
// Short PSK aliases: 0 disables encryption; 1..255 are the public defaultpsk family.
|
||||
return true;
|
||||
}
|
||||
|
||||
return (psk.size == sizeof(defaultpsk) && memcmp(psk.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0);
|
||||
}
|
||||
|
||||
bool Channels::isWellKnownChannel(ChannelIndex chIndex)
|
||||
{
|
||||
const auto &ch = getByIndex(chIndex);
|
||||
// Absent (unencrypted) or single-byte PSK — all the well-known key indexes
|
||||
if (ch.settings.psk.size > 1)
|
||||
return false;
|
||||
|
||||
const char *name = getName(chIndex);
|
||||
for (int p = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; p <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; p++) {
|
||||
const char *presetName =
|
||||
DisplayFormatters::getModemPresetDisplayName(static_cast<meshtastic_Config_LoRaConfig_ModemPreset>(p), false, true);
|
||||
// Presets without a display name fall through to "Invalid" — never a match
|
||||
if (strcmp(presetName, "Invalid") != 0 && strcmp(name, presetName) == 0)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool Channels::hasDefaultChannel()
|
||||
{
|
||||
// If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel
|
||||
|
||||
@@ -86,6 +86,15 @@ class Channels
|
||||
// Returns true if the channel has the default name and PSK
|
||||
bool isDefaultChannel(ChannelIndex chIndex);
|
||||
|
||||
// Returns true if this channel's effective key is publicly decryptable (open or well-known/default PSK).
|
||||
bool usesPublicKey(ChannelIndex chIndex);
|
||||
// Returns true if the channel is "well known": its PSK is absent or a
|
||||
// single-byte well-known key index, AND its name is any modem-preset
|
||||
// display name (e.g. a channel named "LongFast" counts even while the
|
||||
// radio runs MediumFast). Broader than isDefaultChannel, which only
|
||||
// matches the current preset's name and PSK byte 1.
|
||||
bool isWellKnownChannel(ChannelIndex chIndex);
|
||||
|
||||
// Returns true if we can be reached via a channel with the default settings given a region and modem preset
|
||||
bool hasDefaultChannel();
|
||||
|
||||
@@ -144,6 +153,9 @@ extern Channels channels;
|
||||
static const uint8_t defaultpsk[] = {0xd4, 0xf1, 0xbb, 0x3a, 0x20, 0x29, 0x07, 0x59,
|
||||
0xf0, 0xbc, 0xff, 0xab, 0xcf, 0x4e, 0x69, 0x01};
|
||||
|
||||
/// True if a getKey()-resolved key offers no privacy: length 0 (off) or the public defaultpsk family. Pure; for tests.
|
||||
bool cryptoKeyIsPublic(const CryptoKey &key);
|
||||
|
||||
static const uint8_t eventpsk[] = {0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36,
|
||||
0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74,
|
||||
0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1};
|
||||
+108
-3
@@ -12,10 +12,18 @@
|
||||
#include <Curve25519.h>
|
||||
#include <RNG.h>
|
||||
#include <SHA256.h>
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
|
||||
#if !defined(ARCH_STM32WL)
|
||||
#define CryptRNG RNG
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
#include "XEdDSA.h"
|
||||
#include <Ed25519.h>
|
||||
|
||||
#ifndef NUM_LIMBS_256BIT
|
||||
#define NUM_LIMBS_BITS(n) (((n) + sizeof(limb_t) * 8 - 1) / (8 * sizeof(limb_t)))
|
||||
#define NUM_LIMBS_256BIT NUM_LIMBS_BITS(256)
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN)
|
||||
|
||||
/**
|
||||
* Create a public/private key pair with Curve25519.
|
||||
@@ -46,6 +54,9 @@ void CryptoEngine::generateKeyPair(uint8_t *pubKey, uint8_t *privKey)
|
||||
Curve25519::dh1(public_key, private_key);
|
||||
memcpy(pubKey, public_key, sizeof(public_key));
|
||||
memcpy(privKey, private_key, sizeof(private_key));
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key);
|
||||
#endif
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +76,9 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
|
||||
}
|
||||
memcpy(private_key, privKey, sizeof(private_key));
|
||||
memcpy(public_key, pubKey, sizeof(public_key));
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
XEdDSA::priv_curve_to_ed_keys(private_key, xeddsa_private_key, xeddsa_public_key);
|
||||
#endif
|
||||
} else {
|
||||
LOG_WARN("X25519 key generation failed due to blank private key");
|
||||
return false;
|
||||
@@ -72,6 +86,97 @@ bool CryptoEngine::regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey)
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
/**
|
||||
* Build a signing buffer that covers packet metadata and payload:
|
||||
* [fromNode(4) | packetId(4) | portnum(4) | payload(N)]
|
||||
* This prevents replay, reattribution, and portnum redirection attacks.
|
||||
*/
|
||||
static size_t buildSigningBuffer(uint8_t *buf, size_t bufSize, uint32_t fromNode, uint32_t packetId, uint32_t portnum,
|
||||
const uint8_t *payload, size_t payloadLen)
|
||||
{
|
||||
const size_t headerLen = sizeof(uint32_t) * 3;
|
||||
size_t totalLen = headerLen + payloadLen;
|
||||
if (totalLen > bufSize)
|
||||
return 0;
|
||||
// May need endian conversion for oddball platforms.
|
||||
memcpy(buf, &fromNode, sizeof(uint32_t));
|
||||
memcpy(buf + sizeof(uint32_t), &packetId, sizeof(uint32_t));
|
||||
memcpy(buf + sizeof(uint32_t) * 2, &portnum, sizeof(uint32_t));
|
||||
memcpy(buf + headerLen, payload, payloadLen);
|
||||
return totalLen;
|
||||
}
|
||||
|
||||
bool CryptoEngine::xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen,
|
||||
uint8_t *signature)
|
||||
{
|
||||
if (memfll(xeddsa_private_key, 0, sizeof(xeddsa_private_key)))
|
||||
return false;
|
||||
uint8_t sigBuf[MAX_BLOCKSIZE];
|
||||
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
|
||||
if (sigLen == 0)
|
||||
return false;
|
||||
// the XEdDSA::sign function requires at least the first 32 bytes of signature to be pre-filled with randomness
|
||||
HardwareRNG::fill(signature, 32);
|
||||
XEdDSA::sign(signature, xeddsa_private_key, xeddsa_public_key, sigBuf, sigLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool CryptoEngine::xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum,
|
||||
const uint8_t *payload, size_t payloadLen, const uint8_t *signature)
|
||||
{
|
||||
// Use cached Ed25519 key if the Curve25519 key matches, avoiding expensive field inversion
|
||||
if (memcmp(pubKey, cached_curve_pubkey, 32) != 0) {
|
||||
curve_to_ed_pub(pubKey, cached_ed_pubkey);
|
||||
memcpy(cached_curve_pubkey, pubKey, 32);
|
||||
}
|
||||
uint8_t sigBuf[MAX_BLOCKSIZE];
|
||||
size_t sigLen = buildSigningBuffer(sigBuf, sizeof(sigBuf), fromNode, packetId, portnum, payload, payloadLen);
|
||||
if (sigLen == 0)
|
||||
return false;
|
||||
return XEdDSA::verify(signature, cached_ed_pubkey, sigBuf, sigLen);
|
||||
}
|
||||
|
||||
void CryptoEngine::curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey)
|
||||
{
|
||||
|
||||
// Apply the birational map defined in RFC 7748, section 4.1 "Curve25519" to calculate an Ed25519 public
|
||||
// key from a Curve25519 public key. Because the serialization format of Curve25519 public keys only
|
||||
// contains the u coordinate, the x coordinate of the corresponding Ed25519 public key can't be uniquely
|
||||
// calculated as defined by the birational map. The x coordinate is represented in the serialization
|
||||
// format of Ed25519 public keys only in a single sign bit. XEdDSA always normalizes the Ed25519 public
|
||||
// key to a sign bit of zero (the signer negates its key pair when needed), so this function clears the
|
||||
// sign bit unconditionally below instead of taking it as an input.
|
||||
fe u, y;
|
||||
fe one;
|
||||
fe u_minus_one, u_plus_one, u_plus_one_inv;
|
||||
|
||||
// Parse the Curve25519 public key input as a field element containing the u coordinate. RFC 7748,
|
||||
// section 5 "The X25519 and X448 Functions", mandates that the most significant bit of the Curve25519
|
||||
// public key has to be zeroized. This is handled by fe_frombytes internally.
|
||||
fe_frombytes(u, curve_pubkey);
|
||||
|
||||
// Calculate the parameters (u - 1) and (u + 1)
|
||||
fe_1(one);
|
||||
fe_sub(u_minus_one, u, one);
|
||||
fe_add(u_plus_one, u, one);
|
||||
|
||||
// Invert u + 1
|
||||
fe_invert(u_plus_one_inv, u_plus_one);
|
||||
|
||||
// Calculate y = (u - 1) * inv(u + 1) (mod p)
|
||||
fe_mul(y, u_minus_one, u_plus_one_inv);
|
||||
|
||||
// Serialize the field element containing the y coordinate to the Ed25519 public key output
|
||||
fe_tobytes(ed_pubkey, y);
|
||||
|
||||
// Set the sign bit to zero
|
||||
ed_pubkey[31] &= 0x7f;
|
||||
|
||||
// need to convert the pubkey y = ( u - 1) * inv( u + 1) (mod p).
|
||||
}
|
||||
#endif
|
||||
|
||||
bool CryptoEngine::ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user)
|
||||
{
|
||||
if (user.is_licensed) {
|
||||
|
||||
+15
-1
@@ -23,6 +23,7 @@ struct CryptoKey {
|
||||
|
||||
#define MAX_BLOCKSIZE 256
|
||||
#define TEST_CURVE25519_FIELD_OPS // Exposes Curve25519::isWeakPoint() for testing keys
|
||||
#define XEDDSA_SIGNATURE_SIZE 64
|
||||
|
||||
class CryptoEngine
|
||||
{
|
||||
@@ -37,7 +38,12 @@ class CryptoEngine
|
||||
virtual void generateKeyPair(uint8_t *pubKey, uint8_t *privKey);
|
||||
virtual bool regeneratePublicKey(uint8_t *pubKey, uint8_t *privKey);
|
||||
virtual bool ensurePkiKeys(meshtastic_Config_SecurityConfig &security, meshtastic_User &user);
|
||||
|
||||
#endif
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
bool xeddsa_sign(uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload, size_t payloadLen,
|
||||
uint8_t *signature);
|
||||
bool xeddsa_verify(const uint8_t *pubKey, uint32_t fromNode, uint32_t packetId, uint32_t portnum, const uint8_t *payload,
|
||||
size_t payloadLen, const uint8_t *signature);
|
||||
#endif
|
||||
void setDHPrivateKey(uint8_t *_private_key);
|
||||
// The remotePublic key parameter takes the public_key bytes container from
|
||||
@@ -85,6 +91,14 @@ class CryptoEngine
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
uint8_t shared_key[32] = {0};
|
||||
uint8_t private_key[32] = {0};
|
||||
#if !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
uint8_t xeddsa_public_key[32] = {0};
|
||||
uint8_t xeddsa_private_key[32] = {0};
|
||||
void curve_to_ed_pub(const uint8_t *curve_pubkey, uint8_t *ed_pubkey);
|
||||
// Single-entry cache for curve_to_ed_pub conversion (avoids expensive field inversion per packet)
|
||||
uint8_t cached_curve_pubkey[32] = {0};
|
||||
uint8_t cached_ed_pubkey[32] = {0};
|
||||
#endif
|
||||
#endif
|
||||
/**
|
||||
* Init our 128 bit nonce for a new packet
|
||||
|
||||
+11
-2
@@ -18,6 +18,9 @@
|
||||
#define default_telemetry_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
|
||||
#define default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
|
||||
#define default_broadcast_smart_minimum_interval_secs 5 * 60
|
||||
// Floor for our own position broadcasts when stationary (unchanged beyond the broadcast
|
||||
// precision) or fixed_position: identical positions get deduped by traffic management anyway.
|
||||
#define default_position_stationary_broadcast_secs (12 * 60 * 60)
|
||||
#define min_default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60)
|
||||
#define min_default_broadcast_smart_minimum_interval_secs 5 * 60
|
||||
#define default_wait_bluetooth_secs IF_ROUTER(1, 60)
|
||||
@@ -34,8 +37,14 @@
|
||||
enum class TrafficType { POSITION, TELEMETRY };
|
||||
|
||||
// Traffic management defaults
|
||||
#define default_traffic_mgmt_position_precision_bits 24 // ~10m grid cells
|
||||
#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions
|
||||
#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m)
|
||||
#define default_traffic_mgmt_position_min_interval_secs (11 * 60 * 60) // 11 hours between identical positions
|
||||
// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 11h default).
|
||||
#define default_traffic_mgmt_tracker_position_min_interval_secs (60 * 60) // 1 hour
|
||||
// Role cap: lost-and-found origins may refresh a duplicate position this often, so a lost
|
||||
// device updates frequently without flooding. (Quantised to the dedup tick: ~2 ticks.)
|
||||
// Unlike before, lost-and-found is NOT exempt from the relayed precision clamp.
|
||||
#define default_traffic_mgmt_lost_and_found_position_min_interval_secs (15 * 60) // 15 minutes
|
||||
|
||||
// Hop scaling defaults
|
||||
#define default_hop_scaling_min_target_nodes 40 // walk threshold: first hop reaching this cumulative count
|
||||
|
||||
@@ -57,6 +57,11 @@ static void releaseSleepHolds()
|
||||
void LoRaFEMInterface::init(void)
|
||||
{
|
||||
setLnaCanControl(false); // Default is uncontrollable
|
||||
#if defined(RF_PA_DETECT_PIN)
|
||||
pinMode(RF_PA_DETECT_PIN, INPUT);
|
||||
high_power_pa = (digitalRead(RF_PA_DETECT_PIN) == RF_PA_HIGH_POWER_VALUE);
|
||||
LOG_INFO("Detected %s LoRa PA profile", high_power_pa ? "high-power" : "low-power");
|
||||
#endif
|
||||
#ifdef HELTEC_V4
|
||||
pinMode(LORA_PA_POWER, OUTPUT);
|
||||
digitalWrite(LORA_PA_POWER, HIGH);
|
||||
@@ -119,6 +124,13 @@ void LoRaFEMInterface::init(void)
|
||||
pinMode(LORA_KCT8103L_PA_CTX, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, LOW); // LNA enabled by default
|
||||
setLnaCanControl(true);
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
fem_type = KCT8103L_PA;
|
||||
pinMode(LORA_KCT8103L_EN, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_EN, HIGH);
|
||||
delay(1);
|
||||
pinMode(LORA_KCT8103L_TX_RX, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -148,6 +160,9 @@ void LoRaFEMInterface::setSleepModeEnable(void)
|
||||
// shutdown the PA
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, LOW);
|
||||
digitalWrite(LORA_PA_POWER, LOW);
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
// shutdown the PA
|
||||
digitalWrite(LORA_KCT8103L_EN, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -173,6 +188,9 @@ void LoRaFEMInterface::setTxModeEnable(void)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_PA_CSD, HIGH);
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_TX_RX, HIGH);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -206,6 +224,9 @@ void LoRaFEMInterface::setRxModeEnable(void)
|
||||
} else {
|
||||
digitalWrite(LORA_KCT8103L_PA_CTX, HIGH);
|
||||
}
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -247,6 +268,9 @@ void LoRaFEMInterface::setRxModeEnableWhenMCUSleep(void)
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CSD);
|
||||
rtc_gpio_hold_en((gpio_num_t)LORA_KCT8103L_PA_CTX);
|
||||
#endif
|
||||
#elif defined(USE_KCT8103L_PA_ONLY)
|
||||
enableFEMPower();
|
||||
digitalWrite(LORA_KCT8103L_TX_RX, LOW);
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -257,6 +281,11 @@ void LoRaFEMInterface::setLNAEnable(bool enabled)
|
||||
|
||||
int8_t LoRaFEMInterface::powerConversion(int8_t loraOutputPower)
|
||||
{
|
||||
#if defined(RF_PA_DETECT_PIN)
|
||||
if (!high_power_pa) {
|
||||
return loraOutputPower;
|
||||
}
|
||||
#endif
|
||||
#ifdef HELTEC_V4
|
||||
const uint16_t gc1109_tx_gain[] = {11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 11, 10, 10, 9, 9, 8, 7};
|
||||
const uint16_t kct8103l_tx_gain[] = {13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 13, 12, 12, 11, 11, 10, 9, 8, 7};
|
||||
|
||||
@@ -24,7 +24,8 @@ class LoRaFEMInterface
|
||||
LoRaFEMType fem_type;
|
||||
bool lna_enabled = true;
|
||||
bool lna_can_control = false;
|
||||
bool high_power_pa = true;
|
||||
};
|
||||
extern LoRaFEMInterface loraFEMInterface;
|
||||
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -88,6 +88,11 @@ extern const RegionInfo *myRegion;
|
||||
extern void initRegion();
|
||||
extern const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code);
|
||||
|
||||
// Fill `map` with the region->valid-preset table, grouped so regions sharing a
|
||||
// preset list reference the same group. Sent to clients during want_config so
|
||||
// their UI can block illegal region+preset combinations.
|
||||
extern void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map);
|
||||
|
||||
// Valid LoRa spread factor range and defaults
|
||||
constexpr uint8_t LORA_SF_MIN = 5;
|
||||
constexpr uint8_t LORA_SF_MAX = 12;
|
||||
|
||||
@@ -45,6 +45,11 @@ enum RxSource {
|
||||
// For old firmware there is no relay node set
|
||||
#define NO_RELAY_NODE 0
|
||||
|
||||
// How recently we must have heard a direct neighbor for its single-byte relay id to be trusted as a
|
||||
// unique next hop. Mirrors NUM_ONLINE_SECS (NodeDB.cpp). Used by NodeDB::resolveLastByte() to scope
|
||||
// last-byte collision resolution to currently-reachable neighbors.
|
||||
#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2) // 2 hrs
|
||||
|
||||
typedef int ErrorCode;
|
||||
|
||||
/// Alloc and free packets to our global, ISR safe pool
|
||||
|
||||
+203
-14
@@ -98,21 +98,38 @@ void NextHopRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtast
|
||||
// destination
|
||||
if (p->from != 0) {
|
||||
meshtastic_NodeInfoLite *origTx = nodeDB->getMeshNode(p->from);
|
||||
if (origTx) {
|
||||
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
|
||||
// directly from the destination
|
||||
// Single lookup for both relayer checks on the same (request_id, to) pair
|
||||
bool wasAlreadyRelayer = false;
|
||||
bool weWereSoleRelayer = false;
|
||||
bool weWereRelayer = false;
|
||||
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
|
||||
&weWereSoleRelayer);
|
||||
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
|
||||
if (origTx->next_hop != p->relay_node) { // Not already set
|
||||
// Either relayer of ACK was also a relayer of the packet, or we were the *only* relayer and the ACK came
|
||||
// directly from the destination. checkRelayers is read-only on PacketHistory and O(1), so we run it even
|
||||
// when origTx is absent — that lets us still capture the confirmed hop into the TMM overflow cache below.
|
||||
// Single lookup for both relayer checks on the same (request_id, to) pair
|
||||
bool wasAlreadyRelayer = false;
|
||||
bool weWereSoleRelayer = false;
|
||||
bool weWereRelayer = false;
|
||||
checkRelayers(p->relay_node, ourRelayID, p->decoded.request_id, p->to, &wasAlreadyRelayer, &weWereRelayer,
|
||||
&weWereSoleRelayer);
|
||||
if ((weWereRelayer && wasAlreadyRelayer) || (getHopsAway(*p) == 0 && weWereSoleRelayer)) {
|
||||
// M1/M2: only learn a next hop whose last byte maps to a single plausible relay. On a dense
|
||||
// mesh the byte may be ambiguous; storing it would aim future DMs at the wrong node. This gate
|
||||
// now protects BOTH the hot-store route (NodeInfoLite.next_hop) AND the TMM overflow cache —
|
||||
// the overflow cache deliberately holds many more next-hop bytes (long-tail nodes), so it is
|
||||
// even more collision-prone and must never store an ambiguous byte either. Ambiguous/unknown
|
||||
// -> store nothing and keep flooding (safe).
|
||||
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false)) {
|
||||
if (origTx && origTx->next_hop != p->relay_node) { // Not already set
|
||||
LOG_INFO("Update next hop of 0x%x to 0x%x based on ACK/reply (was relayer %d we were sole %d)", p->from,
|
||||
p->relay_node, wasAlreadyRelayer, weWereSoleRelayer);
|
||||
origTx->next_hop = p->relay_node;
|
||||
}
|
||||
noteRouteLearned(p->from, p->relay_node, millis()); // M3: anchor freshness (hot or overflow route)
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Mirror the confirmed (and now unique-resolved) hop into the TMM overflow cache so it
|
||||
// survives even when the source isn't (or is no longer) in the hot NodeDB.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->setNextHop(p->from, p->relay_node);
|
||||
#endif
|
||||
} else {
|
||||
LOG_DEBUG("Not learning next hop for 0x%x: relay byte 0x%x ambiguous/unknown; keep flooding", p->from,
|
||||
p->relay_node);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,6 +161,11 @@ bool NextHopRouter::perhapsRebroadcast(const meshtastic_MeshPacket *p)
|
||||
if (!isToUs(p) && !isFromUs(p) && (p->hop_limit > 0 || exhaustHops)) {
|
||||
if (p->id != 0) {
|
||||
if (isRebroadcaster()) {
|
||||
// NOTE: this is a self-identity match (is the addressed next_hop OUR last byte?), so it
|
||||
// cannot be hardened with resolveLastByte() — a remote node that legitimately shares our
|
||||
// last byte will also match here and rebroadcast. That residual collision needs a wider
|
||||
// on-wire field to fix. M1/M2 instead shrink the blast radius by reducing how often an
|
||||
// ambiguous next_hop byte is ever learned (sniffReceived) or originated (getNextHop).
|
||||
if (p->next_hop == NO_NEXT_HOP_PREFERENCE || p->next_hop == nodeDB->getLastByteOfNodeNum(getNodeNum())) {
|
||||
meshtastic_MeshPacket *tosend = packetPool.allocCopy(*p); // keep a copy because we will be sending it
|
||||
LOG_INFO("Rebroadcast received message coming from %x", p->relay_node);
|
||||
@@ -194,15 +216,63 @@ std::optional<uint8_t> NextHopRouter::getNextHop(NodeNum to, uint8_t relay_node)
|
||||
if (isBroadcast(to))
|
||||
return std::nullopt;
|
||||
|
||||
// Hot store first: a direct array hit on the live NodeDB entry.
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(to);
|
||||
if (node && node->next_hop) {
|
||||
// M3: proactively decay a stale or repeatedly-failing route back to flooding, so a dead hop
|
||||
// isn't trusted on the next DM's first (and on dense meshes, slowest) attempt. We only act on
|
||||
// a health record that still matches the stored byte; a next_hop set by another path (e.g.
|
||||
// TraceRouteModule) with no matching record is left authoritative.
|
||||
const RouteHealth *h = findRouteHealth(to);
|
||||
if (h && h->lastNextHop == node->next_hop && isRouteStale(*h, millis())) {
|
||||
LOG_INFO("Next hop 0x%x for 0x%x is stale (age/fails); flood and clear", node->next_hop, to);
|
||||
node->next_hop = NO_NEXT_HOP_PREFERENCE; // clear persisted route
|
||||
clearRouteHealth(to); // clear RAM health
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
// We are careful not to return the relay node as the next hop
|
||||
if (node->next_hop != relay_node) {
|
||||
// LOG_DEBUG("Next hop for 0x%x is 0x%x", to, node->next_hop);
|
||||
return node->next_hop;
|
||||
// M1/M2: only emit a stored next_hop if its last byte still maps to a UNIQUE, currently
|
||||
// reachable direct neighbor. On a dense mesh the last byte collides, so an ambiguous byte
|
||||
// would unicast a hint toward the wrong physical node; if the neighbor has gone away we'd
|
||||
// unicast into a void. In both cases flood instead (managed flooding still delivers).
|
||||
ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true);
|
||||
if (r.status == LastByteResolution::Unique)
|
||||
return node->next_hop;
|
||||
LOG_WARN("Next hop 0x%x for 0x%x %s; set no pref", node->next_hop, to,
|
||||
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
|
||||
} else
|
||||
LOG_WARN("Next hop for 0x%x is 0x%x, same as relayer; set no pref", to, node->next_hop);
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Fallback: TMM overflow cache holds confirmed hops for nodes that have aged out of the hot store.
|
||||
// It is the same byte source/confidence as NodeInfoLite.next_hop, so it gets the same M1/M2/M3
|
||||
// protection: decay a stale/failing route, then only emit a byte that still resolves to a unique
|
||||
// reachable neighbor. Without this the overflow cache (which holds MORE bytes for MORE nodes) would
|
||||
// reintroduce exactly the silent-misroute that M1/M2 closes on the hot path.
|
||||
if (trafficManagementModule) {
|
||||
uint8_t hint = trafficManagementModule->getNextHopHint(to);
|
||||
if (hint && hint != relay_node) {
|
||||
const RouteHealth *h = findRouteHealth(to);
|
||||
if (h && h->lastNextHop == hint && isRouteStale(*h, millis())) {
|
||||
LOG_INFO("TMM next hop 0x%x for 0x%x is stale (age/fails); flood and clear", hint, to);
|
||||
trafficManagementModule->clearNextHop(to); // clear overflow route (setNextHop won't store 0)
|
||||
clearRouteHealth(to); // clear RAM health
|
||||
return std::nullopt;
|
||||
}
|
||||
ResolvedNode r = nodeDB->resolveLastByte(hint, /*requireDirectNeighbor=*/true);
|
||||
if (r.status == LastByteResolution::Unique) {
|
||||
LOG_DEBUG("Next hop for 0x%x is 0x%x (TMM cache)", to, hint);
|
||||
return hint;
|
||||
}
|
||||
LOG_WARN("TMM next hop 0x%x for 0x%x %s; set no pref", hint, to,
|
||||
r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "not a known neighbor");
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
@@ -311,7 +381,10 @@ int32_t NextHopRouter::doRetransmissions()
|
||||
|
||||
if (!isBroadcast(p.packet->to)) {
|
||||
if (p.numRetransmissions == 1) {
|
||||
// Last retransmission, reset next_hop (fallback to FloodingRouter)
|
||||
// Last retransmission: this directed delivery went un-ACKed. Record the failure
|
||||
// (M3 — accumulates across DMs to age out a flapping/dead route) and reset
|
||||
// next_hop so the final try falls back to FloodingRouter.
|
||||
noteRouteFailure(p.packet->to);
|
||||
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
// Also reset it in the nodeDB
|
||||
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
|
||||
@@ -319,9 +392,32 @@ int32_t NextHopRouter::doRetransmissions()
|
||||
LOG_INFO("Resetting next hop for packet with dest 0x%x\n", p.packet->to);
|
||||
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
}
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
if (trafficManagementModule) {
|
||||
trafficManagementModule->clearNextHop(p.packet->to);
|
||||
}
|
||||
#endif
|
||||
FloodingRouter::send(packetPool.allocCopy(*p.packet));
|
||||
} else {
|
||||
#if NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
|
||||
// M4 (gated): if the route isn't proven healthy, don't spend a second directed
|
||||
// attempt — start flooding one retry sooner to cut recovery latency. A verified
|
||||
// route (fresh, zero recent failures) keeps the unchanged directed-retry path so
|
||||
// the sparse-mesh happy path is untouched.
|
||||
RouteHealth *h = findRouteHealth(p.packet->to);
|
||||
bool verified = h && h->consecutiveFailures == 0 && !isRouteStale(*h, now);
|
||||
if (!verified) {
|
||||
p.packet->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
meshtastic_NodeInfoLite *sentTo = nodeDB->getMeshNode(p.packet->to);
|
||||
if (sentTo)
|
||||
sentTo->next_hop = NO_NEXT_HOP_PREFERENCE;
|
||||
FloodingRouter::send(packetPool.allocCopy(*p.packet));
|
||||
} else {
|
||||
NextHopRouter::send(packetPool.allocCopy(*p.packet));
|
||||
}
|
||||
#else
|
||||
NextHopRouter::send(packetPool.allocCopy(*p.packet));
|
||||
#endif
|
||||
}
|
||||
} else {
|
||||
// Note: we call the superclass version because we don't want to have our version of send() add a new
|
||||
@@ -355,3 +451,96 @@ void NextHopRouter::setNextTx(PendingPacket *pending)
|
||||
printPacket("", pending->packet);
|
||||
setReceivedMessage(); // Run ASAP, so we can figure out our correct sleep time
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M3: RAM route-health table. Bounded array with reuse-oldest eviction (same discipline as
|
||||
// PacketHistory). All age comparisons use unsigned subtraction so they survive the 49.7-day millis()
|
||||
// rollover. dest == 0 marks an empty slot; learnedAtMsec is normalized to 1 on write so an occupied
|
||||
// slot is never read as infinitely old.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
RouteHealth *NextHopRouter::findRouteHealth(NodeNum dest)
|
||||
{
|
||||
if (dest == 0)
|
||||
return nullptr;
|
||||
for (auto &h : routeHealth)
|
||||
if (h.dest == dest)
|
||||
return &h;
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
RouteHealth *NextHopRouter::getOrAllocRouteHealth(NodeNum dest, uint32_t now)
|
||||
{
|
||||
if (dest == 0)
|
||||
return nullptr;
|
||||
|
||||
RouteHealth *oldest = &routeHealth[0];
|
||||
RouteHealth *freeSlot = nullptr;
|
||||
for (auto &h : routeHealth) {
|
||||
if (h.dest == dest)
|
||||
return &h; // existing record
|
||||
if (h.dest == 0) {
|
||||
if (!freeSlot)
|
||||
freeSlot = &h; // remember the first free slot; prefer it over evicting
|
||||
continue;
|
||||
}
|
||||
// Track the oldest occupied slot in case the table is full (rollover-safe).
|
||||
if ((uint32_t)(now - h.learnedAtMsec) > (uint32_t)(now - oldest->learnedAtMsec))
|
||||
oldest = &h;
|
||||
}
|
||||
// Claim the free slot if there is one, else reuse the oldest. Reset before use and stamp the dest
|
||||
// so the record is findable.
|
||||
RouteHealth *slot = freeSlot ? freeSlot : oldest;
|
||||
*slot = RouteHealth{};
|
||||
slot->dest = dest;
|
||||
return slot;
|
||||
}
|
||||
|
||||
void NextHopRouter::noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now)
|
||||
{
|
||||
if (dest == 0 || nextHop == NO_NEXT_HOP_PREFERENCE)
|
||||
return;
|
||||
RouteHealth *h = getOrAllocRouteHealth(dest, now);
|
||||
if (!h)
|
||||
return;
|
||||
// A genuinely new next hop earns a clean slate; re-learning the SAME hop keeps the accumulated
|
||||
// failure count so an asymmetric reverse path that keeps re-teaching a dead forward hop still ages
|
||||
// out instead of resetting the counter every time.
|
||||
if (h->lastNextHop != nextHop) {
|
||||
h->lastNextHop = nextHop;
|
||||
h->consecutiveFailures = 0;
|
||||
}
|
||||
h->learnedAtMsec = now ? now : 1;
|
||||
}
|
||||
|
||||
void NextHopRouter::noteRouteSuccess(NodeNum dest, uint32_t now)
|
||||
{
|
||||
RouteHealth *h = findRouteHealth(dest);
|
||||
if (!h)
|
||||
return; // only routes we actually learned have health to refresh
|
||||
h->consecutiveFailures = 0;
|
||||
h->learnedAtMsec = now ? now : 1;
|
||||
}
|
||||
|
||||
void NextHopRouter::noteRouteFailure(NodeNum dest)
|
||||
{
|
||||
RouteHealth *h = findRouteHealth(dest);
|
||||
if (!h)
|
||||
return; // nothing to penalize (we were flooding, or never learned a route here)
|
||||
if (h->consecutiveFailures < 255)
|
||||
h->consecutiveFailures++;
|
||||
}
|
||||
|
||||
bool NextHopRouter::isRouteStale(const RouteHealth &h, uint32_t now) const
|
||||
{
|
||||
if (h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD)
|
||||
return true;
|
||||
return (uint32_t)(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC;
|
||||
}
|
||||
|
||||
void NextHopRouter::clearRouteHealth(NodeNum dest)
|
||||
{
|
||||
RouteHealth *h = findRouteHealth(dest);
|
||||
if (h)
|
||||
*h = RouteHealth{};
|
||||
}
|
||||
|
||||
@@ -43,6 +43,28 @@ struct PendingPacket {
|
||||
explicit PendingPacket(meshtastic_MeshPacket *p, uint8_t numRetransmissions);
|
||||
};
|
||||
|
||||
/**
|
||||
* RAM-only per-destination route health. Tracks how fresh a learned next_hop is and how many
|
||||
* consecutive directed deliveries to it have failed, so getNextHop() can proactively decay a stale or
|
||||
* repeatedly-failing route back to flooding instead of trusting a dead hop on the next (and on dense
|
||||
* meshes, slowest) attempt. Not persisted: the learned next_hop itself lives in NodeInfoLite; this is
|
||||
* just freshness/failure metadata.
|
||||
*/
|
||||
struct RouteHealth {
|
||||
NodeNum dest = 0; ///< destination this record describes; 0 == empty slot
|
||||
uint32_t learnedAtMsec = 0; ///< millis() when next_hop was last (re)learned (rollover-aware)
|
||||
uint8_t consecutiveFailures = 0; ///< directed deliveries to `dest` that went un-ACKed
|
||||
uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; ///< the relay byte this health refers to
|
||||
};
|
||||
|
||||
// M4 (optional, off by default): when a route is not proven healthy, fall back to flooding one retry
|
||||
// earlier instead of spending a second directed attempt. Trades airtime for recovery latency on dense
|
||||
// meshes; leaves the sparse-mesh happy path (fresh, verified routes) unchanged. Measure on the
|
||||
// simulator before enabling broadly.
|
||||
#ifndef NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED
|
||||
#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 0
|
||||
#endif
|
||||
|
||||
class GlobalPacketIdHashFunction
|
||||
{
|
||||
public:
|
||||
@@ -92,12 +114,22 @@ class NextHopRouter : public FloodingRouter
|
||||
// The number of retransmissions the original sender will do
|
||||
constexpr static uint8_t NUM_RELIABLE_RETX = 3;
|
||||
|
||||
// M3: bounded RAM route-health table (reuse-oldest eviction, like PacketHistory)
|
||||
constexpr static uint8_t ROUTE_HEALTH_MAX = 32; // ~12B/slot -> ~384B
|
||||
constexpr static uint32_t ROUTE_TTL_MSEC = 30UL * 60 * 1000; // re-discover a route unconfirmed for 30 min
|
||||
constexpr static uint8_t ROUTE_FAILURE_THRESHOLD = 3; // consecutive un-ACKed directed deliveries -> dead
|
||||
|
||||
protected:
|
||||
/**
|
||||
* Pending retransmissions
|
||||
*/
|
||||
std::unordered_map<GlobalPacketId, PendingPacket, GlobalPacketIdHashFunction> pending;
|
||||
|
||||
/**
|
||||
* Per-destination route health (M3). Bounded array, reuse-oldest eviction. RAM-only.
|
||||
*/
|
||||
RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {};
|
||||
|
||||
/**
|
||||
* Should this incoming filter be dropped?
|
||||
*
|
||||
@@ -142,13 +174,38 @@ class NextHopRouter : public FloodingRouter
|
||||
|
||||
void setNextTx(PendingPacket *pending);
|
||||
|
||||
// --- M3 route-health helpers (RAM-only). Protected so ReliableRouter (a subclass) can record
|
||||
// delivery success, and so the unit-test shim can reach them via `using`. All take `now` where
|
||||
// time matters so the decay logic is pure and testable without a clock mock. ---
|
||||
|
||||
/// @return the health record for `dest`, or nullptr if we hold none.
|
||||
RouteHealth *findRouteHealth(NodeNum dest);
|
||||
/// @return an existing record for `dest`, else a freshly claimed slot (reuse-oldest on overflow).
|
||||
RouteHealth *getOrAllocRouteHealth(NodeNum dest, uint32_t now);
|
||||
/// Record that we (re)learned `nextHop` for `dest`. Resets the failure count only when the hop
|
||||
/// changed (so a flapping reverse-path re-learn of the same dead hop still ages out).
|
||||
void noteRouteLearned(NodeNum dest, uint8_t nextHop, uint32_t now);
|
||||
/// Record an end-to-end delivery success to `dest` (clears failures, refreshes freshness).
|
||||
void noteRouteSuccess(NodeNum dest, uint32_t now);
|
||||
/// Record that a directed delivery to `dest` went un-ACKed (no-op if we hold no record).
|
||||
void noteRouteFailure(NodeNum dest);
|
||||
/// @return true if the route is too old (TTL) or has failed too many times in a row.
|
||||
bool isRouteStale(const RouteHealth &h, uint32_t now) const;
|
||||
/// Forget any health record for `dest`.
|
||||
void clearRouteHealth(NodeNum dest);
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
public: // expose getNextHop to the test shim without widening production visibility
|
||||
#else
|
||||
private:
|
||||
#endif
|
||||
/**
|
||||
* Get the next hop for a destination, given the relay node
|
||||
* @return the node number of the next hop, 0 if no preference (fallback to FloodingRouter)
|
||||
*/
|
||||
std::optional<uint8_t> getNextHop(NodeNum to, uint8_t relay_node);
|
||||
|
||||
private:
|
||||
/** Check if we should be rebroadcasting this packet if so, do so.
|
||||
* @return true if we did rebroadcast */
|
||||
bool perhapsRebroadcast(const meshtastic_MeshPacket *p) override;
|
||||
|
||||
+711
-54
File diff suppressed because it is too large
Load Diff
+126
-10
@@ -11,6 +11,7 @@
|
||||
|
||||
#include "MeshTypes.h"
|
||||
#include "NodeStatus.h"
|
||||
#include "WarmNodeStore.h"
|
||||
#include "concurrency/Lock.h"
|
||||
#include "configuration.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
@@ -114,6 +115,20 @@ uint32_t sinceLastSeen(const meshtastic_NodeInfoLite *n);
|
||||
/// Given a packet, return how many seconds in the past (vs now) it was received
|
||||
uint32_t sinceReceived(const meshtastic_MeshPacket *p);
|
||||
|
||||
/// Outcome of mapping a single on-wire last-byte (next_hop / relay_node) back to a full NodeNum.
|
||||
/// Because the wire only carries the last byte of a 32-bit node number, the mapping is ambiguous on
|
||||
/// dense meshes (the "birthday problem"). Callers must treat Ambiguous and None as "don't trust it".
|
||||
enum class LastByteResolution : uint8_t {
|
||||
None, ///< no relevant candidate node has this last byte
|
||||
Unique, ///< exactly one relevant candidate -> `num` is valid
|
||||
Ambiguous, ///< two or more relevant candidates collide on this byte
|
||||
};
|
||||
|
||||
struct ResolvedNode {
|
||||
LastByteResolution status = LastByteResolution::None;
|
||||
NodeNum num = 0; ///< valid only when status == Unique
|
||||
};
|
||||
|
||||
/// Given a packet, return the number of hops used to reach this node.
|
||||
/// Returns defaultIfUnknown if the number of hops couldn't be determined.
|
||||
int8_t getHopsAway(const meshtastic_MeshPacket &p, int8_t defaultIfUnknown = -1);
|
||||
@@ -226,9 +241,25 @@ class NodeDB
|
||||
bool updateUser(uint32_t nodeId, meshtastic_User &p, uint8_t channelIndex = 0);
|
||||
|
||||
/*
|
||||
* Sets a node either favorite or unfavorite
|
||||
* Sets a node either favorite or unfavorite. Returns true if the node ends
|
||||
* up in the requested state; false if the node is unknown or favouriting
|
||||
* was refused by the protected-node cap (MAX_NUM_NODES - 2).
|
||||
*/
|
||||
void set_favorite(bool is_favorite, uint32_t nodeId);
|
||||
bool set_favorite(bool is_favorite, uint32_t nodeId);
|
||||
|
||||
/// Count of eviction-protected (favourite/ignored/manually-verified) nodes.
|
||||
int numProtectedNodes() const;
|
||||
|
||||
/// printf-style warning emitted when setProtectedFlag() refuses a node at
|
||||
/// the cap. %s = verb (favorite/ignore), 0x%08x = node, %d = cap. Shared by
|
||||
/// LOG_WARN here and AdminModule::sendWarning so the wording stays in sync.
|
||||
static constexpr const char *PROTECTED_CAP_WARN_FMT = "Can't %s 0x%08x: protected-node limit (%d) reached";
|
||||
|
||||
/// Turn an eviction-protection flag (favourite/ignored/verified) on/off. Off
|
||||
/// always succeeds; on returns false (no change) once the protected set hits
|
||||
/// the cap (MAX_NUM_NODES-2), keeping >=2 always-evictable slots. Callers
|
||||
/// surface the refusal to the user.
|
||||
bool setProtectedFlag(meshtastic_NodeInfoLite *node, uint32_t mask, bool on);
|
||||
|
||||
/*
|
||||
* Returns true if the node is in the NodeDB and marked as favorite
|
||||
@@ -295,6 +326,46 @@ class NodeDB
|
||||
|
||||
virtual meshtastic_NodeInfoLite *getMeshNode(NodeNum n);
|
||||
size_t getNumMeshNodes() { return numMeshNodes; }
|
||||
/// Find a node in our DB, create an empty NodeInfoLite if missing (evicting
|
||||
/// the oldest non-protected node when full). Public so admin handlers can
|
||||
/// register a node we have not heard from yet (e.g. to block it by ID).
|
||||
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
// Warm ("long-tail") tier: minimal {num, last_heard, public_key} records
|
||||
// for nodes evicted from the hot store. See WarmNodeStore.h.
|
||||
WarmNodeStore warmStore;
|
||||
#endif
|
||||
|
||||
/// Copy the 32-byte public key for node n — hot store first, then the warm
|
||||
/// tier. Returns false if we don't know a key for n.
|
||||
bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out);
|
||||
|
||||
/// Resolve a node's device role — hot store (with user) first, then the role
|
||||
/// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for
|
||||
/// nodes that have aged out of the hot store.
|
||||
meshtastic_Config_DeviceConfig_Role getNodeRole(NodeNum n);
|
||||
|
||||
/// last_heard of a hot-store node, or 0 if absent. Plain scan of meshNodes
|
||||
/// with no allocation side effects (unlike getOrCreateMeshNode).
|
||||
uint32_t hotNodeLastHeard(NodeNum n) const;
|
||||
|
||||
/**
|
||||
* Resolve a single on-wire last-byte (e.g. next_hop / relay_node) back to a unique full NodeNum,
|
||||
* detecting last-byte collisions instead of silently picking the first match. A 1-byte id only
|
||||
* needs to be unique among a node's plausible relays, not the whole mesh, so we scope the search:
|
||||
* - requireDirectNeighbor == true : candidates are direct neighbors (hops_away==0) heard within
|
||||
* NEXTHOP_NEIGHBOR_FRESH_SECS. Use on the SEND path.
|
||||
* - requireDirectNeighbor == false : also accept favorites and router-role nodes (unknown hop
|
||||
* distance allowed). Use when learning / preserving hops.
|
||||
* Ignored nodes, our own node, and the broadcast/0 sentinels are never candidates. On a tie the
|
||||
* result is Ambiguous (no tie-break) so callers fall back to flooding rather than misroute.
|
||||
*/
|
||||
ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor);
|
||||
|
||||
/// Convenience wrapper around resolveLastByte(): true iff exactly one relevant candidate matches.
|
||||
/// Ambiguous and None both return false (the safe answer for learning / hop preservation).
|
||||
bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr);
|
||||
|
||||
// Thread-safe satellite-map accessors. Return false if absent or the
|
||||
// corresponding DB is compiled out.
|
||||
@@ -341,11 +412,15 @@ class NodeDB
|
||||
emptyNodeDatabase.version = DEVICESTATE_CUR_VER;
|
||||
size_t nodeDatabaseSize;
|
||||
pb_get_encoded_size(&nodeDatabaseSize, meshtastic_NodeDatabase_fields, &emptyNodeDatabase);
|
||||
// Always include satellite slots so backups from higher-cap peers
|
||||
// decode without truncation, even when our build excludes the DBs.
|
||||
return nodeDatabaseSize + (MAX_NUM_NODES * meshtastic_NodeInfoLite_size) +
|
||||
(MAX_NUM_NODES * meshtastic_NodePositionEntry_size) + (MAX_NUM_NODES * meshtastic_NodeTelemetryEntry_size) +
|
||||
(MAX_NUM_NODES * meshtastic_NodeEnvironmentEntry_size) + (MAX_NUM_NODES * meshtastic_NodeStatusEntry_size);
|
||||
// Decode-stream size ceiling only — no buffer this big is allocated (load
|
||||
// streams from the file). Sized for the largest file any prior firmware
|
||||
// could write (250-node ESP32-S3, satellites uncapped) so capacity
|
||||
// downgrades / peer backups still decode; excess is trimmed after load.
|
||||
// (not constexpr: portduino resolves MAX_NUM_NODES from runtime config)
|
||||
const size_t loadCeiling = ((size_t)MAX_NUM_NODES > 250) ? (size_t)MAX_NUM_NODES : 250;
|
||||
return nodeDatabaseSize + (loadCeiling * meshtastic_NodeInfoLite_size) +
|
||||
(loadCeiling * meshtastic_NodePositionEntry_size) + (loadCeiling * meshtastic_NodeTelemetryEntry_size) +
|
||||
(loadCeiling * meshtastic_NodeEnvironmentEntry_size) + (loadCeiling * meshtastic_NodeStatusEntry_size);
|
||||
}
|
||||
|
||||
// returns true if the maximum number of nodes is reached or we are running low on memory
|
||||
@@ -376,6 +451,12 @@ class NodeDB
|
||||
bool checkLowEntropyPublicKey(const meshtastic_Config_SecurityConfig_public_key_t &keyToTest);
|
||||
#endif
|
||||
|
||||
/// Consolidate crypto key generation logic used across multiple modules
|
||||
/// @param privateKey Optional 32-byte private key to use. If nullptr, generates new random keys.
|
||||
bool generateCryptoKeyPair(const uint8_t *privateKey = nullptr);
|
||||
|
||||
bool createNewIdentity();
|
||||
|
||||
bool backupPreferences(meshtastic_AdminMessage_BackupLocation location);
|
||||
bool restorePreferences(meshtastic_AdminMessage_BackupLocation location,
|
||||
int restoreWhat = SEGMENT_CONFIG | SEGMENT_MODULECONFIG | SEGMENT_DEVICESTATE | SEGMENT_CHANNELS);
|
||||
@@ -424,11 +505,10 @@ class NodeDB
|
||||
mutable concurrency::Lock satelliteMutex;
|
||||
bool duplicateWarned = false;
|
||||
bool localPositionUpdatedSinceBoot = false;
|
||||
bool migrationSavePending = false;
|
||||
uint32_t lastNodeDbSave = 0; // when we last saved our db to flash
|
||||
uint32_t lastBackupAttempt = 0; // when we last tried a backup automatically or manually
|
||||
uint32_t lastSort = 0; // When last sorted the nodeDB
|
||||
/// Find a node in our DB, create an empty NodeInfoLite if missing
|
||||
meshtastic_NodeInfoLite *getOrCreateMeshNode(NodeNum n);
|
||||
|
||||
/*
|
||||
* Internal boolean to track sorting paused
|
||||
@@ -441,9 +521,33 @@ class NodeDB
|
||||
/// read our db from flash
|
||||
void loadFromDisk();
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
// Grant the unit-test shim access to the private maintenance paths below
|
||||
// (migration / cleanup / eviction) without relaxing production access.
|
||||
friend class NodeDBTestShim;
|
||||
#endif
|
||||
|
||||
/// purge db entries without user info
|
||||
void cleanupMeshDB();
|
||||
|
||||
/// Trim each satellite map down to MAX_SATELLITE_NODES, dropping the
|
||||
/// stalest entries (used after loading files written before the cap, or by
|
||||
/// a build with a larger cap). Returns true iff anything was trimmed.
|
||||
bool enforceSatelliteCaps();
|
||||
|
||||
/// Node-DB self-care; call only once identity is established (getNodeNum()
|
||||
/// valid). Confirms self is present, trims/demotes only NON-self overflow, and
|
||||
/// rewrites the store once when something changed (never while storage locked).
|
||||
void nodeDBSelfCare();
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
/// A database from a larger-cap build (e.g. the pre-fork 150-node nRF52 store)
|
||||
/// can exceed MAX_NUM_NODES on load. Rank the hot store, demote the oldest
|
||||
/// overflow into the warm tier preserving {num, last_heard, public_key} so PKI
|
||||
/// DMs survive instead of dropping on truncation.
|
||||
void demoteOldestHotNodesToWarm();
|
||||
#endif
|
||||
|
||||
/// Reinit device state from scratch (not loading from disk)
|
||||
void installDefaultDeviceState(), installDefaultNodeDatabase(), installDefaultChannels(),
|
||||
installDefaultConfig(bool preserveKey), installDefaultModuleConfig();
|
||||
@@ -519,7 +623,9 @@ extern uint32_t error_address;
|
||||
#define NODEINFO_BITFIELD_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_IS_UNMESSAGABLE_SHIFT)
|
||||
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT 8
|
||||
#define NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_MASK (1u << NODEINFO_BITFIELD_HAS_IS_UNMESSAGABLE_SHIFT)
|
||||
// Bits 9..31 reserved for future single-bit flags.
|
||||
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT 9
|
||||
#define NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK (1u << NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_SHIFT)
|
||||
// Bits 10..31 reserved for future single-bit flags.
|
||||
|
||||
// Convenience accessors so call sites read like the old struct fields.
|
||||
inline bool nodeInfoLiteHasUser(const meshtastic_NodeInfoLite *n)
|
||||
@@ -558,6 +664,16 @@ inline bool nodeInfoLiteIsKeyManuallyVerified(const meshtastic_NodeInfoLite *n)
|
||||
{
|
||||
return n && (n->bitfield & NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK);
|
||||
}
|
||||
inline bool nodeInfoLiteHasXeddsaSigned(const meshtastic_NodeInfoLite *n)
|
||||
{
|
||||
return n && (n->bitfield & NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK);
|
||||
}
|
||||
/// A node that the eviction/migration paths must not drop: a favourite, an
|
||||
/// ignored (blocked) node, or a manually-verified key.
|
||||
inline bool nodeInfoLiteIsProtected(const meshtastic_NodeInfoLite *n)
|
||||
{
|
||||
return nodeInfoLiteIsFavorite(n) || nodeInfoLiteIsIgnored(n) || nodeInfoLiteIsKeyManuallyVerified(n);
|
||||
}
|
||||
|
||||
inline void nodeInfoLiteSetBit(meshtastic_NodeInfoLite *n, uint32_t mask, bool value)
|
||||
{
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "configuration.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include "mesh/generated/meshtastic/deviceonly_legacy.pb.h"
|
||||
#include "meshUtils.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
@@ -88,8 +89,10 @@ bool NodeDB::migrateLegacyNodeDatabase()
|
||||
slim.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK;
|
||||
strncpy(slim.long_name, legacy.user.long_name, sizeof(slim.long_name));
|
||||
slim.long_name[sizeof(slim.long_name) - 1] = '\0';
|
||||
sanitizeUtf8(slim.long_name, sizeof(slim.long_name)); // replace bad bytes so nanopb encode never fails
|
||||
strncpy(slim.short_name, legacy.user.short_name, sizeof(slim.short_name));
|
||||
slim.short_name[sizeof(slim.short_name) - 1] = '\0';
|
||||
sanitizeUtf8(slim.short_name, sizeof(slim.short_name)); // same — v24 names may contain non-UTF-8 bytes
|
||||
slim.hw_model = legacy.user.hw_model;
|
||||
slim.role = legacy.user.role;
|
||||
if (legacy.user.is_licensed)
|
||||
|
||||
@@ -486,7 +486,11 @@ bool PacketHistory::wasRelayer(const uint8_t relayer, const uint32_t id, const N
|
||||
}
|
||||
|
||||
/* Check if a certain node was a relayer of a packet in the history given iterator
|
||||
* @return true if node was indeed a relayer, false if not */
|
||||
* @return true if node was indeed a relayer, false if not
|
||||
* NOTE: intentionally byte-domain. Both `relayer` and relayed_by[] are on-wire last bytes, so this
|
||||
* answers "did a relayer with this byte touch the packet" — correct without resolving to a NodeNum.
|
||||
* The collision risk is neutralized where the result is consumed (route learning in
|
||||
* NextHopRouter::sniffReceived now gates the write through NodeDB::resolveUniqueLastByte). */
|
||||
bool PacketHistory::wasRelayer(const uint8_t relayer, const PacketRecord &r, bool *wasSole)
|
||||
{
|
||||
bool found = false;
|
||||
|
||||
+18
-2
@@ -12,6 +12,7 @@
|
||||
#include "Channels.h"
|
||||
#include "Default.h"
|
||||
#include "FSCommon.h"
|
||||
#include "MeshRadio.h"
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PacketHistory.h"
|
||||
@@ -516,9 +517,10 @@ bool PhoneAPI::handleToRadio(const uint8_t *buf, size_t bufLength)
|
||||
STATE_SEND_UIDATA,
|
||||
STATE_SEND_OWN_NODEINFO,
|
||||
STATE_SEND_METADATA,
|
||||
STATE_SEND_CHANNELS
|
||||
STATE_SEND_REGION_PRESETS, // region -> valid modem presets (one message)
|
||||
STATE_SEND_CHANNELS,
|
||||
STATE_SEND_CONFIG,
|
||||
STATE_SEND_MODULE_CONFIG,
|
||||
STATE_SEND_MODULECONFIG,
|
||||
STATE_SEND_OTHER_NODEINFOS, // states progress in this order as the device sends to the client
|
||||
STATE_SEND_FILEMANIFEST,
|
||||
STATE_SEND_COMPLETE_ID,
|
||||
@@ -636,7 +638,20 @@ size_t PhoneAPI::getFromRadio(uint8_t *buf)
|
||||
memset(&fromRadioScratch.metadata, 0, sizeof(fromRadioScratch.metadata));
|
||||
}
|
||||
#endif
|
||||
state = STATE_SEND_REGION_PRESETS;
|
||||
break;
|
||||
|
||||
case STATE_SEND_REGION_PRESETS:
|
||||
// Tell the client which modem presets are legal in each region so its UI
|
||||
// can block illegal region+preset combinations. This is public RF /
|
||||
// regulatory information (region and modem_preset are already in the
|
||||
// unauthenticated LoRa whitelist below), so it is sent unconditionally —
|
||||
// even an unauthorized/locked-down client can render a correct picker.
|
||||
LOG_DEBUG("Send region preset map");
|
||||
fromRadioScratch.which_payload_variant = meshtastic_FromRadio_region_presets_tag;
|
||||
getRegionPresetMap(fromRadioScratch.region_presets);
|
||||
state = STATE_SEND_CHANNELS;
|
||||
config_state = 0; // STATE_SEND_CHANNELS indexes channels starting at 0
|
||||
break;
|
||||
|
||||
case STATE_SEND_CHANNELS:
|
||||
@@ -1517,6 +1532,7 @@ bool PhoneAPI::available()
|
||||
case STATE_SEND_CONFIG:
|
||||
case STATE_SEND_MODULECONFIG:
|
||||
case STATE_SEND_METADATA:
|
||||
case STATE_SEND_REGION_PRESETS:
|
||||
case STATE_SEND_OWN_NODEINFO:
|
||||
case STATE_SEND_FILEMANIFEST:
|
||||
case STATE_SEND_COMPLETE_ID:
|
||||
|
||||
@@ -46,6 +46,7 @@ class PhoneAPI
|
||||
STATE_SEND_MY_INFO, // send our my info record
|
||||
STATE_SEND_OWN_NODEINFO,
|
||||
STATE_SEND_METADATA,
|
||||
STATE_SEND_REGION_PRESETS, // Send the region->valid-preset map (one message)
|
||||
STATE_SEND_CHANNELS, // Send all channels
|
||||
STATE_SEND_CONFIG, // Replacement for the old Radioconfig
|
||||
STATE_SEND_MODULECONFIG, // Send Module specific config
|
||||
|
||||
@@ -16,11 +16,23 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel)
|
||||
|
||||
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
|
||||
{
|
||||
return getPositionPrecisionForChannel(channels.getByIndex(channelIndex));
|
||||
const meshtastic_Channel &ch = channels.getByIndex(channelIndex);
|
||||
if (ch.role == meshtastic_Channel_Role_DISABLED)
|
||||
return 0;
|
||||
uint32_t precision = getPositionPrecisionForChannel(ch);
|
||||
|
||||
// Never send a precise position on a publicly-decryptable channel (key check is gated on > ceiling).
|
||||
if (precision > MAX_POSITION_PRECISION_PUBLIC_KEY && channels.usesPublicKey(channelIndex)) {
|
||||
precision = MAX_POSITION_PRECISION_PUBLIC_KEY;
|
||||
}
|
||||
return precision;
|
||||
}
|
||||
|
||||
static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
{
|
||||
if (precision == 0 || precision >= 32)
|
||||
return coordinate;
|
||||
|
||||
uint32_t coordinateBits = static_cast<uint32_t>(coordinate);
|
||||
uint32_t truncated = coordinateBits & (UINT32_MAX << (32 - precision));
|
||||
|
||||
@@ -30,6 +42,11 @@ static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision)
|
||||
return static_cast<int32_t>(truncated);
|
||||
}
|
||||
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision)
|
||||
{
|
||||
return truncateCoordinate(coordinate, static_cast<uint32_t>(precision));
|
||||
}
|
||||
|
||||
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision)
|
||||
{
|
||||
if (precision == 0) {
|
||||
|
||||
@@ -4,8 +4,23 @@
|
||||
#include "meshtastic/mesh.pb.h"
|
||||
#include <stdint.h>
|
||||
|
||||
// Max precision on a publicly-decryptable channel. CCPA "precise geolocation" = within a ~564m (1,850ft) radius.
|
||||
// Precision is bit-truncation of latitude_i/longitude_i: the latitude cell stays ~constant in meters worldwide
|
||||
// (~700m at 15 bits), while only the longitude cell varies — widest at the equator, narrowing toward the poles.
|
||||
// 15 also matches the MQTT map-report public precision ceiling.
|
||||
#define MAX_POSITION_PRECISION_PUBLIC_KEY 15
|
||||
|
||||
// Configured precision as-is; does NOT apply the public-key clamp -- use the channelIndex overload for the on-wire value.
|
||||
uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel);
|
||||
|
||||
// Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable.
|
||||
uint32_t getPositionPrecisionForChannel(uint8_t channelIndex);
|
||||
|
||||
// Truncate a single latitude_i/longitude_i to `precision` significant bits, centered in the
|
||||
// resulting grid cell (stable under GPS jitter). precision 0 or >=32 returns the value unchanged.
|
||||
// The return is the coordinate (int32_t); the uint8_t overload only narrows the precision arg.
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint32_t precision);
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision);
|
||||
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision);
|
||||
bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision);
|
||||
bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex);
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#include "platform/portduino/PortduinoGlue.h"
|
||||
#include "platform/portduino/SerialHal.h"
|
||||
#include "platform/portduino/SimRadio.h"
|
||||
#include "platform/portduino/USBHal.h"
|
||||
#endif
|
||||
@@ -63,6 +64,8 @@ const RegionProfile PROFILE_HAM_20KHZ = {PRESETS_TINY, 0, 0.0022f, false, true,
|
||||
// Ham '100kHz' profile. 62.5kHz bandwidth coerced to 100kHz via padding.
|
||||
const RegionProfile PROFILE_HAM_100KHZ = {PRESETS_NARROW, 0, 0.01875f, false, true, 0, 1, 1};
|
||||
|
||||
Observable<uint32_t> RadioInterface::loraRxPacketObservable;
|
||||
|
||||
#define RDEF(name, freq_start, freq_end, duty_cycle, power_limit, frequency_switching, wide_lora, profile_ptr, default_preset, \
|
||||
override_slot) \
|
||||
{ \
|
||||
@@ -350,6 +353,9 @@ std::unique_ptr<RadioInterface> initLoRa()
|
||||
portduino_config.lora_spi_dev.c_str());
|
||||
if (portduino_config.lora_spi_dev == "ch341") {
|
||||
RadioLibHAL = ch341Hal;
|
||||
} else if (portduino_config.lora_spi_dev == "serial") {
|
||||
RadioLibHAL = new SerialHal(portduino_config.lora_serial_device, portduino_config.lora_serial_baud,
|
||||
(uint32_t)portduino_config.lora_serial_timeout_ms);
|
||||
} else {
|
||||
if (RadioLibHAL != nullptr) {
|
||||
delete RadioLibHAL;
|
||||
@@ -603,6 +609,62 @@ const RegionInfo *getRegion(meshtastic_Config_LoRaConfig_RegionCode code)
|
||||
return r;
|
||||
}
|
||||
|
||||
void getRegionPresetMap(meshtastic_LoRaRegionPresetMap &map)
|
||||
{
|
||||
map = meshtastic_LoRaRegionPresetMap_init_zero;
|
||||
|
||||
const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]);
|
||||
const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]);
|
||||
const size_t maxPresets = sizeof(map.groups[0].presets) / sizeof(map.groups[0].presets[0]);
|
||||
|
||||
// Coalesce regions that share an identical preset list into one group. Two
|
||||
// regions belong to the same group when they share the same RegionProfile
|
||||
// (which owns the preset list + licensing) AND the same default preset.
|
||||
// Keyed by profile pointer, not the preset-array pointer: PROFILE_NARROW and
|
||||
// PROFILE_HAM_100KHZ share PRESETS_NARROW but differ in licensedOnly.
|
||||
const RegionProfile *groupProfile[sizeof(map.groups) / sizeof(map.groups[0])] = {};
|
||||
|
||||
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) {
|
||||
// No room left to map any further region; once full we can't add more, so
|
||||
// log once and stop. An incomplete map means clients won't constrain the
|
||||
// omitted regions, so this must be discoverable rather than silent.
|
||||
if (map.region_groups_count >= maxRegions) {
|
||||
LOG_ERROR("Region preset map full at %u regions; remaining regions omitted", (unsigned)maxRegions);
|
||||
break;
|
||||
}
|
||||
|
||||
// Find the group this region belongs to, or create it.
|
||||
int gi = -1;
|
||||
for (pb_size_t g = 0; g < map.groups_count; g++) {
|
||||
if (groupProfile[g] == r->profile && map.groups[g].default_preset == r->getDefaultPreset()) {
|
||||
gi = g;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (gi < 0) {
|
||||
if (map.groups_count >= maxGroups) {
|
||||
// Out of group slots (should not happen for the current table). The
|
||||
// region can't be advertised; skip it but make the gap visible.
|
||||
LOG_ERROR("Region preset map out of group slots (%u); region %d omitted", (unsigned)maxGroups, r->code);
|
||||
continue;
|
||||
}
|
||||
gi = map.groups_count++;
|
||||
groupProfile[gi] = r->profile;
|
||||
meshtastic_LoRaPresetGroup &grp = map.groups[gi];
|
||||
grp.default_preset = r->getDefaultPreset();
|
||||
grp.licensed_only = r->profile->licensedOnly;
|
||||
grp.presets_count = 0;
|
||||
for (size_t i = 0; r->profile->presets[i] != MODEM_PRESET_END && grp.presets_count < maxPresets; i++)
|
||||
grp.presets[grp.presets_count++] = r->profile->presets[i];
|
||||
}
|
||||
|
||||
// Map this region to its group (capacity checked at the top of the loop).
|
||||
meshtastic_LoRaRegionPresets &rg = map.region_groups[map.region_groups_count++];
|
||||
rg.region = r->code;
|
||||
rg.group_index = (uint8_t)gi;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get duty cycle for current region. EU_866: 10% for routers, 2.5% for mobile.
|
||||
*/
|
||||
@@ -1365,4 +1427,4 @@ size_t RadioInterface::beginSending(meshtastic_MeshPacket *p)
|
||||
|
||||
sendingPacket = p;
|
||||
return p->encrypted.size + sizeof(PacketHeader);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,6 +128,9 @@ class RadioInterface
|
||||
|
||||
virtual ~RadioInterface() {}
|
||||
|
||||
/// Fires once per valid received LoRa packet (arg = sender NodeNum). Used e.g. to flash LED_LORA.
|
||||
static Observable<uint32_t> loraRxPacketObservable;
|
||||
|
||||
/**
|
||||
* Coerce LoRa config fields (bandwidth/spread_factor) derived from presets.
|
||||
* This is used during early bootstrapping so UIs that display these fields directly remain consistent.
|
||||
|
||||
@@ -614,6 +614,10 @@ void RadioLibInterface::handleReceiveInterrupt()
|
||||
|
||||
printPacket("Lora RX", mp);
|
||||
|
||||
#ifdef LED_LORA
|
||||
loraRxPacketObservable.notifyObservers(mp->from);
|
||||
#endif
|
||||
|
||||
airTime->logAirtime(RX_LOG, rxMsec);
|
||||
|
||||
deliverToReceiver(mp);
|
||||
|
||||
@@ -151,6 +151,10 @@ void ReliableRouter::sniffReceived(const meshtastic_MeshPacket *p, const meshtas
|
||||
LOG_DEBUG("Received a %s for 0x%x, stopping retransmissions", ackId ? "ACK" : "NAK", ackId);
|
||||
if (ackId) {
|
||||
stopRetransmission(p->to, ackId);
|
||||
// M3: an end-to-end ACK proves the directed route to the ACK's sender currently works,
|
||||
// so clear its failure count and refresh freshness (keeps a good route pinned).
|
||||
if (!isBroadcast(getFrom(p)))
|
||||
noteRouteSuccess(getFrom(p), millis());
|
||||
} else {
|
||||
stopRetransmission(p->to, nakId);
|
||||
}
|
||||
|
||||
+81
-53
@@ -100,51 +100,31 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p)
|
||||
return true;
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// When router_preserve_hops is enabled, preserve hops for decoded packets that are not
|
||||
// position or telemetry (those have their own exhaust_hop controls).
|
||||
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled &&
|
||||
moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag &&
|
||||
p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) {
|
||||
LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p));
|
||||
if (trafficManagementModule) {
|
||||
trafficManagementModule->recordRouterHopPreserved();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endif
|
||||
// router_preserve_hops: not suitable right now — removed from config until
|
||||
// the right heuristics for when to preserve vs. exhaust hops are established.
|
||||
// #if HAS_TRAFFIC_MANAGEMENT
|
||||
// if (moduleConfig.has_traffic_management &&
|
||||
// moduleConfig.traffic_management.router_preserve_hops && ...) { ... }
|
||||
// #endif
|
||||
|
||||
// For subsequent hops, check if previous relay is a favorite router
|
||||
// Optimized search for favorite routers with matching last byte
|
||||
// Check ordering optimized for IoT devices (cheapest checks first)
|
||||
for (size_t i = 0; i < nodeDB->getNumMeshNodes(); i++) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i);
|
||||
if (!node)
|
||||
continue;
|
||||
|
||||
// Check 1: is_favorite (cheapest - single bit test)
|
||||
if (!nodeInfoLiteIsFavorite(node))
|
||||
continue;
|
||||
|
||||
// Check 2: has_user (cheap - single bit test)
|
||||
if (!nodeInfoLiteHasUser(node))
|
||||
continue;
|
||||
|
||||
// Check 3: role check (moderate cost - multiple comparisons)
|
||||
if (!IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
|
||||
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check 4: last byte extraction and comparison (most expensive)
|
||||
if (nodeDB->getLastByteOfNodeNum(node->num) == p->relay_node) {
|
||||
// Found a favorite router match
|
||||
LOG_DEBUG("Identified favorite relay router 0x%x from last byte 0x%x", node->num, p->relay_node);
|
||||
// For subsequent hops, preserve hop_limit only when the previous relay is UNAMBIGUOUSLY a favorite
|
||||
// router. The relay_node byte is just the last byte of a 32-bit node number, so on a dense mesh it
|
||||
// collides; the old "first matching node wins" scan could preserve hops for the wrong node
|
||||
// (non-deterministic, depends on NodeDB order). resolveLastByte() reports a collision instead, and
|
||||
// we re-check the favorite/router predicate on the single resolved node. On ambiguity/none we
|
||||
// decrement (the safe default).
|
||||
NodeNum resolved = 0;
|
||||
if (nodeDB->resolveUniqueLastByte(p->relay_node, /*requireDirectNeighbor=*/false, &resolved)) {
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(resolved);
|
||||
if (node && nodeInfoLiteIsFavorite(node) && nodeInfoLiteHasUser(node) &&
|
||||
IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE,
|
||||
meshtastic_Config_DeviceConfig_Role_CLIENT_BASE)) {
|
||||
LOG_DEBUG("Identified unique favorite relay router 0x%x from last byte 0x%x", resolved, p->relay_node);
|
||||
return false; // Don't decrement hop_limit
|
||||
}
|
||||
}
|
||||
|
||||
// No favorite router match found, decrement hop_limit
|
||||
// No unambiguous favorite router match found, decrement hop_limit
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -485,14 +465,16 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
bool decrypted = false;
|
||||
ChannelIndex chIndex = 0;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// Attempt PKI decryption first
|
||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->getMeshNode(p->from) != nullptr &&
|
||||
nodeDB->getMeshNode(p->from)->public_key.size > 0 && nodeDB->getMeshNode(p->to) != nullptr &&
|
||||
nodeDB->getMeshNode(p->to)->public_key.size > 0 && rawSize > MESHTASTIC_PKC_OVERHEAD) {
|
||||
// Attempt PKI decryption first. The sender's key may come from the hot
|
||||
// store or the warm tier (nodes evicted from the hot store keep their key
|
||||
// there), so DMs from long-tail nodes still decrypt.
|
||||
meshtastic_NodeInfoLite_public_key_t fromKey = {0, {0}};
|
||||
if (p->channel == 0 && isToUs(p) && p->to > 0 && !isBroadcast(p->to) && nodeDB->copyPublicKey(p->from, fromKey) &&
|
||||
nodeDB->getMeshNode(p->to) != nullptr && nodeDB->getMeshNode(p->to)->public_key.size > 0 &&
|
||||
rawSize > MESHTASTIC_PKC_OVERHEAD) {
|
||||
LOG_DEBUG("Attempt PKI decryption");
|
||||
|
||||
if (crypto->decryptCurve25519(p->from, nodeDB->getMeshNode(p->from)->public_key, p->id, rawSize, p->encrypted.bytes,
|
||||
bytes)) {
|
||||
if (crypto->decryptCurve25519(p->from, fromKey, p->id, rawSize, p->encrypted.bytes, bytes)) {
|
||||
LOG_INFO("PKI Decryption worked!");
|
||||
|
||||
meshtastic_Data decodedtmp;
|
||||
@@ -503,7 +485,7 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
decrypted = true;
|
||||
LOG_INFO("Packet decrypted using PKI!");
|
||||
p->pki_encrypted = true;
|
||||
memcpy(&p->public_key.bytes, nodeDB->getMeshNode(p->from)->public_key.bytes, 32);
|
||||
memcpy(p->public_key.bytes, fromKey.bytes, 32);
|
||||
p->public_key.size = 32;
|
||||
p->decoded = decodedtmp;
|
||||
p->which_payload_variant = meshtastic_MeshPacket_decoded_tag; // change type to decoded
|
||||
@@ -559,6 +541,38 @@ DecodeState perhapsDecode(meshtastic_MeshPacket *p)
|
||||
if (p->decoded.has_bitfield)
|
||||
p->decoded.want_response |= p->decoded.bitfield & BITFIELD_WANT_RESPONSE_MASK;
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
if (p->decoded.xeddsa_signature.size == XEDDSA_SIGNATURE_SIZE) {
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && node->public_key.size == 32) {
|
||||
p->xeddsa_signed =
|
||||
crypto->xeddsa_verify(node->public_key.bytes, p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes,
|
||||
p->decoded.payload.size, p->decoded.xeddsa_signature.bytes);
|
||||
if (p->xeddsa_signed) {
|
||||
// Mark this node as a signer so future unsigned packets from it are rejected
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_HAS_XEDDSA_SIGNED_MASK, true);
|
||||
LOG_DEBUG("Verified XEdDSA signature from 0x%08x", p->from);
|
||||
} else {
|
||||
LOG_WARN("XEdDSA signature verification failed from 0x%08x, dropping", p->from);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
}
|
||||
} else {
|
||||
LOG_DEBUG("No public key for 0x%08x, cannot verify XEdDSA signature", p->from);
|
||||
}
|
||||
} else {
|
||||
// Unsigned packet — only reject the class of packet a signing node always signs:
|
||||
// an unencrypted broadcast small enough to also carry a signature (see perhapsEncode()).
|
||||
// Unicast packets and oversized broadcasts are never signed, so they must not be
|
||||
// hard-failed here even if this node has signed before.
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->from);
|
||||
if (node && nodeInfoLiteHasXeddsaSigned(node) && isBroadcast(p->to) &&
|
||||
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
LOG_WARN("Dropping unsigned broadcast from 0x%08x that previously signed", p->from);
|
||||
return DecodeState::DECODE_FAILURE;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
/* Not actually ever used.
|
||||
// Decompress if needed. jm
|
||||
if (p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_COMPRESSED_APP) {
|
||||
@@ -629,6 +643,18 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
p->decoded.has_bitfield = true;
|
||||
p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT);
|
||||
p->decoded.bitfield |= (p->decoded.want_response << BITFIELD_WANT_RESPONSE_SHIFT);
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI) && !(MESHTASTIC_EXCLUDE_XEDDSA)
|
||||
// Sign broadcast packets if payload + signature fits within the max Data payload.
|
||||
// The actual encoded size is checked after pb_encode (TOO_LARGE).
|
||||
if (!p->pki_encrypted && isBroadcast(p->to) &&
|
||||
p->decoded.payload.size + XEDDSA_SIGNATURE_SIZE < meshtastic_Constants_DATA_PAYLOAD_LEN) {
|
||||
if (crypto->xeddsa_sign(p->from, p->id, p->decoded.portnum, p->decoded.payload.bytes, p->decoded.payload.size,
|
||||
p->decoded.xeddsa_signature.bytes)) {
|
||||
p->decoded.xeddsa_signature.size = XEDDSA_SIGNATURE_SIZE;
|
||||
LOG_DEBUG("XEdDSA signed packet 0x%08x", p->id);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
size_t numbytes = pb_encode_to_bytes(bytes, sizeof(bytes), &meshtastic_Data_msg, &p->decoded);
|
||||
@@ -676,7 +702,10 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
ChannelIndex chIndex = p->channel; // keep as a local because we are about to change it
|
||||
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI)
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(p->to);
|
||||
// Destination key from the hot store or the warm tier (evicted
|
||||
// long-tail nodes keep their key there)
|
||||
meshtastic_NodeInfoLite_public_key_t destKey = {0, {0}};
|
||||
bool haveDestKey = nodeDB->copyPublicKey(p->to, destKey);
|
||||
// We may want to retool things so we can send a PKC packet when the client specifies a key and nodenum, even if the node
|
||||
// is not in the local nodedb
|
||||
// First, only PKC encrypt packets we are originating
|
||||
@@ -699,18 +728,17 @@ meshtastic_Routing_Error perhapsEncode(meshtastic_MeshPacket *p)
|
||||
if (numbytes + MESHTASTIC_HEADER_LENGTH + MESHTASTIC_PKC_OVERHEAD > MAX_LORA_PAYLOAD_LEN)
|
||||
return meshtastic_Routing_Error_TOO_LARGE;
|
||||
// Check for a known public key for the destination
|
||||
if (node == nullptr || node->public_key.size != 32) {
|
||||
if (!haveDestKey) {
|
||||
LOG_WARN("Unknown public key for destination node 0x%08x (portnum %d), refusing to send legacy DM", p->to,
|
||||
p->decoded.portnum);
|
||||
return meshtastic_Routing_Error_PKI_SEND_FAIL_PUBLIC_KEY;
|
||||
}
|
||||
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) &&
|
||||
memcmp(p->public_key.bytes, node->public_key.bytes, 32) != 0) {
|
||||
if (p->pki_encrypted && !memfll(p->public_key.bytes, 0, 32) && memcmp(p->public_key.bytes, destKey.bytes, 32) != 0) {
|
||||
LOG_WARN("Client public key differs from requested: 0x%02x, stored key begins 0x%02x", *p->public_key.bytes,
|
||||
*node->public_key.bytes);
|
||||
*destKey.bytes);
|
||||
return meshtastic_Routing_Error_PKI_FAILED;
|
||||
}
|
||||
crypto->encryptCurve25519(p->to, getFrom(p), node->public_key, p->id, numbytes, bytes, p->encrypted.bytes);
|
||||
crypto->encryptCurve25519(p->to, getFrom(p), destKey, p->id, numbytes, bytes, p->encrypted.bytes);
|
||||
numbytes += MESHTASTIC_PKC_OVERHEAD;
|
||||
p->channel = 0;
|
||||
p->pki_encrypted = true;
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
#include "mesh/SerialHalDevice.h"
|
||||
#include "NodeDB.h"
|
||||
#include "SPILock.h"
|
||||
#include "concurrency/Periodic.h"
|
||||
#include "configuration.h"
|
||||
#include "mesh/StreamAPI.h"
|
||||
#include "mesh/generated/meshtastic/config.pb.h"
|
||||
#include <Arduino.h>
|
||||
#include <SPI.h>
|
||||
#include <cstring>
|
||||
#include <stdint.h>
|
||||
|
||||
#if defined(ARCH_ESP32)
|
||||
#if defined(HW_SPI1_DEVICE)
|
||||
extern SPIClass SPI1;
|
||||
#endif
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr uint32_t SERIAL_PI_RISING = 1;
|
||||
constexpr uint32_t SERIAL_PI_FALLING = 2;
|
||||
constexpr uint32_t SERIAL_PI_INPUT = 0;
|
||||
constexpr uint32_t SERIAL_PI_OUTPUT = 1;
|
||||
constexpr size_t MAX_INTERRUPT_SLOTS = 8;
|
||||
constexpr int32_t INTERRUPT_POLL_MS = 5;
|
||||
|
||||
struct InterruptSlot {
|
||||
bool used = false;
|
||||
uint32_t pin = 0;
|
||||
uint32_t mode = 0;
|
||||
volatile bool pending = false;
|
||||
};
|
||||
|
||||
concurrency::Lock interruptMutex;
|
||||
InterruptSlot interruptSlots[MAX_INTERRUPT_SLOTS];
|
||||
StreamAPI *interruptStreamApi = nullptr;
|
||||
concurrency::Periodic *interruptEmitter = nullptr;
|
||||
|
||||
int findSlotByPinLocked(uint32_t pin)
|
||||
{
|
||||
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
|
||||
if (interruptSlots[i].used && interruptSlots[i].pin == pin) {
|
||||
return (int)i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
int allocateSlotLocked()
|
||||
{
|
||||
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
|
||||
if (!interruptSlots[i].used) {
|
||||
return (int)i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
#if defined(ARCH_PORTDUINO) || defined(ARCH_RP2040)
|
||||
PinStatus toInterruptMode(uint32_t serialMode)
|
||||
{
|
||||
if (serialMode == SERIAL_PI_RISING) {
|
||||
return PinStatus::RISING;
|
||||
}
|
||||
if (serialMode == SERIAL_PI_FALLING) {
|
||||
return PinStatus::FALLING;
|
||||
}
|
||||
return PinStatus::CHANGE;
|
||||
}
|
||||
#else
|
||||
int toInterruptMode(uint32_t serialMode)
|
||||
{
|
||||
if (serialMode == SERIAL_PI_RISING) {
|
||||
return RISING;
|
||||
}
|
||||
if (serialMode == SERIAL_PI_FALLING) {
|
||||
return FALLING;
|
||||
}
|
||||
return CHANGE;
|
||||
}
|
||||
#endif
|
||||
|
||||
int32_t pumpInterruptEvents();
|
||||
|
||||
void ensureInterruptEmitter()
|
||||
{
|
||||
if (!interruptEmitter) {
|
||||
interruptEmitter = new concurrency::Periodic("SerialHalIrqEmitter", pumpInterruptEvents);
|
||||
}
|
||||
}
|
||||
|
||||
void emitInterruptEvent(uint32_t pin, StreamAPI *streamApi)
|
||||
{
|
||||
if (streamApi == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
meshtastic_SerialHalResponse event = meshtastic_SerialHalResponse_init_zero;
|
||||
event.transaction_id = 0; // asynchronous interrupt notification
|
||||
event.result = meshtastic_SerialHalResponse_Result_OK;
|
||||
event.value = pin; // host-side SerialHal treats value as interrupt pin
|
||||
SerialHalDevice::emitResponse(event, streamApi);
|
||||
}
|
||||
|
||||
void markPendingBySlot(uint8_t slot)
|
||||
{
|
||||
if (slot < MAX_INTERRUPT_SLOTS && interruptSlots[slot].used) {
|
||||
interruptSlots[slot].pending = true;
|
||||
}
|
||||
}
|
||||
|
||||
void isr0()
|
||||
{
|
||||
markPendingBySlot(0);
|
||||
}
|
||||
void isr1()
|
||||
{
|
||||
markPendingBySlot(1);
|
||||
}
|
||||
void isr2()
|
||||
{
|
||||
markPendingBySlot(2);
|
||||
}
|
||||
void isr3()
|
||||
{
|
||||
markPendingBySlot(3);
|
||||
}
|
||||
void isr4()
|
||||
{
|
||||
markPendingBySlot(4);
|
||||
}
|
||||
void isr5()
|
||||
{
|
||||
markPendingBySlot(5);
|
||||
}
|
||||
void isr6()
|
||||
{
|
||||
markPendingBySlot(6);
|
||||
}
|
||||
void isr7()
|
||||
{
|
||||
markPendingBySlot(7);
|
||||
}
|
||||
|
||||
void (*const isrTable[MAX_INTERRUPT_SLOTS])() = {isr0, isr1, isr2, isr3, isr4, isr5, isr6, isr7};
|
||||
|
||||
int32_t pumpInterruptEvents()
|
||||
{
|
||||
uint32_t toEmit[MAX_INTERRUPT_SLOTS] = {0};
|
||||
size_t emitCount = 0;
|
||||
StreamAPI *streamApi = nullptr;
|
||||
|
||||
{
|
||||
concurrency::LockGuard lock(&interruptMutex);
|
||||
streamApi = interruptStreamApi;
|
||||
for (size_t i = 0; i < MAX_INTERRUPT_SLOTS; ++i) {
|
||||
if (interruptSlots[i].used && interruptSlots[i].pending) {
|
||||
interruptSlots[i].pending = false;
|
||||
toEmit[emitCount++] = interruptSlots[i].pin;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (size_t i = 0; i < emitCount; ++i) {
|
||||
emitInterruptEvent(toEmit[i], streamApi);
|
||||
}
|
||||
|
||||
return INTERRUPT_POLL_MS;
|
||||
}
|
||||
} // namespace
|
||||
|
||||
// Helper to safely set response result
|
||||
static inline void setResponseError(meshtastic_SerialHalResponse &response, meshtastic_SerialHalResponse_Result result,
|
||||
const char *error = nullptr)
|
||||
{
|
||||
response.result = result;
|
||||
if (error != nullptr) {
|
||||
snprintf(response.error, sizeof(response.error), "%s", error);
|
||||
}
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleCommand(const uint8_t *buf, size_t len, StreamAPI *streamApi)
|
||||
{
|
||||
if (buf == nullptr || streamApi == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate role - SerialHal commands only handled when config.lora.serial_hal_only
|
||||
if (!config.lora.serial_hal_only) {
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
|
||||
snprintf(response.error, sizeof(response.error), "SerialHal not enabled for this role");
|
||||
emitResponse(response, streamApi);
|
||||
return;
|
||||
}
|
||||
|
||||
// Decode the command
|
||||
meshtastic_SerialHalCommand cmd = meshtastic_SerialHalCommand_init_zero;
|
||||
if (!pb_decode_from_bytes(buf, len, &meshtastic_SerialHalCommand_msg, &cmd)) {
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
response.result = meshtastic_SerialHalResponse_Result_BAD_REQUEST;
|
||||
snprintf(response.error, sizeof(response.error), "Failed to decode SerialHalCommand");
|
||||
emitResponse(response, streamApi);
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize response with matching transaction_id
|
||||
meshtastic_SerialHalResponse response = meshtastic_SerialHalResponse_init_zero;
|
||||
response.transaction_id = cmd.transaction_id;
|
||||
response.result = meshtastic_SerialHalResponse_Result_OK;
|
||||
|
||||
// Dispatch to operation handler
|
||||
switch (cmd.type) {
|
||||
case meshtastic_SerialHalCommand_Type_PIN_MODE:
|
||||
handlePinMode(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_DIGITAL_WRITE:
|
||||
handleDigitalWrite(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_DIGITAL_READ:
|
||||
handleDigitalRead(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_ATTACH_INTERRUPT:
|
||||
handleAttachInterrupt(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_DETACH_INTERRUPT:
|
||||
handleDetachInterrupt(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_SPI_TRANSFER:
|
||||
handleSpiTransfer(cmd, response);
|
||||
break;
|
||||
case meshtastic_SerialHalCommand_Type_NOOP:
|
||||
// NOOP: just return OK
|
||||
break;
|
||||
default:
|
||||
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
|
||||
snprintf(response.error, sizeof(response.error), "Unknown SerialHal operation type");
|
||||
break;
|
||||
}
|
||||
|
||||
emitResponse(response, streamApi);
|
||||
}
|
||||
|
||||
void SerialHalDevice::handlePinMode(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: pinMode pin=%u mode=%u", cmd.pin, cmd.mode);
|
||||
if (cmd.mode == SERIAL_PI_INPUT) {
|
||||
pinMode((int)cmd.pin, INPUT);
|
||||
} else if (cmd.mode == SERIAL_PI_OUTPUT) {
|
||||
pinMode((int)cmd.pin, OUTPUT);
|
||||
} else {
|
||||
setResponseError(response, meshtastic_SerialHalResponse_Result_BAD_REQUEST, "Unsupported pin mode");
|
||||
}
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleDigitalWrite(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: digitalWrite pin=%u value=%u", cmd.pin, cmd.value);
|
||||
digitalWrite((int)cmd.pin, cmd.value ? HIGH : LOW);
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleDigitalRead(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: digitalRead pin=%u", cmd.pin);
|
||||
response.value = (uint32_t)digitalRead((int)cmd.pin);
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleAttachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: attachInterrupt pin=%u mode=%u", cmd.pin, cmd.mode);
|
||||
|
||||
ensureInterruptEmitter();
|
||||
|
||||
int slot = -1;
|
||||
{
|
||||
concurrency::LockGuard lock(&interruptMutex);
|
||||
slot = findSlotByPinLocked(cmd.pin);
|
||||
if (slot < 0) {
|
||||
slot = allocateSlotLocked();
|
||||
}
|
||||
|
||||
if (slot >= 0) {
|
||||
interruptSlots[slot].used = true;
|
||||
interruptSlots[slot].pin = cmd.pin;
|
||||
interruptSlots[slot].mode = cmd.mode;
|
||||
interruptSlots[slot].pending = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (slot < 0) {
|
||||
setResponseError(response, meshtastic_SerialHalResponse_Result_ERROR, "No interrupt slots available");
|
||||
return;
|
||||
}
|
||||
|
||||
::attachInterrupt((int)cmd.pin, isrTable[slot], toInterruptMode(cmd.mode));
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleDetachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
// LOG_DEBUG("SerialHalDevice: detachInterrupt pin=%u", cmd.pin);
|
||||
|
||||
::detachInterrupt((int)cmd.pin);
|
||||
|
||||
{
|
||||
concurrency::LockGuard lock(&interruptMutex);
|
||||
const int slot = findSlotByPinLocked(cmd.pin);
|
||||
if (slot >= 0) {
|
||||
interruptSlots[slot] = InterruptSlot{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void SerialHalDevice::handleSpiTransfer(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response)
|
||||
{
|
||||
if (cmd.data.size == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
#if !ARCH_PORTDUINO
|
||||
if (spiLock == nullptr) {
|
||||
setResponseError(response, meshtastic_SerialHalResponse_Result_ERROR, "SPI lock not initialized");
|
||||
return;
|
||||
}
|
||||
|
||||
#if defined(HW_SPI1_DEVICE)
|
||||
SPIClass &spiBus = SPI1;
|
||||
#else
|
||||
SPIClass &spiBus = SPI;
|
||||
#endif
|
||||
|
||||
response.data.size = cmd.data.size;
|
||||
|
||||
{
|
||||
concurrency::LockGuard guard(spiLock);
|
||||
spiBus.beginTransaction(SPISettings(4000000, MSBFIRST, SPI_MODE0));
|
||||
#ifdef ARCH_ESP32
|
||||
spiBus.transferBytes(cmd.data.bytes, response.data.bytes, cmd.data.size);
|
||||
#else
|
||||
spiBus.transfer(cmd.data.bytes, response.data.bytes, cmd.data.size);
|
||||
#endif
|
||||
spiBus.endTransaction();
|
||||
}
|
||||
#else
|
||||
// SPI wiring is board/radio-specific; keep this explicit for now.
|
||||
response.result = meshtastic_SerialHalResponse_Result_UNSUPPORTED;
|
||||
snprintf(response.error, sizeof(response.error), "SPI not supported on this platform");
|
||||
#endif
|
||||
}
|
||||
|
||||
void SerialHalDevice::emitResponse(const meshtastic_SerialHalResponse &response, StreamAPI *streamApi)
|
||||
{
|
||||
if (streamApi == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Encode the response
|
||||
uint8_t encoded[meshtastic_SerialHalResponse_size] = {0};
|
||||
const size_t responseLen =
|
||||
pb_encode_to_bytes(encoded, sizeof(encoded), &meshtastic_SerialHalResponse_msg, static_cast<const void *>(&response));
|
||||
|
||||
if (responseLen == 0 || responseLen > 0xFFFF) {
|
||||
LOG_ERROR("SerialHalDevice: Failed to encode response (len=%zu)", responseLen);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build frame with StreamAPI framing: START1 SERIALHAL_MAGIC LEN_H LEN_L [payload]
|
||||
constexpr uint8_t START1 = 0x94;
|
||||
constexpr uint8_t SERIALHAL_MAGIC = 0xA5;
|
||||
|
||||
uint8_t hdr[4];
|
||||
hdr[0] = START1;
|
||||
hdr[1] = SERIALHAL_MAGIC;
|
||||
hdr[2] = (uint8_t)((responseLen >> 8) & 0xFF); // LEN_H
|
||||
hdr[3] = (uint8_t)(responseLen & 0xFF); // LEN_L
|
||||
|
||||
// Emit via StreamAPI (this uses the internal txBuf + framing)
|
||||
streamApi->emitSerialHalResponse(hdr, sizeof(hdr), encoded, responseLen);
|
||||
|
||||
// Keep a recent stream instance so async interrupt events can be emitted.
|
||||
{
|
||||
concurrency::LockGuard lock(&interruptMutex);
|
||||
interruptStreamApi = streamApi;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
#pragma once
|
||||
|
||||
#include "mesh/generated/meshtastic/serial_hal.pb.h"
|
||||
#include <cstdint>
|
||||
|
||||
/**
|
||||
* @brief Device-side handler for SerialHal GPIO/SPI operations over StreamAPI framing.
|
||||
*
|
||||
* This module decodes SerialHalCommand protobufs received from a host and executes
|
||||
* the requested GPIO (pinMode, digitalWrite, digitalRead, attach/detachInterrupt) or
|
||||
* SPI operations, then returns results via SerialHalResponse.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Override StreamAPI::handleSerialHalCommand() in a subclass
|
||||
* 2. Call SerialHalDevice::handleCommand(buf, len, streamApi)
|
||||
* 3. SerialHalDevice will decode, execute, and emit the response
|
||||
*
|
||||
* The handler is only active when config.lora.serial_hal_only is true.
|
||||
*/
|
||||
|
||||
class StreamAPI; // forward declaration
|
||||
|
||||
class SerialHalDevice
|
||||
{
|
||||
public:
|
||||
/**
|
||||
* @brief Process a SerialHalCommand and emit a response.
|
||||
*
|
||||
* Decodes the protobuf, validates the operation, executes it on the device,
|
||||
* and writes the response back via the StreamAPI instance.
|
||||
*
|
||||
* @param buf Pointer to the encoded SerialHalCommand protobuf payload (not including framing)
|
||||
* @param len Length of the encoded payload
|
||||
* @param streamApi Pointer to the StreamAPI instance (used for emitting responses)
|
||||
*/
|
||||
static void handleCommand(const uint8_t *buf, size_t len, StreamAPI *streamApi);
|
||||
|
||||
/**
|
||||
* @brief Emit a SerialHalResponse back to the host via StreamAPI framing.
|
||||
*
|
||||
* Encodes the response protobuf and sends it with proper framing (START1 SERIALHAL_MAGIC LEN_H LEN_L payload).
|
||||
*
|
||||
* @param response The response to send
|
||||
* @param streamApi Pointer to the StreamAPI instance
|
||||
*/
|
||||
static void emitResponse(const meshtastic_SerialHalResponse &response, StreamAPI *streamApi);
|
||||
|
||||
private:
|
||||
/**
|
||||
* @brief Execute a GPIO pinMode operation.
|
||||
* @param cmd Decoded SerialHalCommand with PIN_MODE type
|
||||
* @param response Response object to fill with result
|
||||
*/
|
||||
static void handlePinMode(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute a GPIO digitalWrite operation.
|
||||
* @param cmd Decoded SerialHalCommand with DIGITAL_WRITE type
|
||||
* @param response Response object to fill with result
|
||||
*/
|
||||
static void handleDigitalWrite(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute a GPIO digitalRead operation.
|
||||
* @param cmd Decoded SerialHalCommand with DIGITAL_READ type
|
||||
* @param response Response object to fill with result (value field contains read result)
|
||||
*/
|
||||
static void handleDigitalRead(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute an attachInterrupt operation.
|
||||
* @param cmd Decoded SerialHalCommand with ATTACH_INTERRUPT type
|
||||
* @param response Response object to fill with result
|
||||
*/
|
||||
static void handleAttachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute a detachInterrupt operation.
|
||||
* @param cmd Decoded SerialHalCommand with DETACH_INTERRUPT type
|
||||
* @param response Response object to fill with result
|
||||
*/
|
||||
static void handleDetachInterrupt(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
|
||||
/**
|
||||
* @brief Execute an SPI transfer operation.
|
||||
* @param cmd Decoded SerialHalCommand with SPI_TRANSFER type and data to send
|
||||
* @param response Response object to fill with result (data field contains received bytes)
|
||||
*/
|
||||
static void handleSpiTransfer(const meshtastic_SerialHalCommand &cmd, meshtastic_SerialHalResponse &response);
|
||||
};
|
||||
+95
-17
@@ -1,12 +1,15 @@
|
||||
#include "StreamAPI.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RTC.h"
|
||||
#include "RedirectablePrint.h"
|
||||
#include "SerialHalDevice.h"
|
||||
#include "Throttle.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
#include "configuration.h"
|
||||
|
||||
#define START1 0x94
|
||||
#define START2 0xc3
|
||||
#define SERIALHAL_MAGIC 0xa5 // second framing byte for SerialHal frames (START1 SH_MAGIC LEN_H LEN_L PAYLOAD)
|
||||
#define HEADER_LEN 4
|
||||
|
||||
int32_t StreamAPI::runOncePart()
|
||||
@@ -79,18 +82,29 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen)
|
||||
if (ptr == 0) { // looking for START1
|
||||
if (c != START1)
|
||||
rxPtr = 0; // failed to find framing
|
||||
} else if (ptr == 1) { // looking for START2
|
||||
if (c != START2)
|
||||
rxPtr = 0; // failed to find framing
|
||||
} else if (ptr == 1) { // discriminate frame type on second byte
|
||||
if (c == START2) {
|
||||
rxIsSerialHal = false; // standard ToRadio frame
|
||||
serialHalRxActive.store(false);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
} else if (c == SERIALHAL_MAGIC) {
|
||||
rxIsSerialHal = true; // SerialHal command frame
|
||||
serialHalRxActive.store(true);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(true);
|
||||
} else {
|
||||
rxPtr = 0; // unrecognised second byte — not our frame
|
||||
serialHalRxActive.store(false);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
}
|
||||
} else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing
|
||||
uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing
|
||||
|
||||
// console->printf("len %d\n", len);
|
||||
|
||||
if (ptr == HEADER_LEN - 1) {
|
||||
// we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid
|
||||
// protobuf also)
|
||||
if (len > MAX_TO_FROM_RADIO_SIZE)
|
||||
// we _just_ finished our 4 byte header, validate length now
|
||||
uint32_t maxLen = rxIsSerialHal ? (uint32_t)meshtastic_SerialHalCommand_size : MAX_TO_FROM_RADIO_SIZE;
|
||||
if (len > maxLen)
|
||||
rxPtr = 0; // length is bogus, restart search for framing
|
||||
}
|
||||
|
||||
@@ -98,8 +112,16 @@ int32_t StreamAPI::handleRecStream(const char *buf, uint16_t bufLen)
|
||||
if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload?
|
||||
rxPtr = 0; // start over again on the next packet
|
||||
|
||||
// If we didn't just fail the packet and we now have the right # of bytes, parse it
|
||||
handleToRadio(rxBuf + HEADER_LEN, len);
|
||||
// Dispatch based on which frame type we identified at byte 1
|
||||
if (rxIsSerialHal)
|
||||
handleSerialHalCommand(rxBuf + HEADER_LEN, len);
|
||||
else
|
||||
handleToRadio(rxBuf + HEADER_LEN, len);
|
||||
|
||||
if (rxIsSerialHal)
|
||||
serialHalRxActive.store(false);
|
||||
if (rxIsSerialHal)
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +136,12 @@ int32_t StreamAPI::readStream()
|
||||
if (!stream->available()) {
|
||||
// Nothing available this time, if the computer has talked to us recently, poll often, otherwise let CPU sleep a long time
|
||||
bool recentRx = Throttle::isWithinTimespanMs(lastRxMsec, 2000);
|
||||
return recentRx ? 5 : 250;
|
||||
if (!recentRx)
|
||||
return 250; // Sleep a long time if we haven't heard from the computer in a while
|
||||
if (serialHalRxActive.load())
|
||||
return 0; // If we are in the middle of a SerialHal transaction, don't sleep at all because we want to be as
|
||||
// responsive as possible to incoming SerialHal bytes
|
||||
return 5; // Otherwise, poll frequently for new data
|
||||
} else {
|
||||
while (stream->available()) { // Currently we never want to block
|
||||
int cInt = stream->read();
|
||||
@@ -135,18 +162,30 @@ int32_t StreamAPI::readStream()
|
||||
if (ptr == 0) { // looking for START1
|
||||
if (c != START1)
|
||||
rxPtr = 0; // failed to find framing
|
||||
} else if (ptr == 1) { // looking for START2
|
||||
if (c != START2)
|
||||
rxPtr = 0; // failed to find framing
|
||||
} else if (ptr == 1) { // discriminate frame type on second byte
|
||||
if (c == START2) {
|
||||
rxIsSerialHal = false; // standard ToRadio frame
|
||||
serialHalRxActive.store(false);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
} else if (c == SERIALHAL_MAGIC) {
|
||||
rxIsSerialHal = true; // SerialHal command frame
|
||||
serialHalRxActive.store(true);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(true);
|
||||
LOG_WARN("StreamAPI: Detected SerialHal command frame");
|
||||
} else {
|
||||
rxPtr = 0; // unrecognised second byte — not our frame
|
||||
serialHalRxActive.store(false);
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
}
|
||||
} else if (ptr >= HEADER_LEN - 1) { // we have at least read our 4 byte framing
|
||||
uint32_t len = (rxBuf[2] << 8) + rxBuf[3]; // big endian 16 bit length follows framing
|
||||
|
||||
// console->printf("len %d\n", len);
|
||||
|
||||
if (ptr == HEADER_LEN - 1) {
|
||||
// we _just_ finished our 4 byte header, validate length now (note: a length of zero is a valid
|
||||
// protobuf also)
|
||||
if (len > MAX_TO_FROM_RADIO_SIZE)
|
||||
// we _just_ finished our 4 byte header, validate length now
|
||||
uint32_t maxLen = rxIsSerialHal ? (uint32_t)meshtastic_SerialHalCommand_size : MAX_TO_FROM_RADIO_SIZE;
|
||||
if (len > maxLen)
|
||||
rxPtr = 0; // length is bogus, restart search for framing
|
||||
}
|
||||
|
||||
@@ -154,8 +193,16 @@ int32_t StreamAPI::readStream()
|
||||
if (ptr + 1 >= len + HEADER_LEN) { // have we received all of the payload?
|
||||
rxPtr = 0; // start over again on the next packet
|
||||
|
||||
// If we didn't just fail the packet and we now have the right # of bytes, parse it
|
||||
handleToRadio(rxBuf + HEADER_LEN, len);
|
||||
// Dispatch based on which frame type we identified at byte 1
|
||||
if (rxIsSerialHal)
|
||||
handleSerialHalCommand(rxBuf + HEADER_LEN, len);
|
||||
else
|
||||
handleToRadio(rxBuf + HEADER_LEN, len);
|
||||
|
||||
if (rxIsSerialHal)
|
||||
serialHalRxActive.store(false);
|
||||
if (rxIsSerialHal)
|
||||
RedirectablePrint::setSerialHalLogSuppressed(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -199,6 +246,10 @@ void StreamAPI::emitRebooted()
|
||||
|
||||
void StreamAPI::emitLogRecord(meshtastic_LogRecord_Level level, const char *src, const char *format, va_list arg)
|
||||
{
|
||||
if (serialHalRxActive.load()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// IMPORTANT: do NOT touch `fromRadioScratch` or `txBuf` here — those
|
||||
// belong to the main packet-emission path and a LOG_ firing during
|
||||
// `writeStream()` would corrupt an in-flight encode. We keep a
|
||||
@@ -249,4 +300,31 @@ void StreamAPI::onConnectionChanged(bool connected)
|
||||
// received a packet in a while
|
||||
powerFSM.trigger(EVENT_SERIAL_DISCONNECTED);
|
||||
}
|
||||
}
|
||||
|
||||
void StreamAPI::handleSerialHalCommand(const uint8_t *buf, size_t len)
|
||||
{
|
||||
// Default implementation: dispatch to SerialHalDevice for GPIO/SPI handling
|
||||
SerialHalDevice::handleCommand(buf, len, this);
|
||||
}
|
||||
|
||||
void StreamAPI::emitSerialHalResponse(const uint8_t *hdr, size_t hdrLen, const uint8_t *payload, size_t payloadLen)
|
||||
{
|
||||
if (hdr == nullptr || hdrLen != 4 || payload == nullptr || payloadLen > meshtastic_SerialHalResponse_size) {
|
||||
LOG_ERROR("StreamAPI: Invalid SerialHal response parameters");
|
||||
return;
|
||||
}
|
||||
|
||||
// Build complete frame in a temporary buffer
|
||||
uint8_t frame[4 + meshtastic_SerialHalResponse_size];
|
||||
memcpy(frame, hdr, hdrLen);
|
||||
memcpy(frame + hdrLen, payload, payloadLen);
|
||||
|
||||
size_t totalLen = hdrLen + payloadLen;
|
||||
|
||||
// Serialize stream writes against other emit operations via streamLock
|
||||
concurrency::LockGuard guard(&streamLock);
|
||||
stream->write(frame, totalLen);
|
||||
stream->flush();
|
||||
LOG_WARN("StreamAPI: Emitted SerialHal response frame (len=%zu)", totalLen);
|
||||
}
|
||||
+26
-2
@@ -4,10 +4,15 @@
|
||||
#include "Stream.h"
|
||||
#include "concurrency/Lock.h"
|
||||
#include "concurrency/OSThread.h"
|
||||
#include "generated/meshtastic/serial_hal.pb.h"
|
||||
#include <atomic>
|
||||
#include <cstdarg>
|
||||
|
||||
// A To/FromRadio packet + our 32 bit header
|
||||
#define MAX_STREAM_BUF_SIZE (MAX_TO_FROM_RADIO_SIZE + sizeof(uint32_t))
|
||||
// Buffer sized for the larger of a full ToRadio/FromRadio payload or a full SerialHalCommand payload, plus header.
|
||||
#define MAX_STREAM_PAYLOAD_SIZE \
|
||||
(MAX_TO_FROM_RADIO_SIZE > (int)meshtastic_SerialHalCommand_size ? MAX_TO_FROM_RADIO_SIZE \
|
||||
: (int)meshtastic_SerialHalCommand_size)
|
||||
#define MAX_STREAM_BUF_SIZE (MAX_STREAM_PAYLOAD_SIZE + (int)sizeof(uint32_t))
|
||||
|
||||
/**
|
||||
* A version of our 'phone' API that talks over a Stream. So therefore well suited to use with serial links
|
||||
@@ -39,6 +44,8 @@ class StreamAPI : public PhoneAPI
|
||||
|
||||
uint8_t rxBuf[MAX_STREAM_BUF_SIZE] = {0};
|
||||
size_t rxPtr = 0;
|
||||
bool rxIsSerialHal = false; ///< true when the current in-progress frame is a SerialHal frame (START1 SH_MAGIC ...)
|
||||
std::atomic<bool> serialHalRxActive{false};
|
||||
|
||||
/// time of last rx, used, to slow down our polling if we haven't heard from anyone
|
||||
uint32_t lastRxMsec = 0;
|
||||
@@ -56,6 +63,17 @@ class StreamAPI : public PhoneAPI
|
||||
/// Check the current underlying physical link to see if the client is currently connected
|
||||
virtual bool checkIsConnected() override = 0;
|
||||
|
||||
/**
|
||||
* Emit a SerialHal response frame with proper framing (START1 SERIALHAL_MAGIC LEN_H LEN_L payload).
|
||||
* Called by SerialHalDevice to send responses back to the host.
|
||||
*
|
||||
* @param hdr 4-byte header (START1 SERIALHAL_MAGIC LEN_H LEN_L)
|
||||
* @param hdrLen Length of header (should be 4)
|
||||
* @param payload Encoded SerialHalResponse protobuf payload
|
||||
* @param payloadLen Length of payload
|
||||
*/
|
||||
void emitSerialHalResponse(const uint8_t *hdr, size_t hdrLen, const uint8_t *payload, size_t payloadLen);
|
||||
|
||||
private:
|
||||
/**
|
||||
* Read any rx chars from the link and call handleToRadio
|
||||
@@ -75,6 +93,12 @@ class StreamAPI : public PhoneAPI
|
||||
*/
|
||||
void emitRebooted();
|
||||
|
||||
/**
|
||||
* Called when a complete SerialHal-framed packet has been received.
|
||||
* Default implementation dispatches to SerialHalDevice for GPIO/SPI handling.
|
||||
*/
|
||||
virtual void handleSerialHalCommand(const uint8_t *buf, size_t len);
|
||||
|
||||
virtual void onConnectionChanged(bool connected) override;
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,6 +18,7 @@ meshtastic_NodeInfo TypeConversions::ConvertToNodeInfo(const meshtastic_NodeInfo
|
||||
info.is_ignored = nodeInfoLiteIsIgnored(lite);
|
||||
info.is_key_manually_verified = nodeInfoLiteIsKeyManuallyVerified(lite);
|
||||
info.is_muted = nodeInfoLiteIsMuted(lite);
|
||||
info.has_xeddsa_signed = nodeInfoLiteHasXeddsaSigned(lite);
|
||||
|
||||
if (lite->has_hops_away) {
|
||||
info.has_hops_away = true;
|
||||
|
||||
@@ -0,0 +1,617 @@
|
||||
#include "WarmNodeStore.h"
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
#include "FSCommon.h"
|
||||
#include "SPILock.h"
|
||||
#include "SafeFile.h"
|
||||
#include "configuration.h"
|
||||
#include "power/PowerHAL.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <vector>
|
||||
|
||||
#if defined(NRF52840_XXAA)
|
||||
#include "flash/flash_nrf5x.h"
|
||||
#define WARM_RING_MAGIC 0x324E5257u // "WRN2" — v2: last_heard low bits carry role + protected category
|
||||
#define WARM_RING_MAGIC_V1 0x474E5257u // "WRNG" — v1: last_heard was a plain timestamp.
|
||||
// v1 pages are still read on upgrade: we keep each record's identity + public key but
|
||||
// DISCARD its last_heard (the old timestamp would be misread as role/protected bits).
|
||||
// Records re-rank and re-learn their role on the next contact. Legacy pages convert to
|
||||
// v2 naturally as the ring rotates.
|
||||
// A tombstone is an entry record whose last_heard is all-ones — getTime()
|
||||
// (unix seconds) cannot reach 0xFFFFFFFF until 2106, and erased flash is
|
||||
// detected via num == 0xFFFFFFFF before last_heard is ever inspected.
|
||||
#define WARM_RING_TOMBSTONE 0xFFFFFFFFu
|
||||
#else
|
||||
// warm.dat layout: this header followed by count packed WarmNodeEntry records.
|
||||
struct WarmStoreHeader {
|
||||
uint32_t magic; // WARM_STORE_MAGIC
|
||||
uint32_t reserved; // 0; kept so the header stays 16 B
|
||||
uint16_t count; // entries persisted
|
||||
uint16_t entrySize; // sizeof(WarmNodeEntry), format guard
|
||||
uint32_t crc; // crc32 over count * entrySize bytes
|
||||
};
|
||||
static_assert(sizeof(WarmStoreHeader) == 16, "header layout is part of the persistence format");
|
||||
|
||||
#define WARM_STORE_MAGIC 0x324D5257u // "WRM2" — v2: last_heard low bits carry role + protected category
|
||||
#define WARM_STORE_MAGIC_V1 \
|
||||
0x314D5257u // "WRM1" — v1: last_heard was a plain timestamp. On upgrade we keep
|
||||
// identity + key but discard last_heard, then rewrite as v2.
|
||||
|
||||
#ifdef FSCom
|
||||
static const char *warmFileName = "/prefs/warm.dat";
|
||||
#endif
|
||||
#endif // NRF52840_XXAA
|
||||
|
||||
static inline bool keyIsSet(const uint8_t key[32])
|
||||
{
|
||||
for (int i = 0; i < 32; i++)
|
||||
if (key[i])
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
WarmNodeStore::WarmNodeStore()
|
||||
{
|
||||
#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM)
|
||||
entries = static_cast<WarmNodeEntry *>(ps_calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
|
||||
if (!entries) {
|
||||
LOG_WARN("WarmStore: PSRAM alloc failed, using heap");
|
||||
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
|
||||
}
|
||||
#else
|
||||
entries = static_cast<WarmNodeEntry *>(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry)));
|
||||
#endif
|
||||
#if defined(NRF52840_XXAA)
|
||||
memset(pageOf, kNoPage, sizeof(pageOf));
|
||||
#endif
|
||||
}
|
||||
|
||||
WarmNodeStore::~WarmNodeStore()
|
||||
{
|
||||
free(entries); // always malloc-family (calloc / ps_calloc)
|
||||
entries = nullptr;
|
||||
}
|
||||
|
||||
WarmNodeEntry *WarmNodeStore::find(NodeNum num) const
|
||||
{
|
||||
if (!entries || !num)
|
||||
return nullptr;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
|
||||
if (entries[i].num == num)
|
||||
return &entries[i];
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Slot placement with the keyed-first admission policy. Shared by absorb()
|
||||
// and the ring replay, so the policy is applied identically in both paths.
|
||||
WarmNodeEntry *WarmNodeStore::place(NodeNum num, uint32_t lastHeard, const uint8_t *key32)
|
||||
{
|
||||
if (!entries || !num)
|
||||
return nullptr;
|
||||
|
||||
const bool candidateKeyed = key32 && keyIsSet(key32);
|
||||
|
||||
WarmNodeEntry *slot = find(num);
|
||||
const bool sameNode = slot != nullptr;
|
||||
if (!slot) {
|
||||
// Pick a victim: any empty slot, else the oldest keyless entry, else
|
||||
// (only for keyed candidates) the oldest keyed entry.
|
||||
WarmNodeEntry *oldestKeyless = nullptr, *oldestKeyed = nullptr;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
|
||||
WarmNodeEntry &e = entries[i];
|
||||
if (!e.num) {
|
||||
slot = &e;
|
||||
break;
|
||||
}
|
||||
// Compare on the time bits only — the low metadata bits (role/protected) must
|
||||
// not perturb LRU victim selection.
|
||||
if (keyIsSet(e.public_key)) {
|
||||
if (!oldestKeyed || warmTimeOf(e) < warmTimeOf(*oldestKeyed))
|
||||
oldestKeyed = &e;
|
||||
} else {
|
||||
if (!oldestKeyless || warmTimeOf(e) < warmTimeOf(*oldestKeyless))
|
||||
oldestKeyless = &e;
|
||||
}
|
||||
}
|
||||
if (!slot)
|
||||
slot = oldestKeyless ? oldestKeyless : (candidateKeyed ? oldestKeyed : nullptr);
|
||||
if (!slot)
|
||||
return nullptr; // store full of keyed entries and the candidate has no key
|
||||
}
|
||||
|
||||
slot->num = num;
|
||||
slot->last_heard = lastHeard;
|
||||
if (candidateKeyed)
|
||||
memcpy(slot->public_key, key32, 32);
|
||||
else if (!sameNode)
|
||||
// Repurposing a victim slot for a different node: clear its stale key.
|
||||
// A keyless refresh of a node already here keeps the key we learned.
|
||||
memset(slot->public_key, 0, 32);
|
||||
return slot;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32, uint8_t role, uint8_t protectedCat)
|
||||
{
|
||||
// Pack role + protected category into the low bits of last_heard. place() and ring
|
||||
// replay store the raw word verbatim, so the metadata round-trips through flash.
|
||||
const uint32_t packed = warmPackLastHeard(lastHeard, role, protectedCat);
|
||||
const WarmNodeEntry *slot = place(num, packed, key32);
|
||||
if (!slot)
|
||||
return false;
|
||||
persistEntry(*slot);
|
||||
LOG_MIGRATION("WarmStore absorb 0x%08x key=%d last_heard=%u role=%u prot=%u (now %u/%u)", (unsigned)num,
|
||||
keyIsSet(slot->public_key) ? 1 : 0, (unsigned)warmTimeOf(*slot), (unsigned)role, (unsigned)protectedCat,
|
||||
(unsigned)count(), (unsigned)capacity());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const
|
||||
{
|
||||
const WarmNodeEntry *e = find(num);
|
||||
if (!e)
|
||||
return false;
|
||||
role = warmRoleOf(*e);
|
||||
protectedCat = warmProtOf(*e);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::take(NodeNum num, WarmNodeEntry &out)
|
||||
{
|
||||
WarmNodeEntry *e = find(num);
|
||||
if (!e)
|
||||
return false;
|
||||
out = *e;
|
||||
const int idx = static_cast<int>(e - entries);
|
||||
memset(e, 0, sizeof(*e));
|
||||
persistRemove(num, idx);
|
||||
LOG_MIGRATION("WarmStore take(rehydrate) 0x%08x key=%d (now %u/%u)", (unsigned)num, keyIsSet(out.public_key) ? 1 : 0,
|
||||
(unsigned)count(), (unsigned)capacity());
|
||||
return true;
|
||||
}
|
||||
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
void WarmNodeStore::dumpToLog(const char *reason) const
|
||||
{
|
||||
if (!entries) {
|
||||
LOG_MIGRATION("WarmStore dump (%s): backend not allocated", reason);
|
||||
return;
|
||||
}
|
||||
LOG_MIGRATION("WarmStore dump (%s): %u live / %u cap ==>", reason, (unsigned)count(), (unsigned)capacity());
|
||||
unsigned shown = 0;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
|
||||
const WarmNodeEntry &e = entries[i];
|
||||
if (e.num == 0)
|
||||
continue;
|
||||
LOG_MIGRATION(" warm[%3u] 0x%08x last_heard=%u key=%d", (unsigned)i, (unsigned)e.num, (unsigned)e.last_heard,
|
||||
keyIsSet(e.public_key) ? 1 : 0);
|
||||
shown++;
|
||||
}
|
||||
LOG_MIGRATION("WarmStore dump (%s): <== end (%u entries)", reason, shown);
|
||||
}
|
||||
#endif // MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
|
||||
bool WarmNodeStore::copyKey(NodeNum num, uint8_t out[32]) const
|
||||
{
|
||||
const WarmNodeEntry *e = find(num);
|
||||
if (!e || !keyIsSet(e->public_key))
|
||||
return false;
|
||||
memcpy(out, e->public_key, 32);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::contains(NodeNum num) const
|
||||
{
|
||||
return find(num) != nullptr;
|
||||
}
|
||||
|
||||
void WarmNodeStore::remove(NodeNum num)
|
||||
{
|
||||
WarmNodeEntry *e = find(num);
|
||||
if (e) {
|
||||
const int idx = static_cast<int>(e - entries);
|
||||
memset(e, 0, sizeof(*e));
|
||||
persistRemove(num, idx);
|
||||
}
|
||||
}
|
||||
|
||||
void WarmNodeStore::clear()
|
||||
{
|
||||
if (!entries)
|
||||
return;
|
||||
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
|
||||
#if defined(NRF52840_XXAA)
|
||||
memset(pageOf, kNoPage, sizeof(pageOf));
|
||||
#endif
|
||||
persistClear();
|
||||
}
|
||||
|
||||
size_t WarmNodeStore::count() const
|
||||
{
|
||||
size_t n = 0;
|
||||
if (entries)
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
|
||||
if (entries[i].num)
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
bool WarmNodeStore::saveIfDirty()
|
||||
{
|
||||
if (!dirty)
|
||||
return true;
|
||||
bool ok = save();
|
||||
if (ok)
|
||||
dirty = false;
|
||||
return ok;
|
||||
}
|
||||
|
||||
#if defined(NRF52840_XXAA)
|
||||
|
||||
// Raw-flash record-ring backend (nRF52840).
|
||||
// 3 × 4 KB pages below LittleFS. Mutations append 40 B records (entry snapshot,
|
||||
// or tombstone with last_heard == 0xFFFFFFFF) via the shared flash_nrf5x page
|
||||
// cache; saveIfDirty() is the durability point. A full page reclaims the oldest
|
||||
// (stranded live entries re-appended, then erased). Flash access holds spiLock —
|
||||
// the page cache is shared with InternalFS/LittleFS.
|
||||
|
||||
bool WarmNodeStore::ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy) const
|
||||
{
|
||||
flash_nrf5x_read(&h, WARM_FLASH_PAGE_ADDR(page), sizeof(h));
|
||||
if (h.seq == 0xFFFFFFFFu)
|
||||
return false; // erased page
|
||||
if (h.magic == WARM_RING_MAGIC) {
|
||||
if (legacy)
|
||||
*legacy = false;
|
||||
return true;
|
||||
}
|
||||
if (h.magic == WARM_RING_MAGIC_V1) {
|
||||
if (legacy)
|
||||
*legacy = true; // v1 page: replay it, but discard last_heard (see WARM_RING_MAGIC_V1)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Caller holds spiLock.
|
||||
void WarmNodeStore::ringOpenPage(uint8_t page)
|
||||
{
|
||||
// Drop any cached state for the page before the real erase, so a later
|
||||
// cache flush can't resurrect stale bytes.
|
||||
flash_nrf5x_flush();
|
||||
flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(page));
|
||||
WarmPageHeader h;
|
||||
h.magic = WARM_RING_MAGIC;
|
||||
h.seq = nextSeq++;
|
||||
flash_nrf5x_write(WARM_FLASH_PAGE_ADDR(page), &h, sizeof(h));
|
||||
activePage = page;
|
||||
writeSlot = 0;
|
||||
}
|
||||
|
||||
// Caller holds spiLock. May recurse once via ringAppend if the stranded set
|
||||
// fills the fresh page exactly — bounded by WARM_NODE_COUNT <= 2*kRecordsPerPage.
|
||||
void WarmNodeStore::ringRotate()
|
||||
{
|
||||
uint8_t target = 0;
|
||||
if (activePage != kNoPage) {
|
||||
// Lowest-seq valid page, preferring erased pages; never the active one
|
||||
uint32_t bestSeq = 0;
|
||||
bool found = false;
|
||||
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
|
||||
if (p == activePage)
|
||||
continue;
|
||||
WarmPageHeader h;
|
||||
if (!ringReadHeader(p, h)) {
|
||||
target = p; // erased/invalid page: free real estate, take it
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if (!found || static_cast<int32_t>(h.seq - bestSeq) < 0) {
|
||||
target = p;
|
||||
bestSeq = h.seq;
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Capture live entries stranded in the page we're about to erase
|
||||
int stranded[WARM_NODE_COUNT] = {};
|
||||
int nStranded = 0;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++) {
|
||||
if (entries[i].num && pageOf[i] == target)
|
||||
stranded[nStranded++] = static_cast<int>(i);
|
||||
if (pageOf[i] == target)
|
||||
pageOf[i] = kNoPage;
|
||||
}
|
||||
|
||||
ringOpenPage(target);
|
||||
|
||||
for (int k = 0; k < nStranded; k++)
|
||||
ringAppend(entries[stranded[k]], stranded[k]);
|
||||
}
|
||||
|
||||
// Caller holds spiLock.
|
||||
void WarmNodeStore::ringAppend(const WarmNodeEntry &rec, int storeSlot)
|
||||
{
|
||||
if (activePage == kNoPage || writeSlot >= kRecordsPerPage)
|
||||
ringRotate();
|
||||
const uint32_t addr =
|
||||
WARM_FLASH_PAGE_ADDR(activePage) + sizeof(WarmPageHeader) + static_cast<uint32_t>(writeSlot) * sizeof(WarmNodeEntry);
|
||||
flash_nrf5x_write(addr, &rec, sizeof(rec));
|
||||
writeSlot++;
|
||||
if (storeSlot >= 0)
|
||||
pageOf[storeSlot] = activePage;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistEntry(const WarmNodeEntry &e)
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
ringAppend(e, static_cast<int>(&e - entries));
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistRemove(NodeNum num, int storeSlot)
|
||||
{
|
||||
if (storeSlot >= 0 && storeSlot < static_cast<int>(WARM_NODE_COUNT))
|
||||
pageOf[storeSlot] = 0xFF;
|
||||
WarmNodeEntry tomb;
|
||||
memset(&tomb, 0, sizeof(tomb));
|
||||
tomb.num = num;
|
||||
tomb.last_heard = WARM_RING_TOMBSTONE;
|
||||
concurrency::LockGuard g(spiLock);
|
||||
ringAppend(tomb, -1);
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistClear()
|
||||
{
|
||||
concurrency::LockGuard g(spiLock);
|
||||
flash_nrf5x_flush();
|
||||
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++)
|
||||
flash_nrf5x_erase(WARM_FLASH_PAGE_ADDR(p));
|
||||
activePage = 0xFF;
|
||||
writeSlot = 0;
|
||||
nextSeq = 1;
|
||||
dirty = false; // the erased ring already reflects the empty store
|
||||
}
|
||||
|
||||
void WarmNodeStore::load()
|
||||
{
|
||||
if (!entries)
|
||||
return;
|
||||
concurrency::LockGuard g(spiLock);
|
||||
|
||||
// Order valid pages by ascending seq so replay applies oldest first
|
||||
uint8_t order[WARM_FLASH_PAGES] = {};
|
||||
uint32_t seqs[WARM_FLASH_PAGES] = {};
|
||||
bool legacyOf[WARM_FLASH_PAGES] = {}; // per-page: v1 (WRNG) → discard last_heard on replay
|
||||
uint8_t nValid = 0;
|
||||
uint8_t nCorrupt = 0;
|
||||
for (uint8_t p = 0; p < WARM_FLASH_PAGES; p++) {
|
||||
WarmPageHeader h;
|
||||
bool legacy = false;
|
||||
if (!ringReadHeader(p, h, &legacy)) {
|
||||
// An erased page reads back all-ones; any other magic is a
|
||||
// partially-written or bit-rotted header we're dropping, so flag it
|
||||
// rather than silently treating the loss as a clean empty ring.
|
||||
if (h.magic != 0xFFFFFFFFu)
|
||||
nCorrupt++;
|
||||
continue;
|
||||
}
|
||||
legacyOf[p] = legacy;
|
||||
uint8_t pos = nValid;
|
||||
while (pos > 0 && static_cast<int32_t>(h.seq - seqs[pos - 1]) < 0) {
|
||||
order[pos] = order[pos - 1];
|
||||
seqs[pos] = seqs[pos - 1];
|
||||
pos--;
|
||||
}
|
||||
order[pos] = p;
|
||||
seqs[pos] = h.seq;
|
||||
nValid++;
|
||||
}
|
||||
|
||||
if (nValid == 0) {
|
||||
activePage = 0xFF;
|
||||
writeSlot = 0;
|
||||
nextSeq = 1;
|
||||
if (nCorrupt)
|
||||
LOG_WARN("WarmStore: ring unreadable (%u bad page(s)), empty", nCorrupt);
|
||||
else
|
||||
LOG_INFO("WarmStore: ring empty, starting fresh");
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t replayed = 0;
|
||||
uint32_t migrated = 0;
|
||||
for (uint8_t k = 0; k < nValid; k++) {
|
||||
const uint8_t p = order[k];
|
||||
const bool legacy = legacyOf[p];
|
||||
uint16_t slot = 0;
|
||||
for (; slot < kRecordsPerPage; slot++) {
|
||||
WarmNodeEntry rec;
|
||||
flash_nrf5x_read(&rec, WARM_FLASH_PAGE_ADDR(p) + sizeof(WarmPageHeader) + (uint32_t)slot * sizeof(rec), sizeof(rec));
|
||||
if (rec.num == 0xFFFFFFFFu)
|
||||
break; // erased space: end of this page's records (append-only)
|
||||
if (rec.num == 0)
|
||||
continue; // unexpected; skip defensively
|
||||
replayed++;
|
||||
if (rec.last_heard == WARM_RING_TOMBSTONE) {
|
||||
WarmNodeEntry *e = find(rec.num);
|
||||
if (e) {
|
||||
pageOf[e - entries] = 0xFF;
|
||||
memset(e, 0, sizeof(*e));
|
||||
}
|
||||
} else {
|
||||
// v1 (legacy) record: keep identity + key, but discard the old timestamp —
|
||||
// its low bits would otherwise be misread as role/protected metadata.
|
||||
uint32_t lh = rec.last_heard;
|
||||
if (legacy) {
|
||||
lh = 0;
|
||||
migrated++;
|
||||
}
|
||||
const WarmNodeEntry *e = place(rec.num, lh, rec.public_key);
|
||||
if (e)
|
||||
pageOf[e - entries] = p;
|
||||
}
|
||||
}
|
||||
if (k == nValid - 1) { // newest page becomes the active head
|
||||
activePage = p;
|
||||
writeSlot = slot;
|
||||
nextSeq = seqs[k] + 1;
|
||||
// If the head is a v1 page, force the next append to rotate into a fresh v2 page,
|
||||
// so new (v2) records never land in a page whose header says v1 (which would make
|
||||
// a later load discard their last_heard — including the role/protected we just set).
|
||||
if (legacy)
|
||||
writeSlot = kRecordsPerPage;
|
||||
}
|
||||
}
|
||||
if (nCorrupt)
|
||||
LOG_WARN("WarmStore: dropped %u corrupt ring page(s), some nodes lost", nCorrupt);
|
||||
if (migrated)
|
||||
LOG_INFO("WarmStore: migrated %u v1 record(s) (kept key, discarded last_heard)", (unsigned)migrated);
|
||||
LOG_INFO("WarmStore: replayed %u ring records -> %u live nodes (page %u, slot %u)", (unsigned)replayed, (unsigned)count(),
|
||||
activePage, writeSlot);
|
||||
}
|
||||
|
||||
bool WarmNodeStore::save()
|
||||
{
|
||||
if (!powerHAL_isPowerLevelSafe()) {
|
||||
LOG_ERROR("Error: trying to save WarmStore on unsafe device power level.");
|
||||
return false;
|
||||
}
|
||||
concurrency::LockGuard g(spiLock);
|
||||
flash_nrf5x_flush();
|
||||
return true;
|
||||
}
|
||||
|
||||
#else // !NRF52840_XXAA --------------------
|
||||
|
||||
void WarmNodeStore::persistEntry(const WarmNodeEntry &e)
|
||||
{
|
||||
(void)e;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistRemove(NodeNum num, int storeSlot)
|
||||
{
|
||||
(void)num;
|
||||
(void)storeSlot;
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
void WarmNodeStore::persistClear()
|
||||
{
|
||||
dirty = true;
|
||||
}
|
||||
|
||||
#ifdef FSCom
|
||||
|
||||
// ---- File persistence: /prefs/warm.dat snapshots ----------------------------
|
||||
|
||||
// Compact occupied slots to the front of `dst`; returns the count.
|
||||
static uint16_t packEntries(const WarmNodeEntry *src, WarmNodeEntry *dst)
|
||||
{
|
||||
uint16_t n = 0;
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
|
||||
if (src[i].num)
|
||||
dst[n++] = src[i];
|
||||
return n;
|
||||
}
|
||||
|
||||
void WarmNodeStore::load()
|
||||
{
|
||||
if (!entries)
|
||||
return;
|
||||
// Clear first — all failure paths below then correctly represent "empty",
|
||||
// even if load() is called on an already-used instance.
|
||||
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
|
||||
concurrency::LockGuard g(spiLock);
|
||||
auto f = FSCom.open(warmFileName, FILE_O_READ);
|
||||
if (!f)
|
||||
return;
|
||||
WarmStoreHeader h;
|
||||
if ((size_t)f.read((uint8_t *)&h, sizeof(h)) != sizeof(h)) {
|
||||
f.close();
|
||||
LOG_WARN("WarmStore: %s header read failed, starting empty", warmFileName);
|
||||
return;
|
||||
}
|
||||
// v1 (WRM1) is still accepted: same record size, but its last_heard was a plain
|
||||
// timestamp. We keep identity + key and discard last_heard on load (see below).
|
||||
const bool legacy = (h.magic == WARM_STORE_MAGIC_V1);
|
||||
if ((h.magic != WARM_STORE_MAGIC && !legacy) || h.entrySize != sizeof(WarmNodeEntry) || h.count > WARM_NODE_COUNT) {
|
||||
f.close();
|
||||
LOG_WARN("WarmStore: %s header invalid (magic=0x%08x entrySize=%u count=%u), starting empty", warmFileName, h.magic,
|
||||
h.entrySize, h.count);
|
||||
return;
|
||||
}
|
||||
if (h.count) {
|
||||
const size_t len = (size_t)h.count * sizeof(WarmNodeEntry);
|
||||
const bool readOk = (size_t)f.read((uint8_t *)entries, len) == len;
|
||||
f.close();
|
||||
if (!readOk) {
|
||||
LOG_WARN("WarmStore: %s entries read failed, starting empty", warmFileName);
|
||||
return;
|
||||
}
|
||||
// CRC covers the bytes as written (v1 still has the old last_heard), so check before migrating.
|
||||
if (crc32Buffer(entries, len) != h.crc) {
|
||||
LOG_WARN("WarmStore: %s CRC mismatch, starting empty", warmFileName);
|
||||
memset(entries, 0, WARM_NODE_COUNT * sizeof(WarmNodeEntry));
|
||||
return;
|
||||
}
|
||||
if (legacy) {
|
||||
// Migrate v1 → v2: discard the old last_heard (its low bits would be misread as
|
||||
// role/protected); keep num + public_key. Mark dirty so save() rewrites as v2.
|
||||
for (size_t i = 0; i < WARM_NODE_COUNT; i++)
|
||||
if (entries[i].num)
|
||||
entries[i].last_heard = 0;
|
||||
dirty = true;
|
||||
}
|
||||
} else {
|
||||
f.close();
|
||||
}
|
||||
LOG_INFO("WarmStore: loaded %u warm nodes from %s%s", h.count, warmFileName,
|
||||
legacy ? " (v1 migrated: discarded last_heard)" : "");
|
||||
}
|
||||
|
||||
bool WarmNodeStore::save()
|
||||
{
|
||||
if (!entries)
|
||||
return false;
|
||||
if (!powerHAL_isPowerLevelSafe()) {
|
||||
LOG_ERROR("Error: trying to save WarmStore on unsafe device power level.");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::vector<WarmNodeEntry> packed(WARM_NODE_COUNT);
|
||||
WarmStoreHeader h;
|
||||
h.magic = WARM_STORE_MAGIC;
|
||||
h.reserved = 0;
|
||||
h.count = packEntries(entries, packed.data());
|
||||
h.entrySize = sizeof(WarmNodeEntry);
|
||||
h.crc = crc32Buffer(packed.data(), h.count * sizeof(WarmNodeEntry));
|
||||
|
||||
concurrency::LockGuard g(spiLock);
|
||||
FSCom.mkdir("/prefs");
|
||||
|
||||
auto f = SafeFile(warmFileName, false);
|
||||
f.write((const uint8_t *)&h, sizeof(h));
|
||||
f.write((const uint8_t *)packed.data(), h.count * sizeof(WarmNodeEntry));
|
||||
bool ok = f.close();
|
||||
if (!ok)
|
||||
LOG_ERROR("WarmStore: can't write %s", warmFileName);
|
||||
else
|
||||
LOG_DEBUG("WarmStore: saved %u warm nodes to %s", h.count, warmFileName);
|
||||
return ok;
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
void WarmNodeStore::load() {}
|
||||
bool WarmNodeStore::save()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#endif // FSCom
|
||||
#endif // NRF52840_XXAA
|
||||
|
||||
#endif // WARM_NODE_COUNT > 0
|
||||
@@ -0,0 +1,176 @@
|
||||
#pragma once
|
||||
|
||||
#include "MeshTypes.h"
|
||||
#include "mesh-pb-constants.h"
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
|
||||
// Verbose tracing for the warm-store migration + NodeDB self-care. Per-event /
|
||||
// per-boot chatter routes through this so it can be silenced in one place (set
|
||||
// to 0) once they're proven; genuine LOG_WARN anomalies stay unconditional.
|
||||
#ifndef MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
#define MESHTASTIC_NODEDB_MIGRATION_VERBOSE 0
|
||||
#endif
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
#define LOG_MIGRATION(...) LOG_INFO(__VA_ARGS__)
|
||||
#else
|
||||
#define LOG_MIGRATION(...) ((void)0)
|
||||
#endif
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
/**
|
||||
* Warm ("long-tail") node tier.
|
||||
*
|
||||
* Minimal identity record (NodeNum, last_heard, Curve25519 public key) for nodes
|
||||
* evicted from the hot NodeInfoLite store, so DMs to/from them keep encrypting —
|
||||
* the key is expensive to re-learn, the rest rebuilds from traffic in seconds.
|
||||
* Flat fixed array, linear scan (only on hot-store misses), LRU by last_heard
|
||||
* with keyed entries outranking keyless.
|
||||
*
|
||||
* Persistence: nRF52840 uses a 12 KB raw-flash record-ring below LittleFS
|
||||
* (append + replay + compact-on-rotate — see the backend in WarmNodeStore.cpp,
|
||||
* link-guarded by nrf52840_s140_v7.ld). Everywhere else: /prefs/warm.dat.
|
||||
*/
|
||||
struct WarmNodeEntry {
|
||||
NodeNum num; // 0 = empty slot
|
||||
uint32_t last_heard; // recency for LRU ordering — see the metadata steal below
|
||||
uint8_t public_key[32]; // all-zero = no key (a real key is never all-zero)
|
||||
};
|
||||
static_assert(sizeof(WarmNodeEntry) == 40, "WarmNodeEntry must stay 40 B — persistence format depends on it");
|
||||
|
||||
// Metadata packed into the low bits of last_heard.
|
||||
//
|
||||
// The warm tier only uses last_heard to LRU-rank evicted (long-tail) nodes, so ~minute
|
||||
// recency resolution is plenty. We reclaim the low WARM_META_BITS of that field to carry
|
||||
// the evicted node's device role + a protected category, at zero cost to record size
|
||||
// (entry stays 40 B; no RAM/flash growth). The high bits remain a real unix-seconds
|
||||
// timestamp quantised to (1 << WARM_META_BITS) seconds.
|
||||
//
|
||||
// Safe because: a real timestamp can never be all-ones (the tombstone sentinel) before
|
||||
// 2106, and tombstones/erased flash are detected via num before last_heard is read. Only
|
||||
// the LOW bits are stolen — the high (era) bits are untouched, so the time range is intact.
|
||||
static constexpr uint32_t WARM_META_BITS = 6; // role(4) + protected(2)
|
||||
static constexpr uint32_t WARM_META_MASK = (1u << WARM_META_BITS) - 1; // 0x3F → 64 s quantum
|
||||
static constexpr uint32_t WARM_TIME_MASK = ~WARM_META_MASK; // 0xFFFFFFC0
|
||||
static constexpr uint32_t WARM_ROLE_MASK = 0x0Fu; // bits [3:0] device role (0..12)
|
||||
static constexpr uint32_t WARM_PROT_SHIFT = 4; // bits [5:4] protected category
|
||||
static constexpr uint32_t WARM_PROT_MASK = 0x03u;
|
||||
|
||||
// Protected category cached alongside role so consumers needn't re-derive the mapping.
|
||||
enum class WarmProtected : uint8_t { None = 0, Role = 1, Flag = 2 };
|
||||
|
||||
inline uint32_t warmPackLastHeard(uint32_t lastHeard, uint8_t role, uint8_t prot)
|
||||
{
|
||||
return (lastHeard & WARM_TIME_MASK) | (static_cast<uint32_t>(role) & WARM_ROLE_MASK) |
|
||||
((static_cast<uint32_t>(prot) & WARM_PROT_MASK) << WARM_PROT_SHIFT);
|
||||
}
|
||||
inline uint32_t warmTimeOf(const WarmNodeEntry &e)
|
||||
{
|
||||
return e.last_heard & WARM_TIME_MASK;
|
||||
}
|
||||
inline uint8_t warmRoleOf(const WarmNodeEntry &e)
|
||||
{
|
||||
return static_cast<uint8_t>(e.last_heard & WARM_ROLE_MASK);
|
||||
}
|
||||
inline uint8_t warmProtOf(const WarmNodeEntry &e)
|
||||
{
|
||||
return static_cast<uint8_t>((e.last_heard >> WARM_PROT_SHIFT) & WARM_PROT_MASK);
|
||||
}
|
||||
|
||||
// Gated on NRF52840_XXAA: the ring sits at 0xEA000
|
||||
// valid only on the 1 MB-flash nRF52840.
|
||||
#if defined(NRF52840_XXAA)
|
||||
#define WARM_FLASH_PAGE_SIZE 4096u
|
||||
#define WARM_FLASH_PAGES 3u
|
||||
#define WARM_FLASH_REGION_BASE (0xED000u - WARM_FLASH_PAGES * WARM_FLASH_PAGE_SIZE) // 0xEA000
|
||||
#define WARM_FLASH_PAGE_ADDR(i) (WARM_FLASH_REGION_BASE + (i)*WARM_FLASH_PAGE_SIZE)
|
||||
#endif
|
||||
|
||||
class WarmNodeStore
|
||||
{
|
||||
public:
|
||||
WarmNodeStore();
|
||||
~WarmNodeStore();
|
||||
WarmNodeStore(const WarmNodeStore &) = delete;
|
||||
WarmNodeStore &operator=(const WarmNodeStore &) = delete;
|
||||
|
||||
/// Remember an evicted hot node. Keyless candidates never displace keyed
|
||||
/// entries; otherwise the oldest (keyless-first) entry is replaced.
|
||||
/// @param role the node's device role (meshtastic_Config_DeviceConfig_Role, 0..12)
|
||||
/// @param protectedCat WarmProtected category cached for the hop-trim path
|
||||
/// @return true if the node was stored or updated
|
||||
bool absorb(NodeNum num, uint32_t lastHeard, const uint8_t *key32 /* may be NULL */, uint8_t role = 0,
|
||||
uint8_t protectedCat = 0);
|
||||
|
||||
/// Look up the cached device role + protected category for a warm node.
|
||||
/// @return false if the node is not in the warm tier.
|
||||
bool lookupMeta(NodeNum num, uint8_t &role, uint8_t &protectedCat) const;
|
||||
|
||||
/// Find and remove an entry (used when the node is re-admitted to the hot store).
|
||||
bool take(NodeNum num, WarmNodeEntry &out);
|
||||
|
||||
/// Copy the 32-byte public key for a node, if we have one.
|
||||
bool copyKey(NodeNum num, uint8_t out[32]) const;
|
||||
|
||||
bool contains(NodeNum num) const;
|
||||
void remove(NodeNum num);
|
||||
void clear();
|
||||
size_t count() const;
|
||||
size_t capacity() const { return entries ? WARM_NODE_COUNT : 0; }
|
||||
|
||||
#if MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
/// Debug: dump every live warm entry (num / last_heard / has-key) to the
|
||||
/// console. Compiled out unless MESHTASTIC_NODEDB_MIGRATION_VERBOSE.
|
||||
void dumpToLog(const char *reason = "dump") const;
|
||||
#endif
|
||||
|
||||
/// Load persisted entries (called once at boot, after the node DB loads).
|
||||
void load();
|
||||
/// Durability point, piggybacked on the node-database save cadence. On the
|
||||
/// ring backend this flushes the shared flash page cache; on the file
|
||||
/// backend it writes the warm.dat snapshot.
|
||||
bool saveIfDirty();
|
||||
|
||||
private:
|
||||
WarmNodeEntry *entries = nullptr; // WARM_NODE_COUNT slots; PSRAM on ESP32 when available
|
||||
bool dirty = false;
|
||||
|
||||
WarmNodeEntry *find(NodeNum num) const;
|
||||
// Internal slot-placement shared by absorb() and ring replay: applies the
|
||||
// keyed-first admission policy without touching persistence.
|
||||
WarmNodeEntry *place(NodeNum num, uint32_t lastHeard, const uint8_t *key32);
|
||||
|
||||
// Persistence hooks called from the mutation paths. File backend: mark
|
||||
// dirty. Ring backend: append an upsert/tombstone record (+ mark dirty).
|
||||
void persistEntry(const WarmNodeEntry &e); // e must point into entries[]
|
||||
void persistRemove(NodeNum num, int storeSlot);
|
||||
void persistClear();
|
||||
|
||||
#if defined(NRF52840_XXAA)
|
||||
// nRF52840 raw-flash record-ring state.
|
||||
struct WarmPageHeader {
|
||||
uint32_t magic; // WARM_RING_MAGIC
|
||||
uint32_t seq; // page generation; 0xFFFFFFFF = erased/unused
|
||||
};
|
||||
static_assert(sizeof(WarmPageHeader) == 8, "page header is part of the flash format");
|
||||
static constexpr uint16_t kRecordsPerPage = (WARM_FLASH_PAGE_SIZE - sizeof(WarmPageHeader)) / sizeof(WarmNodeEntry); // 102
|
||||
static_assert(WARM_NODE_COUNT <= 2 * ((WARM_FLASH_PAGE_SIZE - 8) / 40), "live set must fit the ring with one page reclaimed");
|
||||
|
||||
static constexpr uint8_t kNoPage = 0xFF; // "no page" sentinel for activePage / pageOf[]
|
||||
|
||||
uint8_t activePage = kNoPage; // no page opened yet (fresh/erased ring)
|
||||
uint16_t writeSlot = 0; // next free record slot in the active page
|
||||
uint32_t nextSeq = 1; // seq for the next page opened
|
||||
uint8_t pageOf[WARM_NODE_COUNT]; // flash page holding each RAM slot's newest record; kNoPage = none
|
||||
|
||||
void ringAppend(const WarmNodeEntry &rec, int storeSlot /* -1 for tombstones */);
|
||||
void ringRotate(); // reclaim oldest page, compacting stranded live entries
|
||||
void ringOpenPage(uint8_t page); // erase + write header (seq = nextSeq++)
|
||||
bool ringReadHeader(uint8_t page, WarmPageHeader &h, bool *legacy = nullptr) const;
|
||||
#endif
|
||||
|
||||
bool save();
|
||||
};
|
||||
|
||||
#endif // WARM_NODE_COUNT > 0
|
||||
@@ -6,6 +6,9 @@
|
||||
#include "main.h"
|
||||
#include "mesh/api/ethServerAPI.h"
|
||||
#include "target_specific.h"
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
#include "mesh/eth/ethOTA.h"
|
||||
#endif
|
||||
#ifdef USE_ARDUINO_ETHERNET
|
||||
#include <Ethernet.h> // arduino-libraries/Ethernet — supports W5100/W5200/W5500
|
||||
// Shorter DHCP timeout so LoRa startup isn't blocked when no DHCP server is present.
|
||||
@@ -154,6 +157,10 @@ static int32_t reconnectETH()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
initEthOTA();
|
||||
#endif
|
||||
|
||||
ethStartupComplete = true;
|
||||
}
|
||||
}
|
||||
@@ -180,6 +187,10 @@ static int32_t reconnectETH()
|
||||
}
|
||||
#endif
|
||||
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
ethOTALoop();
|
||||
#endif
|
||||
|
||||
return 5000; // every 5 seconds
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
#include "configuration.h"
|
||||
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
|
||||
#include "ethOTA.h"
|
||||
#include <ErriezCRC32.h>
|
||||
#include <SHA256.h>
|
||||
#include <Updater.h>
|
||||
#ifdef ARCH_RP2040
|
||||
#include <hardware/watchdog.h>
|
||||
#define FEED_WATCHDOG() watchdog_update()
|
||||
#else
|
||||
#define FEED_WATCHDOG() ((void)0)
|
||||
#endif
|
||||
|
||||
/// Protocol header sent by the upload tool
|
||||
struct __attribute__((packed)) OTAHeader {
|
||||
uint8_t magic[4]; // "MOTA" (Meshtastic OTA)
|
||||
uint32_t firmwareSize; // Size of the firmware payload in bytes (little-endian)
|
||||
uint32_t crc32; // CRC32 of the entire firmware payload
|
||||
};
|
||||
|
||||
/// Response codes sent back to the client
|
||||
enum OTAResponse : uint8_t {
|
||||
OTA_OK = 0x00,
|
||||
OTA_ERR_CRC = 0x01,
|
||||
OTA_ERR_SIZE = 0x02,
|
||||
OTA_ERR_WRITE = 0x03,
|
||||
OTA_ERR_MAGIC = 0x04,
|
||||
OTA_ERR_BEGIN = 0x05,
|
||||
OTA_ACK = 0x06, // ACK uses ASCII ACK character
|
||||
OTA_ERR_AUTH = 0x07,
|
||||
OTA_ERR_TIMEOUT = 0x08,
|
||||
};
|
||||
|
||||
static const uint32_t OTA_TIMEOUT_MS = 30000; // 30s inactivity timeout
|
||||
static const size_t OTA_CHUNK_SIZE = 1024; // 1KB receive buffer
|
||||
static const uint32_t OTA_AUTH_COOLDOWN_MS = 5000; // 5s cooldown after failed auth
|
||||
static const size_t OTA_NONCE_SIZE = 32;
|
||||
static const size_t OTA_HASH_SIZE = 32;
|
||||
|
||||
// OTA PSK — override via USERPREFS_OTA_PSK in userPrefs.jsonc
|
||||
// USERPREFS_OTA_PSK is stringified by PlatformIO (wrapped in quotes), so we
|
||||
// use a char[] and sizeof-1 to exclude the trailing NUL byte from the hash.
|
||||
#ifdef USERPREFS_OTA_PSK
|
||||
static const char otaPSKString[] = USERPREFS_OTA_PSK;
|
||||
static const uint8_t *const otaPSK = reinterpret_cast<const uint8_t *>(otaPSKString);
|
||||
static const size_t otaPSKSize = sizeof(otaPSKString) - 1;
|
||||
#else
|
||||
// Default PSK (CHANGE THIS for production deployments)
|
||||
static const uint8_t otaPSK[] = {0x6d, 0x65, 0x73, 0x68, 0x74, 0x61, 0x73, 0x74, 0x69, 0x63, 0x5f, 0x6f, 0x74, 0x61, 0x5f, 0x64,
|
||||
0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x70, 0x73, 0x6b, 0x5f, 0x76, 0x31, 0x21, 0x21, 0x21};
|
||||
// = "meshtastic_ota_default_psk_v1!!!"
|
||||
static const size_t otaPSKSize = sizeof(otaPSK);
|
||||
#endif
|
||||
|
||||
static EthernetServer *otaServer = nullptr;
|
||||
static uint32_t lastAuthFailure = 0;
|
||||
|
||||
static bool readExact(EthernetClient &client, uint8_t *buf, size_t len)
|
||||
{
|
||||
size_t received = 0;
|
||||
uint32_t lastActivity = millis();
|
||||
|
||||
while (received < len) {
|
||||
if (!client.connected()) {
|
||||
return false;
|
||||
}
|
||||
int avail = client.available();
|
||||
if (avail > 0) {
|
||||
size_t toRead = min((size_t)avail, len - received);
|
||||
size_t got = client.read(buf + received, toRead);
|
||||
received += got;
|
||||
lastActivity = millis();
|
||||
} else {
|
||||
if (millis() - lastActivity > OTA_TIMEOUT_MS) {
|
||||
return false;
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
FEED_WATCHDOG();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Compute SHA256(nonce || psk) for challenge-response authentication
|
||||
static void computeAuthHash(const uint8_t *nonce, size_t nonceLen, const uint8_t *psk, size_t pskLen, uint8_t *hashOut)
|
||||
{
|
||||
SHA256 sha;
|
||||
sha.reset();
|
||||
sha.update(nonce, nonceLen);
|
||||
sha.update(psk, pskLen);
|
||||
sha.finalize(hashOut, OTA_HASH_SIZE);
|
||||
}
|
||||
|
||||
/// Challenge-response authentication. Returns true if client is authenticated.
|
||||
static bool authenticateClient(EthernetClient &client)
|
||||
{
|
||||
// Rate-limit after failed auth — close silently so the error byte is not
|
||||
// misinterpreted as part of the nonce by a re-trying client.
|
||||
if (lastAuthFailure != 0 && (millis() - lastAuthFailure) < OTA_AUTH_COOLDOWN_MS) {
|
||||
LOG_WARN("ETH OTA: Auth cooldown active, rejecting connection");
|
||||
client.stop();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Generate random nonce
|
||||
uint8_t nonce[OTA_NONCE_SIZE];
|
||||
for (size_t i = 0; i < OTA_NONCE_SIZE; i += 4) {
|
||||
uint32_t r = random();
|
||||
size_t remaining = OTA_NONCE_SIZE - i;
|
||||
memcpy(nonce + i, &r, min((size_t)4, remaining));
|
||||
}
|
||||
|
||||
// Send nonce to client
|
||||
client.write(nonce, OTA_NONCE_SIZE);
|
||||
|
||||
// Read client's response: SHA256(nonce || PSK)
|
||||
uint8_t clientHash[OTA_HASH_SIZE];
|
||||
if (!readExact(client, clientHash, OTA_HASH_SIZE)) {
|
||||
LOG_WARN("ETH OTA: Timeout reading auth response");
|
||||
lastAuthFailure = millis();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Compute expected hash
|
||||
uint8_t expectedHash[OTA_HASH_SIZE];
|
||||
computeAuthHash(nonce, OTA_NONCE_SIZE, otaPSK, otaPSKSize, expectedHash);
|
||||
|
||||
// Constant-time comparison to prevent timing attacks
|
||||
uint8_t diff = 0;
|
||||
for (size_t i = 0; i < OTA_HASH_SIZE; i++) {
|
||||
diff |= clientHash[i] ^ expectedHash[i];
|
||||
}
|
||||
|
||||
if (diff != 0) {
|
||||
LOG_WARN("ETH OTA: Authentication failed");
|
||||
client.write(OTA_ERR_AUTH);
|
||||
lastAuthFailure = millis();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Auth success — send ACK
|
||||
client.write(OTA_ACK);
|
||||
LOG_INFO("ETH OTA: Authentication successful");
|
||||
return true;
|
||||
}
|
||||
|
||||
static void handleOTAClient(EthernetClient &client)
|
||||
{
|
||||
LOG_INFO("ETH OTA: Client connected from %u.%u.%u.%u", client.remoteIP()[0], client.remoteIP()[1], client.remoteIP()[2],
|
||||
client.remoteIP()[3]);
|
||||
|
||||
// Step 1: Challenge-response authentication
|
||||
if (!authenticateClient(client)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 2: Read 12-byte header
|
||||
OTAHeader hdr;
|
||||
if (!readExact(client, (uint8_t *)&hdr, sizeof(hdr))) {
|
||||
LOG_WARN("ETH OTA: Timeout reading header");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate magic
|
||||
if (memcmp(hdr.magic, "MOTA", 4) != 0) {
|
||||
LOG_WARN("ETH OTA: Invalid magic");
|
||||
client.write(OTA_ERR_MAGIC);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("ETH OTA: Firmware size=%u, CRC32=0x%08X", hdr.firmwareSize, hdr.crc32);
|
||||
|
||||
// Sanity check on size (must be > 0 and fit in LittleFS)
|
||||
if (hdr.firmwareSize == 0 || hdr.firmwareSize > 1024 * 1024) {
|
||||
LOG_WARN("ETH OTA: Invalid firmware size");
|
||||
client.write(OTA_ERR_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// Begin the update — this opens firmware.bin on LittleFS
|
||||
if (!Update.begin(hdr.firmwareSize)) {
|
||||
LOG_ERROR("ETH OTA: Update.begin() failed, error=%u", Update.getError());
|
||||
client.write(OTA_ERR_BEGIN);
|
||||
return;
|
||||
}
|
||||
|
||||
// ACK the header — client can start sending firmware data
|
||||
client.write(OTA_ACK);
|
||||
|
||||
// Receive firmware in chunks
|
||||
uint8_t buf[OTA_CHUNK_SIZE];
|
||||
size_t remaining = hdr.firmwareSize;
|
||||
uint32_t crc = CRC32_INITIAL;
|
||||
uint32_t lastActivity = millis();
|
||||
size_t totalReceived = 0;
|
||||
|
||||
while (remaining > 0) {
|
||||
if (!client.connected()) {
|
||||
LOG_WARN("ETH OTA: Client disconnected during transfer");
|
||||
Update.end(false);
|
||||
return;
|
||||
}
|
||||
|
||||
int avail = client.available();
|
||||
if (avail <= 0) {
|
||||
if (millis() - lastActivity > OTA_TIMEOUT_MS) {
|
||||
LOG_WARN("ETH OTA: Timeout during transfer (%u/%u bytes)", totalReceived, hdr.firmwareSize);
|
||||
client.write(OTA_ERR_TIMEOUT);
|
||||
Update.end(false);
|
||||
return;
|
||||
}
|
||||
delay(1);
|
||||
FEED_WATCHDOG();
|
||||
continue;
|
||||
}
|
||||
|
||||
size_t toRead = min((size_t)avail, min(remaining, sizeof(buf)));
|
||||
size_t got = client.read(buf, toRead);
|
||||
if (got == 0)
|
||||
continue;
|
||||
|
||||
// Write to Updater (LittleFS firmware.bin)
|
||||
size_t written = Update.write(buf, got);
|
||||
if (written != got) {
|
||||
LOG_ERROR("ETH OTA: Write failed (wrote %u of %u), error=%u", written, got, Update.getError());
|
||||
client.write(OTA_ERR_WRITE);
|
||||
Update.end(false);
|
||||
return;
|
||||
}
|
||||
|
||||
crc = crc32Update(buf, got, crc);
|
||||
remaining -= got;
|
||||
totalReceived += got;
|
||||
lastActivity = millis();
|
||||
FEED_WATCHDOG();
|
||||
|
||||
// Progress log every ~10%
|
||||
if (totalReceived % (hdr.firmwareSize / 10 + 1) < got) {
|
||||
LOG_INFO("ETH OTA: %u%% (%u/%u bytes)", (uint32_t)(100ULL * totalReceived / hdr.firmwareSize), totalReceived,
|
||||
hdr.firmwareSize);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify CRC32
|
||||
uint32_t computedCRC = crc32Final(crc);
|
||||
if (computedCRC != hdr.crc32) {
|
||||
LOG_ERROR("ETH OTA: CRC mismatch (expected=0x%08X, computed=0x%08X)", hdr.crc32, computedCRC);
|
||||
client.write(OTA_ERR_CRC);
|
||||
Update.end(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Finalize — this calls picoOTA.commit() which stages the update for the
|
||||
// bootloader
|
||||
if (!Update.end(true)) {
|
||||
LOG_ERROR("ETH OTA: Update.end() failed, error=%u", Update.getError());
|
||||
client.write(OTA_ERR_WRITE);
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("ETH OTA: Update staged successfully (%u bytes). Rebooting...", hdr.firmwareSize);
|
||||
client.write(OTA_OK);
|
||||
client.flush();
|
||||
delay(500);
|
||||
|
||||
// Reboot — the built-in bootloader will apply the update from LittleFS
|
||||
rp2040.reboot();
|
||||
}
|
||||
|
||||
void initEthOTA()
|
||||
{
|
||||
if (!otaServer) {
|
||||
otaServer = new EthernetServer(ETH_OTA_PORT);
|
||||
otaServer->begin();
|
||||
LOG_INFO("ETH OTA: Server listening on TCP port %d", ETH_OTA_PORT);
|
||||
}
|
||||
}
|
||||
|
||||
void ethOTALoop()
|
||||
{
|
||||
if (!otaServer)
|
||||
return;
|
||||
|
||||
EthernetClient client = otaServer->accept();
|
||||
if (client) {
|
||||
handleOTAClient(client);
|
||||
client.stop();
|
||||
}
|
||||
}
|
||||
|
||||
#endif // HAS_ETHERNET && HAS_ETHERNET_OTA
|
||||
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "configuration.h"
|
||||
|
||||
#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA)
|
||||
|
||||
#ifdef USE_ARDUINO_ETHERNET
|
||||
#include <Ethernet.h>
|
||||
#else
|
||||
#include <RAK13800_W5100S.h>
|
||||
#endif
|
||||
|
||||
#define ETH_OTA_PORT 4243
|
||||
|
||||
/// Initialize the Ethernet OTA server (call after Ethernet is connected)
|
||||
void initEthOTA();
|
||||
|
||||
/// Poll for incoming OTA connections (call periodically from ethClient
|
||||
/// reconnect loop)
|
||||
void ethOTALoop();
|
||||
|
||||
#endif // HAS_ETHERNET && HAS_ETHERNET_OTA
|
||||
@@ -234,6 +234,9 @@ typedef struct _meshtastic_HamParameters {
|
||||
float frequency;
|
||||
/* Optional short name of user */
|
||||
char short_name[5];
|
||||
/* Optional long name of user
|
||||
Appended to callsign */
|
||||
char long_name[15];
|
||||
} meshtastic_HamParameters;
|
||||
|
||||
/* Response envelope for node_remote_hardware_pins */
|
||||
@@ -544,7 +547,7 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_InputEvent_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_default {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_default {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_default {"", 0, 0, ""}
|
||||
#define meshtastic_HamParameters_init_default {"", 0, 0, "", ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_default {0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||
#define meshtastic_SharedContact_init_default {0, false, meshtastic_User_init_default, 0, 0}
|
||||
#define meshtastic_KeyVerificationAdmin_init_default {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0}
|
||||
@@ -557,7 +560,7 @@ extern "C" {
|
||||
#define meshtastic_AdminMessage_InputEvent_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_AdminMessage_OTAEvent_init_zero {_meshtastic_OTAMode_MIN, {0, {0}}}
|
||||
#define meshtastic_LockdownAuth_init_zero {{0, {0}}, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, ""}
|
||||
#define meshtastic_HamParameters_init_zero {"", 0, 0, "", ""}
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_init_zero {0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||
#define meshtastic_SharedContact_init_zero {0, false, meshtastic_User_init_zero, 0, 0}
|
||||
#define meshtastic_KeyVerificationAdmin_init_zero {_meshtastic_KeyVerificationAdmin_MessageType_MIN, 0, 0, false, 0}
|
||||
@@ -584,6 +587,7 @@ extern "C" {
|
||||
#define meshtastic_HamParameters_tx_power_tag 2
|
||||
#define meshtastic_HamParameters_frequency_tag 3
|
||||
#define meshtastic_HamParameters_short_name_tag 4
|
||||
#define meshtastic_HamParameters_long_name_tag 5
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_node_remote_hardware_pins_tag 1
|
||||
#define meshtastic_SharedContact_node_num_tag 1
|
||||
#define meshtastic_SharedContact_user_tag 2
|
||||
@@ -786,7 +790,8 @@ X(a, STATIC, SINGULAR, BOOL, disable, 6)
|
||||
X(a, STATIC, SINGULAR, STRING, call_sign, 1) \
|
||||
X(a, STATIC, SINGULAR, INT32, tx_power, 2) \
|
||||
X(a, STATIC, SINGULAR, FLOAT, frequency, 3) \
|
||||
X(a, STATIC, SINGULAR, STRING, short_name, 4)
|
||||
X(a, STATIC, SINGULAR, STRING, short_name, 4) \
|
||||
X(a, STATIC, SINGULAR, STRING, long_name, 5)
|
||||
#define meshtastic_HamParameters_CALLBACK NULL
|
||||
#define meshtastic_HamParameters_DEFAULT NULL
|
||||
|
||||
@@ -891,7 +896,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg;
|
||||
#define meshtastic_AdminMessage_InputEvent_size 14
|
||||
#define meshtastic_AdminMessage_OTAEvent_size 36
|
||||
#define meshtastic_AdminMessage_size 511
|
||||
#define meshtastic_HamParameters_size 31
|
||||
#define meshtastic_HamParameters_size 47
|
||||
#define meshtastic_KeyVerificationAdmin_size 25
|
||||
#define meshtastic_LockdownAuth_size 56
|
||||
#define meshtastic_NodeRemoteHardwarePinsResponse_size 496
|
||||
|
||||
@@ -69,8 +69,8 @@ typedef PB_BYTES_ARRAY_T(32) meshtastic_NodeInfoLite_public_key_t;
|
||||
typedef struct _meshtastic_NodeInfoLite {
|
||||
/* The node number */
|
||||
uint32_t num;
|
||||
/* Returns the Signal-to-noise ratio (SNR) of the last received message,
|
||||
as measured by the receiver. Return SNR of the last received message in dB */
|
||||
/* In-memory SNR of the last received message in dB. Not serialised directly:
|
||||
always zeroed before encode; persisted as snr_q4 = 19 below. */
|
||||
float snr;
|
||||
/* Set to indicate the last time we received a packet from this node */
|
||||
uint32_t last_heard;
|
||||
@@ -94,6 +94,10 @@ typedef struct _meshtastic_NodeInfoLite {
|
||||
meshtastic_Config_DeviceConfig_Role role;
|
||||
/* The public key of the user's device, for PKI-based encrypted DMs. */
|
||||
meshtastic_NodeInfoLite_public_key_t public_key;
|
||||
/* Q4-encoded SNR: dB × 4, sint32 zigzag. Matches RouteDiscovery convention.
|
||||
Encode: snr_q4 = (int32_t)(snr * 4.0f). Decode: snr = snr_q4 / 4.0f.
|
||||
float snr is always zeroed on disk; this field carries all persisted SNR. */
|
||||
int32_t snr_q4;
|
||||
} meshtastic_NodeInfoLite;
|
||||
|
||||
/* This message is never sent over the wire, but it is used for serializing DB
|
||||
@@ -215,7 +219,7 @@ extern "C" {
|
||||
/* Initializer values for message structs */
|
||||
#define meshtastic_PositionLite_init_default {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0}
|
||||
#define meshtastic_UserLite_init_default {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0}
|
||||
#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}}
|
||||
#define meshtastic_NodeInfoLite_init_default {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, 0}
|
||||
#define meshtastic_DeviceState_init_default {false, meshtastic_MyNodeInfo_init_default, false, meshtastic_User_init_default, 0, {meshtastic_MeshPacket_init_default}, false, meshtastic_MeshPacket_init_default, 0, 0, 0, false, meshtastic_MeshPacket_init_default, 0, {meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default, meshtastic_NodeRemoteHardwarePin_init_default}}
|
||||
#define meshtastic_NodePositionEntry_init_default {0, false, meshtastic_PositionLite_init_default}
|
||||
#define meshtastic_NodeTelemetryEntry_init_default {0, false, meshtastic_DeviceMetrics_init_default}
|
||||
@@ -226,7 +230,7 @@ extern "C" {
|
||||
#define meshtastic_BackupPreferences_init_default {0, 0, false, meshtastic_LocalConfig_init_default, false, meshtastic_LocalModuleConfig_init_default, false, meshtastic_ChannelFile_init_default, false, meshtastic_User_init_default}
|
||||
#define meshtastic_PositionLite_init_zero {0, 0, 0, 0, _meshtastic_Position_LocSource_MIN, 0}
|
||||
#define meshtastic_UserLite_init_zero {{0}, "", "", _meshtastic_HardwareModel_MIN, 0, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, false, 0}
|
||||
#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}}
|
||||
#define meshtastic_NodeInfoLite_init_zero {0, 0, 0, 0, false, 0, 0, 0, "", "", _meshtastic_HardwareModel_MIN, _meshtastic_Config_DeviceConfig_Role_MIN, {0, {0}}, 0}
|
||||
#define meshtastic_DeviceState_init_zero {false, meshtastic_MyNodeInfo_init_zero, false, meshtastic_User_init_zero, 0, {meshtastic_MeshPacket_init_zero}, false, meshtastic_MeshPacket_init_zero, 0, 0, 0, false, meshtastic_MeshPacket_init_zero, 0, {meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero, meshtastic_NodeRemoteHardwarePin_init_zero}}
|
||||
#define meshtastic_NodePositionEntry_init_zero {0, false, meshtastic_PositionLite_init_zero}
|
||||
#define meshtastic_NodeTelemetryEntry_init_zero {0, false, meshtastic_DeviceMetrics_init_zero}
|
||||
@@ -263,6 +267,7 @@ extern "C" {
|
||||
#define meshtastic_NodeInfoLite_hw_model_tag 16
|
||||
#define meshtastic_NodeInfoLite_role_tag 17
|
||||
#define meshtastic_NodeInfoLite_public_key_tag 18
|
||||
#define meshtastic_NodeInfoLite_snr_q4_tag 19
|
||||
#define meshtastic_DeviceState_my_node_tag 2
|
||||
#define meshtastic_DeviceState_owner_tag 3
|
||||
#define meshtastic_DeviceState_receive_queue_tag 5
|
||||
@@ -330,7 +335,8 @@ X(a, STATIC, SINGULAR, STRING, long_name, 14) \
|
||||
X(a, STATIC, SINGULAR, STRING, short_name, 15) \
|
||||
X(a, STATIC, SINGULAR, UENUM, hw_model, 16) \
|
||||
X(a, STATIC, SINGULAR, UENUM, role, 17) \
|
||||
X(a, STATIC, SINGULAR, BYTES, public_key, 18)
|
||||
X(a, STATIC, SINGULAR, BYTES, public_key, 18) \
|
||||
X(a, STATIC, SINGULAR, SINT32, snr_q4, 19)
|
||||
#define meshtastic_NodeInfoLite_CALLBACK NULL
|
||||
#define meshtastic_NodeInfoLite_DEFAULT NULL
|
||||
|
||||
@@ -446,11 +452,11 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg;
|
||||
/* Maximum encoded size of messages (where known) */
|
||||
/* meshtastic_NodeDatabase_size depends on runtime parameters */
|
||||
#define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size
|
||||
#define meshtastic_BackupPreferences_size 2432
|
||||
#define meshtastic_BackupPreferences_size 2410
|
||||
#define meshtastic_ChannelFile_size 718
|
||||
#define meshtastic_DeviceState_size 1944
|
||||
#define meshtastic_NodeEnvironmentEntry_size 170
|
||||
#define meshtastic_NodeInfoLite_size 105
|
||||
#define meshtastic_NodeInfoLite_size 112
|
||||
#define meshtastic_NodePositionEntry_size 42
|
||||
#define meshtastic_NodeStatusEntry_size 89
|
||||
#define meshtastic_NodeTelemetryEntry_size 35
|
||||
|
||||
@@ -206,7 +206,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg;
|
||||
/* Maximum encoded size of messages (where known) */
|
||||
#define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size
|
||||
#define meshtastic_LocalConfig_size 757
|
||||
#define meshtastic_LocalModuleConfig_size 820
|
||||
#define meshtastic_LocalModuleConfig_size 798
|
||||
|
||||
#ifdef __cplusplus
|
||||
} /* extern "C" */
|
||||
|
||||
@@ -96,6 +96,15 @@ PB_BIND(meshtastic_Neighbor, meshtastic_Neighbor, AUTO)
|
||||
PB_BIND(meshtastic_DeviceMetadata, meshtastic_DeviceMetadata, AUTO)
|
||||
|
||||
|
||||
PB_BIND(meshtastic_LoRaPresetGroup, meshtastic_LoRaPresetGroup, AUTO)
|
||||
|
||||
|
||||
PB_BIND(meshtastic_LoRaRegionPresets, meshtastic_LoRaRegionPresets, AUTO)
|
||||
|
||||
|
||||
PB_BIND(meshtastic_LoRaRegionPresetMap, meshtastic_LoRaRegionPresetMap, 2)
|
||||
|
||||
|
||||
PB_BIND(meshtastic_Heartbeat, meshtastic_Heartbeat, AUTO)
|
||||
|
||||
|
||||
|
||||
@@ -325,6 +325,14 @@ typedef enum _meshtastic_HardwareModel {
|
||||
meshtastic_HardwareModel_T_IMPULSE_PLUS = 135,
|
||||
/* Lilygo T-Echo Card */
|
||||
meshtastic_HardwareModel_T_ECHO_CARD = 136,
|
||||
/* Seeed Tracker L2 */
|
||||
meshtastic_HardwareModel_SEEED_WIO_TRACKER_L2 = 137,
|
||||
/* Elecrow CrowPanel Advance P4 models, ESP32-P4 and TFT with SX1262 radio plugin */
|
||||
meshtastic_HardwareModel_CROWPANEL_P4 = 138,
|
||||
/* Heltec Mesh Tower V2 */
|
||||
meshtastic_HardwareModel_HELTEC_MESH_TOWER_V2 = 139,
|
||||
/* Meshnology W10 */
|
||||
meshtastic_HardwareModel_MESHNOLOGY_W10 = 140,
|
||||
/* ------------------------------------------------------------------------------------------------------------------------------------------
|
||||
Reserved ID For developing private Ports. These will show up in live traffic sparsely, so we can use a high number. Keep it within 8 bits.
|
||||
------------------------------------------------------------------------------------------------------------------------------------------ */
|
||||
@@ -1347,6 +1355,53 @@ typedef struct _meshtastic_DeviceMetadata {
|
||||
uint32_t excluded_modules;
|
||||
} meshtastic_DeviceMetadata;
|
||||
|
||||
/* A distinct set of legal modem presets shared by one or more LoRa regions.
|
||||
Regions that have an identical preset list / default / licensing reference
|
||||
the same group (by index) via LoRaRegionPresetMap.region_groups. This keeps
|
||||
the whole map small enough to fit in a single FromRadio packet, since most
|
||||
regions share the one standard preset list. */
|
||||
typedef struct _meshtastic_LoRaPresetGroup {
|
||||
/* The modem presets that are legal for every region referencing this group. */
|
||||
pb_size_t presets_count;
|
||||
meshtastic_Config_LoRaConfig_ModemPreset presets[11];
|
||||
/* The firmware's default modem preset for regions in this group.
|
||||
Always one of `presets`. Clients should select this when switching to one
|
||||
of these regions, or when the current preset is not legal in the new region. */
|
||||
meshtastic_Config_LoRaConfig_ModemPreset default_preset;
|
||||
/* True if regions referencing this group are for licensed operators only
|
||||
(e.g. amateur / ham radio bands). Clients should warn or gate accordingly. */
|
||||
bool licensed_only;
|
||||
} meshtastic_LoRaPresetGroup;
|
||||
|
||||
/* Associates a single LoRa region with its preset group. */
|
||||
typedef struct _meshtastic_LoRaRegionPresets {
|
||||
/* The LoRa region this entry describes. */
|
||||
meshtastic_Config_LoRaConfig_RegionCode region;
|
||||
/* Index into LoRaRegionPresetMap.groups for the preset list that is legal
|
||||
in `region`. */
|
||||
uint8_t group_index;
|
||||
} meshtastic_LoRaRegionPresets;
|
||||
|
||||
/* Map describing which modem presets are valid for each LoRa region. Sent by
|
||||
the firmware during the want_config handshake (as FromRadio.region_presets)
|
||||
so that client UIs can prevent illegal region+preset selections.
|
||||
|
||||
Delivery is grouped to save space: `groups` holds each distinct preset list,
|
||||
and `region_groups` maps every known region to one of those groups by index.
|
||||
A region that does NOT appear in `region_groups` carries no constraint
|
||||
information and should not be restricted by the client (e.g. firmware that
|
||||
predates this message, or a region with no firmware table entry). Clients
|
||||
must also tolerate this whole message being absent. */
|
||||
typedef struct _meshtastic_LoRaRegionPresetMap {
|
||||
/* One entry per distinct (preset-list, default, licensing) combination.
|
||||
Referenced by index from `region_groups`. */
|
||||
pb_size_t groups_count;
|
||||
meshtastic_LoRaPresetGroup groups[8];
|
||||
/* One entry per known LoRa region, pointing at its preset group. */
|
||||
pb_size_t region_groups_count;
|
||||
meshtastic_LoRaRegionPresets region_groups[38];
|
||||
} meshtastic_LoRaRegionPresetMap;
|
||||
|
||||
/* Packets from the radio to the phone will appear on the fromRadio characteristic.
|
||||
It will support READ and NOTIFY. When a new packet arrives the device will BLE notify?
|
||||
It will sit in that descriptor until consumed by the phone,
|
||||
@@ -1403,6 +1458,12 @@ typedef struct _meshtastic_FromRadio {
|
||||
to report success or failure. Replaces the earlier scheme of
|
||||
encoding state as magic-string prefixes inside ClientNotification. */
|
||||
meshtastic_LockdownStatus lockdown_status;
|
||||
/* Map of which modem presets are legal in each LoRa region. Sent once
|
||||
during the want_config handshake (right after `metadata`, before the
|
||||
first `channel`) so client UIs can prevent the user from selecting an
|
||||
illegal region+preset combination. A region that does not appear in
|
||||
any group carries no constraint info and should not be restricted. */
|
||||
meshtastic_LoRaRegionPresetMap region_presets;
|
||||
};
|
||||
} meshtastic_FromRadio;
|
||||
|
||||
@@ -1596,6 +1657,12 @@ extern "C" {
|
||||
#define meshtastic_DeviceMetadata_role_ENUMTYPE meshtastic_Config_DeviceConfig_Role
|
||||
#define meshtastic_DeviceMetadata_hw_model_ENUMTYPE meshtastic_HardwareModel
|
||||
|
||||
#define meshtastic_LoRaPresetGroup_presets_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset
|
||||
#define meshtastic_LoRaPresetGroup_default_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset
|
||||
|
||||
#define meshtastic_LoRaRegionPresets_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1633,6 +1700,9 @@ extern "C" {
|
||||
#define meshtastic_NeighborInfo_init_default {0, 0, 0, 0, {meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default, meshtastic_Neighbor_init_default}}
|
||||
#define meshtastic_Neighbor_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_DeviceMetadata_init_default {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0}
|
||||
#define meshtastic_LoRaPresetGroup_init_default {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0}
|
||||
#define meshtastic_LoRaRegionPresets_init_default {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0}
|
||||
#define meshtastic_LoRaRegionPresetMap_init_default {0, {meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default, meshtastic_LoRaPresetGroup_init_default}, 0, {meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default, meshtastic_LoRaRegionPresets_init_default}}
|
||||
#define meshtastic_Heartbeat_init_default {0}
|
||||
#define meshtastic_NodeRemoteHardwarePin_init_default {0, false, meshtastic_RemoteHardwarePin_init_default}
|
||||
#define meshtastic_ChunkedPayload_init_default {0, 0, 0, {0, {0}}}
|
||||
@@ -1668,6 +1738,9 @@ extern "C" {
|
||||
#define meshtastic_NeighborInfo_init_zero {0, 0, 0, 0, {meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero, meshtastic_Neighbor_init_zero}}
|
||||
#define meshtastic_Neighbor_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_DeviceMetadata_init_zero {"", 0, 0, 0, 0, 0, _meshtastic_Config_DeviceConfig_Role_MIN, 0, _meshtastic_HardwareModel_MIN, 0, 0, 0}
|
||||
#define meshtastic_LoRaPresetGroup_init_zero {0, {_meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN}, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0}
|
||||
#define meshtastic_LoRaRegionPresets_init_zero {_meshtastic_Config_LoRaConfig_RegionCode_MIN, 0}
|
||||
#define meshtastic_LoRaRegionPresetMap_init_zero {0, {meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero, meshtastic_LoRaPresetGroup_init_zero}, 0, {meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero, meshtastic_LoRaRegionPresets_init_zero}}
|
||||
#define meshtastic_Heartbeat_init_zero {0}
|
||||
#define meshtastic_NodeRemoteHardwarePin_init_zero {0, false, meshtastic_RemoteHardwarePin_init_zero}
|
||||
#define meshtastic_ChunkedPayload_init_zero {0, 0, 0, {0, {0}}}
|
||||
@@ -1858,6 +1931,13 @@ extern "C" {
|
||||
#define meshtastic_DeviceMetadata_hasRemoteHardware_tag 10
|
||||
#define meshtastic_DeviceMetadata_hasPKC_tag 11
|
||||
#define meshtastic_DeviceMetadata_excluded_modules_tag 12
|
||||
#define meshtastic_LoRaPresetGroup_presets_tag 1
|
||||
#define meshtastic_LoRaPresetGroup_default_preset_tag 2
|
||||
#define meshtastic_LoRaPresetGroup_licensed_only_tag 3
|
||||
#define meshtastic_LoRaRegionPresets_region_tag 1
|
||||
#define meshtastic_LoRaRegionPresets_group_index_tag 2
|
||||
#define meshtastic_LoRaRegionPresetMap_groups_tag 1
|
||||
#define meshtastic_LoRaRegionPresetMap_region_groups_tag 2
|
||||
#define meshtastic_FromRadio_id_tag 1
|
||||
#define meshtastic_FromRadio_packet_tag 2
|
||||
#define meshtastic_FromRadio_my_info_tag 3
|
||||
@@ -1876,6 +1956,7 @@ extern "C" {
|
||||
#define meshtastic_FromRadio_clientNotification_tag 16
|
||||
#define meshtastic_FromRadio_deviceuiConfig_tag 17
|
||||
#define meshtastic_FromRadio_lockdown_status_tag 18
|
||||
#define meshtastic_FromRadio_region_presets_tag 19
|
||||
#define meshtastic_Heartbeat_nonce_tag 1
|
||||
#define meshtastic_ToRadio_packet_tag 1
|
||||
#define meshtastic_ToRadio_want_config_id_tag 3
|
||||
@@ -2120,7 +2201,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,mqttClientProxyMessage,mqttC
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,fileInfo,fileInfo), 15) \
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,clientNotification,clientNotification), 16) \
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,deviceuiConfig,deviceuiConfig), 17) \
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18)
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_status), 18) \
|
||||
X(a, STATIC, ONEOF, MESSAGE, (payload_variant,region_presets,region_presets), 19)
|
||||
#define meshtastic_FromRadio_CALLBACK NULL
|
||||
#define meshtastic_FromRadio_DEFAULT NULL
|
||||
#define meshtastic_FromRadio_payload_variant_packet_MSGTYPE meshtastic_MeshPacket
|
||||
@@ -2138,6 +2220,7 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,lockdown_status,lockdown_sta
|
||||
#define meshtastic_FromRadio_payload_variant_clientNotification_MSGTYPE meshtastic_ClientNotification
|
||||
#define meshtastic_FromRadio_payload_variant_deviceuiConfig_MSGTYPE meshtastic_DeviceUIConfig
|
||||
#define meshtastic_FromRadio_payload_variant_lockdown_status_MSGTYPE meshtastic_LockdownStatus
|
||||
#define meshtastic_FromRadio_payload_variant_region_presets_MSGTYPE meshtastic_LoRaRegionPresetMap
|
||||
|
||||
#define meshtastic_LockdownStatus_FIELDLIST(X, a) \
|
||||
X(a, STATIC, SINGULAR, UENUM, state, 1) \
|
||||
@@ -2256,6 +2339,27 @@ X(a, STATIC, SINGULAR, UINT32, excluded_modules, 12)
|
||||
#define meshtastic_DeviceMetadata_CALLBACK NULL
|
||||
#define meshtastic_DeviceMetadata_DEFAULT NULL
|
||||
|
||||
#define meshtastic_LoRaPresetGroup_FIELDLIST(X, a) \
|
||||
X(a, STATIC, REPEATED, UENUM, presets, 1) \
|
||||
X(a, STATIC, SINGULAR, UENUM, default_preset, 2) \
|
||||
X(a, STATIC, SINGULAR, BOOL, licensed_only, 3)
|
||||
#define meshtastic_LoRaPresetGroup_CALLBACK NULL
|
||||
#define meshtastic_LoRaPresetGroup_DEFAULT NULL
|
||||
|
||||
#define meshtastic_LoRaRegionPresets_FIELDLIST(X, a) \
|
||||
X(a, STATIC, SINGULAR, UENUM, region, 1) \
|
||||
X(a, STATIC, SINGULAR, UINT32, group_index, 2)
|
||||
#define meshtastic_LoRaRegionPresets_CALLBACK NULL
|
||||
#define meshtastic_LoRaRegionPresets_DEFAULT NULL
|
||||
|
||||
#define meshtastic_LoRaRegionPresetMap_FIELDLIST(X, a) \
|
||||
X(a, STATIC, REPEATED, MESSAGE, groups, 1) \
|
||||
X(a, STATIC, REPEATED, MESSAGE, region_groups, 2)
|
||||
#define meshtastic_LoRaRegionPresetMap_CALLBACK NULL
|
||||
#define meshtastic_LoRaRegionPresetMap_DEFAULT NULL
|
||||
#define meshtastic_LoRaRegionPresetMap_groups_MSGTYPE meshtastic_LoRaPresetGroup
|
||||
#define meshtastic_LoRaRegionPresetMap_region_groups_MSGTYPE meshtastic_LoRaRegionPresets
|
||||
|
||||
#define meshtastic_Heartbeat_FIELDLIST(X, a) \
|
||||
X(a, STATIC, SINGULAR, UINT32, nonce, 1)
|
||||
#define meshtastic_Heartbeat_CALLBACK NULL
|
||||
@@ -2320,6 +2424,9 @@ extern const pb_msgdesc_t meshtastic_Compressed_msg;
|
||||
extern const pb_msgdesc_t meshtastic_NeighborInfo_msg;
|
||||
extern const pb_msgdesc_t meshtastic_Neighbor_msg;
|
||||
extern const pb_msgdesc_t meshtastic_DeviceMetadata_msg;
|
||||
extern const pb_msgdesc_t meshtastic_LoRaPresetGroup_msg;
|
||||
extern const pb_msgdesc_t meshtastic_LoRaRegionPresets_msg;
|
||||
extern const pb_msgdesc_t meshtastic_LoRaRegionPresetMap_msg;
|
||||
extern const pb_msgdesc_t meshtastic_Heartbeat_msg;
|
||||
extern const pb_msgdesc_t meshtastic_NodeRemoteHardwarePin_msg;
|
||||
extern const pb_msgdesc_t meshtastic_ChunkedPayload_msg;
|
||||
@@ -2357,6 +2464,9 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
|
||||
#define meshtastic_NeighborInfo_fields &meshtastic_NeighborInfo_msg
|
||||
#define meshtastic_Neighbor_fields &meshtastic_Neighbor_msg
|
||||
#define meshtastic_DeviceMetadata_fields &meshtastic_DeviceMetadata_msg
|
||||
#define meshtastic_LoRaPresetGroup_fields &meshtastic_LoRaPresetGroup_msg
|
||||
#define meshtastic_LoRaRegionPresets_fields &meshtastic_LoRaRegionPresets_msg
|
||||
#define meshtastic_LoRaRegionPresetMap_fields &meshtastic_LoRaRegionPresetMap_msg
|
||||
#define meshtastic_Heartbeat_fields &meshtastic_Heartbeat_msg
|
||||
#define meshtastic_NodeRemoteHardwarePin_fields &meshtastic_NodeRemoteHardwarePin_msg
|
||||
#define meshtastic_ChunkedPayload_fields &meshtastic_ChunkedPayload_msg
|
||||
@@ -2380,6 +2490,9 @@ extern const pb_msgdesc_t meshtastic_ChunkedPayloadResponse_msg;
|
||||
#define meshtastic_KeyVerificationNumberInform_size 58
|
||||
#define meshtastic_KeyVerificationNumberRequest_size 52
|
||||
#define meshtastic_KeyVerification_size 79
|
||||
#define meshtastic_LoRaPresetGroup_size 26
|
||||
#define meshtastic_LoRaRegionPresetMap_size 490
|
||||
#define meshtastic_LoRaRegionPresets_size 5
|
||||
#define meshtastic_LockdownStatus_size 53
|
||||
#define meshtastic_LogRecord_size 426
|
||||
#define meshtastic_LowEntropyKey_size 0
|
||||
|
||||
@@ -232,34 +232,23 @@ typedef struct _meshtastic_ModuleConfig_PaxcounterConfig {
|
||||
/* Config for the Traffic Management module.
|
||||
Provides packet inspection and traffic shaping to help reduce channel utilization */
|
||||
typedef struct _meshtastic_ModuleConfig_TrafficManagementConfig {
|
||||
/* Master enable for traffic management module */
|
||||
bool enabled;
|
||||
/* Enable position deduplication to drop redundant position broadcasts */
|
||||
bool position_dedup_enabled;
|
||||
/* Number of bits of precision for position deduplication (0-32) */
|
||||
uint32_t position_precision_bits;
|
||||
/* Minimum interval in seconds between position updates from the same node */
|
||||
/* Minimum interval in seconds between position updates from the same node.
|
||||
A non-zero value implicitly enables the suppression window; 0 disables it. */
|
||||
uint32_t position_min_interval_secs;
|
||||
/* Enable direct response to NodeInfo requests from local cache */
|
||||
bool nodeinfo_direct_response;
|
||||
/* Minimum hop distance from requestor before responding to NodeInfo requests */
|
||||
/* Maximum hop distance from the requestor at which direct NodeInfo responses
|
||||
are served from the local cache. A non-zero value implicitly enables direct
|
||||
response; 0 disables it. */
|
||||
uint32_t nodeinfo_direct_response_max_hops;
|
||||
/* Enable per-node rate limiting to throttle chatty nodes */
|
||||
bool rate_limit_enabled;
|
||||
/* Time window in seconds for rate limiting calculations */
|
||||
/* Time window in seconds for per-node rate limiting.
|
||||
A non-zero value implicitly enables rate limiting; 0 disables it. */
|
||||
uint32_t rate_limit_window_secs;
|
||||
/* Maximum packets allowed per node within the rate limit window */
|
||||
/* Maximum packets allowed per node within the rate limit window.
|
||||
A non-zero value implicitly enables rate limiting; 0 disables it. */
|
||||
uint32_t rate_limit_max_packets;
|
||||
/* Enable dropping of unknown/undecryptable packets per rate_limit_window_secs */
|
||||
bool drop_unknown_enabled;
|
||||
/* Number of unknown packets before dropping from a node */
|
||||
/* Maximum unknown/undecryptable packets per rate window before the source
|
||||
is dropped. A non-zero value implicitly enables unknown-packet filtering;
|
||||
0 disables it. */
|
||||
uint32_t unknown_packet_threshold;
|
||||
/* Set hop_limit to 0 for relayed telemetry broadcasts (own packets unaffected) */
|
||||
bool exhaust_hop_telemetry;
|
||||
/* Set hop_limit to 0 for relayed position broadcasts (own packets unaffected) */
|
||||
bool exhaust_hop_position;
|
||||
/* Preserve hop_limit for router-to-router traffic */
|
||||
bool router_preserve_hops;
|
||||
} meshtastic_ModuleConfig_TrafficManagementConfig;
|
||||
|
||||
/* Serial Config */
|
||||
@@ -588,7 +577,7 @@ extern "C" {
|
||||
#define meshtastic_ModuleConfig_DetectionSensorConfig_init_default {0, 0, 0, 0, "", 0, _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_MIN, 0}
|
||||
#define meshtastic_ModuleConfig_AudioConfig_init_default {0, 0, _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MIN, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_init_default {0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_init_default {0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_SerialConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MIN, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MIN, 0}
|
||||
#define meshtastic_ModuleConfig_ExternalNotificationConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_StoreForwardConfig_init_default {0, 0, 0, 0, 0, 0}
|
||||
@@ -607,7 +596,7 @@ extern "C" {
|
||||
#define meshtastic_ModuleConfig_DetectionSensorConfig_init_zero {0, 0, 0, 0, "", 0, _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_MIN, 0}
|
||||
#define meshtastic_ModuleConfig_AudioConfig_init_zero {0, 0, _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MIN, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_init_zero {0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_init_zero {0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_SerialConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MIN, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MIN, 0}
|
||||
#define meshtastic_ModuleConfig_ExternalNotificationConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}
|
||||
#define meshtastic_ModuleConfig_StoreForwardConfig_init_zero {0, 0, 0, 0, 0, 0}
|
||||
@@ -656,20 +645,11 @@ extern "C" {
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_paxcounter_update_interval_tag 2
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_wifi_threshold_tag 3
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_ble_threshold_tag 4
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_enabled_tag 1
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_position_dedup_enabled_tag 2
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_position_precision_bits_tag 3
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_position_min_interval_secs_tag 4
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_nodeinfo_direct_response_tag 5
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_nodeinfo_direct_response_max_hops_tag 6
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_enabled_tag 7
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_window_secs_tag 8
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_max_packets_tag 9
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_drop_unknown_enabled_tag 10
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_unknown_packet_threshold_tag 11
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_exhaust_hop_telemetry_tag 12
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_exhaust_hop_position_tag 13
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_router_preserve_hops_tag 14
|
||||
#define meshtastic_ModuleConfig_SerialConfig_enabled_tag 1
|
||||
#define meshtastic_ModuleConfig_SerialConfig_echo_tag 2
|
||||
#define meshtastic_ModuleConfig_SerialConfig_rxd_tag 3
|
||||
@@ -867,20 +847,11 @@ X(a, STATIC, SINGULAR, INT32, ble_threshold, 4)
|
||||
#define meshtastic_ModuleConfig_PaxcounterConfig_DEFAULT NULL
|
||||
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_FIELDLIST(X, a) \
|
||||
X(a, STATIC, SINGULAR, BOOL, enabled, 1) \
|
||||
X(a, STATIC, SINGULAR, BOOL, position_dedup_enabled, 2) \
|
||||
X(a, STATIC, SINGULAR, UINT32, position_precision_bits, 3) \
|
||||
X(a, STATIC, SINGULAR, UINT32, position_min_interval_secs, 4) \
|
||||
X(a, STATIC, SINGULAR, BOOL, nodeinfo_direct_response, 5) \
|
||||
X(a, STATIC, SINGULAR, UINT32, nodeinfo_direct_response_max_hops, 6) \
|
||||
X(a, STATIC, SINGULAR, BOOL, rate_limit_enabled, 7) \
|
||||
X(a, STATIC, SINGULAR, UINT32, rate_limit_window_secs, 8) \
|
||||
X(a, STATIC, SINGULAR, UINT32, rate_limit_max_packets, 9) \
|
||||
X(a, STATIC, SINGULAR, BOOL, drop_unknown_enabled, 10) \
|
||||
X(a, STATIC, SINGULAR, UINT32, unknown_packet_threshold, 11) \
|
||||
X(a, STATIC, SINGULAR, BOOL, exhaust_hop_telemetry, 12) \
|
||||
X(a, STATIC, SINGULAR, BOOL, exhaust_hop_position, 13) \
|
||||
X(a, STATIC, SINGULAR, BOOL, router_preserve_hops, 14)
|
||||
X(a, STATIC, SINGULAR, UINT32, unknown_packet_threshold, 11)
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_CALLBACK NULL
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_DEFAULT NULL
|
||||
|
||||
@@ -1053,7 +1024,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg;
|
||||
#define meshtastic_ModuleConfig_StoreForwardConfig_size 24
|
||||
#define meshtastic_ModuleConfig_TAKConfig_size 4
|
||||
#define meshtastic_ModuleConfig_TelemetryConfig_size 50
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_size 52
|
||||
#define meshtastic_ModuleConfig_TrafficManagementConfig_size 30
|
||||
#define meshtastic_ModuleConfig_size 227
|
||||
#define meshtastic_RemoteHardwarePin_size 21
|
||||
|
||||
|
||||
@@ -48,39 +48,40 @@ static_assert(sizeof(meshtastic_NodeInfoLite) <= 130, "NodeInfoLite size increas
|
||||
#define MESHTASTIC_EXCLUDE_POSITIONDB 1
|
||||
#else
|
||||
#define MESHTASTIC_EXCLUDE_POSITIONDB 0
|
||||
#endif
|
||||
#endif
|
||||
#endif // STM32WL
|
||||
#endif // MESHTASTIC_EXCLUDE_POSITIONDB
|
||||
|
||||
#ifndef MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||
#if defined(ARCH_STM32WL)
|
||||
#define MESHTASTIC_EXCLUDE_TELEMETRYDB 1
|
||||
#else
|
||||
#define MESHTASTIC_EXCLUDE_TELEMETRYDB 0
|
||||
#endif
|
||||
#endif
|
||||
#endif // STM32WL
|
||||
#endif // MESHTASTIC_EXCLUDE_TELEMETRYDB
|
||||
|
||||
#ifndef MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||
#if defined(ARCH_STM32WL)
|
||||
#define MESHTASTIC_EXCLUDE_ENVIRONMENTDB 1
|
||||
#else
|
||||
#define MESHTASTIC_EXCLUDE_ENVIRONMENTDB 0
|
||||
#endif
|
||||
#endif
|
||||
#endif // STM32WL
|
||||
#endif // MESHTASTIC_EXCLUDE_ENVIRONMENTDB
|
||||
|
||||
#ifndef MESHTASTIC_EXCLUDE_STATUSDB
|
||||
#if defined(ARCH_STM32WL) || defined(MESHTASTIC_EXCLUDE_STATUS)
|
||||
#define MESHTASTIC_EXCLUDE_STATUSDB 1
|
||||
#else
|
||||
#define MESHTASTIC_EXCLUDE_STATUSDB 0
|
||||
#endif
|
||||
#endif
|
||||
#endif // STM32WL
|
||||
#endif // MESHTASTIC_EXCLUDE_STATUSDB
|
||||
|
||||
/// max number of nodes allowed in the nodeDB
|
||||
/// Max nodes in the hot store (full NodeInfoLite). Evicted nodes' identities
|
||||
/// live in the warm tier (WARM_NODE_COUNT). nRF52840 caps at 120 to keep
|
||||
/// nodes.proto inside the stock 28 KB LittleFS; flash-rich platforms (ESP32-S3,
|
||||
/// portduino) keep their larger hot store and lean on warm only for the tail.
|
||||
#ifndef MAX_NUM_NODES
|
||||
#if defined(ARCH_STM32WL)
|
||||
#define MAX_NUM_NODES 10
|
||||
#elif defined(ARCH_NRF52)
|
||||
#define MAX_NUM_NODES 150
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
|
||||
#include "Esp.h"
|
||||
static inline int get_max_num_nodes()
|
||||
@@ -95,38 +96,94 @@ static inline int get_max_num_nodes()
|
||||
}
|
||||
}
|
||||
#define MAX_NUM_NODES get_max_num_nodes()
|
||||
#elif defined(ARCH_PORTDUINO)
|
||||
#define MAX_NUM_NODES 250 // native host: no flash/RAM constraint; match the ESP32-S3 top tier
|
||||
#else
|
||||
#define MAX_NUM_NODES 100
|
||||
#endif
|
||||
#endif
|
||||
#define MAX_NUM_NODES 120 // nRF52840 and generic ESP32 (inc. ESP32C3 etc.)
|
||||
#endif // platform
|
||||
#endif // MAX_NUM_NODES
|
||||
|
||||
/// Per-map cap (position/telemetry/environment/status): only the freshest
|
||||
/// MAX_SATELLITE_NODES nodes keep satellite payloads, the rest just the
|
||||
/// NodeInfoLite header. RAM-bound (the maps are internal-SRAM, not PSRAM), so
|
||||
/// flash-rich hosts get a cap >= their hot store (satellites for every node, as
|
||||
/// before the cap existed) while constrained parts stay at 40.
|
||||
#ifndef MAX_SATELLITE_NODES
|
||||
#if (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO)
|
||||
#define MAX_SATELLITE_NODES 250
|
||||
#else
|
||||
#define MAX_SATELLITE_NODES 40 // nRF52840, generic ESP32, and ESP32-S3 without PSRAM
|
||||
#endif // platform
|
||||
#endif // MAX_SATELLITE_NODES
|
||||
|
||||
/// Warm tier: 40 B {num, last_heard, public_key} records kept for evicted nodes
|
||||
/// so DMs to/from them keep decrypting. 0 disables it; size is per-platform
|
||||
/// below, persisted to /prefs/warm.dat (or the nRF52840 raw-flash ring).
|
||||
#ifndef WARM_NODE_COUNT
|
||||
#if defined(ARCH_STM32WL)
|
||||
#define WARM_NODE_COUNT 0
|
||||
#elif defined(NRF52840_XXAA)
|
||||
// Keyed on the NRF52840_XXAA build flag, not ARCH_NRF52: the latter (from
|
||||
// architecture.h via configuration.h) isn't defined this early in every include
|
||||
// chain. Backed by the raw-flash ring below LittleFS — see WarmNodeStore.h.
|
||||
#define WARM_NODE_COUNT 200
|
||||
#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO)
|
||||
#define WARM_NODE_COUNT 2000 // PSRAM-equipped ESP32-S3 / native host; warm cache in PSRAM (~80 KB)
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4)
|
||||
#define WARM_NODE_COUNT 150 // 512 KB+ SRAM, no PSRAM (S3/C6/P4): ~6 KB heap (#10705)
|
||||
#elif defined(ARCH_ESP32)
|
||||
#define WARM_NODE_COUNT 100 // classic ESP32 (520 KB) / S2 (320 KB) / C3 (400 KB): tightest free heap w/ BLE+WiFi, ~4 KB (#10705)
|
||||
#elif defined(ARCH_RP2040)
|
||||
#define WARM_NODE_COUNT 150 // RP2040 (264 KB) / RP2350 (520 KB): bounded so warm.dat write fits the 8s watchdog (#10746)
|
||||
#else
|
||||
// nRF52840 is handled explicitly above (200, raw-flash ring). Any other nRF52 (non-XXAA) and any
|
||||
// future non-ESP32/non-RP LittleFS part fall through to this 320 default — flag for review if such a
|
||||
// RAM-constrained nRF52 target is ever added.
|
||||
#define WARM_NODE_COUNT 320 // other LittleFS-backed parts (e.g. non-nRF52840 nRF52)
|
||||
#endif // platform
|
||||
#endif // WARM_NODE_COUNT
|
||||
|
||||
/// Max number of channels allowed
|
||||
#define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0]))
|
||||
|
||||
// Traffic Management module configuration
|
||||
// Enable per-variant by defining HAS_TRAFFIC_MANAGEMENT=1 in variant.h
|
||||
#ifndef HAS_TRAFFIC_MANAGEMENT
|
||||
// Enabled by default; STM32WL is excluded due to RAM constraints (MAX_NUM_NODES=10).
|
||||
// Disable per-variant by defining HAS_TRAFFIC_MANAGEMENT=0 in variant.h
|
||||
#ifdef ARCH_STM32WL
|
||||
#define HAS_TRAFFIC_MANAGEMENT 0
|
||||
#endif
|
||||
#ifndef HAS_TRAFFIC_MANAGEMENT
|
||||
#define HAS_TRAFFIC_MANAGEMENT 1
|
||||
#endif
|
||||
|
||||
// HopScalingModule - variable hop module: dynamically adjusts broadcast hop_limit based on mesh density
|
||||
// Enable per-variant by defining HAS_VARIABLE_HOPS=1 in variant.h
|
||||
#ifdef ARCH_STM32WL
|
||||
#define HAS_VARIABLE_HOPS 0
|
||||
#endif
|
||||
|
||||
#ifndef HAS_VARIABLE_HOPS
|
||||
#define HAS_VARIABLE_HOPS 1
|
||||
#endif
|
||||
|
||||
// Cache size for traffic management (number of nodes to track)
|
||||
// Can be overridden per-variant based on available memory
|
||||
// Can be overridden per-variant by defining before this header is included.
|
||||
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000
|
||||
#else
|
||||
#if !HAS_TRAFFIC_MANAGEMENT
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 0
|
||||
#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO)
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 // PSRAM-equipped ESP32-S3 / native host
|
||||
#elif defined(CONFIG_IDF_TARGET_ESP32S3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32P4)
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 500 // 512 KB+ SRAM, no PSRAM (S3/C6/P4): ~5 KB heap (#10705)
|
||||
#elif defined(ARCH_ESP32)
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 400 // classic ESP32 / S2 / C3: tightest free heap, ~4 KB (#10705)
|
||||
#else
|
||||
// nRF52 (incl. nRF52840) and RP2040/RP2350 fall through here — there is no nRF/RP branch above,
|
||||
// by design. These parts have no ESP32-style WiFi+BLE coexistence eating the heap, so the larger
|
||||
// 1000-entry (~10 KB) cache fits: nRF52840 is BLE-only on 256 KB RAM; RP2040/RP2350 have 264/520 KB.
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000 // nRF52 / RP2040 / RP2350 / other non-ESP32
|
||||
#endif
|
||||
#endif
|
||||
#endif // TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
|
||||
/// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic
|
||||
/// returns the encoded packet size
|
||||
|
||||
@@ -78,6 +78,9 @@ class UdpMulticastHandler final
|
||||
return;
|
||||
}
|
||||
mp.transport_mechanism = meshtastic_MeshPacket_TransportMechanism_TRANSPORT_MULTICAST_UDP;
|
||||
// Preserve the whole MeshPacket as received: while payload_variant is encrypted, `channel` is a hash (and is 0 for
|
||||
// PKI DMs), so it must be copied verbatim for the router to attempt PKI/channel decryption. Keep
|
||||
// pki_encrypted/public_key too so downstream auth/metadata can reflect PKI usage correctly.
|
||||
UniquePacketPoolPacket p = packetPool.allocUniqueCopy(mp);
|
||||
// Unset received SNR/RSSI
|
||||
p->rx_snr = 0;
|
||||
|
||||
+83
-33
@@ -2,6 +2,7 @@
|
||||
#include "Channels.h"
|
||||
#include "MeshService.h"
|
||||
#include "NodeDB.h"
|
||||
#include "PositionPrecision.h"
|
||||
#include "PowerFSM.h"
|
||||
#include "RTC.h"
|
||||
#include "SPILock.h"
|
||||
@@ -19,6 +20,7 @@
|
||||
#include "main.h"
|
||||
#endif
|
||||
#ifdef ARCH_PORTDUINO
|
||||
#include "PortduinoGlue.h"
|
||||
#include "unistd.h"
|
||||
#endif
|
||||
|
||||
@@ -106,13 +108,29 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) {
|
||||
return handled;
|
||||
}
|
||||
#ifdef ARCH_PORTDUINO
|
||||
// Simulator only: honor exit_simulator unconditionally for the local client (from==0).
|
||||
// The from==0 branch below now covers pki_encrypted local packets too, but is_managed
|
||||
// can still block it. Rather than threading simulator awareness through the auth gates,
|
||||
// intercept here before any auth logic runs. Local-origin + force_simradio only.
|
||||
// TODO: should a local client bypass admin auth at all? Fenced to the simulator for now.
|
||||
if (portduino_config.force_simradio && mp.from == 0 &&
|
||||
r->which_payload_variant == meshtastic_AdminMessage_exit_simulator_tag) {
|
||||
LOG_INFO("Exiting simulator");
|
||||
exit(0);
|
||||
}
|
||||
#endif
|
||||
meshtastic_Channel *ch = &channels.getByIndex(mp.channel);
|
||||
// Could tighten this up further by tracking the last public_key we went an AdminMessage request to
|
||||
// and only allowing responses from that remote.
|
||||
if (messageIsResponse(r)) {
|
||||
LOG_DEBUG("Allow admin response message");
|
||||
} else if (mp.from == 0 && !mp.pki_encrypted) {
|
||||
// Plain (non-PKC) local admin from BLE/USB client.
|
||||
} else if (mp.from == 0) {
|
||||
// Local admin from a BLE/USB/TCP client. from == 0 cannot arrive from the
|
||||
// mesh: RF drops packets without a sender (RadioLibInterface) and MQTT treats
|
||||
// from == 0 as our own downlink and ignores it. Clients may set pki_encrypted
|
||||
// on self-addressed admin (the python CLI does), so don't use it to reroute
|
||||
// local packets into the remote-PKC key check.
|
||||
//
|
||||
// Under MESHTASTIC_PHONEAPI_ACCESS_CONTROL, the per-connection auth
|
||||
// gate lives in PhoneAPI::handleToRadioPacket — any local admin
|
||||
@@ -163,8 +181,11 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
// without the user doing so deliberately.
|
||||
LOG_INFO("PKC admin valid, but not auto-favoriting node %x because role==CLIENT_BASE", mp.from);
|
||||
} else {
|
||||
LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from);
|
||||
nodeInfoLiteSetBit(remoteNode, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
||||
if (nodeDB->setProtectedFlag(remoteNode, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) {
|
||||
LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from);
|
||||
} else {
|
||||
LOG_WARN("PKC admin valid, but auto-favorite refused for node %x (protected-node cap)", mp.from);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -454,10 +475,15 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
LOG_INFO("Client received set_favorite_node command");
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_favorite_node);
|
||||
if (node != NULL) {
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||
if (screen)
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
||||
if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) {
|
||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||
if (screen)
|
||||
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
|
||||
} else if (mp.from == 0) { // local request from the phone — tell the user why it didn't take
|
||||
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "favorite", r->set_favorite_node, MAX_NUM_NODES - 2);
|
||||
} else {
|
||||
LOG_WARN("Remote set_favorite_node for 0x%x refused: protected-node cap", r->set_favorite_node);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -474,13 +500,19 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
}
|
||||
case meshtastic_AdminMessage_set_ignored_node_tag: {
|
||||
LOG_INFO("Client received set_ignored_node command");
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_ignored_node);
|
||||
// Unlike the sibling node-targeted admin commands, create the entry if
|
||||
// it's absent so the block sticks for a node we've not heard from yet
|
||||
// (e.g. one a remote admin asks us to block) with no NodeInfo or key.
|
||||
meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(r->set_ignored_node);
|
||||
if (node != NULL) {
|
||||
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||
nodeDB->eraseNodeSatellites(node->num);
|
||||
node->public_key.size = 0;
|
||||
memset(node->public_key.bytes, 0, sizeof(node->public_key.bytes));
|
||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||
if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
|
||||
nodeDB->eraseNodeSatellites(node->num);
|
||||
saveChanges(SEGMENT_NODEDATABASE, false);
|
||||
} else if (mp.from == 0) { // local request from the phone — tell the user why it didn't take
|
||||
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", r->set_ignored_node, MAX_NUM_NODES - 2);
|
||||
} else {
|
||||
LOG_WARN("Remote set_ignored_node for 0x%x refused: protected-node cap", r->set_ignored_node);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -542,7 +574,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
#if HAS_SCREEN
|
||||
IF_SCREEN(screen->showSimpleBanner("Device is rebooting\ninto DFU mode.", 0));
|
||||
#endif
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32WL)
|
||||
#if defined(ARCH_NRF52) || defined(ARCH_RP2040) || defined(ARCH_STM32)
|
||||
enterDfuMode();
|
||||
#endif
|
||||
break;
|
||||
@@ -987,22 +1019,14 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
LOG_INFO("Set config: Security");
|
||||
config.security = c.payload_variant.security;
|
||||
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
|
||||
// If the client set the key to blank, go ahead and regenerate so long as we're not in ham mode
|
||||
if (!owner.is_licensed && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
if (config.security.private_key.size != 32) {
|
||||
crypto->generateKeyPair(config.security.public_key.bytes, config.security.private_key.bytes);
|
||||
|
||||
} else {
|
||||
if (crypto->regeneratePublicKey(config.security.public_key.bytes, config.security.private_key.bytes)) {
|
||||
config.security.public_key.size = 32;
|
||||
}
|
||||
}
|
||||
// Only regenerate keys if the private key is not 32 bytes
|
||||
if (config.security.private_key.size != 32) {
|
||||
nodeDB->generateCryptoKeyPair();
|
||||
}
|
||||
// If user provided a private key of correct size but no public key, generate the public key from private key
|
||||
else if (config.security.private_key.size == 32 && config.security.public_key.size == 0) {
|
||||
nodeDB->generateCryptoKeyPair(config.security.private_key.bytes);
|
||||
}
|
||||
#endif
|
||||
owner.public_key.size = config.security.public_key.size;
|
||||
memcpy(owner.public_key.bytes, config.security.public_key.bytes, config.security.public_key.size);
|
||||
#if !MESHTASTIC_EXCLUDE_PKI
|
||||
crypto->setDHPrivateKey(config.security.private_key.bytes);
|
||||
#endif
|
||||
if (config.security.is_managed && !(config.security.admin_key[0].size == 32 || config.security.admin_key[1].size == 32 ||
|
||||
config.security.admin_key[2].size == 32)) {
|
||||
@@ -1012,9 +1036,9 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
|
||||
sendWarning(warning);
|
||||
}
|
||||
|
||||
if (config.security.debug_log_api_enabled == c.payload_variant.security.debug_log_api_enabled &&
|
||||
config.security.serial_enabled == c.payload_variant.security.serial_enabled)
|
||||
requiresReboot = false;
|
||||
changes = SEGMENT_CONFIG | SEGMENT_DEVICESTATE | SEGMENT_NODEDATABASE;
|
||||
|
||||
requiresReboot = true;
|
||||
|
||||
break;
|
||||
case meshtastic_Config_device_ui_tag:
|
||||
@@ -1148,7 +1172,26 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc)
|
||||
if (channels.ensureLicensedOperation()) {
|
||||
sendWarning(licensedModeMessage);
|
||||
}
|
||||
// Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey()
|
||||
// resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex;
|
||||
// running the clamp first could evaluate secondaries against the previous primary and skip the clamp/warning.
|
||||
channels.onConfigChanged(); // tell the radios about this change
|
||||
|
||||
// Persist the public-key precision clamp for all channels that may be affected (e.g. secondaries
|
||||
// that inherit a now-public primary key) and warn the client once if anything was coarsened.
|
||||
bool clamped = false;
|
||||
for (uint8_t i = 0; i < channels.getNumChannels(); i++) {
|
||||
meshtastic_Channel &ch = channels.getByIndex(i);
|
||||
if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.settings.has_module_settings)
|
||||
continue;
|
||||
uint32_t allowed = getPositionPrecisionForChannel(i);
|
||||
if (allowed != ch.settings.module_settings.position_precision) {
|
||||
ch.settings.module_settings.position_precision = allowed;
|
||||
clamped = true;
|
||||
}
|
||||
}
|
||||
if (clamped)
|
||||
sendWarning(publicChannelPrecisionMessage);
|
||||
saveChanges(SEGMENT_CHANNELS, false);
|
||||
}
|
||||
|
||||
@@ -1376,6 +1419,13 @@ void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &r
|
||||
|
||||
void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
|
||||
{
|
||||
#if WARM_NODE_COUNT > 0 && MESHTASTIC_NODEDB_MIGRATION_VERBOSE
|
||||
// Debug aid: dump the warm tier to the console on a local metadata request
|
||||
// (e.g. `meshtastic --info` over USB/BLE). Gated to req.from == 0 so remote
|
||||
// or admin polling can't spam the console.
|
||||
if (nodeDB && req.from == 0)
|
||||
nodeDB->warmStore.dumpToLog("admin get_metadata");
|
||||
#endif
|
||||
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
|
||||
r.get_device_metadata_response = getDeviceMetadata();
|
||||
r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag;
|
||||
|
||||
@@ -88,6 +88,9 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
||||
static constexpr const char *licensedModeMessage =
|
||||
"Licensed mode activated, removing admin channel and encryption from all channels";
|
||||
|
||||
static constexpr const char *publicChannelPrecisionMessage =
|
||||
"Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision";
|
||||
|
||||
extern AdminModule *adminModule;
|
||||
|
||||
void disableBluetooth();
|
||||
@@ -84,7 +84,11 @@ void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChan
|
||||
// Do NOT override explicit broadcast replies
|
||||
// Only reuse lastDest in LaunchRepeatDestination()
|
||||
|
||||
dest = newDest;
|
||||
if (newDest == 0) {
|
||||
dest = NODENUM_BROADCAST;
|
||||
} else {
|
||||
dest = newDest;
|
||||
}
|
||||
channel = newChannel;
|
||||
|
||||
lastDest = dest;
|
||||
@@ -124,7 +128,11 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t
|
||||
// Do NOT override explicit broadcast replies
|
||||
// Only reuse lastDest in LaunchRepeatDestination()
|
||||
|
||||
dest = newDest;
|
||||
if (newDest == 0) {
|
||||
dest = NODENUM_BROADCAST;
|
||||
} else {
|
||||
dest = newDest;
|
||||
}
|
||||
channel = newChannel;
|
||||
|
||||
lastDest = dest;
|
||||
|
||||
@@ -128,8 +128,7 @@ void setupModules()
|
||||
#endif
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT
|
||||
// Instantiate only when enabled to avoid extra memory use and background work.
|
||||
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled) {
|
||||
if (moduleConfig.has_traffic_management) {
|
||||
trafficManagementModule = new TrafficManagementModule();
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -49,6 +49,12 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
|
||||
LOG_WARN("Invalid nodeInfo detected, is_licensed mismatch!");
|
||||
return true;
|
||||
}
|
||||
NodeNum sourceNum = getFrom(&mp);
|
||||
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum);
|
||||
if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed) {
|
||||
LOG_WARN("Dropping unsigned NodeInfo from node 0x%08x that previously signed", sourceNum);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Coerce user.id to be derived from the node number
|
||||
snprintf(p.id, sizeof(p.id), "!%08x", getFrom(&mp));
|
||||
|
||||
@@ -432,6 +432,42 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha
|
||||
|
||||
#define RUNONCE_INTERVAL 5000;
|
||||
|
||||
bool PositionModule::positionUnchangedSinceLastSend(const meshtastic_PositionLite &selfPos, bool useConfiguredPrecision)
|
||||
{
|
||||
if (lastGpsLatitude == 0 && lastGpsLongitude == 0)
|
||||
return false; // no prior broadcast to compare against
|
||||
|
||||
// Broadcast channel = the one sendOurPosition() would pick (first with non-zero on-wire
|
||||
// precision). Default nodes gauge movement at that on-wire (public-clamped) resolution;
|
||||
// trackers use their own configured (unclamped) precision so finer moves still count.
|
||||
uint32_t precisionBits = 0;
|
||||
for (uint8_t ch = 0; ch < 8; ch++) {
|
||||
if (getPositionPrecisionForChannel(ch) == 0)
|
||||
continue;
|
||||
precisionBits =
|
||||
useConfiguredPrecision ? getPositionPrecisionForChannel(channels.getByIndex(ch)) : getPositionPrecisionForChannel(ch);
|
||||
break;
|
||||
}
|
||||
|
||||
return positionWithinPrecisionCell(selfPos.latitude_i, selfPos.longitude_i, lastGpsLatitude, lastGpsLongitude, precisionBits);
|
||||
}
|
||||
|
||||
bool PositionModule::positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int32_t bLat, int32_t bLon, uint32_t precision)
|
||||
{
|
||||
if (precision == 0 || precision >= 32)
|
||||
return false; // sharing disabled or full precision: no coarse cell to hold within
|
||||
|
||||
return truncateCoordinate(aLat, precision) == truncateCoordinate(bLat, precision) &&
|
||||
truncateCoordinate(aLon, precision) == truncateCoordinate(bLon, precision);
|
||||
}
|
||||
|
||||
uint32_t PositionModule::effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs)
|
||||
{
|
||||
if (stationary && stationaryFloorMs > configuredIntervalMs)
|
||||
return stationaryFloorMs;
|
||||
return configuredIntervalMs;
|
||||
}
|
||||
|
||||
int32_t PositionModule::runOnce()
|
||||
{
|
||||
if (sleepOnNextExecution == true) {
|
||||
@@ -458,7 +494,23 @@ int32_t PositionModule::runOnce()
|
||||
|
||||
bool waitingForFreshPosition = (lastGpsSend == 0) && !config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot();
|
||||
|
||||
if (lastGpsSend == 0 || msSinceLastSend >= intervalMs) {
|
||||
// Hold to the 12h floor when fixed_position (every role: pinning yourself forfeits the
|
||||
// exception) or when stationary. A real move still goes out early via smart-broadcast below.
|
||||
// Not-fixed exceptions: lost-and-found broadcasts freely; trackers judge movement at their
|
||||
// own (unclamped) precision rather than the on-wire one (useConfiguredPrecision).
|
||||
const auto role = config.device.role;
|
||||
bool stationary = config.position.fixed_position;
|
||||
if (!stationary && role != meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND && nodeDB->hasValidPosition(node)) {
|
||||
const bool isTracker =
|
||||
IS_ONE_OF(role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_TAK_TRACKER);
|
||||
meshtastic_PositionLite selfPos;
|
||||
if (nodeDB->copyNodePosition(node->num, selfPos))
|
||||
stationary = positionUnchangedSinceLastSend(selfPos, /*useConfiguredPrecision=*/isTracker);
|
||||
}
|
||||
uint32_t effectiveIntervalMs =
|
||||
effectiveBroadcastIntervalMs(intervalMs, stationary, (uint32_t)default_position_stationary_broadcast_secs * 1000UL);
|
||||
|
||||
if (lastGpsSend == 0 || msSinceLastSend >= effectiveIntervalMs) {
|
||||
if (waitingForFreshPosition) {
|
||||
#ifdef GPS_DEBUG
|
||||
LOG_DEBUG("Skip initial position send; no fresh position since boot");
|
||||
|
||||
@@ -38,6 +38,14 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
|
||||
|
||||
void handleNewPosition();
|
||||
|
||||
// Pure broadcast-policy helpers, split out so they're unit-testable without the module.
|
||||
// True when two coordinates truncate to the same precision cell (so a re-broadcast would be a
|
||||
// duplicate). precision 0 or >=32 returns false: no coarse cell to hold within, never suppress.
|
||||
static bool positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int32_t bLat, int32_t bLon, uint32_t precision);
|
||||
// Effective min interval: stationary positions are held to stationaryFloorMs (when that is the
|
||||
// longer of the two); otherwise the normal configured interval.
|
||||
static uint32_t effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs);
|
||||
|
||||
protected:
|
||||
/** Called to handle a particular incoming message
|
||||
|
||||
@@ -57,6 +65,12 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
|
||||
private:
|
||||
meshtastic_MeshPacket *allocPositionPacket();
|
||||
struct SmartPosition getDistanceTraveledSinceLastSend(meshtastic_PositionLite currentPosition);
|
||||
// True when our position is unchanged since the last broadcast: it truncates to the same
|
||||
// precision grid cell, so re-sending would be a duplicate that traffic management dedups
|
||||
// downstream anyway. Used to hold stationary broadcasts to a 12h floor. useConfiguredPrecision
|
||||
// gauges movement at our own configured (unclamped) precision rather than the on-wire
|
||||
// (public-clamped) precision — trackers report finer movement.
|
||||
bool positionUnchangedSinceLastSend(const meshtastic_PositionLite &selfPos, bool useConfiguredPrecision);
|
||||
meshtastic_MeshPacket *allocAtakPli();
|
||||
void trySetRtc(meshtastic_Position p, bool isLocal, bool forceUpdate = false);
|
||||
uint32_t precision;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include "StatusLEDModule.h"
|
||||
#include "MeshService.h"
|
||||
#include "configuration.h"
|
||||
#include "mesh/RadioInterface.h"
|
||||
#include <Arduino.h>
|
||||
|
||||
/*
|
||||
@@ -17,6 +18,9 @@ StatusLEDModule::StatusLEDModule() : concurrency::OSThread("StatusLEDModule")
|
||||
if (inputBroker)
|
||||
inputObserver.observe(inputBroker);
|
||||
#endif
|
||||
#ifdef LED_LORA
|
||||
loraRxObserver.observe(&RadioInterface::loraRxPacketObservable);
|
||||
#endif
|
||||
#ifdef NEOPIXEL_STATUS_POWER_PIN
|
||||
powerPixel.begin();
|
||||
powerPixel.clear();
|
||||
@@ -90,6 +94,18 @@ int StatusLEDModule::handleInputEvent(const InputEvent *event)
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
#ifdef LED_LORA
|
||||
int StatusLEDModule::handleLoRaRx(uint32_t)
|
||||
{
|
||||
// Briefly flash LED_LORA on each received packet. Turn it on now (we share the main thread with
|
||||
// the radio's receive handler, so this is safe) and wake runOnce() at flash end to turn it off.
|
||||
digitalWrite(LED_LORA, LED_STATE_ON);
|
||||
LORA_LED_state = LED_STATE_ON;
|
||||
LORA_LED_starttime = millis();
|
||||
setIntervalFromNow(LORA_RX_LED_FLASH_MS);
|
||||
return 0;
|
||||
}
|
||||
#endif
|
||||
|
||||
int32_t StatusLEDModule::runOnce()
|
||||
{
|
||||
@@ -115,6 +131,12 @@ int32_t StatusLEDModule::runOnce()
|
||||
CHARGE_LED_state = LED_STATE_OFF;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
#if defined(LED_HEARTBEAT)
|
||||
// If we are using the heartbeat, as in the Thinknode M4, we need to explicitly turn off the charge LED
|
||||
// This probably implies that in the future we need to stop re-using this bool for multiple purposes.
|
||||
CHARGE_LED_state = LED_STATE_OFF;
|
||||
#endif
|
||||
}
|
||||
// If we want a LED to be dedicated to the simple hearbeat, we can use that instead of the charge LED
|
||||
#if defined(LED_HEARTBEAT)
|
||||
@@ -142,6 +164,7 @@ int32_t StatusLEDModule::runOnce()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#ifdef LED_PAIRING
|
||||
if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) {
|
||||
PAIRING_LED_state = LED_STATE_OFF;
|
||||
} else if (ble_state == unpaired) {
|
||||
@@ -156,6 +179,7 @@ int32_t StatusLEDModule::runOnce()
|
||||
} else {
|
||||
PAIRING_LED_state = LED_STATE_ON;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Override if disabled in config
|
||||
if (config.device.led_heartbeat_disabled) {
|
||||
@@ -227,6 +251,20 @@ int32_t StatusLEDModule::runOnce()
|
||||
digitalWrite(Battery_LED_4, chargeIndicatorLED4);
|
||||
#endif
|
||||
|
||||
#ifdef LED_LORA
|
||||
// End the LoRa-RX flash once its duration has elapsed; otherwise make sure we come back
|
||||
// exactly at flash end (only ever clamp my_interval down, so other LED timing is preserved).
|
||||
if (LORA_LED_state == LED_STATE_ON) {
|
||||
uint32_t elapsed = millis() - LORA_LED_starttime;
|
||||
if (elapsed >= LORA_RX_LED_FLASH_MS) {
|
||||
digitalWrite(LED_LORA, LED_STATE_OFF);
|
||||
LORA_LED_state = LED_STATE_OFF;
|
||||
} else if ((uint32_t)my_interval > LORA_RX_LED_FLASH_MS - elapsed) {
|
||||
my_interval = LORA_RX_LED_FLASH_MS - elapsed;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
return (my_interval);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,9 @@ class StatusLEDModule : private concurrency::OSThread
|
||||
#if !MESHTASTIC_EXCLUDE_INPUTBROKER
|
||||
int handleInputEvent(const InputEvent *arg);
|
||||
#endif
|
||||
#ifdef LED_LORA
|
||||
int handleLoRaRx(uint32_t sender);
|
||||
#endif
|
||||
|
||||
void setPowerLED(bool);
|
||||
|
||||
@@ -65,6 +68,10 @@ class StatusLEDModule : private concurrency::OSThread
|
||||
CallbackObserver<StatusLEDModule, const InputEvent *> inputObserver =
|
||||
CallbackObserver<StatusLEDModule, const InputEvent *>(this, &StatusLEDModule::handleInputEvent);
|
||||
#endif
|
||||
#ifdef LED_LORA
|
||||
CallbackObserver<StatusLEDModule, uint32_t> loraRxObserver =
|
||||
CallbackObserver<StatusLEDModule, uint32_t>(this, &StatusLEDModule::handleLoRaRx);
|
||||
#endif
|
||||
|
||||
private:
|
||||
bool CHARGE_LED_state = LED_STATE_OFF;
|
||||
@@ -77,6 +84,11 @@ class StatusLEDModule : private concurrency::OSThread
|
||||
uint32_t lastUserbuttonTime = 0;
|
||||
uint32_t POWER_LED_starttime = 0;
|
||||
bool doing_fast_blink = false;
|
||||
#ifdef LED_LORA
|
||||
static constexpr uint32_t LORA_RX_LED_FLASH_MS = 100;
|
||||
bool LORA_LED_state = LED_STATE_OFF;
|
||||
uint32_t LORA_LED_starttime = 0;
|
||||
#endif
|
||||
|
||||
enum PowerState { discharging, charging, charged, critical };
|
||||
|
||||
|
||||
@@ -428,20 +428,6 @@ bool AirQualityTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly)
|
||||
LOG_DEBUG("Start next execution in 5s, then sleep");
|
||||
setIntervalFromNow(FIVE_SECONDS_MS);
|
||||
}
|
||||
|
||||
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR && config.power.is_power_saving) {
|
||||
meshtastic_ClientNotification *notification = clientNotificationPool.allocZeroed();
|
||||
notification->level = meshtastic_LogRecord_Level_INFO;
|
||||
notification->time = getValidTime(RTCQualityFromNet);
|
||||
sprintf(notification->message, "Sending telemetry and sleeping for %us interval in a moment",
|
||||
Default::getConfiguredOrDefaultMs(moduleConfig.telemetry.air_quality_interval,
|
||||
default_telemetry_broadcast_interval_secs) /
|
||||
1000U);
|
||||
service->sendClientNotification(notification);
|
||||
sleepOnNextExecution = true;
|
||||
LOG_DEBUG("Start next execution in 5s, then sleep");
|
||||
setIntervalFromNow(FIVE_SECONDS_MS);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@
|
||||
#include "meshUtils.h"
|
||||
#include <vector>
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
#include "modules/TrafficManagementModule.h"
|
||||
#endif
|
||||
|
||||
extern graphics::Screen *screen;
|
||||
|
||||
TraceRouteModule *traceRouteModule;
|
||||
@@ -323,6 +327,14 @@ void TraceRouteModule::maybeSetNextHop(NodeNum target, uint8_t nextHopByte)
|
||||
LOG_INFO("Updating next-hop for 0x%08x to 0x%02x based on traceroute", target, nextHopByte);
|
||||
node->next_hop = nextHopByte;
|
||||
}
|
||||
|
||||
#if HAS_TRAFFIC_MANAGEMENT
|
||||
// Mirror into the TMM overflow cache. Traceroute is the highest-confidence
|
||||
// source (full known route), and this captures the target even when it isn't
|
||||
// in the hot NodeDB — same rationale as the ACK-confirmed path in NextHopRouter.
|
||||
if (trafficManagementModule)
|
||||
trafficManagementModule->setNextHop(target, nextHopByte);
|
||||
#endif
|
||||
}
|
||||
|
||||
void TraceRouteModule::processUpgradedPacket(const meshtastic_MeshPacket &mp)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,9 +20,12 @@
|
||||
* - Router hop preservation (maintain hop_limit for router-to-router traffic)
|
||||
*
|
||||
* Memory Optimization:
|
||||
* Uses a unified cache with cuckoo hashing for O(1) lookups and 56% memory reduction
|
||||
* compared to separate per-feature caches. Timestamps are stored as 8-bit relative
|
||||
* offsets from a rolling epoch to further reduce memory footprint.
|
||||
* Uses one flat unified cache (plain array, linear scan) shared by all
|
||||
* per-node features instead of separate per-feature caches. Timestamps are
|
||||
* stored as free-running modular tick counters (pos: 8-bit 360 s/tick;
|
||||
* rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry.
|
||||
* LoRa packet rates are low enough that an O(n) scan of ~1000 entries is
|
||||
* negligible next to packet processing.
|
||||
*/
|
||||
class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
{
|
||||
@@ -38,6 +41,24 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
void resetStats();
|
||||
void recordRouterHopPreserved();
|
||||
|
||||
// Next-hop overflow cache (routing hint).
|
||||
// setNextHop: store a confirmed last-byte next hop for `dest`. Called by
|
||||
// NextHopRouter from its ACK-confirmed decision (see sniffReceived). The
|
||||
// byte must come from a bidirectionally-verified relay, not one-way inference.
|
||||
// getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown.
|
||||
// clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store
|
||||
// 0, so this is the way NextHopRouter decays a stale/failing overflow route).
|
||||
void setNextHop(NodeNum dest, uint8_t nextHopByte);
|
||||
uint8_t getNextHopHint(NodeNum dest);
|
||||
void clearNextHop(NodeNum dest);
|
||||
|
||||
// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed
|
||||
// hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after
|
||||
// nodeDB is populated (lazily on first maintenance pass).
|
||||
// @return true if it actually ran (prereqs met / nothing to do); false if
|
||||
// prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry.
|
||||
bool preloadNextHopsFromNodeDB();
|
||||
|
||||
/**
|
||||
* Check if this packet should have its hops exhausted.
|
||||
* Called from perhapsRebroadcast() to force hop_limit = 0 regardless of
|
||||
@@ -48,182 +69,126 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id;
|
||||
}
|
||||
|
||||
// Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can
|
||||
// advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick.
|
||||
// Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs;
|
||||
// ignored in production (clockMs() returns millis()).
|
||||
inline static uint32_t s_testNowMs = 0;
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
static uint32_t clockMs() { return s_testNowMs; }
|
||||
#else
|
||||
static uint32_t clockMs() { return millis(); }
|
||||
#endif
|
||||
|
||||
protected:
|
||||
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
|
||||
bool wantPacket(const meshtastic_MeshPacket *p) override { return true; }
|
||||
void alterReceived(meshtastic_MeshPacket &mp) override;
|
||||
int32_t runOnce() override;
|
||||
// Protected so test shims can force epoch rollover behavior.
|
||||
void resetEpoch(uint32_t nowMs);
|
||||
// Protected so test shims can flush per-node traffic state.
|
||||
void flushCache();
|
||||
// Introspection for tests: the cached device role for a node, or -1 if the node has
|
||||
// no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0).
|
||||
int peekCachedRole(NodeNum node);
|
||||
|
||||
private:
|
||||
// =========================================================================
|
||||
// Unified Cache Entry (10 bytes) - Same for ALL platforms
|
||||
// =========================================================================
|
||||
//
|
||||
// A single compact structure used across ESP32, NRF52, and all other platforms.
|
||||
// Memory: 10 bytes × 2048 entries = 20KB
|
||||
//
|
||||
// Position Fingerprinting:
|
||||
// Instead of storing full coordinates (8 bytes) or a computed hash,
|
||||
// we store an 8-bit fingerprint derived deterministically from the
|
||||
// truncated lat/lon. This extracts the lower 4 significant bits from
|
||||
// each coordinate: fingerprint = (lat_low4 << 4) | lon_low4
|
||||
//
|
||||
// Benefits over hash:
|
||||
// - Adjacent grid cells have sequential fingerprints (no collision)
|
||||
// - Two positions only collide if 16+ grid cells apart in BOTH dimensions
|
||||
// - Deterministic: same input always produces same output
|
||||
//
|
||||
// Adaptive Timestamp Resolution:
|
||||
// All timestamps use 8-bit values with adaptive resolution calculated
|
||||
// from config at startup. Resolution = max(60, min(339, interval/2)).
|
||||
// - Min 60 seconds ensures reasonable precision
|
||||
// - Max 339 seconds allows ~24 hour range (255 * 339 = 86445 sec)
|
||||
// - interval/2 ensures at least 2 ticks per configured interval
|
||||
//
|
||||
// Layout:
|
||||
// [0-3] node - NodeNum (4 bytes)
|
||||
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (1 byte)
|
||||
// [5] rate_count - Packets in current window (1 byte)
|
||||
// [6] unknown_count - Unknown packets count (1 byte)
|
||||
// [7] pos_time - Position timestamp (1 byte, adaptive resolution)
|
||||
// [8] rate_time - Rate window start (1 byte, adaptive resolution)
|
||||
// [9] unknown_time - Unknown tracking start (1 byte, adaptive resolution)
|
||||
// [0-3] node - NodeNum (4 bytes, 0 = empty slot)
|
||||
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen)
|
||||
// [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active)
|
||||
// [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active)
|
||||
// [7] pos_time - Position tick (uint8, free-running 360 s/tick)
|
||||
// [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick)
|
||||
// [9] next_hop - Last-byte relay to reach `node` (0 = none)
|
||||
//
|
||||
// The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count)
|
||||
// caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the
|
||||
// hot store and warm store, for nodes evicted from both. Read/written via
|
||||
// resolveSenderRole(). Max encodable value is 15.
|
||||
//
|
||||
// Presence sentinels (no epoch, no +1 offset needed):
|
||||
// pos active: pos_fingerprint != 0
|
||||
// rate active: getRateCount() != 0 (low 6 bits only)
|
||||
// unknown active: getUnknownCount() != 0 (low 6 bits only)
|
||||
//
|
||||
// next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions.
|
||||
// No TTL — keeps the slot alive across maintenance sweeps.
|
||||
//
|
||||
#if _meshtastic_Config_DeviceConfig_Role_MAX > 15
|
||||
#warning "Device role enum max exceeds 15 — TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values"
|
||||
#endif
|
||||
struct __attribute__((packed)) UnifiedCacheEntry {
|
||||
NodeNum node; // 4 bytes - Node identifier (0 = empty slot)
|
||||
uint8_t pos_fingerprint; // 1 byte - Lower 4 bits of lat + lon
|
||||
uint8_t rate_count; // 1 byte - Packet count (saturates at 255)
|
||||
uint8_t unknown_count; // 1 byte - Unknown packet count (saturates at 255)
|
||||
uint8_t pos_time; // 1 byte - Position timestamp (adaptive resolution)
|
||||
uint8_t rate_time; // 1 byte - Rate window start (adaptive resolution)
|
||||
uint8_t unknown_time; // 1 byte - Unknown tracking start (adaptive resolution)
|
||||
NodeNum node;
|
||||
uint8_t pos_fingerprint;
|
||||
uint8_t rate_count; // [7:6] = role[3:2], [5:0] = count (max 63)
|
||||
uint8_t unknown_count; // [7:6] = role[1:0], [5:0] = count (max 63)
|
||||
uint8_t pos_time;
|
||||
uint8_t rate_unknown_time;
|
||||
uint8_t next_hop;
|
||||
|
||||
uint8_t getRateCount() const { return rate_count & 0x3F; }
|
||||
void setRateCount(uint8_t c) { rate_count = static_cast<uint8_t>((rate_count & 0xC0) | (c & 0x3F)); }
|
||||
uint8_t getUnknownCount() const { return unknown_count & 0x3F; }
|
||||
void setUnknownCount(uint8_t c) { unknown_count = static_cast<uint8_t>((unknown_count & 0xC0) | (c & 0x3F)); }
|
||||
uint8_t getCachedRole() const { return static_cast<uint8_t>(((rate_count >> 6) << 2) | (unknown_count >> 6)); }
|
||||
void setCachedRole(uint8_t role)
|
||||
{
|
||||
rate_count = static_cast<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
|
||||
unknown_count = static_cast<uint8_t>((unknown_count & 0x3F) | ((role & 0x03) << 6));
|
||||
}
|
||||
uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; }
|
||||
uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; }
|
||||
void setRateTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
|
||||
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0xF0) | (t & 0x0F)); }
|
||||
};
|
||||
static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes");
|
||||
|
||||
// =========================================================================
|
||||
// Cuckoo Hash Table Implementation
|
||||
// Flat unified cache
|
||||
// =========================================================================
|
||||
//
|
||||
// Cuckoo hashing provides O(1) worst-case lookup time using two hash functions.
|
||||
// Each key can be in one of two possible locations (h1 or h2). On collision,
|
||||
// the existing entry is "kicked" to its alternate location.
|
||||
// Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at
|
||||
// most cacheSize() × 10 B — microseconds at LoRa packet rates, not worth a
|
||||
// hash table. Insertion on a full cache evicts the stalest entry,
|
||||
// preferring entries without a next_hop hint (those are the long-tail
|
||||
// routing state this cache exists to keep).
|
||||
//
|
||||
// Benefits over linear scan:
|
||||
// - O(1) lookup vs O(n) - critical at packet processing rates
|
||||
// - O(1) insertion (amortized) with simple eviction on cycles
|
||||
// - ~95% load factor achievable
|
||||
//
|
||||
// Cache size rounds to power-of-2 for fast modulo via bitmask.
|
||||
// TRAFFIC_MANAGEMENT_CACHE_SIZE=2000 → cacheSize()=2048
|
||||
//
|
||||
static constexpr uint16_t cacheSize();
|
||||
static constexpr uint16_t cacheMask();
|
||||
static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; }
|
||||
|
||||
// Hash functions for cuckoo hashing
|
||||
inline uint16_t cuckooHash1(NodeNum node) const { return node & cacheMask(); }
|
||||
inline uint16_t cuckooHash2(NodeNum node) const { return ((node * 2654435769u) >> (32 - cuckooHashBits())) & cacheMask(); }
|
||||
static constexpr uint8_t cuckooHashBits();
|
||||
|
||||
// NodeInfo cache configuration (PSRAM path):
|
||||
// - Payload lives in PSRAM
|
||||
// - DRAM keeps packed 12-bit tags with 4-way bucketed cuckoo hashing
|
||||
// (Fan et al., CoNEXT 2014). Tag value 0 is reserved as "empty".
|
||||
static constexpr uint16_t kNodeInfoIndexMetadataBudgetBytes = 3072; // 3KB DRAM tag store
|
||||
static constexpr uint8_t kNodeInfoTargetOccupancyPercent = 95;
|
||||
static constexpr uint8_t kNodeInfoBucketSize = 4;
|
||||
static constexpr uint8_t kNodeInfoTagBits = 12;
|
||||
static constexpr uint16_t kNodeInfoTagMask = static_cast<uint16_t>((1u << kNodeInfoTagBits) - 1u);
|
||||
static constexpr uint16_t kNodeInfoIndexSlotsRaw =
|
||||
static_cast<uint16_t>((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits);
|
||||
static constexpr uint16_t kNodeInfoIndexSlots =
|
||||
static_cast<uint16_t>(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize));
|
||||
static constexpr uint16_t kNodeInfoTargetEntries =
|
||||
static_cast<uint16_t>((kNodeInfoIndexSlots * kNodeInfoTargetOccupancyPercent) / 100u);
|
||||
static_assert((kNodeInfoIndexSlots % kNodeInfoBucketSize) == 0, "NodeInfo slot count must align to bucket size");
|
||||
static_assert(kNodeInfoTargetEntries < (1u << kNodeInfoTagBits), "NodeInfo tag bits must encode payload index");
|
||||
|
||||
static constexpr uint16_t nodeInfoTargetEntries();
|
||||
static constexpr uint16_t nodeInfoIndexMetadataBudgetBytes();
|
||||
static constexpr uint8_t nodeInfoTargetOccupancyPercent();
|
||||
static constexpr uint8_t nodeInfoBucketSize();
|
||||
static constexpr uint8_t nodeInfoTagBits();
|
||||
static constexpr uint16_t nodeInfoTagMask();
|
||||
static constexpr uint16_t nodeInfoIndexSlots();
|
||||
static constexpr uint16_t nodeInfoBucketCount();
|
||||
static constexpr uint16_t nodeInfoBucketMask();
|
||||
static constexpr uint8_t nodeInfoBucketHashBits();
|
||||
inline uint16_t nodeInfoHash1(NodeNum node) const { return node & nodeInfoBucketMask(); }
|
||||
inline uint16_t nodeInfoHash2(NodeNum node) const
|
||||
{
|
||||
return ((node * 2246822519u) >> (32 - nodeInfoBucketHashBits())) & nodeInfoBucketMask();
|
||||
}
|
||||
// NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload
|
||||
// entries, linear scan keyed by `node`, LRU eviction by lastObservedMs.
|
||||
// NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine.
|
||||
static constexpr uint16_t kNodeInfoCacheEntries = 2000;
|
||||
static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; }
|
||||
|
||||
// =========================================================================
|
||||
// Adaptive Timestamp Resolution
|
||||
// Free-Running Tick Counters
|
||||
// =========================================================================
|
||||
//
|
||||
// All timestamps use 8-bit values with adaptive resolution calculated from
|
||||
// config at startup. This allows ~24 hour range while maintaining precision.
|
||||
// Timestamps are stored as free-running modular tick counters derived from
|
||||
// millis(). No epoch anchor needed: modular subtraction gives correct age
|
||||
// as long as the true age stays below the counter period.
|
||||
//
|
||||
// Resolution formula: max(60, min(339, interval/2))
|
||||
// - 60 sec minimum ensures reasonable precision
|
||||
// - 339 sec maximum allows 24 hour range (255 * 339 ≈ 86400 sec)
|
||||
// - interval/2 ensures at least 2 ticks per configured interval
|
||||
// pos_time : uint8 (256 ticks × 360 s = 25.6 h period; max window 12 h = 120 ticks)
|
||||
// rate_time : nibble (16 ticks × 300 s = 80 min period; max window 1 h = 12 ticks)
|
||||
// unknown_time: nibble (16 ticks × 60 s = 16 min period; max window 12 min = 12 ticks)
|
||||
//
|
||||
// Since config changes require reboot, resolution is calculated once.
|
||||
// Presence sentinels (no +1 offset needed; count fields serve as guards):
|
||||
// pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 → 0xFF)
|
||||
// rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role)
|
||||
// unknown active: getUnknownCount() != 0
|
||||
//
|
||||
uint32_t cacheEpochMs = 0;
|
||||
uint16_t posTimeResolution = 60; // Seconds per tick for position
|
||||
uint16_t rateTimeResolution = 60; // Seconds per tick for rate limiting
|
||||
uint16_t unknownTimeResolution = 60; // Seconds per tick for unknown tracking
|
||||
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick
|
||||
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick
|
||||
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick
|
||||
|
||||
// Calculate resolution from configured interval (called once at startup)
|
||||
static uint16_t calcTimeResolution(uint32_t intervalSecs)
|
||||
{
|
||||
// Resolution = interval/2 to ensure at least 2 ticks per interval
|
||||
// Clamped to [60, 339] for min precision and max 24h range
|
||||
uint32_t res = (intervalSecs > 0) ? (intervalSecs / 2) : 60;
|
||||
if (res < 60)
|
||||
res = 60;
|
||||
if (res > 339)
|
||||
res = 339;
|
||||
return static_cast<uint16_t>(res);
|
||||
}
|
||||
|
||||
// Convert to/from 8-bit relative timestamps with given resolution
|
||||
uint8_t toRelativeTime(uint32_t nowMs, uint16_t resolutionSecs) const
|
||||
{
|
||||
uint32_t ticks = (nowMs - cacheEpochMs) / (resolutionSecs * 1000UL);
|
||||
return (ticks > UINT8_MAX) ? UINT8_MAX : static_cast<uint8_t>(ticks);
|
||||
}
|
||||
uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const
|
||||
{
|
||||
return cacheEpochMs + (static_cast<uint32_t>(ticks) * resolutionSecs * 1000UL);
|
||||
}
|
||||
|
||||
// Convenience wrappers for each timestamp type
|
||||
uint8_t toRelativePosTime(uint32_t nowMs) const { return toRelativeTime(nowMs, posTimeResolution); }
|
||||
uint32_t fromRelativePosTime(uint8_t t) const { return fromRelativeTime(t, posTimeResolution); }
|
||||
|
||||
uint8_t toRelativeRateTime(uint32_t nowMs) const { return toRelativeTime(nowMs, rateTimeResolution); }
|
||||
uint32_t fromRelativeRateTime(uint8_t t) const { return fromRelativeTime(t, rateTimeResolution); }
|
||||
|
||||
uint8_t toRelativeUnknownTime(uint32_t nowMs) const { return toRelativeTime(nowMs, unknownTimeResolution); }
|
||||
uint32_t fromRelativeUnknownTime(uint8_t t) const { return fromRelativeTime(t, unknownTimeResolution); }
|
||||
|
||||
// Epoch reset when any timestamp approaches overflow
|
||||
// With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max)
|
||||
bool needsEpochReset(uint32_t nowMs) const
|
||||
{
|
||||
uint16_t maxRes = posTimeResolution;
|
||||
if (rateTimeResolution > maxRes)
|
||||
maxRes = rateTimeResolution;
|
||||
if (unknownTimeResolution > maxRes)
|
||||
maxRes = unknownTimeResolution;
|
||||
return (nowMs - cacheEpochMs) > (200UL * maxRes * 1000UL);
|
||||
}
|
||||
static uint8_t currentPosTick() { return static_cast<uint8_t>(clockMs() / kPosTimeTickMs); }
|
||||
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
|
||||
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((clockMs() / kUnknownTimeTickMs) & 0x0F); }
|
||||
// =========================================================================
|
||||
// Position Fingerprint
|
||||
// =========================================================================
|
||||
@@ -246,7 +211,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
// =========================================================================
|
||||
|
||||
mutable concurrency::Lock cacheLock; // Protects all cache access
|
||||
UnifiedCacheEntry *cache = nullptr; // Cuckoo hash table (unified for all platforms)
|
||||
UnifiedCacheEntry *cache = nullptr; // Flat unified cache (linear scan; all platforms)
|
||||
bool cacheFromPsram = false; // Tracks allocator for correct deallocation
|
||||
|
||||
struct NodeInfoPayloadEntry {
|
||||
@@ -278,11 +243,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
uint8_t decodedBitfield;
|
||||
};
|
||||
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM
|
||||
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan)
|
||||
bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation
|
||||
uint8_t *nodeInfoIndex = nullptr; // Packed 12-bit NodeInfo tags in DRAM
|
||||
uint16_t nodeInfoAllocHint = 0;
|
||||
uint16_t nodeInfoEvictCursor = 0;
|
||||
|
||||
meshtastic_TrafficManagementStats stats;
|
||||
|
||||
@@ -293,29 +255,37 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
NodeNum exhaustRequestedFrom = 0;
|
||||
PacketId exhaustRequestedId = 0;
|
||||
|
||||
// One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass.
|
||||
bool nextHopPreloaded = false;
|
||||
|
||||
// =========================================================================
|
||||
// Cache Operations
|
||||
// =========================================================================
|
||||
|
||||
// Find or create entry for node using cuckoo hashing
|
||||
// Returns nullptr if cache is full and eviction fails
|
||||
// Find or create entry for node (linear scan; stalest-first eviction when full)
|
||||
UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew);
|
||||
|
||||
// Find existing entry (no creation)
|
||||
UnifiedCacheEntry *findEntry(NodeNum node);
|
||||
|
||||
// NodeInfo cache operations (bucketed cuckoo index + PSRAM payloads)
|
||||
// Resolve a sender's advertised device role for the position hot path. The tier-3
|
||||
// cache (this entry's getCachedRole) is authoritative and is kept fresh by
|
||||
// updateCachedRoleFromNodeInfo() — updated when NodeDB learns a role, not re-derived
|
||||
// per packet. Only on first tracking (isNew) do we scan NodeDB (hot store → warm
|
||||
// store, via getNodeRole) to seed the cache, so a resident special-role node is
|
||||
// correct from its first position; after that the read is O(1) and survives the node
|
||||
// aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null
|
||||
// (→ NodeDB scan only).
|
||||
meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew);
|
||||
|
||||
// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
|
||||
// NodeDB's role). Reads role from the packet's User payload; updates only nodes already
|
||||
// tracked (no entry creation). Takes cacheLock.
|
||||
void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp);
|
||||
|
||||
// NodeInfo cache operations (flat PSRAM payload array, linear scan)
|
||||
const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const;
|
||||
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot);
|
||||
uint16_t findNodeInfoPayloadIndex(NodeNum node) const;
|
||||
bool removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex);
|
||||
uint16_t allocateNodeInfoPayloadSlot();
|
||||
uint16_t evictNodeInfoPayloadSlot();
|
||||
bool tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag);
|
||||
uint16_t encodeNodeInfoTag(uint16_t payloadIndex) const;
|
||||
uint16_t decodeNodeInfoPayloadIndex(uint16_t tag) const;
|
||||
uint16_t getNodeInfoTag(uint16_t slot) const;
|
||||
void setNodeInfoTag(uint16_t slot, uint16_t tag);
|
||||
uint16_t countNodeInfoEntriesLocked() const;
|
||||
void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp);
|
||||
|
||||
@@ -333,101 +303,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
|
||||
void incrementStat(uint32_t *field);
|
||||
};
|
||||
|
||||
// =========================================================================
|
||||
// Compile-time Cache Size Calculations
|
||||
// =========================================================================
|
||||
//
|
||||
// Round TRAFFIC_MANAGEMENT_CACHE_SIZE up to next power of 2 for efficient
|
||||
// cuckoo hash indexing (allows bitmask instead of modulo).
|
||||
//
|
||||
// These use C++11-compatible constexpr (single return statement).
|
||||
//
|
||||
|
||||
namespace detail
|
||||
{
|
||||
// Helper: round up to next power of 2 using bit manipulation
|
||||
constexpr uint16_t nextPow2(uint16_t n)
|
||||
{
|
||||
return n == 0 ? 0 : (((n - 1) | ((n - 1) >> 1) | ((n - 1) >> 2) | ((n - 1) >> 4) | ((n - 1) >> 8)) + 1);
|
||||
}
|
||||
|
||||
// Helper: floor(log2(n)) for n >= 0, C++11-compatible constexpr.
|
||||
constexpr uint8_t log2Floor(uint16_t n)
|
||||
{
|
||||
return n <= 1 ? 0 : static_cast<uint8_t>(1 + log2Floor(static_cast<uint16_t>(n >> 1)));
|
||||
}
|
||||
|
||||
// Helper: ceil(log2(n)) for n >= 1, C++11-compatible constexpr.
|
||||
constexpr uint8_t log2Ceil(uint16_t n)
|
||||
{
|
||||
return n <= 1 ? 0 : static_cast<uint8_t>(1 + log2Floor(static_cast<uint16_t>(n - 1)));
|
||||
}
|
||||
} // namespace detail
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::cacheSize()
|
||||
{
|
||||
return detail::nextPow2(TRAFFIC_MANAGEMENT_CACHE_SIZE);
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::cacheMask()
|
||||
{
|
||||
return cacheSize() > 0 ? cacheSize() - 1 : 0;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::cuckooHashBits()
|
||||
{
|
||||
return detail::log2Floor(cacheSize());
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoTargetEntries()
|
||||
{
|
||||
return kNodeInfoTargetEntries;
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoIndexMetadataBudgetBytes()
|
||||
{
|
||||
return kNodeInfoIndexMetadataBudgetBytes;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::nodeInfoTargetOccupancyPercent()
|
||||
{
|
||||
return kNodeInfoTargetOccupancyPercent;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::nodeInfoBucketSize()
|
||||
{
|
||||
return kNodeInfoBucketSize;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::nodeInfoTagBits()
|
||||
{
|
||||
return kNodeInfoTagBits;
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoTagMask()
|
||||
{
|
||||
return kNodeInfoTagMask;
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoIndexSlots()
|
||||
{
|
||||
return kNodeInfoIndexSlots;
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoBucketCount()
|
||||
{
|
||||
return static_cast<uint16_t>(nodeInfoIndexSlots() / nodeInfoBucketSize());
|
||||
}
|
||||
|
||||
constexpr uint16_t TrafficManagementModule::nodeInfoBucketMask()
|
||||
{
|
||||
return nodeInfoBucketCount() > 0 ? nodeInfoBucketCount() - 1 : 0;
|
||||
}
|
||||
|
||||
constexpr uint8_t TrafficManagementModule::nodeInfoBucketHashBits()
|
||||
{
|
||||
return detail::log2Floor(nodeInfoBucketCount());
|
||||
}
|
||||
static_assert(TRAFFIC_MANAGEMENT_CACHE_SIZE <= UINT16_MAX, "cacheSize() returns uint16_t");
|
||||
|
||||
extern TrafficManagementModule *trafficManagementModule;
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
#endif
|
||||
#include "BMM150Sensor.h"
|
||||
#include "BMX160Sensor.h"
|
||||
#include "ICM42607PSensor.h"
|
||||
#include "ICM20948Sensor.h"
|
||||
#include "ICM42607PSensor.h"
|
||||
#include "LIS3DHSensor.h"
|
||||
#include "LSM6DS3Sensor.h"
|
||||
#include "MPU6050Sensor.h"
|
||||
@@ -92,32 +92,44 @@ class AccelerometerThread : public concurrency::OSThread
|
||||
sensor = new BMA423Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<Adafruit_MPU6050.h>)
|
||||
case ScanI2C::DeviceType::MPU6050:
|
||||
sensor = new MPU6050Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
case ScanI2C::DeviceType::BMX160:
|
||||
sensor = new BMX160Sensor(device);
|
||||
break;
|
||||
#if __has_include(<Adafruit_LIS3DH.h>)
|
||||
case ScanI2C::DeviceType::LIS3DH:
|
||||
sensor = new LIS3DHSensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<Adafruit_LSM6DS3TRC.h>)
|
||||
case ScanI2C::DeviceType::LSM6DS3:
|
||||
sensor = new LSM6DS3Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
#ifdef HAS_STK8XXX
|
||||
case ScanI2C::DeviceType::STK8BAXX:
|
||||
sensor = new STK8XXXSensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<ICM_20948.h>)
|
||||
case ScanI2C::DeviceType::ICM20948:
|
||||
sensor = new ICM20948Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<ICM42670P.h>)
|
||||
case ScanI2C::DeviceType::ICM42607P:
|
||||
sensor = new ICM42607PSensor(device);
|
||||
break;
|
||||
#endif
|
||||
#if __has_include(<DFRobot_BMM150.h>)
|
||||
case ScanI2C::DeviceType::BMM150:
|
||||
sensor = new BMM150Sensor(device);
|
||||
break;
|
||||
#endif
|
||||
#ifdef HAS_BMI270
|
||||
case ScanI2C::DeviceType::BMI270:
|
||||
sensor = new BMI270Sensor(device);
|
||||
|
||||
@@ -4,10 +4,16 @@
|
||||
|
||||
#include "detect/ScanI2CTwoWire.h"
|
||||
#include <ICM42670P.h>
|
||||
#include <math.h>
|
||||
|
||||
static constexpr uint16_t ICM42607P_ACCEL_ODR_HZ = 50;
|
||||
static constexpr uint16_t ICM42607P_ACCEL_FSR_G = 2;
|
||||
static constexpr float ICM42607P_COUNTS_PER_G = 32768.0f / ICM42607P_ACCEL_FSR_G;
|
||||
static constexpr float ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE =
|
||||
#ifdef ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG
|
||||
ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG;
|
||||
#else
|
||||
0.0f;
|
||||
#endif
|
||||
|
||||
#ifdef ICM_42607P_INT_PIN
|
||||
volatile static bool ICM42607P_IRQ = false;
|
||||
@@ -18,10 +24,7 @@ void ICM42607PSetInterrupt()
|
||||
}
|
||||
#endif
|
||||
|
||||
ICM42607PSensor::ICM42607PSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice)
|
||||
{
|
||||
wire = ScanI2CTwoWire::fetchI2CBus(foundDevice.address);
|
||||
}
|
||||
ICM42607PSensor::ICM42607PSensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {}
|
||||
|
||||
ICM42607PSensor::~ICM42607PSensor() = default;
|
||||
|
||||
@@ -30,6 +33,7 @@ bool ICM42607PSensor::init()
|
||||
bool addressLsb = deviceAddress() == ICM42607P_ADDR_ALT;
|
||||
|
||||
LOG_DEBUG("ICM-42607-P begin on addr 0x%02X (port=%d)", deviceAddress(), devicePort());
|
||||
TwoWire *wire = ScanI2CTwoWire::fetchI2CBus(device.address);
|
||||
sensor.reset();
|
||||
auto newSensor = std::make_unique<ICM42670>(*wire, addressLsb);
|
||||
|
||||
@@ -82,8 +86,22 @@ int32_t ICM42607PSensor::runOnce()
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
// LOG_DEBUG("ICM-42607-P accel read x=%.3fg y=%.3fg z=%.3fg", (float)event.accel[0] / ICM42607P_COUNTS_PER_G,
|
||||
// (float)event.accel[1] / ICM42607P_COUNTS_PER_G, (float)event.accel[2] / ICM42607P_COUNTS_PER_G);
|
||||
float ax = static_cast<float>(event.accel[0]);
|
||||
float ay = static_cast<float>(event.accel[1]);
|
||||
const float az = static_cast<float>(event.accel[2]);
|
||||
|
||||
if (ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE != 0.0f) {
|
||||
static const float rotRad = ICM42607P_ACCEL_TO_COMPASS_ROTATION_DEG_VALUE * DEG_TO_RAD;
|
||||
static const float cosTheta = cosf(rotRad);
|
||||
static const float sinTheta = sinf(rotRad);
|
||||
const float rotatedX = (ax * cosTheta) - (ay * sinTheta);
|
||||
const float rotatedY = (ax * sinTheta) + (ay * cosTheta);
|
||||
ax = rotatedX;
|
||||
ay = rotatedY;
|
||||
}
|
||||
|
||||
// Match the accel sign convention used by other FusionCompass sensor paths.
|
||||
publishCompassAccelSample(ax, -ay, -az);
|
||||
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
#endif
|
||||
|
||||
@@ -14,7 +14,6 @@ class ICM42607PSensor : public MotionSensor
|
||||
{
|
||||
private:
|
||||
std::unique_ptr<ICM42670> sensor;
|
||||
TwoWire *wire = nullptr;
|
||||
|
||||
public:
|
||||
explicit ICM42607PSensor(ScanI2C::FoundDevice foundDevice);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C && __has_include(<SparkFun_MMC5983MA_Arduino_Library.h>)
|
||||
|
||||
#include "Fusion/Fusion.h"
|
||||
#include "detect/ScanI2CTwoWire.h"
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
@@ -10,8 +11,11 @@ extern graphics::Screen *screen;
|
||||
|
||||
static constexpr float MMC5983MA_ZERO_FIELD = 131072.0f;
|
||||
static constexpr float MMC5983MA_COUNTS_PER_GAUSS = 16384.0f;
|
||||
static constexpr uint16_t MMC5983MA_CONTINUOUS_FREQUENCY_HZ = 10;
|
||||
static constexpr uint16_t MMC5983MA_CONTINUOUS_FREQUENCY_HZ = 50;
|
||||
static constexpr int32_t MMC5983MA_UPDATE_INTERVAL_MS = 20;
|
||||
static constexpr float MMC5983MA_HEADING_OFFSET_DEG = 180.0f;
|
||||
static constexpr uint32_t MMC5983MA_ACCEL_STALE_MS = 300;
|
||||
static constexpr float MMC5983MA_MIN_AXIS_RADIUS = 1e-4f;
|
||||
|
||||
MMC5983MASensor::MMC5983MASensor(ScanI2C::FoundDevice foundDevice) : MotionSensor::MotionSensor(foundDevice) {}
|
||||
|
||||
@@ -59,40 +63,68 @@ bool MMC5983MASensor::readMagnetometer(float &xGauss, float &yGauss, float &zGau
|
||||
}
|
||||
|
||||
int32_t MMC5983MASensor::runOnce()
|
||||
{
|
||||
float magX = 0, magY = 0, magZ = 0;
|
||||
if (!readMagnetometer(magX, magY, magZ)) {
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
{
|
||||
float magX = 0, magY = 0, magZ = 0;
|
||||
if (!readMagnetometer(magX, magY, magZ)) {
|
||||
return MMC5983MA_UPDATE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
if (doCalibration) {
|
||||
beginCalibrationDisplay(showingScreen);
|
||||
updateCalibrationExtrema(magX, magY, magZ, highestX, lowestX, highestY, lowestY, highestZ, lowestZ);
|
||||
finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ,
|
||||
lowestZ);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Hard-iron bias removal.
|
||||
magX -= (highestX + lowestX) * 0.5f;
|
||||
magY -= (highestY + lowestY) * 0.5f;
|
||||
magZ -= (highestZ + lowestZ) * 0.5f;
|
||||
|
||||
// Soft-iron diagonal scaling from calibration extrema.
|
||||
const float radiusX = (highestX - lowestX) * 0.5f;
|
||||
const float radiusY = (highestY - lowestY) * 0.5f;
|
||||
const float radiusZ = (highestZ - lowestZ) * 0.5f;
|
||||
const float avgRadius = (radiusX + radiusY + radiusZ) / 3.0f;
|
||||
magX *= (radiusX > MMC5983MA_MIN_AXIS_RADIUS) ? (avgRadius / radiusX) : 1.0f;
|
||||
magY *= (radiusY > MMC5983MA_MIN_AXIS_RADIUS) ? (avgRadius / radiusY) : 1.0f;
|
||||
magZ *= (radiusZ > MMC5983MA_MIN_AXIS_RADIUS) ? (avgRadius / radiusZ) : 1.0f;
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN
|
||||
float heading;
|
||||
float accelX = 0.0f;
|
||||
float accelY = 0.0f;
|
||||
float accelZ = 0.0f;
|
||||
uint32_t accelAgeMs = 0;
|
||||
|
||||
if (getLatestCompassAccelSample(accelX, accelY, accelZ, accelAgeMs) && accelAgeMs <= MMC5983MA_ACCEL_STALE_MS) {
|
||||
FusionVector ga = {.axis = {accelX, accelY, accelZ}};
|
||||
FusionVector ma = {.axis = {magX, magY, magZ}};
|
||||
if (config.display.compass_orientation > meshtastic_Config_DisplayConfig_CompassOrientation_DEGREES_270) {
|
||||
ma = FusionAxesSwap(ma, FusionAxesAlignmentNXNYPZ);
|
||||
ga = FusionAxesSwap(ga, FusionAxesAlignmentNXNYPZ);
|
||||
}
|
||||
heading = FusionCompassCalculateHeading(FusionConventionNed, ga, ma) + MMC5983MA_HEADING_OFFSET_DEG;
|
||||
} else {
|
||||
heading = atan2f(magY, magX) * RAD_TO_DEG + MMC5983MA_HEADING_OFFSET_DEG;
|
||||
}
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN)
|
||||
if (doCalibration) {
|
||||
beginCalibrationDisplay(showingScreen);
|
||||
updateCalibrationExtrema(magX, magY, magZ, highestX, lowestX, highestY, lowestY, highestZ, lowestZ);
|
||||
finishCalibrationIfExpired(showingScreen, compassCalibrationFileName, highestX, lowestX, highestY, lowestY, highestZ,
|
||||
lowestZ);
|
||||
}
|
||||
#endif
|
||||
if (heading >= 360.0f)
|
||||
heading -= 360.0f;
|
||||
else if (heading < 0.0f)
|
||||
heading += 360.0f;
|
||||
heading = 360.0f - heading;
|
||||
if (heading >= 360.0f)
|
||||
heading -= 360.0f;
|
||||
|
||||
magX -= (highestX + lowestX) / 2;
|
||||
magY -= (highestY + lowestY) / 2;
|
||||
magZ -= (highestZ + lowestZ) / 2;
|
||||
heading = applyCompassOrientation(heading);
|
||||
if (screen)
|
||||
screen->setHeading(heading);
|
||||
#endif
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN
|
||||
float heading = atan2f(magY, magX) * RAD_TO_DEG + MMC5983MA_HEADING_OFFSET_DEG;
|
||||
if (heading < 0.0f) {
|
||||
heading += 360.0f;
|
||||
} else if (heading >= 360.0f) {
|
||||
heading -= 360.0f;
|
||||
}
|
||||
|
||||
heading = applyCompassOrientation(heading);
|
||||
if (screen) {
|
||||
screen->setHeading(heading);
|
||||
}
|
||||
#endif
|
||||
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
return MMC5983MA_UPDATE_INTERVAL_MS;
|
||||
}
|
||||
|
||||
void MMC5983MASensor::calibrate(uint16_t forSeconds)
|
||||
|
||||
@@ -47,9 +47,8 @@ class MagnetometerThread : public concurrency::OSThread
|
||||
{
|
||||
canSleep = true;
|
||||
|
||||
if (isInitialised) {
|
||||
if (isInitialised)
|
||||
return sensor->runOnce();
|
||||
}
|
||||
|
||||
return MOTION_SENSOR_CHECK_INTERVAL_MS;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "FSCommon.h"
|
||||
#include "SPILock.h"
|
||||
#include "SafeFile.h"
|
||||
#include "concurrency/LockGuard.h"
|
||||
#include "graphics/draw/CompassRenderer.h"
|
||||
|
||||
#if !defined(ARCH_STM32WL) && !MESHTASTIC_EXCLUDE_I2C
|
||||
@@ -30,6 +31,17 @@ bool isRangeValid(float highest, float lowest)
|
||||
// NaN/Inf guard without pulling in extra math helpers.
|
||||
return (highest == highest) && (lowest == lowest) && (highest > lowest);
|
||||
}
|
||||
|
||||
struct CompassAccelSample {
|
||||
float x = 0.0f;
|
||||
float y = 0.0f;
|
||||
float z = 0.0f;
|
||||
uint32_t sampledAtMs = 0;
|
||||
bool valid = false;
|
||||
};
|
||||
|
||||
concurrency::Lock latestCompassAccelLock;
|
||||
CompassAccelSample latestCompassAccelSample;
|
||||
} // namespace
|
||||
|
||||
// screen is defined in main.cpp
|
||||
@@ -204,6 +216,35 @@ float MotionSensor::applyCompassOrientation(float heading)
|
||||
}
|
||||
}
|
||||
|
||||
void MotionSensor::publishCompassAccelSample(float x, float y, float z)
|
||||
{
|
||||
concurrency::LockGuard guard(&latestCompassAccelLock);
|
||||
latestCompassAccelSample.x = x;
|
||||
latestCompassAccelSample.y = y;
|
||||
latestCompassAccelSample.z = z;
|
||||
latestCompassAccelSample.sampledAtMs = millis();
|
||||
latestCompassAccelSample.valid = true;
|
||||
}
|
||||
|
||||
bool MotionSensor::getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs)
|
||||
{
|
||||
uint32_t sampledAtMs = 0;
|
||||
{
|
||||
concurrency::LockGuard guard(&latestCompassAccelLock);
|
||||
if (!latestCompassAccelSample.valid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
x = latestCompassAccelSample.x;
|
||||
y = latestCompassAccelSample.y;
|
||||
z = latestCompassAccelSample.z;
|
||||
sampledAtMs = latestCompassAccelSample.sampledAtMs;
|
||||
}
|
||||
|
||||
ageMs = millis() - sampledAtMs;
|
||||
return true;
|
||||
}
|
||||
|
||||
#if !defined(MESHTASTIC_EXCLUDE_SCREEN) && HAS_SCREEN
|
||||
void MotionSensor::drawFrameCalibration(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y)
|
||||
{
|
||||
|
||||
@@ -67,6 +67,8 @@ class MotionSensor
|
||||
static void updateCalibrationExtrema(float x, float y, float z, float &highestX, float &lowestX, float &highestY,
|
||||
float &lowestY, float &highestZ, float &lowestZ);
|
||||
static float applyCompassOrientation(float heading);
|
||||
static void publishCompassAccelSample(float x, float y, float z);
|
||||
static bool getLatestCompassAccelSample(float &x, float &y, float &z, uint32_t &ageMs);
|
||||
|
||||
ScanI2C::FoundDevice device;
|
||||
|
||||
|
||||
@@ -21,7 +21,12 @@
|
||||
#include "PowerStatus.h"
|
||||
|
||||
#include "host/ble_gap.h"
|
||||
#include "host/ble_hs.h"
|
||||
#include "host/ble_store.h"
|
||||
#ifdef ARCH_ESP32
|
||||
#include <nvs.h>
|
||||
#include <nvs_flash.h>
|
||||
#endif
|
||||
|
||||
namespace
|
||||
{
|
||||
@@ -30,6 +35,56 @@ constexpr uint16_t kPreferredBleTxOctets = 251;
|
||||
constexpr uint16_t kPreferredBleTxTimeUs = (kPreferredBleTxOctets + 14) * 8;
|
||||
} // namespace
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// Discard NimBLE bonds left in an incompatible on-disk format. The ESP-IDF/NimBLE upgrade changed
|
||||
// the length of the fixed-size bond records (ble_store_value_sec), so the new host rejects every
|
||||
// old record on each boot ("NVS data size mismatch for obj_type 1 ...") with no auto-recovery --
|
||||
// pairing stays broken until a factory reset. Wipe the bond namespace once when a stored record's
|
||||
// size differs from this build's struct; a same-size store is left untouched, so this never loops.
|
||||
// Adapted from https://github.com/h2zero/NimBLE-Arduino/issues/740
|
||||
static void purgeIncompatibleBleBonds()
|
||||
{
|
||||
esp_err_t initErr = nvs_flash_init();
|
||||
if (initErr != ESP_OK) {
|
||||
LOG_WARN("purgeIncompatibleBleBonds: nvs_flash_init failed, err=%d", (int)initErr);
|
||||
return; // NVS should already be up; if not, nothing safe to do here
|
||||
}
|
||||
|
||||
nvs_handle_t handle = 0;
|
||||
esp_err_t err = nvs_open("nimble_bond", NVS_READWRITE, &handle);
|
||||
if (err == ESP_ERR_NVS_NOT_FOUND) {
|
||||
return; // no bonds stored yet
|
||||
}
|
||||
if (err != ESP_OK) {
|
||||
LOG_ERROR("nimble_bond open failed, err=%d", err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Probe the first record of each fixed-size object type (bonds are written from index 1); a
|
||||
// stored size differing from this build's struct means the store predates a format change.
|
||||
size_t sz = 0;
|
||||
bool mismatch = (nvs_get_blob(handle, "our_sec_1", nullptr, &sz) == ESP_OK && sz != sizeof(struct ble_store_value_sec)) ||
|
||||
(nvs_get_blob(handle, "peer_sec_1", nullptr, &sz) == ESP_OK && sz != sizeof(struct ble_store_value_sec)) ||
|
||||
(nvs_get_blob(handle, "cccd_sec_1", nullptr, &sz) == ESP_OK && sz != sizeof(struct ble_store_value_cccd));
|
||||
|
||||
bool wiped = false;
|
||||
if (mismatch) {
|
||||
LOG_WARN("Wiping incompatible NimBLE bonds (on-disk format changed)");
|
||||
wiped = nvs_erase_all(handle) == ESP_OK && nvs_commit(handle) == ESP_OK;
|
||||
if (!wiped) {
|
||||
LOG_ERROR("Failed to erase nimble_bond namespace");
|
||||
}
|
||||
}
|
||||
|
||||
nvs_close(handle);
|
||||
|
||||
if (wiped) {
|
||||
LOG_INFO("Restarting after NimBLE bond cleanup");
|
||||
ESP.restart();
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
// Debugging options: careful, they slow things down quite a bit!
|
||||
// #define DEBUG_NIMBLE_ON_READ_TIMING // uncomment to time onRead duration
|
||||
// #define DEBUG_NIMBLE_ON_WRITE_TIMING // uncomment to time onWrite duration
|
||||
@@ -47,6 +102,11 @@ BLEServer *bleServer;
|
||||
static bool passkeyShowing;
|
||||
static std::atomic<uint16_t> nimbleBluetoothConnHandle{BLE_HS_CONN_HANDLE_NONE}; // BLE_HS_CONN_HANDLE_NONE means "no connection"
|
||||
|
||||
// Set by onDisconnect to defer (re)starting advertising to the main task. A stale-bond reconnect
|
||||
// triggers a MIC failure + NimBLE host reset; re-entering ble_gap_adv_* from the disconnect
|
||||
// callback while the host is mid-reset crashes (LoadProhibited), so the main task does it instead.
|
||||
static std::atomic<bool> pendingStartAdvertising{false};
|
||||
|
||||
static void clearPairingDisplay()
|
||||
{
|
||||
if (!passkeyShowing) {
|
||||
@@ -155,6 +215,21 @@ class BluetoothPhoneAPI : public PhoneAPI, public concurrency::OSThread
|
||||
protected:
|
||||
virtual int32_t runOnce() override
|
||||
{
|
||||
// Service a deferred advertising restart from onDisconnect, gated on ble_hs_synced() so we
|
||||
// never re-enter the GAP API while the host is still mid-reset.
|
||||
if (pendingStartAdvertising) {
|
||||
if (checkIsConnected()) {
|
||||
pendingStartAdvertising = false; // a new physical connection beat us to it; nothing to do
|
||||
} else if (ble_hs_synced()) {
|
||||
pendingStartAdvertising = false;
|
||||
if (nimbleBluetooth) {
|
||||
nimbleBluetooth->startAdvertising();
|
||||
}
|
||||
} else {
|
||||
return 200; // host still re-syncing after a reset; retry shortly
|
||||
}
|
||||
}
|
||||
|
||||
while (runOnceHasWorkToDo()) {
|
||||
/*
|
||||
PROCESS fromPhoneQueue BEFORE toPhoneQueue:
|
||||
@@ -592,6 +667,14 @@ class NimbleBluetoothSecurityCallback : public BLESecurityCallbacks
|
||||
}
|
||||
void onAuthenticationComplete(ble_gap_conn_desc *desc) override
|
||||
{
|
||||
// Called on every BLE_GAP_EVENT_ENC_CHANGE, success or failure. A stale-bond reconnect
|
||||
// yields a *failed* encryption change here -- don't latch a connected/authenticated state
|
||||
// on a link that is actually being torn down.
|
||||
if (desc == nullptr || !desc->sec_state.encrypted) {
|
||||
LOG_WARN("BLE encryption change without an encrypted link; ignoring");
|
||||
return;
|
||||
}
|
||||
|
||||
LOG_INFO("BLE authentication complete");
|
||||
|
||||
meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED);
|
||||
@@ -667,7 +750,13 @@ class NimbleBluetoothServerCallback : public BLEServerCallbacks
|
||||
|
||||
nimbleBluetoothConnHandle = BLE_HS_CONN_HANDLE_NONE;
|
||||
|
||||
ble->startAdvertising();
|
||||
// Defer the advertising restart to runOnce (see pendingStartAdvertising): calling
|
||||
// startAdvertising() here would crash if this disconnect was a host reset.
|
||||
pendingStartAdvertising = true;
|
||||
if (bluetoothPhoneAPI) {
|
||||
bluetoothPhoneAPI->setIntervalFromNow(0);
|
||||
}
|
||||
concurrency::mainDelay.interrupt(); // wake the main loop to service the restart
|
||||
}
|
||||
};
|
||||
|
||||
@@ -761,6 +850,12 @@ void NimbleBluetooth::setup()
|
||||
|
||||
LOG_INFO("Init the NimBLE bluetooth module");
|
||||
|
||||
#ifdef ARCH_ESP32
|
||||
// Runs before BLEDevice::init() reads the bond store, but logs after the "Init" line above so
|
||||
// any bond-cleanup output doesn't appear to precede the module init.
|
||||
purgeIncompatibleBleBonds(); // wipe bonds left in an incompatible on-disk format (post-upgrade)
|
||||
#endif
|
||||
|
||||
BLEDevice::init(getDeviceName());
|
||||
BLEDevice::setPower(ESP_PWR_LVL_P9);
|
||||
|
||||
@@ -889,6 +984,7 @@ void updateBatteryLevel(uint8_t level)
|
||||
void NimbleBluetooth::clearBonds()
|
||||
{
|
||||
LOG_INFO("Clearing bluetooth bonds!");
|
||||
ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_OUR_SEC, nullptr);
|
||||
ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_PEER_SEC, nullptr);
|
||||
ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_CCCD, nullptr);
|
||||
}
|
||||
@@ -901,13 +997,4 @@ void NimbleBluetooth::sendLog(const uint8_t *logMessage, size_t length)
|
||||
logRadioCharacteristic->setValue(logMessage, length);
|
||||
logRadioCharacteristic->notify();
|
||||
}
|
||||
|
||||
void clearNVS()
|
||||
{
|
||||
ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_PEER_SEC, nullptr);
|
||||
ble_store_util_delete_all(BLE_STORE_OBJ_TYPE_CCCD, nullptr);
|
||||
#ifdef ARCH_ESP32
|
||||
ESP.restart();
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
#endif
|
||||
|
||||
#include "esp_mac.h"
|
||||
#include "freertosinc.h"
|
||||
#include "meshUtils.h"
|
||||
#include "sleep.h"
|
||||
#include "soc/rtc.h"
|
||||
@@ -276,6 +277,8 @@ void cpuDeepSleep(uint32_t msecToWake)
|
||||
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_PERIPH, ESP_PD_OPTION_ON);
|
||||
#endif
|
||||
|
||||
esp_sleep_enable_timer_wakeup(msecToWake * 1000ULL); // call expects usecs
|
||||
esp_deep_sleep_start(); // TBD mA sleep current (battery)
|
||||
// User shutdown (DELAY_FOREVER / portMAX_DELAY): no RTC timer — align with nRF52 system_off semantics.
|
||||
if (msecToWake != portMAX_DELAY)
|
||||
esp_sleep_enable_timer_wakeup(msecToWake * 1000ULL); // call expects usecs
|
||||
esp_deep_sleep_start();
|
||||
}
|
||||
|
||||
@@ -71,9 +71,11 @@ void onConnect(uint16_t conn_handle)
|
||||
// the (single, reused) bluetoothPhoneAPI instance, so a prior session's
|
||||
// authorization can otherwise survive a quick reconnect. handleStartConfig()
|
||||
// re-locks on every want_config too; this closes the window before that.
|
||||
#ifdef MESHTASTIC_PHONEAPI_ACCESS_CONTROL
|
||||
if (bluetoothPhoneAPI) {
|
||||
bluetoothPhoneAPI->setAdminAuthorized(false);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Notify UI (or any other interested firmware components)
|
||||
meshtastic::BluetoothStatus newStatus(meshtastic::BluetoothStatus::ConnectionState::CONNECTED);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user