Merge branch 'develop' into t-beam-bpf
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
+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: 3625166717...03314e6395
@@ -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
|
||||
|
||||
+5
-3
@@ -1160,7 +1160,10 @@ 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();
|
||||
@@ -1484,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+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 {
|
||||
|
||||
@@ -1583,6 +1583,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);
|
||||
@@ -1626,15 +1627,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;
|
||||
}
|
||||
|
||||
@@ -440,6 +440,24 @@ bool Channels::usesPublicKey(ChannelIndex chIndex)
|
||||
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
|
||||
|
||||
@@ -88,6 +88,12 @@ class Channels
|
||||
|
||||
// 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();
|
||||
|
||||
+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;
|
||||
|
||||
+601
-33
File diff suppressed because it is too large
Load Diff
+113
-9
@@ -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
|
||||
@@ -430,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
|
||||
@@ -447,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();
|
||||
@@ -570,6 +668,12 @@ 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
|
||||
|
||||
@@ -28,8 +28,11 @@ uint32_t getPositionPrecisionForChannel(uint8_t channelIndex)
|
||||
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));
|
||||
|
||||
@@ -39,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) {
|
||||
|
||||
@@ -15,6 +15,12 @@ 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);
|
||||
|
||||
@@ -605,6 +605,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.
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+37
-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
|
||||
@@ -720,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
|
||||
@@ -743,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,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
|
||||
@@ -452,7 +452,7 @@ 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -1355,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,
|
||||
@@ -1411,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;
|
||||
|
||||
@@ -1604,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
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1641,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}}}
|
||||
@@ -1676,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}}}
|
||||
@@ -1866,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
|
||||
@@ -1884,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
|
||||
@@ -2128,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
|
||||
@@ -2146,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) \
|
||||
@@ -2264,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
|
||||
@@ -2328,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;
|
||||
@@ -2365,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
|
||||
@@ -2388,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
|
||||
|
||||
+33
-12
@@ -181,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 {
|
||||
@@ -472,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;
|
||||
}
|
||||
@@ -492,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;
|
||||
}
|
||||
@@ -1405,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -137,6 +137,8 @@
|
||||
#define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_SOLAR
|
||||
#elif defined(MUZI_BASE)
|
||||
#define HW_VENDOR meshtastic_HardwareModel_MUZI_BASE
|
||||
#elif defined(HELTEC_MESH_TOWER_V2)
|
||||
#define HW_VENDOR meshtastic_HardwareModel_HELTEC_MESH_TOWER_V2
|
||||
#else
|
||||
#define HW_VENDOR meshtastic_HardwareModel_NRF52_UNKNOWN
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Linker script to configure memory regions. */
|
||||
|
||||
SEARCH_DIR(.)
|
||||
GROUP(-lgcc -lc -lnosys)
|
||||
|
||||
MEMORY
|
||||
{
|
||||
/* App region ends at 0xEA000, not 0xED000: the 12 KB warm-node-store
|
||||
* record-ring (3 x 4 KB pages, src/mesh/WarmNodeStore.h) occupies
|
||||
* 0xEA000-0xED000, directly below the stock LittleFS partition. Boards on
|
||||
* the framework-default linker script are covered by the post-link guard
|
||||
* in extra_scripts/nrf52_warm_region.py instead.
|
||||
*
|
||||
* S140 v6.x app region starts at 0x26000 (152 KB SoftDevice); v7.x uses
|
||||
* 0x27000. All other boundaries are identical — see nrf52840_s140_v7.ld. */
|
||||
FLASH (rx) : ORIGIN = 0x26000, LENGTH = 0xEA000 - 0x26000
|
||||
|
||||
/* SRAM required by Softdevice depend on
|
||||
* - Attribute Table Size (Number of Services and Characteristics)
|
||||
* - Vendor UUID count
|
||||
* - Max ATT MTU
|
||||
* - Concurrent connection peripheral + central + secure links
|
||||
* - Event Len, HVN queue, Write CMD queue
|
||||
*/
|
||||
RAM (rwx) : ORIGIN = 0x20006000, LENGTH = 0x20040000 - 0x20006000
|
||||
}
|
||||
|
||||
SECTIONS
|
||||
{
|
||||
. = ALIGN(4);
|
||||
.svc_data :
|
||||
{
|
||||
PROVIDE(__start_svc_data = .);
|
||||
KEEP(*(.svc_data))
|
||||
PROVIDE(__stop_svc_data = .);
|
||||
} > RAM
|
||||
|
||||
.fs_data :
|
||||
{
|
||||
PROVIDE(__start_fs_data = .);
|
||||
KEEP(*(.fs_data))
|
||||
PROVIDE(__stop_fs_data = .);
|
||||
} > RAM
|
||||
} INSERT AFTER .data;
|
||||
|
||||
INCLUDE "nrf52_common.ld"
|
||||
@@ -5,7 +5,12 @@ GROUP(-lgcc -lc -lnosys)
|
||||
|
||||
MEMORY
|
||||
{
|
||||
FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xED000 - 0x27000
|
||||
/* App region ends at 0xEA000, not 0xED000: the 12 KB warm-node-store
|
||||
* record-ring (3 x 4 KB pages, src/mesh/WarmNodeStore.h) occupies
|
||||
* 0xEA000-0xED000, directly below the stock LittleFS partition. Boards on
|
||||
* the framework-default linker script are covered by the post-link guard
|
||||
* in extra_scripts/nrf52_warm_region.py instead. */
|
||||
FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xEA000 - 0x27000
|
||||
|
||||
/* SRAM required by Softdevice depend on
|
||||
* - Attribute Table Size (Number of Services and Characteristics)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include "hardware/xosc.h"
|
||||
#include <hardware/clocks.h>
|
||||
#include <hardware/pll.h>
|
||||
#include <hardware/watchdog.h>
|
||||
#include <pico/stdlib.h>
|
||||
#include <pico/unique_id.h>
|
||||
|
||||
@@ -99,6 +100,10 @@ void getMacAddr(uint8_t *dmac)
|
||||
|
||||
void rp2040Setup()
|
||||
{
|
||||
if (watchdog_caused_reboot()) {
|
||||
LOG_WARN("Rebooted by watchdog!");
|
||||
}
|
||||
|
||||
/* Sets a random seed to make sure we get different random numbers on each boot. */
|
||||
uint32_t seed = 0;
|
||||
if (!HardwareRNG::seed(seed)) {
|
||||
@@ -128,6 +133,16 @@ void rp2040Setup()
|
||||
#endif
|
||||
}
|
||||
|
||||
void rp2040Loop()
|
||||
{
|
||||
static bool watchdog_running = false;
|
||||
if (!watchdog_running) {
|
||||
watchdog_enable(8000, true); // 8s timeout; pauses during debug
|
||||
watchdog_running = true;
|
||||
}
|
||||
watchdog_update();
|
||||
}
|
||||
|
||||
void enterDfuMode()
|
||||
{
|
||||
reset_usb_boot(0, 0);
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
// Unit tests for NextHop direct-message reliability mitigations (see docs/nexthop-routing-reliability.md):
|
||||
// M1 - NodeDB::resolveLastByte / resolveUniqueLastByte (ambiguity-aware last-byte resolution)
|
||||
// M2 - NextHopRouter::getNextHop strict-neighbor gate + Router::shouldDecrementHopLimit favorite check
|
||||
// M3 - NextHopRouter route-health freshness / failure decay
|
||||
//
|
||||
// Time handling: the route-health helpers take `now` as a parameter so the 30-minute TTL logic is
|
||||
// pure and testable without a clock mock. getNextHop()/sinceLastSeen() use the real native clock;
|
||||
// we back-date timestamps relative to it, and the unsigned-subtraction age math is rollover-safe.
|
||||
|
||||
#include "MeshTypes.h" // before TestUtil.h: provides NodeNum etc.
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#include "configuration.h"
|
||||
#include "gps/RTC.h"
|
||||
#include "mesh/NextHopRouter.h"
|
||||
#include "mesh/NodeDB.h"
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <memory>
|
||||
|
||||
#define MSG_BUF_LEN 200
|
||||
#define TEST_MSG_FMT(fmt, ...) \
|
||||
do { \
|
||||
char _buf[MSG_BUF_LEN]; \
|
||||
snprintf(_buf, sizeof(_buf), fmt, __VA_ARGS__); \
|
||||
TEST_MESSAGE(_buf); \
|
||||
} while (0)
|
||||
|
||||
static constexpr NodeNum kLocalNode = 0x11111111; // last byte 0x11
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MockNodeDB — inject nodes with controlled last byte, hop distance, age, role, favorite flag.
|
||||
// ---------------------------------------------------------------------------
|
||||
class MockNodeDB : public NodeDB
|
||||
{
|
||||
public:
|
||||
void clearTestNodes()
|
||||
{
|
||||
testNodes.clear();
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
// ageSecs is how long ago we last heard the node; getTime() returns a large Unix timestamp on
|
||||
// native, so getTime()-ageSecs does not underflow for the ranges used here.
|
||||
void addNode(NodeNum num, uint8_t hopsAway, bool hasHops, uint32_t ageSecs,
|
||||
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT, bool favorite = false,
|
||||
bool ignored = false, uint8_t nextHop = NO_NEXT_HOP_PREFERENCE)
|
||||
{
|
||||
meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero;
|
||||
node.num = num;
|
||||
node.has_hops_away = hasHops;
|
||||
node.hops_away = hopsAway;
|
||||
node.role = role;
|
||||
node.next_hop = nextHop;
|
||||
node.last_heard = getTime() - ageSecs;
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, favorite);
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_IS_IGNORED_MASK, ignored);
|
||||
nodeInfoLiteSetBit(&node, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
||||
testNodes.push_back(node);
|
||||
meshNodes = &testNodes;
|
||||
numMeshNodes = testNodes.size();
|
||||
}
|
||||
|
||||
std::vector<meshtastic_NodeInfoLite> testNodes;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test shim — expose getNextHop and the route-health helpers; reset health between tests.
|
||||
// Nulls cryptLock so the Router base can be (re)constructed (same pattern as test_mqtt MockRouter).
|
||||
// ---------------------------------------------------------------------------
|
||||
class NextHopRouterTestShim : public NextHopRouter
|
||||
{
|
||||
public:
|
||||
NextHopRouterTestShim() : NextHopRouter()
|
||||
{
|
||||
delete cryptLock;
|
||||
cryptLock = nullptr;
|
||||
}
|
||||
|
||||
using NextHopRouter::clearRouteHealth;
|
||||
using NextHopRouter::findRouteHealth;
|
||||
using NextHopRouter::getNextHop;
|
||||
using NextHopRouter::getOrAllocRouteHealth;
|
||||
using NextHopRouter::isRouteStale;
|
||||
using NextHopRouter::noteRouteFailure;
|
||||
using NextHopRouter::noteRouteLearned;
|
||||
using NextHopRouter::noteRouteSuccess;
|
||||
using Router::shouldDecrementHopLimit; // protected in Router
|
||||
|
||||
void resetRouteHealthForTest()
|
||||
{
|
||||
for (auto &h : routeHealth)
|
||||
h = RouteHealth{};
|
||||
}
|
||||
};
|
||||
|
||||
static MockNodeDB *mockNodeDB = nullptr;
|
||||
static NextHopRouterTestShim *shim = nullptr;
|
||||
|
||||
static constexpr uint32_t TTL = NextHopRouter::ROUTE_TTL_MSEC;
|
||||
static constexpr uint8_t THRESH = NextHopRouter::ROUTE_FAILURE_THRESHOLD;
|
||||
static constexpr uint8_t HEALTH_MAX = NextHopRouter::ROUTE_HEALTH_MAX;
|
||||
|
||||
// Helper: a decoded packet whose hops-away is `hopsAway`, relayed by last byte `relay`.
|
||||
static meshtastic_MeshPacket makeRelayedPacket(uint8_t relay, uint8_t hopsAway)
|
||||
{
|
||||
meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero;
|
||||
p.which_payload_variant = meshtastic_MeshPacket_decoded_tag;
|
||||
p.relay_node = relay;
|
||||
p.hop_start = 4;
|
||||
p.hop_limit = 4 - hopsAway; // getHopsAway() == hop_start - hop_limit
|
||||
return p;
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
myNodeInfo.my_node_num = kLocalNode;
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
mockNodeDB->clearTestNodes();
|
||||
shim->resetRouteHealthForTest();
|
||||
}
|
||||
|
||||
void tearDown(void) {}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 1 — resolveLastByte (M1)
|
||||
// ===========================================================================
|
||||
|
||||
void test_resolve_none_when_empty(void)
|
||||
{
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, r.status);
|
||||
}
|
||||
|
||||
void test_resolve_zero_byte_is_none(void)
|
||||
{
|
||||
// 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel — never resolves.
|
||||
mockNodeDB->addNode(0x22222200, 0, true, 60); // last byte maps to 0xFF, not 0
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x00, true).status);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x00, false).status);
|
||||
}
|
||||
|
||||
void test_resolve_unique_neighbor(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct, fresh, last byte 0xAB
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_collision_is_ambiguous(void)
|
||||
{
|
||||
// Birthday collision: two fresh direct neighbors share last byte 0xAB.
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60);
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0, r.num); // never silently picks one
|
||||
}
|
||||
|
||||
void test_resolve_strict_excludes_stale(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // fresh
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, NEXTHOP_NEIGHBOR_FRESH_SECS + 100); // stale
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_strict_excludes_far(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct neighbor
|
||||
mockNodeDB->addNode(0x000006AB, 2, true, 60); // 2 hops away -> not a direct neighbor
|
||||
ResolvedNode r = mockNodeDB->resolveLastByte(0xAB, true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, r.status);
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, r.num);
|
||||
}
|
||||
|
||||
void test_resolve_lenient_includes_favorite_router(void)
|
||||
{
|
||||
// Unknown hop distance, but a favorite ROUTER: lenient gate accepts, strict gate does not.
|
||||
mockNodeDB->addNode(0x000007AB, 0, false, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, mockNodeDB->resolveLastByte(0xAB, false).status);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0xAB, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_lenient_collision_favorite_plus_neighbor(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000007AB, 0, false, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // direct neighbor, same byte
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, mockNodeDB->resolveLastByte(0xAB, false).status);
|
||||
}
|
||||
|
||||
void test_resolve_skips_self(void)
|
||||
{
|
||||
// A node equal to us (last byte 0x11) must never resolve to ourselves.
|
||||
mockNodeDB->addNode(kLocalNode, 0, true, 60);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0x11, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_skips_ignored(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, /*ignored=*/true);
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::None, mockNodeDB->resolveLastByte(0xAB, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_0x00_maps_to_0xFF_and_collides(void)
|
||||
{
|
||||
// getLastByteOfNodeNum() maps ...00 -> 0xFF, so a ...00 node and a ...FF node collide on 0xFF.
|
||||
TEST_ASSERT_EQUAL_HEX8(0xFF, mockNodeDB->getLastByteOfNodeNum(0x11111100));
|
||||
mockNodeDB->addNode(0x11111100, 0, true, 60); // last byte 0xFF (remapped)
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Unique, mockNodeDB->resolveLastByte(0xFF, true).status);
|
||||
mockNodeDB->addNode(0x222222FF, 0, true, 60); // genuine ...FF
|
||||
TEST_ASSERT_EQUAL(LastByteResolution::Ambiguous, mockNodeDB->resolveLastByte(0xFF, true).status);
|
||||
}
|
||||
|
||||
void test_resolve_unique_helper(void)
|
||||
{
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
NodeNum out = 0;
|
||||
TEST_ASSERT_TRUE(mockNodeDB->resolveUniqueLastByte(0xAB, true, &out));
|
||||
TEST_ASSERT_EQUAL_HEX32(0x000005AB, out);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60); // create collision
|
||||
TEST_ASSERT_FALSE(mockNodeDB->resolveUniqueLastByte(0xAB, true));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 2 — getNextHop (M2 send-path gate + M3 decay)
|
||||
// ===========================================================================
|
||||
|
||||
static constexpr NodeNum DEST = 0x000000B0; // DM destination (last byte 0xB0, distinct from 0xAB)
|
||||
|
||||
void test_getnexthop_unique_returns_byte(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // unique fresh neighbor with byte 0xAB
|
||||
auto nh = shim->getNextHop(DEST, /*relay=*/0x11);
|
||||
TEST_ASSERT_TRUE(nh.has_value());
|
||||
TEST_ASSERT_EQUAL_HEX8(0xAB, nh.value());
|
||||
}
|
||||
|
||||
void test_getnexthop_ambiguous_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
mockNodeDB->addNode(0x000006AB, 0, true, 60); // collision -> ambiguous
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_vanished_neighbor_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
// The only 0xAB node is stale -> strict gate yields None -> flood.
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, NEXTHOP_NEIGHBOR_FRESH_SECS + 100);
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_split_horizon_floods(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60);
|
||||
// relay_node == stored next_hop -> don't send it back the way it came.
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, /*relay=*/0xAB).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_broadcast_is_nullopt(void)
|
||||
{
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(NODENUM_BROADCAST, 0x11).has_value());
|
||||
}
|
||||
|
||||
void test_getnexthop_decays_stale_route(void)
|
||||
{
|
||||
mockNodeDB->addNode(DEST, 2, true, 60, meshtastic_Config_DeviceConfig_Role_CLIENT, false, false, /*nextHop=*/0xAB);
|
||||
mockNodeDB->addNode(0x000005AB, 0, true, 60); // a valid unique neighbor exists...
|
||||
// ...but the health record is older than the TTL, so the route should decay to flooding.
|
||||
shim->noteRouteLearned(DEST, 0xAB, millis() - (TTL + 5000));
|
||||
TEST_ASSERT_FALSE(shim->getNextHop(DEST, 0x11).has_value());
|
||||
// Decay also clears the persisted next_hop and the RAM health record.
|
||||
TEST_ASSERT_EQUAL_HEX8(NO_NEXT_HOP_PREFERENCE, mockNodeDB->getMeshNode(DEST)->next_hop);
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 3 — route-health helpers (M3)
|
||||
// ===========================================================================
|
||||
|
||||
void test_health_fresh_not_stale(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_NOT_NULL(h);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 1000 + TTL - 1));
|
||||
}
|
||||
|
||||
void test_health_ttl_expiry(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_TRUE(shim->isRouteStale(*h, 1000 + TTL)); // boundary is inclusive (>=)
|
||||
}
|
||||
|
||||
void test_health_ttl_rollover_safe(void)
|
||||
{
|
||||
const uint32_t learnAt = 0xFFFFFFFFu - 1000; // learned just before the millis() rollover
|
||||
shim->noteRouteLearned(DEST, 0xAB, learnAt);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
// 1500 ms later (wrapped to now=500): unsigned subtraction yields ~1500 ms, not "stale".
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 500));
|
||||
}
|
||||
|
||||
void test_health_failure_threshold(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
for (uint8_t i = 1; i < THRESH; i++)
|
||||
shim->noteRouteFailure(DEST);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*shim->findRouteHealth(DEST), 1000)); // THRESH-1 failures: ok
|
||||
shim->noteRouteFailure(DEST);
|
||||
TEST_ASSERT_TRUE(shim->isRouteStale(*shim->findRouteHealth(DEST), 1000)); // THRESH failures: stale
|
||||
}
|
||||
|
||||
void test_health_success_resets_failures(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteSuccess(DEST, 2000);
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
|
||||
TEST_ASSERT_FALSE(shim->isRouteStale(*h, 2000));
|
||||
}
|
||||
|
||||
void test_health_relearn_same_hop_keeps_failures(void)
|
||||
{
|
||||
// Anti-flap: an asymmetric reverse path re-teaching the same dead hop must not reset failures.
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteLearned(DEST, 0xAB, 2000); // same hop
|
||||
TEST_ASSERT_EQUAL_UINT8(2, shim->findRouteHealth(DEST)->consecutiveFailures);
|
||||
}
|
||||
|
||||
void test_health_relearn_new_hop_resets_failures(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteFailure(DEST);
|
||||
shim->noteRouteLearned(DEST, 0xCD, 2000); // genuinely new hop -> clean slate
|
||||
RouteHealth *h = shim->findRouteHealth(DEST);
|
||||
TEST_ASSERT_EQUAL_UINT8(0, h->consecutiveFailures);
|
||||
TEST_ASSERT_EQUAL_HEX8(0xCD, h->lastNextHop);
|
||||
}
|
||||
|
||||
void test_health_failure_without_record_is_noop(void)
|
||||
{
|
||||
shim->noteRouteFailure(DEST); // no record yet
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
void test_health_clear(void)
|
||||
{
|
||||
shim->noteRouteLearned(DEST, 0xAB, 1000);
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(DEST));
|
||||
shim->clearRouteHealth(DEST);
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(DEST));
|
||||
}
|
||||
|
||||
void test_health_lru_eviction_bounds_table(void)
|
||||
{
|
||||
// Fill every slot with increasing learn times, then add one more: the oldest must be evicted.
|
||||
for (uint8_t i = 0; i < HEALTH_MAX; i++)
|
||||
shim->noteRouteLearned(0x1000 + i, 0xAB, 1000 + (uint32_t)i * 1000);
|
||||
NodeNum oldest = 0x1000;
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(oldest));
|
||||
shim->noteRouteLearned(0x2000, 0xAB, 1000 + (uint32_t)HEALTH_MAX * 1000); // overflow
|
||||
TEST_ASSERT_NULL(shim->findRouteHealth(oldest)); // evicted
|
||||
TEST_ASSERT_NOT_NULL(shim->findRouteHealth(0x2000)); // newest present
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Group 4 — shouldDecrementHopLimit favorite-router resolution (M2, site 4)
|
||||
// ===========================================================================
|
||||
|
||||
void test_hoplimit_preserve_unique_favorite_router(void)
|
||||
{
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(/*relay=*/0xAB, /*hopsAway=*/1);
|
||||
TEST_ASSERT_FALSE(shim->shouldDecrementHopLimit(&p)); // preserve
|
||||
}
|
||||
|
||||
void test_hoplimit_decrement_on_colliding_favorites(void)
|
||||
{
|
||||
// Headline regression: two favorite routers share the relay byte -> ambiguous -> decrement
|
||||
// (the old "first NodeDB match wins" scan would non-deterministically preserve).
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
mockNodeDB->addNode(0x000008AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/true);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(0xAB, 1);
|
||||
TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // decrement
|
||||
}
|
||||
|
||||
void test_hoplimit_decrement_when_resolved_not_favorite(void)
|
||||
{
|
||||
config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER;
|
||||
mockNodeDB->addNode(0x000007AB, 0, true, 60, meshtastic_Config_DeviceConfig_Role_ROUTER, /*favorite=*/false);
|
||||
meshtastic_MeshPacket p = makeRelayedPacket(0xAB, 1);
|
||||
TEST_ASSERT_TRUE(shim->shouldDecrementHopLimit(&p)); // unique but not a favorite -> decrement
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
|
||||
mockNodeDB = new MockNodeDB();
|
||||
shim = new NextHopRouterTestShim();
|
||||
nodeDB = mockNodeDB;
|
||||
|
||||
printf("\n=== resolveLastByte (M1) ===\n");
|
||||
RUN_TEST(test_resolve_none_when_empty);
|
||||
RUN_TEST(test_resolve_zero_byte_is_none);
|
||||
RUN_TEST(test_resolve_unique_neighbor);
|
||||
RUN_TEST(test_resolve_collision_is_ambiguous);
|
||||
RUN_TEST(test_resolve_strict_excludes_stale);
|
||||
RUN_TEST(test_resolve_strict_excludes_far);
|
||||
RUN_TEST(test_resolve_lenient_includes_favorite_router);
|
||||
RUN_TEST(test_resolve_lenient_collision_favorite_plus_neighbor);
|
||||
RUN_TEST(test_resolve_skips_self);
|
||||
RUN_TEST(test_resolve_skips_ignored);
|
||||
RUN_TEST(test_resolve_0x00_maps_to_0xFF_and_collides);
|
||||
RUN_TEST(test_resolve_unique_helper);
|
||||
|
||||
printf("\n=== getNextHop (M2 + M3 decay) ===\n");
|
||||
RUN_TEST(test_getnexthop_unique_returns_byte);
|
||||
RUN_TEST(test_getnexthop_ambiguous_floods);
|
||||
RUN_TEST(test_getnexthop_vanished_neighbor_floods);
|
||||
RUN_TEST(test_getnexthop_split_horizon_floods);
|
||||
RUN_TEST(test_getnexthop_broadcast_is_nullopt);
|
||||
RUN_TEST(test_getnexthop_decays_stale_route);
|
||||
|
||||
printf("\n=== route-health helpers (M3) ===\n");
|
||||
RUN_TEST(test_health_fresh_not_stale);
|
||||
RUN_TEST(test_health_ttl_expiry);
|
||||
RUN_TEST(test_health_ttl_rollover_safe);
|
||||
RUN_TEST(test_health_failure_threshold);
|
||||
RUN_TEST(test_health_success_resets_failures);
|
||||
RUN_TEST(test_health_relearn_same_hop_keeps_failures);
|
||||
RUN_TEST(test_health_relearn_new_hop_resets_failures);
|
||||
RUN_TEST(test_health_failure_without_record_is_noop);
|
||||
RUN_TEST(test_health_clear);
|
||||
RUN_TEST(test_health_lru_eviction_bounds_table);
|
||||
|
||||
printf("\n=== shouldDecrementHopLimit (M2 site 4) ===\n");
|
||||
RUN_TEST(test_hoplimit_preserve_unique_favorite_router);
|
||||
RUN_TEST(test_hoplimit_decrement_on_colliding_favorites);
|
||||
RUN_TEST(test_hoplimit_decrement_when_resolved_not_favorite);
|
||||
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
@@ -0,0 +1,227 @@
|
||||
// Tests for the NodeDB hot-store migration and favourite/ignored (blocked)
|
||||
// retention paths — src/mesh/NodeDB.cpp.
|
||||
#include "MeshTypes.h" // BEFORE TestUtil.h — provides WARM_NODE_COUNT / MAX_NUM_NODES via mesh-pb-constants.h
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#define NDB_TEST_ENTRY extern "C"
|
||||
#else
|
||||
#define NDB_TEST_ENTRY
|
||||
#endif
|
||||
|
||||
// The migration demotes overflow into the warm tier, so these tests need it.
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
#include "mesh/NodeDB.h"
|
||||
#include <cstring>
|
||||
|
||||
// Subclass shim: exposes the private maintenance paths (via the friend
|
||||
// declaration in NodeDB.h) and lets a test own the hot store directly
|
||||
// (meshNodes/numMeshNodes are public). Declared at global scope so it matches
|
||||
// `friend class NodeDBTestShim` — an anonymous-namespace class would not.
|
||||
class NodeDBTestShim : public NodeDB
|
||||
{
|
||||
public:
|
||||
void runDemote() { demoteOldestHotNodesToWarm(); }
|
||||
void runCleanup() { cleanupMeshDB(); }
|
||||
|
||||
// Read back the role + protected category the warm tier cached for a node.
|
||||
bool warmMeta(NodeNum n, uint8_t &role, uint8_t &prot) { return warmStore.lookupMeta(n, role, prot); }
|
||||
|
||||
void clearHot()
|
||||
{
|
||||
meshNodes->clear();
|
||||
numMeshNodes = 0;
|
||||
}
|
||||
|
||||
void push(NodeNum num, uint32_t lastHeard, bool favorite, bool ignored, bool withUser, bool withKey,
|
||||
meshtastic_Config_DeviceConfig_Role role = meshtastic_Config_DeviceConfig_Role_CLIENT)
|
||||
{
|
||||
meshtastic_NodeInfoLite n = meshtastic_NodeInfoLite_init_zero;
|
||||
n.num = num;
|
||||
n.last_heard = lastHeard;
|
||||
n.role = role;
|
||||
if (favorite)
|
||||
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
|
||||
if (ignored)
|
||||
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
|
||||
if (withUser)
|
||||
nodeInfoLiteSetBit(&n, NODEINFO_BITFIELD_HAS_USER_MASK, true);
|
||||
if (withKey) {
|
||||
n.public_key.size = 32;
|
||||
memset(n.public_key.bytes, static_cast<uint8_t>(num & 0xff), 32);
|
||||
n.public_key.bytes[0] = 0x01; // ensure non-zero (all-zero == "no key")
|
||||
}
|
||||
meshNodes->push_back(n);
|
||||
numMeshNodes = meshNodes->size();
|
||||
}
|
||||
|
||||
// Index 0 is our own node; the eviction/migration scans treat it as self.
|
||||
void seedSelf() { push(0x0BADF00D, 0xFFFFFFFFu, false, false, /*withUser=*/true, /*withKey=*/false); }
|
||||
};
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
NodeDBTestShim *db = nullptr;
|
||||
|
||||
bool warmHasKey(NodeNum n)
|
||||
{
|
||||
meshtastic_NodeInfoLite_public_key_t k = {0, {0}};
|
||||
return db->copyPublicKey(n, k) && k.size == 32;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
db->clearHot();
|
||||
}
|
||||
void tearDown(void) {}
|
||||
|
||||
// Migration: a database from a larger-cap build trims to MAX_NUM_NODES; the
|
||||
// oldest non-protected nodes are demoted into the warm tier (keys preserved),
|
||||
// while self, favourites and ignored survive even when they are the oldest.
|
||||
static void test_migration_demotesOldestKeepsKeepersAndSelf(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
const int extra = MAX_NUM_NODES + 30; // overflow well past the MAX-2 cap
|
||||
for (int i = 1; i <= extra; i++) {
|
||||
const bool fav = (i == 1); // oldest, but a favourite
|
||||
const bool ign = (i == 2); // 2nd-oldest, but blocked
|
||||
db->push(2000 + i, /*last_heard=*/i, fav, ign, /*withUser=*/true, /*withKey=*/true);
|
||||
}
|
||||
|
||||
db->runDemote();
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes());
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x0BADF00D)); // self retained
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(2000 + 1)); // oldest favourite retained
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(2000 + 2)); // oldest ignored retained
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(2000 + extra)); // freshest retained
|
||||
TEST_ASSERT_NULL(db->getMeshNode(2000 + 3)); // oldest non-protected demoted out of hot
|
||||
TEST_ASSERT_TRUE(warmHasKey(2000 + 3)); // ...but its key kept in the warm tier
|
||||
}
|
||||
|
||||
// Eviction carries the device role + protected category into the warm tier. A TRACKER is
|
||||
// hop-protected but NOT eviction-protected, so it gets demoted with its key; the warm
|
||||
// record must report role=TRACKER / category=Role. A plain CLIENT carries role=CLIENT/None.
|
||||
static void test_migration_carriesRoleAndProtectedIntoWarm(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
const int extra = MAX_NUM_NODES + 30; // overflow so the oldest non-protected are demoted
|
||||
for (int i = 1; i <= extra; i++) {
|
||||
const auto role = (i == 3) ? meshtastic_Config_DeviceConfig_Role_TRACKER : meshtastic_Config_DeviceConfig_Role_CLIENT;
|
||||
db->push(2000 + i, /*last_heard=*/i, /*favorite=*/false, /*ignored=*/false, /*withUser=*/true,
|
||||
/*withKey=*/true, role);
|
||||
}
|
||||
|
||||
db->runDemote();
|
||||
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
// TRACKER (i=3): demoted out of hot, key kept, role + protected carried into warm.
|
||||
TEST_ASSERT_NULL(db->getMeshNode(2000 + 3));
|
||||
TEST_ASSERT_TRUE(warmHasKey(2000 + 3));
|
||||
TEST_ASSERT_TRUE(db->warmMeta(2000 + 3, role, prot));
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_TRACKER, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
|
||||
// CLIENT (i=4): also demoted, carries role=CLIENT / category=None.
|
||||
TEST_ASSERT_TRUE(db->warmMeta(2000 + 4, role, prot));
|
||||
TEST_ASSERT_EQUAL(meshtastic_Config_DeviceConfig_Role_CLIENT, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
// Favourite handling: a favourite is never the eviction victim, even when it is
|
||||
// the oldest node in a full hot store.
|
||||
static void test_eviction_preservesFavorite(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
for (int i = 1; i < MAX_NUM_NODES; i++) { // fill to MAX_NUM_NODES total (incl. self)
|
||||
const bool fav = (i == 1); // oldest non-self, favourite
|
||||
db->push(3000 + i, /*last_heard=*/i, fav, false, /*withUser=*/true, /*withKey=*/true);
|
||||
}
|
||||
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES, (int)db->getNumMeshNodes()); // full
|
||||
|
||||
TEST_ASSERT_NOT_NULL(db->getOrCreateMeshNode(0x99990000)); // forces an eviction
|
||||
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(3000 + 1)); // favourite survived despite being oldest
|
||||
TEST_ASSERT_NULL(db->getMeshNode(3000 + 2)); // oldest non-favourite evicted
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(0x99990000));
|
||||
}
|
||||
|
||||
// Ignored handling: an ignored node survives eviction (like a favourite), and is
|
||||
// never purged by cleanupMeshDB even with no user info (a block set by bare ID).
|
||||
static void test_ignored_survivesEvictionAndCleanup(void)
|
||||
{
|
||||
// (a) eviction protection
|
||||
db->clearHot();
|
||||
db->seedSelf();
|
||||
for (int i = 1; i < MAX_NUM_NODES; i++) {
|
||||
const bool ign = (i == 1); // oldest non-self, blocked
|
||||
db->push(4000 + i, /*last_heard=*/i, false, ign, /*withUser=*/true, /*withKey=*/true);
|
||||
}
|
||||
TEST_ASSERT_NOT_NULL(db->getOrCreateMeshNode(0x88880000));
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(4000 + 1)); // blocked node survived
|
||||
TEST_ASSERT_NULL(db->getMeshNode(4000 + 2)); // oldest non-blocked evicted
|
||||
|
||||
// (b) cleanup protection — ignored kept without user info, plain no-user purged
|
||||
db->clearHot();
|
||||
db->seedSelf();
|
||||
db->push(5000, 100, false, /*ignored=*/true, /*withUser=*/false, false);
|
||||
db->push(5001, 100, false, false, /*withUser=*/false, false);
|
||||
db->runCleanup();
|
||||
TEST_ASSERT_NOT_NULL(db->getMeshNode(5000)); // blocked-by-ID kept despite no user info
|
||||
TEST_ASSERT_NULL(db->getMeshNode(5001)); // ordinary no-user node purged
|
||||
}
|
||||
|
||||
// Protected-node cap: at most MAX_NUM_NODES-2 nodes may be protected, so >=2
|
||||
// evictable slots always remain. setProtectedFlag refuses once the cap is hit.
|
||||
static void test_protectedCap_refusesBeyondLimit(void)
|
||||
{
|
||||
db->seedSelf();
|
||||
for (int i = 0; i < MAX_NUM_NODES - 2; i++)
|
||||
db->push(6000 + i, 100, /*favorite=*/true, false, /*withUser=*/true, false);
|
||||
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES - 2, db->numProtectedNodes());
|
||||
|
||||
db->push(7000, 100, false, false, /*withUser=*/true, false);
|
||||
meshtastic_NodeInfoLite *fresh = db->getMeshNode(7000);
|
||||
TEST_ASSERT_NOT_NULL(fresh);
|
||||
TEST_ASSERT_FALSE(db->setProtectedFlag(fresh, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)); // refused at cap
|
||||
TEST_ASSERT_FALSE(nodeInfoLiteIsIgnored(fresh)); // unchanged
|
||||
TEST_ASSERT_EQUAL_INT(MAX_NUM_NODES - 2, db->numProtectedNodes());
|
||||
|
||||
// Adding another flag to an already-protected node doesn't grow the set, so
|
||||
// it's still allowed at the cap.
|
||||
meshtastic_NodeInfoLite *already = db->getMeshNode(6000);
|
||||
TEST_ASSERT_TRUE(db->setProtectedFlag(already, NODEINFO_BITFIELD_IS_IGNORED_MASK, true));
|
||||
}
|
||||
|
||||
NDB_TEST_ENTRY void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
db = new NodeDBTestShim();
|
||||
nodeDB = db;
|
||||
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_migration_demotesOldestKeepsKeepersAndSelf);
|
||||
RUN_TEST(test_migration_carriesRoleAndProtectedIntoWarm);
|
||||
RUN_TEST(test_eviction_preservesFavorite);
|
||||
RUN_TEST(test_ignored_survivesEvictionAndCleanup);
|
||||
RUN_TEST(test_protectedCap_refusesBeyondLimit);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
NDB_TEST_ENTRY void loop() {}
|
||||
|
||||
#else // WARM_NODE_COUNT == 0 — nothing to exercise here
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
NDB_TEST_ENTRY void setup()
|
||||
{
|
||||
UNITY_BEGIN();
|
||||
exit(UNITY_END());
|
||||
}
|
||||
NDB_TEST_ENTRY void loop() {}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "TestUtil.h"
|
||||
#include "modules/PositionModule.h"
|
||||
#include <unity.h>
|
||||
|
||||
// These exercise PositionModule's pure broadcast-policy helpers (stationary detection and the
|
||||
// interval floor). They take plain values, so no device globals or fake clock are needed.
|
||||
|
||||
// Coordinates sharing the top `precision` bits land in the same grid cell.
|
||||
static void test_withinPrecisionCell_jitterStaysInCell()
|
||||
{
|
||||
// At precision 16 the top 16 bits define the cell; the low 16 bits are GPS jitter.
|
||||
TEST_ASSERT_TRUE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x1234ABCD, 0x2234EF01, 16));
|
||||
}
|
||||
|
||||
static void test_withinPrecisionCell_movingLatLeavesCell()
|
||||
{
|
||||
TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12350000, 0x22340000, 16));
|
||||
}
|
||||
|
||||
static void test_withinPrecisionCell_movingLonLeavesCell()
|
||||
{
|
||||
TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22350000, 16));
|
||||
}
|
||||
|
||||
// precision 0 means position sharing is off — never treat as stationary/suppressible.
|
||||
static void test_withinPrecisionCell_zeroPrecisionNeverSuppresses()
|
||||
{
|
||||
TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22340000, 0));
|
||||
}
|
||||
|
||||
// Full precision (>=32): any difference matters, and identical full-precision coords still aren't
|
||||
// "stationary" because there's no coarse cell to hold within.
|
||||
static void test_withinPrecisionCell_fullPrecisionNeverSuppresses()
|
||||
{
|
||||
TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22340000, 32));
|
||||
}
|
||||
|
||||
static void test_effectiveInterval_stationaryRaisesToFloor()
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT32(43200000U, PositionModule::effectiveBroadcastIntervalMs(60000U, true, 43200000U));
|
||||
}
|
||||
|
||||
static void test_effectiveInterval_movingKeepsConfigured()
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT32(60000U, PositionModule::effectiveBroadcastIntervalMs(60000U, false, 43200000U));
|
||||
}
|
||||
|
||||
// A configured interval already longer than the floor is never shortened.
|
||||
static void test_effectiveInterval_longConfiguredWinsOverFloor()
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT32(50000000U, PositionModule::effectiveBroadcastIntervalMs(50000000U, true, 43200000U));
|
||||
}
|
||||
|
||||
static void test_effectiveInterval_zeroFloorIsNoOp()
|
||||
{
|
||||
TEST_ASSERT_EQUAL_UINT32(60000U, PositionModule::effectiveBroadcastIntervalMs(60000U, true, 0U));
|
||||
}
|
||||
|
||||
void setUp(void) {}
|
||||
|
||||
void tearDown(void) {}
|
||||
|
||||
extern "C" {
|
||||
void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_withinPrecisionCell_jitterStaysInCell);
|
||||
RUN_TEST(test_withinPrecisionCell_movingLatLeavesCell);
|
||||
RUN_TEST(test_withinPrecisionCell_movingLonLeavesCell);
|
||||
RUN_TEST(test_withinPrecisionCell_zeroPrecisionNeverSuppresses);
|
||||
RUN_TEST(test_withinPrecisionCell_fullPrecisionNeverSuppresses);
|
||||
RUN_TEST(test_effectiveInterval_stationaryRaisesToFloor);
|
||||
RUN_TEST(test_effectiveInterval_movingKeepsConfigured);
|
||||
RUN_TEST(test_effectiveInterval_longConfiguredWinsOverFloor);
|
||||
RUN_TEST(test_effectiveInterval_zeroFloorIsNoOp);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
void loop() {}
|
||||
}
|
||||
@@ -200,6 +200,87 @@ static void test_applyModemConfig_customCodingRateLowerThanPreset()
|
||||
TEST_ASSERT_EQUAL_UINT8(8, testRadio->getCr());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// getRegionPresetMap() — region->valid-preset map sent to clients during want_config
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
static size_t countKnownRegions()
|
||||
{
|
||||
size_t n = 0;
|
||||
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++)
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
// Every region in the firmware table (except the UNSET sentinel) must appear
|
||||
// exactly once in the map, and all counts must stay within the mesh.options bounds
|
||||
// (exceeding them would mean nanopb silently truncates the wire message).
|
||||
static void test_regionPresetMap_coversAllRegionsWithinBounds()
|
||||
{
|
||||
meshtastic_LoRaRegionPresetMap map;
|
||||
getRegionPresetMap(map);
|
||||
|
||||
const size_t known = countKnownRegions();
|
||||
TEST_ASSERT_EQUAL_UINT((unsigned)known, (unsigned)map.region_groups_count);
|
||||
|
||||
// Bounds derived from the generated nanopb arrays (mesh.options max_count), so
|
||||
// this stays correct if those bounds change.
|
||||
const size_t maxGroups = sizeof(map.groups) / sizeof(map.groups[0]);
|
||||
const size_t maxRegions = sizeof(map.region_groups) / sizeof(map.region_groups[0]);
|
||||
TEST_ASSERT_GREATER_THAN_UINT(0, map.groups_count);
|
||||
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxGroups, map.groups_count);
|
||||
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxRegions, map.region_groups_count);
|
||||
|
||||
// Each known region appears exactly once.
|
||||
for (const RegionInfo *r = regions; r->code != meshtastic_Config_LoRaConfig_RegionCode_UNSET; r++) {
|
||||
int hits = 0;
|
||||
for (pb_size_t i = 0; i < map.region_groups_count; i++)
|
||||
if (map.region_groups[i].region == r->code)
|
||||
hits++;
|
||||
TEST_ASSERT_EQUAL_INT(1, hits);
|
||||
}
|
||||
}
|
||||
|
||||
// The advertised presets must agree with the live region table: every preset is
|
||||
// legal in its region, the default is among them, and the licensed flag matches.
|
||||
static void test_regionPresetMap_matchesRegionTable()
|
||||
{
|
||||
meshtastic_LoRaRegionPresetMap map;
|
||||
getRegionPresetMap(map);
|
||||
|
||||
for (pb_size_t i = 0; i < map.region_groups_count; i++) {
|
||||
meshtastic_Config_LoRaConfig_RegionCode code = map.region_groups[i].region;
|
||||
uint8_t gi = map.region_groups[i].group_index;
|
||||
TEST_ASSERT_LESS_THAN_UINT(map.groups_count, gi);
|
||||
|
||||
const meshtastic_LoRaPresetGroup &grp = map.groups[gi];
|
||||
const RegionInfo *r = getRegion(code);
|
||||
|
||||
// Group's list is non-empty, within the generated array bound, and is the
|
||||
// region's full list.
|
||||
const size_t maxPresets = sizeof(grp.presets) / sizeof(grp.presets[0]);
|
||||
TEST_ASSERT_GREATER_THAN_UINT(0, grp.presets_count);
|
||||
TEST_ASSERT_LESS_OR_EQUAL_UINT((unsigned)maxPresets, grp.presets_count);
|
||||
TEST_ASSERT_EQUAL_UINT((unsigned)r->getNumPresets(), (unsigned)grp.presets_count);
|
||||
|
||||
// Every advertised preset is legal in this region.
|
||||
for (pb_size_t p = 0; p < grp.presets_count; p++)
|
||||
TEST_ASSERT_TRUE(r->supportsPreset(grp.presets[p]));
|
||||
|
||||
// Default preset matches the table, is legal, and is present in the list.
|
||||
TEST_ASSERT_EQUAL(r->getDefaultPreset(), grp.default_preset);
|
||||
TEST_ASSERT_TRUE(r->supportsPreset(grp.default_preset));
|
||||
bool defaultInList = false;
|
||||
for (pb_size_t p = 0; p < grp.presets_count; p++)
|
||||
if (grp.presets[p] == grp.default_preset)
|
||||
defaultInList = true;
|
||||
TEST_ASSERT_TRUE(defaultInList);
|
||||
|
||||
// Licensed flag matches the region's profile.
|
||||
TEST_ASSERT_EQUAL(r->profile->licensedOnly, grp.licensed_only);
|
||||
}
|
||||
}
|
||||
|
||||
void setUp(void)
|
||||
{
|
||||
mockMeshService = new MockMeshService();
|
||||
@@ -241,6 +322,8 @@ void setup()
|
||||
RUN_TEST(test_applyModemConfig_codingRateMatchesPreset);
|
||||
RUN_TEST(test_applyModemConfig_customCodingRateHigherThanPreset);
|
||||
RUN_TEST(test_applyModemConfig_customCodingRateLowerThanPreset);
|
||||
RUN_TEST(test_regionPresetMap_coversAllRegionsWithinBounds);
|
||||
RUN_TEST(test_regionPresetMap_matchesRegionTable);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
// Unit tests for the warm ("long-tail") node tier — src/mesh/WarmNodeStore.cpp.
|
||||
// Covers admission/eviction policy (keyed entries outrank keyless), take()
|
||||
// rehydration semantics, and a tolerant persistence round trip.
|
||||
#include "MeshTypes.h" // BEFORE TestUtil.h — provides WARM_NODE_COUNT via mesh-pb-constants.h
|
||||
#include "TestUtil.h"
|
||||
#include <unity.h>
|
||||
|
||||
#if defined(ARCH_PORTDUINO)
|
||||
#define WS_TEST_ENTRY extern "C"
|
||||
#else
|
||||
#define WS_TEST_ENTRY
|
||||
#endif
|
||||
|
||||
#if WARM_NODE_COUNT > 0
|
||||
|
||||
#include "FSCommon.h"
|
||||
#include "mesh/WarmNodeStore.h"
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
void makeKey(uint8_t out[32], uint8_t seed)
|
||||
{
|
||||
memset(out, 0, 32);
|
||||
out[0] = seed;
|
||||
out[31] = seed ^ 0xA5;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
void test_ws_absorb_and_copyKey_roundTrip()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32], got[32];
|
||||
makeKey(key, 7);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x100, 1000, key));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x100));
|
||||
TEST_ASSERT_TRUE(ws.copyKey(0x100, got));
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, got, 32);
|
||||
TEST_ASSERT_EQUAL(1, ws.count());
|
||||
}
|
||||
|
||||
void test_ws_keylessEntry_hasNoKey()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t got[32];
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x200, 1000, NULL));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x200));
|
||||
TEST_ASSERT_FALSE(ws.copyKey(0x200, got));
|
||||
}
|
||||
|
||||
void test_ws_absorb_rejectsNodeNumZero()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
TEST_ASSERT_FALSE(ws.absorb(0, 1000, NULL));
|
||||
TEST_ASSERT_EQUAL(0, ws.count());
|
||||
}
|
||||
|
||||
void test_ws_absorb_updatesExistingEntry()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32], got[32];
|
||||
makeKey(key, 9);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x300, 1000, NULL));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x300, 2000, key)); // later eviction learned a key
|
||||
TEST_ASSERT_EQUAL(1, ws.count());
|
||||
TEST_ASSERT_TRUE(ws.copyKey(0x300, got));
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, got, 32);
|
||||
}
|
||||
|
||||
void test_ws_take_removesEntry()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 3);
|
||||
ws.absorb(0x400, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role);
|
||||
|
||||
WarmNodeEntry e;
|
||||
TEST_ASSERT_TRUE(ws.take(0x400, e));
|
||||
TEST_ASSERT_EQUAL(0x400, e.num);
|
||||
// last_heard is quantised to the metadata quantum; role/protected ride the low bits.
|
||||
TEST_ASSERT_EQUAL(1234u & WARM_TIME_MASK, warmTimeOf(e));
|
||||
TEST_ASSERT_EQUAL(5, warmRoleOf(e));
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, warmProtOf(e));
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, e.public_key, 32);
|
||||
TEST_ASSERT_FALSE(ws.contains(0x400));
|
||||
TEST_ASSERT_FALSE(ws.take(0x400, e));
|
||||
TEST_ASSERT_EQUAL(0, ws.count());
|
||||
}
|
||||
|
||||
void test_ws_keylessCandidate_neverEvictsKeyedEntries()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
// Fill the store entirely with keyed entries
|
||||
for (size_t i = 0; i < ws.capacity(); i++) {
|
||||
makeKey(key, (uint8_t)i);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, 100 + i, key));
|
||||
}
|
||||
TEST_ASSERT_EQUAL(ws.capacity(), ws.count());
|
||||
// A keyless candidate (even a fresh one) must be rejected
|
||||
TEST_ASSERT_FALSE(ws.absorb(0x9999, 999999, NULL));
|
||||
TEST_ASSERT_FALSE(ws.contains(0x9999));
|
||||
}
|
||||
|
||||
void test_ws_keyedCandidate_evictsOldestKeylessFirst()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 0x42);
|
||||
// Fill with keyed entries except two keyless ones in the middle
|
||||
for (size_t i = 0; i < ws.capacity(); i++) {
|
||||
const bool keyless = (i == 5 || i == 10);
|
||||
// Timestamps spaced by the 64 s warm metadata quantum (<<6) so LRU order survives
|
||||
// quantisation: keyless i=10 is oldest (50), i=5 next (60), keyed all older (10).
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, (keyless ? (i == 10 ? 50u : 60u) : 10u) << 6, keyless ? NULL : key));
|
||||
}
|
||||
// Keyed candidate must displace the OLDEST KEYLESS entry (0x100A, ts=50),
|
||||
// even though every keyed entry is older (ts=10)
|
||||
uint8_t k2[32];
|
||||
makeKey(k2, 0x43);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x8888, 70u << 6, k2));
|
||||
TEST_ASSERT_FALSE(ws.contains(0x1000 + 10));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x1000 + 5));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x8888));
|
||||
}
|
||||
|
||||
void test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
for (size_t i = 0; i < ws.capacity(); i++) {
|
||||
makeKey(key, (uint8_t)i);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x1000 + i, 1000 + i, key)); // 0x1000 is the oldest
|
||||
}
|
||||
uint8_t k2[32];
|
||||
makeKey(k2, 0x44);
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x7777, 999999, k2));
|
||||
TEST_ASSERT_TRUE(ws.contains(0x7777));
|
||||
TEST_ASSERT_FALSE(ws.contains(0x1000)); // oldest keyed evicted
|
||||
TEST_ASSERT_EQUAL(ws.capacity(), ws.count());
|
||||
}
|
||||
|
||||
void test_ws_meta_roundTrip()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
uint8_t key[32];
|
||||
makeKey(key, 0x77);
|
||||
// Keyed TRACKER(5)/Role-protected and keyless SENSOR(6)/unprotected.
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x700, 1234, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role));
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x701, 5678, NULL, 6 /* SENSOR */, (uint8_t)WarmProtected::None));
|
||||
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x700, role, prot));
|
||||
TEST_ASSERT_EQUAL(5, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::Role, prot);
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x701, role, prot));
|
||||
TEST_ASSERT_EQUAL(6, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
// Absent node yields false and leaves outputs untouched-by-contract (just check return).
|
||||
TEST_ASSERT_FALSE(ws.lookupMeta(0x999, role, prot));
|
||||
// Default args still compile (role/protected = 0 = CLIENT/None).
|
||||
TEST_ASSERT_TRUE(ws.absorb(0x702, 9999, NULL));
|
||||
TEST_ASSERT_TRUE(ws.lookupMeta(0x702, role, prot));
|
||||
TEST_ASSERT_EQUAL(0, role);
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
}
|
||||
|
||||
void test_ws_remove_and_clear()
|
||||
{
|
||||
WarmNodeStore ws;
|
||||
ws.absorb(0x500, 1, NULL);
|
||||
ws.absorb(0x501, 2, NULL);
|
||||
ws.remove(0x500);
|
||||
TEST_ASSERT_FALSE(ws.contains(0x500));
|
||||
TEST_ASSERT_EQUAL(1, ws.count());
|
||||
ws.clear();
|
||||
TEST_ASSERT_EQUAL(0, ws.count());
|
||||
}
|
||||
|
||||
void test_ws_persistence_roundTrip()
|
||||
{
|
||||
WarmNodeStore a;
|
||||
uint8_t key[32], got[32];
|
||||
makeKey(key, 0x55);
|
||||
a.absorb(0x600, 4242, key);
|
||||
a.absorb(0x601, 4243, NULL);
|
||||
if (!a.saveIfDirty()) {
|
||||
TEST_IGNORE_MESSAGE("Filesystem not available in this test environment");
|
||||
return;
|
||||
}
|
||||
|
||||
WarmNodeStore b;
|
||||
b.load();
|
||||
TEST_ASSERT_TRUE(b.contains(0x600));
|
||||
TEST_ASSERT_TRUE(b.contains(0x601));
|
||||
TEST_ASSERT_TRUE(b.copyKey(0x600, got));
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, got, 32);
|
||||
|
||||
// Cleanup so reruns start fresh
|
||||
b.clear();
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
// Migration: a v1 (WRM1) warm.dat must keep identity + key but discard last_heard
|
||||
// (so its low bits aren't misread as role/protected). File backend only.
|
||||
void test_ws_v1_migration_discardsLastHeard()
|
||||
{
|
||||
WarmNodeStore a;
|
||||
uint8_t key[32], got[32];
|
||||
makeKey(key, 0x66);
|
||||
a.absorb(0x900, 123456, key, 5 /* TRACKER */, (uint8_t)WarmProtected::Role);
|
||||
if (!a.saveIfDirty()) {
|
||||
TEST_IGNORE_MESSAGE("Filesystem not available in this test environment");
|
||||
return;
|
||||
}
|
||||
|
||||
// Read the whole v2 file, flip the 4-byte header magic to v1 ("WRM1"), write it back.
|
||||
// (CRC covers only the entry bytes, so patching the header magic keeps it valid.)
|
||||
std::vector<uint8_t> buf;
|
||||
{
|
||||
auto f = FSCom.open("/prefs/warm.dat", FILE_O_READ);
|
||||
if (!f) {
|
||||
TEST_IGNORE_MESSAGE("warm.dat not readable in this environment");
|
||||
return;
|
||||
}
|
||||
buf.resize(f.size());
|
||||
f.read(buf.data(), buf.size());
|
||||
f.close();
|
||||
}
|
||||
TEST_ASSERT_TRUE(buf.size() >= 4);
|
||||
const uint32_t v1magic = 0x314D5257u; // "WRM1"
|
||||
memcpy(buf.data(), &v1magic, sizeof(v1magic));
|
||||
{
|
||||
auto f = FSCom.open("/prefs/warm.dat", FILE_O_WRITE);
|
||||
TEST_ASSERT_TRUE((bool)f);
|
||||
f.write(buf.data(), buf.size());
|
||||
f.close();
|
||||
}
|
||||
|
||||
WarmNodeStore b;
|
||||
b.load();
|
||||
TEST_ASSERT_TRUE(b.contains(0x900)); // identity survived migration
|
||||
TEST_ASSERT_TRUE(b.copyKey(0x900, got)); // public key survived
|
||||
TEST_ASSERT_EQUAL_MEMORY(key, got, 32);
|
||||
uint8_t role = 0xFF, prot = 0xFF;
|
||||
TEST_ASSERT_TRUE(b.lookupMeta(0x900, role, prot));
|
||||
TEST_ASSERT_EQUAL(0, role); // last_heard discarded → role/protected reset
|
||||
TEST_ASSERT_EQUAL((uint8_t)WarmProtected::None, prot);
|
||||
|
||||
b.clear();
|
||||
b.saveIfDirty();
|
||||
}
|
||||
|
||||
WS_TEST_ENTRY void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
RUN_TEST(test_ws_absorb_and_copyKey_roundTrip);
|
||||
RUN_TEST(test_ws_keylessEntry_hasNoKey);
|
||||
RUN_TEST(test_ws_absorb_rejectsNodeNumZero);
|
||||
RUN_TEST(test_ws_absorb_updatesExistingEntry);
|
||||
RUN_TEST(test_ws_take_removesEntry);
|
||||
RUN_TEST(test_ws_keylessCandidate_neverEvictsKeyedEntries);
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeylessFirst);
|
||||
RUN_TEST(test_ws_keyedCandidate_evictsOldestKeyedWhenNoKeyless);
|
||||
RUN_TEST(test_ws_meta_roundTrip);
|
||||
RUN_TEST(test_ws_remove_and_clear);
|
||||
RUN_TEST(test_ws_persistence_roundTrip);
|
||||
RUN_TEST(test_ws_v1_migration_discardsLastHeard);
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
WS_TEST_ENTRY void loop() {}
|
||||
|
||||
#else
|
||||
|
||||
void setUp(void) {}
|
||||
void tearDown(void) {}
|
||||
|
||||
WS_TEST_ENTRY void setup()
|
||||
{
|
||||
initializeTestEnvironment();
|
||||
UNITY_BEGIN();
|
||||
exit(UNITY_END());
|
||||
}
|
||||
|
||||
WS_TEST_ENTRY void loop() {}
|
||||
|
||||
#endif
|
||||
@@ -24,6 +24,7 @@
|
||||
// "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN",
|
||||
// "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted.
|
||||
// "USERPREFS_EVENT_MODE": "1",
|
||||
// "USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS": "1", // Extend TMM position dedup and precision clamping to private/custom-key channels (default: well-known channels only)
|
||||
// "USERPREFS_FIRMWARE_EDITION": "meshtastic_FirmwareEdition_BURNING_MAN",
|
||||
// "USERPREFS_FIXED_BLUETOOTH": "121212",
|
||||
// "USERPREFS_FIXED_GPS": "",
|
||||
|
||||
@@ -17,7 +17,7 @@ extra_scripts =
|
||||
extra_scripts/esp32_extra.py
|
||||
|
||||
build_src_filter =
|
||||
${arduino_base.build_src_filter} -<platform/nrf52/> -<platform/nrf54l15/> -<platform/stm32wl> -<platform/rp2xx0> -<mesh/eth/> -<mesh/raspihttp>
|
||||
${arduino_base.build_src_filter} +<platform/esp32/> -<mesh/eth/> -<mesh/raspihttp>
|
||||
|
||||
upload_speed = 921600
|
||||
debug_init_break = tbreak setup
|
||||
@@ -276,9 +276,23 @@ custom_sdkconfig =
|
||||
CONFIG_BT_NIMBLE_ROLE_CENTRAL=n
|
||||
CONFIG_BT_NIMBLE_ROLE_OBSERVER=n
|
||||
CONFIG_BT_CONTROLLER_ENABLED=y
|
||||
CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=8192
|
||||
CONFIG_BT_NIMBLE_MAX_CCCDS=20
|
||||
# BLE RAM right-sizing for a single-phone peripheral. IDF-5.5/Arduino-3.x raised RAM use to where
|
||||
# NimBLE bring-up no longer had enough contiguous heap (host task fails to allocate -> host never
|
||||
# syncs -> BLEDevice::init() hangs; GATT/advertising then OOM). The node only ever has one
|
||||
# connection and never scans, so trimming these over-provisioned controller/host buffers frees
|
||||
# the heap. Keep the host-task stack at the IDF default 5120 (do NOT lower to 4096 -- #2618 raised
|
||||
# it because 4096 overflows); the prior 8192 was an over-allocation that starved advertising.
|
||||
CONFIG_BT_NIMBLE_HOST_TASK_STACK_SIZE=5120
|
||||
CONFIG_BT_NIMBLE_MAX_CCCDS=8
|
||||
CONFIG_BT_NIMBLE_MAX_BONDS=6
|
||||
CONFIG_BT_NIMBLE_MAX_CONNECTIONS=1
|
||||
CONFIG_BT_CTRL_BLE_MAX_ACT=2
|
||||
CONFIG_BT_CTRL_SCAN_DUPL_CACHE_SIZE=10
|
||||
CONFIG_BT_NIMBLE_WHITELIST_SIZE=1
|
||||
CONFIG_BT_NIMBLE_MSYS_1_BLOCK_COUNT=8
|
||||
CONFIG_BT_NIMBLE_MSYS_2_BLOCK_COUNT=8
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_ACL_FROM_LL_COUNT=8
|
||||
CONFIG_BT_NIMBLE_TRANSPORT_EVT_COUNT=12
|
||||
CONFIG_BT_NIMBLE_ENABLE_PERIODIC_SYNC=n
|
||||
CONFIG_BT_NIMBLE_ENABLE_PERIODIC_ADV=n
|
||||
CONFIG_BT_NIMBLE_EXT_SCAN=n
|
||||
|
||||
@@ -13,7 +13,7 @@ build_flags =
|
||||
-DMESHTASTIC_EXCLUDE_PAXCOUNTER=1
|
||||
|
||||
build_src_filter =
|
||||
${esp32_common.build_src_filter} -<libpax/> -<platform/stm32wl> -<nimble> -<mesh/http/> -<platform/rp2xx0> -<mesh/raspihttp> -<serialization/>
|
||||
${esp32_common.build_src_filter} -<libpax/> +<platform/esp32> -<mesh/raspihttp> -<serialization/>
|
||||
|
||||
extra_scripts =
|
||||
${esp32_common.extra_scripts}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
|
||||
#define ADC_MULTIPLIER 2.0
|
||||
|
||||
#define OCV_ARRAY 4180, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100
|
||||
#define OCV_ARRAY 4100, 4050, 3990, 3890, 3800, 3720, 3630, 3530, 3420, 3300, 3100
|
||||
|
||||
#define PIN_BUZZER 9
|
||||
|
||||
|
||||
@@ -33,9 +33,6 @@
|
||||
#ifndef HAS_TRAFFIC_MANAGEMENT
|
||||
#define HAS_TRAFFIC_MANAGEMENT 1
|
||||
#endif
|
||||
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048
|
||||
#endif
|
||||
|
||||
// ---- GC1109 RF FRONT END CONFIGURATION ----
|
||||
// The Heltec V4.2 uses a GC1109 FEM chip with integrated PA and LNA
|
||||
|
||||
@@ -31,9 +31,6 @@
|
||||
#ifndef HAS_TRAFFIC_MANAGEMENT
|
||||
#define HAS_TRAFFIC_MANAGEMENT 1
|
||||
#endif
|
||||
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048
|
||||
#endif
|
||||
|
||||
// ---- KCT8103L RF FRONT END CONFIGURATION ----
|
||||
// The Heltec V4.3 uses a KCT8103L FEM chip with integrated PA and LNA
|
||||
|
||||
@@ -19,14 +19,14 @@ custom_meshtastic_support_level = 1
|
||||
custom_meshtastic_display_name = RAK WisMesh Tap V2
|
||||
custom_meshtastic_images = rak-wismesh-tap-v2.svg
|
||||
custom_meshtastic_tags = RAK
|
||||
custom_meshtastic_partition_scheme = 8MB
|
||||
custom_meshtastic_partition_scheme = 16MB
|
||||
custom_meshtastic_has_mui = true
|
||||
|
||||
extends = esp32s3_base
|
||||
board = wiscore_rak3312
|
||||
board_check = true
|
||||
upload_protocol = esptool
|
||||
board_build.partitions = default_8MB.csv
|
||||
board_build.partitions = default_16MB.csv
|
||||
|
||||
build_flags =
|
||||
${esp32s3_base.build_flags}
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
#define BATTERY_PIN 1
|
||||
#define ADC_CHANNEL ADC_CHANNEL_0
|
||||
#define ADC_MULTIPLIER 1.667
|
||||
#define OCV_ARRAY 4160, 4020, 3940, 3870, 3810, 3760, 3740, 3720, 3680, 3620, 2990
|
||||
|
||||
#define PIN_BUZZER 38
|
||||
|
||||
|
||||
@@ -14,9 +14,6 @@ Board Information: https://wiki.uniteng.com/en/meshtastic/station-g2
|
||||
#ifndef HAS_TRAFFIC_MANAGEMENT
|
||||
#define HAS_TRAFFIC_MANAGEMENT 1
|
||||
#endif
|
||||
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048
|
||||
#endif
|
||||
|
||||
/*
|
||||
#define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage
|
||||
|
||||
@@ -2,17 +2,14 @@
|
||||
[portduino_base]
|
||||
platform =
|
||||
# renovate: datasource=git-refs depName=platform-native packageName=https://github.com/meshtastic/platform-native gitBranch=develop
|
||||
https://github.com/meshtastic/platform-native/archive/cab4b21d902973e43c938dab3cf4844ba02547ec.zip
|
||||
https://github.com/meshtastic/platform-native/archive/61067ac3774e4fe27aa9762c72cebea507f116c8.zip
|
||||
framework = arduino
|
||||
|
||||
build_src_filter =
|
||||
${env.build_src_filter}
|
||||
-<platform/esp32/>
|
||||
${env.build_src_filter}
|
||||
-<platform/>
|
||||
+<platform/portduino>
|
||||
-<nimble/>
|
||||
-<platform/nrf52/>
|
||||
-<platform/nrf54l15/>
|
||||
-<platform/stm32wl/>
|
||||
-<platform/rp2xx0>
|
||||
-<mesh/wifi/>
|
||||
-<mesh/http/>
|
||||
+<mesh/raspihttp/>
|
||||
|
||||
@@ -16,6 +16,3 @@
|
||||
#ifndef HAS_VARIABLE_HOPS
|
||||
#define HAS_VARIABLE_HOPS 1
|
||||
#endif
|
||||
#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE
|
||||
#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048
|
||||
#endif
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
; Heltec MeshTower V2 nrf52840/sx1262 device
|
||||
[env:heltec-mesh-tower-v2]
|
||||
custom_meshtastic_hw_model = 139
|
||||
custom_meshtastic_hw_model_slug = HELTEC_MESH_TOWER_V2
|
||||
custom_meshtastic_architecture = nrf52840
|
||||
custom_meshtastic_actively_supported = false
|
||||
custom_meshtastic_support_level = 1
|
||||
custom_meshtastic_display_name = Heltec MeshTower V2
|
||||
custom_meshtastic_images = heltec-mesh-tower-v2.svg
|
||||
custom_meshtastic_tags = Heltec
|
||||
|
||||
extends = nrf52840_base
|
||||
board = heltec_mesh_tower_v2
|
||||
board_level = pr
|
||||
debug_tool = jlink
|
||||
|
||||
# add -DCFG_SYSVIEW if you want to use the Segger systemview tool for OS profiling.
|
||||
build_flags = ${nrf52840_base.build_flags}
|
||||
-Ivariants/nrf52840/heltec_mesh_tower_v2
|
||||
-DHELTEC_MESH_TOWER_V2
|
||||
-D HAS_LORA_FEM=1
|
||||
|
||||
build_src_filter = ${nrf52_base.build_src_filter} +<../variants/nrf52840/heltec_mesh_tower_v2>
|
||||
lib_deps =
|
||||
${nrf52840_base.lib_deps}
|
||||
# renovate: datasource=custom.pio depName=ArduinoJson packageName=bblanchon/library/ArduinoJson
|
||||
bblanchon/ArduinoJson@6.21.6
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
Copyright (c) 2014-2015 Arduino LLC. All right reserved.
|
||||
Copyright (c) 2016 Sandeep Mistry All right reserved.
|
||||
Copyright (c) 2018, Adafruit Industries (adafruit.com)
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU Lesser General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#include "variant.h"
|
||||
#include "Arduino.h"
|
||||
#include "nrf.h"
|
||||
#include "wiring_constants.h"
|
||||
#include "wiring_digital.h"
|
||||
|
||||
const uint32_t g_ADigitalPinMap[] = {
|
||||
// P0 - pins 0 and 1 are hardwired for xtal and should never be enabled
|
||||
0xff, 0xff, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31,
|
||||
|
||||
// P1
|
||||
32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47};
|
||||
|
||||
void initVariant()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
void variant_shutdown()
|
||||
{
|
||||
nrf_gpio_cfg_default(PIN_GPS_EN);
|
||||
nrf_gpio_cfg_default(PIN_GPS_PPS);
|
||||
nrf_gpio_cfg_default(PIN_GPS_RESET);
|
||||
nrf_gpio_cfg_default(PIN_GPS_STANDBY);
|
||||
nrf_gpio_cfg_default(GPS_RX_PIN);
|
||||
nrf_gpio_cfg_default(GPS_TX_PIN);
|
||||
nrf_gpio_cfg_default(ADC_CTRL);
|
||||
pinMode(LORA_KCT8103L_EN, OUTPUT);
|
||||
digitalWrite(LORA_KCT8103L_EN, LOW);
|
||||
nrf_gpio_cfg_default(LORA_KCT8103L_TX_RX);
|
||||
nrf_gpio_cfg_default(RF_PA_DETECT_PIN);
|
||||
nrf_gpio_cfg_default(SX126X_CS);
|
||||
nrf_gpio_cfg_default(SX126X_DIO1);
|
||||
nrf_gpio_cfg_default(SX126X_BUSY);
|
||||
nrf_gpio_cfg_default(SX126X_RESET);
|
||||
nrf_gpio_cfg_default(PIN_SPI_MISO);
|
||||
nrf_gpio_cfg_default(PIN_SPI_MOSI);
|
||||
nrf_gpio_cfg_default(PIN_SPI_SCK);
|
||||
detachInterrupt(PIN_GPS_PPS);
|
||||
detachInterrupt(PIN_BUTTON1);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/*
|
||||
Copyright (c) 2014-2015 Arduino LLC. All right reserved.
|
||||
Copyright (c) 2016 Sandeep Mistry All right reserved.
|
||||
Copyright (c) 2018, Adafruit Industries (adafruit.com)
|
||||
|
||||
This library is free software; you can redistribute it and/or
|
||||
modify it under the terms of the GNU Lesser General Public
|
||||
License as published by the Free Software Foundation; either
|
||||
version 2.1 of the License, or (at your option) any later version.
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
|
||||
See the GNU Lesser General Public License for more details.
|
||||
You should have received a copy of the GNU Lesser General Public
|
||||
License along with this library; if not, write to the Free Software
|
||||
Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
|
||||
*/
|
||||
|
||||
#ifndef _VARIANT_HELTEC_NRF_
|
||||
#define _VARIANT_HELTEC_NRF_
|
||||
/** Master clock frequency */
|
||||
#define VARIANT_MCK (64000000ul)
|
||||
|
||||
#define USE_LFXO // Board uses 32khz crystal for LF
|
||||
|
||||
/*----------------------------------------------------------------------------
|
||||
* Headers
|
||||
*----------------------------------------------------------------------------*/
|
||||
|
||||
#include "WVariant.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif // __cplusplus
|
||||
|
||||
// Number of pins defined in PinDescription array
|
||||
#define PINS_COUNT (48)
|
||||
#define NUM_DIGITAL_PINS (48)
|
||||
#define NUM_ANALOG_INPUTS (1)
|
||||
#define NUM_ANALOG_OUTPUTS (0)
|
||||
|
||||
#define PIN_LED1 (32 + 15) // green
|
||||
#define LED_BLUE PIN_LED1 // fake for bluefruit library
|
||||
#define LED_GREEN PIN_LED1
|
||||
#define LED_STATE_ON 0 // State when LED is lit
|
||||
|
||||
/*
|
||||
* Buttons
|
||||
*/
|
||||
#define PIN_BUTTON1 (32 + 10) // P1.10, MCU_USER
|
||||
|
||||
/*
|
||||
No longer populated on PCB
|
||||
*/
|
||||
#define PIN_SERIAL2_RX (-1)
|
||||
#define PIN_SERIAL2_TX (-1)
|
||||
|
||||
/*
|
||||
* I2C
|
||||
*/
|
||||
|
||||
#define WIRE_INTERFACES_COUNT 1
|
||||
|
||||
// I2C bus 0, routed to HUSB238 USB PD sink controller.
|
||||
#define PIN_WIRE_SDA (0 + 30) // P0.30, PD_SINK_SDA
|
||||
#define PIN_WIRE_SCL (0 + 5) // P0.05, PD_SINK_SCL
|
||||
|
||||
/*
|
||||
* Lora radio
|
||||
*/
|
||||
#define USE_SX1262
|
||||
#define SX126X_CS (0 + 24)
|
||||
#define LORA_CS SX126X_CS
|
||||
#define SX126X_DIO1 (0 + 20)
|
||||
#define SX126X_BUSY (0 + 17)
|
||||
#define SX126X_RESET (0 + 25)
|
||||
#define SX126X_DIO2_AS_RF_SWITCH
|
||||
#define SX126X_DIO3_TCXO_VOLTAGE 1.8
|
||||
|
||||
#define USE_KCT8103L_PA_ONLY
|
||||
#define LORA_KCT8103L_EN (0 + 15) // CSD - KCT8103L chip enable (HIGH=on)
|
||||
#define LORA_KCT8103L_TX_RX (0 + 16) // TX or bypass control (HIGH=TX, LOW=RX)
|
||||
#define LORA_PA_POWER LORA_KCT8103L_EN
|
||||
#define RF_PA_DETECT_PIN (0 + 13) // HIGH=high-power PA, LOW=low-power
|
||||
#define RF_PA_HIGH_POWER_VALUE HIGH
|
||||
|
||||
/*
|
||||
* SPI Interfaces
|
||||
*/
|
||||
#define SPI_INTERFACES_COUNT 1
|
||||
|
||||
// For LORA, spi 0
|
||||
#define PIN_SPI_MISO (0 + 23)
|
||||
#define PIN_SPI_MOSI (0 + 22)
|
||||
#define PIN_SPI_SCK (0 + 19)
|
||||
|
||||
/*
|
||||
* GPS pins
|
||||
*/
|
||||
|
||||
#define GPS_L76K
|
||||
|
||||
#define PIN_GPS_RESET (32 + 6)
|
||||
#define GPS_RESET_MODE LOW
|
||||
#define PIN_GPS_EN (0 + 7) // P0.07, VGNSS_Ctrl
|
||||
#define GPS_EN_ACTIVE LOW
|
||||
#define PERIPHERAL_WARMUP_MS 1000 // Make sure GNSS power is stable before continuing
|
||||
#define PIN_GPS_STANDBY (32 + 2) // P1.02, WAKE_UP. Low allows sleep, high forces wake.
|
||||
#define PIN_GPS_PPS (32 + 4) // P1.04, 1PPS
|
||||
#define GPS_RX_PIN (32 + 5) // P1.05, MCU RX connected to GPS TXD.
|
||||
#define GPS_TX_PIN (32 + 7) // P1.07, MCU TX connected to GPS RXD.
|
||||
|
||||
#define GPS_THREAD_INTERVAL 50
|
||||
|
||||
#define PIN_SERIAL1_RX GPS_RX_PIN
|
||||
#define PIN_SERIAL1_TX GPS_TX_PIN
|
||||
|
||||
// Hardware watchdog
|
||||
#define HAS_HARDWARE_WATCHDOG
|
||||
#define HARDWARE_WATCHDOG_DONE (0 + 9)
|
||||
#define HARDWARE_WATCHDOG_WAKE (0 + 10)
|
||||
#define HARDWARE_WATCHDOG_TIMEOUT_MS (6 * 60 * 1000) // 6 minute watchdog
|
||||
|
||||
#define SERIAL_PRINT_PORT 0
|
||||
|
||||
#define ADC_CTRL (0 + 21) // P0.21, ADC_Ctrl
|
||||
#define ADC_CTRL_ENABLED HIGH
|
||||
#define BATTERY_PIN (0 + 4) // P0.04, ADC_IN
|
||||
#define ADC_RESOLUTION 14
|
||||
|
||||
#define BATTERY_SENSE_RESOLUTION_BITS 12
|
||||
#define BATTERY_SENSE_RESOLUTION 4096.0
|
||||
#undef AREF_VOLTAGE
|
||||
#define AREF_VOLTAGE 3.0
|
||||
#define VBAT_AR_INTERNAL AR_INTERNAL_3_0
|
||||
#define ADC_MULTIPLIER (4.916F)
|
||||
|
||||
// nRF52840 AIN2 is P0.04 on the MeshTower V2 battery divider.
|
||||
#define BATTERY_LPCOMP_INPUT NRF_LPCOMP_INPUT_2
|
||||
|
||||
// We have AIN2 with a VBAT divider so AIN2 = VBAT * (100/490)
|
||||
// We have the device going deep sleep under 3.1V, which is AIN2 = 0.63V
|
||||
// So we can wake up when VBAT>=VDD is restored to 3.3V, where AIN2 = 0.67V
|
||||
// Ratio 0.67/3.3 = 0.20, so we can pick a bit higher, 2/8 VDD, which means
|
||||
// VBAT=4.04V
|
||||
#define BATTERY_LPCOMP_THRESHOLD NRF_LPCOMP_REF_SUPPLY_2_8
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
/*----------------------------------------------------------------------------
|
||||
* Arduino objects - C++ only
|
||||
*----------------------------------------------------------------------------*/
|
||||
|
||||
#endif
|
||||
@@ -15,6 +15,7 @@ extra_scripts =
|
||||
${env.extra_scripts}
|
||||
extra_scripts/nrf52_extra.py
|
||||
pre:extra_scripts/nrf52_lto.py
|
||||
extra_scripts/nrf52_warm_region.py ; post-link guard: image must end below the 12 KB warm-store raw-flash region at 0xEA000-0xED000
|
||||
|
||||
build_type = release
|
||||
build_flags =
|
||||
@@ -43,7 +44,7 @@ build_unflags =
|
||||
-std=gnu++11
|
||||
|
||||
build_src_filter =
|
||||
${arduino_base.build_src_filter} -<platform/esp32/> -<platform/nrf54l15/> -<platform/stm32wl> -<nimble/> -<mesh/wifi/> -<mesh/api/> -<mesh/http/> -<modules/esp32> -<platform/rp2xx0> -<mesh/eth/> -<mesh/raspihttp> -<serialization/>
|
||||
${arduino_base.build_src_filter} +<platform/nrf52/> -<nimble/> -<mesh/wifi/> -<mesh/api/> -<mesh/http/> -<modules/esp32> -<mesh/eth/> -<mesh/raspihttp> -<serialization/>
|
||||
|
||||
lib_deps=
|
||||
${arduino_base.lib_deps}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
[nrf52840_base]
|
||||
extends = nrf52_base
|
||||
|
||||
; Cap the app image at 0xEA000 (below the WarmNodeStore raw-flash region).
|
||||
; Boards that have upgraded to S140 v7 override this in their own platformio.ini.
|
||||
board_build.ldscript = src/platform/nrf52/nrf52840_s140_v6.ld
|
||||
|
||||
build_flags =
|
||||
${nrf52_base.build_flags}
|
||||
-DSERIAL_BUFFER_SIZE=4096
|
||||
|
||||
@@ -32,10 +32,6 @@ build_flags =
|
||||
|
||||
build_src_filter =
|
||||
${arduino_base.build_src_filter}
|
||||
-<platform/esp32/>
|
||||
-<platform/stm32wl>
|
||||
-<platform/nrf52/>
|
||||
-<platform/rp2xx0>
|
||||
-<nimble/>
|
||||
-<mesh/wifi/>
|
||||
-<mesh/api/>
|
||||
|
||||
@@ -20,7 +20,7 @@ build_flags =
|
||||
-D__FREERTOS=1
|
||||
# -D _POSIX_THREADS
|
||||
build_src_filter =
|
||||
${arduino_base.build_src_filter} -<platform/esp32/> -<nimble/> -<modules/esp32> -<platform/nrf52/> -<platform/nrf54l15/> -<platform/stm32wl> -<mesh/eth/> -<mesh/wifi/> -<mesh/http/> -<mesh/raspihttp>
|
||||
${arduino_base.build_src_filter} +<platform/rp2xx0/> -<nimble/> -<modules/esp32> -<mesh/eth/> -<mesh/wifi/> -<mesh/http/> -<mesh/raspihttp>
|
||||
|
||||
lib_ignore =
|
||||
BluetoothOTA
|
||||
|
||||
@@ -4,12 +4,16 @@ board = rpipico2
|
||||
board_level = community
|
||||
upload_protocol = picotool
|
||||
|
||||
# Increase LittleFS from 0.5m to 0.75m so GZIP firmware (~614KB) fits for OTA staging
|
||||
board_build.filesystem_size = 0.75m
|
||||
|
||||
build_flags =
|
||||
${rp2350_base.build_flags}
|
||||
-ULED_BUILTIN # avoid "LED_BUILTIN redefined" warnings from framework common.h
|
||||
-I variants/rp2350/diy/pico2_w5500_e22
|
||||
-D HW_SPI1_DEVICE
|
||||
-D EBYTE_E22_900M30S # selects the EBYTE E22-900M30S module config, including TCXO voltage support and TX gain / max power settings
|
||||
-D HAS_ETHERNET_OTA # enable Ethernet OTA firmware update server on port 4243
|
||||
|
||||
# Re-enable Ethernet and API source paths excluded in rp2350_base
|
||||
build_src_filter = ${rp2350_base.build_src_filter} +<mesh/eth/> +<mesh/api/> +<mqtt/>
|
||||
|
||||
@@ -17,7 +17,7 @@ build_flags =
|
||||
-D__PLAT_RP2350__
|
||||
-D__FREERTOS=1
|
||||
build_src_filter =
|
||||
${arduino_base.build_src_filter} -<platform/esp32/> -<nimble/> -<modules/esp32> -<platform/nrf52/> -<platform/nrf54l15/> -<platform/stm32wl> -<mesh/eth/> -<mesh/wifi/> -<mesh/http/> -<mesh/raspihttp> -<platform/rp2xx0/pico_sleep> -<platform/rp2xx0/hardware_rosc>
|
||||
${arduino_base.build_src_filter} +<platform/rp2xx0/> -<nimble/> -<modules/esp32> -<mesh/eth/> -<mesh/wifi/> -<mesh/http/> -<mesh/raspihttp> -<platform/rp2xx0/pico_sleep> -<platform/rp2xx0/hardware_rosc>
|
||||
|
||||
lib_ignore =
|
||||
BluetoothOTA
|
||||
|
||||
@@ -45,7 +45,7 @@ build_flags =
|
||||
-Wl,--wrap=_tzset_unlocked_r
|
||||
|
||||
build_src_filter =
|
||||
${arduino_base.build_src_filter} -<platform/esp32/> -<nimble/> -<mesh/api/> -<mesh/wifi/> -<mesh/http/> -<modules/esp32> -<mesh/eth/> -<input> -<buzz> -<modules/RemoteHardwareModule.cpp> -<platform/nrf52> -<platform/nrf54l15/> -<platform/portduino> -<platform/rp2xx0> -<mesh/raspihttp>
|
||||
${arduino_base.build_src_filter} +<platform/stm32wl/> -<nimble/> -<mesh/api/> -<mesh/wifi/> -<mesh/http/> -<modules/esp32> -<mesh/eth/> -<input> -<buzz> -<modules/RemoteHardwareModule.cpp> -<mesh/raspihttp>
|
||||
|
||||
board_upload.offset_address = 0x08000000
|
||||
upload_protocol = stlink
|
||||
|
||||
Reference in New Issue
Block a user