Feat/mesh beacon (#10618)

* 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<meshtastic_MeshBeacon>
  (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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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_<n>_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) <noreply@anthropic.com>
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 <steve@erayd.net>
Co-authored-by: Darafei Praliaskouski <me@komzpa.net>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Tom
2026-06-27 08:20:51 -05:00
committed by GitHub
co-authored by GitHub Steve Gilberd Darafei Praliaskouski github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Claude Opus 4.8
parent ad132e9540
commit ec5d230305
14 changed files with 3037 additions and 8 deletions
+454
View File
@@ -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_<n>_*` 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: <key>, 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 |
+5 -5
View File
@@ -105,6 +105,11 @@ class Channels
bool setDefaultPresetCryptoForHash(ChannelHash channelHash); 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]; } int16_t getHash(ChannelIndex i) { return hashes[i]; }
private: private:
@@ -124,11 +129,6 @@ class Channels
*/ */
int16_t generateHash(ChannelIndex channelNum); int16_t generateHash(ChannelIndex channelNum);
/**
* Validate a channel, fixing any errors as needed
*/
meshtastic_Channel &fixupChannel(ChannelIndex chIndex);
/** /**
* Writes the default lora config * Writes the default lora config
*/ */
+1
View File
@@ -30,6 +30,7 @@
#define default_screen_on_secs IF_ROUTER(1, 60 * 10) #define default_screen_on_secs IF_ROUTER(1, 60 * 10)
#define default_node_info_broadcast_secs 3 * 60 * 60 #define default_node_info_broadcast_secs 3 * 60 * 60
#define default_neighbor_info_broadcast_secs 6 * 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_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 min_neighbor_info_broadcast_secs 4 * 60 * 60
#define default_map_publish_interval_secs 60 * 60 #define default_map_publish_interval_secs 60 * 60
+149
View File
@@ -1308,6 +1308,152 @@ void NodeDB::installDefaultModuleConfig()
moduleConfig.ambient_lighting.green = (myNodeInfo.my_node_num & 0x00FF00) >> 8; moduleConfig.ambient_lighting.green = (myNodeInfo.my_node_num & 0x00FF00) >> 8;
moduleConfig.ambient_lighting.blue = myNodeInfo.my_node_num & 0x0000FF; 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_<N>_* 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(); initModuleConfigIntervals();
} }
@@ -2758,6 +2904,9 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat)
moduleConfig.has_audio = true; moduleConfig.has_audio = true;
moduleConfig.has_paxcounter = true; moduleConfig.has_paxcounter = true;
moduleConfig.has_statusmessage = true; moduleConfig.has_statusmessage = true;
#if !MESHTASTIC_EXCLUDE_BEACON
moduleConfig.has_mesh_beacon = true;
#endif
success &= success &=
saveProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, &meshtastic_LocalModuleConfig_msg, &moduleConfig); saveProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, &meshtastic_LocalModuleConfig_msg, &moduleConfig);
+41 -1
View File
@@ -8,6 +8,9 @@
#include "error.h" #include "error.h"
#include "main.h" #include "main.h"
#include "mesh-pb-constants.h" #include "mesh-pb-constants.h"
#if !MESHTASTIC_EXCLUDE_BEACON
#include "modules/MeshBeaconModule.h"
#endif
#include <pb_decode.h> #include <pb_decode.h>
#include <pb_encode.h> #include <pb_encode.h>
@@ -365,7 +368,15 @@ void RadioLibInterface::onNotify(uint32_t notification)
switch (notification) { switch (notification) {
case ISR_TX: 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(); startReceive();
setTransmitDelay(); setTransmitDelay();
break; break;
@@ -388,9 +399,27 @@ void RadioLibInterface::onNotify(uint32_t notification)
if (delay_remaining > 0) { if (delay_remaining > 0) {
// There's still some delay pending on this packet, so resume waiting for it to elapse // There's still some delay pending on this packet, so resume waiting for it to elapse
notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, false); 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 { } else {
if (isChannelActive()) { // check if there is currently a LoRa packet on the channel if (isChannelActive()) { // check if there is currently a LoRa packet on the channel
#if !MESHTASTIC_EXCLUDE_BEACON
if (!MeshBeaconModule::hasTargetRadioSettings(txp))
#endif
{
startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again
}
setTransmitDelay(); setTransmitDelay();
} else { } else {
// Send any outgoing packets we have ready as fast as possible to keep the time between channel scan and // 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)) if (!isFromUs(p))
txRelay++; txRelay++;
printPacket("Completed sending", p); printPacket("Completed sending", p);
#if !MESHTASTIC_EXCLUDE_BEACON
MeshBeaconModule::clearTargetRadioSettings(p);
MeshBeaconModule::reconfigureForBeaconTX(this, nullptr);
#endif
// We are done sending that packet, release it // We are done sending that packet, release it
packetPool.release(p); 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. */ channel scan and actual transmit as low as possible to avoid collisions. */
if (disabled || !config.lora.tx_enabled) { if (disabled || !config.lora.tx_enabled) {
LOG_WARN("Drop Tx packet because LoRa Tx disabled"); 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); packetPool.release(txp);
return false; return false;
} else { } else {
+13
View File
@@ -832,6 +832,19 @@ void Router::handleReceived(meshtastic_MeshPacket *p, RxSource src)
skipHandle = true; 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 = bool shouldIgnoreNonstandardPorts =
config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY; config.device.rebroadcast_mode == meshtastic_Config_DeviceConfig_RebroadcastMode_CORE_PORTNUMS_ONLY;
#if USERPREFS_EVENT_MODE #if USERPREFS_EVENT_MODE
+99
View File
@@ -35,6 +35,9 @@
#ifdef MESHTASTIC_ENCRYPTED_STORAGE #ifdef MESHTASTIC_ENCRYPTED_STORAGE
#include "security/EncryptedStorage.h" #include "security/EncryptedStorage.h"
#endif #endif
#if !MESHTASTIC_EXCLUDE_BEACON
#include "modules/MeshBeaconModule.h"
#endif
#if !MESHTASTIC_EXCLUDE_MQTT #if !MESHTASTIC_EXCLUDE_MQTT
#include "mqtt/MQTT.h" #include "mqtt/MQTT.h"
@@ -313,6 +316,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
case meshtastic_AdminMessage_set_module_config_tag: case meshtastic_AdminMessage_set_module_config_tag:
LOG_DEBUG("Client set module config"); 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)) { if (!handleSetModuleConfig(r->set_module_config)) {
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); 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.has_traffic_management = true;
moduleConfig.traffic_management = c.payload_variant.traffic_management; moduleConfig.traffic_management = c.payload_variant.traffic_management;
break; 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); saveChanges(SEGMENT_MODULECONFIG, shouldReboot);
return true; return true;
+4
View File
@@ -64,7 +64,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
protected: protected:
void handleSetConfig(const meshtastic_Config &c, bool fromOthers); void handleSetConfig(const meshtastic_Config &c, bool fromOthers);
#ifdef PIO_UNIT_TESTING
protected:
#else
private: private:
#endif
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c); bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
void handleSetChannel(); void handleSetChannel();
+611
View File
@@ -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 <Throttle.h>
#include <string.h>
// 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<int32_t>(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;
}
+163
View File
@@ -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<meshtastic_MeshBeacon>,
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<meshtastic_MeshBeacon>, public Observable<const meshtastic_MeshPacket *>
{
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;
+7
View File
@@ -28,6 +28,9 @@
#if !MESHTASTIC_EXCLUDE_NODEINFO #if !MESHTASTIC_EXCLUDE_NODEINFO
#include "modules/NodeInfoModule.h" #include "modules/NodeInfoModule.h"
#endif #endif
#if !MESHTASTIC_EXCLUDE_BEACON
#include "modules/MeshBeaconModule.h"
#endif
#if !MESHTASTIC_EXCLUDE_GPS #if !MESHTASTIC_EXCLUDE_GPS
#include "modules/PositionModule.h" #include "modules/PositionModule.h"
#endif #endif
@@ -143,6 +146,10 @@ void setupModules()
#if !MESHTASTIC_EXCLUDE_NODEINFO #if !MESHTASTIC_EXCLUDE_NODEINFO
nodeInfoModule = new NodeInfoModule(); nodeInfoModule = new NodeInfoModule();
#endif #endif
#if !MESHTASTIC_EXCLUDE_BEACON
meshBeaconBroadcastModule = new MeshBeaconBroadcastModule();
meshBeaconListenerModule = new MeshBeaconListenerModule();
#endif
#if !MESHTASTIC_EXCLUDE_GPS #if !MESHTASTIC_EXCLUDE_GPS
positionModule = new PositionModule(); positionModule = new PositionModule();
#endif #endif
+1 -1
View File
@@ -1 +1 @@
24 25
File diff suppressed because it is too large Load Diff
+31
View File
@@ -58,6 +58,37 @@
// "USERPREFS_MQTT_ENCRYPTION_ENABLED": "true", // "USERPREFS_MQTT_ENCRYPTION_ENABLED": "true",
// "USERPREFS_MQTT_TLS_ENABLED": "false", // "USERPREFS_MQTT_TLS_ENABLED": "false",
// "USERPREFS_MQTT_ROOT_TOPIC": "event/REPLACEME", // "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_<N>_* 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_RINGTONE_NAG_SECS": "60",
// "USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS": "43200", // "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. // "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.