emdashes begone (#10847)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
# LoRa Region → Preset Compatibility — Client Implementation Spec
|
||||
# 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`
|
||||
@@ -13,7 +13,7 @@ Apple second, then web/python) · **Firmware side:** implemented in `firmware`
|
||||
## 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
|
||||
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
|
||||
@@ -22,7 +22,7 @@ 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
|
||||
purely advisory metadata - the firmware remains the source of truth and still
|
||||
validates/clamps on its own.
|
||||
|
||||
---
|
||||
@@ -74,7 +74,7 @@ share one identical preset list (the "standard" 9-preset list), so the map is de
|
||||
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
|
||||
nanopb (firmware) array bounds - clients do **not** need to enforce these, but they bound
|
||||
what you can receive:
|
||||
|
||||
| field | max_count |
|
||||
@@ -154,7 +154,7 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
(`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
|
||||
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
|
||||
@@ -171,7 +171,7 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
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.
|
||||
render the full preset list as before - never show an empty picker.
|
||||
|
||||
---
|
||||
|
||||
@@ -193,14 +193,14 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
> 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)
|
||||
### 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
|
||||
`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
|
||||
@@ -213,16 +213,16 @@ These rules are what keep the UX correct across firmware versions. Implement all
|
||||
`LoRaConfigScreen`). Gate/filter the `ChannelOption` (preset) dropdown by the selected
|
||||
`RegionInfo`'s entry in the map.
|
||||
|
||||
### 8.2 Apple — `meshtastic/Meshtastic-Apple` (Swift / SwiftUI)
|
||||
### 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.)
|
||||
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
|
||||
`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
|
||||
|
||||
+60
-60
@@ -1,22 +1,22 @@
|
||||
# Mesh Beacon Module — Function, Settings, and Client Interface Spec
|
||||
# Mesh Beacon Module - Function, Settings, and Client Interface Spec
|
||||
|
||||
Status: draft, tracks firmware branch `feat/mesh-beacon`.
|
||||
Audience: firmware reviewers (Part 1) and client-app developers — Android / Apple / Web / Python (Part 2).
|
||||
Audience: firmware reviewers (Part 1) and client-app developers - Android / Apple / Web / Python (Part 2).
|
||||
|
||||
The Mesh Beacon module lets a node periodically **advertise the existence of a mesh** to
|
||||
nodes that are not yet on it — broadcasting a short human-readable message plus an optional
|
||||
nodes that are not yet on it - broadcasting a short human-readable message plus an optional
|
||||
"join offer" (a channel, region, and modem preset). It is the mechanism behind invitations
|
||||
like _"Join us on NarrowSlow"_: a node sitting on one preset/region can shout an invitation
|
||||
that listeners on other presets/regions can hear and surface to their user.
|
||||
|
||||
The module is deliberately **advisory**. The firmware never auto-joins an advertised
|
||||
channel or auto-switches preset/region in response to a received beacon — it delivers the
|
||||
channel or auto-switches preset/region in response to a received beacon - it delivers the
|
||||
information to the client app and stops there. All "should I act on this?" decisions belong
|
||||
to the client and, ultimately, the user.
|
||||
|
||||
---
|
||||
|
||||
## Part 1 — Function and settings choices
|
||||
## Part 1 - Function and settings choices
|
||||
|
||||
### 1.1 Two roles in one module
|
||||
|
||||
@@ -25,7 +25,7 @@ to the client and, ultimately, the user.
|
||||
| **Broadcaster** | `MeshBeaconBroadcastModule` | `FLAG_BROADCAST_ENABLED` set | Periodically transmits `MESH_BEACON_APP` packets on the configured radio settings. |
|
||||
| **Listener** | `MeshBeaconListenerModule` | `FLAG_LISTEN_ENABLED` set | Receives `MESH_BEACON_APP` packets and caches the offer for the client (the packet itself flows to the client unchanged). |
|
||||
|
||||
The boolean toggles live in a single `flags` bitfield (see [§1.8](#18-settings-reference-moduleconfigmeshbeaconconfig-tag-17)) — broadcasting and
|
||||
The boolean toggles live in a single `flags` bitfield (see [§1.8](#18-settings-reference-moduleconfigmeshbeaconconfig-tag-17)) - broadcasting and
|
||||
listening can be enabled independently on the same node. The whole module compiles out under the
|
||||
`MESHTASTIC_EXCLUDE_BEACON` build flag.
|
||||
|
||||
@@ -60,16 +60,16 @@ Every outgoing beacon packet is stamped uniformly (`sendBeacon` → `stampPacket
|
||||
|
||||
- `to = NODENUM_BROADCAST`
|
||||
- `from = local node` (see [§1.6](#16-broadcast_send_as_node-currently-disabled) for the disabled spoof path)
|
||||
- **`hop_limit = 0`** — beacons are **zero-hop**. They are never rebroadcast by the mesh; only
|
||||
- **`hop_limit = 0`** - beacons are **zero-hop**. They are never rebroadcast by the mesh; only
|
||||
direct RF neighbours hear them. This is the primary spam-control mechanism. (`hop_start` is
|
||||
normally `0` too, but `FLAG_LEGACY_SPLIT` raises it to `1` for old-firmware compatibility — see
|
||||
normally `0` too, but `FLAG_LEGACY_SPLIT` raises it to `1` for old-firmware compatibility - see
|
||||
[§1.5](#15-legacy-split-flag_legacy_split).)
|
||||
- `priority = BACKGROUND`, `want_ack = false`.
|
||||
|
||||
Broadcasting is additionally gated at runtime by:
|
||||
|
||||
- airtime utilisation (`isTxAllowedAirUtil()`), and
|
||||
- device role — **`CLIENT_HIDDEN` never broadcasts**.
|
||||
- device role - **`CLIENT_HIDDEN` never broadcasts**.
|
||||
|
||||
#### Interval
|
||||
|
||||
@@ -78,7 +78,7 @@ Broadcasting is additionally gated at runtime by:
|
||||
floor are silently raised, both at config-set time (AdminModule) and at runtime.
|
||||
|
||||
The cadence is **reboot-safe**. Each broadcast's time is persisted to flash via `TransmitHistory`
|
||||
(keyed by `MESH_BEACON_APP`), and the broadcaster reads it back on boot — so a node that reboots
|
||||
(keyed by `MESH_BEACON_APP`), and the broadcaster reads it back on boot - so a node that reboots
|
||||
(or crash-loops) won't re-broadcast until a full interval has elapsed since its last real send,
|
||||
rather than firing ~30 s after every boot. The timestamp is written **before** the transmit, so a
|
||||
brown-out during the high-current LoRa TX still counts as "sent." This mirrors `NodeInfoModule` /
|
||||
@@ -90,13 +90,13 @@ A beacon's whole point is often to reach a mesh on a _different_ preset/region/c
|
||||
broadcaster currently runs. Before transmitting a beacon tagged with target radio settings, the
|
||||
module temporarily reconfigures the radio (`reconfigureForBeaconTX`), sends, then restores the
|
||||
prior config. Per-packet target settings are held in an 8-entry **sidecar table** keyed by packet
|
||||
ID — chosen so the `MeshPacket` proto carries no extra per-packet radio fields, and normal
|
||||
ID - chosen so the `MeshPacket` proto carries no extra per-packet radio fields, and normal
|
||||
(non-beacon) traffic is never touched.
|
||||
|
||||
Two safety guards run before any radio switch (`beaconTxConfigInvalid`):
|
||||
|
||||
1. **An unlicensed node never keys up on a licensed-only (ham) region.** (The reverse — a licensed
|
||||
node operating in a non-ham region — is allowed. The switch only touches preset/region/channel,
|
||||
1. **An unlicensed node never keys up on a licensed-only (ham) region.** (The reverse - a licensed
|
||||
node operating in a non-ham region - is allowed. The switch only touches preset/region/channel,
|
||||
never `owner.is_licensed`.)
|
||||
2. **The preset must be valid for the target region** (`validateConfigLora`).
|
||||
|
||||
@@ -109,13 +109,13 @@ Encryption keys off the **primary** channel slot, and the radio-thread channel s
|
||||
_after_ encryption. So when a beacon goes out on an override channel (different name/PSK), the
|
||||
module installs the beacon channel into the primary slot for the synchronous duration of
|
||||
`send()`, then restores it (`sendBeaconPacket`). This guarantees the packet is encrypted with the
|
||||
beacon channel's key and stamped with its hash — not the primary's. Meshtastic threading is
|
||||
beacon channel's key and stamped with its hash - not the primary's. Meshtastic threading is
|
||||
cooperative, so there is no preemption between swap and restore.
|
||||
|
||||
### 1.4 Where beacons are sent: single-target and multi-target
|
||||
|
||||
The broadcaster can send to one set of radio settings or to several. **Single- and multi-target
|
||||
are equal options — neither is preferred and neither is legacy.** Pick whichever matches the
|
||||
are equal options - neither is preferred and neither is legacy.** Pick whichever matches the
|
||||
deployment.
|
||||
|
||||
- **Single-target:** the scalar `broadcast_on_preset` / `broadcast_on_region` /
|
||||
@@ -124,9 +124,9 @@ deployment.
|
||||
non-empty it takes over from the scalar `broadcast_on_*` fields, and the broadcaster sends **one
|
||||
beacon copy per entry**. Each `BroadcastTarget` is `{ optional preset, region, optional channel_index }`,
|
||||
where `channel_index` references a slot in the node's own channel table (the channel must already be
|
||||
configured locally — its key is needed to encrypt the beacon). Within one cycle, targets that
|
||||
resolve to the **same** effective preset/region/channel are de-duplicated — only the first is
|
||||
transmitted — so an accidentally repeated entry costs no extra airtime.
|
||||
configured locally - its key is needed to encrypt the beacon). Within one cycle, targets that
|
||||
resolve to the **same** effective preset/region/channel are de-duplicated - only the first is
|
||||
transmitted - so an accidentally repeated entry costs no extra airtime.
|
||||
|
||||
#### Same-settings vs. other-settings
|
||||
|
||||
@@ -135,7 +135,7 @@ settings** or specify **different** ones:
|
||||
|
||||
- **Same-settings ("message of the day"):** leave the preset / region / channel unset. They fall
|
||||
back to the running config, so the beacon goes out on the node's current mesh with **no radio
|
||||
switch** — a plain periodic broadcast to whoever is already on this preset/region.
|
||||
switch** - a plain periodic broadcast to whoever is already on this preset/region.
|
||||
- **Other-settings (cross-mesh invite):** set a preset / region / channel that differs from the
|
||||
running config. The radio is temporarily switched for that copy's TX, then restored (see
|
||||
[§1.3](#radio-switching-for-tx)).
|
||||
@@ -154,8 +154,8 @@ offer, but old firmware only decodes `TEXT_MESSAGE_APP` and would never show the
|
||||
`FLAG_LEGACY_SPLIT` is set **and both text and offer content are present**, the broadcaster
|
||||
emits **two** packets on the same beacon radio settings instead of one:
|
||||
|
||||
- **Packet A** — `MESH_BEACON_APP` carrying the **offer only** (no text).
|
||||
- **Packet B** — `TEXT_MESSAGE_APP` carrying the **text only**.
|
||||
- **Packet A** - `MESH_BEACON_APP` carrying the **offer only** (no text).
|
||||
- **Packet B** - `TEXT_MESSAGE_APP` carrying the **text only**.
|
||||
|
||||
This is an independent two-packet decision, not an either/or: offer-only and text-only payloads
|
||||
still go out as a single packet in their respective cases; only the both-present case splits.
|
||||
@@ -163,7 +163,7 @@ still go out as a single packet in their respective cases; only the both-present
|
||||
**(b) `hop_start = 1` override.** When `FLAG_LEGACY_SPLIT` is set, **every** beacon packet it sends
|
||||
(combined, split-A, or split-B; even same-settings ones) is stamped with `hop_start = 1` while
|
||||
`hop_limit` stays `0`. Pre-2.7.20 firmware drops `hop_start == 0` packets in a pre-decryption check
|
||||
before it can read the bitfield, so `hop_start = 1` lets those nodes accept the beacon — and it
|
||||
before it can read the bitfield, so `hop_start = 1` lets those nodes accept the beacon - and it
|
||||
remains genuinely zero-hop (`hop_limit = 0` still prevents any rebroadcast).
|
||||
|
||||
> **Side effect for clients:** with `hop_start = 1, hop_limit = 0`, receivers compute
|
||||
@@ -181,26 +181,26 @@ AdminModule and should be treated as canonical:
|
||||
> A remote admin may only set `broadcast_send_as_node` to **their own** node ID
|
||||
> (`mp.from`). Any other value is rejected and reset to the stored value.
|
||||
|
||||
Design note for when it is re-enabled: it is a _node-ID_ spoof only — it rewrites `from` but forges
|
||||
Design note for when it is re-enabled: it is a _node-ID_ spoof only - it rewrites `from` but forges
|
||||
no signature. Once `from` is not us, the packet is no longer `isFromUs()`, so the router skips
|
||||
XEdDSA signing and receivers get an unsigned packet attributed to another node.
|
||||
|
||||
### 1.7 Reception behaviour (listener)
|
||||
|
||||
When `FLAG_LISTEN_ENABLED` is **off**, the router drops incoming `MESH_BEACON_APP` packets up front
|
||||
(`Router::handleReceived`, same pattern as a disabled NeighborInfo module) — so they reach neither
|
||||
(`Router::handleReceived`, same pattern as a disabled NeighborInfo module) - so they reach neither
|
||||
the modules nor the phone. When it is **on**, the packet flows normally and the listener's
|
||||
`wantPacket` accepts it (`has_mesh_beacon` + `FLAG_LISTEN_ENABLED` + `portnum == MESH_BEACON_APP`).
|
||||
On a valid beacon (`handleReceivedProtobuf`):
|
||||
|
||||
1. **Offer → cache.** Any offer (`offer_channel` / `offer_region` / `offer_preset`) is stored in
|
||||
the static `lastReceivedOffer` (sender, channel, region, preset, `received_at`). `received_at`
|
||||
is `0` if the node has no RTC fix yet — **consumers must not treat `0` as a valid timestamp.**
|
||||
is `0` if the node has no RTC fix yet - **consumers must not treat `0` as a valid timestamp.**
|
||||
2. **Never auto-applied.** The firmware does not switch channel/preset/region from a received
|
||||
offer. Acting on it is the client app's job.
|
||||
3. The handler returns `CONTINUE` (not `STOP`), so the original `MESH_BEACON_APP` packet **flows to
|
||||
the client unchanged** through the normal FromRadio path (see Part 2). The client reads the
|
||||
`message` field directly from that packet — there is no separate copy.
|
||||
`message` field directly from that packet - there is no separate copy.
|
||||
|
||||
The firmware deliberately does **not** unwrap a combined beacon's text into a synthesized
|
||||
`TEXT_MESSAGE_APP`, and does **not** fire `EVENT_RECEIVED_MSG`: a beacon is an advisory broadcast,
|
||||
@@ -236,35 +236,35 @@ a real `TEXT_MESSAGE_APP` over RF (see [§1.5](#15-legacy-split-flag_legacy_spli
|
||||
| 2 | `FLAG_BROADCAST_ENABLED` | Periodically broadcast beacons from this node. |
|
||||
| 4 | `FLAG_LEGACY_SPLIT` | Legacy compatibility: (a) split text+offer into separate `TEXT_MESSAGE_APP` + `MESH_BEACON_APP` packets, and (b) stamp `hop_start = 1` on every beacon so pre-2.7.20 firmware accepts it (see [§1.5](#15-legacy-split-flag_legacy_split)). |
|
||||
|
||||
`BroadcastTarget`: `1 preset` (optional, falls back to running config), `2 region` (`UNSET` = running config), `4 channel_index` (optional `uint32`, index into the node's channel table; if unset, the default channel for the preset is used). Tag `3` is an unused gap — it previously held an embedded `ChannelSettings`, dropped to keep `ModuleConfig` within the BLE `FromRadio` size budget.
|
||||
`BroadcastTarget`: `1 preset` (optional, falls back to running config), `2 region` (`UNSET` = running config), `4 channel_index` (optional `uint32`, index into the node's channel table; if unset, the default channel for the preset is used). Tag `3` is an unused gap - it previously held an embedded `ChannelSettings`, dropped to keep `ModuleConfig` within the BLE `FromRadio` size budget.
|
||||
|
||||
---
|
||||
|
||||
## Part 2 — Client interface specification
|
||||
## Part 2 - Client interface specification
|
||||
|
||||
This section is what a client app needs to integrate with the beacon module. Everything goes
|
||||
through the **standard admin / ToRadio / FromRadio protocol** — there is no bespoke transport.
|
||||
through the **standard admin / ToRadio / FromRadio protocol** - there is no bespoke transport.
|
||||
|
||||
### 2.1 Capability detection
|
||||
|
||||
The module is build-flag optional. Treat it as present when the node's `LocalModuleConfig`
|
||||
contains a `mesh_beacon` sub-message (`LocalModuleConfig.mesh_beacon`, tag 18). If absent, the
|
||||
firmware was built with `MESHTASTIC_EXCLUDE_BEACON` — hide the beacon UI.
|
||||
firmware was built with `MESHTASTIC_EXCLUDE_BEACON` - hide the beacon UI.
|
||||
|
||||
### 2.2 Reading and writing configuration
|
||||
|
||||
Standard module-config flow — no new admin messages:
|
||||
Standard module-config flow - no new admin messages:
|
||||
|
||||
- **Read:** `AdminMessage.get_module_config_request = ModuleConfig.MeshBeaconConfig` (variant 17).
|
||||
Reply is `get_module_config_response` with the `mesh_beacon` payload.
|
||||
- **Write:** `AdminMessage.set_module_config { mesh_beacon = … }`.
|
||||
|
||||
The on/off toggles (listen, broadcast, legacy-split) are bits in the `flags` field, not separate
|
||||
booleans — read/write them with the `MeshBeaconConfig.Flags` values
|
||||
booleans - read/write them with the `MeshBeaconConfig.Flags` values
|
||||
(`FLAG_LISTEN_ENABLED = 1`, `FLAG_BROADCAST_ENABLED = 2`, `FLAG_LEGACY_SPLIT = 4`). To toggle one
|
||||
bit, read the current `flags`, set/clear the bit, and write the whole config back.
|
||||
|
||||
The firmware **sanitises on write** — your value may be silently adjusted. Mirror these rules
|
||||
The firmware **sanitises on write** - your value may be silently adjusted. Mirror these rules
|
||||
client-side so the UI doesn't disagree with the device:
|
||||
|
||||
| Rule | Firmware behaviour |
|
||||
@@ -276,7 +276,7 @@ client-side so the UI doesn't disagree with the device:
|
||||
| `broadcast_offer_region` not a known region | Cleared to `UNSET`. |
|
||||
| `broadcast_targets[i].region` not a known region | That entry's region cleared to `UNSET` (TX falls back to running config). |
|
||||
| `broadcast_targets[i].preset` invalid for that entry's region | That entry's `preset` and `channel_index` cleared. |
|
||||
| `broadcast_targets[i].channel_index` ≥ `MAX_NUM_CHANNELS` (8) | That entry's `channel_index` cleared (existence is **not** checked — see §2.5). |
|
||||
| `broadcast_targets[i].channel_index` ≥ `MAX_NUM_CHANNELS` (8) | That entry's `channel_index` cleared (existence is **not** checked - see §2.5). |
|
||||
| `broadcast_send_as_node` ≠ sender's node ID (remote admin) | Rejected, reset to stored value. |
|
||||
|
||||
Setting beacon config does **not** trigger a reboot (`shouldReboot = false`); changes take effect
|
||||
@@ -285,7 +285,7 @@ effective (sanitised) values.
|
||||
|
||||
### 2.3 Receiving beacons
|
||||
|
||||
A received beacon reaches the client as a normal `FromRadio.packet` (`MeshPacket`) — the listener
|
||||
A received beacon reaches the client as a normal `FromRadio.packet` (`MeshPacket`) - the listener
|
||||
returns `CONTINUE`, so the packet is **not** consumed on-device. The client must:
|
||||
|
||||
1. Subscribe to the FromRadio packet stream as usual.
|
||||
@@ -295,48 +295,48 @@ returns `CONTINUE`, so the packet is **not** consumed on-device. The client must
|
||||
4. `packet.from` is the **originating beaconer** (the firmware preserves it).
|
||||
|
||||
> **Requires `FLAG_LISTEN_ENABLED` set in `flags`.** With listening disabled the firmware drops
|
||||
> received `MESH_BEACON_APP` packets in the router — before they reach the phone or any on-device
|
||||
> handler — the same way it drops a disabled module's packets (e.g. NeighborInfo). The node still
|
||||
> received `MESH_BEACON_APP` packets in the router - before they reach the phone or any on-device
|
||||
> handler - the same way it drops a disabled module's packets (e.g. NeighborInfo). The node still
|
||||
> physically receives the RF, but the client will not see beacons over the FromRadio stream until
|
||||
> listening is enabled.
|
||||
|
||||
#### Reading the text — no duplication
|
||||
#### Reading the text - no duplication
|
||||
|
||||
For a beacon-aware client the text is **simply the `message` field of the `MESH_BEACON_APP`
|
||||
packet** you already decode for the offer (step 3 above). One packet, one field — the firmware does
|
||||
packet** you already decode for the offer (step 3 above). One packet, one field - the firmware does
|
||||
**not** inject a separate `TEXT_MESSAGE_APP` copy, so there is nothing to deduplicate.
|
||||
|
||||
The only time a beacon's text arrives as a separate `TEXT_MESSAGE_APP` is when the broadcaster set
|
||||
`FLAG_LEGACY_SPLIT`: in that mode the `MESH_BEACON_APP` carries the **offer only** (empty `message`)
|
||||
and the text is sent as a normal `TEXT_MESSAGE_APP` over RF, so legacy/non-beacon-aware clients can
|
||||
display it. These two cases are mutually exclusive — a given beacon's text appears exactly once,
|
||||
either in `MESH_BEACON_APP.message` (combined) or as a `TEXT_MESSAGE_APP` (legacy-split) — so a
|
||||
display it. These two cases are mutually exclusive - a given beacon's text appears exactly once,
|
||||
either in `MESH_BEACON_APP.message` (combined) or as a `TEXT_MESSAGE_APP` (legacy-split) - so a
|
||||
client never needs to dedup. Render whichever it receives.
|
||||
|
||||
### 2.4 Acting on an offer (the core client responsibility)
|
||||
|
||||
When a `MESH_BEACON_APP` carries offer content, present it to the user as an **invitation** —
|
||||
When a `MESH_BEACON_APP` carries offer content, present it to the user as an **invitation** -
|
||||
e.g. _"Node ⟨from⟩ invites you to join '⟨offer_channel.name⟩' on ⟨preset⟩/⟨region⟩."_ Then, only on
|
||||
explicit user confirmation, apply it by writing normal config:
|
||||
|
||||
- `offer_channel` → add/replace a `Channel` (`set_channel`), typically as a secondary channel.
|
||||
- `offer_region` / `offer_preset` → `set_config { lora = … }` (`use_preset = true`, set
|
||||
`modem_preset` and `region`). **Note this changes the node's own radio and will drop it off its
|
||||
current mesh** — make that consequence explicit in the UI.
|
||||
current mesh** - make that consequence explicit in the UI.
|
||||
|
||||
**The firmware will never do any of this for the user. No silent auto-apply.** The on-device
|
||||
`lastReceivedOffer` cache is a firmware-internal convenience and is **not** currently exposed via
|
||||
an admin message — clients should source offers from the live `MESH_BEACON_APP` packet stream
|
||||
an admin message - clients should source offers from the live `MESH_BEACON_APP` packet stream
|
||||
(§2.3), not expect a "get last offer" RPC.
|
||||
|
||||
#### Offer trust model — read before applying
|
||||
#### Offer trust model - read before applying
|
||||
|
||||
- **The advertised PSK is not a secret.** `offer_channel.psk` is a public join token sent in the
|
||||
clear inside a broadcast; it is a convenience, not a security boundary. An operator who wants a
|
||||
genuinely private channel must distribute the PSK out-of-band and leave `offer_channel` unset.
|
||||
Surface offered channels as **public/open** to the user.
|
||||
- **Validate before applying.** Reject or warn if `offer_preset` is not valid for `offer_region`,
|
||||
and **never** apply a licensed-only (ham) region for a user who is not a licensed operator —
|
||||
and **never** apply a licensed-only (ham) region for a user who is not a licensed operator -
|
||||
mirror the firmware's own guard.
|
||||
- Beacons are **unsigned** when sent as another node (the disabled send-as path), and even normal
|
||||
beacons assert nothing about the sender's authority. Treat `from` as informational.
|
||||
@@ -356,7 +356,7 @@ broadcast_offer_preset = NARROW_SLOW
|
||||
broadcast_offer_region = EU_N_868
|
||||
broadcast_offer_channel = { name: "MyChannel", psk: <32-byte key> }
|
||||
broadcast_interval_secs = 3600
|
||||
// channel_index points at slots in THIS node's channel table — configure those channels first.
|
||||
// channel_index points at slots in THIS node's channel table - configure those channels first.
|
||||
broadcast_targets = [
|
||||
{ preset: LONG_FAST, region: EU_868, channel_index: 0 },
|
||||
{ preset: NARROW_SLOW, region: EU_N_868, channel_index: 1 },
|
||||
@@ -364,13 +364,13 @@ broadcast_targets = [
|
||||
```
|
||||
|
||||
The same fields can be baked in at build time via `userPrefs.jsonc`
|
||||
(`USERPREFS_MESH_BEACON_*`) — see that file for the full list, including
|
||||
(`USERPREFS_MESH_BEACON_*`) - see that file for the full list, including
|
||||
`USERPREFS_MESH_BEACON_TARGET_<n>_*` for multi-target entries.
|
||||
|
||||
#### Single-target vs. multi-target — equal options, different channel representation
|
||||
#### Single-target vs. multi-target - equal options, different channel representation
|
||||
|
||||
Single-target and multi-target are **equal, first-class options**. Neither is preferred,
|
||||
deprecated, or a "legacy" fallback — pick whichever matches the deployment (a single-target
|
||||
deprecated, or a "legacy" fallback - pick whichever matches the deployment (a single-target
|
||||
beacon with no overrides is a plain message-of-the-day; a multi-target list reaches several
|
||||
preset/region/channel combinations). The broadcaster uses `broadcast_targets` when it is
|
||||
non-empty and the scalar `broadcast_on_*` fields when it is empty.
|
||||
@@ -379,13 +379,13 @@ The one **subtle implementation difference** is how each names its TX channel:
|
||||
|
||||
| Path | TX channel is specified by | Channel name/PSK live… |
|
||||
| ------------- | ------------------------------------------------------- | ----------------------------------------- |
|
||||
| Single-target | `broadcast_on_channel` — an embedded `ChannelSettings` | …inline in the beacon config |
|
||||
| Multi-target | `broadcast_targets[i].channel_index` — a `uint32` index | …in the node's channel table (referenced) |
|
||||
| Single-target | `broadcast_on_channel` - an embedded `ChannelSettings` | …inline in the beacon config |
|
||||
| Multi-target | `broadcast_targets[i].channel_index` - a `uint32` index | …in the node's channel table (referenced) |
|
||||
|
||||
This asymmetry is deliberate: embedding a full `ChannelSettings` in every one of the (up to
|
||||
four) targets would push `ModuleConfig` past the BLE `FromRadio` size limit, so a target
|
||||
references an already-configured channel-table slot instead. `broadcast_offer_channel` (the
|
||||
advertised join token) is **always** inline regardless of path — it is the advertisement payload
|
||||
advertised join token) is **always** inline regardless of path - it is the advertisement payload
|
||||
and must carry the actual name/PSK.
|
||||
|
||||
#### Configuring a multi-target broadcaster (two-step)
|
||||
@@ -412,19 +412,19 @@ admin writes**, in order:
|
||||
|
||||
Notes:
|
||||
|
||||
- A target may **only** reference a channel that already exists locally — the node needs that
|
||||
- A target may **only** reference a channel that already exists locally - the node needs that
|
||||
channel's key to encrypt the beacon. A `channel_index` that is out of range, or points at a
|
||||
blank/unconfigured slot, is not an error: the beacon falls back to the node's **current/primary
|
||||
channel** (its name, PSK, and slot) on the target preset/region. The channel name only defaults
|
||||
to the preset's display name (e.g. `LongFast`) when the primary channel itself is unnamed — so
|
||||
to the preset's display name (e.g. `LongFast`) when the primary channel itself is unnamed - so
|
||||
the fallback is "broadcast on my home channel," **not** a freshly-synthesised default-PSK channel
|
||||
for that preset.
|
||||
- `channel_index` must be `< MAX_NUM_CHANNELS` (8); the firmware clears it on write otherwise (see
|
||||
§2.2 sanitise rules). This is the **only** check on write — the firmware does **not** verify that
|
||||
§2.2 sanitise rules). This is the **only** check on write - the firmware does **not** verify that
|
||||
the referenced slot is actually populated, because you may legitimately write the beacon config
|
||||
before creating the channel. **Validating that a referenced channel exists is the client app's
|
||||
responsibility.** A dangling reference doesn't error; it silently falls back to the preset's
|
||||
default channel — so without a client-side check, the user can believe they're advertising
|
||||
default channel - so without a client-side check, the user can believe they're advertising
|
||||
channel _X_ while the node is really transmitting on the preset default. Before writing, confirm
|
||||
each `channel_index` maps to a configured `Channel`, and warn the user otherwise.
|
||||
- **No automatic deduplication of channels.** Neither the beacon config nor the channel table
|
||||
@@ -432,9 +432,9 @@ Notes:
|
||||
indices whose slots hold identical settings, and `set_channel` will happily store two slots with
|
||||
the same name/PSK. The broadcaster _does_ skip transmitting a target whose effective
|
||||
preset/region/channel duplicates an earlier one in the same cycle (so a duplicated entry wastes
|
||||
no airtime), but it does not rewrite or reject your config — keeping the target list free of
|
||||
no airtime), but it does not rewrite or reject your config - keeping the target list free of
|
||||
redundant entries is up to the client.
|
||||
- The single-target path needs no separate `set_channel` step — its `broadcast_on_channel` is
|
||||
- The single-target path needs no separate `set_channel` step - its `broadcast_on_channel` is
|
||||
written inline in the same beacon-config message.
|
||||
|
||||
### 2.6 Quick reference
|
||||
@@ -449,6 +449,6 @@ Notes:
|
||||
| Min broadcast interval | 3600 s (1 h) |
|
||||
| Message max length | 100 bytes |
|
||||
| Hop behaviour | Zero-hop (`hop_limit = 0`), never rebroadcast; `hop_start = 1` under `FLAG_LEGACY_SPLIT` |
|
||||
| Auto-apply offers? | **Never** — client + user decide |
|
||||
| Auto-apply offers? | **Never** - client + user decide |
|
||||
| Offer PSK | Public join token, not a secret |
|
||||
| Disabled today | `broadcast_send_as_node` application |
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
# NextHop direct-message reliability on dense meshes — findings & plan
|
||||
# NextHop direct-message reliability on dense meshes - findings & plan
|
||||
|
||||
**Status:** Implemented — mitigations and tests in `PR3-tmm-nexthop`
|
||||
**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.
|
||||
**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
|
||||
references throughout) and standalone - you should not need the original investigation
|
||||
context to pick it up.
|
||||
|
||||
---
|
||||
@@ -17,16 +17,16 @@ context to pick it up.
|
||||
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
|
||||
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
|
||||
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
|
||||
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.
|
||||
@@ -51,25 +51,25 @@ never collides with the `0`-valued sentinels `NO_NEXT_HOP_PREFERENCE` / `NO_RELA
|
||||
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`):
|
||||
**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`):
|
||||
**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
|
||||
**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`
|
||||
**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
|
||||
@@ -78,7 +78,7 @@ from the **reverse** path's relayer.
|
||||
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
|
||||
**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
|
||||
@@ -95,7 +95,7 @@ that array.
|
||||
| 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). |
|
||||
| 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,
|
||||
@@ -106,13 +106,13 @@ Collision math (uniform last byte over 255 buckets): P(collision) ≈ 50% at ~19
|
||||
|
||||
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
|
||||
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
|
||||
`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.
|
||||
@@ -124,7 +124,7 @@ grows with channel utilization, so retransmit intervals **lengthen** exactly whe
|
||||
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
|
||||
### 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
|
||||
@@ -140,9 +140,9 @@ plan rather than changing it:
|
||||
- **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.
|
||||
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"
|
||||
recover which full node number a colliding value meant - so "detect ambiguity → flood"
|
||||
remains the correct strategy.
|
||||
|
||||
---
|
||||
@@ -151,10 +151,10 @@ plan rather than changing it:
|
||||
|
||||
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
|
||||
(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)
|
||||
### 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):
|
||||
@@ -181,13 +181,13 @@ bool resolveUniqueLastByte(uint8_t lastByte, bool requireDirectNeighbor, NodeNum
|
||||
**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
|
||||
- **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
|
||||
### 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,
|
||||
@@ -219,7 +219,7 @@ Apply M1's safe fallback at the other sites:
|
||||
**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
|
||||
(`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
|
||||
@@ -228,7 +228,7 @@ Apply M1's safe fallback at the other sites:
|
||||
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)
|
||||
### 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.
|
||||
@@ -253,20 +253,20 @@ 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). |
|
||||
| `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`):
|
||||
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`
|
||||
`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
|
||||
@@ -287,16 +287,16 @@ the current DM (reset NodeDB `next_hop` + flood, and bump the cross-DM failure c
|
||||
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)
|
||||
### M4 - Earlier flood for unverified routes (gated, off by default)
|
||||
|
||||
Compile-gated so healthy sparse meshes are untouched. **Default is off** — the define
|
||||
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
|
||||
(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
|
||||
@@ -337,13 +337,13 @@ simulator before broad enable.
|
||||
`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).
|
||||
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`
|
||||
### 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
|
||||
@@ -366,7 +366,7 @@ testable without a clock mock.
|
||||
### 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
|
||||
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
|
||||
@@ -374,15 +374,15 @@ via the `LOG_INFO "Route to … stale"` / "Resetting next hop" lines). Use this
|
||||
|
||||
### 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
|
||||
- `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
|
||||
- `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
|
||||
- `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
|
||||
@@ -406,13 +406,13 @@ production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B
|
||||
|
||||
**Pending (environment-blocked, not yet run):**
|
||||
|
||||
- **Multi-hop A–B–C recovery sim** — the `simulator/` broker hub is **not git-tracked**
|
||||
- **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:
|
||||
- **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
|
||||
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
|
||||
@@ -424,9 +424,9 @@ production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B
|
||||
|
||||
## Risks & limitations
|
||||
|
||||
- **Site-1 impostor rebroadcast** is unfixable without a wider field — documented; M1/M2
|
||||
- **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
|
||||
- **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 +
|
||||
@@ -441,14 +441,14 @@ production; sanity-check RAM headroom on the smallest nRF52 build for the ~384 B
|
||||
|
||||
Each step is independently testable; land them as separate commits.
|
||||
|
||||
1. **M1 resolver + unit tests** — `NodeDB` only; no behavior change until wired. Lands the
|
||||
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
|
||||
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/
|
||||
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
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user