diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0e2f179ca..404eb021c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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) diff --git a/bin/eth-ota-upload.py b/bin/eth-ota-upload.py new file mode 100644 index 000000000..68bc1e2a7 --- /dev/null +++ b/bin/eth-ota-upload.py @@ -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() diff --git a/boards/heltec_mesh_tower_v2.json b/boards/heltec_mesh_tower_v2.json new file mode 100644 index 000000000..42512262f --- /dev/null +++ b/boards/heltec_mesh_tower_v2.json @@ -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" +} diff --git a/docs/lora_region_preset_compatibility_client_spec.md b/docs/lora_region_preset_compatibility_client_spec.md new file mode 100644 index 000000000..b62fe7757 --- /dev/null +++ b/docs/lora_region_preset_compatibility_client_spec.md @@ -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`: + +```text +struct RegionPresetInfo { Set presets; ModemPreset default; bool licensedOnly } + +fun decode(map: LoRaRegionPresetMap): Map { + 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. diff --git a/docs/nexthop-routing-reliability.md b/docs/nexthop-routing-reliability.md new file mode 100644 index 000000000..114bd5687 --- /dev/null +++ b/docs/nexthop-routing-reliability.md @@ -0,0 +1,456 @@ +# NextHop direct-message reliability on dense meshes — findings & plan + +**Status:** Implemented — mitigations and tests in `PR3-tmm-nexthop` +**Date:** 2026-06-13 +**Area:** `src/mesh` router stack (`NextHopRouter`, `ReliableRouter`, `FloodingRouter`, `Router`, `NodeDB`, `PacketHistory`) +**Constraint:** No over-the-air / wire-format changes — `next_hop` and `relay_node` stay 1 byte, no `PacketHeader` changes, no breaking protobuf changes. All new state is RAM-only. + +This document captures the analysis and the proposed mitigations so the work can be +continued on this branch by anyone. It is intentionally code-grounded (file:line +references throughout) and standalone — you should not need the original investigation +context to pick it up. + +--- + +## TL;DR + +NextHop routing for direct messages (DMs) is unreliable on dense meshes. The headline +cause is the **birthday problem**: `next_hop` and `relay_node` are each a single byte +(the last byte of a 32-bit node number), so on a mesh of N nodes the probability that +two share the same byte hits ~50% at **~19 nodes** and is near-certain by 50–100. But +there are **other, equally important issues**: that single byte is trusted blindly at +five different code sites, learned routes **never decay**, routes are learned from the +**reverse (ACK) path** (asymmetric-link hazard), and collision-driven spurious +rebroadcasts **amplify congestion** exactly when the mesh is busy. + +Because we can't widen the on-wire field, the fix is **interpretation-side** ("don't +trust a byte that doesn't map to a unique reachable neighbor — flood instead") plus +**recovery-side** ("decay stale/failing routes so they get re-discovered"). Four +mitigations, M1–M4, all RAM-only. The net behavioral change: on dense/mobile meshes a +DM that today silently misroutes or black-holes instead falls back to managed flooding +(which still delivers) and re-learns a fresh route quickly. Sparse-mesh happy paths are +unchanged. + +--- + +## How NextHop routing works today (mechanics) + +Inheritance chain: `Router` → `FloodingRouter` → `NextHopRouter` → `ReliableRouter`. + +**The single-byte identifiers.** Both routing bytes come from one helper: + +```cpp +// src/mesh/NodeDB.h:255 +uint8_t getLastByteOfNodeNum(NodeNum num) { return (uint8_t)((num & 0xFF) ? (num & 0xFF) : 0xFF); } +``` + +It projects a 32-bit node number onto 255 values (`0x00` is remapped to `0xFF` so it +never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELAY_NODE`, +`src/mesh/MeshTypes.h:44-46`). `next_hop` and `relay_node` in the packet header are +`uint8_t` (`src/mesh/mesh.pb.h`, comments "Last byte of the node number…"). The learned +route stored per destination, `meshtastic_NodeInfoLite::next_hop`, is also a single byte +(`src/mesh/generated/meshtastic/deviceonly.pb.h:83`). + +**Sending a DM** — `NextHopRouter::send` (`src/mesh/NextHopRouter.cpp:23`): + +1. `p->relay_node = getLastByteOfNodeNum(getNodeNum())` (mark ourselves as relayer). +2. `p->next_hop = getNextHop(p->to, p->relay_node)` (`src/mesh/NextHopRouter.cpp:192`): + look up `nodeDB->getMeshNode(to)->next_hop`; return it unless it equals the relayer + byte; otherwise `NO_NEXT_HOP_PREFERENCE` (→ flood). + +**Relaying** — `NextHopRouter::perhapsRebroadcast` (`src/mesh/NextHopRouter.cpp:133`): +rebroadcast iff `next_hop == NO_NEXT_HOP_PREFERENCE` (flood) **or** +`next_hop == getLastByteOfNodeNum(getNodeNum())` (we are the addressed next hop) +(`:147`). Each node only ever compares against **its own** byte. + +**Learning** — `NextHopRouter::sniffReceived` (`src/mesh/NextHopRouter.cpp:89`): on an +ACK/reply (`request_id`/`reply_id` set), if the relayer of the ACK was also a relayer of +the original packet (validated via `PacketHistory::checkRelayers`), set +`origTx->next_hop = p->relay_node` (`:114`). I.e. the **forward** next-hop is learned +from the **reverse** path's relayer. + +**Retransmission / fallback** — `NextHopRouter::doRetransmissions` +(`src/mesh/NextHopRouter.cpp:284`). Budgets: `NUM_RELIABLE_RETX=3` (originator: initial + +- 2 retries), `NUM_INTERMEDIATE_RETX=2` (relayer: 1 retry). On the **last** retry + (`numRetransmissions==1`) it resets `next_hop` to `NO_NEXT_HOP_PREFERENCE` on the packet + **and** clears `sentTo->next_hop` in NodeDB, then floods (`:313-321`). Retransmit timing + comes from `iface->getRetransmissionMsec`, whose contention window **grows with channel + utilization** (`src/mesh/RadioInterface.cpp` `getTxDelayMsec`/`getTxDelayMsecWeighted`). + +**Dedup / relayer history** — `PacketHistory` (`src/mesh/PacketHistory.cpp`): a bounded +ring (`PACKETHISTORY_MAX = max(MAX_NUM_NODES*2, 100)`, 20 B/record) keyed by +`(sender,id)`, tracking up to `NUM_RELAYERS=6` relayer **bytes** per packet in +`relayed_by[]`. `wasRelayer` (`:490`) and `checkRelayers` (`:517`) match bytes against +that array. + +--- + +## Root-cause analysis + +### 1. The single byte is trusted blindly at five sites (the birthday problem) + +| # | Site | File:line | Failure on collision | +| --- | -------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| 1 | Rebroadcast self-check | `NextHopRouter.cpp:147` | A remote "impostor" node sharing the intended next-hop's byte also rebroadcasts → wasted airtime / congestion. | +| 2 | Route learning | `NextHopRouter.cpp:111-114` | Stores an ambiguous byte as the route; later resolves to the wrong physical node. | +| 3 | Relayer validation | `PacketHistory.cpp:490-538` | `wasRelayer(byte)` returns true for the wrong node → mis-validated ACK / mis-learn. | +| 4 | Favorite-router hop preservation | `Router.cpp:120-145` | **First** NodeDB node whose last byte matches wins — non-deterministic; can preserve hops for the wrong relay (hop leak). | +| 5 | Send-path lookup | `NextHopRouter.cpp:192-207` | Emits a byte that may address the wrong node; no check it still maps to a reachable neighbor. | + +Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19 nodes, + +> 99% by ~75 nodes. Dense meshes are squarely in the "always colliding" regime. + +### 2. Stale routes never decay + +The learned `next_hop` byte is cleared only on the **current DM's** last retry +(`NextHopRouter.cpp:313-321`). A route learned hours ago that has since gone dead is +still trusted on the **next** DM's first attempt — which on a congested mesh is also the +slowest attempt. Result: silent black-hole at a dead hop until the retransmission budget +drains, then a late flood. Intermediate nodes hold stale routes indefinitely. + +### 3. Reverse-path (asymmetric-link) learning + +`origTx->next_hop` is learned from the ACK's relayer (`NextHopRouter.cpp:110-114`) — the +**reverse** direction. RF links are frequently asymmetric, so the best reverse relay can +be a poor forward relay. Worse, the next reverse ACK immediately re-learns the same bad +hop, so the route **flaps** back to the bad value even after a failure reset. + +### 4. Congestion amplification + +Collision-driven impostor rebroadcasts (issue 1) add airtime; the contention window +grows with channel utilization, so retransmit intervals **lengthen** exactly when the +mesh is busy. The 3-try reliable budget can then expire before delivery. On dense +meshes, efficiency _is_ reliability. + +### Note: pubkey-derived node numbers (develop / 2.8) — does not change the plan + +develop derives the node number from the public key: +`my_node_num = crc32Buffer(public_key)` (`src/mesh/NodeDB.cpp:481`), re-derived on key +change in `createNewIdentity()` (`src/mesh/NodeDB.cpp:3113`). This **reinforces** the +plan rather than changing it: + +- **Birthday problem unchanged and now textbook-exact.** CRC32 mixes well → the last + byte is uniformly distributed over 256 values. Derivation adds no wire bits. +- **Node numbers are now immutable / identity-bound.** Pre-2.8 `pickNewNodeNum()` could + renumber a node to dodge a conflict; now the number is fixed by the key, so a last-byte + collision **cannot be resolved operationally by renumbering** → M1/M2/M3 become _more_ + necessary. +- **Resolver gets cleaner inputs.** Stable node numbers keep a learned byte bound to one + identity (good for M3 freshness). `createNewIdentity()` retires the old entry by marking + it **ignored** and clearing its pubkey (`src/mesh/NodeDB.cpp:3123-3125`), which M1's + candidate gate already skips — so key rotation can't pollute resolution. +- **No wire-free disambiguation unlocked.** A receiver still gets only 1 byte and cannot + recover which full node number a colliding value meant — so "detect ambiguity → flood" + remains the correct strategy. + +--- + +## Proposed mitigations + +Key insight for all of M1/M2: **a 1-byte ID only needs to be unique among a node's +direct neighbors / plausible relays, not the whole mesh.** That candidate set is small +(typically 5–15), so a byte usually resolves unambiguously there; when it doesn't, fall +back to the _safe_ behavior (flood / decrement / don't-learn). + +### M1 — Ambiguity-aware last-byte resolution (new NodeDB primitive) + +New types + methods in `src/mesh/NodeDB.h` (near line 255) / `src/mesh/NodeDB.cpp` +(near `getMeshNode`, ~2936): + +```cpp +enum class LastByteResolution : uint8_t { None, Unique, Ambiguous }; +struct ResolvedNode { LastByteResolution status = LastByteResolution::None; NodeNum num = 0; }; + +// Resolve a single on-wire last-byte to a unique full NodeNum among relevant candidates. +ResolvedNode resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor); +// Convenience: true iff exactly one relevant candidate (Ambiguous and None both -> false = SAFE). +bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum = nullptr); +``` + +- **One linear pass** over `meshNodes`, reusing `getNumMeshNodes()`/`getMeshNodeByIndex()`, + the bitfield helpers (`nodeInfoLiteIsFavorite/HasUser/IsIgnored`), `sinceLastSeen()`, + and `getLastByteOfNodeNum()`. **Early-exit** on the 2nd match (return `Ambiguous`). +- **Guard:** `if (lastByte == 0) return {None, 0};` (covers `NO_RELAY_NODE` / MQTT-invalid). +- **Candidate gate** (skip): `num == getNodeNum()` (never resolve to ourselves), `num == 0`, + `num == NODENUM_BROADCAST`, `nodeInfoLiteIsIgnored`. Then match + `getLastByteOfNodeNum(node->num) == lastByte` (cheapest test last, mirroring `Router.cpp:119`). +- **Relevance gate:** + - `requireDirectNeighbor == true` (strict, for SEND): `has_hops_away && hops_away == 0` + **and** `sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS`. + - `requireDirectNeighbor == false` (lenient, for learn / hop-preserve): accept if direct + neighbor **or** `nodeInfoLiteIsFavorite` **or** role ∈ {ROUTER, ROUTER_LATE, CLIENT_BASE}. +- **No tie-break.** A collision must return `Ambiguous` — picking "best SNR" would + resurrect the silent-misroute bug. (Deliberate non-goal; document in code.) + +New constant in `src/mesh/MeshTypes.h` (near line 44): +`#define NEXTHOP_NEIGHBOR_FRESH_SECS (60 * 60 * 2)` (mirrors `NUM_ONLINE_SECS`). + +### M2 — Only route on bytes that resolve to a unique, reachable neighbor + +In `getNextHop` (`src/mesh/NextHopRouter.cpp:192-207`), after the existing split-horizon +check (`node->next_hop != relay_node`), require the stored byte to resolve to a **unique, +currently-fresh direct neighbor**; else flood: + +```cpp +if (node->next_hop != relay_node) { + ResolvedNode r = nodeDB->resolveLastByte(node->next_hop, /*requireDirectNeighbor=*/true); + if (r.status == LastByteResolution::Unique) return node->next_hop; + LOG_WARN("Next hop 0x%x for 0x%x %s -> flood", node->next_hop, to, + r.status == LastByteResolution::Ambiguous ? "ambiguous among neighbors" : "no longer a neighbor"); + return std::nullopt; +} +``` + +This self-heals when a neighbor goes away (unicast-into-a-void becomes a flood). It +applies to originating, relaying, and retrying, since all route through `getNextHop`. + +Apply M1's safe fallback at the other sites: + +- **Learning** (`NextHopRouter.cpp:111-114`): gate `origTx->next_hop = p->relay_node` on + `resolveUniqueLastByte(p->relay_node, /*direct=*/false)`. Ambiguous/unknown → don't + learn (leave route unset → flood). +- **Favorite-router preservation** (`Router.cpp:120-145`): replace the "first match wins" + loop with `resolveUniqueLastByte(p->relay_node, /*direct=*/false)` + a re-check that the + resolved node is favorite/has_user/router. Ambiguous/none/not-favorite → **decrement** + (safe). Net: removes one full DB scan, adds one resolver scan (wash). + +**Left unchanged, by design (document why in code):** + +- **Site 1** rebroadcast self-check (`NextHopRouter.cpp:147`) and self-identity checks + (`ReliableRouter.cpp:127`): a node matches its **own** byte — no DB resolution helps. A + remote impostor sharing the intended next-hop's byte will still rebroadcast. M1/M2 + shrink the blast radius by reducing how often an ambiguous byte is ever stored or + originated; a true fix needs a wider field (out of scope). **This is the one residual + the plan cannot fully close.** +- **Site 3** `wasRelayer`/`checkRelayers` (`PacketHistory.cpp:490-538`): intentionally + byte-domain (both sides are on-wire bytes); the consumer (learning) is now hardened. + Add a one-line comment; do not change. + +### M3 — Route freshness / failure memory (RAM table on NextHopRouter) + +A bounded, LRU-evicted table keyed by destination, mirroring `PacketHistory`'s +reuse-oldest discipline (not an unbounded map) to cap RAM. + +`src/mesh/NextHopRouter.h` (near `pending`, line 99): + +```cpp +struct RouteHealth { + NodeNum dest = 0; // 0 == empty slot + uint32_t learnedAtMsec = 0; // millis() at last (re)learn; rollover-aware + uint8_t consecutiveFailures = 0; + uint8_t lastNextHop = NO_NEXT_HOP_PREFERENCE; // byte this health refers to +}; +static constexpr uint8_t ROUTE_HEALTH_MAX = 32; // ~384B; drop to 16 if RAM-tight +RouteHealth routeHealth[ROUTE_HEALTH_MAX] = {}; +// Helpers take `now` (pure/testable): findRouteHealth, getOrAllocRouteHealth, +// noteRouteLearned, noteRouteSuccess, noteRouteFailure, isRouteStale, clearRouteHealth +``` + +Policy: + +| Constant | Value | Rationale | +| ------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `ROUTE_TTL_MSEC` | 30 min | Survives a normal conversation; re-discovers a moved node within a telemetry interval. | +| `ROUTE_FAILURE_THRESHOLD` | 3 | 1–2 consecutive failures are transient LoRa collisions; 3 to the same hop = dead. Accumulates **across** DMs (independent of the per-DM 3-try budget). | + +`isRouteStale(h, now)` = `(now - h.learnedAtMsec) >= ROUTE_TTL_MSEC || h.consecutiveFailures >= ROUTE_FAILURE_THRESHOLD`. +All age math uses **unsigned subtraction** (rollover-safe, matching +`PacketHistory.cpp:364`); treat `learnedAtMsec == 0` as "set now". + +Wiring (as built — `src/mesh/NextHopRouter.cpp`, `src/mesh/ReliableRouter.cpp`): + +- `getNextHop`: if a health record matches the stored byte and `isRouteStale`, clear + `node->next_hop` (NodeDB) **and** `clearRouteHealth`, return `nullopt` (flood). No + record yet (cold path, first DM after boot) → trust NodeDB, but the M2 strict-neighbor + gate still applies. +- `sniffReceived` learn: gate the write through `resolveUniqueLastByte` (M2), then + `noteRouteLearned(p->from, p->relay_node, millis())` — resets `consecutiveFailures` + **only if the hop changed** (anti-flap for asymmetric re-learn); otherwise just refreshes + `learnedAtMsec`. (No success signal is taken on the intermediate reverse-pass: an ACK + merely passing through us is not proof that _we_ delivered, and resetting failures there + would reintroduce the asymmetric flap.) +- `doRetransmissions`: on the last-retransmission branch (`numRetransmissions == 1`, the + point a directed delivery has gone un-ACKed for both originator and intermediate) → + `noteRouteFailure(to)`, then the existing NodeDB `next_hop` reset + flood. We deliberately + do **not** `clearRouteHealth` here: keeping the record is what lets the failure count + accumulate across DMs so a flapping reverse-path-relearned dead hop eventually ages out. +- `ReliableRouter::sniffReceived` ACK path → `noteRouteSuccess(getFrom(p), millis())` + (an end-to-end ACK addressed to us is genuine forward-delivery proof; clears failures and + refreshes freshness). `noteRouteSuccess`/`noteRouteFailure` are no-ops when no record + exists, so flood-only destinations never pollute the table. + +**Reconciliation (no double-handling):** `doRetransmissions` owns _in-flight_ failure of +the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure counter); +`getNextHop` owns _between-DM_ staleness (TTL or failure-threshold → flood + clear). The +only place that erases a health record is the `getNextHop` decay path; the retransmission +path leaves it intact so the counter survives a reverse-path re-learn. + +### M4 — Earlier flood for unverified routes (gated, off by default) + +Compile-gated so healthy sparse meshes are untouched. **Default is off** — the define +lives in `NextHopRouter.h` and must be flipped to measure: +`#define NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED 1`. + +In `doRetransmissions`, the directed-retry `else` branch: if the route is **not verified** +(`!findRouteHealth(to) || consecutiveFailures > 0 || isRouteStale`), reset `next_hop` and +flood on this attempt instead of spending another directed try. A **verified** route +(record present, `consecutiveFailures == 0`, within TTL — i.e. recently ACKed) takes the +unchanged directed-retry path, so the sparse-mesh happy path is untouched. Trade-off: +airtime ↔ latency; the gate ensures we never pay the flood cost on a proven route, only on +one we already distrust. Off by default precisely so it can be A/B-measured on the +simulator before broad enable. + +--- + +## Files to modify + +| File | Change | +| ------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src/mesh/MeshTypes.h` | `NEXTHOP_NEIGHBOR_FRESH_SECS`, `ROUTE_TTL_MSEC`, `ROUTE_FAILURE_THRESHOLD`, `NEXTHOP_EARLY_FLOOD_ON_UNVERIFIED` | +| `src/mesh/NodeDB.h` / `src/mesh/NodeDB.cpp` | `LastByteResolution`, `ResolvedNode`, `resolveLastByte`, `resolveUniqueLastByte` | +| `src/mesh/NextHopRouter.h` | `RouteHealth` + array + helpers; `#ifdef PIO_UNIT_TESTING public:` for helpers and `getNextHop` | +| `src/mesh/NextHopRouter.cpp` | `getNextHop` (M2 gate + M3 decay); `sniffReceived` (learn gate + health seed + success); `doRetransmissions` (failure counting + M4); comment site 1 | +| `src/mesh/Router.cpp` | `shouldDecrementHopLimit` → resolver + favorite/router re-check | +| `src/mesh/ReliableRouter.cpp` | ACK path → `noteRouteSuccess` | +| `test/test_nexthop_routing/test_main.cpp` | **new** unit suite (auto-built under `[env:native]`) | + +**Reuse, don't reinvent:** `getLastByteOfNodeNum`, `sinceLastSeen`, the bitfield helpers, +`getMeshNodeByIndex`/`getNumMeshNodes`, PacketHistory's reuse-oldest eviction shape, and +`MockNodeDB::addTestNode` (from `test/test_hop_scaling/test_main.cpp`). + +--- + +## Edge cases + +- **`0x00`↔`0xFF` projection:** the resolver compares via `getLastByteOfNodeNum` on both + sides, so a `…00` node and a `…FF` node correctly collide on `0xFF` → `Ambiguous`. Test + explicitly. +- **MQTT packets:** `relay_node`/`next_hop` are forced invalid when `hop_start == 0` + (`src/mesh/RadioLibInterface.cpp:603-605`) → byte 0 → resolver `None` → don't learn + (correct). +- **`has_hops_away == false`** nodes are excluded from the strict gate (never fabricate a + Unique neighbor for M2); admitted to the lenient gate only via favorite/router role. + Safe; self-corrects once `hops_away` is learned. +- **Self / broadcast:** the resolver skips `getNodeNum()` and `NODENUM_BROADCAST`; + `getNextHop` already early-returns for broadcast. +- **Perf:** M2 adds one O(N) resolver scan per directed send/relay (early-exit on the 2nd + match), cheaper than the crypto already on that path; site-4 is a wash. If ever hot, a + future 256-entry last-byte index is the optimization (not now — RAM). + +--- + +## Verification (all tiers) + +### 1. Native unit tests — new `test/test_nexthop_routing/test_main.cpp` + +`pio test -e native -f test_nexthop_routing`; on macOS `./bin/test-native-docker.sh -f test_nexthop_routing`. +Design the RouteHealth helpers to take `now` as a parameter so the 30-min TTL logic is +testable without a clock mock. + +- **Resolver:** None / Unique / **Ambiguous (birthday collision)** / strict-excludes-stale / + strict-excludes-far / lenient-includes-favorite-router / lenient-collision / skips-self / + skips-ignored / **`0x00`↔`0xFF` collision** / early-exit. +- **`getNextHop`:** unique→byte, **ambiguous→nullopt**, stale-neighbor→nullopt, + split-horizon (relay==next_hop)→nullopt, broadcast→nullopt. +- **RouteHealth:** TTL boundary, **rollover** (learn near `0xFFFFFFFF`, check after wrap), + failure threshold, success-resets, **re-learn-same-hop keeps fails (anti-flap)**, + re-learn-new-hop resets, LRU eviction bound, clear. +- **Site-4:** preserve on unique favorite router; **decrement on two colliding favorites**; + decrement when the resolved node is not a favorite. +- **Sparse-mesh regression:** all-distinct last bytes → every resolve Unique, `getNextHop` + returns the stored byte unchanged (proves no happy-path change). +- Re-run `test_packet_history` and `test_hop_scaling` for no regression. + +### 2. portduino SimRadio simulator + +`pio run -e native && ./bin/test-simulator.sh`. Best vehicle for the **intermediate-node** +path the 2-device bench can't reach. Line topology A — B — C: establish A→C (B learns a +directed route), stop B relaying that dest, confirm A re-discovers via flood within +`ROUTE_FAILURE_THRESHOLD` and that B's `noteRouteFailure`/`clearRouteHealth` fires (visible +via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this to A/B M4 +(attempts-to-delivery, total airtime). + +### 3. Hardware via meshtastic MCP (auto-detect; 3+ devices for a real hop) + +- `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py` — **the multi-hop validator + for this work** (added on this branch). Self-discovers an A — relay — C line, asserts a + directed DM is delivered across the relay (next_hop + M1/M2/M3 engaged), and asserts + delivery recovers after the relay is power-cycled (M3). Skips unless the bench is a true + multi-hop line (≥3 roles via `--hub-profile`, endpoints out of direct RF range). +- `mcp-server/tests/mesh/test_direct_with_ack.py` — happy-path regression: a fresh/unique + route still delivers a want_ack DM on the first/second try (M4's gate must keep this + green). +- `mcp-server/tests/mesh/test_peer_offline_recovery.py` — 2-device recovery validator: peer + off mid-conversation then back. Must stay green and ideally recover in fewer attempts. + +### 4. Build / format sanity + +native-macos **and** Docker both ways; trunk clang-format@16.0.3; a release `pio run` to +confirm the `#ifdef PIO_UNIT_TESTING` visibility widening does **not** leak into +production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B table. + +--- + +## Verification status (as built on `nexthop-redux`) + +| Tier | What ran | Result | +| -------------------------------- | ----------------------------------------------------------------------------------- | ------------------- | +| Unit (native-macos) | `test_nexthop_routing` (31 cases) | ✅ 31/31 | +| Unit (Docker / Linux, CI parity) | `test_nexthop_routing` | ✅ 31/31 | +| Regression | `test_packet_history`, `test_hop_scaling`, `test_mqtt`, `test_traffic_management` | ✅ 105/105 | +| Build | `pio run -e native-macos` (M4 off) and with `-DNEXTHOP_EARLY_FLOOD_ON_UNVERIFIED=1` | ✅ both link | +| Format | trunk `clang-format@16.0.3` | ✅ no issues | +| Simulator (CI `simulator-tests`) | `meshtasticd -s` + `meshtastic.test.testSimulator()` on native-macos | ✅ exit 0, no crash | + +**Pending (environment-blocked, not yet run):** + +- **Multi-hop A–B–C recovery sim** — the `simulator/` broker hub is **not git-tracked** + (only stale local `.pyc`), and two `meshtasticd -s` instances can't hear each other + without it. The intermediate-node failure-count path and the M4 A/B therefore have unit + coverage of their logic but no end-to-end multi-node run yet. +- **Hardware / multi-hop tier** — a committable bench test now exists: + `mcp-server/tests/mesh/test_nexthop_multihop_recovery.py`. It self-discovers a real + multi-hop pair (A — relay — C), asserts a directed DM is delivered across the relay, and + asserts delivery recovers after the relay is power-cycled (the M3 path). It + `pytest.skip`s cleanly unless the bench is a true line with endpoints out of direct RF + range (≥3 roles via `--hub-profile`), so it's safe to commit and only asserts when the + NextHop path is genuinely exercised. Collected + verified to skip without hardware; + not yet run on a bench. `test_direct_with_ack.py` / `test_peer_offline_recovery.py` + remain the 2-device happy-path/recovery regressions. + +--- + +## Risks & limitations + +- **Site-1 impostor rebroadcast** is unfixable without a wider field — documented; M1/M2 + only shrink its frequency. +- **Dense meshes flood DMs more often** — intended (a flooded DM arrives; a mis-unicast one + black-holes). Call out in the PR so reviewers expect a slightly higher DM flood rate on + very dense meshes. +- **M4 airtime** if the gate is too loose → default conservative + compile-gated + + simulator A/B before broad enable. +- **RAM** ~384 B (32 slots); 16 slots (~192 B) with graceful LRU degradation if tight. +- **Asymmetric flap** not fully closed (a _new_ bad hop resets the counter); the TTL + backstop bounds it. Per-hop failure history is future work (more RAM). + +--- + +## How to continue this work (commit sequencing) + +Each step is independently testable; land them as separate commits. + +1. **M1 resolver + unit tests** — `NodeDB` only; no behavior change until wired. Lands the + `resolveLastByte`/`resolveUniqueLastByte` primitive and its full unit-test matrix. +2. **M2 + wiring + tests** — `getNextHop` strict gate, learning gate, favorite-router + preservation rewrite. Adds the `getNextHop` and site-4 tests. +3. **M3 health table + decay + tests** — RAM `RouteHealth` table, decay-on-read, failure/ + success accounting, reconciliation with the existing last-retry reset. Adds the + route-health unit tests and the simulator recovery check. +4. **M4 gated tuning** — early-flood-on-unverified behind the compile flag; simulator A/B + and hardware regression. + +Reference plan (with the same content) was developed at +`~/.claude/plans/nexthop-routing-for-direct-lexical-shell.md` on the author's machine; this +in-repo doc is the canonical handoff copy. diff --git a/extra_scripts/nrf52_warm_region.py b/extra_scripts/nrf52_warm_region.py new file mode 100644 index 000000000..e1a942bef --- /dev/null +++ b/extra_scripts/nrf52_warm_region.py @@ -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) diff --git a/mcp-server/tests/mesh/test_nexthop_multihop_recovery.py b/mcp-server/tests/mesh/test_nexthop_multihop_recovery.py new file mode 100644 index 000000000..a61645c91 --- /dev/null +++ b/mcp-server/tests/mesh/test_nexthop_multihop_recovery.py @@ -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" + ) diff --git a/platformio.ini b/platformio.ini index bedd42a1e..d9c685e8b 100644 --- a/platformio.ini +++ b/platformio.ini @@ -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} - - +build_src_filter = ${env.build_src_filter} - + - ; 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] diff --git a/protobufs b/protobufs index 362516671..03314e639 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 36251667179496c1e1261f4e4e5b349a51e5e861 +Subproject commit 03314e63950bda910695eaeb4ba8169dae0425ef diff --git a/src/configuration.h b/src/configuration.h index 817204da0..3833f97b3 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -179,6 +179,13 @@ along with this program. If not, see . #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 diff --git a/src/gps/GPS.cpp b/src/gps/GPS.cpp index b2fbcac8d..5078a6a27 100644 --- a/src/gps/GPS.cpp +++ b/src/gps/GPS.cpp @@ -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; } diff --git a/src/gps/GPS.h b/src/gps/GPS.h index 25de0379a..4028e351a 100644 --- a/src/gps/GPS.h +++ b/src/gps/GPS.h @@ -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 diff --git a/src/gps/RTC.cpp b/src/gps/RTC.cpp index a8288a069..78439a50a 100644 --- a/src/gps/RTC.cpp +++ b/src/gps/RTC.cpp @@ -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 { diff --git a/src/graphics/draw/MenuHandler.cpp b/src/graphics/draw/MenuHandler.cpp index 1a8be9a1f..3140de4de 100644 --- a/src/graphics/draw/MenuHandler.cpp +++ b/src/graphics/draw/MenuHandler.cpp @@ -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; } diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 457e64461..5b5875971 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -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(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 diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 9ba84e2ee..7392284f5 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -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(); diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 97f87fc7b..e802a0324 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -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 diff --git a/src/mesh/LoRaFEMInterface.cpp b/src/mesh/LoRaFEMInterface.cpp index d34ed008e..6fe6950ac 100644 --- a/src/mesh/LoRaFEMInterface.cpp +++ b/src/mesh/LoRaFEMInterface.cpp @@ -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}; diff --git a/src/mesh/LoRaFEMInterface.h b/src/mesh/LoRaFEMInterface.h index 14220c6e3..f524f51d7 100644 --- a/src/mesh/LoRaFEMInterface.h +++ b/src/mesh/LoRaFEMInterface.h @@ -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 \ No newline at end of file +#endif diff --git a/src/mesh/MeshRadio.h b/src/mesh/MeshRadio.h index 965f3c46e..3387887c2 100644 --- a/src/mesh/MeshRadio.h +++ b/src/mesh/MeshRadio.h @@ -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; diff --git a/src/mesh/MeshTypes.h b/src/mesh/MeshTypes.h index 680926d3c..58ef32150 100644 --- a/src/mesh/MeshTypes.h +++ b/src/mesh/MeshTypes.h @@ -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 diff --git a/src/mesh/NextHopRouter.cpp b/src/mesh/NextHopRouter.cpp index e8613d457..5fe079a19 100644 --- a/src/mesh/NextHopRouter.cpp +++ b/src/mesh/NextHopRouter.cpp @@ -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 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{}; +} diff --git a/src/mesh/NextHopRouter.h b/src/mesh/NextHopRouter.h index 42ef13cd9..467d9ca68 100644 --- a/src/mesh/NextHopRouter.h +++ b/src/mesh/NextHopRouter.h @@ -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 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 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; diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 89d50d033..2a7d9d3c9 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -66,6 +66,10 @@ #include #endif +#ifdef ARCH_RP2040 +#include +#endif + #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI #include #endif @@ -197,6 +201,8 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (ostream) { const auto *vec = static_cast *>(iter->pData); for (auto item : *vec) { + item.snr_q4 = (int32_t)(item.snr * 4.0f); + item.snr = 0.0f; if (!pb_encode_tag_for_field(ostream, iter)) return false; if (!pb_encode_submessage(ostream, meshtastic_NodeInfoLite_fields, &item)) @@ -206,8 +212,12 @@ bool meshtastic_NodeDatabase_callback(pb_istream_t *istream, pb_ostream_t *ostre if (istream && istream->bytes_left) { meshtastic_NodeInfoLite node = meshtastic_NodeInfoLite_init_zero; auto *vec = static_cast *>(iter->pData); - if (pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) + if (pb_decode(istream, meshtastic_NodeInfoLite_fields, &node)) { + if (node.snr_q4) + node.snr = node.snr_q4 / 4.0f; + node.snr_q4 = 0; vec->push_back(node); + } } return true; } @@ -482,9 +492,17 @@ NodeDB::NodeDB() myNodeInfo.my_node_num = crc32Buffer(config.security.public_key.bytes, config.security.public_key.size); } #endif - // Include our owner in the node db under our nodenum - meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum()); - TypeConversions::CopyUserToNodeInfoLite(info, owner); + // Identity is now established, so run the self-care pass on the store + // loadFromDisk() deliberately left untrimmed: confirm self, trim/demote only + // non-self overflow, pin self to index 0, rewrite once if healed. + nodeDBSelfCare(); + + // If we migrated from legacy during loadFromDisk(), persist the migrated DB + // only after identity and self-care are established. + if (migrationSavePending) { + saveNodeDatabaseToDisk(); + migrationSavePending = false; + } // If node database has not been saved for the first time, save it now #ifdef FSCom @@ -576,6 +594,16 @@ NodeDB::NodeDB() moduleConfig.mqtt.map_report_settings.publish_interval_secs = default_map_publish_interval_secs; } + // If a fixed position is configured, restore the persisted position into localPosition at boot. + // This keeps position broadcasts / MQTT map reports working after reboot on GPS-less nodes. + if (config.position.fixed_position) { + meshtastic_PositionLite fixedPos; + if (copyNodePosition(getNodeNum(), fixedPos) && (fixedPos.latitude_i != 0 || fixedPos.longitude_i != 0)) { + setLocalPosition(TypeConversions::ConvertToPosition(fixedPos)); + LOG_INFO("Restored fixed position to localPosition: lat=%d lon=%d", fixedPos.latitude_i, fixedPos.longitude_i); + } + } + // Ensure that the neighbor info update interval is coerced to the minimum moduleConfig.neighbor_info.update_interval = Default::getConfiguredOrMinimumValue(moduleConfig.neighbor_info.update_interval, min_neighbor_info_broadcast_secs); @@ -689,6 +717,39 @@ template std::vector snapshotSatelliteNodeNums(const Map } return result; } + +// Drop the stalest entry of `map` (staleness proxied via the owner's +// last_heard; 0 = owner evicted, i.e. an orphan — first out). Never evicts our +// own node's entry. Caller holds satelliteMutex. Returns false if nothing +// could be evicted. +template bool evictStalestSatellite(NodeDB &db, Map &map) +{ + auto victim = map.end(); + uint32_t victimTs = UINT32_MAX; + for (auto it = map.begin(); it != map.end(); ++it) { + if (it->first == db.getNodeNum()) + continue; + uint32_t ts = db.hotNodeLastHeard(it->first); + if (ts < victimTs) { + victimTs = ts; + victim = it; + } + } + if (victim == map.end()) + return false; + map.erase(victim); + return true; +} + +// Keep `map` within MAX_SATELLITE_NODES ahead of inserting `incoming` (the +// tier-1/tier-2 split: only the freshest MAX_SATELLITE_NODES nodes carry +// satellite payloads). Caller holds satelliteMutex. +template void evictSatelliteOverCap(NodeDB &db, Map &map, NodeNum incoming) +{ + if (map.size() < MAX_SATELLITE_NODES || map.count(incoming)) + return; + evictStalestSatellite(db, map); +} } // namespace void NodeDB::resetRadioConfig(bool is_fresh_install) @@ -721,6 +782,7 @@ bool NodeDB::factoryReset(bool eraseBleBonds) LOG_ERROR("Could not remove rangetest.csv file"); } #endif + spiLock->unlock(); // rmDir above nuked the .dat file, but TransmitHistory's in-memory @@ -731,6 +793,14 @@ bool NodeDB::factoryReset(bool eraseBleBonds) #if HAS_SCREEN messageStore.clearAllMessages(); #endif + +#if WARM_NODE_COUNT > 0 + // On nRF52840 the warm tier lives in raw flash outside /prefs, so rmDir + // didn't touch it; clear it and persist the empty store. + warmStore.clear(); + warmStore.saveIfDirty(); +#endif + // second, install default state (this will deal with the duplicate mac address issue) installDefaultNodeDatabase(); installDefaultDeviceState(); @@ -745,6 +815,7 @@ bool NodeDB::factoryReset(bool eraseBleBonds) // This will erase what's in NVS including ssl keys, persistent variables and ble pairing nvs_flash_erase(); #endif + #ifdef ARCH_NRF52 LOG_INFO("Clear bluetooth bonds!"); bond_print_list(BLE_GAP_ROLE_PERIPH); @@ -767,12 +838,15 @@ void NodeDB::installDefaultNodeDatabase() #if !MESHTASTIC_EXCLUDE_POSITIONDB nodePositions.clear(); #endif + #if !MESHTASTIC_EXCLUDE_TELEMETRYDB nodeTelemetry.clear(); #endif + #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB nodeEnvironment.clear(); #endif + #if !MESHTASTIC_EXCLUDE_STATUSDB nodeStatus.clear(); #endif @@ -835,34 +909,42 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #else config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; #endif + #ifdef USERPREFS_LORACONFIG_MODEM_PRESET config.lora.modem_preset = USERPREFS_LORACONFIG_MODEM_PRESET; #else config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; #endif + #ifdef USERPREFS_LORACONFIG_USE_PRESET config.lora.use_preset = USERPREFS_LORACONFIG_USE_PRESET; #else config.lora.use_preset = true; #endif + #ifdef USERPREFS_LORACONFIG_BANDWIDTH config.lora.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH; #endif + #ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR config.lora.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR; #endif + #ifdef USERPREFS_LORACONFIG_CODING_RATE config.lora.coding_rate = USERPREFS_LORACONFIG_CODING_RATE; #endif + #ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; #endif + config.lora.hop_limit = HOP_RELIABLE; #ifdef USERPREFS_CONFIG_LORA_IGNORE_MQTT config.lora.ignore_mqtt = USERPREFS_CONFIG_LORA_IGNORE_MQTT; #else config.lora.ignore_mqtt = false; #endif + // Initialize admin_key_count to zero byte numAdminKeys = 0; @@ -892,6 +974,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) numAdminKeys++; } #endif + config.security.admin_key_count = numAdminKeys; if (shouldPreserveKey) { @@ -906,6 +989,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #ifdef PIN_GPS_EN config.position.gps_en_gpio = PIN_GPS_EN; #endif + #if defined(USERPREFS_CONFIG_GPS_MODE) config.position.gps_mode = USERPREFS_CONFIG_GPS_MODE; #elif !HAS_GPS || GPS_DEFAULT_NOT_PRESENT @@ -918,11 +1002,13 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #else config.position.gps_mode = meshtastic_Config_PositionConfig_GpsMode_ENABLED; #endif + #ifdef USERPREFS_CONFIG_SMART_POSITION_ENABLED config.position.position_broadcast_smart_enabled = USERPREFS_CONFIG_SMART_POSITION_ENABLED; #else config.position.position_broadcast_smart_enabled = true; #endif + config.position.broadcast_smart_minimum_distance = 100; config.position.broadcast_smart_minimum_interval_secs = default_broadcast_smart_minimum_interval_secs; if (config.device.role != meshtastic_Config_DeviceConfig_Role_ROUTER && @@ -942,6 +1028,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) // default to bluetooth capability of platform as default config.bluetooth.enabled = true; #endif + config.bluetooth.fixed_pin = defaultBLEPin; #if defined(ST7735_CS) || defined(USE_EINK) || defined(ILI9341_DRIVER) || defined(ILI9342_DRIVER) || defined(ST7789_CS) || \ @@ -953,7 +1040,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) if (st7789_id == 0xFFFFFF) { hasScreen = false; } -#endif +#endif // HELTEC_MESH_NODE_T114 #elif ARCH_PORTDUINO bool hasScreen = false; if (portduino_config.displayPanel) @@ -973,6 +1060,7 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.bluetooth.mode = hasScreen ? meshtastic_Config_BluetoothConfig_PairingMode_RANDOM_PIN : meshtastic_Config_BluetoothConfig_PairingMode_FIXED_PIN; #endif + // for backward compat, default position flags are ALT+MSL config.position.position_flags = (meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE | meshtastic_Config_PositionConfig_PositionFlags_ALTITUDE_MSL | @@ -985,8 +1073,8 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) config.network.enabled_protocols = USERPREFS_NETWORK_ENABLED_PROTOCOLS; #else config.network.enabled_protocols = 0; -#endif -#endif +#endif // Network enabled protocols +#endif // UDP Multicast #ifdef USERPREFS_NETWORK_WIFI_ENABLED config.network.wifi_enabled = USERPREFS_NETWORK_WIFI_ENABLED; @@ -1013,16 +1101,20 @@ void NodeDB::installDefaultConfig(bool preserveKey = false) #ifdef DISPLAY_FLIP_SCREEN config.display.flip_screen = true; #endif + #ifdef RAK4630 config.display.wake_on_tap_or_motion = true; #endif + #if defined(T_WATCH_S3) || defined(SENSECAP_INDICATOR) config.display.screen_on_secs = 30; config.display.wake_on_tap_or_motion = true; #endif + #ifdef COMPASS_ORIENTATION config.display.compass_orientation = COMPASS_ORIENTATION; #endif + #if defined(ARCH_ESP32) && !MESHTASTIC_EXCLUDE_WIFI if (MeshtasticOTA::isUpdated()) { MeshtasticOTA::recoverConfig(&config.network); @@ -1046,6 +1138,7 @@ void NodeDB::initConfigIntervals() #else config.position.gps_update_interval = default_gps_update_interval; #endif + #ifdef USERPREFS_CONFIG_POSITION_BROADCAST_INTERVAL config.position.position_broadcast_secs = USERPREFS_CONFIG_POSITION_BROADCAST_INTERVAL; #else @@ -1066,6 +1159,24 @@ void NodeDB::initConfigIntervals() #endif } +// Always-on traffic management defaults. Only booleans are written; every +// numeric field stays 0 and resolves to its default_traffic_mgmt_* macro at +// use (e.g. position dedup precision/interval), so fork-wide tuning changes +// take effect without another migration. Rate limiting and the features that +// exhaust or reshape relayed traffic (exhaust_hop_*, drop_unknown_enabled, +// nodeinfo_direct_response) stay opt-in. +static void installTrafficManagementDefaults(meshtastic_LocalModuleConfig &mc) +{ + mc.has_traffic_management = true; + mc.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; +#if HAS_TRAFFIC_MANAGEMENT + // Position dedup ships enabled at the 11-hour default window on all supported targets. + // STM32WL is excluded at compile time (HAS_TRAFFIC_MANAGEMENT=0 in mesh-pb-constants.h). + // Set position_min_interval_secs=0 at runtime to disable dedup. + mc.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; +#endif +} + void NodeDB::installDefaultModuleConfig() { LOG_INFO("Install default ModuleConfig"); @@ -1082,22 +1193,26 @@ void NodeDB::installDefaultModuleConfig() defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) moduleConfig.external_notification.enabled = true; #endif + #if defined(PIN_BUZZER) moduleConfig.external_notification.output_buzzer = PIN_BUZZER; moduleConfig.external_notification.use_pwm = true; moduleConfig.external_notification.alert_message_buzzer = true; #endif + #if defined(PIN_VIBRATION) moduleConfig.external_notification.output_vibra = PIN_VIBRATION; moduleConfig.external_notification.alert_message_vibra = true; moduleConfig.external_notification.output_ms = 500; #endif + #if defined(LED_NOTIFICATION) moduleConfig.external_notification.output = LED_NOTIFICATION; moduleConfig.external_notification.active = LED_STATE_ON; moduleConfig.external_notification.alert_message = true; moduleConfig.external_notification.output_ms = 1000; #endif + #if defined(PIN_VIBRATION) moduleConfig.external_notification.nag_timeout = 2; #elif defined(PIN_BUZZER) || defined(LED_NOTIFICATION) || defined(NEOPIXEL_STATUS_NOTIFICATION_PIN) @@ -1114,14 +1229,16 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.external_notification.nag_timeout = 0; #else moduleConfig.external_notification.nag_timeout = default_ringtone_nag_secs; -#endif -#endif +#endif // HAS_TFT +#endif // HAS_I2S + #ifdef NANO_G2_ULTRA moduleConfig.external_notification.enabled = true; moduleConfig.external_notification.alert_message = true; moduleConfig.external_notification.output_ms = 100; moduleConfig.external_notification.active = true; -#endif +#endif // NANO_G2_ULTRA + #ifdef T_LORA_PAGER moduleConfig.canned_message.updown1_enabled = true; moduleConfig.canned_message.inputbroker_pin_a = ROTARY_A; @@ -1131,35 +1248,43 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.canned_message.inputbroker_event_ccw = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar(29); moduleConfig.canned_message.inputbroker_event_press = meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_SELECT; #endif + moduleConfig.has_canned_message = true; + #if USERPREFS_MQTT_ENABLED && !MESHTASTIC_EXCLUDE_MQTT moduleConfig.mqtt.enabled = true; #endif + #ifdef USERPREFS_MQTT_ADDRESS strncpy(moduleConfig.mqtt.address, USERPREFS_MQTT_ADDRESS, sizeof(moduleConfig.mqtt.address)); #else strncpy(moduleConfig.mqtt.address, default_mqtt_address, sizeof(moduleConfig.mqtt.address)); #endif + #ifdef USERPREFS_MQTT_USERNAME strncpy(moduleConfig.mqtt.username, USERPREFS_MQTT_USERNAME, sizeof(moduleConfig.mqtt.username)); #else strncpy(moduleConfig.mqtt.username, default_mqtt_username, sizeof(moduleConfig.mqtt.username)); #endif + #ifdef USERPREFS_MQTT_PASSWORD strncpy(moduleConfig.mqtt.password, USERPREFS_MQTT_PASSWORD, sizeof(moduleConfig.mqtt.password)); #else strncpy(moduleConfig.mqtt.password, default_mqtt_password, sizeof(moduleConfig.mqtt.password)); #endif + #ifdef USERPREFS_MQTT_ROOT_TOPIC strncpy(moduleConfig.mqtt.root, USERPREFS_MQTT_ROOT_TOPIC, sizeof(moduleConfig.mqtt.root)); #else strncpy(moduleConfig.mqtt.root, default_mqtt_root, sizeof(moduleConfig.mqtt.root)); #endif + #ifdef USERPREFS_MQTT_ENCRYPTION_ENABLED moduleConfig.mqtt.encryption_enabled = USERPREFS_MQTT_ENCRYPTION_ENABLED; #else moduleConfig.mqtt.encryption_enabled = default_mqtt_encryption_enabled; #endif + #ifdef USERPREFS_MQTT_TLS_ENABLED moduleConfig.mqtt.tls_enabled = USERPREFS_MQTT_TLS_ENABLED; #else @@ -1169,6 +1294,8 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.has_neighbor_info = true; moduleConfig.neighbor_info.enabled = false; + installTrafficManagementDefaults(moduleConfig); + moduleConfig.has_detection_sensor = true; moduleConfig.detection_sensor.enabled = false; moduleConfig.detection_sensor.detection_trigger_type = meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_LOGIC_HIGH; @@ -1253,11 +1380,13 @@ void NodeDB::initModuleConfigIntervals() #else moduleConfig.telemetry.device_update_interval = MAX_INTERVAL; #endif + #ifdef USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL moduleConfig.telemetry.environment_update_interval = USERPREFS_CONFIG_ENV_TELEM_UPDATE_INTERVAL; #else moduleConfig.telemetry.environment_update_interval = 0; #endif + #ifdef USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED moduleConfig.telemetry.environment_measurement_enabled = USERPREFS_CONFIG_ENVIRONMENT_MEASUREMENT_ENABLED; #endif @@ -1302,6 +1431,10 @@ void NodeDB::resetNodes(bool keepFavorites) std::fill(nodeDatabase.nodes.begin() + 1, nodeDatabase.nodes.end(), meshtastic_NodeInfoLite()); } (void)ourNum; +#if WARM_NODE_COUNT > 0 + warmStore.clear(); // warm entries are never favorites; a DB reset clears them too +#endif + devicestate.has_rx_waypoint = false; saveNodeDatabaseToDisk(); saveDeviceStateToDisk(); @@ -1323,6 +1456,11 @@ void NodeDB::removeNodeByNum(NodeNum nodeNum) meshtastic_NodeInfoLite()); if (removed) eraseNodeSatellites(nodeNum); +#if WARM_NODE_COUNT > 0 + // Explicit user removal: don't let the warm tier resurrect the node + warmStore.remove(nodeNum); +#endif + LOG_DEBUG("NodeDB::removeNodeByNum purged %d entries. Save changes", removed); saveNodeDatabaseToDisk(); } @@ -1436,6 +1574,7 @@ void NodeDB::setNodeStatus(NodeNum n, const meshtastic_StatusMessage &status) (void)status; #else concurrency::LockGuard guard(&satelliteMutex); + evictSatelliteOverCap(*this, nodeStatus, n); nodeStatus[n] = status; #endif } @@ -1447,6 +1586,7 @@ void NodeDB::touchNodePositionTime(NodeNum n, uint32_t time) (void)time; #else concurrency::LockGuard guard(&satelliteMutex); + evictSatelliteOverCap(*this, nodePositions, n); nodePositions[n].time = time; #endif } @@ -1457,23 +1597,86 @@ void NodeDB::eraseNodeSatellites(NodeNum n) #if !MESHTASTIC_EXCLUDE_POSITIONDB nodePositions.erase(n); #endif + #if !MESHTASTIC_EXCLUDE_TELEMETRYDB nodeTelemetry.erase(n); #endif + #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB nodeEnvironment.erase(n); #endif + #if !MESHTASTIC_EXCLUDE_STATUSDB nodeStatus.erase(n); #endif } +bool NodeDB::enforceSatelliteCaps() +{ + concurrency::LockGuard guard(&satelliteMutex); + bool trimmedAny = false; + auto trim = [this, &trimmedAny](auto &map, const char *name) { + const size_t before = map.size(); + while (map.size() > MAX_SATELLITE_NODES) { + if (!evictStalestSatellite(*this, map)) + break; + } + if (map.size() != before) { + trimmedAny = true; + LOG_MIGRATION("Trimmed %s satellites %u -> %u (cap %d)", name, (unsigned)before, (unsigned)map.size(), + MAX_SATELLITE_NODES); + } + }; +#if !MESHTASTIC_EXCLUDE_POSITIONDB + trim(nodePositions, "position"); +#endif + +#if !MESHTASTIC_EXCLUDE_TELEMETRYDB + trim(nodeTelemetry, "telemetry"); +#endif + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB + trim(nodeEnvironment, "environment"); +#endif + +#if !MESHTASTIC_EXCLUDE_STATUSDB + trim(nodeStatus, "status"); +#endif + + (void)trim; // all four maps may be compiled out + return trimmedAny; +} + +#if WARM_NODE_COUNT > 0 +// Classify an evicted node's hop-protected category for the warm tier. Favorite/ignored/ +// verified are local flags (rarely reach warm — they're eviction-protected — but classify +// them if they do); otherwise tracker/sensor/tak_tracker are role-protected. +static uint8_t warmProtectedCategory(const meshtastic_NodeInfoLite &n) +{ + if (n.bitfield & (NODEINFO_BITFIELD_IS_FAVORITE_MASK | NODEINFO_BITFIELD_IS_IGNORED_MASK | + NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK)) + return static_cast(WarmProtected::Flag); + if (IS_ONE_OF(n.role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_SENSOR, + meshtastic_Config_DeviceConfig_Role_TAK_TRACKER)) + return static_cast(WarmProtected::Role); + return static_cast(WarmProtected::None); +} + +// The warm tier packs the device role into a 4-bit field (WARM_ROLE_MASK). Fail the build +// loudly if a new role outgrows it, rather than silently truncating role on eviction. +static_assert(_meshtastic_Config_DeviceConfig_Role_MAX <= WARM_ROLE_MASK, + "device role no longer fits the 4-bit warm metadata field"); +#endif // WARM_NODE_COUNT > 0 + void NodeDB::cleanupMeshDB() { int newPos = 0, removed = 0; for (int i = 0; i < numMeshNodes; i++) { meshtastic_NodeInfoLite &n = meshNodes->at(i); - if (nodeInfoLiteHasUser(&n)) { + // Keep ignored (blocked) nodes even without user info: a block set by + // bare node ID has no NodeInfo and would otherwise be purged here, + // silently dropping the block. + if (nodeInfoLiteHasUser(&n) || nodeInfoLiteIsIgnored(&n)) { if (n.public_key.size > 0) { if (memfll(n.public_key.bytes, 0, n.public_key.size)) { n.public_key.size = 0; @@ -1486,8 +1689,16 @@ void NodeDB::cleanupMeshDB() } else { // No user info - drop this node and its satellites const NodeNum gone = n.num; - if (gone) + if (gone) { +#if WARM_NODE_COUNT > 0 + // Keep any key we learned (e.g. via a DM before the NodeInfo + // exchange completed) rather than losing it with the purge. + if (n.public_key.size == 32) + warmStore.absorb(gone, n.last_heard, n.public_key.bytes, n.role, warmProtectedCategory(n)); +#endif + eraseNodeSatellites(gone); + } removed++; } } @@ -1518,12 +1729,15 @@ void NodeDB::installDefaultDeviceState() #else snprintf(owner.long_name, sizeof(owner.long_name), "Meshtastic %04x", getNodeNum() & 0x0ffff); #endif + clampLongName(owner.long_name); // vendor userprefs may exceed the local cap + #ifdef USERPREFS_CONFIG_OWNER_SHORT_NAME snprintf(owner.short_name, sizeof(owner.short_name), (const char *)USERPREFS_CONFIG_OWNER_SHORT_NAME); #else snprintf(owner.short_name, sizeof(owner.short_name), "%04x", getNodeNum() & 0x0ffff); #endif + snprintf(owner.id, sizeof(owner.id), "!%08x", getNodeNum()); // Default node ID now based on nodenum memcpy(owner.macaddr, ourMacAddr, sizeof(owner.macaddr)); owner.has_is_unmessagable = true; @@ -1644,6 +1858,115 @@ LoadFileResult NodeDB::loadProto(const char *filename, size_t protoSize, size_t return state; } +#if WARM_NODE_COUNT > 0 +void NodeDB::demoteOldestHotNodesToWarm() +{ + const int keep = MAX_NUM_NODES; + if (numMeshNodes <= keep) + return; + + // Protected nodes (favorite/ignored/verified) outrank recency and are demoted + // only when the store is full of them; within a class, most-recently-heard + // wins. Index 0 is self and stays put (sort from +1), as in runtime eviction. + std::sort(meshNodes->begin() + 1, meshNodes->begin() + numMeshNodes, + [](const meshtastic_NodeInfoLite &a, const meshtastic_NodeInfoLite &b) { + const bool ka = nodeInfoLiteIsProtected(&a); + const bool kb = nodeInfoLiteIsProtected(&b); + if (ka != kb) + return ka; + return a.last_heard > b.last_heard; + }); + + int demoted = 0; + for (int i = keep; i < numMeshNodes; i++) { + const meshtastic_NodeInfoLite &n = (*meshNodes)[i]; + if (n.num == 0) + continue; + // Keep the public key if we have one (40 B warm record); keyless nodes + // still get a placeholder so re-admission restores last_heard. + warmStore.absorb(n.num, n.last_heard, n.public_key.size > 0 ? n.public_key.bytes : nullptr, n.role, + warmProtectedCategory(n)); + // Demotion drops the node from the header table, so drop its satellites + // too (the eviction chokepoint) — they'd otherwise orphan until the next + // enforceSatelliteCaps pass. + eraseNodeSatellites(n.num); + demoted++; + } + numMeshNodes = keep; // the resize() in loadFromDisk reclaims the demoted tail + LOG_MIGRATION("NodeDB migration: demoted %d node(s) over %d into the warm tier (keepers preferred)", demoted, keep); +} +#endif + +void NodeDB::nodeDBSelfCare() +{ + if (!meshNodes) + return; + + const NodeNum self = getNodeNum(); + const bool nodesOverCap = numMeshNodes > MAX_NUM_NODES; + + // Confirm self is present and its key matches what we just (re)derived. A + // non-empty DB that doesn't contain us means a foreign/over-cap or corrupt + // nodes.proto was loaded; an empty DB is just a fresh device (no warning). + meshtastic_NodeInfoLite *selfNode = getMeshNode(self); + if (!selfNode && numMeshNodes > 0) { + LOG_WARN("NodeDB self-care: self 0x%08x absent from DB, re-adding", (unsigned)self); + } else if (selfNode && owner.public_key.size == 32 && selfNode->public_key.size == 32 && + memcmp(selfNode->public_key.bytes, owner.public_key.bytes, 32) != 0) { + LOG_WARN("NodeDB self-care: self 0x%08x key mismatch, refreshing", (unsigned)self); + } + + // Maintenance that must never touch self. Pin self to index 0 first so + // the positional demote/eviction scans (which skip index 0) provably exclude + // us, wherever the loaded file happened to place our row. + if (selfNode && numMeshNodes > 0 && selfNode != &meshNodes->at(0)) { + std::swap(meshNodes->at(0), *selfNode); + } + +#if WARM_NODE_COUNT > 0 + if (nodesOverCap) + demoteOldestHotNodesToWarm(); // demotes oldest NON-self overflow; index 0 (us) left in place +#endif + + if (numMeshNodes > MAX_NUM_NODES) { + LOG_WARN("NodeDB self-care: %d over cap %d, truncating", numMeshNodes, MAX_NUM_NODES); + numMeshNodes = MAX_NUM_NODES; + } + // Normalise the backing store to the hot cap so getOrCreateMeshNode always + // has spare slots to append into (it indexes meshNodes->at(numMeshNodes++)). + meshNodes->resize(MAX_NUM_NODES); + + const bool satsTrimmed = enforceSatelliteCaps(); + + // Ensure self exists, sits at index 0, and carries current owner info — after + // any demotion has freed a slot. Covers the foreign/fixture case where the + // loaded file did not contain us at all. + meshtastic_NodeInfoLite *info = getOrCreateMeshNode(self); + if (info) { + TypeConversions::CopyUserToNodeInfoLite(info, owner); + if (info != &meshNodes->at(0)) + std::swap(meshNodes->at(0), *info); + } + + // One-shot rewrite: only when we healed something, and never while storage + // is locked — a locked boot loads placeholder defaults that must not be written + // over the encrypted store; reloadFromDisk() re-runs self-care once unlocked. +#ifdef MESHTASTIC_ENCRYPTED_STORAGE + const bool storageLocked = EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked(); +#else + const bool storageLocked = false; +#endif + + if ((nodesOverCap || satsTrimmed) && !storageLocked) { + LOG_MIGRATION("NodeDB self-care: healed store (nodes-over-cap:%s sats-trimmed:%s); rewriting nodes.proto once", + nodesOverCap ? "yes" : "no", satsTrimmed ? "yes" : "no"); + saveNodeDatabaseToDisk(); +#if WARM_NODE_COUNT > 0 + warmStore.saveIfDirty(); +#endif + } +} + void NodeDB::loadFromDisk() { // Mark the current device state as completely unusable, so that if we fail reading the entire file from @@ -1657,6 +1980,8 @@ void NodeDB::loadFromDisk() storageCorruptThisLoad = false; #endif + migrationSavePending = false; + meshtastic_Config_SecurityConfig backupSecurity = meshtastic_Config_SecurityConfig_init_zero; #ifdef ARCH_ESP32 @@ -1666,6 +1991,7 @@ void NodeDB::loadFromDisk() rmDir("/static/static"); // Remove bad static web files bundle from initial 2.5.13 release spiLock->unlock(); #endif + #ifdef FSCom #if defined(FACTORY_INSTALL) && !defined(ARCH_PORTDUINO) spiLock->lock(); @@ -1680,7 +2006,7 @@ void NodeDB::loadFromDisk() } } spiLock->unlock(); -#endif +#endif // FACTORY_INSTALL, not PORTDUINO spiLock->lock(); if (FSCom.exists(legacyPrefFileName)) { spiLock->unlock(); @@ -1698,7 +2024,8 @@ void NodeDB::loadFromDisk() spiLock->unlock(); } -#endif +#endif // FSCom + #ifdef MESHTASTIC_ENCRYPTED_STORAGE // Only take the locked-boot defaults path when lockdown is ACTIVE (the // device is provisioned) AND storage is still locked. A lockdown-capable @@ -1752,7 +2079,7 @@ void NodeDB::loadFromDisk() installDefaultNodeDatabase(); } else if (nodeDatabase.version < DEVICESTATE_CUR_VER) { if (migrateLegacyNodeDatabase()) - saveNodeDatabaseToDisk(); + migrationSavePending = true; else installDefaultNodeDatabase(); } else { @@ -1765,33 +2092,40 @@ void NodeDB::loadFromDisk() #else 0u; #endif + const unsigned telCount = #if !MESHTASTIC_EXCLUDE_TELEMETRYDB (unsigned)nodeTelemetry.size(); #else 0u; #endif + const unsigned envCount = #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB (unsigned)nodeEnvironment.size(); #else 0u; #endif + const unsigned statusCount = #if !MESHTASTIC_EXCLUDE_STATUSDB (unsigned)nodeStatus.size(); #else 0u; #endif + LOG_INFO("Loaded saved nodedatabase v%d: %d nodes, %u pos, %u tel, %u env, %u status", nodeDatabase.version, nodeDatabase.nodes.size(), posCount, telCount, envCount, statusCount); } - if (numMeshNodes > MAX_NUM_NODES) { - LOG_WARN("Node count %d exceeds MAX_NUM_NODES %d, truncating", numMeshNodes, MAX_NUM_NODES); - numMeshNodes = MAX_NUM_NODES; - } - meshNodes->resize(MAX_NUM_NODES); + // Left UNTRIMMED on purpose: trim/demote/satellite-cap/self-pin/rewrite all + // run in nodeDBSelfCare() once getNodeNum() is valid (still 0 here on a cold + // boot, so we could only assume index 0 == self — the very bug being fixed). +#if WARM_NODE_COUNT > 0 + // Load the warm tier so its on-disk snapshot is available before the node DB + // is exercised (and before nodeDBSelfCare() demotes any overflow into it). + warmStore.load(); +#endif // static DeviceState scratch; We no longer read into a tempbuf because this structure is 15KB of valuable RAM state = loadProto(deviceStateFileName, meshtastic_DeviceState_size, sizeof(meshtastic_DeviceState), @@ -1863,18 +2197,23 @@ void NodeDB::loadFromDisk() #ifdef USERPREFS_CONFIG_LORA_REGION config.lora.region = USERPREFS_CONFIG_LORA_REGION; #endif + #ifdef USERPREFS_LORACONFIG_USE_PRESET config.lora.use_preset = USERPREFS_LORACONFIG_USE_PRESET; #endif + #ifdef USERPREFS_LORACONFIG_BANDWIDTH config.lora.bandwidth = USERPREFS_LORACONFIG_BANDWIDTH; #endif + #ifdef USERPREFS_LORACONFIG_SPREAD_FACTOR config.lora.spread_factor = USERPREFS_LORACONFIG_SPREAD_FACTOR; #endif + #ifdef USERPREFS_LORACONFIG_CODING_RATE config.lora.coding_rate = USERPREFS_LORACONFIG_CODING_RATE; #endif + #ifdef USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY config.lora.override_frequency = USERPREFS_LORACONFIG_OVERRIDE_FREQUENCY; #endif @@ -1891,6 +2230,7 @@ void NodeDB::loadFromDisk() #if defined(USERPREFS_USE_ADMIN_KEY_0) || defined(USERPREFS_USE_ADMIN_KEY_1) || defined(USERPREFS_USE_ADMIN_KEY_2) uint16_t sum = 0; #endif + #ifdef USERPREFS_USE_ADMIN_KEY_0 for (uint8_t b = 0; b < 32; b++) { @@ -1949,6 +2289,16 @@ void NodeDB::loadFromDisk() } } + // Always-on traffic management: a device that has NEVER configured TMM + // (has_traffic_management false — AdminModule always sets the has_ flag on + // write, even when disabling) gets the fork defaults. Explicitly configured + // devices keep their exact settings. + if (!moduleConfig.has_traffic_management) { + LOG_INFO("Traffic management never configured, installing always-on defaults"); + installTrafficManagementDefaults(moduleConfig); + saveToDisk(SEGMENT_MODULECONFIG); + } + state = loadProto(channelFileName, meshtastic_ChannelFile_size, sizeof(meshtastic_ChannelFile), &meshtastic_ChannelFile_msg, &channelFile); if (state != LoadFileResult::LOAD_SUCCESS) { @@ -2083,6 +2433,17 @@ bool NodeDB::reloadFromDisk() return false; } + // loadFromDisk() leaves the store untrimmed; run self-care now (getNodeNum() + // is valid at runtime) to trim/demote non-self overflow, pin self to index 0 + // and normalise the backing store before the node DB is exercised again. + nodeDBSelfCare(); + + // Preserve constructor ordering: persist any migration only after self-care. + if (migrationSavePending) { + saveNodeDatabaseToDisk(); + migrationSavePending = false; + } + // Push the now-real config to the radio. if (rIface) { channels.onConfigChanged(); @@ -2203,6 +2564,7 @@ bool NodeDB::saveChannelsToDisk() FSCom.mkdir("/prefs"); spiLock->unlock(); #endif + return saveProto(channelFileName, meshtastic_ChannelFile_size, &meshtastic_ChannelFile_msg, &channelFile); } @@ -2258,6 +2620,7 @@ bool NodeDB::saveNodeDatabaseToDisk() FSCom.mkdir("/prefs"); spiLock->unlock(); #endif + // Project the maps into the on-disk vectors just before encoding; cleared // again on the way out so we don't carry duplicate state. concurrency::LockGuard guard(&satelliteMutex); @@ -2274,6 +2637,7 @@ bool NodeDB::saveNodeDatabaseToDisk() #else nodeDatabase.positions.clear(); #endif + #if !MESHTASTIC_EXCLUDE_TELEMETRYDB nodeDatabase.telemetry.clear(); nodeDatabase.telemetry.reserve(nodeTelemetry.size()); @@ -2287,6 +2651,7 @@ bool NodeDB::saveNodeDatabaseToDisk() #else nodeDatabase.telemetry.clear(); #endif + #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB nodeDatabase.environment.clear(); nodeDatabase.environment.reserve(nodeEnvironment.size()); @@ -2300,6 +2665,7 @@ bool NodeDB::saveNodeDatabaseToDisk() #else nodeDatabase.environment.clear(); #endif + #if !MESHTASTIC_EXCLUDE_STATUSDB nodeDatabase.status.clear(); nodeDatabase.status.reserve(nodeStatus.size()); @@ -2326,6 +2692,16 @@ bool NodeDB::saveNodeDatabaseToDisk() nodeDatabase.environment.shrink_to_fit(); nodeDatabase.status.clear(); nodeDatabase.status.shrink_to_fit(); +#if WARM_NODE_COUNT > 0 +#ifdef ARCH_RP2040 + // nodes.proto + warm.dat are written back-to-back without the loop running between them; + // reset the 8s HW watchdog so the second write gets a full budget (issue #10746). + watchdog_update(); +#endif + // Same cadence as the node DB; failure is logged but must not propagate — + // a false return from here would trigger saveToDisk()'s fsFormat() path. + warmStore.saveIfDirty(); +#endif return ok; } @@ -2361,6 +2737,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) FSCom.mkdir("/prefs"); spiLock->unlock(); #endif + if (saveWhat & SEGMENT_CONFIG) { config.has_device = true; config.has_display = true; @@ -2474,6 +2851,10 @@ HopStartStatus classifyHopStart(const meshtastic_MeshPacket &p) return HopStartStatus::INVALID; if (p.hop_start == 0) { + // hop_start == hop_limit == 0: intentional zero-hop broadcast (e.g. beacon). Valid by definition — + // the packet was never meant to travel any hops, so no hop_start ambiguity applies. + if (p.hop_limit == 0) + return HopStartStatus::VALID; // Firmware prior to 2.3.0 (585805c) lacked a hop_start field. Firmware version 2.5.0 (bf34329) introduced a // bitfield that is always present. Use the presence of the bitfield to determine if the origin's firmware // version is guaranteed to have hop_start populated. Note that this can only be done for decoded packets as @@ -2562,6 +2943,7 @@ void NodeDB::updatePosition(uint32_t nodeId, const meshtastic_Position &p, RxSou #else { concurrency::LockGuard guard(&satelliteMutex); + evictSatelliteOverCap(*this, nodePositions, nodeId); meshtastic_PositionLite &slot = nodePositions[nodeId]; // creates default-zero entry if missing if (src == RX_SRC_LOCAL) { @@ -2619,8 +3001,10 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS } #if !MESHTASTIC_EXCLUDE_TELEMETRYDB concurrency::LockGuard guard(&satelliteMutex); + evictSatelliteOverCap(*this, nodeTelemetry, nodeId); nodeTelemetry[nodeId] = t.variant.device_metrics; #endif + } else if (t.which_variant == meshtastic_Telemetry_environment_metrics_tag) { if (src == RX_SRC_LOCAL) { LOG_DEBUG("updateTelemetry LOCAL env"); @@ -2629,8 +3013,10 @@ void NodeDB::updateTelemetry(uint32_t nodeId, const meshtastic_Telemetry &t, RxS } #if !MESHTASTIC_EXCLUDE_ENVIRONMENTDB concurrency::LockGuard guard(&satelliteMutex); + evictSatelliteOverCap(*this, nodeEnvironment, nodeId); nodeEnvironment[nodeId] = t.variant.environment_metrics; #endif + } else { return; // air_quality / power / local_stats / health: not stored per-node } @@ -2659,13 +3045,13 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) info->num = contact.node_num; TypeConversions::CopyUserToNodeInfoLite(info, contact.user); if (contact.should_ignore) { - // If should_ignore is set, - // we need to clear the public key and other cruft, in addition to setting the node as ignored - nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_IGNORED_MASK, true); + // Block the contact and drop its rich satellite data, but keep the + // public key copied above — an ignored peer keeps a usable identity + // (a verifiable target) rather than a bare node number. + if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) + LOG_WARN(PROTECTED_CAP_WARN_FMT, "ignore", contact.node_num, MAX_NUM_NODES - 2); nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, false); eraseNodeSatellites(contact.node_num); - info->public_key.size = 0; - memset(info->public_key.bytes, 0, sizeof(info->public_key.bytes)); } else { /* Clients are sending add_contact before every text message DM (because clients may hold a larger node database with * public keys than the radio holds). However, we don't want to update last_heard just because we sent someone a DM! @@ -2684,12 +3070,16 @@ void NodeDB::addFromContact(meshtastic_SharedContact contact) } else { // Normal case: set is_favorite to prevent expiration. // last_heard will remain as-is (or remain 0 if this entry wasn't in the nodeDB). - nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true); + // If the protected cap refuses the favorite, fall back to stamping last_heard so the + // contact still isn't the first eviction victim. + if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) + info->last_heard = getTime(); } // As the clients will begin sending the contact with DMs, we want to strictly check if the node is manually verified if (contact.manually_verified) { - nodeInfoLiteSetBit(info, NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true); + if (!setProtectedFlag(info, NODEINFO_BITFIELD_IS_KEY_MANUALLY_VERIFIED_MASK, true)) + LOG_WARN(PROTECTED_CAP_WARN_FMT, "verify", contact.node_num, MAX_NUM_NODES - 2); } // Mark the node's key as manually verified to indicate trustworthiness. updateGUIforNode = info; @@ -2805,8 +3195,14 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) mp.via_mqtt); // Store if we received this packet via MQTT #if HAS_VARIABLE_HOPS - // Only sample packets that arrived over LoRa. - if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && hopScalingModule) { + // Only sample genuine RF-origin packets. The transport check excludes packets received + // directly from the broker (TRANSPORT_MQTT), but an MQTT-origin packet rebroadcast onto + // LoRa by a gateway arrives as TRANSPORT_LORA with via_mqtt set — count those would + // inflate the local mesh-size estimate with non-RF nodes (and they usually carry + // hop_start==0, landing in the hop-0 bucket that pulls the recommendation lowest), so + // exclude via_mqtt too. + if (mp.transport_mechanism == meshtastic_MeshPacket_TransportMechanism_TRANSPORT_LORA && !mp.via_mqtt && + hopScalingModule) { uint8_t hopCount = std::max(int8_t(0), getHopsAway(mp)); hopScalingModule->samplePacketForHistogram(mp.from, hopCount); } @@ -2822,14 +3218,48 @@ void NodeDB::updateFrom(const meshtastic_MeshPacket &mp) } } -void NodeDB::set_favorite(bool is_favorite, uint32_t nodeId) +int NodeDB::numProtectedNodes() const +{ + int count = 0; + for (int i = 0; i < numMeshNodes; i++) + if (nodeInfoLiteIsProtected(&meshNodes->at(i))) + count++; + return count; +} + +bool NodeDB::setProtectedFlag(meshtastic_NodeInfoLite *node, uint32_t mask, bool on) +{ + if (!node) + return false; + if (!on) { + nodeInfoLiteSetBit(node, mask, false); + return true; + } + // Adding a flag to a node that is already protected doesn't grow the + // protected set, so it's always allowed. A newly-protected node is refused + // once the protected set has reached MAX_NUM_NODES-2, leaving two evictable + // slots so getOrCreateMeshNode can always make room. + if (nodeInfoLiteIsProtected(node) || numProtectedNodes() < MAX_NUM_NODES - 2) { + nodeInfoLiteSetBit(node, mask, true); + return true; + } + return false; +} + +bool NodeDB::set_favorite(bool is_favorite, uint32_t nodeId) { meshtastic_NodeInfoLite *lite = getMeshNode(nodeId); - if (lite && nodeInfoLiteIsFavorite(lite) != is_favorite) { - nodeInfoLiteSetBit(lite, NODEINFO_BITFIELD_IS_FAVORITE_MASK, is_favorite); + if (!lite) + return false; + if (nodeInfoLiteIsFavorite(lite) == is_favorite) + return true; // already in the requested state + if (setProtectedFlag(lite, NODEINFO_BITFIELD_IS_FAVORITE_MASK, is_favorite)) { sortMeshDB(); saveNodeDatabaseToDisk(); + return true; } + LOG_WARN(PROTECTED_CAP_WARN_FMT, "favorite", nodeId, MAX_NUM_NODES - 2); + return false; } bool NodeDB::isFavorite(uint32_t nodeId) @@ -2953,12 +3383,117 @@ meshtastic_NodeInfoLite *NodeDB::getMeshNode(NodeNum n) return NULL; } +ResolvedNode NodeDB::resolveLastByte(uint8_t lastByte, bool requireDirectNeighbor) +{ + ResolvedNode result; // defaults to {None, 0} + + // 0 is the NO_RELAY_NODE / NO_NEXT_HOP_PREFERENCE sentinel (also what MQTT-sourced packets carry + // when hop_start==0). getLastByteOfNodeNum() never yields 0, so nothing can legitimately match. + if (lastByte == 0) + return result; + + const NodeNum self = getNodeNum(); + NodeNum firstMatch = 0; + uint8_t matches = 0; + + for (size_t i = 0; i < numMeshNodes; i++) { + const meshtastic_NodeInfoLite *node = &meshNodes->at(i); + + // Candidate gate: never resolve to ourselves, the sentinels, or an ignored node. + if (node->num == self || node->num == 0 || node->num == NODENUM_BROADCAST) + continue; + if (nodeInfoLiteIsIgnored(node)) + continue; + if (getLastByteOfNodeNum(node->num) != lastByte) // cheapest discriminator last + continue; + + // Relevance gate: is this node a plausible relay for the requested scope? + bool relevant; + if (requireDirectNeighbor) { + relevant = node->has_hops_away && node->hops_away == 0 && sinceLastSeen(node) < NEXTHOP_NEIGHBOR_FRESH_SECS; + } else { + const bool directNeighbor = node->has_hops_away && node->hops_away == 0; + const bool routerRole = + IS_ONE_OF(node->role, meshtastic_Config_DeviceConfig_Role_ROUTER, meshtastic_Config_DeviceConfig_Role_ROUTER_LATE, + meshtastic_Config_DeviceConfig_Role_CLIENT_BASE); + relevant = directNeighbor || nodeInfoLiteIsFavorite(node) || routerRole; + } + if (!relevant) + continue; + + if (++matches == 1) { + firstMatch = node->num; + } else { + // A second relevant candidate shares this byte: ambiguous. No further scanning can + // change that, so stop early and report the collision. + result.status = LastByteResolution::Ambiguous; + result.num = 0; + return result; + } + } + + if (matches == 1) { + result.status = LastByteResolution::Unique; + result.num = firstMatch; + } + return result; +} + +bool NodeDB::resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum *outNum) +{ + ResolvedNode r = resolveLastByte(lastByte, requireDirectNeighbor); + if (r.status == LastByteResolution::Unique) { + if (outNum) + *outNum = r.num; + return true; + } + return false; +} + // returns true if the maximum number of nodes is reached or we are running low on memory bool NodeDB::isFull() { return (numMeshNodes >= MAX_NUM_NODES) || (memGet.getFreeHeap() < MINIMUM_SAFE_FREE_HEAP); } +uint32_t NodeDB::hotNodeLastHeard(NodeNum n) const +{ + for (int i = 0; i < numMeshNodes; i++) + if (meshNodes->at(i).num == n) + return meshNodes->at(i).last_heard; + return 0; +} + +bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) +{ + const meshtastic_NodeInfoLite *info = getMeshNode(n); + if (info && info->public_key.size == 32) { + out = info->public_key; + return true; + } +#if WARM_NODE_COUNT > 0 + if (warmStore.copyKey(n, out.bytes)) { + out.size = 32; + return true; + } +#endif + return false; +} + +meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) +{ + const meshtastic_NodeInfoLite *info = getMeshNode(n); + if (nodeInfoLiteHasUser(info)) + return info->role; +#if WARM_NODE_COUNT > 0 + // Hot-store miss: fall back to the role the warm tier cached at eviction. + uint8_t role = 0, prot = 0; + if (warmStore.lookupMeta(n, role, prot)) + return static_cast(role); +#endif + return meshtastic_Config_DeviceConfig_Role_CLIENT; +} + /// Find a node in our DB, create an empty NodeInfo if missing meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) { @@ -2995,7 +3530,14 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) } if (oldestIndex != -1) { - eraseNodeSatellites(meshNodes->at(oldestIndex).num); + const meshtastic_NodeInfoLite &evicted = meshNodes->at(oldestIndex); +#if WARM_NODE_COUNT > 0 + // Demote to the warm tier so the identity (and crucially the + // PKI key) outlives the hot-store slot. + warmStore.absorb(evicted.num, evicted.last_heard, evicted.public_key.size == 32 ? evicted.public_key.bytes : NULL, + evicted.role, warmProtectedCategory(evicted)); +#endif + eraseNodeSatellites(evicted.num); // Shove the remaining nodes down the chain for (int i = oldestIndex; i < numMeshNodes - 1; i++) { meshNodes->at(i) = meshNodes->at(i + 1); @@ -3003,12 +3545,36 @@ meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) (numMeshNodes)--; } } + // Don't append past the end of the vector. The protected-node cap + // (numProtectedNodes() <= MAX_NUM_NODES-2) means the eviction above frees + // a slot in normal operation; this guards the legacy case of a pre-cap + // database that is full of protected nodes — refuse rather than overrun. + if (numMeshNodes >= MAX_NUM_NODES) + return NULL; + // Pre-size before append when run before nodeDBSelfCare() (boot keygen); else at() aborts on nRF52. + if (static_cast(numMeshNodes) >= meshNodes->size()) + meshNodes->resize(numMeshNodes + 1); // add the node at the end lite = &meshNodes->at((numMeshNodes)++); // everything is missing except the nodenum memset(lite, 0, sizeof(*lite)); lite->num = n; +#if WARM_NODE_COUNT > 0 + // Re-admission: restore what the warm tier kept for this node + WarmNodeEntry warm; + if (warmStore.take(n, warm)) { + lite->last_heard = warmTimeOf(warm); // mask off the stolen role/protected metadata bits + // Restore the role the warm tier cached, so re-admission isn't stuck at CLIENT + // until the next NodeInfo arrives. + lite->role = static_cast(warmRoleOf(warm)); + if (!memfll(warm.public_key, 0, sizeof(warm.public_key))) { + lite->public_key.size = 32; + memcpy(lite->public_key.bytes, warm.public_key, 32); + } + LOG_MIGRATION("Rehydrated node 0x%x from warm tier (key=%d)", n, lite->public_key.size == 32); + } +#endif LOG_INFO("Adding node to database with %i nodes and %u bytes free!", numMeshNodes, memGet.getFreeHeap()); } @@ -3150,6 +3716,8 @@ bool NodeDB::createNewIdentity() myNodeInfo.my_node_num = newNodeNum; meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum()); + if (!info) + return false; TypeConversions::CopyUserToNodeInfoLite(info, owner); return true; diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index 3fe244294..b7f3d7f9e 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -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) { diff --git a/src/mesh/NodeDBLegacyMigration.cpp b/src/mesh/NodeDBLegacyMigration.cpp index 18d6daa4d..107a4e32d 100644 --- a/src/mesh/NodeDBLegacyMigration.cpp +++ b/src/mesh/NodeDBLegacyMigration.cpp @@ -14,6 +14,7 @@ #include "configuration.h" #include "mesh-pb-constants.h" #include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" +#include "meshUtils.h" #include #include @@ -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) diff --git a/src/mesh/PacketHistory.cpp b/src/mesh/PacketHistory.cpp index f5c36b083..e4f565d1a 100644 --- a/src/mesh/PacketHistory.cpp +++ b/src/mesh/PacketHistory.cpp @@ -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; diff --git a/src/mesh/PhoneAPI.cpp b/src/mesh/PhoneAPI.cpp index d37a3e837..5ee553bdc 100644 --- a/src/mesh/PhoneAPI.cpp +++ b/src/mesh/PhoneAPI.cpp @@ -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: diff --git a/src/mesh/PhoneAPI.h b/src/mesh/PhoneAPI.h index cd5939b0c..ec7a593c1 100644 --- a/src/mesh/PhoneAPI.h +++ b/src/mesh/PhoneAPI.h @@ -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 diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp index b5b29903b..4302531a5 100644 --- a/src/mesh/PositionPrecision.cpp +++ b/src/mesh/PositionPrecision.cpp @@ -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(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(truncated); } +int32_t truncateCoordinate(int32_t coordinate, uint8_t precision) +{ + return truncateCoordinate(coordinate, static_cast(precision)); +} + void applyPositionPrecision(meshtastic_Position &position, uint32_t precision) { if (precision == 0) { diff --git a/src/mesh/PositionPrecision.h b/src/mesh/PositionPrecision.h index 1955cf80f..8299f50a9 100644 --- a/src/mesh/PositionPrecision.h +++ b/src/mesh/PositionPrecision.h @@ -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); diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 0d973e2e2..281587d44 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -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. */ diff --git a/src/mesh/ReliableRouter.cpp b/src/mesh/ReliableRouter.cpp index a1cfb1200..3126c820e 100644 --- a/src/mesh/ReliableRouter.cpp +++ b/src/mesh/ReliableRouter.cpp @@ -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); } diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index f176f60e6..d52fa49f7 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -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; diff --git a/src/mesh/WarmNodeStore.cpp b/src/mesh/WarmNodeStore.cpp new file mode 100644 index 000000000..39fbd90c6 --- /dev/null +++ b/src/mesh/WarmNodeStore.cpp @@ -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 +#include + +#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(ps_calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry))); + if (!entries) { + LOG_WARN("WarmStore: PSRAM alloc failed, using heap"); + entries = static_cast(calloc(WARM_NODE_COUNT, sizeof(WarmNodeEntry))); + } +#else + entries = static_cast(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(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(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(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(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(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(&e - entries)); +} + +void WarmNodeStore::persistRemove(NodeNum num, int storeSlot) +{ + if (storeSlot >= 0 && storeSlot < static_cast(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(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 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 diff --git a/src/mesh/WarmNodeStore.h b/src/mesh/WarmNodeStore.h new file mode 100644 index 000000000..8d9bb5be4 --- /dev/null +++ b/src/mesh/WarmNodeStore.h @@ -0,0 +1,176 @@ +#pragma once + +#include "MeshTypes.h" +#include "mesh-pb-constants.h" +#include +#include + +// 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(role) & WARM_ROLE_MASK) | + ((static_cast(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(e.last_heard & WARM_ROLE_MASK); +} +inline uint8_t warmProtOf(const WarmNodeEntry &e) +{ + return static_cast((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 diff --git a/src/mesh/eth/ethClient.cpp b/src/mesh/eth/ethClient.cpp index a039953f4..003ef8670 100644 --- a/src/mesh/eth/ethClient.cpp +++ b/src/mesh/eth/ethClient.cpp @@ -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 // 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 } diff --git a/src/mesh/eth/ethOTA.cpp b/src/mesh/eth/ethOTA.cpp new file mode 100644 index 000000000..7519a6112 --- /dev/null +++ b/src/mesh/eth/ethOTA.cpp @@ -0,0 +1,293 @@ +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA) + +#include "ethOTA.h" +#include +#include +#include +#ifdef ARCH_RP2040 +#include +#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(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 diff --git a/src/mesh/eth/ethOTA.h b/src/mesh/eth/ethOTA.h new file mode 100644 index 000000000..d2659958e --- /dev/null +++ b/src/mesh/eth/ethOTA.h @@ -0,0 +1,22 @@ +#pragma once + +#include "configuration.h" + +#if HAS_ETHERNET && defined(HAS_ETHERNET_OTA) + +#ifdef USE_ARDUINO_ETHERNET +#include +#else +#include +#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 diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 474b332a8..5bd116730 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -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 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 27f5ad7bf..3cb4a5a57 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -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" */ diff --git a/src/mesh/generated/meshtastic/mesh.pb.cpp b/src/mesh/generated/meshtastic/mesh.pb.cpp index a68ffabac..ac991c580 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.cpp +++ b/src/mesh/generated/meshtastic/mesh.pb.cpp @@ -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) diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 5b27f01d1..ce92d81f1 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -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 diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 25937e972..c7edfa0aa 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -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 diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index b4d999011..7d64bc058 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -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 diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 8715e202d..8bcfcd878 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -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; diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 5cc94a68f..89c5b9d43 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -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 diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index ed7215a98..1a0eed933 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -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"); diff --git a/src/modules/PositionModule.h b/src/modules/PositionModule.h index d0a4d4603..45535e1b2 100644 --- a/src/modules/PositionModule.h +++ b/src/modules/PositionModule.h @@ -38,6 +38,14 @@ class PositionModule : public ProtobufModule, 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, 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; diff --git a/src/modules/TraceRouteModule.cpp b/src/modules/TraceRouteModule.cpp index b6cb5d041..915203ed1 100644 --- a/src/modules/TraceRouteModule.cpp +++ b/src/modules/TraceRouteModule.cpp @@ -8,6 +8,10 @@ #include "meshUtils.h" #include +#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) diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index 4c73c2947..95e206e11 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -2,9 +2,11 @@ #if HAS_TRAFFIC_MANAGEMENT +#include "Channels.h" #include "Default.h" #include "MeshService.h" #include "NodeDB.h" +#include "PositionPrecision.h" #include "Router.h" #include "TypeConversions.h" #include "airtime.h" @@ -13,6 +15,7 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include +#include #include #define TM_LOG_DEBUG(fmt, ...) LOG_DEBUG("[TM] " fmt, ##__VA_ARGS__) @@ -27,8 +30,6 @@ namespace { constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interval -constexpr uint32_t kUnknownResetMs = 60 * 1000UL; // Unknown packet window -constexpr uint8_t kMaxCuckooKicks = 16; // Max displacement chain length // NodeInfo direct response: enforced maximum hops by device role // Both use maxHops logic (respond when hopsAway <= threshold) @@ -48,6 +49,17 @@ uint32_t secsToMs(uint32_t secs) return static_cast(ms); } +// Advertised role of the originating node (from NodeDB), or CLIENT (no exception) if unknown. +// Position filtering grants two role exceptions: trackers may refresh duplicates hourly, and +// lost-and-found is throttled only to the shortest dedup window. Both are still subject to +// the channel-precision ceiling in alterReceived(). +meshtastic_Config_DeviceConfig_Role originRole(NodeNum from) +{ + // Resolve via NodeDB: hot store (with user) → warm-tier cached role → CLIENT. The + // warm fallback keeps role exceptions firing for trackers/etc. aged out of the hot store. + return nodeDB ? nodeDB->getNodeRole(from) : meshtastic_Config_DeviceConfig_Role_CLIENT; +} + /** * Clamp precision to a valid dedup range. * Invalid values use the module default precision. @@ -65,42 +77,6 @@ uint8_t sanitizePositionPrecision(uint8_t precision) return 32; } -/** - * Check if a timestamp is within a time window. - * Handles wrap-around correctly using unsigned subtraction. - */ -bool isWithinWindow(uint32_t nowMs, uint32_t startMs, uint32_t intervalMs) -{ - if (intervalMs == 0 || startMs == 0) - return false; - return (nowMs - startMs) < intervalMs; -} - -/** - * Truncate lat/lon to specified precision for position deduplication. - * - * The truncation works by masking off lower bits and rounding to the center - * of the resulting grid cell. This creates a stable truncated value even - * when GPS jitter causes small coordinate changes. - * - * @param value Raw latitude_i or longitude_i from position - * @param precision Number of significant bits to keep (0-32) - * @return Truncated and centered coordinate value - */ -int32_t truncateLatLon(int32_t value, uint8_t precision) -{ - if (precision == 0 || precision >= 32) - return value; - - // Create mask to zero out lower bits - uint32_t mask = UINT32_MAX << (32 - precision); - uint32_t truncated = static_cast(value) & mask; - - // Add half the truncation step to center in the grid cell - truncated += (1u << (31 - precision)); - return static_cast(truncated); -} - /** * Saturating increment for uint8_t counters. * Prevents overflow by capping at UINT8_MAX (255). @@ -162,23 +138,10 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme encryptedOk = true; // Can process encrypted packets stats = meshtastic_TrafficManagementStats_init_zero; - // Initialize rolling epoch for relative timestamps - cacheEpochMs = millis(); - - // Calculate adaptive time resolutions from config (config changes require reboot) - // Resolution = max(60, min(339, interval/2)) for ~24 hour range with good precision - posTimeResolution = calcTimeResolution(Default::getConfiguredOrDefault( - moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); - rateTimeResolution = calcTimeResolution(moduleConfig.traffic_management.rate_limit_window_secs); - unknownTimeResolution = calcTimeResolution(kUnknownResetMs / 1000); // ~5 min default - const auto &cfg = moduleConfig.traffic_management; - TM_LOG_INFO("Enabled: pos_dedup=%d nodeinfo_resp=%d rate_limit=%d drop_unknown=%d exhaust_telem=%d exhaust_pos=%d " - "preserve_hops=%d", - cfg.position_dedup_enabled, cfg.nodeinfo_direct_response, cfg.rate_limit_enabled, cfg.drop_unknown_enabled, - cfg.exhaust_hop_telemetry, cfg.exhaust_hop_position, cfg.router_preserve_hops); - TM_LOG_DEBUG("Time resolutions: pos=%us, rate=%us, unknown=%us", posTimeResolution, rateTimeResolution, - unknownTimeResolution); + TM_LOG_INFO("Config: nodeinfo_max_hops=%u rate_window=%us rate_max=%u unknown_thresh=%u pos_interval=%us", + cfg.nodeinfo_direct_response_max_hops, cfg.rate_limit_window_secs, cfg.rate_limit_max_packets, + cfg.unknown_packet_threshold, cfg.position_min_interval_secs); // Allocate unified cache (10 bytes/entry for all platforms) #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 @@ -203,27 +166,16 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - TM_LOG_INFO("Allocating NodeInfo cache: target=%u occupancy=%u%% payload=%u bytes (PSRAM) tags=%u bytes (%u-bit, %u slots, " - "%u buckets x %u)", - static_cast(nodeInfoTargetEntries()), static_cast(nodeInfoTargetOccupancyPercent()), - static_cast(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry)), - static_cast(nodeInfoIndexMetadataBudgetBytes()), static_cast(nodeInfoTagBits()), - static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoBucketCount()), - static_cast(nodeInfoBucketSize())); + TM_LOG_INFO("Allocating NodeInfo cache: %u entries, %u bytes (PSRAM flat array)", + static_cast(nodeInfoTargetEntries()), + static_cast(nodeInfoTargetEntries() * sizeof(NodeInfoPayloadEntry))); - nodeInfoIndex = static_cast(calloc(nodeInfoIndexMetadataBudgetBytes(), sizeof(uint8_t))); - if (!nodeInfoIndex) { - TM_LOG_WARN("NodeInfo index allocation failed; direct responses will fall back to NodeDB"); + nodeInfoPayload = static_cast(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry))); + if (nodeInfoPayload) { + nodeInfoPayloadFromPsram = true; + TM_LOG_INFO("NodeInfo PSRAM cache ready"); } else { - nodeInfoPayload = static_cast(ps_calloc(nodeInfoTargetEntries(), sizeof(NodeInfoPayloadEntry))); - if (nodeInfoPayload) { - nodeInfoPayloadFromPsram = true; - TM_LOG_INFO("NodeInfo bucketed cuckoo cache ready"); - } else { - TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB"); - free(nodeInfoIndex); - nodeInfoIndex = nullptr; - } + TM_LOG_WARN("NodeInfo PSRAM payload allocation failed; direct responses will fall back to NodeDB"); } #else TM_LOG_DEBUG("NodeInfo PSRAM cache not available on this target"); @@ -255,11 +207,6 @@ TrafficManagementModule::~TrafficManagementModule() delete[] nodeInfoPayload; nodeInfoPayload = nullptr; } - - if (nodeInfoIndex) { - free(nodeInfoIndex); - nodeInfoIndex = nullptr; - } } // ============================================================================= @@ -280,9 +227,9 @@ void TrafficManagementModule::resetStats() void TrafficManagementModule::recordRouterHopPreserved() { - if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) - return; - incrementStat(&stats.router_hops_preserved); + // router_preserve_hops: not suitable right now — removed from config until + // the right heuristic for when to preserve vs. exhaust is clearer. + (void)stats.router_hops_preserved; } void TrafficManagementModule::incrementStat(uint32_t *field) @@ -292,17 +239,11 @@ void TrafficManagementModule::incrementStat(uint32_t *field) } // ============================================================================= -// Cuckoo Hash Table Operations +// Flat Unified Cache Operations // ============================================================================= /** - * Find an existing entry for the given node. - * - * Cuckoo hashing guarantees that if an entry exists, it's in one of exactly - * two locations: hash1(node) or hash2(node). This provides O(1) lookup. - * - * @param node NodeNum to search for - * @return Pointer to entry if found, nullptr otherwise + * Find an existing entry for the given node (linear scan). */ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(NodeNum node) { @@ -313,36 +254,84 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(N if (!cache || node == 0) return nullptr; - // Check primary location - uint16_t h1 = cuckooHash1(node); - if (cache[h1].node == node) - return &cache[h1]; - - // Check alternate location - uint16_t h2 = cuckooHash2(node); - if (cache[h2].node == node) - return &cache[h2]; - + for (uint16_t i = 0; i < cacheSize(); i++) { + if (cache[i].node == node) + return &cache[i]; + } return nullptr; #endif } +int TrafficManagementModule::peekCachedRole(NodeNum node) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)node; + return -1; +#else + concurrency::LockGuard guard(&cacheLock); + const UnifiedCacheEntry *entry = findEntry(node); + return entry ? static_cast(entry->getCachedRole()) : -1; +#endif +} + /** - * Find or create an entry for the given node using cuckoo hashing. + * Find or create an entry for the given node. * - * If the node exists, returns the existing entry. Otherwise, attempts to - * insert a new entry using cuckoo displacement: - * - * 1. Try to insert at h1(node) - if empty, done - * 2. Try to insert at h2(node) - if empty, done - * 3. Kick existing entry from h1 to its alternate location - * 4. Repeat up to kMaxCuckooKicks times - * 5. If cycle detected or max kicks exceeded, evict oldest entry + * One linear pass tracks the match, the first empty slot, and the eviction + * victim. When the cache is full, the victim is the stalest entry (largest + * of its three relative timestamps is smallest), preferring entries without + * a next_hop hint — those hints are the long-tail routing state the cache + * exists to keep, and the maintenance sweep never ages them out. * * @param node NodeNum to find or create * @param isNew Set to true if a new entry was created - * @return Pointer to entry, or nullptr if allocation failed + * @return Pointer to entry, or nullptr if the cache is unavailable */ +// Sender-role resolution for the position hot path. The tier-3 cache is authoritative +// here and is kept fresh by updateCachedRoleFromNodeInfo() — i.e. updated at the same +// time NodeDB learns a role, not re-derived on every packet. We only fall back to a +// NodeDB scan (tiers 1+2) the first time we start tracking a node, to seed the cache so +// a resident special-role node is correct from its very first position. Thereafter the +// read is O(1) and survives the node aging out of both NodeDB stores. +meshtastic_Config_DeviceConfig_Role TrafficManagementModule::resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew) +{ + if (!entry) + return originRole(from); + if (isNew) { + // First time tracking this node: seed tier 3 from NodeDB (hot → warm). Stores + // CLIENT (0) too, which simply reads back as "no exception". + const meshtastic_Config_DeviceConfig_Role role = originRole(from); + entry->setCachedRole(static_cast(std::min(15, static_cast(role)))); + return role; + } + // Established entry: trust the cached role (refreshed on NodeInfo). No NodeDB scan. + return static_cast(entry->getCachedRole()); +} + +// Refresh the tier-3 role cache from an observed NodeInfo — the same event that updates +// NodeDB's role — so role changes (including demotion back to CLIENT) are picked up +// without scanning NodeDB on the position hot path. Role is read straight from the +// packet's User payload (authoritative regardless of module ordering). Only updates nodes +// we already track (findEntry, no create) so NodeInfo from non-position nodes can't pollute +// the cache; the role rides along with the node's existing position/rate/unknown state. +void TrafficManagementModule::updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (mp.decoded.payload.size == 0) + return; + meshtastic_User user = meshtastic_User_init_zero; + if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &user)) + return; + + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findEntry(getFrom(&mp)); + if (entry) + entry->setCachedRole(static_cast(std::min(15, static_cast(user.role)))); +#else + (void)mp; +#endif +} + TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreateEntry(NodeNum node, bool *isNew) { #if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 @@ -351,304 +340,89 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat *isNew = false; return nullptr; #else - if (!cache || node == 0) { - if (isNew) - *isNew = false; + if (isNew) + *isNew = false; + if (!cache || node == 0) return nullptr; - } - // Check if entry already exists (O(1) lookup) - uint16_t h1 = cuckooHash1(node); - if (cache[h1].node == node) { - if (isNew) - *isNew = false; - return &cache[h1]; - } + UnifiedCacheEntry *empty = nullptr; + UnifiedCacheEntry *victim = nullptr; + bool leastPreferredVictim = true; + uint8_t victimRecency = UINT8_MAX; - uint16_t h2 = cuckooHash2(node); - if (cache[h2].node == node) { - if (isNew) - *isNew = false; - return &cache[h2]; - } - - // Entry doesn't exist - try to insert - - // Prefer empty slot at h1 - if (cache[h1].node == 0) { - memset(&cache[h1], 0, sizeof(UnifiedCacheEntry)); - cache[h1].node = node; - if (isNew) - *isNew = true; - return &cache[h1]; - } - - // Try empty slot at h2 - if (cache[h2].node == 0) { - memset(&cache[h2], 0, sizeof(UnifiedCacheEntry)); - cache[h2].node = node; - if (isNew) - *isNew = true; - return &cache[h2]; - } - - // Both slots occupied - perform cuckoo displacement - // Start by kicking entry at h1 to its alternate location - UnifiedCacheEntry displaced = cache[h1]; - memset(&cache[h1], 0, sizeof(UnifiedCacheEntry)); - cache[h1].node = node; - - for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) { - // Find alternate location for displaced entry - uint16_t altH1 = cuckooHash1(displaced.node); - uint16_t altH2 = cuckooHash2(displaced.node); - uint16_t altSlot = (altH1 == h1) ? altH2 : altH1; - - if (cache[altSlot].node == 0) { - // Found empty slot - insert displaced entry - cache[altSlot] = displaced; - if (isNew) - *isNew = true; - return &cache[h1]; + for (uint16_t i = 0; i < cacheSize(); i++) { + UnifiedCacheEntry &e = cache[i]; + if (e.node == node) + return &e; + if (e.node == 0) { + if (!empty) + empty = &e; + continue; + } + if (empty) + continue; // an empty slot beats any victim; stop scoring + // "Preferred" entries are evicted last: a confirmed next-hop hint (routing overflow + // store) or a cached special (non-CLIENT) role (tracker / lost-and-found / router). + // Both are the long-tail state this cache exists to retain. + const bool preferred = e.next_hop != 0 || e.getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT; + // Age in pos-ticks (8-bit modular, wraps correctly). Entries with no + // pos state (pos_time==0) score as maximally old (age=currentPosTick()). + const uint8_t nowPosTick = currentPosTick(); + const uint8_t posAge = static_cast(nowPosTick - e.pos_time); + // Blend in rate/unknown ages scaled to pos-tick units (coarser = conservative). + const uint8_t rateAgePosScale = + static_cast(static_cast((currentRateTick() - e.getRateTime()) & 0x0F) * 5 / 3); + const uint8_t unknownAgePosScale = + static_cast(static_cast((currentUnknownTick() - e.getUnknownTime()) & 0x0F) / 6); + uint8_t recencyAge = posAge; + if (e.getRateCount() != 0 && rateAgePosScale > recencyAge) + recencyAge = rateAgePosScale; + if (e.getUnknownCount() != 0 && unknownAgePosScale > recencyAge) + recencyAge = unknownAgePosScale; + const uint8_t recency = static_cast(UINT8_MAX - recencyAge); + if (!victim || (preferred == leastPreferredVictim ? recency < victimRecency : !preferred)) { + victim = &e; + leastPreferredVictim = preferred; + victimRecency = recency; } - - // Kick entry from alternate slot - UnifiedCacheEntry temp = cache[altSlot]; - cache[altSlot] = displaced; - displaced = temp; - h1 = altSlot; } - // Cuckoo cycle detected or max kicks exceeded. - // The displaced entry has no valid cuckoo slot — drop it to preserve cache integrity. - // Placing it at an arbitrary slot would make it unreachable by findEntry(). - TM_LOG_DEBUG("Cuckoo cycle, evicting node 0x%08x", displaced.node); - + UnifiedCacheEntry *slot = empty ? empty : victim; + if (!slot) + return nullptr; + if (!empty) + TM_LOG_DEBUG("Unified cache full, evicting node 0x%08x", slot->node); + memset(slot, 0, sizeof(UnifiedCacheEntry)); + slot->node = node; if (isNew) *isNew = true; - return &cache[cuckooHash1(node)]; + return slot; #endif } const TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findNodeInfoEntry(NodeNum node) const { #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + if (!nodeInfoPayload || node == 0) return nullptr; - uint16_t payloadIndex = findNodeInfoPayloadIndex(node); - if (payloadIndex >= nodeInfoTargetEntries()) - return nullptr; - - return &nodeInfoPayload[payloadIndex]; + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + if (nodeInfoPayload[i].node == node) + return &nodeInfoPayload[i]; + } + return nullptr; #else (void)node; return nullptr; #endif } -uint16_t TrafficManagementModule::encodeNodeInfoTag(uint16_t payloadIndex) const -{ - if (payloadIndex >= nodeInfoTargetEntries()) - return 0; - return static_cast(payloadIndex + 1u); -} - -uint16_t TrafficManagementModule::decodeNodeInfoPayloadIndex(uint16_t tag) const -{ - if (tag == 0 || tag > nodeInfoTargetEntries()) - return UINT16_MAX; - return static_cast(tag - 1u); -} - -uint16_t TrafficManagementModule::getNodeInfoTag(uint16_t slot) const -{ -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoIndex || slot >= nodeInfoIndexSlots()) - return 0; - - const uint32_t bitOffset = static_cast(slot) * nodeInfoTagBits(); - const uint16_t byteOffset = static_cast(bitOffset >> 3); - const uint8_t shift = static_cast(bitOffset & 7u); - uint32_t packed = 0; - - if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) - packed |= static_cast(nodeInfoIndex[byteOffset]); - if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) - packed |= static_cast(nodeInfoIndex[byteOffset + 1u]) << 8; - if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) - packed |= static_cast(nodeInfoIndex[byteOffset + 2u]) << 16; - - return static_cast((packed >> shift) & nodeInfoTagMask()); -#else - (void)slot; - return 0; -#endif -} - -void TrafficManagementModule::setNodeInfoTag(uint16_t slot, uint16_t tag) -{ -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoIndex || slot >= nodeInfoIndexSlots()) - return; - - const uint16_t normalizedTag = static_cast(tag & nodeInfoTagMask()); - const uint32_t bitOffset = static_cast(slot) * nodeInfoTagBits(); - const uint16_t byteOffset = static_cast(bitOffset >> 3); - const uint8_t shift = static_cast(bitOffset & 7u); - uint32_t packed = 0; - - if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) - packed |= static_cast(nodeInfoIndex[byteOffset]); - if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) - packed |= static_cast(nodeInfoIndex[byteOffset + 1u]) << 8; - if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) - packed |= static_cast(nodeInfoIndex[byteOffset + 2u]) << 16; - - const uint32_t mask = static_cast(nodeInfoTagMask()) << shift; - packed = (packed & ~mask) | ((static_cast(normalizedTag) << shift) & mask); - - if (byteOffset < nodeInfoIndexMetadataBudgetBytes()) - nodeInfoIndex[byteOffset] = static_cast(packed & 0xFFu); - if (static_cast(byteOffset + 1u) < nodeInfoIndexMetadataBudgetBytes()) - nodeInfoIndex[byteOffset + 1u] = static_cast((packed >> 8) & 0xFFu); - if (static_cast(byteOffset + 2u) < nodeInfoIndexMetadataBudgetBytes()) - nodeInfoIndex[byteOffset + 2u] = static_cast((packed >> 16) & 0xFFu); -#else - (void)slot; - (void)tag; -#endif -} - -uint16_t TrafficManagementModule::findNodeInfoPayloadIndex(NodeNum node) const -{ -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoPayload || !nodeInfoIndex || node == 0) - return UINT16_MAX; - - const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)}; - - for (uint8_t b = 0; b < 2; b++) { - const uint16_t base = static_cast(buckets[b] * nodeInfoBucketSize()); - for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { - uint16_t tag = getNodeInfoTag(static_cast(base + slot)); - if (tag == 0) - continue; - - uint16_t payloadIndex = decodeNodeInfoPayloadIndex(tag); - if (payloadIndex >= nodeInfoTargetEntries()) - continue; - - if (nodeInfoPayload[payloadIndex].node == node) - return payloadIndex; - } - } - - return UINT16_MAX; -#else - (void)node; - return UINT16_MAX; -#endif -} - -bool TrafficManagementModule::removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex) -{ -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoIndex || node == 0 || payloadIndex >= nodeInfoTargetEntries()) - return false; - - const uint16_t payloadTag = encodeNodeInfoTag(payloadIndex); - if (payloadTag == 0) - return false; - const uint16_t buckets[2] = {nodeInfoHash1(node), nodeInfoHash2(node)}; - - for (uint8_t b = 0; b < 2; b++) { - const uint16_t base = static_cast(buckets[b] * nodeInfoBucketSize()); - for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { - const uint16_t indexSlot = static_cast(base + slot); - if (getNodeInfoTag(indexSlot) == payloadTag) { - setNodeInfoTag(indexSlot, 0); - return true; - } - } - } - - return false; -#else - (void)node; - (void)payloadIndex; - return false; -#endif -} - -uint16_t TrafficManagementModule::allocateNodeInfoPayloadSlot() -{ -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoPayload) - return UINT16_MAX; - - for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) { - uint16_t idx = static_cast((nodeInfoAllocHint + tries) % nodeInfoTargetEntries()); - if (nodeInfoPayload[idx].node == 0) { - nodeInfoAllocHint = static_cast((idx + 1u) % nodeInfoTargetEntries()); - return idx; - } - } -#endif - return UINT16_MAX; -} - -uint16_t TrafficManagementModule::evictNodeInfoPayloadSlot() -{ -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoPayload || !nodeInfoIndex) - return UINT16_MAX; - - for (uint16_t tries = 0; tries < nodeInfoTargetEntries(); tries++) { - uint16_t idx = static_cast(nodeInfoEvictCursor % nodeInfoTargetEntries()); - nodeInfoEvictCursor = static_cast((nodeInfoEvictCursor + 1u) % nodeInfoTargetEntries()); - - NodeNum oldNode = nodeInfoPayload[idx].node; - if (oldNode == 0) - continue; - - removeNodeInfoIndexEntry(oldNode, idx); // best effort; cache tolerates occasional stale miss - nodeInfoPayload[idx].node = 0; - return idx; - } -#endif - return UINT16_MAX; -} - -bool TrafficManagementModule::tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag) -{ -#if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoIndex || !nodeInfoPayload || bucket >= nodeInfoBucketCount() || tag == 0) - return false; - - const uint16_t base = static_cast(bucket * nodeInfoBucketSize()); - for (uint8_t slot = 0; slot < nodeInfoBucketSize(); slot++) { - const uint16_t indexSlot = static_cast(base + slot); - const uint16_t existingTag = getNodeInfoTag(indexSlot); - if (existingTag == 0) { - setNodeInfoTag(indexSlot, tag); - return true; - } - - // Opportunistically reuse stale tags that point at empty/invalid payload slots. - const uint16_t payloadIndex = decodeNodeInfoPayloadIndex(existingTag); - if (payloadIndex >= nodeInfoTargetEntries() || nodeInfoPayload[payloadIndex].node == 0) { - setNodeInfoTag(indexSlot, tag); - return true; - } - } -#else - (void)bucket; - (void)tag; -#endif - return false; -} - +/** + * Find or create a NodeInfo payload entry (linear scan of the flat PSRAM + * array). One pass tracks the match, the first empty slot, and the LRU + * victim by lastObservedMs (wrap-safe age). NodeInfo traffic is low-rate, + * so the O(n) scan is negligible. + */ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot) { @@ -656,88 +430,40 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr *usedEmptySlot = false; #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoPayload || !nodeInfoIndex || node == 0) + if (!nodeInfoPayload || node == 0) return nullptr; - uint16_t existing = findNodeInfoPayloadIndex(node); - if (existing < nodeInfoTargetEntries()) - return &nodeInfoPayload[existing]; + NodeInfoPayloadEntry *empty = nullptr; + NodeInfoPayloadEntry *lru = nullptr; + uint32_t lruAge = 0; + const uint32_t now = clockMs(); - const uint16_t beforeCount = countNodeInfoEntriesLocked(); - - uint16_t payloadIndex = allocateNodeInfoPayloadSlot(); - if (payloadIndex == UINT16_MAX) { - payloadIndex = evictNodeInfoPayloadSlot(); - if (payloadIndex == UINT16_MAX) - return nullptr; - } - - nodeInfoPayload[payloadIndex].node = node; - - // 4-way bucketed cuckoo insertion mirrors Cuckoo Filter practice from - // Fan et al. (CoNEXT 2014): high occupancy with short relocation chains. - uint16_t pending = encodeNodeInfoTag(payloadIndex); - uint16_t h1 = nodeInfoHash1(node); - uint16_t h2 = nodeInfoHash2(node); - - if (!tryInsertNodeInfoEntryInBucket(h1, pending) && !tryInsertNodeInfoEntryInBucket(h2, pending)) { - uint16_t currentBucket = h1; - for (uint8_t kicks = 0; kicks < kMaxCuckooKicks; kicks++) { - const uint16_t base = static_cast(currentBucket * nodeInfoBucketSize()); - const uint16_t kickSlot = static_cast((node + kicks) & (nodeInfoBucketSize() - 1u)); - const uint16_t pos = static_cast(base + kickSlot); - - uint16_t displaced = getNodeInfoTag(pos); - setNodeInfoTag(pos, pending); - pending = displaced; - - uint16_t displacedPayload = decodeNodeInfoPayloadIndex(pending); - if (displacedPayload >= nodeInfoTargetEntries()) { - pending = 0; - break; - } - - NodeNum displacedNode = nodeInfoPayload[displacedPayload].node; - if (displacedNode == 0) { - pending = 0; - break; - } - - uint16_t altH1 = nodeInfoHash1(displacedNode); - uint16_t altH2 = nodeInfoHash2(displacedNode); - uint16_t altBucket = (altH1 == currentBucket) ? altH2 : altH1; - - if (tryInsertNodeInfoEntryInBucket(altBucket, pending)) { - pending = 0; - break; - } - - currentBucket = altBucket; + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + NodeInfoPayloadEntry &e = nodeInfoPayload[i]; + if (e.node == node) + return &e; + if (e.node == 0) { + if (!empty) + empty = &e; + continue; } - - if (pending != 0) { - uint16_t droppedPayload = decodeNodeInfoPayloadIndex(pending); - if (droppedPayload < nodeInfoTargetEntries()) - nodeInfoPayload[droppedPayload].node = 0; - TM_LOG_DEBUG("NodeInfo bucketed cuckoo overflow, dropped payload idx=%u", - static_cast(droppedPayload < nodeInfoTargetEntries() ? droppedPayload : UINT16_MAX)); + if (empty) + continue; // an empty slot beats any victim; stop scoring + const uint32_t age = now - e.lastObservedMs; // unsigned subtraction is wrap-safe + if (!lru || age > lruAge) { + lru = &e; + lruAge = age; } } - uint16_t finalIndex = findNodeInfoPayloadIndex(node); - if (finalIndex >= nodeInfoTargetEntries()) { - // New entry did not survive insertion chain. - if (payloadIndex < nodeInfoTargetEntries() && nodeInfoPayload[payloadIndex].node == node) - nodeInfoPayload[payloadIndex].node = 0; + NodeInfoPayloadEntry *slot = empty ? empty : lru; + if (!slot) return nullptr; - } - - if (usedEmptySlot) { - const uint16_t afterCount = countNodeInfoEntriesLocked(); - *usedEmptySlot = afterCount > beforeCount; - } - - return &nodeInfoPayload[finalIndex]; + memset(slot, 0, sizeof(NodeInfoPayloadEntry)); + slot->node = node; + if (usedEmptySlot) + *usedEmptySlot = (slot == empty); + return slot; #else (void)node; return nullptr; @@ -747,12 +473,12 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const { #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoIndex) + if (!nodeInfoPayload) return 0; uint16_t count = 0; - for (uint16_t i = 0; i < nodeInfoIndexSlots(); i++) { - if (getNodeInfoTag(i) != 0) + for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { + if (nodeInfoPayload[i].node != 0) count++; } return count; @@ -764,7 +490,7 @@ uint16_t TrafficManagementModule::countNodeInfoEntriesLocked() const void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &mp) { #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (!nodeInfoPayload || !nodeInfoIndex || mp.decoded.payload.size == 0) + if (!nodeInfoPayload || mp.decoded.payload.size == 0) return; meshtastic_User user = meshtastic_User_init_zero; @@ -786,7 +512,7 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // richer context than "just the user protobuf" when PSRAM is present. // This path is intentionally independent from NodeInfoModule/NodeDB. entry->user = user; - entry->lastObservedMs = millis(); + entry->lastObservedMs = clockMs(); entry->lastObservedRxTime = mp.rx_time; entry->sourceChannel = mp.channel; entry->hasDecodedBitfield = mp.decoded.has_bitfield; @@ -797,36 +523,111 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m } if (usedEmptySlot) { - TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u target (%u packed slots, %u-bit tags, %u-byte DRAM index)", - static_cast(cachedCount), static_cast(nodeInfoTargetEntries()), - static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoTagBits()), - static_cast(nodeInfoIndexMetadataBudgetBytes())); + TM_LOG_INFO("NodeInfo PSRAM cache entries: %u/%u", static_cast(cachedCount), + static_cast(nodeInfoTargetEntries())); } #else (void)mp; #endif } +// ============================================================================= +// Next-Hop Overflow Cache +// ============================================================================= +// +// A routing hint store. The byte is the last byte of the NodeNum to use as next +// hop to reach `dest`. It is written ONLY from NextHopRouter's ACK-confirmed +// decision (a bidirectionally-verified relay) — never inferred one-way from +// relayed traffic. The TMM cache holds confirmed next-hops that have aged out of +// the hot NodeDB (NodeInfoLite), and NextHopRouter::getNextHop() consults it as a +// fallback after the hot store. + +void TrafficManagementModule::setNextHop(NodeNum dest, uint8_t nextHopByte) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (!cache || dest == 0 || nextHopByte == 0) + return; + + concurrency::LockGuard guard(&cacheLock); + bool isNew = false; + UnifiedCacheEntry *entry = findOrCreateEntry(dest, &isNew); + if (entry) + entry->next_hop = nextHopByte; // last-write-wins; only confirmed bytes reach here +#else + (void)dest; + (void)nextHopByte; +#endif +} + +uint8_t TrafficManagementModule::getNextHopHint(NodeNum dest) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (!cache || dest == 0) + return 0; + + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findEntry(dest); + return entry ? entry->next_hop : 0; +#else + (void)dest; + return 0; +#endif +} + +void TrafficManagementModule::clearNextHop(NodeNum dest) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (!cache || dest == 0) + return; + + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findEntry(dest); + if (entry) + entry->next_hop = 0; // keep the entry (other stats), just drop the routing hint +#else + (void)dest; +#endif +} + +bool TrafficManagementModule::preloadNextHopsFromNodeDB() +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (!cache || !nodeDB) + return false; // prerequisites not ready yet — caller should retry on a later pass + + uint16_t seeded = 0; + concurrency::LockGuard guard(&cacheLock); + const size_t count = nodeDB->getNumMeshNodes(); + for (size_t i = 0; i < count; i++) { + const meshtastic_NodeInfoLite *node = nodeDB->getMeshNodeByIndex(i); + if (!node || node->num == 0 || node->next_hop == 0) + continue; + + bool isNew = false; + UnifiedCacheEntry *entry = findOrCreateEntry(node->num, &isNew); + // Don't clobber a freshly-learned confirmed hop with a (possibly stale) persisted one. + if (entry && entry->next_hop == 0) { + entry->next_hop = node->next_hop; + seeded++; + } + } + + TM_LOG_INFO("Preloaded %u next-hop hints from NodeDB", static_cast(seeded)); + return true; +#else + return true; // nothing to preload on a cache-less build; don't keep retrying +#endif +} + // ============================================================================= // Epoch Management // ============================================================================= -/** - * Reset the timestamp epoch when relative offsets approach overflow. - * - * Called when epoch age exceeds ~19 hours (approaching 8-bit minute overflow). - * Invalidates all cached per-node traffic state. - */ -void TrafficManagementModule::resetEpoch(uint32_t nowMs) +void TrafficManagementModule::flushCache() { #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - TM_LOG_DEBUG("Resetting cache epoch"); - cacheEpochMs = nowMs; - - // Full flush avoids stale dedup identity/counters surviving epoch rollover. + TM_LOG_DEBUG("Flushing cache"); memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); -#else - (void)nowMs; #endif } @@ -870,7 +671,12 @@ uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncate uint8_t latBits = (static_cast(lat_truncated) >> shift) & ((1u << bitsToTake) - 1); uint8_t lonBits = (static_cast(lon_truncated) >> shift) & ((1u << bitsToTake) - 1); - return static_cast((latBits << 4) | lonBits); + const uint8_t fp = static_cast((latBits << 4) | lonBits); + // 0 is the "no position seen" sentinel for pos_fingerprint, so a real position that happens to + // hash to 0 must not collide with it (otherwise its duplicates would never dedup). Remap 0 -> 0xFF, + // mirroring NodeDB::getLastByteOfNodeNum()'s 0 -> 0xFF idiom. Cost: the 0x00 bucket merges into + // 0xFF (one extra collision in 256 — negligible; the fingerprint already collides every 16 cells). + return fp ? fp : 0xFF; } // ============================================================================= @@ -885,7 +691,7 @@ uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncate // force hop_limit=0 on the rebroadcast copy, allowing one final relay hop. ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPacket &mp) { - if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + if (!moduleConfig.has_traffic_management) return ProcessMessage::CONTINUE; ignoreRequest = false; @@ -895,7 +701,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack incrementStat(&stats.packets_inspected); const auto &cfg = moduleConfig.traffic_management; - const uint32_t nowMs = millis(); + const uint32_t nowMs = TrafficManagementModule::clockMs(); // ------------------------------------------------------------------------- // Undecoded Packet Handling @@ -904,7 +710,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // a misbehaving node. Track and optionally drop repeat offenders. if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) { - if (cfg.drop_unknown_enabled && cfg.unknown_packet_threshold > 0) { + if (cfg.unknown_packet_threshold > 0) { if (shouldDropUnknown(&mp, nowMs)) { logAction("drop", &mp, "unknown"); incrementStat(&stats.unknown_packet_drops); @@ -915,9 +721,12 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack return ProcessMessage::CONTINUE; } - // Learn NodeInfo payloads into the dedicated PSRAM cache. - if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) + // Learn NodeInfo payloads into the dedicated PSRAM cache, and refresh the tier-3 + // role cache for any node we already track (keeps the dedup role exception current). + if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) { cacheNodeInfoPacket(mp); + updateCachedRoleFromNodeInfo(mp); + } // ------------------------------------------------------------------------- // NodeInfo Direct Response @@ -927,8 +736,8 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // STOP prevents the request from being rebroadcast toward the target node, // and our cached response is sent back to the requestor with hop_limit=0. - if (cfg.nodeinfo_direct_response && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && - !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { + if (cfg.nodeinfo_direct_response_max_hops > 0 && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && + mp.decoded.want_response && !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { if (shouldRespondToNodeInfo(&mp, true)) { meshtastic_User requester = meshtastic_User_init_zero; if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { @@ -949,7 +758,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // GPS jitter within the configured precision. if (!isFromUs(&mp) && !isToUs(&mp)) { - if (cfg.position_dedup_enabled && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) { + if (channels.isWellKnownChannel(mp.channel) && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) { meshtastic_Position pos = meshtastic_Position_init_zero; if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &pos)) { if (shouldDropPosition(&mp, &pos, nowMs)) { @@ -967,7 +776,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // Throttle nodes sending too many packets within a time window. // Excludes routing and admin packets which are essential for mesh operation. - if (cfg.rate_limit_enabled && cfg.rate_limit_window_secs > 0 && cfg.rate_limit_max_packets > 0) { + if (cfg.rate_limit_window_secs > 0 && cfg.rate_limit_max_packets > 0) { if (mp.decoded.portnum != meshtastic_PortNum_ROUTING_APP && mp.decoded.portnum != meshtastic_PortNum_ADMIN_APP) { if (isRateLimited(mp.from, nowMs)) { logAction("drop", &mp, "rate-limit"); @@ -984,7 +793,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) { - if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + if (!moduleConfig.has_traffic_management) return; if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) @@ -993,40 +802,45 @@ void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) if (isFromUs(&mp)) return; - // ------------------------------------------------------------------------- - // Relayed Broadcast Hop Exhaustion - // ------------------------------------------------------------------------- - // For relayed telemetry or position broadcasts from other nodes, optionally - // set hop_limit=0 so they don't propagate further through the mesh. + // exhaust_hop_telemetry / exhaust_hop_position / router_preserve_hops: + // not suitable right now — the right heuristics for when to exhaust or + // preserve hops need more field data before we expose them as config knobs. + // exhaustRequested stays false; perhapsRebroadcast() behaves normally. - const auto &cfg = moduleConfig.traffic_management; - const bool isTelemetry = mp.decoded.portnum == meshtastic_PortNum_TELEMETRY_APP; const bool isPosition = mp.decoded.portnum == meshtastic_PortNum_POSITION_APP; - // Only exhaust telemetry hops when channel is actually congested, mirroring the same - // airtime checks that gate self-generated telemetry in the telemetry modules. - const bool channelBusy = airTime && (!airTime->isTxAllowedChannelUtil(true) || !airTime->isTxAllowedAirUtil()); - const bool shouldExhaust = - ((channelBusy && isTelemetry && cfg.exhaust_hop_telemetry) || (isPosition && cfg.exhaust_hop_position)); - if (!shouldExhaust || !isBroadcast(mp.to)) - return; - - if (mp.hop_limit > 0) { - const char *reason = isTelemetry ? "exhaust-hop-telemetry" : "exhaust-hop-position"; - logAction("exhaust", &mp, reason); - // Adjust hop_start so downstream nodes compute correct hopsAway (hop_start - hop_limit). - // Without this, hop_limit=0 with original hop_start would show inflated hopsAway. - mp.hop_start = mp.hop_start - mp.hop_limit + 1; - mp.hop_limit = 0; - // Signal perhapsRebroadcast() to allow one final relay with hop_limit=0. - // Without this flag, perhapsRebroadcast() would skip the packet since hop_limit==0. - // The packet-scoped flag is checked in NextHopRouter::perhapsRebroadcast() - // and forces tosend->hop_limit=0, ensuring no further propagation beyond the - // next node. - exhaustRequested = true; - exhaustRequestedFrom = getFrom(&mp); - exhaustRequestedId = mp.id; - incrementStat(&stats.hop_exhausted_packets); + // ------------------------------------------------------------------------- + // Relayed Position Precision Clamp + // ------------------------------------------------------------------------- + // Clamp relayed position broadcasts to the channel's configured precision + // ceiling. Guards against forwarding more-precise coordinates than the + // channel is intended to carry (e.g. a LongFast channel set to 13-bit / + // ~1.5 km). chanPrec==0 means position sharing is disabled on the channel; + // skip — not our job to zero positions on relay. + // Ham mode (owner.is_licensed) is exempt. Lost-and-found is NOT exempt — its relayed + // positions get the same precision clamp as any node. + // Compile USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS to extend to private channels. + if (!owner.is_licensed && isPosition && isBroadcast(mp.to)) { +#ifdef USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS + const bool shouldClamp = true; +#else + const bool shouldClamp = channels.isWellKnownChannel(mp.channel); +#endif + if (shouldClamp) { + const uint32_t chanPrec = getPositionPrecisionForChannel(mp.channel); + if (chanPrec > 0) { + meshtastic_Position pos = meshtastic_Position_init_default; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &pos)) { + const uint32_t packetPrec = pos.precision_bits > 0 ? pos.precision_bits : 32u; + if (packetPrec > chanPrec) { + applyPositionPrecision(pos, chanPrec); + mp.decoded.payload.size = pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes), + &meshtastic_Position_msg, &pos); + logAction("clamp", &mp, "precision"); + } + } + } + } } } @@ -1036,45 +850,58 @@ void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) int32_t TrafficManagementModule::runOnce() { - if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + if (!moduleConfig.has_traffic_management) return INT32_MAX; #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - const uint32_t nowMs = millis(); + const uint32_t nowMs = TrafficManagementModule::clockMs(); - // Check if epoch reset needed (~3.5 hours approaching 8-bit minute overflow) - if (needsEpochReset(nowMs)) { - concurrency::LockGuard guard(&cacheLock); - resetEpoch(nowMs); - return kMaintenanceIntervalMs; - } + // Warm-start the next-hop cache from persisted NodeInfoLite hints once nodeDB + // is populated. Done here (not in the constructor) so nodeDB has finished + // loading. Takes its own lock, so call before acquiring the sweep guard below. + // Only latch the one-shot guard once the preload actually ran; if nodeDB wasn't + // ready yet, retry on the next maintenance pass instead of skipping it forever. + if (!nextHopPreloaded && preloadNextHopsFromNodeDB()) + nextHopPreloaded = true; - // Calculate TTLs for cache expiration + // Free-running tick counters (no epoch needed). + // TTL expressed in ticks: + // pos: 4× position_min_interval_secs (clamped to 255 ticks @ 6 min/tick) + // rate: 2× rate_limit_window_secs (clamped to 15 ticks @ 5 min/tick; only relevant when rate limits are configured) + // unknown: fixed 12 ticks @ 1 min/tick (only relevant when unknown_packet_threshold > 0) const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); - const uint32_t positionTtlMs = positionIntervalMs * 4; + const uint8_t posTtlTicks = + static_cast(std::min(static_cast(255), (positionIntervalMs * 4) / kPosTimeTickMs)); - const uint32_t rateIntervalMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); - const uint32_t rateTtlMs = (rateIntervalMs > 0) ? rateIntervalMs * 2 : (10 * 60 * 1000UL); + const uint32_t rateWindowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + const uint8_t rateTtlTicks = static_cast( + std::min(static_cast(15), (rateWindowMs > 0 ? rateWindowMs * 2 : 24 * kRateTimeTickMs) / kRateTimeTickMs)); - const uint32_t unknownTtlMs = kUnknownResetMs * 5; + // unknown: fixed 12-tick TTL (12 min — 4 ticks past the 5-min default window) + const uint8_t unknownTtlTicks = 12; + + const uint8_t nowPosTick = currentPosTick(); + const uint8_t nowRateTick = currentRateTick(); + const uint8_t nowUnknownTick = currentUnknownTick(); // Sweep cache and clear expired entries uint16_t activeEntries = 0; uint16_t expiredEntries = 0; - const uint32_t sweepStartMs = millis(); + const uint32_t sweepStartMs = TrafficManagementModule::clockMs(); + const auto &cfg = moduleConfig.traffic_management; concurrency::LockGuard guard(&cacheLock); + for (uint16_t i = 0; i < cacheSize(); i++) { if (cache[i].node == 0) continue; bool anyValid = false; - // Check and clear expired position data - if (cache[i].pos_time != 0) { - uint32_t posTimeMs = fromRelativePosTime(cache[i].pos_time); - if (!isWithinWindow(nowMs, posTimeMs, positionTtlMs)) { + // Check and clear expired position data (presence: pos_fingerprint != 0) + if (cache[i].pos_fingerprint != 0) { + if (static_cast(nowPosTick - cache[i].pos_time) >= posTtlTicks) { cache[i].pos_fingerprint = 0; cache[i].pos_time = 0; } else { @@ -1082,28 +909,35 @@ int32_t TrafficManagementModule::runOnce() } } - // Check and clear expired rate limit data - if (cache[i].rate_time != 0) { - uint32_t rateTimeMs = fromRelativeRateTime(cache[i].rate_time); - if (!isWithinWindow(nowMs, rateTimeMs, rateTtlMs)) { - cache[i].rate_count = 0; - cache[i].rate_time = 0; + // Check and clear expired rate limit data (presence: getRateCount() != 0) + if (cache[i].getRateCount() != 0) { + if ((static_cast(nowRateTick - cache[i].getRateTime()) & 0x0F) >= rateTtlTicks) { + cache[i].setRateCount(0); + cache[i].setRateTime(0); } else { anyValid = true; } } - // Check and clear expired unknown tracking data - if (cache[i].unknown_time != 0) { - uint32_t unknownTimeMs = fromRelativeUnknownTime(cache[i].unknown_time); - if (!isWithinWindow(nowMs, unknownTimeMs, unknownTtlMs)) { - cache[i].unknown_count = 0; - cache[i].unknown_time = 0; + // Check and clear expired unknown tracking data (presence: getUnknownCount() != 0) + if (cache[i].getUnknownCount() != 0) { + if ((static_cast(nowUnknownTick - cache[i].getUnknownTime()) & 0x0F) >= unknownTtlTicks) { + cache[i].setUnknownCount(0); + cache[i].setUnknownTime(0); } else { anyValid = true; } } + // Two fields have no TTL of their own and pin the slot, so they outlive the + // dedup/rate/unknown state: + // - a confirmed next-hop hint (the routing overflow store), and + // - a cached special (non-CLIENT) role, so a tracker / lost-and-found / router + // keeps its dedup-window exception across quiet periods rather than reverting + // to CLIENT the moment its timed state expires. + if (cache[i].next_hop != 0 || cache[i].getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT) + anyValid = true; + // If all data expired, free the slot entirely if (!anyValid) { memset(&cache[i], 0, sizeof(UnifiedCacheEntry)); @@ -1115,14 +949,12 @@ int32_t TrafficManagementModule::runOnce() TM_LOG_DEBUG("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, static_cast(activeEntries), static_cast(cacheSize()), - static_cast(millis() - sweepStartMs)); + static_cast(TrafficManagementModule::clockMs() - sweepStartMs)); #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) - if (nodeInfoPayload && nodeInfoIndex) { - TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u target (%u packed slots, %u buckets, %u-bit tags, %u-byte index)", - static_cast(countNodeInfoEntriesLocked()), static_cast(nodeInfoTargetEntries()), - static_cast(nodeInfoIndexSlots()), static_cast(nodeInfoBucketCount()), - static_cast(nodeInfoTagBits()), static_cast(nodeInfoIndexMetadataBudgetBytes())); + if (nodeInfoPayload) { + TM_LOG_DEBUG("NodeInfo PSRAM cache: %u/%u", static_cast(countNodeInfoEntriesLocked()), + static_cast(nodeInfoTargetEntries())); } #endif @@ -1146,15 +978,21 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, if (!pos->has_latitude_i || !pos->has_longitude_i) return false; - uint8_t precision = Default::getConfiguredOrDefault(moduleConfig.traffic_management.position_precision_bits, - default_traffic_mgmt_position_precision_bits); - precision = sanitizePositionPrecision(precision); + // Precision is driven by the channel's own position_precision ceiling — the same + // grid the channel uses for broadcast. Falls back to the firmware default (19-bit, + // ~90m cells) when the channel has no precision configured (chanPrec == 0). + const uint32_t chanPrec = getPositionPrecisionForChannel(p->channel); + uint8_t precision = sanitizePositionPrecision( + chanPrec > 0 ? static_cast(chanPrec) : static_cast(default_traffic_mgmt_position_precision_bits)); - const int32_t lat_truncated = truncateLatLon(pos->latitude_i, precision); - const int32_t lon_truncated = truncateLatLon(pos->longitude_i, precision); + const int32_t lat_truncated = truncateCoordinate(pos->latitude_i, precision); + const int32_t lon_truncated = truncateCoordinate(pos->longitude_i, precision); const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); - const uint32_t minIntervalMs = secsToMs(Default::getConfiguredOrDefault( - moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); + // Drop gate uses the RAW configured interval: 0 means "dedup disabled" (the + // contract documented below). The 12h default is only for resolution/TTL + // sizing (constructor / runOnce), not for deciding whether to drop — feeding + // the default here would silently turn the 0-disables-dedup contract off. + uint32_t minIntervalMs = secsToMs(moduleConfig.traffic_management.position_min_interval_secs); bool isNew = false; concurrency::LockGuard guard(&cacheLock); @@ -1162,19 +1000,46 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, if (!entry) return false; - // Compare fingerprint and check time window - // When minIntervalMs == 0, deduplication is disabled (withinInterval = false means never drop) - const bool hasPositionState = !isNew && entry->pos_time != 0; + // Role exceptions keyed on the originating node's advertised role, resolved across + // all three tiers (hot store → warm store → TMM live cache). The position path is + // the one place that needs sender-role, and it also keeps tier 3 warm so the + // exception survives the node aging out of both NodeDB stores — important in the + // common dedup-only config, where isRateLimited()'s role write never runs. + const meshtastic_Config_DeviceConfig_Role role = resolveSenderRole(p->from, entry, isNew); + if (role == meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND) { + // Lost-and-found may refresh a duplicate position at most every ~15 min (cap, never + // lengthens; quantised to ~2 dedup ticks). Only when dedup is active — never tighten + // past an operator who disabled it (0). + const uint32_t lostFoundCapMs = secsToMs(default_traffic_mgmt_lost_and_found_position_min_interval_secs); + if (minIntervalMs != 0 && minIntervalMs > lostFoundCapMs) + minIntervalMs = lostFoundCapMs; + } else if (role == meshtastic_Config_DeviceConfig_Role_TRACKER || role == meshtastic_Config_DeviceConfig_Role_TAK_TRACKER) { + // Trackers may refresh a duplicate position as often as hourly (cap, never lengthens). + const uint32_t trackerCapMs = secsToMs(default_traffic_mgmt_tracker_position_min_interval_secs); + if (minIntervalMs > trackerCapMs) + minIntervalMs = trackerCapMs; + } + + // Compare fingerprint and check time window. + // When minIntervalMs == 0, deduplication is disabled (withinInterval = false means never drop). + // Presence: pos_fingerprint != 0; computePositionFingerprint() remaps 0 -> 0xFF so zero means unseen. + const bool hasPositionState = !isNew && entry->pos_fingerprint != 0; const bool samePosition = hasPositionState && entry->pos_fingerprint == fingerprint; + const uint8_t nowPosTick = currentPosTick(); + // Clamp to [1, 255]: intervals shorter than one tick still dedup within the same tick. + const uint8_t windowTicks = + (minIntervalMs == 0) ? 0 + : static_cast(std::min(static_cast(UINT8_MAX), + std::max(static_cast(1), minIntervalMs / kPosTimeTickMs))); const bool withinInterval = - hasPositionState && (minIntervalMs != 0) && isWithinWindow(nowMs, fromRelativePosTime(entry->pos_time), minIntervalMs); + hasPositionState && (windowTicks != 0) && (static_cast(nowPosTick - entry->pos_time) < windowTicks); TM_LOG_DEBUG("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, entry->pos_fingerprint, samePosition, withinInterval, isNew); - // Update cache entry + // Update cache entry (raw tick; 0 is a valid tick value) entry->pos_fingerprint = fingerprint; - entry->pos_time = toRelativePosTime(nowMs); + entry->pos_time = nowPosTick; // Drop only if same position AND within the minimum interval return samePosition && withinInterval; @@ -1218,7 +1083,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke // If the PSRAM cache exists but misses, we intentionally do not fall back // to the node-wide table. This keeps the PSRAM direct-reply path separate // from NodeInfoModule/NodeDB behavior when PSRAM is available. - if (nodeInfoPayload && nodeInfoIndex) { + if (nodeInfoPayload) { TM_LOG_DEBUG("NodeInfo PSRAM cache miss for node=0x%08x", p->to); return false; } @@ -1263,7 +1128,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke reply->decoded.bitfield |= BITFIELD_OK_TO_MQTT_MASK; if (hasCachedUser && cachedLastObservedMs != 0) { - uint32_t ageMs = millis() - cachedLastObservedMs; + uint32_t ageMs = clockMs() - cachedLastObservedMs; TM_LOG_DEBUG("NodeInfo PSRAM hit node=0x%08x age=%lu ms src_ch=%u req_ch=%u rx_time=%lu", p->to, static_cast(ageMs), static_cast(cachedSourceChannel), static_cast(p->channel), static_cast(cachedLastObservedRxTime)); @@ -1328,25 +1193,32 @@ bool TrafficManagementModule::isRateLimited(NodeNum from, uint32_t nowMs) if (!entry) return false; - // Check if window has expired - if (isNew || !isWithinWindow(nowMs, fromRelativeRateTime(entry->rate_time), windowMs)) { - entry->rate_time = toRelativeRateTime(nowMs); - entry->rate_count = 1; + // Window ticks: clamp to [1,15] so zero windowMs (config error) opens a new window. + const uint8_t windowTicks = static_cast(std::min(static_cast(15), windowMs / kRateTimeTickMs)); + const uint8_t nowRateTick = currentRateTick(); + const bool windowExpired = + isNew || entry->getRateCount() == 0 || + ((static_cast(nowRateTick - entry->getRateTime()) & 0x0F) >= std::max(static_cast(1), windowTicks)); + if (windowExpired) { + entry->setRateTime(nowRateTick); + entry->setRateCount(1); return false; } - // Increment counter (saturates at 255) - saturatingIncrement(entry->rate_count); + // Increment counter, saturating at 63 (6-bit field max). + const uint8_t cur = entry->getRateCount(); + if (cur < 0x3F) + entry->setRateCount(static_cast(cur + 1)); - // Check against threshold (uint8_t max is 255, but config is uint32_t) + // Threshold capped at 60 so a saturated reading (63) always exceeds it. uint32_t threshold = moduleConfig.traffic_management.rate_limit_max_packets; - if (threshold > 255) - threshold = 255; + if (threshold > 60) + threshold = 60; - bool limited = entry->rate_count > threshold; - if (limited || entry->rate_count == threshold) { - TM_LOG_DEBUG("Rate limit 0x%08x: count=%u threshold=%u -> %s", from, entry->rate_count, threshold, - limited ? "DROP" : "at-limit"); + const uint8_t count = entry->getRateCount(); + bool limited = count > threshold; + if (limited || count == threshold) { + TM_LOG_DEBUG("Rate limit 0x%08x: count=%u threshold=%u -> %s", from, count, threshold, limited ? "DROP" : "at-limit"); } return limited; #endif @@ -1359,12 +1231,11 @@ bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, (void)nowMs; return false; #else - if (!moduleConfig.traffic_management.drop_unknown_enabled || moduleConfig.traffic_management.unknown_packet_threshold == 0) + if (moduleConfig.traffic_management.unknown_packet_threshold == 0) return false; - uint32_t windowMs = kUnknownResetMs; - if (moduleConfig.traffic_management.rate_limit_window_secs > 0) - windowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + // Fixed 5-tick (5 min) unknown window; capped at 12 ticks (12 min max). + static constexpr uint8_t kUnknownWindowTicks = 5; bool isNew = false; concurrency::LockGuard guard(&cacheLock); @@ -1372,23 +1243,31 @@ bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, if (!entry) return false; - // Check if window has expired - if (isNew || !isWithinWindow(nowMs, fromRelativeUnknownTime(entry->unknown_time), windowMs)) { - entry->unknown_time = toRelativeUnknownTime(nowMs); - entry->unknown_count = 0; + // Check if window has expired (presence: getUnknownCount() != 0) + const uint8_t nowUnknownTick = currentUnknownTick(); + const bool windowExpired = isNew || entry->getUnknownCount() == 0 || + ((static_cast(nowUnknownTick - entry->getUnknownTime()) & 0x0F) >= kUnknownWindowTicks); + if (windowExpired) { + entry->setUnknownTime(nowUnknownTick); + entry->setUnknownCount(0); } - // Increment counter (saturates at 255) - saturatingIncrement(entry->unknown_count); + // Increment counter, saturating at 63 (6-bit field max). With threshold + // capped at 60, a saturated reading always exceeds the limit — no special + // already-saturated edge case needed. + const uint8_t cur = entry->getUnknownCount(); + if (cur < 0x3F) + entry->setUnknownCount(static_cast(cur + 1)); - // Check against threshold + // Threshold capped at 60 so a saturated reading (63) always exceeds it. uint32_t threshold = moduleConfig.traffic_management.unknown_packet_threshold; - if (threshold > 255) - threshold = 255; + if (threshold > 60) + threshold = 60; - bool drop = entry->unknown_count > threshold; - if (drop || entry->unknown_count == threshold) { - TM_LOG_DEBUG("Unknown packets 0x%08x: count=%u threshold=%u -> %s", p->from, entry->unknown_count, threshold, + const uint8_t count = entry->getUnknownCount(); + bool drop = count > threshold; + if (drop || count == threshold) { + TM_LOG_DEBUG("Unknown packets 0x%08x: count=%u threshold=%u -> %s", p->from, count, threshold, drop ? "DROP" : "at-limit"); } return drop; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index fe3483a8e..231849e8f 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -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((rate_count & 0xC0) | (c & 0x3F)); } + uint8_t getUnknownCount() const { return unknown_count & 0x3F; } + void setUnknownCount(uint8_t c) { unknown_count = static_cast((unknown_count & 0xC0) | (c & 0x3F)); } + uint8_t getCachedRole() const { return static_cast(((rate_count >> 6) << 2) | (unknown_count >> 6)); } + void setCachedRole(uint8_t role) + { + rate_count = static_cast((rate_count & 0x3F) | ((role >> 2) << 6)); + unknown_count = static_cast((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((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); } + void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast((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((1u << kNodeInfoTagBits) - 1u); - static constexpr uint16_t kNodeInfoIndexSlotsRaw = - static_cast((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits); - static constexpr uint16_t kNodeInfoIndexSlots = - static_cast(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize)); - static constexpr uint16_t kNodeInfoTargetEntries = - static_cast((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(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(ticks); - } - uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const - { - return cacheEpochMs + (static_cast(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(clockMs() / kPosTimeTickMs); } + static uint8_t currentRateTick() { return static_cast((clockMs() / kRateTimeTickMs) & 0x0F); } + static uint8_t currentUnknownTick() { return static_cast((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(1 + log2Floor(static_cast(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(1 + log2Floor(static_cast(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(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; diff --git a/src/nimble/NimbleBluetooth.cpp b/src/nimble/NimbleBluetooth.cpp index 5722a6691..3ea0aab31 100644 --- a/src/nimble/NimbleBluetooth.cpp +++ b/src/nimble/NimbleBluetooth.cpp @@ -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 +#include +#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 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 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 diff --git a/src/platform/esp32/main-esp32.cpp b/src/platform/esp32/main-esp32.cpp index 69b69ff68..bcd618c7e 100644 --- a/src/platform/esp32/main-esp32.cpp +++ b/src/platform/esp32/main-esp32.cpp @@ -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(); } diff --git a/src/platform/nrf52/architecture.h b/src/platform/nrf52/architecture.h index 53ca80969..0c04bbbab 100644 --- a/src/platform/nrf52/architecture.h +++ b/src/platform/nrf52/architecture.h @@ -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 diff --git a/src/platform/nrf52/nrf52840_s140_v6.ld b/src/platform/nrf52/nrf52840_s140_v6.ld new file mode 100644 index 000000000..c8dac4e55 --- /dev/null +++ b/src/platform/nrf52/nrf52840_s140_v6.ld @@ -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" diff --git a/src/platform/nrf52/nrf52840_s140_v7.ld b/src/platform/nrf52/nrf52840_s140_v7.ld index 6aaeb4034..4546b4a7a 100644 --- a/src/platform/nrf52/nrf52840_s140_v7.ld +++ b/src/platform/nrf52/nrf52840_s140_v7.ld @@ -5,7 +5,12 @@ GROUP(-lgcc -lc -lnosys) MEMORY { - FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xED000 - 0x27000 + /* App region ends at 0xEA000, not 0xED000: the 12 KB warm-node-store + * record-ring (3 x 4 KB pages, src/mesh/WarmNodeStore.h) occupies + * 0xEA000-0xED000, directly below the stock LittleFS partition. Boards on + * the framework-default linker script are covered by the post-link guard + * in extra_scripts/nrf52_warm_region.py instead. */ + FLASH (rx) : ORIGIN = 0x27000, LENGTH = 0xEA000 - 0x27000 /* SRAM required by Softdevice depend on * - Attribute Table Size (Number of Services and Characteristics) diff --git a/src/platform/rp2xx0/main-rp2xx0.cpp b/src/platform/rp2xx0/main-rp2xx0.cpp index e59b0a9cd..ee50f4fb1 100644 --- a/src/platform/rp2xx0/main-rp2xx0.cpp +++ b/src/platform/rp2xx0/main-rp2xx0.cpp @@ -3,6 +3,7 @@ #include "hardware/xosc.h" #include #include +#include #include #include @@ -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); diff --git a/test/test_nexthop_routing/test_main.cpp b/test/test_nexthop_routing/test_main.cpp new file mode 100644 index 000000000..e766cbdee --- /dev/null +++ b/test/test_nexthop_routing/test_main.cpp @@ -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 + +#include "configuration.h" +#include "gps/RTC.h" +#include "mesh/NextHopRouter.h" +#include "mesh/NodeDB.h" +#include +#include +#include + +#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 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() {} diff --git a/test/test_nodedb_blocked/test_main.cpp b/test/test_nodedb_blocked/test_main.cpp new file mode 100644 index 000000000..715c01323 --- /dev/null +++ b/test/test_nodedb_blocked/test_main.cpp @@ -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 + +#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 + +// 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(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 diff --git a/test/test_position_module/test_main.cpp b/test/test_position_module/test_main.cpp new file mode 100644 index 000000000..778779af6 --- /dev/null +++ b/test/test_position_module/test_main.cpp @@ -0,0 +1,81 @@ +#include "TestUtil.h" +#include "modules/PositionModule.h" +#include + +// 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() {} +} diff --git a/test/test_radio/test_main.cpp b/test/test_radio/test_main.cpp index 104e6b36b..892c7f695 100644 --- a/test/test_radio/test_main.cpp +++ b/test/test_radio/test_main.cpp @@ -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()); } diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 7631999b3..1844653ab 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -1,3 +1,4 @@ +#include "MeshTypes.h" // Include BEFORE TestUtil.h — provides HAS_TRAFFIC_MANAGEMENT (via mesh-pb-constants.h) #include "TestUtil.h" #include #include @@ -10,7 +11,9 @@ #if HAS_TRAFFIC_MANAGEMENT +#include "airtime.h" #include "mesh/CryptoEngine.h" +#include "mesh/Default.h" #include "mesh/MeshService.h" #include "mesh/NodeDB.h" #include "mesh/Router.h" @@ -28,6 +31,25 @@ constexpr NodeNum kLocalNode = 0x11111111; constexpr NodeNum kRemoteNode = 0x22222222; constexpr NodeNum kTargetNode = 0x33333333; +// Telemetry hop exhaustion is gated on channel congestion (alterReceived checks +// airTime->isTxAllowedChannelUtil/isTxAllowedAirUtil). Installs a global +// airTime reporting 100% channel utilization for the enclosing scope. +class ScopedBusyAirTime +{ + public: + ScopedBusyAirTime() : previous(airTime) + { + for (uint32_t i = 0; i < CHANNEL_UTILIZATION_PERIODS; i++) + busy.channelUtilization[i] = 10000; // 10 s of airtime per 10 s period + airTime = &busy; + } + ~ScopedBusyAirTime() { airTime = previous; } + + private: + AirTime busy; + AirTime *previous; +}; + class MockNodeDB : public NodeDB { public: @@ -54,6 +76,39 @@ class MockNodeDB : public NodeDB cachedNode.bitfield |= NODEINFO_BITFIELD_HAS_USER_MASK; } + // Role the TMM should see for the cached node (sender-role-aware throttles). + void setCachedNodeRole(meshtastic_Config_DeviceConfig_Role role) { cachedNode.role = role; } + + // Direct mutable access to the cached node for fine-grained bitfield manipulation in tests. + meshtastic_NodeInfoLite &cachedNodeForTest() + { + hasCachedNode = true; + return cachedNode; + } + + // Seed a node into the hot-store buffer at index 1 (index 0 is reserved for + // "self"). Respects the fixed-buffer invariant: `meshNodes` is a buffer of + // MAX_NUM_NODES slots with `numMeshNodes` as the logical count — we grow the + // buffer if needed and bump the count, never clear()/push_back() (which would + // shrink it and break NodeDB::resetNodes()'s begin()+1..end() fill). + void setHotNode(NodeNum n, uint8_t nextHop) + { + if (meshNodes->size() < 2) + meshNodes->resize(2); + (*meshNodes)[1] = meshtastic_NodeInfoLite_init_zero; + (*meshNodes)[1].num = n; + (*meshNodes)[1].next_hop = nextHop; + numMeshNodes = 2; + } + + // Evict everything but "self" — simulates the hot DB rolling over. Logical + // count only; the buffer is left intact so the invariant holds. + void rollHotStore() + { + numMeshNodes = 1; + clearCachedNode(); + } + private: bool hasCachedNode = false; NodeNum cachedNodeNum = 0; @@ -102,8 +157,9 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule { public: using TrafficManagementModule::alterReceived; + using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; - using TrafficManagementModule::resetEpoch; + using TrafficManagementModule::peekCachedRole; using TrafficManagementModule::runOnce; bool ignoreRequestFlag() const { return ignoreRequest; } @@ -116,11 +172,13 @@ static void resetTrafficConfig() moduleConfig = meshtastic_LocalModuleConfig_init_zero; moduleConfig.has_traffic_management = true; moduleConfig.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; - moduleConfig.traffic_management.enabled = true; config = meshtastic_LocalConfig_init_zero; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + channelFile = meshtastic_ChannelFile_init_zero; + owner.is_licensed = false; + myNodeInfo.my_node_num = kLocalNode; router = nullptr; @@ -129,6 +187,10 @@ static void resetTrafficConfig() mockNodeDB->resetNodes(); mockNodeDB->clearCachedNode(); nodeDB = mockNodeDB; + + // Virtual clock base (1 h in, so tick subtraction never underflows). Tests advance time by + // bumping TrafficManagementModule::s_testNowMs instead of sleeping real seconds across a tick. + TrafficManagementModule::s_testNowMs = 3600000; } static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to = NODENUM_BROADCAST) @@ -175,6 +237,52 @@ static meshtastic_MeshPacket makePositionPacket(NodeNum from, int32_t lat, int32 return packet; } +static meshtastic_MeshPacket makePositionPacketWithPrecision(NodeNum from, int32_t lat, int32_t lon, uint32_t precisionBits) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_POSITION_APP, from, NODENUM_BROADCAST); + meshtastic_Position pos = meshtastic_Position_init_zero; + pos.has_latitude_i = true; + pos.has_longitude_i = true; + pos.latitude_i = lat; + pos.longitude_i = lon; + pos.precision_bits = precisionBits; + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_Position_msg, &pos); + return packet; +} + +static bool decodePositionPayload(const meshtastic_MeshPacket &packet, meshtastic_Position &out) +{ + out = meshtastic_Position_init_zero; + return pb_decode_from_bytes(packet.decoded.payload.bytes, packet.decoded.payload.size, &meshtastic_Position_msg, &out); +} + +// Primary channel with a well-known single-byte PSK and the (empty -> preset) +// default name, so Channels::isWellKnownChannel(0) is true. +static void installWellKnownPrimaryChannel() +{ + channelFile = meshtastic_ChannelFile_init_zero; + channelFile.channels_count = 1; + channelFile.channels[0].index = 0; + channelFile.channels[0].has_settings = true; + channelFile.channels[0].role = meshtastic_Channel_Role_PRIMARY; + channelFile.channels[0].settings.psk.size = 1; + channelFile.channels[0].settings.psk.bytes[0] = 1; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; +} + +// Install the well-known primary channel AND set a specific position_precision so +// shouldDropPosition() uses that precision ceiling rather than the default fallback. +// precision=0 means "no channel ceiling" and falls back to the firmware default (19 bits). +static void installWellKnownPrimaryChannelWithPrecision(uint32_t precision) +{ + installWellKnownPrimaryChannel(); + channelFile.channels[0].settings.has_module_settings = true; + channelFile.channels[0].settings.module_settings.position_precision = precision; +} + static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName) { meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); @@ -189,6 +297,21 @@ static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longNa return packet; } +static meshtastic_MeshPacket makeNodeInfoPacketWithRole(NodeNum from, meshtastic_Config_DeviceConfig_Role role) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); + + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", from); + strncpy(user.long_name, "rolenode", sizeof(user.long_name) - 1); + strncpy(user.short_name, "rn", sizeof(user.short_name) - 1); + user.role = role; + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + return packet; +} + /** * Verify the module is a no-op when traffic management is disabled. * Important so config toggles cannot accidentally change routing behavior. @@ -214,7 +337,6 @@ static void test_tm_moduleDisabled_doesNothing(void) */ static void test_tm_unknownPackets_dropOnNPlusOne(void) { - moduleConfig.traffic_management.drop_unknown_enabled = true; moduleConfig.traffic_management.unknown_packet_threshold = 2; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); @@ -238,9 +360,8 @@ static void test_tm_unknownPackets_dropOnNPlusOne(void) */ static void test_tm_positionDedup_dropsDuplicateWithinWindow(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -263,9 +384,8 @@ static void test_tm_positionDedup_dropsDuplicateWithinWindow(void) */ static void test_tm_positionDedup_allowsMovedPosition(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -286,11 +406,10 @@ static void test_tm_positionDedup_allowsMovedPosition(void) */ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void) { - moduleConfig.traffic_management.rate_limit_enabled = true; moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 3; TrafficManagementModuleTestShim module; - meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode); ProcessMessage r1 = module.handleReceived(packet); ProcessMessage r2 = module.handleReceived(packet); @@ -305,43 +424,16 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void) TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); TEST_ASSERT_TRUE(module.ignoreRequestFlag()); } - -/** - * Verify routing/admin traffic is exempt from rate limiting. - * Important because throttling control traffic can destabilize the mesh. - */ -static void test_tm_rateLimit_skipsRoutingAndAdminPorts(void) -{ - moduleConfig.traffic_management.rate_limit_enabled = true; - moduleConfig.traffic_management.rate_limit_window_secs = 60; - moduleConfig.traffic_management.rate_limit_max_packets = 1; - TrafficManagementModuleTestShim module; - meshtastic_MeshPacket routingPacket = makeDecodedPacket(meshtastic_PortNum_ROUTING_APP, kRemoteNode); - meshtastic_MeshPacket adminPacket = makeDecodedPacket(meshtastic_PortNum_ADMIN_APP, kRemoteNode); - - for (int i = 0; i < 4; i++) { - ProcessMessage rr = module.handleReceived(routingPacket); - ProcessMessage ar = module.handleReceived(adminPacket); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(rr)); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(ar)); - } - - meshtastic_TrafficManagementStats stats = module.getStats(); - TEST_ASSERT_EQUAL_UINT32(0, stats.rate_limit_drops); -} - /** * Verify packets sourced from this node bypass dedup and rate limiting. * Important so local transmissions are not accidentally self-throttled. */ static void test_tm_fromUs_bypassesPositionAndRateFilters(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; - moduleConfig.traffic_management.rate_limit_enabled = true; moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 1; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678); @@ -367,12 +459,10 @@ static void test_tm_fromUs_bypassesPositionAndRateFilters(void) */ static void test_tm_localDestination_bypassesTransitFilters(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; - moduleConfig.traffic_management.rate_limit_enabled = true; moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 1; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); @@ -400,7 +490,6 @@ static void test_tm_localDestination_bypassesTransitFilters(void) */ static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; mockNodeDB->setCachedNode(kTargetNode); @@ -425,7 +514,6 @@ static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void) */ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; config.lora.config_ok_to_mqtt = true; @@ -471,7 +559,6 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) */ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); @@ -507,7 +594,6 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) */ static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); @@ -534,7 +620,6 @@ static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) */ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); @@ -568,7 +653,6 @@ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) */ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; config.lora.config_ok_to_mqtt = true; @@ -622,7 +706,6 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie */ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); @@ -650,12 +733,13 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi #endif /** - * Verify relayed telemetry broadcasts are hop-exhausted when enabled. - * Important to prevent further mesh propagation while still allowing one relay step. + * Verify relayed telemetry broadcasts are NOT hop-exhausted. + * exhaust_hop_telemetry / exhaust_hop_position have been removed from the config + * as "not suitable right now" — alterReceived must leave hop_limit unchanged. */ -static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void) +static void test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; + ScopedBusyAirTime busyChannel; // congestion present but exhaust is disabled TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); packet.hop_start = 5; @@ -664,19 +748,19 @@ static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void) module.alterReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); - TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); - TEST_ASSERT_EQUAL_UINT8(3, packet.hop_start); - TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); - TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); + TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit); // unchanged + TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); // unchanged + TEST_ASSERT_FALSE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); } /** - * Verify hop exhaustion skips unicast and local-origin packets. - * Important to avoid mutating traffic that should retain normal forwarding behavior. + * Verify alterReceived does not modify unicast or local-origin packets. + * The precision clamp (the only active alterReceived path) only fires for + * broadcast position packets from remote nodes — these should be untouched. */ static void test_tm_alterReceived_skipsLocalAndUnicast(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; TrafficManagementModuleTestShim module; meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode); @@ -703,9 +787,9 @@ static void test_tm_alterReceived_skipsLocalAndUnicast(void) */ static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; - moduleConfig.traffic_management.position_min_interval_secs = 1; + // 360 s = 1 pos-tick (kPosTimeTickMs); advance the virtual clock past one tick period. + moduleConfig.traffic_management.position_min_interval_secs = 360; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -714,7 +798,7 @@ static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) ProcessMessage r1 = module.handleReceived(first); ProcessMessage r2 = module.handleReceived(second); - testDelay(1200); + TrafficManagementModule::s_testNowMs += 360001; // advance past one 6-min pos-tick (virtual clock) ProcessMessage r3 = module.handleReceived(third); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -730,9 +814,9 @@ static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) */ static void test_tm_positionDedup_intervalZero_neverDrops(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; + // position_min_interval_secs=0 disables the drop gate (shouldDropPosition returns false for any packet). moduleConfig.traffic_management.position_min_interval_secs = 0; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -753,9 +837,9 @@ static void test_tm_positionDedup_intervalZero_neverDrops(void) */ static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 99; + // Channel precision=99 is out of range; sanitizePositionPrecision falls back to default (19 bits). moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(99); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -771,18 +855,21 @@ static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void) } /** - * Verify precision=32 does not collapse all positions to one fingerprint. - * Important to prevent false duplicate drops at the full-precision boundary. + * Verify the dedup fingerprint does not collapse positions that are distinct at the + * channel's *effective* precision. Dedup only runs on well-known (public) channels, + * where precision is capped at MAX_POSITION_PRECISION_PUBLIC_KEY (15) regardless of the + * channel's configured value — so the requested 32 is clamped to 15. Positions must + * therefore differ in the top 15 bits (>= 2^17 raw units) to read as distinct; here + * they differ by 2^18, well clear of the precision-15 grid, so neither is dropped. */ -static void test_tm_positionDedup_precision32_allowsDistinctPositions(void) +static void test_tm_positionDedup_distinctAtClampedChannelPrecision(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 32; moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(32); // clamped to 15 on a public channel TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); - meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234 + (1 << 18), -1220845678 + (1 << 18)); ProcessMessage r1 = module.handleReceived(first); ProcessMessage r2 = module.handleReceived(second); @@ -794,18 +881,19 @@ static void test_tm_positionDedup_precision32_allowsDistinctPositions(void) } /** - * Verify invalid precision=0 is treated as full precision. - * Important so invalid config does not collapse all positions into one fingerprint. + * Verify channel precision=0 (no channel ceiling set) falls back to the firmware + * default precision (19 bits / ~90 m cells). Positions more than one default grid + * cell apart must remain distinct, not collapse into one fingerprint. */ -static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void) +static void test_tm_positionDedup_precisionZero_channelFallsBackToDefault(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 0; moduleConfig.traffic_management.position_min_interval_secs = 300; + // precision=0 in the channel → getPositionPrecisionForChannel returns 0 → falls back to default 19 bits. + installWellKnownPrimaryChannelWithPrecision(0); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); - meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 384221234, -1210845678); ProcessMessage r1 = module.handleReceived(first); ProcessMessage r2 = module.handleReceived(second); @@ -820,20 +908,19 @@ static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void) * Verify epoch reset invalidates stale position identity for dedup. * Important so reset paths cannot leak prior packet identity into new windows. */ -static void test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset(void) +static void test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); - meshtastic_MeshPacket afterReset = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterFlush = makePositionPacket(kRemoteNode, 374221234, -1220845678); meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 374221234, -1220845678); ProcessMessage r1 = module.handleReceived(first); - module.resetEpoch(millis()); - ProcessMessage r2 = module.handleReceived(afterReset); + module.flushCache(); + ProcessMessage r2 = module.handleReceived(afterFlush); ProcessMessage r3 = module.handleReceived(duplicate); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -849,19 +936,17 @@ static void test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset(vo */ static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; - moduleConfig.traffic_management.rate_limit_enabled = true; moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 10; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; - meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode); meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000); meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 0x12300000, 0x45600000); - ProcessMessage seeded = module.handleReceived(text); + ProcessMessage seeded = module.handleReceived(telemetry); ProcessMessage r1 = module.handleReceived(first); ProcessMessage r2 = module.handleReceived(duplicate); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -878,15 +963,15 @@ static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero */ static void test_tm_rateLimit_resetsAfterWindowExpires(void) { - moduleConfig.traffic_management.rate_limit_enabled = true; - moduleConfig.traffic_management.rate_limit_window_secs = 1; + // 300 s = 1 rate-tick (kRateTimeTickMs); advance the virtual clock past one tick period. + moduleConfig.traffic_management.rate_limit_window_secs = 300; moduleConfig.traffic_management.rate_limit_max_packets = 1; TrafficManagementModuleTestShim module; - meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode); ProcessMessage r1 = module.handleReceived(packet); ProcessMessage r2 = module.handleReceived(packet); - testDelay(1200); + TrafficManagementModule::s_testNowMs += 300001; // advance past one 5-min rate-tick (virtual clock) ProcessMessage r3 = module.handleReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -895,45 +980,19 @@ static void test_tm_rateLimit_resetsAfterWindowExpires(void) TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); } - -/** - * Verify rate-limit thresholds above 255 effectively clamp to 255. - * Important because counters are uint8_t and must not overflow behavior. - */ -static void test_tm_rateLimit_thresholdAbove255_clamps(void) -{ - moduleConfig.traffic_management.rate_limit_enabled = true; - moduleConfig.traffic_management.rate_limit_window_secs = 60; - moduleConfig.traffic_management.rate_limit_max_packets = 300; - TrafficManagementModuleTestShim module; - meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); - - for (int i = 0; i < 255; i++) { - ProcessMessage result = module.handleReceived(packet); - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); - } - ProcessMessage dropped = module.handleReceived(packet); - meshtastic_TrafficManagementStats stats = module.getStats(); - - TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(dropped)); - TEST_ASSERT_EQUAL_UINT32(1, stats.rate_limit_drops); -} - /** * Verify unknown-packet tracking resets after its active window expires. * Important so old unknown traffic does not trigger delayed drops. */ static void test_tm_unknownPackets_resetAfterWindowExpires(void) { - moduleConfig.traffic_management.drop_unknown_enabled = true; moduleConfig.traffic_management.unknown_packet_threshold = 1; - moduleConfig.traffic_management.rate_limit_window_secs = 1; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); ProcessMessage r1 = module.handleReceived(packet); ProcessMessage r2 = module.handleReceived(packet); - testDelay(1200); + TrafficManagementModule::s_testNowMs += 300001; // advance past 5 unknown-ticks (5 × 60s) (virtual clock) ProcessMessage r3 = module.handleReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -944,17 +1003,17 @@ static void test_tm_unknownPackets_resetAfterWindowExpires(void) } /** - * Verify unknown threshold values above 255 clamp to the counter ceiling. - * Important to align config semantics with saturating counter storage. + * Verify unknown threshold values above the implementation cap (60) are clamped. + * The counter is a 6-bit field (saturates at 63); threshold is capped at 60 so a + * saturated reading always exceeds it. A config value of 300 should behave as 60. */ static void test_tm_unknownPackets_thresholdAbove255_clamps(void) { - moduleConfig.traffic_management.drop_unknown_enabled = true; moduleConfig.traffic_management.unknown_packet_threshold = 300; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); - for (int i = 0; i < 255; i++) { + for (int i = 0; i < 60; i++) { ProcessMessage result = module.handleReceived(packet); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); } @@ -966,12 +1025,11 @@ static void test_tm_unknownPackets_thresholdAbove255_clamps(void) } /** - * Verify relayed position broadcasts can also be hop-exhausted. - * Important because telemetry and position use separate exhaust flags. + * Verify relayed position broadcasts are NOT hop-exhausted. + * exhaust_hop_position has been removed — alterReceived must leave hop_limit unchanged. */ -static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void) +static void test_tm_alterReceived_positionBroadcast_hopLimitUnchanged(void) { - moduleConfig.traffic_management.exhaust_hop_position = true; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST); packet.hop_start = 5; @@ -980,19 +1038,17 @@ static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void) module.alterReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); - TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); - TEST_ASSERT_EQUAL_UINT8(4, packet.hop_start); - TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); - TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); + TEST_ASSERT_EQUAL_UINT8(2, packet.hop_limit); // unchanged + TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); // unchanged + TEST_ASSERT_FALSE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); } - /** - * Verify hop exhaustion ignores undecoded/encrypted packets. + * Verify alterReceived ignores undecoded/encrypted packets. * Important so we never mutate packets that were not decoded by this module. */ static void test_tm_alterReceived_skipsUndecodedPackets(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST); packet.hop_start = 5; @@ -1008,19 +1064,19 @@ static void test_tm_alterReceived_skipsUndecodedPackets(void) } /** - * Verify exhaustRequested is per-packet and resets on next handleReceived(). - * Important so a prior packet cannot leak hop-exhaust state into later packets. + * Verify shouldExhaustHops() always returns false — exhaust_hop_* features are + * removed, so the exhaustRequested flag is never set. + * Guards against accidental re-enablement without updating the flag logic. */ -static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void) +static void test_tm_alterReceived_exhaustFlagAlwaysFalse(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; TrafficManagementModuleTestShim module; meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); telemetry.hop_start = 5; telemetry.hop_limit = 3; module.alterReceived(telemetry); - TEST_ASSERT_TRUE(module.shouldExhaustHops(telemetry)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry)); meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); ProcessMessage result = module.handleReceived(text); @@ -1028,41 +1084,40 @@ static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void) TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry)); - TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); } /** - * Verify exhaust requests are packet-scoped (from + id). - * Important so stale state from one packet cannot influence unrelated packets - * that pass through duplicate/rebroadcast paths before handleReceived(). + * Verify shouldExhaustHops() returns false for any packet regardless of from/id. + * Since exhaust is removed, the from+id scope check is moot — this guards that + * the always-false invariant holds across multiple distinct packets. */ static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; TrafficManagementModuleTestShim module; - meshtastic_MeshPacket exhausted = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); - exhausted.id = 0x1010; - exhausted.hop_start = 5; - exhausted.hop_limit = 3; - module.alterReceived(exhausted); + meshtastic_MeshPacket p1 = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + p1.id = 0x1010; + p1.hop_start = 5; + p1.hop_limit = 3; + module.alterReceived(p1); - meshtastic_MeshPacket unrelated = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST); - unrelated.id = 0x2020; - unrelated.hop_start = 4; - unrelated.hop_limit = 0; + meshtastic_MeshPacket p2 = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST); + p2.id = 0x2020; + p2.hop_start = 4; + p2.hop_limit = 0; - TEST_ASSERT_TRUE(module.shouldExhaustHops(exhausted)); - TEST_ASSERT_FALSE(module.shouldExhaustHops(unrelated)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(p1)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(p2)); } /** - * Verify runOnce() returns sleep-forever interval when module is disabled. - * Important to ensure the maintenance thread is effectively inert when off. + * Verify runOnce() returns sleep-forever interval when has_traffic_management is false. + * TMM has no runtime enable flag — the presence bit is the only runtime gate. */ static void test_tm_runOnce_disabledReturnsMaxInterval(void) { - moduleConfig.traffic_management.enabled = false; + moduleConfig.has_traffic_management = false; TrafficManagementModuleTestShim module; int32_t interval = module.runOnce(); @@ -1083,6 +1138,468 @@ static void test_tm_runOnce_enabledReturnsMaintenanceInterval(void) TEST_ASSERT_EQUAL_INT32(60 * 1000, interval); } +// --------------------------------------------------------------------------- +// Next-hop overflow cache +// --------------------------------------------------------------------------- + +/** + * Round-trip set/get of a confirmed next hop, plus the input guards. + */ +static void test_tm_nextHop_setAndGetRoundTrip(void) +{ + TrafficManagementModuleTestShim module; + + // Unknown node yields no hint. + TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kTargetNode)); + + // Store a confirmed hop and read it back. + module.setNextHop(kTargetNode, 0x42); + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); + + // Zero dest and zero byte are rejected (no spurious entry created). + module.setNextHop(0, 0x42); + module.setNextHop(kRemoteNode, 0); + TEST_ASSERT_EQUAL_UINT8(0, module.getNextHopHint(kRemoteNode)); + + // Last-write-wins on re-confirmation. + module.setNextHop(kTargetNode, 0x99); + TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode)); +} + +/** + * The headline scenario: a node carrying a next hop in the hot NodeInfoLite DB + * is warm-loaded into the TMM cache, then the hot DB is "rolled" (the node ages + * out entirely). The hint must still be served — now exclusively from TMM. + */ +static void test_tm_nextHop_servedAfterNodeDbRoll(void) +{ + TrafficManagementModuleTestShim module; + + // Seed the hot NodeInfoLite DB with a node that has a confirmed next hop. + mockNodeDB->setHotNode(kTargetNode, 0x42); + + // Warm-start the overflow cache from the hot DB. + module.preloadNextHopsFromNodeDB(); + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); + + // Roll the main NodeInfoLite DB: the node is evicted from the hot store. + mockNodeDB->rollHotStore(); + TEST_ASSERT_NULL(nodeDB->getMeshNode(kTargetNode)); // gone from the hot store + + // Hit is still served — proving it now comes from the TMM overflow cache. + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); +} + +/** + * Preload must not clobber a freshly-learned (confirmed) hop with a possibly + * stale persisted one from NodeInfoLite. + */ +static void test_tm_nextHop_preloadDoesNotClobberLearned(void) +{ + TrafficManagementModuleTestShim module; + + // A fresher confirmed hop is already cached. + module.setNextHop(kTargetNode, 0x99); + + // The hot DB carries an older next hop for the same node. + mockNodeDB->setHotNode(kTargetNode, 0x42); + + module.preloadNextHopsFromNodeDB(); + + // The freshly-learned hop survives. + TEST_ASSERT_EQUAL_UINT8(0x99, module.getNextHopHint(kTargetNode)); +} + +/** + * A pure routing hint (no dedup/rate/unknown state) must survive the maintenance + * sweep — next_hop != 0 keeps the slot alive even though it has no TTL. + */ +static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void) +{ + TrafficManagementModuleTestShim module; + + module.setNextHop(kTargetNode, 0x42); + + // The sweep frees slots whose sub-stores are all empty; next_hop must veto that. + module.runOnce(); + + TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); +} + +// --------------------------------------------------------------------------- +// Role exceptions: TRACKER and LOST_AND_FOUND +// --------------------------------------------------------------------------- + +/** + * Verify TRACKER role caps the dedup window at 1 hour. + * A duplicate position that would normally be blocked for 11 h (default) must + * be forwarded once the 1-hour tracker cap expires. + */ +static void test_tm_trackerRole_capsDedupWindowAtOneHour(void) +{ + // Operator interval is 11 h — longer than the tracker cap. + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); // still within 1-hour cap + // Advance past 1 hour (tracker cap = 3600 s; pos-tick = 360 s → 10 ticks). + TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; + ProcessMessage r3 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify TAK_TRACKER role receives the same 1-hour dedup cap as TRACKER. + * Both roles share the same exception branch; this guards against future divergence. + */ +static void test_tm_takTrackerRole_capsDedupWindowAtOneHour(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TAK_TRACKER); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); + TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; + ProcessMessage r3 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify the tracker role exception survives the node being evicted from BOTH the + * hot and warm NodeDB stores — the TMM unified cache is the third fallback. The + * role is cached on the entry while NodeDB still knows the node; once NodeDB + * forgets it (getNodeRole → CLIENT), the cached role must keep the 1-hour cap + * applied instead of reverting to the 11-hour default interval. + */ +static void test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole(void) +{ + // Operator interval is 11 h — longer than the tracker cap. + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + // First packet: NodeDB knows the role; it is cached onto the TMM entry. + ProcessMessage r1 = module.handleReceived(first); + + // Node ages out of NodeDB entirely (hot + warm). getNodeRole now returns CLIENT. + mockNodeDB->clearCachedNode(); + + ProcessMessage r2 = module.handleReceived(dup); // within 1-hour cap — still drop + // Advance past the tracker cap (3600 s) but stay well under the 11-hour default. + // Without the cached-role fallback this would still be inside the 11-hour window + // (CLIENT → no exception) and wrongly drop; with it, the 1-hour cap lets it pass. + TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; + ProcessMessage r3 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify a role change observed via NodeInfo updates the cached role (write-time update), + * so an exception is dropped when a node is demoted from TRACKER back to CLIENT. The role + * is read from the NodeInfo's User payload — the same event that updates NodeDB — not by + * re-scanning NodeDB on the position path. + */ +static void test_tm_roleChange_viaNodeInfo_dropsTrackerException(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + // First position seeds the TMM entry with TRACKER (from NodeDB on isNew). + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + ProcessMessage r1 = module.handleReceived(first); + + // The node demotes to CLIENT and announces it. The NodeInfo refresh must overwrite + // the cached TRACKER role with CLIENT — even though the packet payload, not NodeDB, + // is the source of truth here (NodeDB role left stale on purpose to prove the path). + meshtastic_MeshPacket info = makeNodeInfoPacketWithRole(kRemoteNode, meshtastic_Config_DeviceConfig_Role_CLIENT); + module.handleReceived(info); + + // Past the 1-hour tracker cap but within the 11-hour CLIENT interval. With the stale + // TRACKER role this would pass; after the demotion it must drop (full interval applies). + TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + ProcessMessage r2 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify a cached special (non-CLIENT) role pins the TMM entry through the maintenance + * sweep: once the node's position state has expired and been cleared, the entry — and its + * role — must still survive (role has no TTL), the same way a confirmed next-hop hint does. + */ +static void test_tm_specialRole_pinsEntryThroughSweep(void) +{ + // Short position interval → short pos TTL so the sweep clears pos state quickly. + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + module.handleReceived(makePositionPacket(kRemoteNode, 374221234, -1220845678)); + TEST_ASSERT_EQUAL_INT(static_cast(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(kRemoteNode)); + + // Advance well past the position TTL, then sweep: pos state is cleared but the role + // (no TTL) must keep the entry alive. Without the role pin the entry would be reclaimed + // and peekCachedRole would return -1. + TrafficManagementModule::s_testNowMs += 60UL * 60UL * 1000UL; // 1 h + module.runOnce(); + + TEST_ASSERT_EQUAL_INT(static_cast(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(kRemoteNode)); +} + +/** + * Verify special-role entries are evicted last. A tracker that is the OLDEST entry must + * survive cache pressure that evicts many newer CLIENT entries, because a cached + * special role marks the entry "preferred" (like a next-hop hint) in victim selection. + */ +static void test_tm_specialRole_evictedLastUnderPressure(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + TrafficManagementModuleTestShim module; + + // Oldest entry: a tracker. Seed its role from NodeDB on first position. + const NodeNum tracker = 0xAA0000FF; + mockNodeDB->setCachedNode(tracker); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + module.handleReceived(makePositionPacket(tracker, 100, 200)); + TEST_ASSERT_EQUAL_INT(static_cast(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(tracker)); + + mockNodeDB->clearCachedNode(); // every subsequent filler resolves to CLIENT (unprotected) + + // Fill past capacity with newer CLIENT nodes, forcing many evictions. The tracker is + // the stalest entry but is "preferred", so an unprotected client must always be the + // victim instead — the tracker must never be evicted. + for (uint32_t i = 0; i < static_cast(TRAFFIC_MANAGEMENT_CACHE_SIZE) + 50; i++) { + const NodeNum filler = 0x01000000u + i; + module.handleReceived(makePositionPacket(filler, 300 + static_cast(i), 400 + static_cast(i))); + } + + TEST_ASSERT_EQUAL_INT(static_cast(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(tracker)); +} + +/** + * Verify the tracker cap never lengthens an operator-configured interval shorter + * than the cap default. The cap is a ceiling, not a floor. + */ +static void test_tm_trackerRole_doesNotLengthenShorterOperatorInterval(void) +{ + // Operator set 5-minute interval — shorter than the 1-hour tracker cap. + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterShortInterval = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); + // The 300 s operator interval rounds to 1 pos-tick (360 s) — dedup is tick-granular. + // Advance past one full tick to verify the window is 1 tick (not the 10-tick tracker cap). + TrafficManagementModule::s_testNowMs += 360'000UL + 1; + ProcessMessage r3 = module.handleReceived(afterShortInterval); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify LOST_AND_FOUND role caps duplicate-position dedup at ~15 min (2 pos-ticks), + * not the old one-tick fast-announce. A configured 11-hour interval is shortened to the + * 15-min cap; a duplicate one tick later still drops, but one past the 2-tick cap passes. + */ +static void test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes(void) +{ + // Long interval that would normally suppress duplicates for 11 h. + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterOneTick = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); // same tick — drop + // One pos-tick later: STILL within the 15-min (~2-tick) cap, unlike the old 1-tick exception. + // (360 s = kPosTimeTickMs, kept as a literal because the constant is private to the module.) + TrafficManagementModule::s_testNowMs += 360'000UL + 1; + ProcessMessage r3 = module.handleReceived(afterOneTick); // still drop + // Jump past the full cap (>= 2 ticks since the last packet): now it passes. + TrafficManagementModule::s_testNowMs += 2 * 360'000UL; + ProcessMessage r4 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r4)); + TEST_ASSERT_EQUAL_UINT32(2, stats.position_dedup_drops); +} + +/** + * Verify a node with unknown role (absent from NodeDB) is not granted any role + * exception: the full configured interval applies, so a duplicate inside that + * window is dropped exactly as for an ordinary CLIENT. + */ +static void test_tm_unknownRole_noDbEntry_appliesFullInterval(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + // No cached node — getMeshNode returns nullptr for kRemoteNode. + mockNodeDB->clearCachedNode(); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterShort = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); // within 300-s window — must drop + // The 300 s operator interval rounds to 1 pos-tick (360 s) — dedup is tick-granular. + // Advance past one full tick to confirm the packet passes without any tracker exception. + TrafficManagementModule::s_testNowMs += 360'000UL + 1; + ProcessMessage r3 = module.handleReceived(afterShort); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify a node present in NodeDB but without user info (HAS_USER bit clear) + * is not granted a role exception: it falls back to CLIENT and the full + * configured interval applies. + */ +static void test_tm_unknownRole_noUserBit_appliesFullInterval(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + // Node is in NodeDB but the HAS_USER bit is NOT set — role must be ignored. + mockNodeDB->setCachedNode(kRemoteNode); + // Clear the HAS_USER bit that setCachedNode sets, leaving role at CLIENT default. + mockNodeDB->cachedNodeForTest().bitfield &= ~NODEINFO_BITFIELD_HAS_USER_MASK; + mockNodeDB->cachedNodeForTest().role = meshtastic_Config_DeviceConfig_Role_TRACKER; + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); // within 300-s window — must drop + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify a LOST_AND_FOUND origin now GETS the relayed precision clamp — the + * anti-dox exemption was removed, so a relayed position more precise than the + * channel setting is clamped down to the channel ceiling like any other node's. + */ +static void test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp(void) +{ + // Set channel precision ceiling to 13 bits. Must be <= MAX_POSITION_PRECISION_PUBLIC_KEY + // (15) — well-known channels have a public PSK (size==1), so getPositionPrecisionForChannel + // clamps any value above 15 via usesPublicKey(). + installWellKnownPrimaryChannelWithPrecision(13); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND); + + TrafficManagementModuleTestShim module; + + // Full-precision packet — 32 bits — exceeds the channel cap. + const uint32_t fullPrecision = 32; + meshtastic_MeshPacket packet = makePositionPacketWithPrecision(kRemoteNode, 374221234, -1220845678, fullPrecision); + packet.hop_start = 3; + packet.hop_limit = 2; // relayed (hop_limit < hop_start) so clamp logic applies + + module.alterReceived(packet); + + meshtastic_Position out; + TEST_ASSERT_TRUE(decodePositionPayload(packet, out)); + // Clamped to channel ceiling (13 bits) — lost-and-found is no longer exempt. + // Note: precision must be <= MAX_POSITION_PRECISION_PUBLIC_KEY (15); well-known + // channels always have a public PSK so getPositionPrecisionForChannel caps at 15. + TEST_ASSERT_EQUAL_UINT32(13, out.precision_bits); +} } // namespace void setUp(void) @@ -1106,7 +1623,6 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_positionDedup_dropsDuplicateWithinWindow); RUN_TEST(test_tm_positionDedup_allowsMovedPosition); RUN_TEST(test_tm_rateLimit_dropsOnlyAfterThreshold); - RUN_TEST(test_tm_rateLimit_skipsRoutingAndAdminPorts); RUN_TEST(test_tm_fromUs_bypassesPositionAndRateFilters); RUN_TEST(test_tm_localDestination_bypassesTransitFilters); RUN_TEST(test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops); @@ -1120,25 +1636,39 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); #endif - RUN_TEST(test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast); + RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires); RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops); RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision); - RUN_TEST(test_tm_positionDedup_precision32_allowsDistinctPositions); - RUN_TEST(test_tm_positionDedup_precisionZero_allowsDistinctPositions); - RUN_TEST(test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset); + RUN_TEST(test_tm_positionDedup_distinctAtClampedChannelPrecision); + RUN_TEST(test_tm_positionDedup_precisionZero_channelFallsBackToDefault); + RUN_TEST(test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush); RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero); RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires); - RUN_TEST(test_tm_rateLimit_thresholdAbove255_clamps); RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires); RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps); - RUN_TEST(test_tm_alterReceived_exhaustsRelayedPositionBroadcast); + RUN_TEST(test_tm_alterReceived_positionBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsUndecodedPackets); - RUN_TEST(test_tm_alterReceived_resetExhaustFlagOnNextPacket); + RUN_TEST(test_tm_alterReceived_exhaustFlagAlwaysFalse); RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped); RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval); RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval); + RUN_TEST(test_tm_nextHop_setAndGetRoundTrip); + RUN_TEST(test_tm_nextHop_servedAfterNodeDbRoll); + RUN_TEST(test_tm_nextHop_preloadDoesNotClobberLearned); + RUN_TEST(test_tm_nextHop_keptAliveAcrossMaintenanceSweep); + RUN_TEST(test_tm_trackerRole_capsDedupWindowAtOneHour); + RUN_TEST(test_tm_takTrackerRole_capsDedupWindowAtOneHour); + RUN_TEST(test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole); + RUN_TEST(test_tm_roleChange_viaNodeInfo_dropsTrackerException); + RUN_TEST(test_tm_specialRole_pinsEntryThroughSweep); + RUN_TEST(test_tm_specialRole_evictedLastUnderPressure); + RUN_TEST(test_tm_trackerRole_doesNotLengthenShorterOperatorInterval); + RUN_TEST(test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes); + RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp); + RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval); + RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval); exit(UNITY_END()); } diff --git a/test/test_warm_store/test_main.cpp b/test/test_warm_store/test_main.cpp new file mode 100644 index 000000000..2ecacc75d --- /dev/null +++ b/test/test_warm_store/test_main.cpp @@ -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 + +#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 +#include + +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 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 diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 57ede8bbf..118588e19 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -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": "", diff --git a/variants/esp32/esp32-common.ini b/variants/esp32/esp32-common.ini index a94defff2..fac7cc911 100644 --- a/variants/esp32/esp32-common.ini +++ b/variants/esp32/esp32-common.ini @@ -17,7 +17,7 @@ extra_scripts = extra_scripts/esp32_extra.py build_src_filter = - ${arduino_base.build_src_filter} - - - - - - + ${arduino_base.build_src_filter} + - - 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 diff --git a/variants/esp32p4/esp32p4.ini b/variants/esp32p4/esp32p4.ini index 7995a5205..64dbb2b58 100644 --- a/variants/esp32p4/esp32p4.ini +++ b/variants/esp32p4/esp32p4.ini @@ -13,7 +13,7 @@ build_flags = -DMESHTASTIC_EXCLUDE_PAXCOUNTER=1 build_src_filter = - ${esp32_common.build_src_filter} - - - - - - - + ${esp32_common.build_src_filter} - + - - extra_scripts = ${esp32_common.extra_scripts} diff --git a/variants/esp32s3/ELECROW-ThinkNode-M5/variant.h b/variants/esp32s3/ELECROW-ThinkNode-M5/variant.h index 2d0b413fe..ee86c3df6 100644 --- a/variants/esp32s3/ELECROW-ThinkNode-M5/variant.h +++ b/variants/esp32s3/ELECROW-ThinkNode-M5/variant.h @@ -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 diff --git a/variants/esp32s3/heltec_v4/variant.h b/variants/esp32s3/heltec_v4/variant.h index 5a806653e..d35cce554 100644 --- a/variants/esp32s3/heltec_v4/variant.h +++ b/variants/esp32s3/heltec_v4/variant.h @@ -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 diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h index d59f0ae2c..b7d8897a5 100644 --- a/variants/esp32s3/heltec_v4_r8/variant.h +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -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 diff --git a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini index be964cc6d..66093d033 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini +++ b/variants/esp32s3/rak_wismesh_tap_v2/platformio.ini @@ -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} diff --git a/variants/esp32s3/rak_wismesh_tap_v2/variant.h b/variants/esp32s3/rak_wismesh_tap_v2/variant.h index f8672edac..a169a1e8a 100644 --- a/variants/esp32s3/rak_wismesh_tap_v2/variant.h +++ b/variants/esp32s3/rak_wismesh_tap_v2/variant.h @@ -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 diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h index 9e844588f..fdd7c280d 100755 --- a/variants/esp32s3/station-g2/variant.h +++ b/variants/esp32s3/station-g2/variant.h @@ -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 diff --git a/variants/native/portduino.ini b/variants/native/portduino.ini index 9006f926a..3ba852387 100644 --- a/variants/native/portduino.ini +++ b/variants/native/portduino.ini @@ -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} - - + ${env.build_src_filter} + - + + - - - - - - - - - - - + diff --git a/variants/native/portduino/variant.h b/variants/native/portduino/variant.h index fb36e2588..7ccf2e34b 100644 --- a/variants/native/portduino/variant.h +++ b/variants/native/portduino/variant.h @@ -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 diff --git a/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini b/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini new file mode 100644 index 000000000..05f3f49d4 --- /dev/null +++ b/variants/nrf52840/heltec_mesh_tower_v2/platformio.ini @@ -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 diff --git a/variants/nrf52840/heltec_mesh_tower_v2/variant.cpp b/variants/nrf52840/heltec_mesh_tower_v2/variant.cpp new file mode 100644 index 000000000..08843c0a6 --- /dev/null +++ b/variants/nrf52840/heltec_mesh_tower_v2/variant.cpp @@ -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); +} diff --git a/variants/nrf52840/heltec_mesh_tower_v2/variant.h b/variants/nrf52840/heltec_mesh_tower_v2/variant.h new file mode 100644 index 000000000..1dd0ce63f --- /dev/null +++ b/variants/nrf52840/heltec_mesh_tower_v2/variant.h @@ -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 diff --git a/variants/nrf52840/nrf52.ini b/variants/nrf52840/nrf52.ini index f99c78d8a..56ab06ef1 100644 --- a/variants/nrf52840/nrf52.ini +++ b/variants/nrf52840/nrf52.ini @@ -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} - - - - - - - - - - - - + ${arduino_base.build_src_filter} + - - - - - - - - lib_deps= ${arduino_base.lib_deps} diff --git a/variants/nrf52840/nrf52840.ini b/variants/nrf52840/nrf52840.ini index c5590cbc3..610b5b530 100644 --- a/variants/nrf52840/nrf52840.ini +++ b/variants/nrf52840/nrf52840.ini @@ -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 diff --git a/variants/nrf54l15/nrf54l15.ini b/variants/nrf54l15/nrf54l15.ini index d772de4d5..6b004a675 100644 --- a/variants/nrf54l15/nrf54l15.ini +++ b/variants/nrf54l15/nrf54l15.ini @@ -32,10 +32,6 @@ build_flags = build_src_filter = ${arduino_base.build_src_filter} - - - - - - - - - - - diff --git a/variants/rp2040/rp2040.ini b/variants/rp2040/rp2040.ini index 2b7002397..c560d21a6 100644 --- a/variants/rp2040/rp2040.ini +++ b/variants/rp2040/rp2040.ini @@ -20,7 +20,7 @@ build_flags = -D__FREERTOS=1 # -D _POSIX_THREADS build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - - + ${arduino_base.build_src_filter} + - - - - - - lib_ignore = BluetoothOTA diff --git a/variants/rp2350/diy/pico2_w5500_e22/platformio.ini b/variants/rp2350/diy/pico2_w5500_e22/platformio.ini index 379dd5910..b8535dda5 100644 --- a/variants/rp2350/diy/pico2_w5500_e22/platformio.ini +++ b/variants/rp2350/diy/pico2_w5500_e22/platformio.ini @@ -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} + + + diff --git a/variants/rp2350/rp2350.ini b/variants/rp2350/rp2350.ini index 2931fd7b6..d2958d965 100644 --- a/variants/rp2350/rp2350.ini +++ b/variants/rp2350/rp2350.ini @@ -17,7 +17,7 @@ build_flags = -D__PLAT_RP2350__ -D__FREERTOS=1 build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - - - - + ${arduino_base.build_src_filter} + - - - - - - - - lib_ignore = BluetoothOTA diff --git a/variants/stm32/stm32.ini b/variants/stm32/stm32.ini index 2116b046e..c5ef6c860 100644 --- a/variants/stm32/stm32.ini +++ b/variants/stm32/stm32.ini @@ -45,7 +45,7 @@ build_flags = -Wl,--wrap=_tzset_unlocked_r build_src_filter = - ${arduino_base.build_src_filter} - - - - - - - - - - - - - - - + ${arduino_base.build_src_filter} + - - - - - - - - - - board_upload.offset_address = 0x08000000 upload_protocol = stlink