From ec5d2303052a9cea4164ea32da7e09eb5dddb9c9 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sat, 27 Jun 2026 14:20:51 +0100 Subject: [PATCH] Feat/mesh beacon (#10618) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Tips robot virtual node / relayer to different LoRa modes & channels Note that this commit has details hardcoded for the Wellington (NZ) mesh, and also requires the following patch to the protobufs: ----- diff --git a/meshtastic/mesh.proto b/meshtastic/mesh.proto index 03162d8..ec54c99 100644 --- a/meshtastic/mesh.proto +++ b/meshtastic/mesh.proto @@ -1393,6 +1393,21 @@ message MeshPacket { * Set by the firmware internally, clients are not supposed to set this. */ uint32 tx_after = 20; + + /* + * The modem preset to use fo rthis packet + */ + uint32 modem_preset = 21; + + /* + * The frequency slot to use for this packet + */ + uint32 frequency_slot = 22; + + /* + * Whether the packet has a nonstandard radio config + */ + bool nonstandard_radio_config = 23; } /* ----- * fix: repair mesh tips CI build * feat: add MeshBeacon module (Phase 1 — proto + generated code + initial stub) * feat(beacon): implement broadcaster + listener (phases 2-5) * feat(beacon): wire RadioLibInterface hooks + admin validation (phases 6-7) * fix(beacon): fix LocalModuleConfig flat access (no payload_variant), add localonly proto field * feat(beacon): fix broadcaster inheritance, add preset/region validation + proto cache - MeshBeaconBroadcastModule now inherits ProtobufModule (alongside private MeshBeaconModule + OSThread), giving it allocDataPacket() and setStartDelay() without extra includes. - Payload cache: rebuildCache() encodes the MeshBeacon protobuf once and stores it in payloadCache[]/payloadCacheSize; sendBeacon() only calls rebuildCache() when payloadCacheDirty==true. AdminModule calls invalidateCache() after saving new config so the next broadcast picks up changes. - Region/preset validation in handleSetModuleConfig (mesh_beacon_tag): broadcast_on_preset is validated against the device's current region via RadioInterface::validateConfigLora(); broadcast_offer_region is validated via RadioInterface::validateConfigRegion(). Invalid values are zeroed with a LOG_WARN before saving. * feat(beacon): add unit tests for MeshBeaconModule and AdminModule configuration validation * remove old meshtips * more validation in NodeDB and AdminModule, and userprefs for baked in goodness * copilot is my gravity * mmmmm... beacon * oops * Enhance unit tests for MeshBeaconModule with detailed validation checks and output formatting * new lines. Why not? * finally * legacy mode activate! * Update protobufs (#17) Co-authored-by: NomDeTom <116762865+NomDeTom@users.noreply.github.com> * better logic, fixed a test * updated for packet signing fixed a test added guards for licensed/ham mode * channel numbers * beacon: encrypt on the beacon channel PSK; fix split note When broadcast_on_channel overrides the primary channel's name/PSK, the beacon was encrypted with the PRIMARY PSK: perhapsEncode keys encryption off the primary slot, but the radio-thread channel switch happens only after encryption. sendBeaconPacket() now installs the beacon channel into the primary slot for the synchronous duration of send() (cooperative threading => no interleaving) so encryption/hash use the beacon channel, then restores it. A shared beaconChannelSettings() helper builds the channel for both the encrypt-time swap and the RF-time swap so the key+hash cannot drift. Also: correct the legacy-split comments (both packets go out on the same beacon radio settings, not the normal config) and merge the two consecutive `if (hasText)` blocks in the listener (cppcheck duplicateCondition). Tests: add channelPskOverride_swapsBeaconChannelAndRestores and noChannelOverride_doesNotSwapPrimary; MockRouter snapshots the primary channel at send() time. Co-Authored-By: Claude Opus 4.8 (1M context) * test/beacon: drain toPhoneQueue in tearDown to fix LSan leak abort The listener delivers received text via MeshService::sendToPhone(), which enqueues the packet into toPhoneQueue and takes ownership. Nothing dequeues it in tests, so the three listener tests carrying message text stranded a MeshPacket each — 1272 bytes / 3 allocations that LeakSanitizer flagged at process exit, aborting the coverage run (surfaced by pio as [ERRORED] / SIGHUP even though all 40 assertions passed). Drain the phone queue in tearDown (getForPhone()/releaseToPool) so the packets return to packetPool. Suite is now GREEN with no sanitizer abort. Co-Authored-By: Claude Opus 4.8 (1M context) * legacy hop override for zero-hoppers * ever more beacons * beacon: comment out broadcast_send_as_node pending further review Functionality preserved in comments with full signing/has_bitfield notes for when it is re-enabled. Proto tag 3 retained on the wire. Co-Authored-By: Claude Sonnet 4.6 * test/beacon: fix fromIsCustomNodeWhenSet now that send-as-node is disabled broadcast_send_as_node is commented out; from is always the local node. Update the test assertion and doc comment to match current behaviour. Co-Authored-By: Claude Sonnet 4.6 * Update protobufs (#21) Co-authored-by: NomDeTom <116762865+NomDeTom@users.noreply.github.com> * flags for beacons * beacon: do more with less — slot-index targets + validation Multi-target beacons embedded a full ChannelSettings in every BroadcastTarget, blowing ModuleConfig past the 512-byte BLE FromRadio budget so the firmware would not compile. Targets now reference an existing channel-table slot by channel_index and the broadcaster resolves it via channels.getByIndex() at TX time. Net effect: the same multi-target capability for a fraction of the bytes — FromRadio 609 -> 510 B, MeshBeaconConfig 596 -> 324 B, AdminMessage 615 -> 511 B. - proto: BroadcastTarget.channel (embedded) -> channel_index (uint32 ref); regen all generated headers (size constants propagate to admin/localonly/deviceonly/mesh). - broadcaster: resolve channel_index from the channel table; an out-of-range or blank slot falls back to the default channel for the target preset rather than borrowing the primary's name/PSK. - AdminModule: validate broadcast_targets entries on write (region/preset sanitised like the single-target fields; channel_index range-checked). - userPrefs: TARGET__CHANNEL_{NAME,NUM,PSK} collapse to a single CHANNEL_INDEX. - docs: two-step (set_channel -> set_module_config) multi-target setup, inline-vs- reference distinction, and single-/multi-target are equal (not "legacy") options. - tests: target validation + channel-index resolution incl. blank-slot fallback (47/47 green on `./bin/run-tests.sh -e native -f test_mesh_beacon`). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NRAF5csgsMn6p1zEcFL8Qz * throttling after reboot * address copilot review * simplify * fix(beacon): use 0x%08x for node/packet IDs in logs; register test suite The %#08lx log specifiers passed uint32_t (NodeNum/PacketId) to a %lx length modifier — undefined behaviour on 64-bit (native test) targets and non-standard width. Switch to the project-standard 0x%08x. Also bump test/native-suite-count to 25 for the added test_mesh_beacon suite. clod helped too * copilot & clarity clod helped too * refactor(beacon): use auto for the sanitized config copy clod helped too * fix(beacon): guard empty-payload sends; gate has_mesh_beacon on build flag; document ISR_TX pre-switch clod helped too --------- Co-authored-by: Steve Gilberd Co-authored-by: Darafei Praliaskouski Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) --- docs/mesh_beacon_module.md | 454 +++++++++ src/mesh/Channels.h | 10 +- src/mesh/Default.h | 1 + src/mesh/NodeDB.cpp | 149 +++ src/mesh/RadioLibInterface.cpp | 44 +- src/mesh/Router.cpp | 13 + src/modules/AdminModule.cpp | 99 ++ src/modules/AdminModule.h | 4 + src/modules/MeshBeaconModule.cpp | 611 +++++++++++ src/modules/MeshBeaconModule.h | 163 +++ src/modules/Modules.cpp | 7 + test/native-suite-count | 2 +- test/test_mesh_beacon/test_main.cpp | 1457 +++++++++++++++++++++++++++ userPrefs.jsonc | 31 + 14 files changed, 3037 insertions(+), 8 deletions(-) create mode 100644 docs/mesh_beacon_module.md create mode 100644 src/modules/MeshBeaconModule.cpp create mode 100644 src/modules/MeshBeaconModule.h create mode 100644 test/test_mesh_beacon/test_main.cpp diff --git a/docs/mesh_beacon_module.md b/docs/mesh_beacon_module.md new file mode 100644 index 000000000..617dd797e --- /dev/null +++ b/docs/mesh_beacon_module.md @@ -0,0 +1,454 @@ +# 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). + +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 +"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 +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 + +### 1.1 Two roles in one module + +| Role | Class | Active when | What it does | +| --------------- | --------------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| **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 +listening can be enabled independently on the same node. The whole module compiles out under the +`MESHTASTIC_EXCLUDE_BEACON` build flag. + +### 1.2 Wire message + +Beacons travel on a dedicated port number: + +```protobuf +MESH_BEACON_APP = 37 // meshtastic/portnums.proto +ENCODING: protobuf (meshtastic.MeshBeacon) +``` + +```protobuf +message MeshBeacon { + string message = 1; // human-readable text, max 100 bytes (buffer 101) + ChannelSettings offer_channel = 2; // optional advertised channel (name + PSK + slot) + Config.LoRaConfig.RegionCode offer_region = 3; // optional advertised region (UNSET = none) + optional Config.LoRaConfig.ModemPreset offer_preset = 4; // optional advertised preset +} +``` + +`.options` size caps (enforced at generation and on send): +`message ≤ 100`, `offer_channel.name ≤ 12`, `offer_channel.psk ≤ 32`. + +The three `offer_*` fields together describe _"there is a reachable mesh on this +region+preset, here is the channel to use."_ Any subset may be present; an empty message with +a populated offer (or vice-versa) is valid. + +### 1.3 Transmission behaviour + +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 + 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 + [§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**. + +#### Interval + +`broadcast_interval_secs` controls cadence. The floor is **3600 s (1 hour)** +(`default_mesh_beacon_min_broadcast_interval_secs`); `0` means "use default". Values below the +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 +(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` / +`PositionModule`. + +#### Radio switching for TX + +A beacon's whole point is often to reach a mesh on a _different_ preset/region/channel than the +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 +(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, + never `owner.is_licensed`.) +2. **The preset must be valid for the target region** (`validateConfigLora`). + + If either fails, the radio is **not** switched and the radio driver **drops** the packet rather + than letting it fall through onto the current config. + +#### Channel encryption on an override channel + +Encryption keys off the **primary** channel slot, and the radio-thread channel switch happens +_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 +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 +deployment. + +- **Single-target:** the scalar `broadcast_on_preset` / `broadcast_on_region` / + `broadcast_on_channel` fields describe one destination. Used when `broadcast_targets` is empty. +- **Multi-target:** `broadcast_targets` (repeated `BroadcastTarget`) describes several. When + 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. + +#### Same-settings vs. other-settings + +Independent of single/multi, each destination can either reuse the node's **own current radio +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. +- **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)). + +Both modes support both styles: a single-target beacon with no `broadcast_on_*` overrides is a +message-of-the-day on the current mesh; a multi-target list can mix one entry on the current +settings with others on different presets/regions. + +### 1.5 Legacy split (`FLAG_LEGACY_SPLIT`) + +This one flag controls **two** independent legacy-compatibility behaviours. Both are about making +beacons usable by firmware that predates this module. + +**(a) Text/offer packet split.** A combined `MESH_BEACON_APP` packet carries both the text and the +offer, but old firmware only decodes `TEXT_MESSAGE_APP` and would never show the text. When +`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**. + +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. + +**(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 +remains genuinely zero-hop (`hop_limit = 0` still prevents any rebroadcast). + +> **Side effect for clients:** with `hop_start = 1, hop_limit = 0`, receivers compute +> `hops_away = hop_start − hop_limit = 1`, so a legacy-split beacon reads as **1 hop away** even +> though it arrived over direct RF. Without legacy-split it reads as direct (0). Don't treat a +> beacon's `hops_away` as a reliable distance signal. + +### 1.6 `broadcast_send_as_node` (currently disabled) + +The schema reserves `broadcast_send_as_node` (field 3) to send beacons _as_ another node ID. **The +firmware application of this field is currently commented out pending review**, so beacons always +go out as the local node today. The access-control rule is, however, already enforced in +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 +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 +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.** +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. + +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, +not a personal message, so it must not duplicate the text or wake the device from sleep. If a +broadcaster needs non-beacon-aware clients to see the text, it uses `FLAG_LEGACY_SPLIT`, which sends +a real `TEXT_MESSAGE_APP` over RF (see [§1.5](#15-legacy-split-flag_legacy_split)). + +### 1.8 Settings reference (`ModuleConfig.MeshBeaconConfig`, tag 17) + +| # | Field | Type | Meaning / constraints | +| --- | ------------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------ | +| 1 | `flags` | uint32 (bitfield) | Bitwise-OR of `Flags` values (listen / broadcast / legacy-split toggles). See enum below. | +| 3 | `broadcast_send_as_node` | uint32 | Send-as node ID. **Application disabled in firmware.** Remote admin may only set to own node ID. | +| 4 | `broadcast_message` | string | Text in each broadcast. **Hard-capped at 100 bytes.** | +| 5 | `broadcast_offer_channel` | ChannelSettings | Channel advertised in `offer_channel`. | +| 6 | `broadcast_offer_region` | RegionCode | Region advertised in `offer_region`. Must be a known region or it is cleared. | +| 7 | `broadcast_offer_preset` | optional ModemPreset | Preset advertised in `offer_preset`. Validated against offer region (else cleared). | +| 8 | `broadcast_on_channel` | ChannelSettings | Channel to transmit on (single-target). Empty name → preset display name. | +| 9 | `broadcast_on_region` | RegionCode | Region to transmit on (single-target). | +| 10 | `broadcast_on_preset` | optional ModemPreset | Preset to transmit on (single-target). Validated against on-region (else this + `on_channel` cleared). | +| 11 | `broadcast_interval_secs` | uint32 | Cadence. **Min 3600**, default 3600; `0` = default. | +| 13 | `broadcast_targets` | repeated BroadcastTarget | Multi-target list; when non-empty overrides the single-target `broadcast_on_*` fields. | + +> The three boolean toggles were folded into the `flags` bitfield; field tags 2 and 12 are now +> unused (the branch is unreleased, so the old tags are left as gaps rather than reserved). + +**`Flags` enum** (nested in `MeshBeaconConfig`; OR the values into `flags`): + +| Bit value | Name | Meaning | +| --------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 0 | `FLAG_NONE` | No options enabled. | +| 1 | `FLAG_LISTEN_ENABLED` | Receive beacons; cache the offer. The packet flows to the client, which reads `message` directly. | +| 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. + +--- + +## 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. + +### 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. + +### 2.2 Reading and writing configuration + +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 +(`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 +client-side so the UI doesn't disagree with the device: + +| Rule | Firmware behaviour | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `broadcast_message` length | Truncated to 100 bytes. | +| `broadcast_interval_secs` | If non-zero and `< 3600`, raised to 3600. | +| `broadcast_on_preset` invalid for `broadcast_on_region` (or current region) | Cleared, **and `broadcast_on_channel` cleared too.** | +| `broadcast_offer_preset` invalid for offer/current region | Cleared. | +| `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_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 +on the next broadcast cycle. After a successful write, **re-read** the config to display the +effective (sanitised) values. + +### 2.3 Receiving beacons + +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. +2. For packets with `decoded.portnum == MESH_BEACON_APP (37)`, decode `decoded.payload` as a + `meshtastic.MeshBeacon`. +3. Read `message`, `offer_channel`, `offer_region`, `offer_preset` (presence-checked). +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 +> physically receives the RF, but the client will not see beacons over the FromRadio stream until +> listening is enabled. + +#### 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 +**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 +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** — +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. + +**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 +(§2.3), not expect a "get last offer" RPC. + +#### 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 — + 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. + +### 2.5 Configuring this node as a broadcaster + +To make a node advertise a mesh, write `MeshBeaconConfig` with `FLAG_BROADCAST_ENABLED` set in +`flags` and at least one of: a non-empty `broadcast_message`, or offer content +(`broadcast_offer_*`). With neither, the broadcaster has nothing to send and stays silent. + +Typical multi-region invite beacon: + +```text +flags = FLAG_BROADCAST_ENABLED | FLAG_LEGACY_SPLIT // broadcast on; split so legacy nodes still see the text +broadcast_message = "Join us on NarrowSlow!" +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. +broadcast_targets = [ + { preset: LONG_FAST, region: EU_868, channel_index: 0 }, + { preset: NARROW_SLOW, region: EU_N_868, channel_index: 1 }, +] +``` + +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_TARGET__*` for multi-target entries. + +#### 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 +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. + +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) | + +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 +and must carry the actual name/PSK. + +#### Configuring a multi-target broadcaster (two-step) + +Because a target's channel is a reference, configuring a multi-target broadcaster takes **two +admin writes**, in order: + +1. **Create/define each channel in the node's channel table** with the normal channel admin flow + (the same `set_channel` your app already uses for adding channels): + + ```text + AdminMessage.set_channel { index: 1, role: SECONDARY, + settings: { name: "NarrowSlow", psk: , channel_num: 0 } } + ``` + +2. **Write the beacon config**, pointing each target at the slot index from step 1: + + ```text + AdminMessage.set_module_config { mesh_beacon: { + flags = FLAG_BROADCAST_ENABLED + broadcast_targets = [ { preset: NARROW_SLOW, region: EU_N_868, channel_index: 1 } ] + } } + ``` + +Notes: + +- 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 + 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 + 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 + 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 + dedups by content: two `broadcast_targets` may carry the same `channel_index`, or different + 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 + redundant entries is up to the client. +- 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 + +| Concern | Value | +| ---------------------- | ---------------------------------------------------------------------------------------- | +| Port number | `MESH_BEACON_APP = 37` | +| Wire message | `meshtastic.MeshBeacon` | +| Config message | `ModuleConfig.MeshBeaconConfig` (variant tag 17) | +| On/off toggles | `flags` bitfield (`MeshBeaconConfig.Flags`) | +| Local config presence | `LocalModuleConfig.mesh_beacon` (tag 18) | +| 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 | +| Offer PSK | Public join token, not a secret | +| Disabled today | `broadcast_send_as_node` application | diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 7392284f5..d1ae9b91b 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -105,6 +105,11 @@ class Channels bool setDefaultPresetCryptoForHash(ChannelHash channelHash); + /** + * Validate a channel, fixing any errors as needed + */ + meshtastic_Channel &fixupChannel(ChannelIndex chIndex); + int16_t getHash(ChannelIndex i) { return hashes[i]; } private: @@ -124,11 +129,6 @@ class Channels */ int16_t generateHash(ChannelIndex channelNum); - /** - * Validate a channel, fixing any errors as needed - */ - meshtastic_Channel &fixupChannel(ChannelIndex chIndex); - /** * Writes the default lora config */ diff --git a/src/mesh/Default.h b/src/mesh/Default.h index e802a0324..b38a68185 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -30,6 +30,7 @@ #define default_screen_on_secs IF_ROUTER(1, 60 * 10) #define default_node_info_broadcast_secs 3 * 60 * 60 #define default_neighbor_info_broadcast_secs 6 * 60 * 60 +#define default_mesh_beacon_min_broadcast_interval_secs 3600 #define min_node_info_broadcast_secs 60 * 60 // No regular broadcasts of more than once an hour #define min_neighbor_info_broadcast_secs 4 * 60 * 60 #define default_map_publish_interval_secs 60 * 60 diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 05d36c8af..3a01aa357 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1308,6 +1308,152 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.ambient_lighting.green = (myNodeInfo.my_node_num & 0x00FF00) >> 8; moduleConfig.ambient_lighting.blue = myNodeInfo.my_node_num & 0x0000FF; +#if !MESHTASTIC_EXCLUDE_BEACON + moduleConfig.has_mesh_beacon = true; + // Default flags: listen on, broadcast off, legacy split on. + moduleConfig.mesh_beacon.flags = meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED | + meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT; +// Set or clear a single beacon flag bit from a USERPREFS boolean. +#define BEACON_APPLY_FLAG(enabled, flag) \ + do { \ + if (enabled) \ + moduleConfig.mesh_beacon.flags |= (uint32_t)(flag); \ + else \ + moduleConfig.mesh_beacon.flags &= ~(uint32_t)(flag); \ + } while (0) +#ifdef USERPREFS_MESH_BEACON_LISTEN_ENABLED + BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_LISTEN_ENABLED, meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED); +#endif +#ifdef USERPREFS_MESH_BEACON_BROADCAST_ENABLED + BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_BROADCAST_ENABLED, + meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_BROADCAST_ENABLED); +#endif +#ifdef USERPREFS_MESH_BEACON_MESSAGE + strncpy(moduleConfig.mesh_beacon.broadcast_message, USERPREFS_MESH_BEACON_MESSAGE, + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.broadcast_message[sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1] = '\0'; +#endif +#ifdef USERPREFS_MESH_BEACON_INTERVAL_SECS + moduleConfig.mesh_beacon.broadcast_interval_secs = + (USERPREFS_MESH_BEACON_INTERVAL_SECS != 0 && + USERPREFS_MESH_BEACON_INTERVAL_SECS < default_mesh_beacon_min_broadcast_interval_secs) + ? default_mesh_beacon_min_broadcast_interval_secs + : USERPREFS_MESH_BEACON_INTERVAL_SECS; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_PRESET + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = USERPREFS_MESH_BEACON_OFFER_PRESET; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_REGION + moduleConfig.mesh_beacon.broadcast_offer_region = USERPREFS_MESH_BEACON_OFFER_REGION; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME + moduleConfig.mesh_beacon.has_broadcast_offer_channel = true; + strncpy(moduleConfig.mesh_beacon.broadcast_offer_channel.name, USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME, + sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.name) - 1); + moduleConfig.mesh_beacon.broadcast_offer_channel.name[sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.name) - 1] = + '\0'; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK + moduleConfig.mesh_beacon.has_broadcast_offer_channel = true; + static const uint8_t beaconOfferPsk[] = USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK; + static_assert(sizeof(beaconOfferPsk) <= sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes), + "USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK exceeds the 32-byte channel PSK buffer"); + memcpy(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes, beaconOfferPsk, sizeof(beaconOfferPsk)); + moduleConfig.mesh_beacon.broadcast_offer_channel.psk.size = sizeof(beaconOfferPsk); +#endif +#ifdef USERPREFS_MESH_BEACON_ON_PRESET + moduleConfig.mesh_beacon.has_broadcast_on_preset = true; + moduleConfig.mesh_beacon.broadcast_on_preset = USERPREFS_MESH_BEACON_ON_PRESET; +#endif +#ifdef USERPREFS_MESH_BEACON_ON_REGION + moduleConfig.mesh_beacon.broadcast_on_region = USERPREFS_MESH_BEACON_ON_REGION; +#endif +#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NAME + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, USERPREFS_MESH_BEACON_ON_CHANNEL_NAME, + sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); + moduleConfig.mesh_beacon.broadcast_on_channel.name[sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1] = '\0'; +#endif +#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_PSK + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + static const uint8_t beaconOnPsk[] = USERPREFS_MESH_BEACON_ON_CHANNEL_PSK; + static_assert(sizeof(beaconOnPsk) <= sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes), + "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK exceeds the 32-byte channel PSK buffer"); + memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconOnPsk, sizeof(beaconOnPsk)); + moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconOnPsk); +#endif +#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NUM + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + moduleConfig.mesh_beacon.broadcast_on_channel.channel_num = USERPREFS_MESH_BEACON_ON_CHANNEL_NUM; +#endif +#ifdef USERPREFS_MESH_BEACON_LEGACY_SPLIT + BEACON_APPLY_FLAG(USERPREFS_MESH_BEACON_LEGACY_SPLIT, meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT); +#endif +#undef BEACON_APPLY_FLAG +// Per-preset broadcast targets (up to 4). Each TARGET__* key bumps broadcast_targets_count as needed. +#define BEACON_TARGET_PRESET(N, VAL) \ + do { \ + if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1) \ + moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1; \ + moduleConfig.mesh_beacon.broadcast_targets[(N)].has_preset = true; \ + moduleConfig.mesh_beacon.broadcast_targets[(N)].preset = (VAL); \ + } while (0) +#define BEACON_TARGET_REGION(N, VAL) \ + do { \ + if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1) \ + moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1; \ + moduleConfig.mesh_beacon.broadcast_targets[(N)].region = (VAL); \ + } while (0) +// Target channel is referenced by index into the device's channel table (0..MAX_NUM_CHANNELS-1). +#define BEACON_TARGET_CH_INDEX(N, VAL) \ + do { \ + if (moduleConfig.mesh_beacon.broadcast_targets_count < (N) + 1) \ + moduleConfig.mesh_beacon.broadcast_targets_count = (N) + 1; \ + moduleConfig.mesh_beacon.broadcast_targets[(N)].has_channel_index = true; \ + moduleConfig.mesh_beacon.broadcast_targets[(N)].channel_index = (VAL); \ + } while (0) +#ifdef USERPREFS_MESH_BEACON_TARGET_0_PRESET + BEACON_TARGET_PRESET(0, USERPREFS_MESH_BEACON_TARGET_0_PRESET); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_0_REGION + BEACON_TARGET_REGION(0, USERPREFS_MESH_BEACON_TARGET_0_REGION); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_0_CHANNEL_INDEX + BEACON_TARGET_CH_INDEX(0, USERPREFS_MESH_BEACON_TARGET_0_CHANNEL_INDEX); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_1_PRESET + BEACON_TARGET_PRESET(1, USERPREFS_MESH_BEACON_TARGET_1_PRESET); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_1_REGION + BEACON_TARGET_REGION(1, USERPREFS_MESH_BEACON_TARGET_1_REGION); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_1_CHANNEL_INDEX + BEACON_TARGET_CH_INDEX(1, USERPREFS_MESH_BEACON_TARGET_1_CHANNEL_INDEX); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_2_PRESET + BEACON_TARGET_PRESET(2, USERPREFS_MESH_BEACON_TARGET_2_PRESET); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_2_REGION + BEACON_TARGET_REGION(2, USERPREFS_MESH_BEACON_TARGET_2_REGION); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_2_CHANNEL_INDEX + BEACON_TARGET_CH_INDEX(2, USERPREFS_MESH_BEACON_TARGET_2_CHANNEL_INDEX); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_3_PRESET + BEACON_TARGET_PRESET(3, USERPREFS_MESH_BEACON_TARGET_3_PRESET); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_3_REGION + BEACON_TARGET_REGION(3, USERPREFS_MESH_BEACON_TARGET_3_REGION); +#endif +#ifdef USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX + BEACON_TARGET_CH_INDEX(3, USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX); +#endif +#undef BEACON_TARGET_PRESET +#undef BEACON_TARGET_REGION +#undef BEACON_TARGET_CH_INDEX +#endif // !MESHTASTIC_EXCLUDE_BEACON + initModuleConfigIntervals(); } @@ -2758,6 +2904,9 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) moduleConfig.has_audio = true; moduleConfig.has_paxcounter = true; moduleConfig.has_statusmessage = true; +#if !MESHTASTIC_EXCLUDE_BEACON + moduleConfig.has_mesh_beacon = true; +#endif success &= saveProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, &meshtastic_LocalModuleConfig_msg, &moduleConfig); diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index daf98696e..3aff92831 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -8,6 +8,9 @@ #include "error.h" #include "main.h" #include "mesh-pb-constants.h" +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif #include #include @@ -365,7 +368,15 @@ void RadioLibInterface::onNotify(uint32_t notification) switch (notification) { case ISR_TX: - handleTransmitInterrupt(); + handleTransmitInterrupt(); // completeSending() already restored the radio to the home config +#if !MESHTASTIC_EXCLUDE_BEACON + // Pre-switch the radio to the NEXT queued packet's beacon config (no-op for normal traffic). + // Not required for correctness — TRANSMIT_DELAY_COMPLETED would switch before CAD anyway — but + // doing it here lets the next beacon skip the switch-only delay cycle and, more importantly, + // keeps the post-TX listen window (and the CAD/LBT that follows) on the channel we're about to + // transmit on. Only engages when the next packet is itself a beacon — exactly when we want it. + MeshBeaconModule::reconfigureForBeaconTX(this, txQueue.getFront()); +#endif startReceive(); setTransmitDelay(); break; @@ -388,9 +399,27 @@ void RadioLibInterface::onNotify(uint32_t notification) if (delay_remaining > 0) { // There's still some delay pending on this packet, so resume waiting for it to elapse notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, false); +#if !MESHTASTIC_EXCLUDE_BEACON + } else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) { + // The beacon's target radio config is invalid (bad preset/region, or an + // unlicensed node keying up on a ham-only region). Drop the packet — never + // transmit it on the current (home) config — and move on to the next queued packet. + LOG_DEBUG("Beacon: invalid TX radio config, dropping packet 0x%08x", txp->id); + meshtastic_MeshPacket *bad = txQueue.dequeue(); + MeshBeaconModule::clearTargetRadioSettings(bad); + packetPool.release(bad); + setTransmitDelay(); + } else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) { + setTransmitDelay(); +#endif } else { if (isChannelActive()) { // check if there is currently a LoRa packet on the channel - startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again +#if !MESHTASTIC_EXCLUDE_BEACON + if (!MeshBeaconModule::hasTargetRadioSettings(txp)) +#endif + { + startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again + } setTransmitDelay(); } else { // Send any outgoing packets we have ready as fast as possible to keep the time between channel scan and @@ -522,6 +551,10 @@ void RadioLibInterface::completeSending() if (!isFromUs(p)) txRelay++; printPacket("Completed sending", p); +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); + MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); +#endif // We are done sending that packet, release it packetPool.release(p); @@ -682,6 +715,13 @@ bool RadioLibInterface::startSend(meshtastic_MeshPacket *txp) channel scan and actual transmit as low as possible to avoid collisions. */ if (disabled || !config.lora.tx_enabled) { LOG_WARN("Drop Tx packet because LoRa Tx disabled"); +#if !MESHTASTIC_EXCLUDE_BEACON + // This packet may have already triggered a beacon radio switch in TRANSMIT_DELAY_COMPLETED; + // since it never reaches completeSending() here, restore the radio so it isn't left on the + // beacon config (which would also break RX on the home channel). + MeshBeaconModule::clearTargetRadioSettings(txp); + MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); +#endif packetPool.release(txp); return false; } else { diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index d52fa49f7..6f08e8cab 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -832,6 +832,19 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src) skipHandle = true; } +#if !MESHTASTIC_EXCLUDE_BEACON + // Beacon listening is disabled: drop beacon packets so they are neither surfaced to the + // phone nor handled on-device (same pattern as the disabled neighbor-info case above). + if (p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && + p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP && + (!moduleConfig.has_mesh_beacon || + !(moduleConfig.mesh_beacon.flags & meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED))) { + LOG_DEBUG("Beacon listening is disabled, ignore beacon packet"); + cancelSending(p->from, p->id); + skipHandle = true; + } +#endif + bool shouldIgnoreNonstandardPorts = config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY; #if USERPREFS_EVENT_MODE diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 39ae6b788..e19ad3dbb 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -35,6 +35,9 @@ #ifdef MESHTASTIC_ENCRYPTED_STORAGE #include "security/EncryptedStorage.h" #endif +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" @@ -313,6 +316,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta case meshtastic_AdminMessage_set_module_config_tag: LOG_DEBUG("Client set module config"); +#if !MESHTASTIC_EXCLUDE_BEACON + // broadcast_send_as_node: remote admins may only set this to their own node ID. + if (mp.from != 0 && r->set_module_config.which_payload_variant == meshtastic_ModuleConfig_mesh_beacon_tag) { + auto &b = r->set_module_config.payload_variant.mesh_beacon; + if (b.broadcast_send_as_node != 0 && b.broadcast_send_as_node != mp.from) { + LOG_WARN("Beacon: rejecting broadcast_send_as_node 0x%08x from node 0x%08x (must match sender)", + b.broadcast_send_as_node, mp.from); + b.broadcast_send_as_node = moduleConfig.mesh_beacon.broadcast_send_as_node; + } + } +#endif if (!handleSetModuleConfig(r->set_module_config)) { myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); } @@ -1161,6 +1175,91 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) moduleConfig.has_traffic_management = true; moduleConfig.traffic_management = c.payload_variant.traffic_management; break; +#if !MESHTASTIC_EXCLUDE_BEACON + case meshtastic_ModuleConfig_mesh_beacon_tag: { + LOG_INFO("Set module config: MeshBeacon"); + // Sanitize a local copy rather than const_cast-ing the const input (UB if a truly-const + // object is ever passed); the validated copy is assigned into moduleConfig below. + auto beaconCfg = c.payload_variant.mesh_beacon; + // Hard cap at 100 chars. + beaconCfg.broadcast_message[100] = '\0'; + // Enforce interval minimum (0 means unset/use default). + if (beaconCfg.broadcast_interval_secs != 0 && + beaconCfg.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs) + beaconCfg.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs; + // Validate broadcast_on_preset against broadcast_on_region (or current region if unset). + if (beaconCfg.has_broadcast_on_preset) { + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.modem_preset = beaconCfg.broadcast_on_preset; + if (beaconCfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + probe.region = beaconCfg.broadcast_on_region; + if (!RadioInterface::validateConfigLora(probe)) { + LOG_WARN("Beacon: broadcast_on_preset %d invalid for region, clearing", beaconCfg.broadcast_on_preset); + beaconCfg.has_broadcast_on_preset = false; + beaconCfg.has_broadcast_on_channel = false; + } + } + // Validate broadcast_offer_preset against broadcast_offer_region (or current region if unset). + if (beaconCfg.has_broadcast_offer_preset) { + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.modem_preset = beaconCfg.broadcast_offer_preset; + if (beaconCfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + probe.region = beaconCfg.broadcast_offer_region; + if (!RadioInterface::validateConfigLora(probe)) { + LOG_WARN("Beacon: broadcast_offer_preset %d invalid for region, clearing", beaconCfg.broadcast_offer_preset); + beaconCfg.has_broadcast_offer_preset = false; + } + } + // Validate broadcast_offer_region is a known region code. + if (beaconCfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + const RegionInfo *r = getRegion(beaconCfg.broadcast_offer_region); + if (r->code != beaconCfg.broadcast_offer_region) { + LOG_WARN("Beacon: broadcast_offer_region %d invalid, clearing", beaconCfg.broadcast_offer_region); + beaconCfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + } + } + // Validate each multi-target entry the same way as the single-target broadcast_on_* fields, + // so a bad preset/region is cleared on write rather than relying on the runtime TX drop. + for (pb_size_t i = 0; i < beaconCfg.broadcast_targets_count; i++) { + auto &t = beaconCfg.broadcast_targets[i]; + // Region must be a known region code (UNSET = use running config at TX time). + if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + const RegionInfo *r = getRegion(t.region); + if (r->code != t.region) { + LOG_WARN("Beacon: broadcast_targets[%u] region %d invalid, clearing", i, t.region); + t.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + } + } + // Preset must be valid for the target region (or current region if unset). + if (t.has_preset) { + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.modem_preset = t.preset; + if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + probe.region = t.region; + if (!RadioInterface::validateConfigLora(probe)) { + LOG_WARN("Beacon: broadcast_targets[%u] preset %d invalid for region, clearing", i, t.preset); + t.has_preset = false; + t.has_channel_index = false; + } + } + // channel_index must reference a real channel-table slot. + if (t.has_channel_index && t.channel_index >= MAX_NUM_CHANNELS) { + LOG_WARN("Beacon: broadcast_targets[%u] channel_index %u out of range, clearing", i, t.channel_index); + t.has_channel_index = false; + } + } + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon = beaconCfg; + shouldReboot = false; + // Payload content changed — invalidate the broadcaster's cache. + if (meshBeaconBroadcastModule) + meshBeaconBroadcastModule->invalidateCache(); + break; + } +#endif } saveChanges(SEGMENT_MODULECONFIG, shouldReboot); return true; diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 1d5c64423..d072ab2fe 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -64,7 +64,11 @@ class AdminModule : public ProtobufModule, public Obser protected: void handleSetConfig(const meshtastic_Config &c, bool fromOthers); +#ifdef PIO_UNIT_TESTING + protected: +#else private: +#endif bool handleSetModuleConfig(const meshtastic_ModuleConfig &c); void handleSetChannel(); diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp new file mode 100644 index 000000000..8a86aebaf --- /dev/null +++ b/src/modules/MeshBeaconModule.cpp @@ -0,0 +1,611 @@ +#include "MeshBeaconModule.h" +#include "Default.h" +#include "DisplayFormatters.h" +#include "NodeDB.h" +#include "RTC.h" +#include "RadioInterface.h" +#include "Router.h" +#include "TransmitHistory.h" +#include "configuration.h" +#include "main.h" +#include +#include + +// Static members +meshtastic_Config_LoRaConfig_ModemPreset MeshBeaconModule::originalModemPreset; +uint16_t MeshBeaconModule::originalLoraChannel; +meshtastic_Config_LoRaConfig_RegionCode MeshBeaconModule::originalRegion; +meshtastic_ChannelSettings MeshBeaconModule::originalPrimaryChannel; + +static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; + +static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset *preset, + uint16_t *slot, bool *legacyHopOverride = nullptr, + meshtastic_Config_LoRaConfig_RegionCode *region = nullptr, bool *has_channel = nullptr, + meshtastic_ChannelSettings *channel = nullptr) +{ + if (!p) + return false; + for (const auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + if (preset) + *preset = entry.preset; + if (slot) + *slot = entry.slot; + if (legacyHopOverride) + *legacyHopOverride = entry.legacyHopOverride; + if (region) + *region = entry.region; + if (has_channel) + *has_channel = entry.has_channel; + if (channel && entry.has_channel) + *channel = entry.channel; + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// MeshBeaconModule base +// --------------------------------------------------------------------------- + +MeshBeaconModule::MeshBeaconModule() +{ + originalModemPreset = config.lora.modem_preset; + originalLoraChannel = config.lora.channel_num; + originalRegion = config.lora.region; + originalPrimaryChannel = channels.getPrimary(); +} + +void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, + uint16_t slot, bool legacyHopOverride, + meshtastic_Config_LoRaConfig_RegionCode region, bool has_channel, + const meshtastic_ChannelSettings *channel) +{ + if (!p) + return; + MeshBeaconModule_TargetRadioSettings *target = nullptr; + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + target = &entry; + break; + } + if (!target && !entry.inUse) + target = &entry; + } + if (!target) + target = &targetRadioSettings[0]; + target->inUse = true; + target->id = p->id; + target->preset = preset; + target->slot = slot; + target->legacyHopOverride = legacyHopOverride; + target->region = region; + target->has_channel = has_channel; + if (has_channel && channel) + target->channel = *channel; +} + +bool MeshBeaconModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + return getTargetRadioSettings(p, nullptr, nullptr); +} + +void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + if (!p) + return; + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + entry.inUse = false; + return; + } + } +} + +bool MeshBeaconModule::beaconTxConfigInvalid(const meshtastic_MeshPacket *p) +{ + meshtastic_Config_LoRaConfig_ModemPreset preset; + meshtastic_Config_LoRaConfig_RegionCode sidecarRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + if (!getTargetRadioSettings(p, &preset, nullptr, nullptr, &sidecarRegion)) + return false; // not a beacon-switch packet — nothing to validate, normal traffic unaffected + + const meshtastic_Config_LoRaConfig_RegionCode region = + (sidecarRegion != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? sidecarRegion : config.lora.region; + + // An unlicensed node must never key up on a ham-only (licensed-only) region. The reverse is + // allowed: a licensed (ham) node may operate in a non-ham region — and the switch only touches + // preset/region/channel, never owner.is_licensed, so it cannot deactivate licensed mode. + const RegionInfo *r = getRegion(region); + if (r && r->profile->licensedOnly && !owner.is_licensed) + return true; + + // Preset must be valid for the target region. + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.modem_preset = preset; + probe.region = region; + return !RadioInterface::validateConfigLora(probe); +} + +meshtastic_ChannelSettings MeshBeaconModule::beaconChannelSettings(const meshtastic_ChannelSettings &base, + meshtastic_Config_LoRaConfig_ModemPreset preset, + const meshtastic_ChannelSettings *overrideChannel) +{ + meshtastic_ChannelSettings ch = base; + if (overrideChannel) { + ch.channel_num = overrideChannel->channel_num; + if (overrideChannel->name[0] != '\0') + strncpy(ch.name, overrideChannel->name, sizeof(ch.name) - 1); + if (overrideChannel->psk.size > 0) + ch.psk = overrideChannel->psk; + } + // If no usable name survived (no override, or a blank-named one), default to the preset's + // display name so the beacon channel is identifiable rather than borrowing the primary's name. + if (ch.name[0] == '\0') + strncpy(ch.name, DisplayFormatters::getModemPresetDisplayName(preset, false, true), sizeof(ch.name) - 1); + ch.name[sizeof(ch.name) - 1] = '\0'; + return ch; +} + +bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p) +{ + // True while a beacon radio switch is in effect and still needs undoing. We track the switch + // explicitly rather than inferring it from "live config differs from the snapshot", because that + // heuristic both missed cases (a channel name/PSK swap that left preset/slot/region unchanged would + // never be restored) and fired falsely (a legitimate non-beacon channel edit would be reverted on + // the next TX). With the flag the restore fires for ANY field we changed and only when we changed + // it — including on TX-failure paths, which route through this same restore call. + static bool radioSwitched = false; + + meshtastic_ChannelSettings *primaryCh = &channels.getByIndex(channels.getPrimaryIndex()).settings; + meshtastic_Config_LoRaConfig_ModemPreset targetPreset; + uint16_t targetSlot; + + const auto channelDiffers = [&](const meshtastic_ChannelSettings &target) { + return strncmp(primaryCh->name, target.name, sizeof(primaryCh->name)) != 0 || primaryCh->psk.size != target.psk.size || + memcmp(primaryCh->psk.bytes, target.psk.bytes, primaryCh->psk.size) != 0 || + primaryCh->channel_num != target.channel_num; + }; + + bool legacyHopOverride = false; + meshtastic_Config_LoRaConfig_RegionCode sidecarRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + bool sidecarHasChannel = false; + meshtastic_ChannelSettings sidecarChannel = {}; + if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot, &legacyHopOverride, &sidecarRegion, &sidecarHasChannel, + &sidecarChannel)) { + + // Legacy compatibility: older firmware (pre-v2.7.20) drops hop_start==0 packets via the + // pre-hop check before decryption, so they can't see has_bitfield to validate them. + // Setting hop_start=1 (with hop_limit remaining 0) makes the packet pass the old check + // while still being zero-hop (hop_limit=0 prevents any rebroadcast). + if (legacyHopOverride) + p->hop_start = 1; + + const meshtastic_Config_LoRaConfig_RegionCode targetRegion = + (sidecarRegion != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? sidecarRegion : config.lora.region; + const meshtastic_ChannelSettings *overrideCh = sidecarHasChannel ? &sidecarChannel : nullptr; + + meshtastic_ChannelSettings targetChannel = beaconChannelSettings(*primaryCh, targetPreset, overrideCh); + + if (targetPreset == config.lora.modem_preset && targetSlot == config.lora.channel_num && + targetRegion == config.lora.region && !channelDiffers(targetChannel)) + return false; + + // Guard: never key up on an invalid target config — bad preset for the region, or an + // unlicensed node keying up on a ham-only region. Refuse the switch here so we never + // transmit on it; the radio driver drops the packet outright (see RadioLibInterface, + // beaconTxConfigInvalid) rather than letting it fall through onto the current config. + if (beaconTxConfigInvalid(p)) { + LOG_DEBUG("Beacon: target preset %d/region %d invalid (or ham mismatch), not switching", targetPreset, targetRegion); + return false; + } + + // Snapshot current (non-beacon) settings so we restore to the latest config. Skip while a + // switch is already active, so a second switch before the restore can't capture the beacon + // config as the "home" we later restore to. + if (!radioSwitched) { + originalModemPreset = config.lora.modem_preset; + originalLoraChannel = config.lora.channel_num; + originalRegion = config.lora.region; + originalPrimaryChannel = *primaryCh; + } + + LOG_INFO("Beacon: switch radio for packet 0x%08x to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot, + targetRegion); + config.lora.modem_preset = targetPreset; + config.lora.channel_num = targetSlot; + if (targetRegion != config.lora.region) + config.lora.region = targetRegion; + *primaryCh = targetChannel; + + channels.fixupChannel(channels.getPrimaryIndex()); + p->channel = channels.getHash(channels.getPrimaryIndex()); + iface->reconfigure(); + radioSwitched = true; + return true; + + } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && radioSwitched) { + + LOG_INFO("Beacon: restoring radio config after beacon TX"); + config.lora.modem_preset = originalModemPreset; + config.lora.channel_num = originalLoraChannel; + config.lora.region = originalRegion; + *primaryCh = originalPrimaryChannel; + primaryCh->name[sizeof(primaryCh->name) - 1] = '\0'; + + channels.fixupChannel(channels.getPrimaryIndex()); + iface->reconfigure(); + radioSwitched = false; + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// MeshBeaconBroadcastModule +// --------------------------------------------------------------------------- + +MeshBeaconBroadcastModule *meshBeaconBroadcastModule; + +MeshBeaconBroadcastModule::MeshBeaconBroadcastModule() + : MeshBeaconModule(), ProtobufModule("beacon_tx", meshtastic_PortNum_MESH_BEACON_APP, &meshtastic_MeshBeacon_msg), + concurrency::OSThread("MeshBeaconBroadcast") +{ + setIntervalFromNow(setStartDelay()); +} + +void MeshBeaconBroadcastModule::rebuildCache() +{ + const auto &bcfg = moduleConfig.mesh_beacon; + meshtastic_MeshBeacon beacon = meshtastic_MeshBeacon_init_zero; + strncpy(beacon.message, bcfg.broadcast_message, sizeof(beacon.message) - 1); + if (bcfg.has_broadcast_offer_channel) { + beacon.has_offer_channel = true; + beacon.offer_channel = bcfg.broadcast_offer_channel; + // PSK is included intentionally: this beacon is a public join-invitation. + // The offered channel is not secret — the PSK here is a convenience token, + // not a security boundary. Operators who want a private channel must + // distribute the PSK out-of-band and leave offer_channel unset. + } + beacon.has_offer_preset = bcfg.has_broadcast_offer_preset; + beacon.offer_preset = bcfg.broadcast_offer_preset; + beacon.offer_region = bcfg.broadcast_offer_region; + // Note: an empty config legitimately encodes to 0 bytes, and pb_encode_to_bytes can't distinguish + // that from a (here effectively impossible — buffer is max-sized) failure, so we always clear the + // dirty flag. The combined send is gated on payloadCacheSize > 0, so an empty payload is never TX'd. + payloadCacheSize = (pb_size_t)pb_encode_to_bytes(payloadCache, sizeof(payloadCache), &meshtastic_MeshBeacon_msg, &beacon); + payloadCacheDirty = false; + LOG_DEBUG("Beacon: payload cache rebuilt (%u bytes)", payloadCacheSize); +} + +void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset, + bool has_channel, const meshtastic_ChannelSettings *overrideChannel) +{ + const bool cryptoOverride = + has_channel && overrideChannel && (overrideChannel->name[0] != '\0' || overrideChannel->psk.size > 0); + if (!cryptoOverride) { + router->send(p); + return; + } + + // perhapsEncode() keys encryption (and the channel-hash hint) off the PRIMARY channel slot, and + // the radio-thread channel switch only happens AFTER encryption — so a beacon on an override + // channel would otherwise be encrypted with the PRIMARY PSK, not the beacon channel's. Install the + // beacon channel into the primary slot for the synchronous duration of send(), then restore. + // Meshtastic threading is cooperative (no preemption between the swap and restore). + meshtastic_Channel &primary = channels.getByIndex(channels.getPrimaryIndex()); + const meshtastic_ChannelSettings saved = primary.settings; + primary.settings = beaconChannelSettings(saved, targetPreset, overrideChannel); + channels.fixupChannel(channels.getPrimaryIndex()); + + router->send(p); // encrypts with the beacon channel's key and stamps its hash + + primary.settings = saved; + channels.fixupChannel(channels.getPrimaryIndex()); +} + +void MeshBeaconBroadcastModule::sendBeacon() +{ + const auto &bcfg = moduleConfig.mesh_beacon; + + const bool hasText = bcfg.broadcast_message[0] != '\0'; + const bool hasRadioContent = bcfg.has_broadcast_offer_preset || bcfg.has_broadcast_offer_channel || + (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET); + + if (!hasText && !hasRadioContent) { + LOG_DEBUG("Beacon: nothing to send (empty message, no offer), skipping"); + return; + } + + // Stamp common fields shared by every outgoing beacon packet. + const auto stampPacket = [&](meshtastic_MeshPacket *p) { + p->to = NODENUM_BROADCAST; + p->from = nodeDB->getNodeNum(); + // broadcast_send_as_node: commented out pending further review. + // Spoof notes preserved for when this is re-enabled: + // broadcast_send_as_node overrides the source NodeNum. NOTE: this is a *node-ID* spoof + // only — it rewrites the 'from' field but does NOT forge any signature. Once 'from' is + // not us, the packet is no longer isFromUs(), so Router::perhapsEncode() skips XEdDSA + // signing and receivers get an unsigned packet attributed to another node. + // When broadcast_send_as_node == 0 the beacon is genuinely from us and Router::perhapsEncode() + // signs it under the same XEdDSA broadcast policy as normal channel messages. + // When broadcast_send_as_node rewrites p->from, perhapsEncode() sees isFromUs()=false and + // skips setting has_bitfield — must be set explicitly so receivers can classify hop_start + // correctly and so ok_to_mqtt is honoured on the spoofed packet. + // if (bcfg.broadcast_send_as_node != 0) { + // p->from = bcfg.broadcast_send_as_node; + // p->decoded.has_bitfield = true; + // p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT); + // } + p->hop_limit = 0; // all beacon packets are zero hopped to limit spamming. + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + p->want_ack = false; + p->rx_time = getValidTime(RTCQualityFromNet); + }; + + // ── Packet type decisions ──────────────────────────────────────────────── + // + // FLAG_LEGACY_SPLIT: when both text and offer are present, send TWO packets — A) + // MESH_BEACON_APP (offer only) and B) TEXT_MESSAGE_APP (text only) — both on the SAME beacon + // radio settings, so nodes that only decode TEXT_MESSAGE_APP still receive the text. Otherwise a + // single packet is sent (offer-only, text-only, or the combined offer+text path). + // + // These are independent decisions, NOT a mutually-exclusive if/else chain: the split + // case must emit both A and B. Conditions are spelled out as named booleans to avoid + // the && / || precedence trap (and a prior bug where the split case dropped the text). + const bool legacySplit = bcfg.flags & MESH_BEACON_FLAG_LEGACY_SPLIT; + const bool splitBoth = legacySplit && hasRadioContent && hasText; + const bool sendOfferOnly = splitBoth || (hasRadioContent && !hasText); + const bool sendTextOnly = splitBoth || (!hasRadioContent && hasText); + const bool sendCombined = !legacySplit && hasRadioContent && hasText; + + // Build offer payload once — shared across all targets. + uint8_t offerBuf[meshtastic_MeshBeacon_size] = {}; + pb_size_t offerSize = 0; + if (sendOfferOnly) { + meshtastic_MeshBeacon offerOnly = meshtastic_MeshBeacon_init_zero; + if (bcfg.has_broadcast_offer_channel) { + offerOnly.has_offer_channel = true; + offerOnly.offer_channel = bcfg.broadcast_offer_channel; + } + offerOnly.has_offer_preset = bcfg.has_broadcast_offer_preset; + offerOnly.offer_preset = bcfg.broadcast_offer_preset; + offerOnly.offer_region = bcfg.broadcast_offer_region; + offerSize = (pb_size_t)pb_encode_to_bytes(offerBuf, sizeof(offerBuf), &meshtastic_MeshBeacon_msg, &offerOnly); + if (offerSize == 0) + LOG_WARN("Beacon: offer encode failed, skipping offer packet(s)"); + } + if (sendCombined && payloadCacheDirty) + rebuildCache(); + + // ── Per-target loop ────────────────────────────────────────────────────── + // + // If broadcast_targets is populated, iterate over those. Otherwise use the single-target + // broadcast_on_preset / broadcast_on_region / broadcast_on_channel fields. The two paths are + // equal options; they differ only in how the TX channel is named (single-target embeds a + // ChannelSettings inline; a target references a channel-table slot by channel_index). + struct EffTarget { + meshtastic_Config_LoRaConfig_ModemPreset preset; + uint16_t slot; + meshtastic_Config_LoRaConfig_RegionCode region; + bool has_channel; + meshtastic_ChannelSettings channel; + }; + + const bool useTargetList = bcfg.broadcast_targets_count > 0; + const int targetCount = useTargetList ? (int)bcfg.broadcast_targets_count : 1; + + // Dedup state: the beacon payload is identical across targets, so two targets that resolve to + // the same effective radio config (preset + resolved region + channel) would just re-broadcast + // the same packet — wasted airtime and a redundant radio switch each. We skip the later one. + // Keyed on the *resolved* values so an explicit "current region" dedups against an UNSET one. + EffTarget sent[4]; + meshtastic_Config_LoRaConfig_RegionCode sentRegion[4]; + int sentCount = 0; + const auto sameEffectiveTarget = [](const EffTarget &a, meshtastic_Config_LoRaConfig_RegionCode ar, const EffTarget &b, + meshtastic_Config_LoRaConfig_RegionCode br) { + if (a.preset != b.preset || ar != br || a.has_channel != b.has_channel) + return false; + if (!a.has_channel) + return true; // both fall back to the default channel for the (same) preset + return a.slot == b.slot && strncmp(a.channel.name, b.channel.name, sizeof(a.channel.name)) == 0 && + a.channel.psk.size == b.channel.psk.size && + memcmp(a.channel.psk.bytes, b.channel.psk.bytes, a.channel.psk.size) == 0; + }; + + for (int ti = 0; ti < targetCount; ti++) { + EffTarget tgt = {}; + if (useTargetList) { + const auto &bt = bcfg.broadcast_targets[ti]; + tgt.preset = bt.has_preset ? bt.preset : config.lora.modem_preset; + tgt.region = bt.region; + // Resolve the channel from the device's channel table by index. A slot is only usable + // if it is actually configured (has a name or PSK — its key is needed to encrypt). An + // out-of-range index, or a blank slot, falls back to the default channel for the target + // preset (see beaconChannelSettings), exactly as an unset channel_index would. + tgt.has_channel = false; + tgt.slot = config.lora.channel_num; + if (bt.has_channel_index) { + if (bt.channel_index >= (uint32_t)channels.getNumChannels()) { + LOG_WARN("Beacon: target %d channel_index %u out of range, using default channel for preset", ti, + bt.channel_index); + } else { + const meshtastic_ChannelSettings &cs = channels.getByIndex(bt.channel_index).settings; + if (cs.name[0] != '\0' || cs.psk.size > 0) { + tgt.has_channel = true; + tgt.channel = cs; + tgt.slot = cs.channel_num; + } else { + LOG_DEBUG("Beacon: target %d channel_index %u is a blank slot, using default channel for preset", ti, + bt.channel_index); + } + } + } + } else { + tgt.preset = bcfg.has_broadcast_on_preset ? bcfg.broadcast_on_preset : config.lora.modem_preset; + tgt.region = bcfg.broadcast_on_region; + tgt.has_channel = bcfg.has_broadcast_on_channel; + if (tgt.has_channel) + tgt.channel = bcfg.broadcast_on_channel; + tgt.slot = tgt.has_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num; + } + + // Skip a target whose effective radio config duplicates one already sent this cycle. + const meshtastic_Config_LoRaConfig_RegionCode resolvedRegion = + (tgt.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? tgt.region : config.lora.region; + bool duplicate = false; + for (int si = 0; si < sentCount; si++) { + if (sameEffectiveTarget(tgt, resolvedRegion, sent[si], sentRegion[si])) { + duplicate = true; + break; + } + } + if (duplicate) { + LOG_DEBUG("Beacon: target %d duplicates an earlier target's radio config, skipping", ti); + continue; + } + sent[sentCount] = tgt; + sentRegion[sentCount] = resolvedRegion; + sentCount++; + + const bool channelOverrideConfigured = tgt.has_channel && (tgt.channel.name[0] != '\0' || tgt.channel.psk.size > 0 || + tgt.channel.channel_num != config.lora.channel_num); + const bool presetDiffers = + (tgt.preset != config.lora.modem_preset) || + (tgt.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && tgt.region != config.lora.region) || + channelOverrideConfigured; + const meshtastic_ChannelSettings *chPtr = tgt.has_channel ? &tgt.channel : nullptr; + + const auto applyTarget = [&](meshtastic_MeshPacket *p) { + if (presetDiffers || legacySplit) + setTargetRadioSettings(p, tgt.preset, tgt.slot, legacySplit, tgt.region, tgt.has_channel, chPtr); + sendBeaconPacket(p, tgt.preset, tgt.has_channel, chPtr); + }; + + if (sendOfferOnly && offerSize > 0) { + meshtastic_MeshPacket *pA = allocDataPacket(); + if (!pA) { + LOG_WARN("Beacon: failed to allocate split-A packet (target %d)", ti); + return; + } + memcpy(pA->decoded.payload.bytes, offerBuf, offerSize); + pA->decoded.payload.size = offerSize; + pA->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + stampPacket(pA); + LOG_INFO("Beacon: split-A MESH_BEACON_APP (offer only) from=0x%08x target=%d", pA->from, ti); + applyTarget(pA); + } + + if (sendTextOnly) { + meshtastic_MeshPacket *pB = allocDataPacket(); + if (!pB) { + LOG_WARN("Beacon: failed to allocate split-B packet (target %d)", ti); + return; + } + pb_size_t msgLen = (pb_size_t)strnlen(bcfg.broadcast_message, sizeof(bcfg.broadcast_message) - 1); + memcpy(pB->decoded.payload.bytes, bcfg.broadcast_message, msgLen); + pB->decoded.payload.size = msgLen; + pB->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + stampPacket(pB); + LOG_INFO("Beacon: split-B TEXT_MESSAGE_APP msg='%.40s' from=0x%08x target=%d", bcfg.broadcast_message, pB->from, ti); + applyTarget(pB); + } + + if (sendCombined && payloadCacheSize > 0) { + meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) { + LOG_WARN("Beacon: failed to allocate beacon packet (target %d)", ti); + return; + } + memcpy(p->decoded.payload.bytes, payloadCache, payloadCacheSize); + p->decoded.payload.size = payloadCacheSize; + p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + stampPacket(p); + LOG_INFO("Beacon: MESH_BEACON_APP offer+msg from=0x%08x msg='%.40s' target=%d", p->from, bcfg.broadcast_message, ti); + applyTarget(p); + } + } +} + +int32_t MeshBeaconBroadcastModule::runOnce() +{ + const auto &bcfg = moduleConfig.mesh_beacon; + const uint32_t intervalSecs = + Default::getConfiguredOrDefault(bcfg.broadcast_interval_secs, default_mesh_beacon_min_broadcast_interval_secs); + const uint32_t intervalMs = + Default::getConfiguredOrMinimumValue(intervalSecs, default_mesh_beacon_min_broadcast_interval_secs) * 1000; + + if ((bcfg.flags & MESH_BEACON_FLAG_BROADCAST_ENABLED) && airTime->isTxAllowedAirUtil() && + config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { + // Throttle against the reboot-safe transmit history (mirrors NodeInfoModule): skip if we + // broadcast within the interval, even across a reboot. 0 = never sent → send now. + uint32_t lastSent = transmitHistory ? transmitHistory->getLastSentToMeshMillis(meshtastic_PortNum_MESH_BEACON_APP) : 0; + if (lastSent == 0 || !Throttle::isWithinTimespanMs(lastSent, intervalMs)) { + // Record the send BEFORE transmitting: the LoRa TX is a high-current event that can + // brown out a marginal supply, and if that reboots us mid-transmit we still want the + // "sent" marker persisted so we don't re-broadcast immediately on every boot. + if (transmitHistory) + transmitHistory->setLastSentToMesh(meshtastic_PortNum_MESH_BEACON_APP); + sendBeacon(); + } + } + + return static_cast(intervalMs); +} + +// --------------------------------------------------------------------------- +// MeshBeaconListenerModule +// --------------------------------------------------------------------------- + +MeshBeaconListenerModule *meshBeaconListenerModule; +MeshBeaconListenerModule::BeaconOffer MeshBeaconListenerModule::lastReceivedOffer; + +MeshBeaconListenerModule::MeshBeaconListenerModule() + : ProtobufModule("beacon_listen", meshtastic_PortNum_MESH_BEACON_APP, &meshtastic_MeshBeacon_msg) +{ + lastReceivedOffer = {}; +} + +bool MeshBeaconListenerModule::wantPacket(const meshtastic_MeshPacket *p) +{ + return moduleConfig.has_mesh_beacon && (moduleConfig.mesh_beacon.flags & MESH_BEACON_FLAG_LISTEN_ENABLED) && + p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP; +} + +bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) +{ + const bool hasOfferContent = + b && (b->has_offer_channel || b->offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || b->has_offer_preset); + const pb_size_t msgLen = b ? (pb_size_t)strnlen(b->message, sizeof(b->message) - 1) : 0; + const bool hasText = msgLen > 0; + if (!b || (!hasText && !hasOfferContent)) + return false; + + // NOTE: we deliberately do NOT unwrap the text into a synthesized TEXT_MESSAGE_APP for the + // phone. The original MESH_BEACON_APP packet already flows to the client (we return CONTINUE), + // so a beacon-aware client renders `message` directly — injecting a copy would only duplicate + // it. Broadcasters that need non-beacon-aware clients to see the text use FLAG_LEGACY_SPLIT, + // which sends a real TEXT_MESSAGE_APP over RF. We also do not fire EVENT_RECEIVED_MSG: a beacon + // is an advisory broadcast, not a personal message, and must not wake the device from sleep. + if (hasText) + LOG_INFO("Beacon: received from 0x%08x: '%.40s'", mp.from, b->message); + + // Cache any offer for the client app — never auto-applied. + if (hasOfferContent) { + lastReceivedOffer.valid = true; + lastReceivedOffer.sender = mp.from; + lastReceivedOffer.has_channel = b->has_offer_channel; + if (b->has_offer_channel) + lastReceivedOffer.channel = b->offer_channel; + lastReceivedOffer.region = b->offer_region; + lastReceivedOffer.preset = b->offer_preset; + lastReceivedOffer.received_at = + getValidTime(RTCQualityFromNet); // 0 if no RTC fix yet — consumers must not treat 0 as valid + LOG_INFO("Beacon: stored offer from 0x%08x (preset=%d)", mp.from, b->offer_preset); + } + + notifyObservers(&mp); + return false; +} diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h new file mode 100644 index 000000000..5baea179b --- /dev/null +++ b/src/modules/MeshBeaconModule.h @@ -0,0 +1,163 @@ +#pragma once +#include "MeshRadio.h" +#include "Observer.h" +#include "ProtobufModule.h" +#include "RadioInterface.h" +#include "concurrency/OSThread.h" +#include "mesh/generated/meshtastic/mesh_beacon.pb.h" +#include "mesh/generated/meshtastic/module_config.pb.h" + +// Short aliases for the MeshBeaconConfig.flags bitfield (see module_config.proto MeshBeaconConfig.Flags). +#define MESH_BEACON_FLAG_LISTEN_ENABLED meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED +#define MESH_BEACON_FLAG_BROADCAST_ENABLED meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_BROADCAST_ENABLED +#define MESH_BEACON_FLAG_LEGACY_SPLIT meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT + +// Sidecar entry pairing a packet ID with target radio settings for beacon TX. +typedef struct { + bool inUse; + PacketId id; + meshtastic_Config_LoRaConfig_ModemPreset preset; + uint16_t slot; + // When true, reconfigureForBeaconTX sets hop_start=1 so pre-2.7.20 firmware + // (which drops hop_start==0 packets) accepts the zero-hop beacon. + bool legacyHopOverride; + // Per-target radio settings. UNSET region means use current lora.region. + meshtastic_Config_LoRaConfig_RegionCode region; + bool has_channel; + meshtastic_ChannelSettings channel; +} MeshBeaconModule_TargetRadioSettings; + +/** + * Base class: holds the radio-switching sidecar table and static helpers. + * The sidecar avoids touching MeshPacket proto fields for per-packet radio state. + */ +class MeshBeaconModule +{ + public: + MeshBeaconModule(); + + /** + * Reconfigure the radio for beacon TX, or restore to original config if p is NULL. + * Returns true if the radio was reconfigured (caller must re-run transmit delay for CCA). + * Driven by broadcast_on_preset / broadcast_on_channel from MeshBeaconConfig. + */ + static bool reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p); + + /** + * Associate target radio settings with an outgoing packet by its ID. + * Sidecar holds 8 entries; evicts slot 0 on overflow. + */ + static void + setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, uint16_t slot, + bool legacyHopOverride = false, + meshtastic_Config_LoRaConfig_RegionCode region = meshtastic_Config_LoRaConfig_RegionCode_UNSET, + bool has_channel = false, const meshtastic_ChannelSettings *channel = nullptr); + + /** + * Returns true if the sidecar table contains an entry for this packet's ID. + * Used by RadioLibInterface to gate the channel-active check. + */ + static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p); + + /** + * Remove the sidecar entry for this packet after it has been sent. + * Called from RadioLibInterface::completeSending(). + */ + static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); + + /** + * True if p is tagged for a beacon radio switch whose target config must NOT be transmitted: + * preset invalid for the target region, or an unlicensed node would key up on a ham-only + * (licensed-only) region. The radio driver drops such packets rather than sending them on the + * current config. False for any packet without a sidecar entry (normal traffic is never affected). + */ + static bool beaconTxConfigInvalid(const meshtastic_MeshPacket *p); + + protected: + /** + * Build the ChannelSettings the beacon transmits on: the base (primary) channel overlaid with + * any broadcast_on_channel overrides, defaulting an empty name to the target preset's display + * name. Shared by the encrypt-time channel swap and the radio-thread RF swap so the channel + * key + hash are identical at both points. + */ + static meshtastic_ChannelSettings beaconChannelSettings(const meshtastic_ChannelSettings &base, + meshtastic_Config_LoRaConfig_ModemPreset preset, + const meshtastic_ChannelSettings *overrideChannel = nullptr); + + static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; + static uint16_t originalLoraChannel; + static meshtastic_Config_LoRaConfig_RegionCode originalRegion; + static meshtastic_ChannelSettings originalPrimaryChannel; +}; + +/** + * Broadcaster: periodically sends MeshBeacon packets on the configured preset/channel. + * Active only when the FLAG_BROADCAST_ENABLED bit is set in moduleConfig.mesh_beacon.flags. + * Inherits ProtobufModule to access allocDataProtobuf + setStartDelay. + * + * Packet flow: + * Normal (combined): one MESH_BEACON_APP carrying offer + message on the beacon radio config. + * Legacy split: two packets when both text and offer are present and FLAG_LEGACY_SPLIT is set, + * both sent on the same beacon radio settings: + * A) MESH_BEACON_APP with offer only (no text). + * B) TEXT_MESSAGE_APP with the text (for clients that only decode TEXT_MESSAGE_APP). + */ +class MeshBeaconBroadcastModule : private MeshBeaconModule, + public ProtobufModule, + private concurrency::OSThread +{ + public: + MeshBeaconBroadcastModule(); + + // Mark the cached payload dirty (call after config change). + void invalidateCache() { payloadCacheDirty = true; } + + protected: + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &, meshtastic_MeshBeacon *) override { return false; } + virtual int32_t runOnce() override; + + protected: + void sendBeacon(); + void rebuildCache(); + + // Send one beacon packet. When overrideChannel is set and has a name/PSK override, + // the packet is encrypted with that channel's key (not the primary's). + void sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset, + bool has_channel = false, const meshtastic_ChannelSettings *overrideChannel = nullptr); + + bool payloadCacheDirty = true; + uint8_t payloadCache[meshtastic_MeshBeacon_size] = {}; + pb_size_t payloadCacheSize = 0; +}; +extern MeshBeaconBroadcastModule *meshBeaconBroadcastModule; + +/** + * Listener: receives MESH_BEACON_APP packets and caches any offered channel/preset for the client + * app to retrieve. It does NOT unwrap the text into a separate message — the original beacon packet + * already reaches the client (handler returns CONTINUE), which reads `message` from it directly. + * Does NOT auto-apply offered settings — client app must do so explicitly. + * Active only when the FLAG_LISTEN_ENABLED bit is set in moduleConfig.mesh_beacon.flags. + */ +class MeshBeaconListenerModule : public ProtobufModule, public Observable +{ + public: + MeshBeaconListenerModule(); + + struct BeaconOffer { + bool valid; + NodeNum sender; + bool has_channel; + meshtastic_ChannelSettings channel; + meshtastic_Config_LoRaConfig_RegionCode region; + meshtastic_Config_LoRaConfig_ModemPreset preset; + uint32_t received_at; + }; + + // Last received offer — accessible to admin/API for client app retrieval. + static BeaconOffer lastReceivedOffer; + + protected: + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) override; + virtual bool wantPacket(const meshtastic_MeshPacket *p) override; +}; +extern MeshBeaconListenerModule *meshBeaconListenerModule; diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 89c5b9d43..1e9380575 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -28,6 +28,9 @@ #if !MESHTASTIC_EXCLUDE_NODEINFO #include "modules/NodeInfoModule.h" #endif +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif #if !MESHTASTIC_EXCLUDE_GPS #include "modules/PositionModule.h" #endif @@ -143,6 +146,10 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_NODEINFO nodeInfoModule = new NodeInfoModule(); #endif +#if !MESHTASTIC_EXCLUDE_BEACON + meshBeaconBroadcastModule = new MeshBeaconBroadcastModule(); + meshBeaconListenerModule = new MeshBeaconListenerModule(); +#endif #if !MESHTASTIC_EXCLUDE_GPS positionModule = new PositionModule(); #endif diff --git a/test/native-suite-count b/test/native-suite-count index a45fd52cc..7273c0fa8 100644 --- a/test/native-suite-count +++ b/test/native-suite-count @@ -1 +1 @@ -24 +25 diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp new file mode 100644 index 000000000..f19fac087 --- /dev/null +++ b/test/test_mesh_beacon/test_main.cpp @@ -0,0 +1,1457 @@ +/** + * Unit tests for MeshBeaconModule: + * - AdminModule::handleSetModuleConfig validation (invalid/valid inputs) + * - MeshBeaconBroadcastModule payload cache lifecycle + * - MeshBeaconBroadcastModule::sendBeacon sends a correctly formed packet + * - MeshBeaconListenerModule offer caching and empty-message guard + */ + +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define BEACON_TEST_ENTRY extern "C" +#else +#define BEACON_TEST_ENTRY +#endif + +#if !MESHTASTIC_EXCLUDE_BEACON + +#include "MeshRadio.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "RadioInterface.h" +#include "airtime.h" +#include "modules/AdminModule.h" +#include "modules/MeshBeaconModule.h" +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Formatted diagnostic helper. TEST_MESSAGE emits a line into Unity's per-test +// output (shown inline alongside the :PASS/:FAIL result); use this when you need +// a printf-style formatted note tied to a specific assertion. Plain printf() also +// works for free-standing log lines (e.g. the group headers in setup() below). +// --------------------------------------------------------------------------- +#define MSG_BUF_LEN 256 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, ##__VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + +namespace +{ + +constexpr NodeNum kLocalNode = 0xAAAA0001; +constexpr NodeNum kRemoteNode = 0xBBBB0002; + +// --------------------------------------------------------------------------- +// Minimal MockMeshService — stubs out side-effecting virtuals. +// handleToRadio is non-virtual so it runs the real implementation; we guard +// against the router->sendLocal path by setting a MockRouter below. +// --------------------------------------------------------------------------- +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; + +// --------------------------------------------------------------------------- +// MockRouter: captures every packet handed to send() instead of transmitting. +// --------------------------------------------------------------------------- +class MockRouter : public Router +{ + public: + ~MockRouter() + { + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *p) override + { + // Capture the primary channel as seen AT send() time. sendBeaconPacket() temporarily swaps + // the beacon channel into the primary slot around this call so perhapsEncode keys off it, + // so this snapshot reflects which channel the packet would actually be encrypted on. + if (channelFile.channels_count > 0) + primaryAtSend.push_back(channels.getByIndex(channels.getPrimaryIndex()).settings); + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + // Locally-addressed packets land here instead of send(). Release immediately + // rather than queuing into fromRadioQueue (which is never drained in tests). + void enqueueReceivedMessage(meshtastic_MeshPacket *p) override { packetPool.release(p); } + + std::vector sentPackets; + std::vector primaryAtSend; +}; + +// --------------------------------------------------------------------------- +// AdminModuleTestShim — exposes protected handleSetModuleConfig. +// --------------------------------------------------------------------------- +class AdminModuleTestShim : public AdminModule +{ + public: + using AdminModule::handleSetModuleConfig; +}; + +// --------------------------------------------------------------------------- +// MeshBeaconBroadcastModuleTestShim — exposes private internals for testing. +// --------------------------------------------------------------------------- +class MeshBeaconBroadcastModuleTestShim : public MeshBeaconBroadcastModule +{ + public: + using MeshBeaconBroadcastModule::payloadCache; + using MeshBeaconBroadcastModule::payloadCacheDirty; + using MeshBeaconBroadcastModule::payloadCacheSize; + using MeshBeaconBroadcastModule::rebuildCache; + using MeshBeaconBroadcastModule::runOnce; + using MeshBeaconBroadcastModule::sendBeacon; +}; + +// --------------------------------------------------------------------------- +// MeshBeaconListenerModuleTestShim — exposes handleReceivedProtobuf. +// --------------------------------------------------------------------------- +class MeshBeaconListenerModuleTestShim : public MeshBeaconListenerModule +{ + public: + using MeshBeaconListenerModule::handleReceivedProtobuf; + using MeshBeaconListenerModule::wantPacket; +}; + +// --------------------------------------------------------------------------- +// Globals managed by setUp / tearDown. +// --------------------------------------------------------------------------- +static MockMeshService *mockSvc = nullptr; +static MockRouter *mockRouter = nullptr; +static AdminModuleTestShim *testAdmin = nullptr; +static AirTime *testAirTime = nullptr; + +// --------------------------------------------------------------------------- +// Helper: build a ModuleConfig wrapper for the beacon case (mirrors the wire +// format used by set_module_config admin messages). +// --------------------------------------------------------------------------- +static meshtastic_ModuleConfig makeBeaconModuleConfig(meshtastic_ModuleConfig_MeshBeaconConfig bcfg) +{ + meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero; + mc.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag; + mc.payload_variant.mesh_beacon = bcfg; + return mc; +} + +// --------------------------------------------------------------------------- +// Helper: reset module/device config to a known baseline. +// --------------------------------------------------------------------------- +static void resetConfig() +{ + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + config = meshtastic_LocalConfig_init_zero; + + // Device is an EU_868 node with LONG_FAST — the starting point. + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + // Allow TX unconditionally so airtime checks don't block sendBeacon(). + config.lora.override_duty_cycle = true; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + myNodeInfo.my_node_num = kLocalNode; + + initRegion(); +} + +// Install a single PRIMARY channel with an explicit name + PSK so the beacon channel-swap path +// (sendBeaconPacket) has a real primary slot to save/restore and to read the active PSK from. +static void installTestPrimaryChannel(const char *name, const uint8_t *psk, size_t pskLen) +{ + channelFile.channels_count = 1; + meshtastic_Channel &ch = channelFile.channels[0]; + ch = meshtastic_Channel_init_zero; + ch.index = 0; + ch.has_settings = true; + ch.role = meshtastic_Channel_Role_PRIMARY; + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + ch.settings.psk.size = (pb_size_t)pskLen; + memcpy(ch.settings.psk.bytes, psk, pskLen); + channels.onConfigChanged(); // set primaryIndex + recompute hashes +} + +// Install a secondary channel at the given table index. Pass psk=nullptr/pskLen=0 for a blank slot +// (no name, no PSK) to exercise the "referenced slot is unconfigured" fallback. +static void installTestSecondaryChannel(uint8_t index, const char *name, const uint8_t *psk, size_t pskLen) +{ + if (channelFile.channels_count < (pb_size_t)(index + 1)) + channelFile.channels_count = index + 1; + meshtastic_Channel &ch = channelFile.channels[index]; + ch = meshtastic_Channel_init_zero; + ch.index = index; + ch.has_settings = true; + ch.role = meshtastic_Channel_Role_SECONDARY; + if (name) + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + if (psk && pskLen) { + ch.settings.psk.size = (pb_size_t)pskLen; + memcpy(ch.settings.psk.bytes, psk, pskLen); + } + channels.onConfigChanged(); +} + +// =========================================================================== +// Group 1: AdminModule config validation — bad inputs must be sanitised +// =========================================================================== + +/** + * Verify SHORT_TURBO is rejected when the region is EU_868 (turbo presets are not in that + * region's allowed preset set). Important to catch regressions where admin stores unlawful + * radio settings that would violate regional radio regulations. + */ +static void test_adminValidation_turboPresetOnEU868_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; + bcfg.has_broadcast_on_preset = true; + bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); + TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.has_broadcast_on_preset, "SHORT_TURBO must be cleared for EU_868"); +} + +/** + * Verify LONG_TURBO is also cleared for EU_868, not just SHORT_TURBO. + * Important to confirm rejection covers the entire turbo preset family rather than one variant. + */ +static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_on_preset = true; + bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); +} + +/** + * Verify a turbo preset passes validation for US (PROFILE_STD allows all presets). + * Important because the same preset that is illegal in EU_868 must be preserved in permissive regions. + */ +static void test_adminValidation_turboPresetOnUS_isAccepted(void) +{ + resetConfig(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_on_preset = true; + bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); +} + +/** + * Verify an out-of-range region code (255) is sanitised to UNSET rather than stored verbatim. + * Important to prevent invalid proto enum values from reaching the broadcaster and being broadcast + * over the air. + */ +static void test_adminValidation_unknownOfferRegion_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_offer_region = (meshtastic_Config_LoRaConfig_RegionCode)255; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, moduleConfig.mesh_beacon.broadcast_offer_region); +} + +/** + * Verify a known-good offer region (US) is written through unchanged after admin validation. + * Important as a positive-path control alongside the rejection tests. + */ +static void test_adminValidation_validOfferRegion_isPreserved(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_offer_region); +} + +/** + * Verify an out-of-range region in a multi-target entry is sanitised to UNSET on write. + * Important because broadcast_targets entries are validated independently of the single-target + * broadcast_on_* fields, and an invalid enum must never reach the radio-switch path. + */ +static void test_adminValidation_targetUnknownRegion_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].region = (meshtastic_Config_LoRaConfig_RegionCode)255; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL(1, moduleConfig.mesh_beacon.broadcast_targets_count); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, moduleConfig.mesh_beacon.broadcast_targets[0].region); +} + +/** + * Verify a preset that is illegal for a multi-target entry's region clears that entry's preset + * (and its channel), matching the single-target broadcast_on_preset rule. + */ +static void test_adminValidation_targetInvalidPresetForRegion_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + bcfg.broadcast_targets[0].has_channel_index = true; + bcfg.broadcast_targets[0].channel_index = 1; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset, + "SHORT_TURBO must be cleared for EU_868 target"); + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); +} + +/** + * Verify a channel_index beyond the channel-table capacity is cleared on write. + * Important so the broadcaster never indexes out of bounds when resolving a target channel. + */ +static void test_adminValidation_targetChannelIndexOutOfRange_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_channel_index = true; + bcfg.broadcast_targets[0].channel_index = MAX_NUM_CHANNELS; // one past the last valid slot + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); +} + +/** + * Verify an in-range channel_index survives admin validation unchanged. + */ +static void test_adminValidation_targetChannelIndexInRange_isPreserved(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].has_channel_index = true; + bcfg.broadcast_targets[0].channel_index = 0; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); + TEST_ASSERT_EQUAL_UINT32(0, moduleConfig.mesh_beacon.broadcast_targets[0].channel_index); +} + +/** + * Verify a valid preset/region multi-target entry survives admin validation unchanged. + * Positive-path control for the per-target validation. + */ +static void test_adminValidation_targetValidPresetForRegion_isPreserved(void) +{ + resetConfig(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_US; + bcfg.broadcast_targets[0].has_preset = true; + bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_targets[0].preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_targets[0].region); +} + +/** + * Verify broadcast_message is hard-capped at 100 characters (NUL forced at index 100). + * Important to prevent oversized beacon payloads from abusing airtime across the mesh. + */ +static void test_adminValidation_messageTooLong_isTruncatedAt100(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + // Fill with 'A' up to the full array size; admin must enforce ≤100 chars. + memset(bcfg.broadcast_message, 'A', sizeof(bcfg.broadcast_message)); + bcfg.broadcast_message[sizeof(bcfg.broadcast_message) - 1] = '\0'; // pb_decode guarantee + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + // Byte at index 100 must be NUL (length capped at 100). + TEST_ASSERT_EQUAL('\0', moduleConfig.mesh_beacon.broadcast_message[100]); + // Bytes before it should still be 'A'. + TEST_ASSERT_EQUAL('A', moduleConfig.mesh_beacon.broadcast_message[0]); +} + +/** + * Verify any non-zero interval below 3600 s is clamped up to the 1-hour minimum. + * Important to prevent high-rate beacon floods from a misconfigured or malicious client. + */ +static void test_adminValidation_intervalTooLow_isClamped(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_interval_secs = 60; // way below minimum + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL_UINT32(3600, moduleConfig.mesh_beacon.broadcast_interval_secs); +} + +/** + * Verify an interval above the minimum is stored as-is without modification. + * Important to confirm the clamp is one-sided (lower bound only, no upper bound enforced). + */ +static void test_adminValidation_intervalTooHigh_isPreserved(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_interval_secs = 999999; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL_UINT32(999999, moduleConfig.mesh_beacon.broadcast_interval_secs); +} + +/** + * Verify LONG_FAST (enum value 0) survives admin validation without being treated as 'absent'. + * Important to guard the has_broadcast_offer_preset presence-flag fix against zero-value erasure. + */ +static void test_adminValidation_longFastOfferPreset_isPreserved(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_offer_preset = true; + bcfg.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_offer_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, moduleConfig.mesh_beacon.broadcast_offer_preset); +} + +/** + * Verify that interval 0 (the documented 'use default' sentinel) is not raised to 3600 by the clamp. + * Important because 0 and 3600 have different runtime semantics in runOnce via + * Default::getConfiguredOrDefault. + */ +static void test_adminValidation_intervalZero_isNotClamped(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_interval_secs = 0; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL_UINT32(0, moduleConfig.mesh_beacon.broadcast_interval_secs); +} + +/** + * Verify that saving a new beacon config marks the broadcaster's payload cache dirty. + * Important so the next TX re-encodes from the latest config rather than a pre-save stale snapshot. + */ +static void test_adminValidation_validSave_invalidatesCache(void) +{ + resetConfig(); + + // Prime the broadcaster with a clean state so the dirty flag is known. + std::unique_ptr bcast(new MeshBeaconBroadcastModuleTestShim()); + meshBeaconBroadcastModule = bcast.get(); + bcast->payloadCacheDirty = false; // pretend it was freshly built + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; + strncpy(bcfg.broadcast_message, "hello", sizeof(bcfg.broadcast_message) - 1); + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE_MESSAGE(bcast->payloadCacheDirty, "Config save must mark payload cache dirty"); + + meshBeaconBroadcastModule = nullptr; +} + +// =========================================================================== +// Group 2: Broadcaster payload cache +// =========================================================================== + +/** + * Verify rebuildCache produces at least one encoded byte when broadcast_message is set. + * Important as the most basic liveness check for the protobuf encoding path. + */ +static void test_broadcaster_rebuildCache_producesNonEmptyPayload(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "Test beacon", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + TEST_ASSERT_TRUE(bcast.payloadCacheDirty); + + bcast.rebuildCache(); + + TEST_ASSERT_FALSE_MESSAGE(bcast.payloadCacheDirty, "rebuildCache must clear dirty flag"); + TEST_ASSERT_GREATER_THAN_MESSAGE(0, (int)bcast.payloadCacheSize, "rebuildCache must produce a non-empty payload"); +} + +/** + * Verify the cached bytes round-trip through pb_decode back to the original message string. + * Important to catch any protobuf field-tag or wire-type regression in the encoding path. + */ +static void test_broadcaster_rebuildCache_payloadDecodesCorrectly(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + const char *msg = "Hello, Meshtastic!"; + strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + + // Decode the cached bytes back into a MeshBeacon struct. + meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; + pb_istream_t stream = pb_istream_from_buffer(bcast.payloadCache, bcast.payloadCacheSize); + bool ok = pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); + + TEST_ASSERT_TRUE_MESSAGE(ok, "Cached payload must decode without error"); + TEST_ASSERT_EQUAL_STRING(msg, decoded.message); +} + +/** + * Verify offer_region and offer_preset are present in the encoded cache payload. + * Important to confirm the offer-carrying path correctly uses the has_broadcast_offer_preset flag. + */ +static void test_broadcaster_rebuildCache_offerFieldsEncoded(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "offer-test", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + + meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; + pb_istream_t stream = pb_istream_from_buffer(bcast.payloadCache, bcast.payloadCacheSize); + pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, decoded.offer_region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, decoded.offer_preset); +} + +/** + * Verify invalidateCache flips payloadCacheDirty back to true after a successful rebuild. + * Important to confirm the cache-invalidation contract relied on by admin saves and config observers. + */ +static void test_broadcaster_invalidateCache_setsDirtyFlag(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + TEST_ASSERT_FALSE(bcast.payloadCacheDirty); + + bcast.invalidateCache(); + TEST_ASSERT_TRUE(bcast.payloadCacheDirty); +} + +/** + * Verify calling rebuildCache a second time without an intervening invalidation is a no-op. + * Important to prevent spurious re-encodes when config-observer callbacks fire multiple times. + */ +static void test_broadcaster_rebuildCache_idempotent(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "idem", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + pb_size_t firstSize = bcast.payloadCacheSize; + bcast.rebuildCache(); // second call — should be identical + pb_size_t secondSize = bcast.payloadCacheSize; + + TEST_ASSERT_FALSE(bcast.payloadCacheDirty); + TEST_ASSERT_EQUAL(firstSize, secondSize); +} + +// =========================================================================== +// Group 3: Broadcaster sendBeacon — packet structure +// =========================================================================== + +/** + * Verify the 'from' field defaults to the local node number when broadcast_send_as_node is 0. + * Important for correct source attribution in peer node tables that receive the beacon. + */ +static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; + moduleConfig.mesh_beacon.broadcast_send_as_node = 0; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-local", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); +} + +/** + * Verify broadcast_send_as_node is currently disabled: 'from' is always the local node + * even when broadcast_send_as_node is set to a remote node number. + * (broadcast_send_as_node is commented out as "not suitable right now".) + */ +static void test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; + moduleConfig.mesh_beacon.broadcast_send_as_node = kRemoteNode; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-remote", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + // broadcast_send_as_node is disabled; from is always the local node + TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); +} + +/** + * Verify the 'to' field is always NODENUM_BROADCAST regardless of other settings. + * Important because beacons are mesh-wide announcements and must never be addressed to a single peer. + */ +static void test_broadcaster_sendBeacon_addressedToBroadcast(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "bcast-addr", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(NODENUM_BROADCAST, mockRouter->sentPackets[0].to); +} + +/** + * Verify MESH_BEACON_APP portnum is used when the packet carries a radio offer payload. + * Important so receivers use the structured protobuf decoder rather than treating it as raw text. + */ +static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "portnum-check", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); +} + +/** + * Verify TEXT_MESSAGE_APP portnum is used when no offer content is present, even if + * broadcast_on_preset is set (that field governs which radio config to use for TX, not portnum). + * Important so standard clients display plain-text beacons without needing a MESH_BEACON_APP decoder. + */ +static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + const char *msg = "plain-text-beacon"; + strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + // broadcast_on_preset set, but no offer — should still be TEXT_MESSAGE_APP + moduleConfig.mesh_beacon.has_broadcast_on_preset = true; + moduleConfig.mesh_beacon.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + const meshtastic_MeshPacket &p = mockRouter->sentPackets[0]; + TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, p.decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(strlen(msg), p.decoded.payload.size); + TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)p.decoded.payload.bytes, p.decoded.payload.size); +} + +/** + * Verify the MESH_BEACON_APP payload decodes back to the original message string. + * Important to catch encode/decode regressions in the full sendBeacon → wire → pb_decode round-trip. + */ +static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + const char *msg = "Greetings from the beacon"; + strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + const meshtastic_MeshPacket &p = mockRouter->sentPackets[0]; + meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; + pb_istream_t stream = pb_istream_from_buffer(p.decoded.payload.bytes, p.decoded.payload.size); + bool ok = pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); + + TEST_ASSERT_TRUE_MESSAGE(ok, "Sent payload must decode without error"); + TEST_ASSERT_EQUAL_STRING(msg, decoded.message); +} + +/** + * Verify a beacon with offer fields but no message text is still emitted on MESH_BEACON_APP. + * Important because offer-only beacons are a valid use case that the early-return guard must not + * suppress. + */ +static void test_broadcaster_sendBeacon_offerOnly_isSent(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); +} + +/** + * Verify runOnce sends exactly one packet when broadcast_enabled is true. + * Important to confirm the OSThread timer callback drives the full send path end-to-end. + */ +static void test_broadcaster_runOnce_sendsWhenEnabled(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; + moduleConfig.mesh_beacon.broadcast_interval_secs = 3600; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "runOnce-enabled", + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.runOnce(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); +} + +/** + * Verify runOnce transmits nothing when broadcast_enabled is false. + * Important to confirm the feature can be cleanly disabled via remote admin without rebooting. + */ +static void test_broadcaster_runOnce_silentWhenDisabled(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags &= ~MESH_BEACON_FLAG_BROADCAST_ENABLED; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "runOnce-disabled", + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.runOnce(); + + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); +} + +// =========================================================================== +// Group 4: Listener — offer caching and guards +// =========================================================================== + +// Helper: build a decoded MESH_BEACON_APP packet carrying the given MeshBeacon. +static meshtastic_MeshPacket makeBeaconPacket(const meshtastic_MeshBeacon &b, NodeNum from = kRemoteNode) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = 0xDEAD0001; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + p.decoded.payload.size = + (pb_size_t)pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_MeshBeacon_msg, &b); + return p; +} + +/** + * Verify a beacon carrying preset and region offer fields is stored in lastReceivedOffer. + * Important to confirm the client app's offer cache is populated correctly for join-offer UI flows. + */ +static void test_listener_receiveWithOffer_cachesOffer(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + strncpy(b.message, "Join us on US/MEDIUM_FAST", sizeof(b.message) - 1); + b.has_offer_preset = true; + b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + b.offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Offer with preset must be cached"); + TEST_ASSERT_EQUAL(kRemoteNode, MeshBeaconListenerModule::lastReceivedOffer.sender); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, MeshBeaconListenerModule::lastReceivedOffer.region); +} + +/** + * Verify a beacon with a full ChannelSettings offer sets has_channel and copies the channel struct. + * Important because the client app checks has_channel before rendering a channel join offer. + */ +static void test_listener_receiveWithChannelOffer_setsHasChannel(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + strncpy(b.message, "Channel offer test", sizeof(b.message) - 1); + b.has_offer_channel = true; + b.offer_channel.channel_num = 5; + strncpy(b.offer_channel.name, "TestNet", sizeof(b.offer_channel.name) - 1); + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.has_channel, + "has_channel must be set when offer_channel is present"); + TEST_ASSERT_EQUAL_UINT32(5, MeshBeaconListenerModule::lastReceivedOffer.channel.channel_num); +} + +/** + * Verify a beacon with neither message text nor offer fields is silently discarded. + * Important to avoid spurious cache updates and wasted inbox copies from empty-payload packets. + */ +static void test_listener_emptyMessageWithoutOffer_isDropped(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + // message field intentionally left blank + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Empty message must not update offer cache"); +} + +/** + * Verify a LONG_FAST offer (preset enum value 0) with no message still populates the offer cache. + * Important to guard the has_offer_preset fix — LONG_FAST must not be treated as 'no offer present'. + */ +static void test_listener_offerOnly_isCached(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + b.has_offer_preset = true; + b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset); +} + +/** + * Verify a null MeshBeacon pointer is handled gracefully and returns false without a crash. + * Important to guard against the ProtobufModule base class passing nullptr on a decode failure. + */ +static void test_listener_nullBeacon_isDropped(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + bool result = listener.handleReceivedProtobuf(mp, nullptr); + + TEST_ASSERT_FALSE_MESSAGE(result, "Null beacon must return false"); + TEST_ASSERT_FALSE(MeshBeaconListenerModule::lastReceivedOffer.valid); +} + +/** + * Verify a text-only beacon (no offer fields set) does not mark the offer cache valid. + * Important to prevent the client from showing a join dialog in response to plain-text beacons. + */ +static void test_listener_receiveWithNoOffer_cacheStaysInvalid(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + strncpy(b.message, "No offer here", sizeof(b.message) - 1); + // has_offer_preset == false, has_offer_channel == false + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "No offer fields → cache must stay invalid"); +} + +/** + * Verify the listener does NOT unwrap a combined beacon's text into a synthesized TEXT_MESSAGE_APP. + * The original MESH_BEACON_APP packet already reaches the client (the handler returns CONTINUE), so + * a beacon-aware client reads `message` directly from it — injecting a copy would only duplicate it, + * and re-injecting onto the mesh would amplify/re-attribute. So: nothing onto the mesh, nothing + * synthesized to the phone, and the handler must not consume the packet. + */ +static void test_listener_textMessage_notUnwrapped(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + strncpy(b.message, "hello mesh", sizeof(b.message) - 1); + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + bool consumed = listener.handleReceivedProtobuf(mp, &b); + + // CONTINUE (not STOP): the original MESH_BEACON_APP keeps flowing to the client, which reads + // `message` from it — the simple path for a beacon-aware client. + TEST_ASSERT_FALSE_MESSAGE(consumed, "Listener must not consume the beacon; the original must reach the client"); + // Nothing re-injected onto the mesh. + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, mockRouter->sentPackets.size(), + "Received beacon text must not be re-injected into the mesh"); + // No synthesized TEXT_MESSAGE_APP delivered to the phone (no duplicate of the beacon's text). + meshtastic_MeshPacket *toPhone = service->getForPhone(); + TEST_ASSERT_NULL_MESSAGE(toPhone, "Listener must not inject a duplicate text packet to the phone"); +} + +/** + * Verify wantPacket returns false for MESH_BEACON_APP when listen_enabled is false. + * Important to confirm the module opts out of processing when its config flag is cleared. + */ +static void test_listener_wantPacket_falseWhenDisabled(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags &= ~MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + + TEST_ASSERT_FALSE(listener.wantPacket(&mp)); +} + +/** + * Verify wantPacket returns true for MESH_BEACON_APP packets when listen_enabled is true. + * Important as a basic routing sanity check confirming the module is registered for its portnum. + */ +static void test_listener_wantPacket_trueWhenEnabled(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + + TEST_ASSERT_TRUE(listener.wantPacket(&mp)); +} + +// =========================================================================== +// Group 6: Legacy split messages +// =========================================================================== + +/** + * Verify broadcast_legacy_split causes sendBeacon to emit exactly two packets when both + * text and offer content are present. + * Important to confirm the split path is wired end-to-end rather than short-circuiting. + */ +static void test_broadcaster_legacySplit_sendsTwoPackets(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "split-text", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "Legacy split must emit exactly 2 packets"); +} + +/** + * Verify the first packet in a legacy-split send is MESH_BEACON_APP (the offer packet). + * Important so receivers without a MESH_BEACON_APP decoder still get the text from packet B. + */ +static void test_broadcaster_legacySplit_firstPacketIsBeaconApp(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "split-offer-only", + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); +} + +/** + * Verify the MESH_BEACON_APP packet in a legacy-split send carries no message text. + * Important so peers' MESH_BEACON_APP handlers do not duplicate the text already in packet B. + */ +static void test_broadcaster_legacySplit_firstPacketHasNoMessageText(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "hidden-in-split", + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); + const meshtastic_MeshPacket &pA = mockRouter->sentPackets[0]; + meshtastic_MeshBeacon decodedA = meshtastic_MeshBeacon_init_zero; + pb_istream_t stream = pb_istream_from_buffer(pA.decoded.payload.bytes, pA.decoded.payload.size); + pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decodedA); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", decodedA.message, "Offer-only MESH_BEACON_APP must have empty message"); +} + +/** + * Verify the second packet in a legacy-split send is TEXT_MESSAGE_APP containing the message text. + * Important so legacy clients that only handle TEXT_MESSAGE_APP receive the human-readable text. + */ +static void test_broadcaster_legacySplit_secondPacketIsTextMessage(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LEGACY_SPLIT; + const char *msg = "split-B-text"; + strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); + const meshtastic_MeshPacket &pB = mockRouter->sentPackets[1]; + TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, pB.decoded.portnum); + TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)pB.decoded.payload.bytes, pB.decoded.payload.size); +} + +// =========================================================================== +// Group 7: Beacon-channel PSK swap (broadcast_on_channel override) +// =========================================================================== + +/** + * When broadcast_on_channel overrides the primary channel's name/PSK, the packet must be encrypted + * on the BEACON channel, not the primary. perhapsEncode keys off the primary slot, so sendBeaconPacket + * temporarily installs the beacon channel there for the send and restores it after. Verify both: the + * primary slot IS the beacon channel during send(), and it is restored afterwards (no leak). + */ +static void test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; // gives the beacon radio content to send + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, "BeaconCh", + sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); + static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; + moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconPsk); + memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconPsk, sizeof(beaconPsk)); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + // During send(), the primary slot must hold the BEACON channel (so encryption uses its PSK). + TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); + const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0]; + TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconCh", atSend.name, "primary must be the beacon channel during send"); + TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the beacon channel PSK"); + + // After send(), the primary channel must be restored to the original (no leak into normal traffic). + const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings; + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send"); + TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size); + TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]); +} + +/** + * Without a broadcast_on_channel override, the beacon must transmit on the primary channel unchanged + * (no swap). Guards against the swap firing — and churning the channel table — when it isn't needed. + */ +static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + // No broadcast_on_channel override. + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name, + "primary must stay unchanged when no channel override is set"); +} + +/** + * A broadcast_target whose channel_index points at a configured table slot must transmit encrypted + * on THAT slot's channel (name + PSK), not the primary. Verifies the slot-index → channel-table + * resolution introduced when BroadcastTarget dropped its embedded ChannelSettings. + */ +static void test_broadcaster_targetChannelIndex_usesTableSlot(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + installTestSecondaryChannel(1, "BeaconNet", beaconPsk, sizeof(beaconPsk)); + + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; // content to send + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index = true; + moduleConfig.mesh_beacon.broadcast_targets[0].channel_index = 1; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", mockRouter->primaryAtSend[0].name, + "beacon must be encrypted on the referenced slot's channel"); + // Primary slot restored to home after send (no leak). + TEST_ASSERT_EQUAL_STRING("Home", channels.getByIndex(channels.getPrimaryIndex()).settings.name); +} + +/** + * A broadcast_target whose channel_index points at a BLANK table slot (no name, no PSK) must fall + * back to the default channel for the preset rather than borrowing the primary's name/PSK with a + * clobbered channel_num. Guards the blank-slot handling for multi-target configs. + */ +static void test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + installTestSecondaryChannel(1, nullptr, nullptr, 0); // blank slot: no name, no PSK + + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index = true; + moduleConfig.mesh_beacon.broadcast_targets[0].channel_index = 1; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + // Exactly one beacon goes out, and a blank slot does NOT trigger a crypto swap — the primary + // stays "Home" (no garbage / no clobber), matching the no-channel-override default-for-preset path. + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name, + "blank slot must not swap the beacon onto a borrowed channel"); +} + +/** + * Two broadcast_targets that resolve to the same effective radio config (same preset/region/channel) + * must produce only ONE beacon — the payload is identical, so re-broadcasting wastes airtime. + */ +static void test_broadcaster_duplicateTargets_dedupedToOnePacket(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 2; + for (int i = 0; i < 2; i++) { + moduleConfig.mesh_beacon.broadcast_targets[i].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[i].preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW; + moduleConfig.mesh_beacon.broadcast_targets[i].region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + } + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, mockRouter->sentPackets.size(), "duplicate targets must collapse to one beacon"); +} + +/** + * Two distinct broadcast_targets (different presets) must BOTH be sent — dedup must not over-collapse. + */ +static void test_broadcaster_distinctTargets_bothSent(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 2; + moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW; + moduleConfig.mesh_beacon.broadcast_targets[1].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[1].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "distinct targets must each be sent"); +} + +} // namespace + +// =========================================================================== +// Unity lifecycle +// =========================================================================== + +void setUp(void) +{ + testAirTime = new AirTime(); + airTime = testAirTime; + + mockSvc = new MockMeshService(); + service = mockSvc; + + mockRouter = new MockRouter(); + router = mockRouter; + + testAdmin = new AdminModuleTestShim(); +} + +void tearDown(void) +{ + meshBeaconBroadcastModule = nullptr; + + delete testAdmin; + testAdmin = nullptr; + + // Drain any packets the listener delivered via sendToPhone() (toPhoneQueue takes ownership and + // nothing else dequeues them in tests) so they are returned to packetPool — otherwise they leak + // and LeakSanitizer aborts the process at exit. + if (mockSvc) { + meshtastic_MeshPacket *p; + while ((p = mockSvc->getForPhone()) != nullptr) + mockSvc->releaseToPool(p); + } + + service = nullptr; + delete mockSvc; + mockSvc = nullptr; + + router = nullptr; + delete mockRouter; + mockRouter = nullptr; + + airTime = nullptr; + delete testAirTime; + testAirTime = nullptr; +} + +BEACON_TEST_ENTRY void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== AdminModule config validation ===\n"); + + RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared); + RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared); + RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted); + RUN_TEST(test_adminValidation_unknownOfferRegion_isCleared); + RUN_TEST(test_adminValidation_validOfferRegion_isPreserved); + RUN_TEST(test_adminValidation_targetUnknownRegion_isCleared); + RUN_TEST(test_adminValidation_targetInvalidPresetForRegion_isCleared); + RUN_TEST(test_adminValidation_targetValidPresetForRegion_isPreserved); + RUN_TEST(test_adminValidation_targetChannelIndexOutOfRange_isCleared); + RUN_TEST(test_adminValidation_targetChannelIndexInRange_isPreserved); + RUN_TEST(test_adminValidation_messageTooLong_isTruncatedAt100); + RUN_TEST(test_adminValidation_intervalTooLow_isClamped); + RUN_TEST(test_adminValidation_intervalTooHigh_isPreserved); + RUN_TEST(test_adminValidation_intervalZero_isNotClamped); + RUN_TEST(test_adminValidation_longFastOfferPreset_isPreserved); + RUN_TEST(test_adminValidation_validSave_invalidatesCache); + + printf("\n=== Broadcaster payload cache ===\n"); + + RUN_TEST(test_broadcaster_rebuildCache_producesNonEmptyPayload); + RUN_TEST(test_broadcaster_rebuildCache_payloadDecodesCorrectly); + RUN_TEST(test_broadcaster_rebuildCache_offerFieldsEncoded); + RUN_TEST(test_broadcaster_invalidateCache_setsDirtyFlag); + RUN_TEST(test_broadcaster_rebuildCache_idempotent); + + printf("\n=== Broadcaster sendBeacon ===\n"); + + RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset); + RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); + RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast); + RUN_TEST(test_broadcaster_sendBeacon_usesBeaconPortnum); + RUN_TEST(test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum); + RUN_TEST(test_broadcaster_sendBeacon_payloadDecodesCorrectly); + RUN_TEST(test_broadcaster_sendBeacon_offerOnly_isSent); + RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled); + RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled); + + printf("\n=== Listener offer caching ===\n"); + + RUN_TEST(test_listener_receiveWithOffer_cachesOffer); + RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel); + RUN_TEST(test_listener_emptyMessageWithoutOffer_isDropped); + RUN_TEST(test_listener_offerOnly_isCached); + RUN_TEST(test_listener_nullBeacon_isDropped); + RUN_TEST(test_listener_receiveWithNoOffer_cacheStaysInvalid); + RUN_TEST(test_listener_textMessage_notUnwrapped); + RUN_TEST(test_listener_wantPacket_falseWhenDisabled); + RUN_TEST(test_listener_wantPacket_trueWhenEnabled); + + printf("\n=== Legacy split messages ===\n"); + + RUN_TEST(test_broadcaster_legacySplit_sendsTwoPackets); + RUN_TEST(test_broadcaster_legacySplit_firstPacketIsBeaconApp); + RUN_TEST(test_broadcaster_legacySplit_firstPacketHasNoMessageText); + RUN_TEST(test_broadcaster_legacySplit_secondPacketIsTextMessage); + + printf("\n=== Beacon-channel PSK swap ===\n"); + + RUN_TEST(test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores); + RUN_TEST(test_broadcaster_noChannelOverride_doesNotSwapPrimary); + RUN_TEST(test_broadcaster_targetChannelIndex_usesTableSlot); + RUN_TEST(test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset); + RUN_TEST(test_broadcaster_duplicateTargets_dedupedToOnePacket); + RUN_TEST(test_broadcaster_distinctTargets_bothSent); + + exit(UNITY_END()); +} + +BEACON_TEST_ENTRY void loop() {} + +#else // MESHTASTIC_EXCLUDE_BEACON + +void setUp(void) {} +void tearDown(void) {} + +BEACON_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +BEACON_TEST_ENTRY void loop() {} + +#endif diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 118588e19..4df50abca 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -58,6 +58,37 @@ // "USERPREFS_MQTT_ENCRYPTION_ENABLED": "true", // "USERPREFS_MQTT_TLS_ENABLED": "false", // "USERPREFS_MQTT_ROOT_TOPIC": "event/REPLACEME", + // "USERPREFS_MESH_BEACON_LISTEN_ENABLED": "true", // Accept incoming beacons (default: on) + // "USERPREFS_MESH_BEACON_BROADCAST_ENABLED": "true", // Periodically broadcast beacons from this node + // "USERPREFS_MESH_BEACON_MESSAGE": "Join us on NarrowSlow!!!", // Text payload included in every beacon broadcast + // "USERPREFS_MESH_BEACON_INTERVAL_SECS": "3600", // Broadcast interval in seconds (min 3600 = 1 hour) + // "USERPREFS_MESH_BEACON_OFFER_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW", // Modem preset advertised in the beacon payload (listeners can adopt this) + // "USERPREFS_MESH_BEACON_OFFER_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_N_868", // Region advertised in the beacon payload + // "USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME": "'MyChannel'", // Channel name advertised in the beacon payload + // "USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK": "{ 0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1 }", // PSK for the offered channel (32-byte AES-256) + // "USERPREFS_MESH_BEACON_ON_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST", // Modem preset to use when transmitting beacons (radio temporarily switched for TX) + // "USERPREFS_MESH_BEACON_ON_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", // Region to use when transmitting beacons + // "USERPREFS_MESH_BEACON_ON_CHANNEL_NAME": "'LongFast'", // Channel name to use on the TX radio config - uses default if unset. + // "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK": "{ 0x01 }", // PSK for the TX channel (0x01 = Meshtastic default PSK) + // "USERPREFS_MESH_BEACON_ON_CHANNEL_NUM": "0", // LoRa channel/frequency slot to use on the TX radio config - zero is default, 20 is standard for US LongFast, etc. + // "USERPREFS_MESH_BEACON_LEGACY_SPLIT": "true", // When both text and offer are present, split into a separate MESH_BEACON_APP (offer) and TEXT_MESSAGE_APP (text) for legacy client compatibility + // Multi-target broadcast: when any TARGET__* key is set, broadcast_targets overrides the + // single-target broadcast_on_* fields above. Each target transmits its own copy of the beacon. + // Up to 4 targets (0-3). Only TARGET_0 is used here; uncomment TARGET_1 to add a second preset. + // CHANNEL_INDEX references a slot in the device's channel table (0..MAX_NUM_CHANNELS-1); that + // channel must already be configured on the node (its key is needed to encrypt the beacon). + // "USERPREFS_MESH_BEACON_TARGET_0_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST", + // "USERPREFS_MESH_BEACON_TARGET_0_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", + // "USERPREFS_MESH_BEACON_TARGET_0_CHANNEL_INDEX": "0", // device channel-table slot to transmit on + // "USERPREFS_MESH_BEACON_TARGET_1_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW", + // "USERPREFS_MESH_BEACON_TARGET_1_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_N_868", + // "USERPREFS_MESH_BEACON_TARGET_1_CHANNEL_INDEX": "1", + // "USERPREFS_MESH_BEACON_TARGET_2_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST", + // "USERPREFS_MESH_BEACON_TARGET_2_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", + // "USERPREFS_MESH_BEACON_TARGET_2_CHANNEL_INDEX": "2", + // "USERPREFS_MESH_BEACON_TARGET_3_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST", + // "USERPREFS_MESH_BEACON_TARGET_3_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", + // "USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX": "3", // "USERPREFS_RINGTONE_NAG_SECS": "60", // "USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS": "43200", // "USERPREFS_UI_TEST_LOG": "true", // Test-only: emits `Screen: frame N/M name=... reason=...` log per UI transition (for the mcp-server ui test tier); off in release builds.