feat(beacon): per-target and offered frequency slots (#12126)

* feat(beacon): per-target and offered frequency slots

Implements the slot fields from meshtastic/protobufs#1059 on top of the
broadcast_targets scheme from #11646:

- BroadcastTarget.frequency_slot pins a target's slot. Unset derives it the
  way a node on that channel would: the target region's override slot, else
  the hash of the target channel's name on the target preset. A target on
  the home radio keeps the home slot. A pin the target region does not hold
  skips the target. The deprecated ChannelSettings.channel_num is no longer
  read, and the home node's channel_num no longer leaks into other presets.
- broadcast_offer_frequency_slot is advertised as MeshBeacon.offer_frequency_slot
  only when a receiver could not derive it. An offer pinned to a slot its
  region lacks is withheld; the text still goes out.
- The listener keeps a received offer's slot.
- Admin clears slot 0, and a slot past an explicit region and preset.
- userPrefs: USERPREFS_MESH_BEACON_OFFER_FREQUENCY_SLOT and
  USERPREFS_MESH_BEACON_TARGET_<N>_FREQUENCY_SLOT.

Also from #11662:

- A beacon is addressed at its target's channel index, so perhapsEncode()
  keys it like any other packet; the primary channel is never overwritten.
  The TX hook writes the target preset's channel hash for a blank-named
  channel on another preset.
- A beacon whose send() did not queue releases its target entry on every
  failure, not only ERRNO_SHOULD_RELEASE.
- A beacon still queued a broadcast interval after it was armed, or whose
  entry was evicted from a full table, is dropped at TX instead of going
  out on the home radio.

Supersedes #11662.

Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>

* fix(beacon): address review of frequency slots

- TX gate checks the target preset against its region or EU sibling, not the home slot
- Targets and offers run an EU sibling's preset in that sibling, as the switch does
- Switch clears override_frequency and restores tx_power; restore keeps fields edited mid-switch
- An unusable channel_index skips the target instead of falling back to the primary
- Beacon config validation moved to MeshBeaconModule::sanitizeConfig(), also run on load;
  message cap no longer ends mid-character
- Evicted-id ring sized to the TX queue

* fix(radio): frequencySlotCount() reports no slots for UNSET

UNSET's table span is a placeholder; an unknown region code resolves to it too.

* fix(beacon): slot count and restore for custom bandwidth

- frequencySlotCount() counts a custom bandwidth wider than the region at the region default, as the clamp runs it
- resolveFrequencySlot() falls back to the hash when the region override slot exceeds the count
- The beacon switch restores a custom bandwidth applyModemConfig() clamped for the target region

* fix(beacon): run a target's named preset on a node with custom modem params

The switch sets use_preset for a target that names a preset and restores it after TX. A target naming no preset keeps the custom params.

* fix(beacon): keep an out-of-range target channel_index

sanitizeConfig() cleared has_channel_index for an index past the channel
table. That turns the entry into an unset-index target, and sendBeacon()
sends those on the primary channel - under a name and key the operator
never named. sanitizeConfig() now also runs from loadFromDisk(), so a
stored or userPrefs index did that at every boot, not only on the write
that set it.

Keep the index and only warn. sendBeacon() already bounds-checks it
against channels.getNumChannels(), which is tighter than
MAX_NUM_CHANNELS, before getByIndex(), and skips the target - so nothing
can index out of bounds, which is what the clear was there for.

test_adminValidation_targetChannelIndexOutOfRange_isCleared becomes
_isKeptAndSkipped: the index survives the write, and the stored config
emits no beacon rather than one on the primary.

Reported by NomDeTom on #12126.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SwNPp17REf1c9je42aWyJa

---------

Co-authored-by: Tom <116762865+NomDeTom@users.noreply.github.com>
Co-authored-by: Jonathan Bennett <jbennett@incomsystems.biz>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-24 18:41:20 +00:00
1 parent e039e34171
commit 95ff510f47
10 files changed
+1307 -331

No files matched your search

+1 -57
View File
@@ -1383,63 +1383,7 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
// 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;
// Cap at the generated field size, so the limit follows the proto's max_size.
beaconCfg.broadcast_message[sizeof(beaconCfg.broadcast_message) - 1] = '\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_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 broadcast target 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;
}
}
MeshBeaconModule::sanitizeConfig(beaconCfg);
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon = beaconCfg;
shouldReboot = false;