Merge branch 'develop' into t-beam-bpf

This commit is contained in:
Austin
2026-06-22 09:29:54 -04:00
committed by GitHub
co-authored by GitHub
85 changed files with 7068 additions and 1300 deletions
+18 -1
View File
@@ -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)
+252
View File
@@ -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()
+54
View File
@@ -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.
+456
View File
@@ -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 50100. 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, M1M4, 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 515), 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 | 12 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 ABC 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.
+70
View File
@@ -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
View File
@@ -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]
+7
View File
@@ -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
View File
@@ -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;
}
+1
View File
@@ -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
View File
@@ -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 {
+13 -4
View File
@@ -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;
}
+18
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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
+29
View File
@@ -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};
+2 -1
View File
@@ -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
+5
View File
@@ -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;
+5
View File
@@ -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
View File
@@ -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{};
}
+57
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+113 -9
View File
@@ -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)
{
+3
View File
@@ -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)
+5 -1
View File
@@ -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
View File
@@ -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:
+1
View File
@@ -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
+9 -1
View File
@@ -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) {
+6
View File
@@ -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);
+56
View File
@@ -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.
*/
+4
View File
@@ -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
View File
@@ -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;
+617
View File
@@ -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
+176
View File
@@ -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
+11
View File
@@ -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
}
+293
View File
@@ -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
+22
View File
@@ -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
+1 -1
View File
@@ -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)
+106 -1
View File
@@ -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
+78 -21
View File
@@ -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
View File
@@ -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;
+1 -2
View File
@@ -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
+53 -1
View File
@@ -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");
+14
View File
@@ -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;
+12
View File
@@ -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
+137 -261
View File
@@ -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;
+97 -10
View File
@@ -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
+5 -2
View File
@@ -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();
}
+2
View File
@@ -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
+46
View File
@@ -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"
+6 -1
View File
@@ -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)
+15
View File
@@ -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);
+465
View File
@@ -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() {}
+227
View File
@@ -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
+81
View File
@@ -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() {}
}
+83
View File
@@ -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
+295
View File
@@ -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
+1
View File
@@ -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 -3
View File
@@ -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
+1 -1
View File
@@ -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
-3
View File
@@ -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
-3
View File
@@ -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
-3
View File
@@ -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
+4 -7
View File
@@ -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/>
-3
View File
@@ -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
+2 -1
View File
@@ -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}
+4
View File
@@ -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
-4
View File
@@ -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/>
+1 -1
View File
@@ -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/>
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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