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:
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
@@ -35,6 +35,9 @@
|
||||
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
|
||||
#include "security/EncryptedStorage.h"
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
#include "modules/MeshBeaconModule.h"
|
||||
#endif
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_MQTT
|
||||
#include "mqtt/MQTT.h"
|
||||
@@ -313,6 +316,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
|
||||
|
||||
case meshtastic_AdminMessage_set_module_config_tag:
|
||||
LOG_DEBUG("Client set module config");
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
// broadcast_send_as_node: remote admins may only set this to their own node ID.
|
||||
if (mp.from != 0 && r->set_module_config.which_payload_variant == meshtastic_ModuleConfig_mesh_beacon_tag) {
|
||||
auto &b = r->set_module_config.payload_variant.mesh_beacon;
|
||||
if (b.broadcast_send_as_node != 0 && b.broadcast_send_as_node != mp.from) {
|
||||
LOG_WARN("Beacon: rejecting broadcast_send_as_node 0x%08x from node 0x%08x (must match sender)",
|
||||
b.broadcast_send_as_node, mp.from);
|
||||
b.broadcast_send_as_node = moduleConfig.mesh_beacon.broadcast_send_as_node;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if (!handleSetModuleConfig(r->set_module_config)) {
|
||||
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
|
||||
}
|
||||
@@ -1161,6 +1175,91 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
|
||||
moduleConfig.has_traffic_management = true;
|
||||
moduleConfig.traffic_management = c.payload_variant.traffic_management;
|
||||
break;
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
case meshtastic_ModuleConfig_mesh_beacon_tag: {
|
||||
LOG_INFO("Set module config: MeshBeacon");
|
||||
// Sanitize a local copy rather than const_cast-ing the const input (UB if a truly-const
|
||||
// object is ever passed); the validated copy is assigned into moduleConfig below.
|
||||
auto beaconCfg = c.payload_variant.mesh_beacon;
|
||||
// Hard cap at 100 chars.
|
||||
beaconCfg.broadcast_message[100] = '\0';
|
||||
// Enforce interval minimum (0 means unset/use default).
|
||||
if (beaconCfg.broadcast_interval_secs != 0 &&
|
||||
beaconCfg.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs)
|
||||
beaconCfg.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs;
|
||||
// Validate broadcast_on_preset against broadcast_on_region (or current region if unset).
|
||||
if (beaconCfg.has_broadcast_on_preset) {
|
||||
meshtastic_Config_LoRaConfig probe = config.lora;
|
||||
probe.use_preset = true;
|
||||
probe.modem_preset = beaconCfg.broadcast_on_preset;
|
||||
if (beaconCfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
||||
probe.region = beaconCfg.broadcast_on_region;
|
||||
if (!RadioInterface::validateConfigLora(probe)) {
|
||||
LOG_WARN("Beacon: broadcast_on_preset %d invalid for region, clearing", beaconCfg.broadcast_on_preset);
|
||||
beaconCfg.has_broadcast_on_preset = false;
|
||||
beaconCfg.has_broadcast_on_channel = false;
|
||||
}
|
||||
}
|
||||
// Validate broadcast_offer_preset against broadcast_offer_region (or current region if unset).
|
||||
if (beaconCfg.has_broadcast_offer_preset) {
|
||||
meshtastic_Config_LoRaConfig probe = config.lora;
|
||||
probe.use_preset = true;
|
||||
probe.modem_preset = beaconCfg.broadcast_offer_preset;
|
||||
if (beaconCfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
||||
probe.region = beaconCfg.broadcast_offer_region;
|
||||
if (!RadioInterface::validateConfigLora(probe)) {
|
||||
LOG_WARN("Beacon: broadcast_offer_preset %d invalid for region, clearing", beaconCfg.broadcast_offer_preset);
|
||||
beaconCfg.has_broadcast_offer_preset = false;
|
||||
}
|
||||
}
|
||||
// Validate broadcast_offer_region is a known region code.
|
||||
if (beaconCfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
const RegionInfo *r = getRegion(beaconCfg.broadcast_offer_region);
|
||||
if (r->code != beaconCfg.broadcast_offer_region) {
|
||||
LOG_WARN("Beacon: broadcast_offer_region %d invalid, clearing", beaconCfg.broadcast_offer_region);
|
||||
beaconCfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||
}
|
||||
}
|
||||
// Validate each multi-target entry the same way as the single-target broadcast_on_* fields,
|
||||
// so a bad preset/region is cleared on write rather than relying on the runtime TX drop.
|
||||
for (pb_size_t i = 0; i < beaconCfg.broadcast_targets_count; i++) {
|
||||
auto &t = beaconCfg.broadcast_targets[i];
|
||||
// Region must be a known region code (UNSET = use running config at TX time).
|
||||
if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
|
||||
const RegionInfo *r = getRegion(t.region);
|
||||
if (r->code != t.region) {
|
||||
LOG_WARN("Beacon: broadcast_targets[%u] region %d invalid, clearing", i, t.region);
|
||||
t.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
|
||||
}
|
||||
}
|
||||
// Preset must be valid for the target region (or current region if unset).
|
||||
if (t.has_preset) {
|
||||
meshtastic_Config_LoRaConfig probe = config.lora;
|
||||
probe.use_preset = true;
|
||||
probe.modem_preset = t.preset;
|
||||
if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)
|
||||
probe.region = t.region;
|
||||
if (!RadioInterface::validateConfigLora(probe)) {
|
||||
LOG_WARN("Beacon: broadcast_targets[%u] preset %d invalid for region, clearing", i, t.preset);
|
||||
t.has_preset = false;
|
||||
t.has_channel_index = false;
|
||||
}
|
||||
}
|
||||
// channel_index must reference a real channel-table slot.
|
||||
if (t.has_channel_index && t.channel_index >= MAX_NUM_CHANNELS) {
|
||||
LOG_WARN("Beacon: broadcast_targets[%u] channel_index %u out of range, clearing", i, t.channel_index);
|
||||
t.has_channel_index = false;
|
||||
}
|
||||
}
|
||||
moduleConfig.has_mesh_beacon = true;
|
||||
moduleConfig.mesh_beacon = beaconCfg;
|
||||
shouldReboot = false;
|
||||
// Payload content changed — invalidate the broadcaster's cache.
|
||||
if (meshBeaconBroadcastModule)
|
||||
meshBeaconBroadcastModule->invalidateCache();
|
||||
break;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
saveChanges(SEGMENT_MODULECONFIG, shouldReboot);
|
||||
return true;
|
||||
|
||||
@@ -64,7 +64,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
|
||||
protected:
|
||||
void handleSetConfig(const meshtastic_Config &c, bool fromOthers);
|
||||
|
||||
#ifdef PIO_UNIT_TESTING
|
||||
protected:
|
||||
#else
|
||||
private:
|
||||
#endif
|
||||
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
|
||||
void handleSetChannel();
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -28,6 +28,9 @@
|
||||
#if !MESHTASTIC_EXCLUDE_NODEINFO
|
||||
#include "modules/NodeInfoModule.h"
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
#include "modules/MeshBeaconModule.h"
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_GPS
|
||||
#include "modules/PositionModule.h"
|
||||
#endif
|
||||
@@ -143,6 +146,10 @@ void setupModules()
|
||||
#if !MESHTASTIC_EXCLUDE_NODEINFO
|
||||
nodeInfoModule = new NodeInfoModule();
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_BEACON
|
||||
meshBeaconBroadcastModule = new MeshBeaconBroadcastModule();
|
||||
meshBeaconListenerModule = new MeshBeaconListenerModule();
|
||||
#endif
|
||||
#if !MESHTASTIC_EXCLUDE_GPS
|
||||
positionModule = new PositionModule();
|
||||
#endif
|
||||
|
||||
Reference in New Issue
Block a user