diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 223d87c065..3713a6f89f 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -37,12 +37,16 @@ uint8_t xorHash(const uint8_t *p, size_t len) * If no suitable channel could be found, return -1 */ int16_t Channels::generateHash(ChannelIndex channelNum) +{ + return hashFor(channelNum, getName(channelNum)); +} + +int16_t Channels::hashFor(ChannelIndex channelNum, const char *name) { auto k = getKey(channelNum); if (k.length < 0) return -1; // invalid else { - const char *name = getName(channelNum); uint8_t h = xorHash((const uint8_t *)name, strlen(name)); h ^= xorHash(k.bytes, k.length); diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 6fd9f807a4..58b8e48988 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -119,6 +119,9 @@ class Channels int16_t getHash(ChannelIndex i) { return hashes[i]; } + /** The hash a channel would carry under another name, e.g. a blank-named one on another preset. -1 if invalid. */ + int16_t hashFor(ChannelIndex chIndex, const char *name); + /** Return true if the channel has AEAD (authenticated encryption) enabled */ bool isAEADEnabled(ChannelIndex chIndex); diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 515c28a0da..e63e90abf3 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -32,6 +32,9 @@ #include "mesh/generated/meshtastic/deviceonly_legacy.pb.h" #include "meshUtils.h" #include "modules/NeighborInfoModule.h" +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif #include "sleep.h" #include "target_specific.h" #if HAS_VARIABLE_HOPS @@ -1618,6 +1621,10 @@ void NodeDB::installDefaultModuleConfig() #ifdef USERPREFS_MESH_BEACON_OFFER_REGION moduleConfig.mesh_beacon.broadcast_offer_region = USERPREFS_MESH_BEACON_OFFER_REGION; #endif +#ifdef USERPREFS_MESH_BEACON_OFFER_FREQUENCY_SLOT + moduleConfig.mesh_beacon.has_broadcast_offer_frequency_slot = true; + moduleConfig.mesh_beacon.broadcast_offer_frequency_slot = USERPREFS_MESH_BEACON_OFFER_FREQUENCY_SLOT; +#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, @@ -1668,6 +1675,14 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.mesh_beacon.broadcast_targets[(N)].has_channel_index = true; \ moduleConfig.mesh_beacon.broadcast_targets[(N)].channel_index = (VAL); \ } while (0) +// 1-based frequency slot, as Config.LoRaConfig.channel_num. Unset derives it from the target channel. +#define BEACON_TARGET_SLOT(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_frequency_slot = true; \ + moduleConfig.mesh_beacon.broadcast_targets[(N)].frequency_slot = (VAL); \ + } while (0) #ifdef USERPREFS_MESH_BEACON_TARGET_0_PRESET BEACON_TARGET_PRESET(0, USERPREFS_MESH_BEACON_TARGET_0_PRESET); #endif @@ -1677,6 +1692,9 @@ void NodeDB::installDefaultModuleConfig() #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_0_FREQUENCY_SLOT + BEACON_TARGET_SLOT(0, USERPREFS_MESH_BEACON_TARGET_0_FREQUENCY_SLOT); +#endif #ifdef USERPREFS_MESH_BEACON_TARGET_1_PRESET BEACON_TARGET_PRESET(1, USERPREFS_MESH_BEACON_TARGET_1_PRESET); #endif @@ -1686,6 +1704,9 @@ void NodeDB::installDefaultModuleConfig() #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_1_FREQUENCY_SLOT + BEACON_TARGET_SLOT(1, USERPREFS_MESH_BEACON_TARGET_1_FREQUENCY_SLOT); +#endif #ifdef USERPREFS_MESH_BEACON_TARGET_2_PRESET BEACON_TARGET_PRESET(2, USERPREFS_MESH_BEACON_TARGET_2_PRESET); #endif @@ -1695,6 +1716,9 @@ void NodeDB::installDefaultModuleConfig() #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_2_FREQUENCY_SLOT + BEACON_TARGET_SLOT(2, USERPREFS_MESH_BEACON_TARGET_2_FREQUENCY_SLOT); +#endif #ifdef USERPREFS_MESH_BEACON_TARGET_3_PRESET BEACON_TARGET_PRESET(3, USERPREFS_MESH_BEACON_TARGET_3_PRESET); #endif @@ -1704,9 +1728,13 @@ void NodeDB::installDefaultModuleConfig() #ifdef USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX BEACON_TARGET_CH_INDEX(3, USERPREFS_MESH_BEACON_TARGET_3_CHANNEL_INDEX); #endif +#ifdef USERPREFS_MESH_BEACON_TARGET_3_FREQUENCY_SLOT + BEACON_TARGET_SLOT(3, USERPREFS_MESH_BEACON_TARGET_3_FREQUENCY_SLOT); +#endif #undef BEACON_TARGET_PRESET #undef BEACON_TARGET_REGION #undef BEACON_TARGET_CH_INDEX +#undef BEACON_TARGET_SLOT #endif // !MESHTASTIC_EXCLUDE_BEACON initModuleConfigIntervals(); @@ -2854,6 +2882,10 @@ void NodeDB::loadFromDisk() LOG_INFO("Loaded saved moduleConfig v%d", moduleConfig.version); } } +#if !MESHTASTIC_EXCLUDE_BEACON + // userPrefs and a config already in flash never pass through set_module_config + MeshBeaconModule::sanitizeConfig(moduleConfig.mesh_beacon); +#endif // Always-on traffic management: a device that has NEVER configured TMM // (has_traffic_management false - AdminModule always sets the has_ flag on diff --git a/src/mesh/RadioInterface.cpp b/src/mesh/RadioInterface.cpp index 31af780ab3..eaeaf576e3 100644 --- a/src/mesh/RadioInterface.cpp +++ b/src/mesh/RadioInterface.cpp @@ -1289,6 +1289,41 @@ void RadioInterface::clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig) checkOrClampConfigLora(loraConfig, true); } +uint32_t RadioInterface::frequencySlotCount(const meshtastic_Config_LoRaConfig &loraConfig) +{ + const RegionInfo *region = getRegion(loraConfig.region); + // UNSET is no band: its table span is a placeholder, and unknown codes resolve to it too + if (region->code == meshtastic_Config_LoRaConfig_RegionCode_UNSET) + return 0; + float bwKHz = loraConfig.use_preset ? modemPresetToBwKHz(loraConfig.modem_preset, region->wideLora) + : clampBandwidthKHz(bwCodeToKHz(loraConfig.bandwidth)); + const float edges = region->profile->spacing + (region->profile->padding * 2); // in MHz + // A custom bandwidth wider than the region runs at the region default, as checkOrClampConfigLora() clamps it + if (!loraConfig.use_preset && (region->freqEnd - region->freqStart) < edges + (bwKHz / 1000)) + bwKHz = bwCodeToKHz(bwKHzToCode(modemPresetToBwKHz(region->getDefaultPreset(), region->wideLora))); + // Same arithmetic as applyModemConfig(), so a slot checked here is a slot the radio can tune + return round((region->freqEnd - region->freqStart + region->profile->spacing) / (edges + (bwKHz / 1000))); +} + +uint32_t RadioInterface::resolveFrequencySlot(const meshtastic_Config_LoRaConfig &loraConfig, const char *channelName) +{ + const RegionInfo *region = getRegion(loraConfig.region); + const uint32_t numFreqSlots = frequencySlotCount(loraConfig); + if (loraConfig.channel_num > 0 && loraConfig.channel_num <= numFreqSlots) + return loraConfig.channel_num; + if (region->overrideSlot > 0 && (uint32_t)region->overrideSlot <= numFreqSlots) + return region->overrideSlot; + if (!numFreqSlots) // UNSET/degenerate region, and % 0 is a SIGFPE + return 1; + + // A blank name is the default channel, which Channels::getName() spells as the preset name + const char *presetName = DisplayFormatters::getModemPresetDisplayName(loraConfig.modem_preset, false, loraConfig.use_preset); + if (!channelName || !*channelName) + channelName = presetName; + const char *hashOf = (region->overrideSlot == OVERRIDE_SLOT_PRESET_HASH) ? presetName : channelName; + return (hash(hashOf) % numFreqSlots) + 1; // hash slots are 0-based, channel_num is 1-based +} + /** * Pull our channel settings etc... from protobufs to the dumb interface settings * Note: this must be given only settings which have been validated or clamped! diff --git a/src/mesh/RadioInterface.h b/src/mesh/RadioInterface.h index 6a5cb0eb58..33aee9f3fc 100644 --- a/src/mesh/RadioInterface.h +++ b/src/mesh/RadioInterface.h @@ -292,6 +292,13 @@ class RadioInterface // Make a candidate radio configuration valid, even if it isn't. static void clampConfigLora(meshtastic_Config_LoRaConfig &loraConfig); + // How many frequency slots the config's region holds at the config's bandwidth. 0 for an UNSET region. + static uint32_t frequencySlotCount(const meshtastic_Config_LoRaConfig &loraConfig); + + // The 1-based slot a config lands on for a channel name, without making that channel primary: an in-range + // channel_num wins, else the region's override slot, else the hash applyModemConfig() would use. + static uint32_t resolveFrequencySlot(const meshtastic_Config_LoRaConfig &loraConfig, const char *channelName); + // If preset is locked to a sibling of currentRegion among the swappable EU regions // (EU_868/EU_866/EU_N_868), return the sibling region owning the preset, else nullptr. static const RegionInfo *regionSwapForPreset(meshtastic_Config_LoRaConfig_RegionCode currentRegion, diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7e10a3ed78..3b5ab8d021 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -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; diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 34758de0ed..eda236d85f 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -5,9 +5,11 @@ #include "RadioInterface.h" #include "Router.h" #include "TransmitHistory.h" +#include "UptimeClock.h" #include "configuration.h" #include "gps/RTC.h" #include "main.h" +#include "meshUtils.h" #include #include @@ -15,40 +17,77 @@ meshtastic_Config_LoRaConfig_ModemPreset MeshBeaconModule::originalModemPreset; uint16_t MeshBeaconModule::originalLoraChannel; meshtastic_Config_LoRaConfig_RegionCode MeshBeaconModule::originalRegion; -meshtastic_ChannelSettings MeshBeaconModule::originalPrimaryChannel; +float MeshBeaconModule::originalOverrideFrequency; +int8_t MeshBeaconModule::originalTxPower; +uint32_t MeshBeaconModule::originalBandwidth; +bool MeshBeaconModule::originalUsePreset; + +// What the active switch installed, so the restore can tell a LoRa edit made mid-switch from its own. +static struct { + meshtastic_Config_LoRaConfig_ModemPreset preset; + uint32_t slot; + meshtastic_Config_LoRaConfig_RegionCode region; + float overrideFrequency; + int8_t txPower; + uint32_t bandwidth; + bool usePreset; +} switchedTo; static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; +// Evicted ids whose packet may still be queued, else it keys up on the home config with the target's key. +// Sized to the TX queue, not the table: an id is forgotten only after more later evictions than the queue holds. +static PacketId evictedIds[MAX_TX_QUEUE]; +static uint8_t evictedNext; + // Explicit switch state, not inferred: "live config differs from the snapshot" missed name/PSK-only // swaps and fired on legitimate channel edits. static bool radioSwitched = false; static uint32_t switchedForId = 0; -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) +// The interval runOnce() schedules on: configured, else the default, never below the minimum. +static uint32_t beaconIntervalMs() +{ + const uint32_t secs = Default::getConfiguredOrDefault(moduleConfig.mesh_beacon.broadcast_interval_secs, + default_mesh_beacon_min_broadcast_interval_secs); + return Default::getConfiguredOrMinimumValue(secs, default_mesh_beacon_min_broadcast_interval_secs) * 1000; +} + +// Queued a whole broadcast interval: the next beacon is due, and this one describes a mesh that may have moved on. +static bool targetRadioSettingsStale(const MeshBeaconModule_TargetRadioSettings &entry) +{ + return entry.inUse && Throttle::hasElapsed(entry.armedAtMs, beaconIntervalMs()); +} + +static void rememberEvicted(PacketId id) +{ + evictedIds[evictedNext] = id; + evictedNext = (uint8_t)((evictedNext + 1) % (sizeof(evictedIds) / sizeof(evictedIds[0]))); +} + +static bool wasEvicted(PacketId id) +{ + for (const PacketId e : evictedIds) + if (id && e == id) + return true; + return false; +} + +static void forgetEvicted(PacketId id) +{ + for (PacketId &e : evictedIds) + if (id && e == id) + e = 0; +} + +const MeshBeaconModule_TargetRadioSettings *MeshBeaconModule::getTargetRadioSettings(const meshtastic_MeshPacket *p) { 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; + return nullptr; + for (const auto &entry : targetRadioSettings) + if (entry.inUse && entry.id == p->id) + return &entry; + return nullptr; } // Is a target entry still live for this packet id? Unlike sendingPacket or the radio's standby @@ -70,16 +109,14 @@ 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, +bool 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) + meshtastic_Config_LoRaConfig_RegionCode region, int16_t channelHash, bool usePreset) { if (!p) - return; + return false; MeshBeaconModule_TargetRadioSettings *target = nullptr; for (auto &entry : targetRadioSettings) { if (entry.inUse && entry.id == p->id) { @@ -90,20 +127,25 @@ void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, me target = &entry; } if (!target) { - // Table full. Never evict the entry the outstanding switch is gated on: dropping it would - // unblock the restore and put the home config back under a beacon that has not keyed up. + // Table full. A stale entry goes first. Never evict the entry the outstanding switch is gated on: + // dropping it would unblock the restore and put the home config back under a beacon that has not keyed up. for (auto &entry : targetRadioSettings) { - if (!radioSwitched || entry.id != switchedForId) { + if (targetRadioSettingsStale(entry) && (!radioSwitched || entry.id != switchedForId)) { target = &entry; break; } } + for (auto &entry : targetRadioSettings) { + if (!target && (!radioSwitched || entry.id != switchedForId)) + target = &entry; + } if (!target) { LOG_WARN("Beacon: target table full and every slot is in flight, drop target for 0x%08x", p->id); - return; + return false; } LOG_WARN("Beacon: target table full (%u slots), evicting packet 0x%08x for 0x%08x", (unsigned)(sizeof(targetRadioSettings) / sizeof(targetRadioSettings[0])), target->id, p->id); + rememberEvicted(target->id); } target->inUse = true; target->id = p->id; @@ -111,71 +153,232 @@ void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, me target->slot = slot; target->legacyHopOverride = legacyHopOverride; target->region = region; - target->has_channel = has_channel; - if (has_channel && channel) - target->channel = *channel; + target->channelHash = channelHash; + target->usePreset = usePreset; + target->armedAtMs = Time::getMillis(); // the clock Throttle::hasElapsed() reads it against + return true; } bool MeshBeaconModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p) { - return getTargetRadioSettings(p, nullptr, nullptr); + return getTargetRadioSettings(p) != nullptr; } -void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) +static void clearTargetRadioSettingsById(PacketId id) { - if (!p) - return; + forgetEvicted(id); for (auto &entry : targetRadioSettings) { - if (entry.inUse && entry.id == p->id) { + if (entry.inUse && entry.id == id) { entry.inUse = false; return; } } } +void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + if (p) + clearTargetRadioSettingsById(p->id); +} + +const RegionInfo *MeshBeaconModule::beaconRegionFor(meshtastic_Config_LoRaConfig_RegionCode region, + meshtastic_Config_LoRaConfig_ModemPreset preset, bool usePreset) +{ + const RegionInfo *r = getRegion(region); + return (!usePreset || r->supportsPreset(preset)) ? r : RadioInterface::regionSwapForPreset(region, preset); +} + 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)) + const MeshBeaconModule_TargetRadioSettings *s = getTargetRadioSettings(p); + if (!s) { + if (p && wasEvicted(p->id)) { + LOG_WARN("Beacon: packet 0x%08x lost its target entry while queued, drop", p->id); + return true; + } return false; // not a beacon-switch packet - nothing to validate, normal traffic unaffected + } + + if (targetRadioSettingsStale(*s)) { + LOG_WARN("Beacon: packet 0x%08x queued past its broadcast interval, drop", p->id); + return true; + } const meshtastic_Config_LoRaConfig_RegionCode region = - (sidecarRegion != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? sidecarRegion : config.lora.region; + (s->region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? s->region : 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. + // preset/region/slot, 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. + // Preset must run in the target region or its EU sibling. Checked directly: validateConfigLora() would test + // the home channel_num against the target's slot count, and record a critical error on every failure. + const RegionInfo *runs = beaconRegionFor(region, s->preset, s->usePreset); + if (!runs) + return true; + // And the slot must exist at the bandwidth the switch will actually run, which on custom modem params is the node's own. + // 0 is "derive", which always lands in range. meshtastic_Config_LoRaConfig probe = config.lora; - probe.use_preset = true; - probe.modem_preset = preset; - probe.region = region; - return !RadioInterface::validateConfigLora(probe); + probe.use_preset = s->usePreset; + probe.modem_preset = s->preset; + probe.region = runs->code; + return s->slot > RadioInterface::frequencySlotCount(probe); } -meshtastic_ChannelSettings MeshBeaconModule::beaconChannelSettings(const meshtastic_ChannelSettings &base, - meshtastic_Config_LoRaConfig_ModemPreset preset, - const meshtastic_ChannelSettings *overrideChannel) +void MeshBeaconModule::beaconChannelName(const meshtastic_ChannelSettings &ch, meshtastic_Config_LoRaConfig_ModemPreset preset, + char (&out)[sizeof(meshtastic_ChannelSettings::name)], bool usePreset) { - 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; + // A blank name is the default channel, which on the target preset is that preset's display name - + // the name every node there hashes its slot and channel hash from. + const char *name = ch.name[0] ? ch.name : DisplayFormatters::getModemPresetDisplayName(preset, false, usePreset); + strncpy(out, name, sizeof(out) - 1); + out[sizeof(out) - 1] = '\0'; +} + +uint32_t MeshBeaconModule::offerFrequencySlot(const meshtastic_ModuleConfig_MeshBeaconConfig &bcfg, uint32_t *derivedOut) +{ + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + if (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + probe.region = bcfg.broadcast_offer_region; + // An unset preset is the offered region's default: what a receiver derives with, not what this node runs. + probe.modem_preset = + bcfg.has_broadcast_offer_preset ? bcfg.broadcast_offer_preset : getRegion(probe.region)->getDefaultPreset(); + if (const RegionInfo *runs = beaconRegionFor(probe.region, probe.modem_preset)) + probe.region = runs->code; // an EU sibling's preset counts slots in the sibling, as the receiver will run it + + // No offered channel leaves this empty, which resolveFrequencySlot() hashes as the preset name. + char name[sizeof(meshtastic_ChannelSettings::name)] = ""; + if (bcfg.has_broadcast_offer_channel) + beaconChannelName(bcfg.broadcast_offer_channel, probe.modem_preset, name); + + probe.channel_num = 0; + const uint32_t derived = RadioInterface::resolveFrequencySlot(probe, name); + if (derivedOut) + *derivedOut = derived; + if (!bcfg.has_broadcast_offer_frequency_slot || bcfg.broadcast_offer_frequency_slot == 0) + return derived; + probe.channel_num = bcfg.broadcast_offer_frequency_slot; + return RadioInterface::resolveFrequencySlot(probe, name); +} + +bool MeshBeaconModule::offerSlotUsable(const meshtastic_ModuleConfig_MeshBeaconConfig &bcfg) +{ + if (!bcfg.has_broadcast_offer_frequency_slot || bcfg.broadcast_offer_frequency_slot == 0) + return true; + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + if (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + probe.region = bcfg.broadcast_offer_region; + probe.modem_preset = + bcfg.has_broadcast_offer_preset ? bcfg.broadcast_offer_preset : getRegion(probe.region)->getDefaultPreset(); + if (const RegionInfo *runs = beaconRegionFor(probe.region, probe.modem_preset)) + probe.region = runs->code; + return bcfg.broadcast_offer_frequency_slot <= RadioInterface::frequencySlotCount(probe); +} + +void MeshBeaconModule::fillOffer(meshtastic_MeshBeacon &beacon, const meshtastic_ModuleConfig_MeshBeaconConfig &bcfg) +{ + // Withheld whole, not advertised on a substitute slot: a receiver that joins the derived slot would + // land on a different mesh from the one the operator described. + if (!offerSlotUsable(bcfg)) { + LOG_WARN("Beacon: offer slot %u not in the offered region, offer withheld", bcfg.broadcast_offer_frequency_slot); + return; + } + 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; + + // Spend bytes on a slot only where a receiver could not work it out from what the offer already says. + uint32_t derived = 0; + const uint32_t advertised = offerFrequencySlot(bcfg, &derived); + if (advertised != derived) { + beacon.has_offer_frequency_slot = true; + beacon.offer_frequency_slot = advertised; + } +} + +// A slot that can never be valid: 0, which the proto forbids, or past the slot count of an explicit region and preset. +// A pin against the running region or preset is left for send time, which skips it while it does not fit. +static bool beaconSlotValid(uint32_t slot, meshtastic_Config_LoRaConfig_RegionCode region, bool hasPreset, + meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + if (slot == 0) + return false; + if (region == meshtastic_Config_LoRaConfig_RegionCode_UNSET || !hasPreset) + return true; + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.region = region; + probe.modem_preset = preset; + if (const RegionInfo *runs = MeshBeaconModule::beaconRegionFor(region, preset)) + probe.region = runs->code; + return slot <= RadioInterface::frequencySlotCount(probe); +} + +static bool knownRegion(meshtastic_Config_LoRaConfig_RegionCode region) +{ + return getRegion(region)->code == region; +} + +void MeshBeaconModule::sanitizeConfig(meshtastic_ModuleConfig_MeshBeaconConfig &bcfg) +{ + // Capped at the generated field size, so the limit follows the proto's max_size, and never cut mid-character. + sanitizeUtf8(bcfg.broadcast_message, sizeof(bcfg.broadcast_message)); + // 0 means unset/use default. + if (bcfg.broadcast_interval_secs != 0 && bcfg.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs) + bcfg.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs; + + // Region before preset, so a bad region cannot reject a good preset. + if (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && + !knownRegion(bcfg.broadcast_offer_region)) { + LOG_WARN("Beacon: broadcast_offer_region %d invalid, clearing", bcfg.broadcast_offer_region); + bcfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + } + // Presets are checked against an explicit region only: an unset one resolves, and is gated, at send. + if (bcfg.has_broadcast_offer_preset && bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && + !beaconRegionFor(bcfg.broadcast_offer_region, bcfg.broadcast_offer_preset)) { + LOG_WARN("Beacon: broadcast_offer_preset %d invalid for region, clearing", bcfg.broadcast_offer_preset); + bcfg.has_broadcast_offer_preset = false; + } + + for (pb_size_t i = 0; i < bcfg.broadcast_targets_count; i++) { + auto &t = bcfg.broadcast_targets[i]; + if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && !knownRegion(t.region)) { + LOG_WARN("Beacon: broadcast_targets[%u] region %d invalid, clearing", i, t.region); + t.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + } + // channel_index is kept: a bad preset is no reason to move the target onto another channel. + if (t.has_preset && t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && !beaconRegionFor(t.region, t.preset)) { + LOG_WARN("Beacon: broadcast_targets[%u] preset %d invalid for region, clearing", i, t.preset); + t.has_preset = false; + } + // channel_index is kept: sendBeacon() skips an unusable index, where clearing it would leave an + // unset-index target, which beacons on the primary - under another name and key, at every boot. + if (t.has_channel_index && t.channel_index >= MAX_NUM_CHANNELS) + LOG_WARN("Beacon: broadcast_targets[%u] channel_index %u out of range, target will be skipped", i, t.channel_index); + if (t.has_frequency_slot && !beaconSlotValid(t.frequency_slot, t.region, t.has_preset, t.preset)) { + LOG_WARN("Beacon: broadcast_targets[%u] frequency_slot %u invalid, clearing", i, t.frequency_slot); + t.has_frequency_slot = false; + } + } + if (bcfg.has_broadcast_offer_frequency_slot && + !beaconSlotValid(bcfg.broadcast_offer_frequency_slot, bcfg.broadcast_offer_region, bcfg.has_broadcast_offer_preset, + bcfg.broadcast_offer_preset)) { + LOG_WARN("Beacon: broadcast_offer_frequency_slot %u invalid, clearing", bcfg.broadcast_offer_frequency_slot); + bcfg.has_broadcast_offer_frequency_slot = false; } - // 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) @@ -198,38 +401,30 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ ~ApplyingScope() { flag = false; } } applyingScope(applying); - 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)) { + const MeshBeaconModule_TargetRadioSettings *s = getTargetRadioSettings(p); + if (s) { + const meshtastic_Config_LoRaConfig_ModemPreset targetPreset = s->preset; + const uint16_t targetSlot = s->slot; // 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) + if (s->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_Config_LoRaConfig_RegionCode targetRegion = + (s->region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? s->region : config.lora.region; + // The region the TX gate passed: installing a sibling's preset in place would fall back to the region default + if (const RegionInfo *runs = beaconRegionFor(targetRegion, targetPreset, s->usePreset)) + targetRegion = runs->code; - meshtastic_ChannelSettings targetChannel = beaconChannelSettings(*primaryCh, targetPreset, overrideCh); - - if (targetPreset == config.lora.modem_preset && targetSlot == config.lora.channel_num && - targetRegion == config.lora.region && !channelDiffers(targetChannel)) + // Only RF settings switch; the channel travels on the packet. Compare against the slot the radio is + // actually on, since channel_num may still be 0 ("derive"), which never equals a concrete slot. + const uint16_t liveSlot = + (uint16_t)RadioInterface::resolveFrequencySlot(config.lora, channels.getName(channels.getPrimaryIndex())); + if (targetPreset == config.lora.modem_preset && s->usePreset == config.lora.use_preset && + (targetSlot == 0 || targetSlot == liveSlot) && targetRegion == config.lora.region) return false; // Guard: never key up on an invalid target config - bad preset for the region, or an @@ -247,7 +442,10 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ originalModemPreset = config.lora.modem_preset; originalLoraChannel = config.lora.channel_num; originalRegion = config.lora.region; - originalPrimaryChannel = *primaryCh; + originalOverrideFrequency = config.lora.override_frequency; + originalTxPower = config.lora.tx_power; + originalBandwidth = config.lora.bandwidth; + originalUsePreset = config.lora.use_preset; switchDepth = 0; } switchDepth++; @@ -262,19 +460,22 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ if (nodeDB) nodeDB->setLoraSlotTransient(true); config.lora.modem_preset = targetPreset; + config.lora.use_preset = s->usePreset; // a named preset is run even on a node with custom modem params config.lora.channel_num = targetSlot; if (targetRegion != config.lora.region) config.lora.region = targetRegion; - *primaryCh = targetChannel; + // The switch tunes by slot: a home override_frequency would put the beacon on the home frequency. + config.lora.override_frequency = 0; - channels.fixupChannel(channels.getPrimaryIndex()); - p->channel = channels.getHash(channels.getPrimaryIndex()); radioSwitched = true; // set before reconfigure(), so the flag never lags the radio it describes switchedForId = p->id; iface->reconfigure(); + // After reconfigure(): applyModemConfig() clamps tx_power and an over-wide custom bandwidth in place. + switchedTo = {config.lora.modem_preset, config.lora.channel_num, config.lora.region, config.lora.override_frequency, + config.lora.tx_power, config.lora.bandwidth, config.lora.use_preset}; return true; - } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && radioSwitched) { + } else if (radioSwitched) { // s is null here: either no packet, or one carrying no target // Null p is "release if nothing holds it": hold off until the arming beacon has finished. A // non-null untagged p is the driver about to transmit it, so that always restores. @@ -285,13 +486,19 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ LOG_INFO("Beacon: restore radio config after TX, undoing %u switch(es) -> preset=%d slot=%u region=%d", switchDepth, originalModemPreset, originalLoraChannel, originalRegion); - config.lora.modem_preset = originalModemPreset; - config.lora.channel_num = originalLoraChannel; - config.lora.region = originalRegion; - *primaryCh = originalPrimaryChannel; - primaryCh->name[sizeof(primaryCh->name) - 1] = '\0'; + // Only fields still as the switch left them: anything else is an edit made while the beacon held the radio. + const auto restore = [](auto &live, auto installed, auto home) { + if (live == installed) + live = home; + }; + restore(config.lora.modem_preset, switchedTo.preset, originalModemPreset); + restore(config.lora.channel_num, switchedTo.slot, (uint32_t)originalLoraChannel); + restore(config.lora.region, switchedTo.region, originalRegion); + restore(config.lora.override_frequency, switchedTo.overrideFrequency, originalOverrideFrequency); + restore(config.lora.tx_power, switchedTo.txPower, originalTxPower); + restore(config.lora.bandwidth, switchedTo.bandwidth, originalBandwidth); + restore(config.lora.use_preset, switchedTo.usePreset, originalUsePreset); - channels.fixupChannel(channels.getPrimaryIndex()); if (nodeDB) { // config.lora describes the committed config again nodeDB->setLoraSlotTransient(false); nodeDB->refreshCommittedLoraSlot(); @@ -319,6 +526,11 @@ RadioTxHook::PreTxAction MeshBeaconTxHook::beforeTransmit(RadioInterface *iface, LOG_DEBUG("Beacon: invalid TX radio config, drop packet 0x%08x", p->id); return PRETX_DROP; } + // perhapsEncode() hashed a blank-named channel under the running preset's name; the header byte is + // written after this hook, so put the target preset's hash there. + const MeshBeaconModule_TargetRadioSettings *s = MeshBeaconModule::getTargetRadioSettings(p); + if (s && s->channelHash >= 0) + p->channel = (uint8_t)s->channelHash; // A switch leaves the radio on a channel we have not scanned yet, so the driver owes us a // fresh transmit delay before it keys up. return MeshBeaconModule::reconfigureForBeaconTX(iface, p) ? PRETX_DEFER : PRETX_SEND; @@ -355,17 +567,7 @@ 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; + fillOffer(beacon, bcfg); // 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. @@ -374,38 +576,17 @@ void MeshBeaconBroadcastModule::rebuildCache() 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) +void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p) { - // Beacons uplink to MQTT like any other primary-slot packet - Router::send() publishes on slot 0's - // uplink_enabled, and under the swap below the topic is the beacon channel's. Both intentional. - const bool cryptoOverride = - has_channel && overrideChannel && (overrideChannel->name[0] != '\0' || overrideChannel->psk.size > 0); - if (!cryptoOverride) { - if (router->send(p) == ERRNO_SHOULD_RELEASE) { - MeshBeaconModule::clearTargetRadioSettings(p); - packetPool.release(p); - } + // Beacons uplink to MQTT on their channel's uplink_enabled, like any other packet on that channel. + const PacketId id = p->id; // every send() failure path but ERRNO_SHOULD_RELEASE has freed p by the time it returns + const ErrorCode sent = router->send(p); + if (sent == ERRNO_OK) 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()); - - if (router->send(p) == ERRNO_SHOULD_RELEASE) { // encrypts with the beacon channel's key and stamps its hash - MeshBeaconModule::clearTargetRadioSettings(p); + // Not queued, so the interface never owns the entry and no TX hook will ever release it + clearTargetRadioSettingsById(id); + if (sent == ERRNO_SHOULD_RELEASE) packetPool.release(p); - } - - primary.settings = saved; - channels.fixupChannel(channels.getPrimaryIndex()); } void MeshBeaconBroadcastModule::sendBeacon() @@ -413,8 +594,14 @@ 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); + // A pinned slot is offer content only when fillOffer() puts it on the air, i.e. it differs from the derived one. + // An offer withheld for an unusable pin is no content at all: the text still goes out, the invitation does not. + uint32_t derivedOfferSlot = 0; + const bool offerSlotOnAir = + bcfg.has_broadcast_offer_frequency_slot && offerFrequencySlot(bcfg, &derivedOfferSlot) != derivedOfferSlot; + const bool hasRadioContent = (bcfg.has_broadcast_offer_preset || bcfg.has_broadcast_offer_channel || offerSlotOnAir || + (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)) && + offerSlotUsable(bcfg); if (!hasText && !hasRadioContent) { LOG_DEBUG("Beacon: empty msg, no offer, skip"); @@ -452,13 +639,7 @@ void MeshBeaconBroadcastModule::sendBeacon() 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; + fillOffer(offerOnly, bcfg); offerSize = (pb_size_t)pb_encode_to_bytes(offerBuf, sizeof(offerBuf), &meshtastic_MeshBeacon_msg, &offerOnly); if (offerSize == 0) LOG_WARN("Beacon: offer encode failed, skip"); @@ -473,18 +654,24 @@ void MeshBeaconBroadcastModule::sendBeacon() // configured on the node - its key is needed to encrypt. struct EffTarget { meshtastic_Config_LoRaConfig_ModemPreset preset; - uint16_t slot; + bool usePreset; // false keeps the node's custom modem params + uint16_t slot; // resolved, never 0 meshtastic_Config_LoRaConfig_RegionCode region; - bool has_channel; - meshtastic_ChannelSettings channel; + ChannelIndex channelIndex; // table slot to encrypt on; the primary when none is named + // The name the channel goes out under on the target preset, for its slot hash and its wire hash + char channelName[sizeof(meshtastic_ChannelSettings::name)]; }; + // The slot the node is already on, resolved the same way a target's is, so the two compare. + const uint16_t homeSlot = + (uint16_t)RadioInterface::resolveFrequencySlot(config.lora, channels.getName(channels.getPrimaryIndex())); + // An empty list still beacons once, on the node's running preset and region over the primary // channel. Each entry below overrides only what it sets. const int targetCount = bcfg.broadcast_targets_count > 0 ? (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 effective radio config (preset + resolved region + slot + 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]; @@ -492,48 +679,66 @@ void MeshBeaconBroadcastModule::sendBeacon() 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; + return a.preset == b.preset && a.usePreset == b.usePreset && ar == br && a.slot == b.slot && + a.channelIndex == b.channelIndex; }; for (int ti = 0; ti < targetCount; ti++) { // Defaults: running radio config, primary channel. A target entry overrides from here. EffTarget tgt = {}; tgt.preset = config.lora.modem_preset; - tgt.slot = config.lora.channel_num; - if (ti < (int)bcfg.broadcast_targets_count) { - const auto &bt = bcfg.broadcast_targets[ti]; - if (bt.has_preset) - tgt.preset = bt.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. - if (bt.has_channel_index) { - if (bt.channel_index >= (uint32_t)channels.getNumChannels()) { - LOG_WARN("Beacon: target %d channel_index %u out of range, use preset default", 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 blank, use preset default", ti, bt.channel_index); - } + tgt.usePreset = config.lora.use_preset; + tgt.channelIndex = channels.getPrimaryIndex(); + const auto *bt = ti < (int)bcfg.broadcast_targets_count ? &bcfg.broadcast_targets[ti] : nullptr; + if (bt) { + if (bt->has_preset) { + tgt.preset = bt->preset; + tgt.usePreset = true; + } + tgt.region = bt->region; + // An unusable index skips the target: the primary is the preset's default channel only when unnamed, + // so falling back to it would send under another name and key. + if (bt->has_channel_index) { + if (bt->channel_index >= (uint32_t)channels.getNumChannels()) { + LOG_WARN("Beacon: target %d channel_index %u out of range, skip", ti, bt->channel_index); + continue; } + const meshtastic_Channel &ch = channels.getByIndex((ChannelIndex)bt->channel_index); + if (!ch.has_settings || ch.role == meshtastic_Channel_Role_DISABLED || + (ch.settings.name[0] == '\0' && ch.settings.psk.size == 0)) { + LOG_WARN("Beacon: target %d channel_index %u unusable, skip", ti, bt->channel_index); + continue; + } + tgt.channelIndex = (ChannelIndex)bt->channel_index; } } + beaconChannelName(channels.getByIndex(tgt.channelIndex).settings, tgt.preset, tgt.channelName, tgt.usePreset); + + meshtastic_Config_LoRaConfig_RegionCode resolvedRegion = + (tgt.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? tgt.region : config.lora.region; + // A preset owned by an EU sibling runs in that sibling, as it does when the node itself selects it. + if (const RegionInfo *runs = beaconRegionFor(resolvedRegion, tgt.preset, tgt.usePreset)) + resolvedRegion = runs->code; + + // The slot, per the proto: a pin wins; unset derives it the way a node running this channel would, + // from the target region's override slot or the channel name's hash. A target on the home radio keeps + // the home slot, which is what secondary channels share. + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = tgt.usePreset; + probe.modem_preset = tgt.preset; + probe.region = resolvedRegion; + const bool pinned = bt && bt->has_frequency_slot && bt->frequency_slot > 0; + const bool homeRadio = resolvedRegion == config.lora.region && tgt.preset == config.lora.modem_preset && + tgt.usePreset == config.lora.use_preset; + if (pinned && bt->frequency_slot > RadioInterface::frequencySlotCount(probe)) { + // Skipped, not derived: the operator named a frequency, and beaconing on another one is worse than not at all. + LOG_WARN("Beacon: target %d frequency_slot %u not in region %d, skip", ti, bt->frequency_slot, resolvedRegion); + continue; + } + probe.channel_num = pinned ? bt->frequency_slot : homeRadio ? homeSlot : 0; + tgt.slot = (uint16_t)RadioInterface::resolveFrequencySlot(probe, tgt.channelName); // 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])) { @@ -549,18 +754,24 @@ void MeshBeaconBroadcastModule::sendBeacon() 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; + // Only RF settings can need a switch; the channel is named on the packet instead. + const bool radioDiffers = !homeRadio || tgt.slot != homeSlot; + // On another preset a blank-named channel hashes under that preset's name, not the one perhapsEncode() uses + const int16_t wireHash = radioDiffers ? channels.hashFor(tgt.channelIndex, tgt.channelName) : -1; 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); + // perhapsEncode() keys encryption and the channel hash off this index, so the primary slot is never touched + p->channel = tgt.channelIndex; + if (radioDiffers || legacySplit) { + const bool armed = + setTargetRadioSettings(p, tgt.preset, tgt.slot, legacySplit, resolvedRegion, wireHash, tgt.usePreset); + // No entry means no switch and no TX gate: it would key up on the home radio with this target's key + if (!armed && radioDiffers) { + packetPool.release(p); + return; + } + } + sendBeaconPacket(p); }; if (sendOfferOnly && offerSize > 0) { @@ -611,10 +822,7 @@ void MeshBeaconBroadcastModule::sendBeacon() 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; + const uint32_t intervalMs = beaconIntervalMs(); if ((bcfg.flags & MESH_BEACON_FLAG_BROADCAST_ENABLED) && airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { @@ -655,8 +863,8 @@ bool MeshBeaconListenerModule::wantPacket(const meshtastic_MeshPacket *p) 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 bool hasOfferContent = b && (b->has_offer_channel || b->offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || + b->has_offer_preset || b->has_offer_frequency_slot); 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)) @@ -680,6 +888,8 @@ bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacke lastReceivedOffer.channel = b->offer_channel; lastReceivedOffer.region = b->offer_region; lastReceivedOffer.preset = b->offer_preset; + lastReceivedOffer.has_frequency_slot = b->has_offer_frequency_slot; + lastReceivedOffer.frequency_slot = b->has_offer_frequency_slot ? b->offer_frequency_slot : 0; 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); diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index 9ae6d4ddda..113258a75a 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -13,19 +13,27 @@ #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. +// Sidecar entry pairing a packet ID with target radio settings for beacon TX. The channel is not here: +// the packet is addressed at its channel-table index, so perhapsEncode() keys it like any other packet. typedef struct { bool inUse; PacketId id; meshtastic_Config_LoRaConfig_ModemPreset preset; + // 1-based frequency slot, resolved by sendBeacon(): a pin, or derived for the target's region, preset and + // channel name. 0 leaves the derivation to applyModemConfig() on the primary channel's name. 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; + // Wire hash for the packet header, or -1 to keep what perhapsEncode() stamped. Needed when a blank-named + // channel goes out on another preset: its name, and so its hash, is that preset's, not the running one's. + int16_t channelHash; + // When the entry was armed. A beacon still queued a broadcast interval later is dropped, not sent. + uint32_t armedAtMs; + // False keeps the node's custom modem params, for a target that names no preset on a node not using one. + bool usePreset; } MeshBeaconModule_TargetRadioSettings; /** @@ -46,13 +54,17 @@ class MeshBeaconModule /** * Associate target radio settings with an outgoing packet by its ID. - * Sidecar holds 8 entries; evicts slot 0 on overflow. + * Sidecar holds 8 entries; on overflow a stale entry goes first, and an evicted packet is dropped at TX. + * Returns false if no entry could be claimed, in which case the packet must not be sent. */ - static void + static bool 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); + int16_t channelHash = -1, bool usePreset = true); + + /** The sidecar entry for this packet, or nullptr. */ + static const MeshBeaconModule_TargetRadioSettings *getTargetRadioSettings(const meshtastic_MeshPacket *p); /** * Returns true if the sidecar table contains an entry for this packet's ID. @@ -67,28 +79,48 @@ class MeshBeaconModule 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). + * True if p must NOT be transmitted: it is tagged for a beacon radio switch whose preset is invalid for + * the target region, or would key an unlicensed node up on a ham-only (licensed-only) region, or it + * has sat queued past a broadcast interval, or it lost its sidecar entry to eviction. The radio + * driver drops such packets rather than sending them on the current config. False for any other + * packet (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 - * the target's channel-table slot, 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. + * The region that runs preset from region: region itself, its EU sibling, or nullptr. Without usePreset the preset + * is not run, so it is region itself. No side effects. */ - static meshtastic_ChannelSettings beaconChannelSettings(const meshtastic_ChannelSettings &base, - meshtastic_Config_LoRaConfig_ModemPreset preset, - const meshtastic_ChannelSettings *overrideChannel = nullptr); + static const RegionInfo *beaconRegionFor(meshtastic_Config_LoRaConfig_RegionCode region, + meshtastic_Config_LoRaConfig_ModemPreset preset, bool usePreset = true); + + /** Clear what can never be valid in a beacon config. Run on admin writes and on the config loaded at boot. */ + static void sanitizeConfig(meshtastic_ModuleConfig_MeshBeaconConfig &bcfg); + + /** + * The 1-based slot the offer advertises: its pin, else what a receiver derives from the offered region, + * preset and channel name. derivedOut, if given, receives the derived slot alone. + */ + static uint32_t offerFrequencySlot(const meshtastic_ModuleConfig_MeshBeaconConfig &bcfg, uint32_t *derivedOut = nullptr); + + /** False if the offer pins a slot the offered region does not hold, which withholds the whole offer. */ + static bool offerSlotUsable(const meshtastic_ModuleConfig_MeshBeaconConfig &bcfg); + + /** Copy the offer into an outgoing beacon, with offer_frequency_slot only where a receiver could not derive it. */ + static void fillOffer(meshtastic_MeshBeacon &beacon, const meshtastic_ModuleConfig_MeshBeaconConfig &bcfg); + + protected: + // The name a beacon channel transmits under: its own, or for a blank-named channel the target preset's. + static void beaconChannelName(const meshtastic_ChannelSettings &ch, meshtastic_Config_LoRaConfig_ModemPreset preset, + char (&out)[sizeof(meshtastic_ChannelSettings::name)], bool usePreset = true); static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; static uint16_t originalLoraChannel; static meshtastic_Config_LoRaConfig_RegionCode originalRegion; - static meshtastic_ChannelSettings originalPrimaryChannel; + static float originalOverrideFrequency; + static int8_t originalTxPower; + static uint32_t originalBandwidth; + static bool originalUsePreset; }; /** @@ -135,10 +167,9 @@ class MeshBeaconBroadcastModule : private MeshBeaconModule, 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); + // Send one beacon packet. p->channel already names the target's channel-table slot, so + // perhapsEncode() encrypts with that channel's key. + void sendBeaconPacket(meshtastic_MeshPacket *p); bool payloadCacheDirty = true; uint8_t payloadCache[meshtastic_MeshBeacon_size] = {}; @@ -165,6 +196,9 @@ class MeshBeaconListenerModule : public ProtobufModule, p meshtastic_ChannelSettings channel; meshtastic_Config_LoRaConfig_RegionCode region; meshtastic_Config_LoRaConfig_ModemPreset preset; + // Only set when the sender's slot is not derivable from region, preset and channel name. + bool has_frequency_slot; + uint32_t frequency_slot; uint32_t received_at; }; diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 2809452a09..540e594c67 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -17,10 +17,13 @@ #if !MESHTASTIC_EXCLUDE_BEACON +#include "Default.h" +#include "DisplayFormatters.h" #include "MeshRadio.h" #include "MeshService.h" #include "NodeDB.h" #include "RadioInterface.h" +#include "UptimeClock.h" #include "airtime.h" #include "modules/AdminModule.h" #include "modules/MeshBeaconModule.h" @@ -63,16 +66,19 @@ class MockRouter : public Router public: ErrorCode send(meshtastic_MeshPacket *p) override { - // Capture the primary channel as seen AT send() time. sendBeaconPacket() temporarily swaps - // the beacon channel into the primary slot around this call so perhapsEncode keys off it, - // so this snapshot reflects which channel the packet would actually be encrypted on. + // Capture the primary channel as seen AT send() time, so a test can show the beacon never + // swaps another channel into it. p->channel is the table slot perhapsEncode() will key off. if (channelFile.channels_count > 0) primaryAtSend.push_back(channels.getByIndex(channels.getPrimaryIndex()).settings); sentPackets.push_back(*p); - packetPool.release(p); - return ERRNO_OK; + // Router::send() frees p on every path but ERRNO_SHOULD_RELEASE, which leaves it to the caller + if (nextResult != ERRNO_SHOULD_RELEASE) + packetPool.release(p); + return nextResult; } + ErrorCode nextResult = ERRNO_OK; + // Locally-addressed packets land here instead of send(). Release immediately // rather than queuing into fromRadioQueue (which is never drained in tests). void enqueueReceivedMessage(meshtastic_MeshPacket *p) override { packetPool.release(p); } @@ -148,8 +154,8 @@ static void resetConfig() initRegion(); } -// Install a single PRIMARY channel with an explicit name + PSK so the beacon channel-swap path -// (sendBeaconPacket) has a real primary slot to save/restore and to read the active PSK from. +// Install a single PRIMARY channel with an explicit name + PSK, so a test can tell the primary apart +// from the channel a beacon is addressed at. static void installTestPrimaryChannel(const char *name, const uint8_t *psk, size_t pskLen) { channelFile.channels_count = 1; @@ -200,6 +206,7 @@ static void test_adminValidation_turboPresetOnEU868_isCleared(void) meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.flags |= MESH_BEACON_FLAG_BROADCAST_ENABLED; bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; // an unset region resolves at send bcfg.broadcast_targets[0].has_preset = true; bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; @@ -219,6 +226,7 @@ static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; // an unset region resolves at send bcfg.broadcast_targets[0].has_preset = true; bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; @@ -258,6 +266,7 @@ static void test_adminValidation_mediumTurboPresetOnEU868_isCleared(void) meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_targets_count = 1; + bcfg.broadcast_targets[0].region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; // an unset region resolves at send bcfg.broadcast_targets[0].has_preset = true; bcfg.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_TURBO; @@ -341,8 +350,8 @@ static void test_adminValidation_targetUnknownRegion_isCleared(void) } /** - * Verify a preset that is illegal for a broadcast target's region clears that entry's preset - * and its channel. + * Verify a preset that is illegal for a broadcast target's region clears that entry's preset, + * and keeps its channel: a bad preset is no reason to move the target onto the primary. */ static void test_adminValidation_targetInvalidPresetForRegion_isCleared(void) { @@ -360,25 +369,45 @@ static void test_adminValidation_targetInvalidPresetForRegion_isCleared(void) TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.broadcast_targets[0].has_preset, "SHORT_TURBO must be cleared for EU_868 target"); - TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); + TEST_ASSERT_EQUAL_UINT32(1, moduleConfig.mesh_beacon.broadcast_targets[0].channel_index); } /** - * Verify a channel_index beyond the channel-table capacity is cleared on write. - * Important so the broadcaster never indexes out of bounds when resolving a target channel. + * Verify a channel_index beyond the channel-table capacity SURVIVES the write, and that the target it + * names is skipped at send. + * + * Clearing the index instead would leave an unset-index target, which sendBeacon() sends on the primary - + * under a name and key the operator never named. sanitizeConfig() also runs from loadFromDisk(), so a + * cleared index would do that at every boot, not only on the admin write that set it. */ -static void test_adminValidation_targetChannelIndexOutOfRange_isCleared(void) +static void test_adminValidation_targetChannelIndexOutOfRange_isKeptAndSkipped(void) { resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_offer_preset = true; // content, so "nothing sent" is not vacuous + bcfg.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; bcfg.broadcast_targets_count = 1; bcfg.broadcast_targets[0].has_channel_index = true; bcfg.broadcast_targets[0].channel_index = MAX_NUM_CHANNELS; // one past the last valid slot testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index); + TEST_ASSERT_TRUE_MESSAGE(moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index, + "an out-of-range index must be kept, so the target is skipped rather than moved"); + TEST_ASSERT_EQUAL_UINT32(MAX_NUM_CHANNELS, moduleConfig.mesh_beacon.broadcast_targets[0].channel_index); + + // And the stored config produces no beacon at all, rather than one on the primary. + mockRouter->sentPackets.clear(); // only what sendBeacon() emits counts below + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, mockRouter->sentPackets.size(), + "an out-of-range index must skip the target, not use the primary"); } /** @@ -1133,12 +1162,12 @@ static void test_broadcaster_legacySplit_secondPacketIsTextMessage(void) } // =========================================================================== -// Group 7: Beacon-channel PSK swap (target channel-table slot) +// Group 7: Beacon channel addressing (target channel-table slot) // =========================================================================== /** - * With no target channel_index, the beacon must transmit on the primary channel unchanged - * (no swap). Guards against the swap firing - and churning the channel table - when it isn't needed. + * With no target channel_index, the beacon must be addressed at the primary channel, which stays + * unchanged. Guards against the beacon churning the channel table when it isn't needed. */ static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) { @@ -1156,14 +1185,14 @@ static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) bcast.sendBeacon(); TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0, mockRouter->sentPackets[0].channel, "beacon must be addressed at the primary slot"); TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name, "primary must stay unchanged when no channel override is set"); } /** - * A broadcast_target whose channel_index points at a configured table slot must transmit encrypted - * on THAT slot's channel (name + PSK), not the primary. Verifies the slot-index → channel-table - * resolution introduced when BroadcastTarget dropped its embedded ChannelSettings. + * A broadcast_target whose channel_index points at a configured table slot must be addressed at THAT + * slot, so perhapsEncode() encrypts with its key, and the primary must never be overwritten to get there. */ static void test_broadcaster_targetChannelIndex_usesTableSlot(void) { @@ -1186,23 +1215,18 @@ static void test_broadcaster_targetChannelIndex_usesTableSlot(void) bcast.sendBeacon(); TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(1, mockRouter->sentPackets[0].channel, "beacon must be addressed at the referenced slot"); const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0]; - TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconNet", atSend.name, "beacon must be encrypted on the referenced slot's channel"); - TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size); - TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the slot's PSK"); - // Primary slot restored to home after send (no leak into normal traffic). - const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings; - TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send"); - TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size); - TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", atSend.name, "primary must not be overwritten to reach the slot"); + TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), atSend.psk.size); + TEST_ASSERT_EQUAL_UINT8(0xAA, atSend.psk.bytes[0]); } /** - * A broadcast_target whose channel_index points at a BLANK table slot (no name, no PSK) must fall - * back to the default channel for the preset rather than borrowing the primary's name/PSK with a - * clobbered channel_num. Guards the blank-slot handling for multi-target configs. + * A broadcast_target whose channel_index points at a BLANK table slot (no name, no PSK) is skipped: + * falling back would send under the primary's name and key, which is the preset default only when unnamed. */ -static void test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset(void) +static void test_broadcaster_targetChannelIndex_blankSlotSkipsTarget(void) { resetConfig(); static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, @@ -1220,12 +1244,8 @@ static void test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset(void) MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); - // Exactly one beacon goes out, and a blank slot does NOT trigger a crypto swap - the primary - // stays "Home" (no garbage / no clobber), matching the no-channel-override default-for-preset path. - TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); - TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); - TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name, - "blank slot must not swap the beacon onto a borrowed channel"); + // Nothing goes out: the only target names an unusable slot, and is skipped rather than sent on the primary. + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, mockRouter->sentPackets.size(), "blank slot must skip the target, not use the primary"); } /** @@ -1296,6 +1316,9 @@ class ReentrantRadioInterface : public RadioInterface static constexpr int kReentryLimit = 16; int reconfigureCalls = 0; bool reenterOnReconfigure = false; + // Non-zero stand in for applyModemConfig() writing region-clamped values back into config.lora. + int8_t txPowerOnReconfigure = 0; + uint32_t bandwidthOnReconfigure = 0; ErrorCode send(meshtastic_MeshPacket *p) override { @@ -1313,6 +1336,10 @@ class ReentrantRadioInterface : public RadioInterface bool reconfigure() override { reconfigureCalls++; + if (txPowerOnReconfigure) + config.lora.tx_power = txPowerOnReconfigure; + if (bandwidthOnReconfigure) + config.lora.bandwidth = bandwidthOnReconfigure; if (reenterOnReconfigure && reconfigureCalls < kReentryLimit) MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); return true; @@ -1335,7 +1362,7 @@ static void test_beaconRestore_isNotReenteredByCompleteSending(void) meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; pkt.id = 0x5EED0001; MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, - meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + meshtastic_Config_LoRaConfig_RegionCode_UNSET); // Switch to the beacon config. Not the case under test, so leave re-entry off. TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); @@ -1371,14 +1398,14 @@ static void test_beaconSwitch_isNotUndoneByCompleteSending(void) meshtastic_MeshPacket first = meshtastic_MeshPacket_init_zero; first.id = 0x5EED0002; MeshBeaconModule::setTargetRadioSettings(&first, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, - meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + meshtastic_Config_LoRaConfig_RegionCode_UNSET); TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &first), "first switch should have applied"); // Second switch with the restore still outstanding, and reconfigure() re-entering. meshtastic_MeshPacket second = meshtastic_MeshPacket_init_zero; second.id = 0x5EED0003; MeshBeaconModule::setTargetRadioSettings(&second, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST, 0, false, - meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + meshtastic_Config_LoRaConfig_RegionCode_UNSET); radio.reconfigureCalls = 0; radio.reenterOnReconfigure = true; TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &second), "second switch should have applied"); @@ -1432,7 +1459,7 @@ static void test_beaconRestore_deferredUntilPacketCompletes(void) meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; pkt.id = 0x5EED0004; MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, - meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + meshtastic_Config_LoRaConfig_RegionCode_UNSET); TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); // The packet has not been sent yet, so its target settings are still live. @@ -1494,7 +1521,7 @@ static void test_txHook_beaconPacket_isDefer(void) meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; pkt.id = 0x7A000002; MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, - meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + meshtastic_Config_LoRaConfig_RegionCode_UNSET); TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &pkt), "a beacon switch must defer the transmit"); @@ -1526,7 +1553,7 @@ static void test_txHook_invalidTarget_isDrop(void) meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; pkt.id = 0x7A000003; MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, 0, false, - meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + meshtastic_Config_LoRaConfig_RegionCode_UNSET); TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DROP, RadioTxHooks::beforeTransmit(&radio, &pkt), "an invalid target config must be dropped, not transmitted"); @@ -1549,7 +1576,7 @@ static void test_txHook_unregistered_isNoOp(void) meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; pkt.id = 0x7A000004; MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, - meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + meshtastic_Config_LoRaConfig_RegionCode_UNSET); TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_SEND, RadioTxHooks::beforeTransmit(&radio, &pkt), "with no hook registered even a beacon is ordinary traffic to the driver"); @@ -1579,7 +1606,7 @@ static void test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome(void) meshtastic_MeshPacket beacon = meshtastic_MeshPacket_init_zero; beacon.id = 0x7A000005; MeshBeaconModule::setTargetRadioSettings(&beacon, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, - meshtastic_Config_LoRaConfig_RegionCode_UNSET, false, nullptr); + meshtastic_Config_LoRaConfig_RegionCode_UNSET); TEST_ASSERT_EQUAL_INT_MESSAGE(RadioTxHook::PRETX_DEFER, RadioTxHooks::beforeTransmit(&radio, &beacon), "the beacon switch should have applied"); TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, config.lora.modem_preset, @@ -1608,6 +1635,654 @@ static void test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome(void) RadioTxHooks::packetReleased(&radio, &beacon); } +// =========================================================================== +// Group 9: Frequency slots (BroadcastTarget.frequency_slot, the offer's slot) +// =========================================================================== + +static const uint8_t kHomePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + +// The node on US/LONG_FAST, where slots are plentiful (104 at 250 kHz) and LongFast derives to slot 20. +static void resetConfigUS() +{ + resetConfig(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); +} + +static meshtastic_Config_LoRaConfig loraProbe(meshtastic_Config_LoRaConfig_RegionCode region, + meshtastic_Config_LoRaConfig_ModemPreset preset, uint32_t slot) +{ + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.region = region; + probe.modem_preset = preset; + probe.channel_num = slot; + return probe; +} + +// The offer a sent MESH_BEACON_APP packet carries. +static meshtastic_MeshBeacon decodeSentBeacon(size_t i) +{ + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + const meshtastic_MeshPacket &p = mockRouter->sentPackets[i]; + pb_istream_t stream = pb_istream_from_buffer(p.decoded.payload.bytes, p.decoded.payload.size); + TEST_ASSERT_TRUE_MESSAGE(pb_decode(&stream, &meshtastic_MeshBeacon_msg, &b), "sent beacon must decode"); + return b; +} + +/** + * The slot rule the beacon shares with applyModemConfig(): an in-range pin wins, anything else derives, and a + * blank name derives as the preset name. US LongFast landing on slot 20 is the value every US node runs. + */ +static void test_resolveFrequencySlot_pinWinsElseDerives(void) +{ + resetConfigUS(); + const auto us = meshtastic_Config_LoRaConfig_RegionCode_US; + const auto longFast = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + TEST_ASSERT_EQUAL_UINT32(104, RadioInterface::frequencySlotCount(loraProbe(us, longFast, 0))); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(48, RadioInterface::resolveFrequencySlot(loraProbe(us, longFast, 48), "LongFast"), + "an in-range pin must win"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(20, RadioInterface::resolveFrequencySlot(loraProbe(us, longFast, 0), "LongFast"), + "unpinned LongFast derives to slot 20"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(20, RadioInterface::resolveFrequencySlot(loraProbe(us, longFast, 0), ""), + "a blank name must derive as the preset name"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(20, RadioInterface::resolveFrequencySlot(loraProbe(us, longFast, 500), "LongFast"), + "an out-of-range pin must derive, as applyModemConfig() does"); +} + +/** + * RadioInterface::frequencySlotCount() must report no slots for UNSET, which is no band: its table entry spans + * 902-928 MHz only as a placeholder. Counting that span (104 at LongFast) accepted beacon slot pins and derived + * slots for a node with no region. An unknown region code resolves to UNSET and must count the same. + */ +static void test_frequencySlotCount_unsetRegionHoldsNone(void) +{ + resetConfigUS(); + const auto longFast = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + TEST_ASSERT_EQUAL_UINT32( + 0, RadioInterface::frequencySlotCount(loraProbe(meshtastic_Config_LoRaConfig_RegionCode_UNSET, longFast, 0))); + TEST_ASSERT_EQUAL_UINT32( + 0, RadioInterface::frequencySlotCount(loraProbe((meshtastic_Config_LoRaConfig_RegionCode)255, longFast, 0))); + TEST_ASSERT_EQUAL_UINT32_MESSAGE( + 1, RadioInterface::resolveFrequencySlot(loraProbe(meshtastic_Config_LoRaConfig_RegionCode_UNSET, longFast, 48), ""), + "with no slots a pin cannot win, and UNSET resolves to slot 1"); +} + +/** + * frequencySlotCount() must count a custom bandwidth wider than the region at the region default, because + * checkOrClampConfigLora() clamps it to that before the radio tunes. Counting the raw 1625 kHz on EU_868 gave 0 slots, + * so a target pinned to slot 1 there was skipped and an unpinned one was dropped at the TX gate. + */ +static void test_frequencySlotCount_overWideCustomBandwidth_countsRegionDefault(void) +{ + resetConfig(); + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = false; + probe.bandwidth = 1600; // 1625 kHz, wider than EU_868's 250 kHz span + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, RadioInterface::frequencySlotCount(probe), "EU_868 holds one slot at its default"); +} + +/** + * resolveFrequencySlot() must never return a slot past frequencySlotCount(). ITU2_125CM's override slot 37 does not + * exist at a 1625 kHz custom bandwidth (3 slots), and returning it made the TX gate drop every unpinned target there. + * Past the count it falls back to the channel-name hash. + */ +static void test_resolveFrequencySlot_overrideSlotPastCount_derives(void) +{ + resetConfig(); + meshtastic_Config_LoRaConfig probe = config.lora; + probe.region = meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM; + probe.use_preset = false; + probe.bandwidth = 1600; + probe.channel_num = 0; + + const uint32_t count = RadioInterface::frequencySlotCount(probe); + const uint32_t slot = RadioInterface::resolveFrequencySlot(probe, "Home"); + TEST_ASSERT_EQUAL_UINT32(3, count); + TEST_ASSERT_TRUE_MESSAGE(slot >= 1 && slot <= count, "the resolved slot must exist in the region"); +} + +/** + * The switch must give back a custom bandwidth that applyModemConfig() clamped for a narrower target region. It + * writes the clamp into config.lora.bandwidth, and without a restore the node kept the target's bandwidth after one + * beacon, and the next save wrote it to flash. + */ +static void test_beaconSwitch_restoresClampedCustomBandwidth(void) +{ + resetConfigUS(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + config.lora.use_preset = false; + config.lora.bandwidth = 500; + + ReentrantRadioInterface radio; + radio.bandwidthOnReconfigure = 250; // EU_868's default, where 500 kHz does not fit + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7C000004; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_EU_868); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); + + radio.bandwidthOnReconfigure = 0; + MeshBeaconModule::clearTargetRadioSettings(&pkt); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(500, config.lora.bandwidth, "restore must undo the target region's bandwidth clamp"); +} + +/** + * On a node with custom modem params (use_preset=false), a target that names a preset must go out on that preset, as + * the proto's "Modem preset to use for this target" requires. The switch only set modem_preset, which + * applyModemConfig() ignores without use_preset, so such beacons kept the node's custom SF/BW/CR and no node on the + * target preset could decode them. A target that names no preset keeps the custom params, and use_preset is restored. + */ +static void test_broadcaster_customParamsNode_runsNamedTargetPreset(void) +{ + resetConfigUS(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + config.lora.use_preset = false; + config.lora.bandwidth = 125; + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 2; + moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + moduleConfig.mesh_beacon.broadcast_targets[1].region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); + meshtastic_MeshPacket named = mockRouter->sentPackets[0]; + const MeshBeaconModule_TargetRadioSettings *s0 = MeshBeaconModule::getTargetRadioSettings(&named); + const MeshBeaconModule_TargetRadioSettings *s1 = MeshBeaconModule::getTargetRadioSettings(&mockRouter->sentPackets[1]); + TEST_ASSERT_NOT_NULL(s0); + TEST_ASSERT_NOT_NULL(s1); + TEST_ASSERT_TRUE_MESSAGE(s0->usePreset, "a named target preset must be run"); + TEST_ASSERT_FALSE_MESSAGE(s1->usePreset, "a target naming no preset keeps the custom params"); + + ReentrantRadioInterface radio; + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &named), "beacon switch should have applied"); + TEST_ASSERT_TRUE_MESSAGE(config.lora.use_preset, "the switch must run the target preset"); + TEST_ASSERT_EQUAL_INT(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, config.lora.modem_preset); + + MeshBeaconModule::clearTargetRadioSettings(&named); + MeshBeaconModule::clearTargetRadioSettings(&mockRouter->sentPackets[1]); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + TEST_ASSERT_FALSE_MESSAGE(config.lora.use_preset, "restore must put the custom params back"); +} + +/** + * A target that pins a slot must arm exactly that slot. NYMesh's MediumSlow slot 48 is the case the field + * exists for: its hash would put a joiner on another frequency. + */ +static void test_broadcaster_pinnedTargetSlot_armsThatSlot(void) +{ + resetConfigUS(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + auto &t = moduleConfig.mesh_beacon.broadcast_targets[0]; + t.has_preset = true; + t.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; + t.has_frequency_slot = true; + t.frequency_slot = 48; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + const MeshBeaconModule_TargetRadioSettings *s = MeshBeaconModule::getTargetRadioSettings(&mockRouter->sentPackets[0]); + TEST_ASSERT_NOT_NULL_MESSAGE(s, "a target on another preset must arm a radio switch"); + TEST_ASSERT_EQUAL_UINT16_MESSAGE(48, s->slot, "the pinned slot must be armed verbatim"); + TEST_ASSERT_EQUAL_INT(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, s->preset); + MeshBeaconModule::clearTargetRadioSettings(&mockRouter->sentPackets[0]); +} + +/** + * An unpinned target on another preset derives its slot from its own channel name on that preset, the way a + * node running that channel would - never by reusing the home node's pinned channel_num. + */ +static void test_broadcaster_unpinnedTargetOnOtherPreset_derivesSlot(void) +{ + resetConfigUS(); + config.lora.channel_num = 7; // the home node's own pin, which must not leak into the target + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + const uint32_t expected = RadioInterface::resolveFrequencySlot( + loraProbe(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, 0), "Home"); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + const MeshBeaconModule_TargetRadioSettings *s = MeshBeaconModule::getTargetRadioSettings(&mockRouter->sentPackets[0]); + TEST_ASSERT_NOT_NULL(s); + TEST_ASSERT_EQUAL_UINT16_MESSAGE(expected, s->slot, "unpinned slot must derive from the target channel's name"); + MeshBeaconModule::clearTargetRadioSettings(&mockRouter->sentPackets[0]); +} + +/** + * A blank-named channel on another preset goes out as that preset's default channel, so its wire hash must be + * computed under the target preset's name - perhapsEncode() hashed it under the running one. + */ +static void test_broadcaster_blankChannelOnOtherPreset_carriesTargetPresetHash(void) +{ + resetConfigUS(); + static const uint8_t defaultPsk[1] = {0x01}; + installTestPrimaryChannel("", defaultPsk, sizeof(defaultPsk)); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + const char *mediumFast = + DisplayFormatters::getModemPresetDisplayName(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, false, true); + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + const MeshBeaconModule_TargetRadioSettings *s = MeshBeaconModule::getTargetRadioSettings(&mockRouter->sentPackets[0]); + TEST_ASSERT_NOT_NULL(s); + TEST_ASSERT_EQUAL_INT16_MESSAGE(channels.hashFor(0, mediumFast), s->channelHash, + "wire hash must be the target preset's default channel hash"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(channels.getHash(0), s->channelHash, "the running preset's hash would reach nobody"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE( + RadioInterface::resolveFrequencySlot( + loraProbe(meshtastic_Config_LoRaConfig_RegionCode_US, meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, 0), ""), + s->slot, "blank name must derive the target preset's default slot"); + MeshBeaconModule::clearTargetRadioSettings(&mockRouter->sentPackets[0]); +} + +/** + * A pin the target's region does not hold skips the target rather than beaconing somewhere else. + * EU_868 has a single slot at 250 kHz. + */ +static void test_broadcaster_pinnedSlotOutsideRegion_skipsTarget(void) +{ + resetConfig(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_frequency_slot = true; + moduleConfig.mesh_beacon.broadcast_targets[0].frequency_slot = 5; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, mockRouter->sentPackets.size(), "a slot the region lacks must skip the target"); +} + +/** + * An unpinned target on the home radio but another channel stays on the home slot - secondary channels share + * the primary's frequency - so it needs no radio switch, just the right channel index. + */ +static void test_broadcaster_secondaryChannelOnHomeRadio_needsNoSwitch(void) +{ + resetConfigUS(); + static const uint8_t otherPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + installTestSecondaryChannel(1, "Other", otherPsk, sizeof(otherPsk)); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_channel_index = true; + moduleConfig.mesh_beacon.broadcast_targets[0].channel_index = 1; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT8(1, mockRouter->sentPackets[0].channel); + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::hasTargetRadioSettings(&mockRouter->sentPackets[0]), + "a secondary channel on the home radio must not switch the radio"); +} + +/** + * offer_frequency_slot goes on the air only where a receiver could not derive it: a pin equal to the derived slot + * costs nothing, a different one is sent. + */ +static void test_offer_slotSentOnlyWhenNotDerivable(void) +{ + resetConfigUS(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + moduleConfig.mesh_beacon.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + moduleConfig.mesh_beacon.has_broadcast_offer_frequency_slot = true; + moduleConfig.mesh_beacon.broadcast_offer_frequency_slot = 20; // what US LongFast derives to anyway + + { + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + } + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_FALSE_MESSAGE(decodeSentBeacon(0).has_offer_frequency_slot, "a derivable slot must not be sent"); + + moduleConfig.mesh_beacon.broadcast_offer_frequency_slot = 48; + { + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + } + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); + const meshtastic_MeshBeacon b = decodeSentBeacon(1); + TEST_ASSERT_TRUE_MESSAGE(b.has_offer_frequency_slot, "a pinned non-default slot must be sent"); + TEST_ASSERT_EQUAL_UINT32(48, b.offer_frequency_slot); +} + +/** + * An offer pinned to a slot its region lacks is withheld whole - joining the derived slot would put a receiver on + * another mesh - while the text still goes out. + */ +static void test_offer_pinnedSlotOutsideRegion_withholdsOffer(void) +{ + resetConfig(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "hello", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + moduleConfig.mesh_beacon.has_broadcast_offer_frequency_slot = true; + moduleConfig.mesh_beacon.broadcast_offer_frequency_slot = 5; // EU_868 holds one slot + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_MESSAGE(meshtastic_PortNum_TEXT_MESSAGE_APP, mockRouter->sentPackets[0].decoded.portnum, + "with the offer withheld only the text remains"); +} + +/** A received offer's slot is kept for the client, and a slot alone counts as offer content. */ +static void test_listener_storesOfferFrequencySlot(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.flags |= MESH_BEACON_FLAG_LISTEN_ENABLED; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + b.has_offer_frequency_slot = true; + b.offer_frequency_slot = 48; + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "a slot alone is offer content"); + TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.has_frequency_slot); + TEST_ASSERT_EQUAL_UINT32(48, MeshBeaconListenerModule::lastReceivedOffer.frequency_slot); +} + +/** + * Admin clears a slot that can never be valid: 0, or one past an explicit region and preset. A pin against the + * running region is kept, because the region it lands in can change before send time. + */ +static void test_adminValidation_frequencySlots(void) +{ + resetConfig(); + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_targets_count = 3; + bcfg.broadcast_targets[0].has_frequency_slot = true; // 0 is forbidden + bcfg.broadcast_targets[0].frequency_slot = 0; + bcfg.broadcast_targets[1].has_frequency_slot = true; // EU_868 LongFast holds one slot + bcfg.broadcast_targets[1].frequency_slot = 5; + bcfg.broadcast_targets[1].region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + bcfg.broadcast_targets[1].has_preset = true; + bcfg.broadcast_targets[1].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + bcfg.broadcast_targets[2].has_frequency_slot = true; // running region: decided at send + bcfg.broadcast_targets[2].frequency_slot = 5; + bcfg.has_broadcast_offer_frequency_slot = true; + bcfg.broadcast_offer_frequency_slot = 48; + bcfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + bcfg.has_broadcast_offer_preset = true; + bcfg.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + const auto &stored = moduleConfig.mesh_beacon; + TEST_ASSERT_FALSE_MESSAGE(stored.broadcast_targets[0].has_frequency_slot, "slot 0 must be cleared"); + TEST_ASSERT_FALSE_MESSAGE(stored.broadcast_targets[1].has_frequency_slot, "a slot past an explicit region must be cleared"); + TEST_ASSERT_TRUE_MESSAGE(stored.broadcast_targets[2].has_frequency_slot, "a pin on the running region must be kept"); + TEST_ASSERT_TRUE_MESSAGE(stored.has_broadcast_offer_frequency_slot, "a valid offer slot must be kept"); + TEST_ASSERT_EQUAL_UINT32(48, stored.broadcast_offer_frequency_slot); +} + +/** The TX hook writes the target's wire hash into the header channel byte before encodeRadioBuffer() reads it. */ +static void test_txHook_rewritesChannelHashForTarget(void) +{ + resetConfig(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + + MeshBeaconTxHook hook; + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000010; + pkt.channel = 0x11; // what perhapsEncode() stamped + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET, 0x42); + + RadioTxHooks::beforeTransmit(&radio, &pkt); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0x42, pkt.channel, "header must carry the target's channel hash"); + + MeshBeaconModule::clearTargetRadioSettings(&pkt); + RadioTxHooks::packetReleased(&radio, &pkt); +} + +/** A beacon still queued a whole broadcast interval after it was armed is dropped, not sent on a stale config. */ +static void test_txHook_staleBeacon_isDrop(void) +{ + resetConfig(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + Time::setTestMillis(1000); + + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7A000011; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::beaconTxConfigInvalid(&pkt), "a fresh beacon is valid"); + + Time::advanceTestMillis(default_mesh_beacon_min_broadcast_interval_secs * 1000 + 1); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::beaconTxConfigInvalid(&pkt), "a beacon queued past its interval must drop"); + + MeshBeaconModule::clearTargetRadioSettings(&pkt); + Time::useRealClock(); +} + +/** + * A beacon that lost its entry to a full table must be dropped at the radio: without the entry it would key up on + * the home config with its target channel's key. + */ +static void test_txHook_evictedBeacon_isDrop(void) +{ + resetConfig(); + meshtastic_MeshPacket pkts[16]; + for (uint32_t i = 0; i < 16; i++) { + pkts[i] = meshtastic_MeshPacket_init_zero; + pkts[i].id = 0x7B000000 + i; + MeshBeaconModule::setTargetRadioSettings(&pkts[i], meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0); + } + // The table is full long before the last insert, which evicts the previous one from the same slot. + TEST_ASSERT_TRUE(MeshBeaconModule::hasTargetRadioSettings(&pkts[15])); + TEST_ASSERT_FALSE(MeshBeaconModule::hasTargetRadioSettings(&pkts[14])); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::beaconTxConfigInvalid(&pkts[14]), "an evicted beacon must be dropped"); + + for (auto &p : pkts) + MeshBeaconModule::clearTargetRadioSettings(&p); + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::beaconTxConfigInvalid(&pkts[14]), "a released id is forgotten"); +} + +/** A beacon send() did not queue frees its entry, whichever way send() refused it. */ +static void test_broadcaster_unqueuedSend_releasesTarget(void) +{ + const ErrorCode results[] = {ERRNO_SHOULD_RELEASE, ERRNO_NO_INTERFACES}; + for (const ErrorCode result : results) { + resetConfig(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + mockRouter->sentPackets.clear(); + mockRouter->nextResult = result; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::hasTargetRadioSettings(&mockRouter->sentPackets[0]), + "an unqueued beacon must not hold a target entry"); + } + mockRouter->nextResult = ERRNO_OK; +} + +// =========================================================================== +// Group 10: Review fixes - TX gate, EU siblings, switch/restore fields, UTF-8 cap +// =========================================================================== + +/** + * The TX gate must judge a target by its own slot, never the home node's. A US node pinned to LongFast slot 60 + * beaconing to LongTurbo (52 slots) used to have every beacon dropped, because validateConfigLora() checked the + * home channel_num against the target preset's slot count and recorded a critical error each time. + */ +static void test_txHook_homeSlotPastTargetRange_isNotDropped(void) +{ + resetConfigUS(); + config.lora.channel_num = 60; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7C000001; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_US); + + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::beaconTxConfigInvalid(&pkt), "the home slot must not gate a target's TX"); + MeshBeaconModule::clearTargetRadioSettings(&pkt); +} + +/** + * A preset owned by an EU sibling region must arm that sibling, as selecting the preset on the node itself would. + * Armed on EU_868 instead, the gate dropped it, or the switch installed it where applyModemConfig() falls back to + * the region default and sends the NarrowSlow hash and key on home LongFast. + */ +static void test_broadcaster_euSiblingPreset_armsSiblingRegion(void) +{ + resetConfig(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.broadcast_targets_count = 1; + moduleConfig.mesh_beacon.broadcast_targets[0].has_preset = true; + moduleConfig.mesh_beacon.broadcast_targets[0].preset = meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + const MeshBeaconModule_TargetRadioSettings *s = MeshBeaconModule::getTargetRadioSettings(&mockRouter->sentPackets[0]); + TEST_ASSERT_NOT_NULL(s); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_RegionCode_EU_N_868, s->region, + "NarrowSlow must run in its sibling region"); + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconModule::beaconTxConfigInvalid(&mockRouter->sentPackets[0]), + "a sibling's preset must pass the TX gate"); + MeshBeaconModule::clearTargetRadioSettings(&mockRouter->sentPackets[0]); +} + +/** + * The switch must tune by slot and give back everything applyModemConfig() changed. A home override_frequency + * left in place put every targeted beacon on the home frequency, and the target region's clamped tx_power was + * never restored, so the node kept the lower limit and the next save wrote it to flash. + */ +static void test_beaconSwitch_tunesBySlot_restoresOverrideAndTxPower(void) +{ + resetConfigUS(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + config.lora.override_frequency = 915.0f; + config.lora.tx_power = 30; + + ReentrantRadioInterface radio; + radio.txPowerOnReconfigure = 27; // EU_868's limit + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7C000002; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_EU_868); + + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(0.0f, config.lora.override_frequency, "the switch must tune by slot"); + + radio.txPowerOnReconfigure = 0; + MeshBeaconModule::clearTargetRadioSettings(&pkt); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + TEST_ASSERT_EQUAL_FLOAT_MESSAGE(915.0f, config.lora.override_frequency, "restore must put the override back"); + TEST_ASSERT_EQUAL_INT8_MESSAGE(30, config.lora.tx_power, "restore must undo the target region's power clamp"); + TEST_ASSERT_EQUAL_INT(meshtastic_Config_LoRaConfig_RegionCode_US, config.lora.region); +} + +/** + * A LoRa edit (admin, menu or wasm) that lands while a beacon holds the radio must survive the restore. Writing + * the snapshot back unconditionally silently reverted it. + */ +static void test_beaconRestore_keepsEditMadeMidSwitch(void) +{ + resetConfig(); + installTestPrimaryChannel("Home", kHomePsk, sizeof(kHomePsk)); + + ReentrantRadioInterface radio; + meshtastic_MeshPacket pkt = meshtastic_MeshPacket_init_zero; + pkt.id = 0x7C000003; + MeshBeaconModule::setTargetRadioSettings(&pkt, meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW, 0, false, + meshtastic_Config_LoRaConfig_RegionCode_UNSET); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, &pkt), "beacon switch should have applied"); + + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; // the edit + MeshBeaconModule::clearTargetRadioSettings(&pkt); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconModule::reconfigureForBeaconTX(&radio, nullptr), "restore should have applied"); + TEST_ASSERT_EQUAL_INT_MESSAGE(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW, config.lora.modem_preset, + "an edit made mid-switch must not be reverted"); +} + +/** + * The broadcast_message cap must not leave half a multi-byte character at the cut, which receivers render as + * garbage or reject. 'a' plus 30 two-byte characters fills all 61 bytes, so the terminator lands on a trail byte. + */ +static void test_adminValidation_messageCutMidCharacter_isRepaired(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_message[0] = 'a'; + for (size_t i = 1; i + 1 < sizeof(bcfg.broadcast_message); i += 2) { + bcfg.broadcast_message[i] = (char)0xC3; // U+00E9 + bcfg.broadcast_message[i + 1] = (char)0xA9; + } + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + const char *msg = moduleConfig.mesh_beacon.broadcast_message; + const size_t len = strlen(msg); + TEST_ASSERT_TRUE_MESSAGE(len > 0, "the message must survive"); + TEST_ASSERT_NOT_EQUAL_MESSAGE(0xC0, (uint8_t)msg[len - 1] & 0xC0, "the message must not end on a lead byte"); +} + } // namespace // =========================================================================== @@ -1675,7 +2350,7 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_adminValidation_targetUnknownRegion_isCleared); RUN_TEST(test_adminValidation_targetInvalidPresetForRegion_isCleared); RUN_TEST(test_adminValidation_targetValidPresetForRegion_isPreserved); - RUN_TEST(test_adminValidation_targetChannelIndexOutOfRange_isCleared); + RUN_TEST(test_adminValidation_targetChannelIndexOutOfRange_isKeptAndSkipped); RUN_TEST(test_adminValidation_targetChannelIndexInRange_isPreserved); RUN_TEST(test_adminValidation_messageTooLong_isTruncatedToField); RUN_TEST(test_adminValidation_intervalTooLow_isClamped); @@ -1726,7 +2401,7 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_noChannelOverride_doesNotSwapPrimary); RUN_TEST(test_broadcaster_targetChannelIndex_usesTableSlot); - RUN_TEST(test_broadcaster_targetChannelIndex_blankSlotFallsBackToPreset); + RUN_TEST(test_broadcaster_targetChannelIndex_blankSlotSkipsTarget); RUN_TEST(test_broadcaster_duplicateTargets_dedupedToOnePacket); RUN_TEST(test_broadcaster_distinctTargets_bothSent); @@ -1744,6 +2419,36 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_txHook_invalidTarget_isDrop); RUN_TEST(test_txHook_unregistered_isNoOp); RUN_TEST(test_txHook_untaggedPacketAheadOfQueuedBeacon_restoresHome); + RUN_TEST(test_txHook_rewritesChannelHashForTarget); + RUN_TEST(test_txHook_staleBeacon_isDrop); + RUN_TEST(test_txHook_evictedBeacon_isDrop); + + printf("\n=== Frequency slots ===\n"); + + RUN_TEST(test_resolveFrequencySlot_pinWinsElseDerives); + RUN_TEST(test_frequencySlotCount_unsetRegionHoldsNone); + RUN_TEST(test_frequencySlotCount_overWideCustomBandwidth_countsRegionDefault); + RUN_TEST(test_resolveFrequencySlot_overrideSlotPastCount_derives); + RUN_TEST(test_beaconSwitch_restoresClampedCustomBandwidth); + RUN_TEST(test_broadcaster_customParamsNode_runsNamedTargetPreset); + RUN_TEST(test_broadcaster_pinnedTargetSlot_armsThatSlot); + RUN_TEST(test_broadcaster_unpinnedTargetOnOtherPreset_derivesSlot); + RUN_TEST(test_broadcaster_blankChannelOnOtherPreset_carriesTargetPresetHash); + RUN_TEST(test_broadcaster_pinnedSlotOutsideRegion_skipsTarget); + RUN_TEST(test_broadcaster_secondaryChannelOnHomeRadio_needsNoSwitch); + RUN_TEST(test_offer_slotSentOnlyWhenNotDerivable); + RUN_TEST(test_offer_pinnedSlotOutsideRegion_withholdsOffer); + RUN_TEST(test_listener_storesOfferFrequencySlot); + RUN_TEST(test_adminValidation_frequencySlots); + RUN_TEST(test_broadcaster_unqueuedSend_releasesTarget); + + printf("\n=== Review fixes ===\n"); + + RUN_TEST(test_txHook_homeSlotPastTargetRange_isNotDropped); + RUN_TEST(test_broadcaster_euSiblingPreset_armsSiblingRegion); + RUN_TEST(test_beaconSwitch_tunesBySlot_restoresOverrideAndTxPower); + RUN_TEST(test_beaconRestore_keepsEditMadeMidSwitch); + RUN_TEST(test_adminValidation_messageCutMidCharacter_isRepaired); exit(UNITY_END()); } diff --git a/userPrefs.jsonc b/userPrefs.jsonc index c9af7b9630..f5590987c7 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -95,6 +95,7 @@ // "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_OFFER_FREQUENCY_SLOT": "48", // 1-based slot advertised; only set when the mesh pins a non-default slot // "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 // Broadcast targets: every beacon destination is a TARGET__* entry. With none set, the node // beacons once on its running preset and region over the primary channel. Up to 4 targets (0-3); @@ -105,6 +106,7 @@ // "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_0_FREQUENCY_SLOT": "48", // 1-based slot to transmit on; unset derives it from the channel // "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",