From e9864f8cc84479026b6bb3d02709c07cdf76fe84 Mon Sep 17 00:00:00 2001 From: Steve Gilberd Date: Tue, 1 Jul 2025 11:29:59 +1200 Subject: [PATCH 01/24] Tips robot virtual node / relayer to different LoRa modes & channels Note that this commit has details hardcoded for the Wellington (NZ) mesh, and also requires the following patch to the protobufs: ----- diff --git a/meshtastic/mesh.proto b/meshtastic/mesh.proto index 03162d8..ec54c99 100644 --- a/meshtastic/mesh.proto +++ b/meshtastic/mesh.proto @@ -1393,6 +1393,21 @@ message MeshPacket { * Set by the firmware internally, clients are not supposed to set this. */ uint32 tx_after = 20; + + /* + * The modem preset to use fo rthis packet + */ + uint32 modem_preset = 21; + + /* + * The frequency slot to use for this packet + */ + uint32 frequency_slot = 22; + + /* + * Whether the packet has a nonstandard radio config + */ + bool nonstandard_radio_config = 23; } /* ----- --- src/mesh/Channels.h | 10 +- src/mesh/RadioLibInterface.cpp | 15 +- src/modules/MeshTipsModule.cpp | 242 +++++++++++++++++++++++++++++++++ src/modules/MeshTipsModule.h | 80 +++++++++++ src/modules/Modules.cpp | 7 + 5 files changed, 348 insertions(+), 6 deletions(-) create mode 100644 src/modules/MeshTipsModule.cpp create mode 100644 src/modules/MeshTipsModule.h diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index a3cc7791c7c..955b695627f 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -96,6 +96,11 @@ class Channels bool setDefaultPresetCryptoForHash(ChannelHash channelHash); + /** + * Validate a channel, fixing any errors as needed + */ + meshtastic_Channel &fixupChannel(ChannelIndex chIndex); + int16_t getHash(ChannelIndex i) { return hashes[i]; } private: @@ -115,11 +120,6 @@ class Channels */ int16_t generateHash(ChannelIndex channelNum); - /** - * Validate a channel, fixing any errors as needed - */ - meshtastic_Channel &fixupChannel(ChannelIndex chIndex); - /** * Writes the default lora config */ diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index daf98696eff..6136be51c7b 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -8,6 +8,9 @@ #include "error.h" #include "main.h" #include "mesh-pb-constants.h" +#if !MESHTASTIC_EXCLUDE_TIPS +#include "modules/MeshTipsModule.h" +#endif #include #include @@ -366,6 +369,9 @@ void RadioLibInterface::onNotify(uint32_t notification) switch (notification) { case ISR_TX: handleTransmitInterrupt(); +#if !MESHTASTIC_EXCLUDE_TIPS + MeshTipsModule::configureRadioForPacket(this, txQueue.getFront()); +#endif startReceive(); setTransmitDelay(); break; @@ -388,9 +394,16 @@ void RadioLibInterface::onNotify(uint32_t notification) if (delay_remaining > 0) { // There's still some delay pending on this packet, so resume waiting for it to elapse notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, false); +#if !MESHTASTIC_EXCLUDE_TIPS + } else if (MeshTipsModule::configureRadioForPacket(this, txp)) { + // We just switched radio config, so wait to ensure the new channel is available + setTransmitDelay(); +#endif } else { if (isChannelActive()) { // check if there is currently a LoRa packet on the channel - startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again + if (!txp->nonstandard_radio_config) { + startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again + } setTransmitDelay(); } else { // Send any outgoing packets we have ready as fast as possible to keep the time between channel scan and diff --git a/src/modules/MeshTipsModule.cpp b/src/modules/MeshTipsModule.cpp new file mode 100644 index 00000000000..dfb5dbb6b87 --- /dev/null +++ b/src/modules/MeshTipsModule.cpp @@ -0,0 +1,242 @@ +#include "MeshTipsModule.h" +#include "Default.h" +#include "MeshService.h" +#include "RTC.h" +#include "RadioInterface.h" +#include "configuration.h" +#include "main.h" +#include + +#define NODENUM_TIPS 0x00000004 + +static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; // original modem preset +static uint16_t originalLoraChannel; // original frequency slot +char originalChannelName[sizeof(MeshTipsModule_TXSettings)]; // original channel name + +MeshTipsModule::MeshTipsModule() +{ + originalModemPreset = config.lora.modem_preset; + originalLoraChannel = config.lora.channel_num; + strncpy(originalChannelName, channels.getPrimary().name, sizeof(originalChannelName)); +} + +bool MeshTipsModule::configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p) +{ + meshtastic_ChannelSettings *c = (meshtastic_ChannelSettings *)&channels.getPrimary(); + if (p && p->from == NODENUM_TIPS && p->nonstandard_radio_config && + (p->modem_preset != config.lora.modem_preset || p->frequency_slot != config.lora.channel_num)) { + LOG_INFO("Reconfiguring for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, p->decoded.payload.size); + + config.lora.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)p->modem_preset; + config.lora.channel_num = p->frequency_slot; + memset(c->name, 0, sizeof(c->name)); + + switch (p->modem_preset) { + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + strncpy(c->name, "ShortTurbo", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + strncpy(c->name, "ShortFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + strncpy(c->name, "ShortSlow", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + strncpy(c->name, "MediumFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + strncpy(c->name, "MediumSlow", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + strncpy(c->name, "LongFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + strncpy(c->name, "LongMod", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + strncpy(c->name, "LongSlow", sizeof(c->name)); + break; + } + + channels.fixupChannel(channels.getPrimaryIndex()); + p->channel = channels.getHash(channels.getPrimaryIndex()); + iface->reconfigure(); + + return true; + } else if ((!p || !p->nonstandard_radio_config) && + (config.lora.modem_preset != originalModemPreset || config.lora.channel_num != originalLoraChannel)) { + LOG_INFO("Reconfiguring for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, p->decoded.payload.size); + + config.lora.modem_preset = originalModemPreset; + config.lora.channel_num = originalLoraChannel; + memset(c->name, 0, sizeof(c->name)); + strncpy(c->name, originalChannelName, sizeof(c->name)); + + channels.fixupChannel(channels.getPrimaryIndex()); + iface->reconfigure(); + return true; + } + return false; +} + +MeshTipsModule_TXSettings MeshTipsModule::stripTargetRadioSettings(meshtastic_MeshPacket *p) +{ + MeshTipsModule_TXSettings s = { + .preset = originalModemPreset, + .slot = originalLoraChannel, + }; + + // clamp final byte of payload, just in case, because I don't know if this is taken care of elsewhere + p->decoded.payload.bytes[p->decoded.payload.size] = 0; + + if (!p || strlen((char *)p->decoded.payload.bytes) < 4 || p->decoded.payload.bytes[0] != '#') + return s; + + char *msg = strchr((char *)p->decoded.payload.bytes, ' '); + if (!*msg) + return s; + *msg++ = 0; + + const char presetString[3] = {p->decoded.payload.bytes[1], p->decoded.payload.bytes[2], 0}; + char *slotString = (char *)&p->decoded.payload.bytes[3]; + if (!*slotString) + return s; + + for (char *c = slotString; *c; c++) { + if (!strchr("1234567890", *c)) + return s; + } + + uint8_t preset = 0; + if (!strncmp(presetString, "ST", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + else if (!strncmp(presetString, "SF", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + else if (!strncmp(presetString, "SS", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW; + else if (!strncmp(presetString, "MF", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + else if (!strncmp(presetString, "MS", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; + else if (!strncmp(presetString, "LF", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + else if (!strncmp(presetString, "LM", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE; + else if (!strncmp(presetString, "LS", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + else + return s; + + s.slot = std::stoi(slotString); + + p->decoded.payload.size = 1; + // don't use strcpy, because strcpy has undefined behaviour if src & dst overlap + for (char *a = msg, *b = (char *)p->decoded.payload.bytes; (*b++ = *a++);) + p->decoded.payload.size++; + + return s; +} + +MeshTipsNodeInfoModule *meshTipsNodeInfoModule; + +bool MeshTipsNodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *pptr) +{ + // do nothing if we receive nodeinfo, because we only care about sending our own + return true; +} + +void MeshTipsNodeInfoModule::sendTipsNodeInfo() +{ + LOG_INFO("Send NodeInfo for mesh tips"); + static meshtastic_User u = { + .hw_model = meshtastic_HardwareModel_PRIVATE_HW, + .is_licensed = false, + .role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE, + .public_key = + { + .size = 32, + .bytes = {0x39, 0x37, 0x58, 0xe4, 0x05, 0x34, 0x7d, 0xe0, 0x49, 0x73, 0xec, 0xaf, 0xbc, 0x8e, 0x07, 0xe8, + 0x66, 0x57, 0xe4, 0xa1, 0x2d, 0x53, 0x0e, 0x26, 0x51, 0x1f, 0x1a, 0x6c, 0xbf, 0xe8, 0x5e, 0x04}, + }, + .has_is_unmessagable = true, + .is_unmessagable = true, + }; + strncpy(u.id, "!mesh_tips", sizeof(u.id)); + strncpy(u.long_name, "WLG Mesh Tips Robot", sizeof(u.long_name)); + strncpy(u.short_name, "TIPS", sizeof(u.short_name)); + meshtastic_MeshPacket *p = allocDataProtobuf(u); + p->to = NODENUM_BROADCAST; + p->from = NODENUM_TIPS; + p->hop_limit = 0; + p->decoded.want_response = false; + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + p->modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + meshtastic_MeshPacket *p_LF20 = packetPool.allocCopy(*p); + service->sendToMesh(p, RX_SRC_LOCAL, false); + + p_LF20->frequency_slot = 20; + p_LF20->nonstandard_radio_config = true; + service->sendToMesh(p_LF20, RX_SRC_LOCAL, false); +} + +MeshTipsNodeInfoModule::MeshTipsNodeInfoModule() + : ProtobufModule("nodeinfo_tips", meshtastic_PortNum_NODEINFO_APP, &meshtastic_User_msg), + concurrency::OSThread("MeshTipsNodeInfo") +{ + MeshTipsModule(); + + setIntervalFromNow(setStartDelay()); // Send our initial owner announcement 30 seconds + // after we start (to give network time to setup) +} + +int32_t MeshTipsNodeInfoModule::runOnce() +{ + if (airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { + sendTipsNodeInfo(); + } + return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); +} + +MeshTipsMessageModule *meshTipsMessageModule; + +ProcessMessage MeshTipsMessageModule::handleReceived(const meshtastic_MeshPacket &mp) +{ +#ifdef DEBUG_PORT + auto &d = mp.decoded; +#endif + + meshtastic_MeshPacket *p = packetPool.allocCopy(mp); + MeshTipsModule_TXSettings s = stripTargetRadioSettings(p); + if (!p->decoded.payload.size || p->decoded.payload.bytes[0] == '#') + return ProcessMessage::STOP; + p->to = NODENUM_BROADCAST; + p->decoded.source = p->from; + p->from = NODENUM_TIPS; + p->channel = channels.getPrimaryIndex(); + p->hop_limit = 0; + p->hop_start = 0; + p->rx_rssi = 0; + p->rx_snr = 0; + p->priority = meshtastic_MeshPacket_Priority_HIGH; + p->want_ack = false; + p->modem_preset = s.preset; + p->frequency_slot = s.slot; + if (s.preset != originalModemPreset || s.slot != originalLoraChannel) { + p->nonstandard_radio_config = true; + } + p->rx_time = getValidTime(RTCQualityFromNet); + service->sendToMesh(p, RX_SRC_LOCAL, false); + + powerFSM.trigger(EVENT_RECEIVED_MSG); + notifyObservers(&mp); + + return ProcessMessage::CONTINUE; // No other module should be caring about this message +} + +bool MeshTipsMessageModule::wantPacket(const meshtastic_MeshPacket *p) +{ + meshtastic_Channel *c = &channels.getByIndex(p->channel); + return c->role == meshtastic_Channel_Role_SECONDARY && strlen(c->settings.name) == strlen("Tips") && + !strcmp(c->settings.name, "Tips") && p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; +} \ No newline at end of file diff --git a/src/modules/MeshTipsModule.h b/src/modules/MeshTipsModule.h new file mode 100644 index 00000000000..7b3173dee22 --- /dev/null +++ b/src/modules/MeshTipsModule.h @@ -0,0 +1,80 @@ +#pragma once +#include "Observer.h" +#include "ProtobufModule.h" +#include "RadioInterface.h" +#include "SinglePortModule.h" + +typedef struct { + meshtastic_Config_LoRaConfig_ModemPreset preset; + uint16_t slot; +} MeshTipsModule_TXSettings; + +/** + * Base class for the tips robot + */ +class MeshTipsModule +{ + public: + /** + * Constructor + */ + MeshTipsModule(); + + /** + * Configure the radio to send the target packet, or return to default config if p is NULL + */ + static bool configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p); + + /** + * Get target modem settings + */ + MeshTipsModule_TXSettings stripTargetRadioSettings(meshtastic_MeshPacket *p); +}; + +/** + * Tips module for sending tips into the mesh + */ +class MeshTipsNodeInfoModule : private MeshTipsModule, public ProtobufModule, private concurrency::OSThread +{ + public: + /** + * Constructor + */ + MeshTipsNodeInfoModule(); + + protected: + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *p) override; + + /** + * Send NodeInfo to the mesh + */ + void sendTipsNodeInfo(); + + /** Does our periodic broadcast */ + virtual int32_t runOnce() override; +}; +extern MeshTipsNodeInfoModule *meshTipsNodeInfoModule; + +/** + * Text message handling for the tips robot + */ +class MeshTipsMessageModule : private MeshTipsModule, public SinglePortModule, public Observable +{ + public: + /** + * Constructor + */ + MeshTipsMessageModule() : MeshTipsModule(), SinglePortModule("tips", meshtastic_PortNum_TEXT_MESSAGE_APP) {} + + protected: + /** + * Called to handle a particular incoming message + */ + virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + /** + * Indicate whether this module wants to process the packet + */ + virtual bool wantPacket(const meshtastic_MeshPacket *p) override; +}; +extern MeshTipsMessageModule *meshTipsMessageModule; \ No newline at end of file diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 5cc94a68fca..042ac677f6f 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -28,6 +28,9 @@ #if !MESHTASTIC_EXCLUDE_NODEINFO #include "modules/NodeInfoModule.h" #endif +#if !MESHTASTIC_EXCLUDE_TIPS +#include "modules/MeshTipsModule.h" +#endif #if !MESHTASTIC_EXCLUDE_GPS #include "modules/PositionModule.h" #endif @@ -144,6 +147,10 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_NODEINFO nodeInfoModule = new NodeInfoModule(); #endif +#if !MESHTASTIC_EXCLUDE_TIPS + meshTipsNodeInfoModule = new MeshTipsNodeInfoModule(); + meshTipsMessageModule = new MeshTipsMessageModule(); +#endif #if !MESHTASTIC_EXCLUDE_GPS positionModule = new PositionModule(); #endif From 3ba64b3d65db78fd291419e83007de3268835105 Mon Sep 17 00:00:00 2001 From: Darafei Praliaskouski Date: Sun, 17 May 2026 19:46:23 +0400 Subject: [PATCH 02/24] fix: repair mesh tips CI build --- src/mesh/RadioLibInterface.cpp | 8 ++- src/modules/MeshTipsModule.cpp | 118 ++++++++++++++++++++++++++------- src/modules/MeshTipsModule.h | 17 ++++- 3 files changed, 117 insertions(+), 26 deletions(-) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 6136be51c7b..b1efef352af 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -401,7 +401,10 @@ void RadioLibInterface::onNotify(uint32_t notification) #endif } else { if (isChannelActive()) { // check if there is currently a LoRa packet on the channel - if (!txp->nonstandard_radio_config) { +#if !MESHTASTIC_EXCLUDE_TIPS + if (!MeshTipsModule::hasTargetRadioSettings(txp)) +#endif + { startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again } setTransmitDelay(); @@ -535,6 +538,9 @@ void RadioLibInterface::completeSending() if (!isFromUs(p)) txRelay++; printPacket("Completed sending", p); +#if !MESHTASTIC_EXCLUDE_TIPS + MeshTipsModule::clearTargetRadioSettings(p); +#endif // We are done sending that packet, release it packetPool.release(p); diff --git a/src/modules/MeshTipsModule.cpp b/src/modules/MeshTipsModule.cpp index dfb5dbb6b87..45677a36651 100644 --- a/src/modules/MeshTipsModule.cpp +++ b/src/modules/MeshTipsModule.cpp @@ -6,12 +6,36 @@ #include "configuration.h" #include "main.h" #include +#include #define NODENUM_TIPS 0x00000004 -static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; // original modem preset -static uint16_t originalLoraChannel; // original frequency slot -char originalChannelName[sizeof(MeshTipsModule_TXSettings)]; // original channel name +static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; // original modem preset +static uint16_t originalLoraChannel; // original frequency slot +char originalChannelName[sizeof(((meshtastic_ChannelSettings *)nullptr)->name)]; // original channel name + +typedef struct { + bool inUse; + PacketId id; + MeshTipsModule_TXSettings settings; +} MeshTipsModule_TargetRadioSettings; + +static MeshTipsModule_TargetRadioSettings targetRadioSettings[8]; + +static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, MeshTipsModule_TXSettings *settings) +{ + if (!p) + return false; + + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + if (settings) + *settings = entry.settings; + return true; + } + } + return false; +} MeshTipsModule::MeshTipsModule() { @@ -20,18 +44,60 @@ MeshTipsModule::MeshTipsModule() strncpy(originalChannelName, channels.getPrimary().name, sizeof(originalChannelName)); } +void MeshTipsModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, MeshTipsModule_TXSettings settings) +{ + if (!p) + return; + + MeshTipsModule_TargetRadioSettings *slot = nullptr; + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + slot = &entry; + break; + } + if (!slot && !entry.inUse) + slot = &entry; + } + + if (!slot) + slot = &targetRadioSettings[0]; + + slot->inUse = true; + slot->id = p->id; + slot->settings = settings; +} + +bool MeshTipsModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + return getTargetRadioSettings(p, nullptr); +} + +void MeshTipsModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + if (!p) + return; + + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + entry.inUse = false; + return; + } + } +} + bool MeshTipsModule::configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p) { meshtastic_ChannelSettings *c = (meshtastic_ChannelSettings *)&channels.getPrimary(); - if (p && p->from == NODENUM_TIPS && p->nonstandard_radio_config && - (p->modem_preset != config.lora.modem_preset || p->frequency_slot != config.lora.channel_num)) { + MeshTipsModule_TXSettings target; + if (p && p->from == NODENUM_TIPS && getTargetRadioSettings(p, &target) && + (target.preset != config.lora.modem_preset || target.slot != config.lora.channel_num)) { LOG_INFO("Reconfiguring for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, p->decoded.payload.size); - config.lora.modem_preset = (meshtastic_Config_LoRaConfig_ModemPreset)p->modem_preset; - config.lora.channel_num = p->frequency_slot; + config.lora.modem_preset = target.preset; + config.lora.channel_num = target.slot; memset(c->name, 0, sizeof(c->name)); - switch (p->modem_preset) { + switch (target.preset) { case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: strncpy(c->name, "ShortTurbo", sizeof(c->name)); break; @@ -63,9 +129,12 @@ bool MeshTipsModule::configureRadioForPacket(RadioInterface *iface, meshtastic_M iface->reconfigure(); return true; - } else if ((!p || !p->nonstandard_radio_config) && + } else if ((!p || !getTargetRadioSettings(p, nullptr)) && (config.lora.modem_preset != originalModemPreset || config.lora.channel_num != originalLoraChannel)) { - LOG_INFO("Reconfiguring for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, p->decoded.payload.size); + if (p) + LOG_INFO("Reconfiguring for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, p->decoded.payload.size); + else + LOG_INFO("Restoring radio config after tips TX"); config.lora.modem_preset = originalModemPreset; config.lora.channel_num = originalLoraChannel; @@ -86,14 +155,17 @@ MeshTipsModule_TXSettings MeshTipsModule::stripTargetRadioSettings(meshtastic_Me .slot = originalLoraChannel, }; - // clamp final byte of payload, just in case, because I don't know if this is taken care of elsewhere - p->decoded.payload.bytes[p->decoded.payload.size] = 0; - - if (!p || strlen((char *)p->decoded.payload.bytes) < 4 || p->decoded.payload.bytes[0] != '#') + if (!p || p->decoded.payload.size < 4 || p->decoded.payload.bytes[0] != '#') return s; + // Clamp final byte of payload while parsing the command prefix. + if (p->decoded.payload.size < sizeof(p->decoded.payload.bytes)) + p->decoded.payload.bytes[p->decoded.payload.size] = 0; + else + p->decoded.payload.bytes[sizeof(p->decoded.payload.bytes) - 1] = 0; + char *msg = strchr((char *)p->decoded.payload.bytes, ' '); - if (!*msg) + if (!msg || !*msg) return s; *msg++ = 0; @@ -107,7 +179,6 @@ MeshTipsModule_TXSettings MeshTipsModule::stripTargetRadioSettings(meshtastic_Me return s; } - uint8_t preset = 0; if (!strncmp(presetString, "ST", 2)) s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; else if (!strncmp(presetString, "SF", 2)) @@ -127,7 +198,7 @@ MeshTipsModule_TXSettings MeshTipsModule::stripTargetRadioSettings(meshtastic_Me else return s; - s.slot = std::stoi(slotString); + s.slot = strtoul(slotString, nullptr, 10); p->decoded.payload.size = 1; // don't use strcpy, because strcpy has undefined behaviour if src & dst overlap @@ -170,13 +241,14 @@ void MeshTipsNodeInfoModule::sendTipsNodeInfo() p->hop_limit = 0; p->decoded.want_response = false; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - p->modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; meshtastic_MeshPacket *p_LF20 = packetPool.allocCopy(*p); service->sendToMesh(p, RX_SRC_LOCAL, false); - p_LF20->frequency_slot = 20; - p_LF20->nonstandard_radio_config = true; + setTargetRadioSettings(p_LF20, { + .preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + .slot = 20, + }); service->sendToMesh(p_LF20, RX_SRC_LOCAL, false); } @@ -220,10 +292,8 @@ ProcessMessage MeshTipsMessageModule::handleReceived(const meshtastic_MeshPacket p->rx_snr = 0; p->priority = meshtastic_MeshPacket_Priority_HIGH; p->want_ack = false; - p->modem_preset = s.preset; - p->frequency_slot = s.slot; if (s.preset != originalModemPreset || s.slot != originalLoraChannel) { - p->nonstandard_radio_config = true; + setTargetRadioSettings(p, s); } p->rx_time = getValidTime(RTCQualityFromNet); service->sendToMesh(p, RX_SRC_LOCAL, false); @@ -239,4 +309,4 @@ bool MeshTipsMessageModule::wantPacket(const meshtastic_MeshPacket *p) meshtastic_Channel *c = &channels.getByIndex(p->channel); return c->role == meshtastic_Channel_Role_SECONDARY && strlen(c->settings.name) == strlen("Tips") && !strcmp(c->settings.name, "Tips") && p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; -} \ No newline at end of file +} diff --git a/src/modules/MeshTipsModule.h b/src/modules/MeshTipsModule.h index 7b3173dee22..9779015e865 100644 --- a/src/modules/MeshTipsModule.h +++ b/src/modules/MeshTipsModule.h @@ -25,6 +25,21 @@ class MeshTipsModule */ static bool configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p); + /** + * Remember per-packet target radio settings for locally generated tips packets. + */ + static void setTargetRadioSettings(const meshtastic_MeshPacket *p, MeshTipsModule_TXSettings settings); + + /** + * Check whether a packet has locally tracked target radio settings. + */ + static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p); + + /** + * Forget locally tracked target radio settings for a packet. + */ + static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); + /** * Get target modem settings */ @@ -77,4 +92,4 @@ class MeshTipsMessageModule : private MeshTipsModule, public SinglePortModule, p */ virtual bool wantPacket(const meshtastic_MeshPacket *p) override; }; -extern MeshTipsMessageModule *meshTipsMessageModule; \ No newline at end of file +extern MeshTipsMessageModule *meshTipsMessageModule; From e380d96375ba2d3202a26c2aee1ada1afab95082 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 2 Jun 2026 21:54:11 +0100 Subject: [PATCH 03/24] =?UTF-8?q?feat:=20add=20MeshBeacon=20module=20(Phas?= =?UTF-8?q?e=201=20=E2=80=94=20proto=20+=20generated=20code=20+=20initial?= =?UTF-8?q?=20stub)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- protobufs | 2 +- .../generated/meshtastic/mesh_beacon.pb.cpp | 12 + .../generated/meshtastic/mesh_beacon.pb.h | 71 ++++ .../generated/meshtastic/module_config.pb.cpp | 5 +- .../generated/meshtastic/module_config.pb.h | 85 ++++- src/mesh/generated/meshtastic/portnums.pb.h | 5 + src/modules/MeshBeaconModule.cpp | 340 ++++++++++++++++++ src/modules/MeshBeaconModule.h | 129 +++++++ src/modules/Modules.cpp | 7 + 9 files changed, 652 insertions(+), 4 deletions(-) create mode 100644 src/mesh/generated/meshtastic/mesh_beacon.pb.cpp create mode 100644 src/mesh/generated/meshtastic/mesh_beacon.pb.h create mode 100644 src/modules/MeshBeaconModule.cpp create mode 100644 src/modules/MeshBeaconModule.h diff --git a/protobufs b/protobufs index 1df6c115424..1dc0fb9f54d 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit 1df6c1154248c82abb21b73eb06c203f595780db +Subproject commit 1dc0fb9f54df81aa4a44a76b01e7307a0ce0c783 diff --git a/src/mesh/generated/meshtastic/mesh_beacon.pb.cpp b/src/mesh/generated/meshtastic/mesh_beacon.pb.cpp new file mode 100644 index 00000000000..6a079b7a7f9 --- /dev/null +++ b/src/mesh/generated/meshtastic/mesh_beacon.pb.cpp @@ -0,0 +1,12 @@ +/* Automatically generated nanopb constant definitions */ +/* Generated by nanopb-0.4.9.1 */ + +#include "meshtastic/mesh_beacon.pb.h" +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +PB_BIND(meshtastic_MeshBeacon, meshtastic_MeshBeacon, AUTO) + + + diff --git a/src/mesh/generated/meshtastic/mesh_beacon.pb.h b/src/mesh/generated/meshtastic/mesh_beacon.pb.h new file mode 100644 index 00000000000..f81d245226e --- /dev/null +++ b/src/mesh/generated/meshtastic/mesh_beacon.pb.h @@ -0,0 +1,71 @@ +/* Automatically generated nanopb header */ +/* Generated by nanopb-0.4.9.1 */ + +#ifndef PB_MESHTASTIC_MESHTASTIC_MESH_BEACON_PB_H_INCLUDED +#define PB_MESHTASTIC_MESHTASTIC_MESH_BEACON_PB_H_INCLUDED +#include +#include "meshtastic/channel.pb.h" +#include "meshtastic/config.pb.h" + +#if PB_PROTO_HEADER_VERSION != 40 +#error Regenerate this file with the current version of nanopb generator. +#endif + +/* Struct definitions */ +/* Payload for MESH_BEACON_APP packets. + Periodically broadcast by nodes in beacon mode. + Listeners deliver the text message to the local inbox and cache any offered + channel/preset for the client app to act on — the firmware never auto-applies them. */ +typedef struct _meshtastic_MeshBeacon { + /* Human-readable beacon message. Max 100 bytes enforced by firmware on send. */ + char message[101]; + /* Optional channel (name + PSK) being advertised to listening clients. + A client app may offer to switch the user to this channel; firmware never applies it automatically. */ + bool has_offer_channel; + meshtastic_ChannelSettings offer_channel; + /* Optional region being advertised alongside offer_preset. */ + meshtastic_Config_LoRaConfig_RegionCode offer_region; + /* Optional modem preset being advertised. + Combined with offer_region, tells a client "there is a mesh on this preset/region". */ + meshtastic_Config_LoRaConfig_ModemPreset offer_preset; +} meshtastic_MeshBeacon; + + +#ifdef __cplusplus +extern "C" { +#endif + +/* Initializer values for message structs */ +#define meshtastic_MeshBeacon_init_default {"", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN} +#define meshtastic_MeshBeacon_init_zero {"", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN} + +/* Field tags (for use in manual encoding/decoding) */ +#define meshtastic_MeshBeacon_message_tag 1 +#define meshtastic_MeshBeacon_offer_channel_tag 2 +#define meshtastic_MeshBeacon_offer_region_tag 3 +#define meshtastic_MeshBeacon_offer_preset_tag 4 + +/* Struct field encoding specification for nanopb */ +#define meshtastic_MeshBeacon_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, STRING, message, 1) \ +X(a, STATIC, OPTIONAL, MESSAGE, offer_channel, 2) \ +X(a, STATIC, SINGULAR, UENUM, offer_region, 3) \ +X(a, STATIC, SINGULAR, UENUM, offer_preset, 4) +#define meshtastic_MeshBeacon_CALLBACK NULL +#define meshtastic_MeshBeacon_DEFAULT NULL +#define meshtastic_MeshBeacon_offer_channel_MSGTYPE meshtastic_ChannelSettings + +extern const pb_msgdesc_t meshtastic_MeshBeacon_msg; + +/* Defines for backwards compatibility with code written before nanopb-0.4.0 */ +#define meshtastic_MeshBeacon_fields &meshtastic_MeshBeacon_msg + +/* Maximum encoded size of messages (where known) */ +#define MESHTASTIC_MESHTASTIC_MESH_BEACON_PB_H_MAX_SIZE meshtastic_MeshBeacon_size +#define meshtastic_MeshBeacon_size 180 + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif diff --git a/src/mesh/generated/meshtastic/module_config.pb.cpp b/src/mesh/generated/meshtastic/module_config.pb.cpp index f2fe5d967f9..545347c0c6f 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.cpp +++ b/src/mesh/generated/meshtastic/module_config.pb.cpp @@ -6,7 +6,7 @@ #error Regenerate this file with the current version of nanopb generator. #endif -PB_BIND(meshtastic_ModuleConfig, meshtastic_ModuleConfig, AUTO) +PB_BIND(meshtastic_ModuleConfig, meshtastic_ModuleConfig, 2) PB_BIND(meshtastic_ModuleConfig_MQTTConfig, meshtastic_ModuleConfig_MQTTConfig, AUTO) @@ -57,6 +57,9 @@ PB_BIND(meshtastic_ModuleConfig_AmbientLightingConfig, meshtastic_ModuleConfig_A PB_BIND(meshtastic_ModuleConfig_StatusMessageConfig, meshtastic_ModuleConfig_StatusMessageConfig, AUTO) +PB_BIND(meshtastic_ModuleConfig_MeshBeaconConfig, meshtastic_ModuleConfig_MeshBeaconConfig, 2) + + PB_BIND(meshtastic_ModuleConfig_TAKConfig, meshtastic_ModuleConfig_TAKConfig, AUTO) diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 25937e9720d..fad461db54e 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -5,6 +5,8 @@ #define PB_MESHTASTIC_MESHTASTIC_MODULE_CONFIG_PB_H_INCLUDED #include #include "meshtastic/atak.pb.h" +#include "meshtastic/channel.pb.h" +#include "meshtastic/config.pb.h" #if PB_PROTO_HEADER_VERSION != 40 #error Regenerate this file with the current version of nanopb generator. @@ -449,6 +451,42 @@ typedef struct _meshtastic_ModuleConfig_StatusMessageConfig { char node_status[80]; } meshtastic_ModuleConfig_StatusMessageConfig; +/* MeshBeacon module config */ +typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { + /* Enable receiving MESH_BEACON_APP packets from other nodes. + The text portion is delivered to the local message inbox. + Offered channel/preset are stored for the client app to act on. */ + bool listen_enabled; + /* Enable periodically broadcasting MESH_BEACON_APP packets from this node. */ + bool broadcast_enabled; + /* Optional: node ID to send beacon messages AS. + When set, the `from` field of outgoing beacon packets is set to this node ID, + making beacons appear to originate from that node. + When unset (0), beacons are sent as the local node. + A remote admin can only set this field to their own node ID. */ + uint32_t broadcast_send_as_node; + /* Message to include in each beacon broadcast. Max 100 bytes enforced by firmware. */ + char broadcast_message[101]; + /* Optional channel (name + PSK) to advertise in the MeshBeacon offer_channel field. */ + bool has_broadcast_offer_channel; + meshtastic_ChannelSettings broadcast_offer_channel; + /* Optional region to advertise in the MeshBeacon offer_region field. */ + meshtastic_Config_LoRaConfig_RegionCode broadcast_offer_region; + /* Optional modem preset to advertise in the MeshBeacon offer_preset field. */ + meshtastic_Config_LoRaConfig_ModemPreset broadcast_offer_preset; + /* Channel settings (name + PSK) to use when sending beacons. + If unset, beacons go out on the primary channel. */ + bool has_broadcast_on_channel; + meshtastic_ChannelSettings broadcast_on_channel; + /* Region to use when sending beacons on broadcast_on_preset. */ + meshtastic_Config_LoRaConfig_RegionCode broadcast_on_region; + /* Modem preset to use when sending beacons. + If different from current config, the radio is temporarily switched for TX. */ + meshtastic_Config_LoRaConfig_ModemPreset broadcast_on_preset; + /* How often to broadcast, in seconds. Min 3600 (1 h), max 259200 (72 h). Default 3600. */ + uint32_t broadcast_interval_secs; +} meshtastic_ModuleConfig_MeshBeaconConfig; + /* TAK team/role configuration */ typedef struct _meshtastic_ModuleConfig_TAKConfig { /* Team color. @@ -516,6 +554,8 @@ typedef struct _meshtastic_ModuleConfig { meshtastic_ModuleConfig_TrafficManagementConfig traffic_management; /* TAK team/role configuration for TAK_TRACKER */ meshtastic_ModuleConfig_TAKConfig tak; + /* MeshBeacon module config */ + meshtastic_ModuleConfig_MeshBeaconConfig mesh_beacon; } payload_variant; } meshtastic_ModuleConfig; @@ -573,6 +613,11 @@ extern "C" { +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_region_ENUMTYPE meshtastic_Config_LoRaConfig_RegionCode +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_preset_ENUMTYPE meshtastic_Config_LoRaConfig_ModemPreset + #define meshtastic_ModuleConfig_TAKConfig_team_ENUMTYPE meshtastic_Team #define meshtastic_ModuleConfig_TAKConfig_role_ENUMTYPE meshtastic_MemberRole @@ -597,6 +642,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_default {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_default {""} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, 0, 0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_ModuleConfig_TAKConfig_init_default {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_default {0, "", _meshtastic_RemoteHardwarePinType_MIN} #define meshtastic_ModuleConfig_init_zero {0, {meshtastic_ModuleConfig_MQTTConfig_init_zero}} @@ -616,6 +662,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_zero {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_zero {""} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, 0, 0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_ModuleConfig_TAKConfig_init_zero {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_zero {0, "", _meshtastic_RemoteHardwarePinType_MIN} @@ -735,6 +782,17 @@ extern "C" { #define meshtastic_ModuleConfig_AmbientLightingConfig_green_tag 4 #define meshtastic_ModuleConfig_AmbientLightingConfig_blue_tag 5 #define meshtastic_ModuleConfig_StatusMessageConfig_node_status_tag 1 +#define meshtastic_ModuleConfig_MeshBeaconConfig_listen_enabled_tag 1 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_enabled_tag 2 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_send_as_node_tag 3 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_message_tag 4 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_channel_tag 5 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_region_tag 6 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_preset_tag 7 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_channel_tag 8 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_region_tag 9 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_preset_tag 10 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_interval_secs_tag 11 #define meshtastic_ModuleConfig_TAKConfig_team_tag 1 #define meshtastic_ModuleConfig_TAKConfig_role_tag 2 #define meshtastic_RemoteHardwarePin_gpio_pin_tag 1 @@ -759,6 +817,7 @@ extern "C" { #define meshtastic_ModuleConfig_statusmessage_tag 14 #define meshtastic_ModuleConfig_traffic_management_tag 15 #define meshtastic_ModuleConfig_tak_tag 16 +#define meshtastic_ModuleConfig_mesh_beacon_tag 17 /* Struct field encoding specification for nanopb */ #define meshtastic_ModuleConfig_FIELDLIST(X, a) \ @@ -777,7 +836,8 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,detection_sensor,payload_var X(a, STATIC, ONEOF, MESSAGE, (payload_variant,paxcounter,payload_variant.paxcounter), 13) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,statusmessage,payload_variant.statusmessage), 14) \ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,traffic_management,payload_variant.traffic_management), 15) \ -X(a, STATIC, ONEOF, MESSAGE, (payload_variant,tak,payload_variant.tak), 16) +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,tak,payload_variant.tak), 16) \ +X(a, STATIC, ONEOF, MESSAGE, (payload_variant,mesh_beacon,payload_variant.mesh_beacon), 17) #define meshtastic_ModuleConfig_CALLBACK NULL #define meshtastic_ModuleConfig_DEFAULT NULL #define meshtastic_ModuleConfig_payload_variant_mqtt_MSGTYPE meshtastic_ModuleConfig_MQTTConfig @@ -796,6 +856,7 @@ X(a, STATIC, ONEOF, MESSAGE, (payload_variant,tak,payload_variant.tak), 1 #define meshtastic_ModuleConfig_payload_variant_statusmessage_MSGTYPE meshtastic_ModuleConfig_StatusMessageConfig #define meshtastic_ModuleConfig_payload_variant_traffic_management_MSGTYPE meshtastic_ModuleConfig_TrafficManagementConfig #define meshtastic_ModuleConfig_payload_variant_tak_MSGTYPE meshtastic_ModuleConfig_TAKConfig +#define meshtastic_ModuleConfig_payload_variant_mesh_beacon_MSGTYPE meshtastic_ModuleConfig_MeshBeaconConfig #define meshtastic_ModuleConfig_MQTTConfig_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, BOOL, enabled, 1) \ @@ -981,6 +1042,23 @@ X(a, STATIC, SINGULAR, STRING, node_status, 1) #define meshtastic_ModuleConfig_StatusMessageConfig_CALLBACK NULL #define meshtastic_ModuleConfig_StatusMessageConfig_DEFAULT NULL +#define meshtastic_ModuleConfig_MeshBeaconConfig_FIELDLIST(X, a) \ +X(a, STATIC, SINGULAR, BOOL, listen_enabled, 1) \ +X(a, STATIC, SINGULAR, BOOL, broadcast_enabled, 2) \ +X(a, STATIC, SINGULAR, UINT32, broadcast_send_as_node, 3) \ +X(a, STATIC, SINGULAR, STRING, broadcast_message, 4) \ +X(a, STATIC, OPTIONAL, MESSAGE, broadcast_offer_channel, 5) \ +X(a, STATIC, SINGULAR, UENUM, broadcast_offer_region, 6) \ +X(a, STATIC, SINGULAR, UENUM, broadcast_offer_preset, 7) \ +X(a, STATIC, OPTIONAL, MESSAGE, broadcast_on_channel, 8) \ +X(a, STATIC, SINGULAR, UENUM, broadcast_on_region, 9) \ +X(a, STATIC, SINGULAR, UENUM, broadcast_on_preset, 10) \ +X(a, STATIC, SINGULAR, UINT32, broadcast_interval_secs, 11) +#define meshtastic_ModuleConfig_MeshBeaconConfig_CALLBACK NULL +#define meshtastic_ModuleConfig_MeshBeaconConfig_DEFAULT NULL +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_channel_MSGTYPE meshtastic_ChannelSettings +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_channel_MSGTYPE meshtastic_ChannelSettings + #define meshtastic_ModuleConfig_TAKConfig_FIELDLIST(X, a) \ X(a, STATIC, SINGULAR, UENUM, team, 1) \ X(a, STATIC, SINGULAR, UENUM, role, 2) @@ -1011,6 +1089,7 @@ extern const pb_msgdesc_t meshtastic_ModuleConfig_TelemetryConfig_msg; extern const pb_msgdesc_t meshtastic_ModuleConfig_CannedMessageConfig_msg; extern const pb_msgdesc_t meshtastic_ModuleConfig_AmbientLightingConfig_msg; extern const pb_msgdesc_t meshtastic_ModuleConfig_StatusMessageConfig_msg; +extern const pb_msgdesc_t meshtastic_ModuleConfig_MeshBeaconConfig_msg; extern const pb_msgdesc_t meshtastic_ModuleConfig_TAKConfig_msg; extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; @@ -1032,6 +1111,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_CannedMessageConfig_fields &meshtastic_ModuleConfig_CannedMessageConfig_msg #define meshtastic_ModuleConfig_AmbientLightingConfig_fields &meshtastic_ModuleConfig_AmbientLightingConfig_msg #define meshtastic_ModuleConfig_StatusMessageConfig_fields &meshtastic_ModuleConfig_StatusMessageConfig_msg +#define meshtastic_ModuleConfig_MeshBeaconConfig_fields &meshtastic_ModuleConfig_MeshBeaconConfig_msg #define meshtastic_ModuleConfig_TAKConfig_fields &meshtastic_ModuleConfig_TAKConfig_msg #define meshtastic_RemoteHardwarePin_fields &meshtastic_RemoteHardwarePin_msg @@ -1044,6 +1124,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_ExternalNotificationConfig_size 42 #define meshtastic_ModuleConfig_MQTTConfig_size 224 #define meshtastic_ModuleConfig_MapReportSettings_size 14 +#define meshtastic_ModuleConfig_MeshBeaconConfig_size 274 #define meshtastic_ModuleConfig_NeighborInfoConfig_size 10 #define meshtastic_ModuleConfig_PaxcounterConfig_size 30 #define meshtastic_ModuleConfig_RangeTestConfig_size 12 @@ -1054,7 +1135,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_TAKConfig_size 4 #define meshtastic_ModuleConfig_TelemetryConfig_size 50 #define meshtastic_ModuleConfig_TrafficManagementConfig_size 52 -#define meshtastic_ModuleConfig_size 227 +#define meshtastic_ModuleConfig_size 278 #define meshtastic_RemoteHardwarePin_size 21 #ifdef __cplusplus diff --git a/src/mesh/generated/meshtastic/portnums.pb.h b/src/mesh/generated/meshtastic/portnums.pb.h index 494ef4a5497..67b32f1e611 100644 --- a/src/mesh/generated/meshtastic/portnums.pb.h +++ b/src/mesh/generated/meshtastic/portnums.pb.h @@ -98,6 +98,11 @@ typedef enum _meshtastic_PortNum { This module allows setting an extra string of status for a node. Broadcasts on change and on a timer, possibly once a day. */ meshtastic_PortNum_NODE_STATUS_APP = 36, + /* Beacon module broadcast packets. + ENCODING: protobuf (MeshBeacon) + Periodically broadcast by nodes in beacon mode; received by nodes with listen_enabled. + Carries a text message plus optional channel/preset offers for client apps. */ + meshtastic_PortNum_MESH_BEACON_APP = 37, /* Provides a hardware serial interface to send and receive from the Meshtastic network. Connect to the RX/TX pins of a device with 38400 8N1. Packets received from the Meshtastic network is forwarded to the RX pin while sending a packet to TX will go out to the Mesh network. diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp new file mode 100644 index 00000000000..a3e6badee71 --- /dev/null +++ b/src/modules/MeshBeaconModule.cpp @@ -0,0 +1,340 @@ +#include "MeshBeaconModule.h" +#include "Default.h" +#include "MeshService.h" +#include "RTC.h" +#include "RadioInterface.h" +#include "configuration.h" +#include "main.h" +#include +#include + +// TODO(beacon): Remove virtual nodenum. Beacon packets will originate from the real local node. +#define NODENUM_BEACON 0x00000005 + +static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; // original modem preset +static uint16_t originalLoraChannel; // original frequency slot +char originalBeaconChannelName[sizeof(((meshtastic_ChannelSettings *)nullptr)->name)]; // original channel name + +typedef struct { + bool inUse; + PacketId id; + MeshBeaconModule_TXSettings settings; +} MeshBeaconModule_TargetRadioSettings; + +static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; + +// Internal: look up sidecar entry by packet ID. +static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, MeshBeaconModule_TXSettings *settings) +{ + if (!p) + return false; + + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + if (settings) + *settings = entry.settings; + return true; + } + } + return false; +} + +MeshBeaconModule::MeshBeaconModule() +{ + originalModemPreset = config.lora.modem_preset; + originalLoraChannel = config.lora.channel_num; + strncpy(originalBeaconChannelName, channels.getPrimary().name, sizeof(originalBeaconChannelName)); +} + +void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, MeshBeaconModule_TXSettings settings) +{ + if (!p) + return; + + MeshBeaconModule_TargetRadioSettings *slot = nullptr; + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + slot = &entry; + break; + } + if (!slot && !entry.inUse) + slot = &entry; + } + + // All 8 slots full: evict slot 0 (silent overwrite) + if (!slot) + slot = &targetRadioSettings[0]; + + slot->inUse = true; + slot->id = p->id; + slot->settings = settings; +} + +bool MeshBeaconModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + return getTargetRadioSettings(p, nullptr); +} + +void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + if (!p) + return; + + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + entry.inUse = false; + return; + } + } +} + +bool MeshBeaconModule::configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p) +{ + // TODO(beacon): Drive from broadcast_on_preset / broadcast_on_channel in MeshBeaconConfig + // rather than the per-packet sidecar table. + meshtastic_ChannelSettings *c = (meshtastic_ChannelSettings *)&channels.getPrimary(); + MeshBeaconModule_TXSettings target; + if (p && p->from == NODENUM_BEACON && getTargetRadioSettings(p, &target) && + (target.preset != config.lora.modem_preset || target.slot != config.lora.channel_num)) { + LOG_INFO("Beacon: reconfiguring radio for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, + p->decoded.payload.size); + + config.lora.modem_preset = target.preset; + config.lora.channel_num = target.slot; + memset(c->name, 0, sizeof(c->name)); + + switch (target.preset) { + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + strncpy(c->name, "ShortTurbo", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + strncpy(c->name, "ShortFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + strncpy(c->name, "ShortSlow", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + strncpy(c->name, "MediumFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + strncpy(c->name, "MediumSlow", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + strncpy(c->name, "LongFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + strncpy(c->name, "LongMod", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + strncpy(c->name, "LongSlow", sizeof(c->name)); + break; + default: + break; + } + + channels.fixupChannel(channels.getPrimaryIndex()); + p->channel = channels.getHash(channels.getPrimaryIndex()); + iface->reconfigure(); + return true; + + } else if ((!p || !getTargetRadioSettings(p, nullptr)) && + (config.lora.modem_preset != originalModemPreset || config.lora.channel_num != originalLoraChannel)) { + if (p) + LOG_INFO("Beacon: reconfiguring radio for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, + p->decoded.payload.size); + else + LOG_INFO("Beacon: restoring radio config after beacon TX"); + + config.lora.modem_preset = originalModemPreset; + config.lora.channel_num = originalLoraChannel; + memset(c->name, 0, sizeof(c->name)); + strncpy(c->name, originalBeaconChannelName, sizeof(c->name)); + + channels.fixupChannel(channels.getPrimaryIndex()); + iface->reconfigure(); + return true; + } + return false; +} + +MeshBeaconModule_TXSettings MeshBeaconModule::stripTargetRadioSettings(meshtastic_MeshPacket *p) +{ + // TODO(beacon): Remove entirely. Beacon payloads are structured MeshBeacon protobufs, + // not freeform text with embedded #PPNN routing directives. + MeshBeaconModule_TXSettings s = { + .preset = originalModemPreset, + .slot = originalLoraChannel, + }; + + if (!p || p->decoded.payload.size < 4 || p->decoded.payload.bytes[0] != '#') + return s; + + if (p->decoded.payload.size < sizeof(p->decoded.payload.bytes)) + p->decoded.payload.bytes[p->decoded.payload.size] = 0; + else + p->decoded.payload.bytes[sizeof(p->decoded.payload.bytes) - 1] = 0; + + char *msg = strchr((char *)p->decoded.payload.bytes, ' '); + if (!msg || !*msg) + return s; + *msg++ = 0; + + const char presetString[3] = {p->decoded.payload.bytes[1], p->decoded.payload.bytes[2], 0}; + char *slotString = (char *)&p->decoded.payload.bytes[3]; + if (!*slotString) + return s; + + for (char *c = slotString; *c; c++) { + if (!strchr("1234567890", *c)) + return s; + } + + if (!strncmp(presetString, "ST", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + else if (!strncmp(presetString, "SF", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; + else if (!strncmp(presetString, "SS", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW; + else if (!strncmp(presetString, "MF", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + else if (!strncmp(presetString, "MS", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; + else if (!strncmp(presetString, "LF", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + else if (!strncmp(presetString, "LM", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE; + else if (!strncmp(presetString, "LS", 2)) + s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + else + return s; + + s.slot = strtoul(slotString, nullptr, 10); + + p->decoded.payload.size = 1; + for (char *a = msg, *b = (char *)p->decoded.payload.bytes; (*b++ = *a++);) + p->decoded.payload.size++; + + return s; +} + +// --------------------------------------------------------------------------- +// MeshBeaconNodeInfoModule +// TODO(beacon): Remove this class. Replaced by real-node broadcasts. +// --------------------------------------------------------------------------- + +MeshBeaconNodeInfoModule *meshBeaconNodeInfoModule; + +bool MeshBeaconNodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *pptr) +{ + return true; +} + +void MeshBeaconNodeInfoModule::sendBeaconNodeInfo() +{ + // TODO(beacon): Remove - replaced by MeshBeaconBroadcastModule sending structured MeshBeacon packets. + LOG_INFO("Beacon: send NodeInfo for mesh beacon"); + static meshtastic_User u = { + .hw_model = meshtastic_HardwareModel_PRIVATE_HW, + .is_licensed = false, + .role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE, + .public_key = + { + .size = 32, + .bytes = {0x39, 0x37, 0x58, 0xe4, 0x05, 0x34, 0x7d, 0xe0, 0x49, 0x73, 0xec, 0xaf, 0xbc, 0x8e, 0x07, 0xe8, + 0x66, 0x57, 0xe4, 0xa1, 0x2d, 0x53, 0x0e, 0x26, 0x51, 0x1f, 0x1a, 0x6c, 0xbf, 0xe8, 0x5e, 0x04}, + }, + .has_is_unmessagable = true, + .is_unmessagable = true, + }; + // TODO(beacon): long_name, short_name, id should come from config (broadcast_node_id / device name) + strncpy(u.id, "!mesh_bcn", sizeof(u.id)); + strncpy(u.long_name, "Mesh Beacon Node", sizeof(u.long_name)); + strncpy(u.short_name, "BCN", sizeof(u.short_name)); + meshtastic_MeshPacket *p = allocDataProtobuf(u); + p->to = NODENUM_BROADCAST; + p->from = NODENUM_BEACON; + p->hop_limit = 0; + p->decoded.want_response = false; + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + + // TODO(beacon): The second copy on a different preset comes from broadcast_on_preset config. + meshtastic_MeshPacket *p_LF20 = packetPool.allocCopy(*p); + service->sendToMesh(p, RX_SRC_LOCAL, false); + + setTargetRadioSettings(p_LF20, { + .preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, + .slot = 20, + }); + service->sendToMesh(p_LF20, RX_SRC_LOCAL, false); +} + +MeshBeaconNodeInfoModule::MeshBeaconNodeInfoModule() + : ProtobufModule("nodeinfo_beacon", meshtastic_PortNum_NODEINFO_APP, &meshtastic_User_msg), + concurrency::OSThread("MeshBeaconNodeInfo") +{ + MeshBeaconModule(); + setIntervalFromNow(setStartDelay()); +} + +int32_t MeshBeaconNodeInfoModule::runOnce() +{ + if (airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { + sendBeaconNodeInfo(); + } + // TODO(beacon): Use broadcast_interval_secs from MeshBeaconConfig (min 3600 s, max 259200 s). + return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); +} + +// --------------------------------------------------------------------------- +// MeshBeaconMessageModule +// TODO(beacon): Split into MeshBeaconBroadcastModule + MeshBeaconListenerModule (see header). +// --------------------------------------------------------------------------- + +MeshBeaconMessageModule *meshBeaconMessageModule; + +ProcessMessage MeshBeaconMessageModule::handleReceived(const meshtastic_MeshPacket &mp) +{ + // TODO(beacon): On the broadcaster side: read broadcast_message / broadcast_offer_channel / + // broadcast_offer_preset from config, build a MeshBeacon protobuf, and send it periodically + // via the OSThread. This relay-incoming-message logic is replaced entirely. + // + // TODO(beacon): On the listener side: decode MeshBeacon protobuf, deliver text portion as a + // TEXT_MESSAGE_APP packet to the inbox, store offered channel / preset in a local cache for + // client app retrieval. Do NOT apply offered settings automatically. +#ifdef DEBUG_PORT + auto &d = mp.decoded; +#endif + meshtastic_MeshPacket *p = packetPool.allocCopy(mp); + MeshBeaconModule_TXSettings s = stripTargetRadioSettings(p); + if (!p->decoded.payload.size || p->decoded.payload.bytes[0] == '#') + return ProcessMessage::STOP; + p->to = NODENUM_BROADCAST; + p->decoded.source = p->from; + p->from = NODENUM_BEACON; + p->channel = channels.getPrimaryIndex(); + p->hop_limit = 0; + p->hop_start = 0; + p->rx_rssi = 0; + p->rx_snr = 0; + p->priority = meshtastic_MeshPacket_Priority_HIGH; + p->want_ack = false; + if (s.preset != originalModemPreset || s.slot != originalLoraChannel) { + setTargetRadioSettings(p, s); + } + p->rx_time = getValidTime(RTCQualityFromNet); + service->sendToMesh(p, RX_SRC_LOCAL, false); + + powerFSM.trigger(EVENT_RECEIVED_MSG); + notifyObservers(&mp); + + return ProcessMessage::CONTINUE; +} + +bool MeshBeaconMessageModule::wantPacket(const meshtastic_MeshPacket *p) +{ + // TODO(beacon): Replace with: p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP + // No special named channel needed — any node with beacons_listen=true receives beacon packets. + meshtastic_Channel *c = &channels.getByIndex(p->channel); + return c->role == meshtastic_Channel_Role_SECONDARY && strlen(c->settings.name) == strlen("Beacon") && + !strcmp(c->settings.name, "Beacon") && p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; +} diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h new file mode 100644 index 00000000000..2d138bfd1df --- /dev/null +++ b/src/modules/MeshBeaconModule.h @@ -0,0 +1,129 @@ +#pragma once +#include "Observer.h" +#include "ProtobufModule.h" +#include "RadioInterface.h" +#include "SinglePortModule.h" + +// TODO(beacon): Replace with config-driven approach - beacon_on_preset / beacon_on_channel +// The radio settings for a single outgoing beacon packet +typedef struct { + meshtastic_Config_LoRaConfig_ModemPreset preset; + uint16_t slot; +} MeshBeaconModule_TXSettings; + +/** + * Base class for the beacon module. + * Holds the radio-switching sidecar table and static helpers shared by + * MeshBeaconNodeInfoModule and MeshBeaconMessageModule. + * + * TODO(beacon): Remove virtual-node / NODENUM_BEACON approach entirely. + * Beacons originate from the real local node. The NodeInfo sub-module + * and the fixed NODENUM are carryover from MeshTips and will be removed. + * + * TODO(beacon): Replace stripTargetRadioSettings() text-prefix parsing (#PPNN) + * with structured config fields (broadcast_on_preset, broadcast_on_channel). + * + * TODO(beacon): Replace TEXT_MESSAGE_APP portnum filter with MESH_BEACON_APP portnum + * once that portnum is added to portnums.proto and generated code is regenerated. + * + * TODO(beacon): Add MeshBeaconConfig to module_config.proto (see plan in session memory). + * + * TODO(beacon): Add AdminMessage handling so broadcast_message, intervals, and + * offer_channel / offer_preset can be set remotely via admin. + */ +class MeshBeaconModule +{ + public: + MeshBeaconModule(); + + /** + * Configure the radio to send the target packet, or restore to default config if p is NULL. + * Returns true if the radio was reconfigured (caller should insert a new transmit delay + * to let the new channel clear before sending). + * + * TODO(beacon): Drive target settings from broadcast_on_preset / broadcast_on_channel in + * MeshBeaconConfig rather than a per-packet sidecar table lookup. + */ + static bool configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p); + + /** + * Associate target radio settings with an outgoing packet by its ID. + * The sidecar table holds up to 8 entries; the oldest is evicted on overflow. + */ + static void setTargetRadioSettings(const meshtastic_MeshPacket *p, MeshBeaconModule_TXSettings settings); + + /** + * Returns true if the sidecar table contains an entry for this packet's ID. + * Used by RadioLibInterface to gate the channel-active check on non-default-preset packets. + */ + static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p); + + /** + * Remove the sidecar entry for this packet after it has been sent. + * Called from RadioLibInterface::completeSending(). + */ + static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); + + /** + * Parse and strip the #PPNN command prefix from an incoming text payload. + * Returns target radio settings derived from the prefix (or defaults if no valid prefix). + * Mutates p->decoded.payload in-place: strips the prefix, leaving only the message body. + * + * TODO(beacon): Remove entirely - beacon payloads will be structured MeshBeacon protobufs, + * not freeform text with embedded routing directives. Radio target comes from config, not payload. + */ + MeshBeaconModule_TXSettings stripTargetRadioSettings(meshtastic_MeshPacket *p); +}; + +/** + * Periodic NodeInfo broadcaster for the beacon virtual node. + * + * TODO(beacon): Remove this class entirely. Beacons originate from the real node; there is + * no virtual nodenum. The periodic broadcast is replaced by MeshBeaconBroadcastModule (OSThread). + */ +class MeshBeaconNodeInfoModule : private MeshBeaconModule, public ProtobufModule, private concurrency::OSThread +{ + public: + MeshBeaconNodeInfoModule(); + + protected: + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *p) override; + + void sendBeaconNodeInfo(); + + virtual int32_t runOnce() override; +}; +extern MeshBeaconNodeInfoModule *meshBeaconNodeInfoModule; + +/** + * Text message relay / listener for the beacon module. + * + * TODO(beacon): Split into two separate classes: + * - MeshBeaconBroadcastModule (OSThread) — periodically sends MeshBeacon protobuf packets + * on broadcast_on_preset / broadcast_on_channel with the configured broadcast_message, + * broadcast_offer_channel, and broadcast_offer_preset. Only active when beacon_broadcast=true. + * - MeshBeaconListenerModule (SinglePortModule on MESH_BEACON_APP) — receives MeshBeacon + * packets, delivers text to the text message inbox, and stores the offered channel/preset + * for retrieval by a client app. Only active when beacons_listen=true. + * Does NOT auto-apply offered channel or preset — client app must do this explicitly. + * + * TODO(beacon): wantPacket() currently filters on a "Beacon" named SECONDARY channel + + * TEXT_MESSAGE_APP. Replace with MESH_BEACON_APP portnum filter (no special channel needed). + * + * TODO(beacon): handleReceived() currently relays incoming messages from another node. + * Replace with originating logic: read broadcast_message from config, build MeshBeacon + * proto, set target radio settings from broadcast_on_preset, send periodically. + */ +class MeshBeaconMessageModule : private MeshBeaconModule, + public SinglePortModule, + public Observable +{ + public: + MeshBeaconMessageModule() : MeshBeaconModule(), SinglePortModule("beacon", meshtastic_PortNum_TEXT_MESSAGE_APP) {} + + protected: + virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + + virtual bool wantPacket(const meshtastic_MeshPacket *p) override; +}; +extern MeshBeaconMessageModule *meshBeaconMessageModule; diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 042ac677f6f..d88edd062fa 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -31,6 +31,9 @@ #if !MESHTASTIC_EXCLUDE_TIPS #include "modules/MeshTipsModule.h" #endif +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif #if !MESHTASTIC_EXCLUDE_GPS #include "modules/PositionModule.h" #endif @@ -151,6 +154,10 @@ void setupModules() meshTipsNodeInfoModule = new MeshTipsNodeInfoModule(); meshTipsMessageModule = new MeshTipsMessageModule(); #endif +#if !MESHTASTIC_EXCLUDE_BEACON + meshBeaconNodeInfoModule = new MeshBeaconNodeInfoModule(); + meshBeaconMessageModule = new MeshBeaconMessageModule(); +#endif #if !MESHTASTIC_EXCLUDE_GPS positionModule = new PositionModule(); #endif From dd57b9e59a4913762a3b93c9c403e83c7c02a744 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 2 Jun 2026 22:07:49 +0100 Subject: [PATCH 04/24] feat(beacon): implement broadcaster + listener (phases 2-5) --- src/modules/MeshBeaconModule.cpp | 399 +++++++++++++------------------ src/modules/MeshBeaconModule.h | 126 ++++------ src/modules/Modules.cpp | 4 +- 3 files changed, 218 insertions(+), 311 deletions(-) diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index a3e6badee71..fc33db6e8d9 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -1,85 +1,80 @@ #include "MeshBeaconModule.h" #include "Default.h" #include "MeshService.h" +#include "NodeDB.h" #include "RTC.h" #include "RadioInterface.h" #include "configuration.h" #include "main.h" #include -#include +#include -// TODO(beacon): Remove virtual nodenum. Beacon packets will originate from the real local node. -#define NODENUM_BEACON 0x00000005 - -static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; // original modem preset -static uint16_t originalLoraChannel; // original frequency slot -char originalBeaconChannelName[sizeof(((meshtastic_ChannelSettings *)nullptr)->name)]; // original channel name - -typedef struct { - bool inUse; - PacketId id; - MeshBeaconModule_TXSettings settings; -} MeshBeaconModule_TargetRadioSettings; +// Static members +meshtastic_Config_LoRaConfig_ModemPreset MeshBeaconModule::originalModemPreset; +uint16_t MeshBeaconModule::originalLoraChannel; +char MeshBeaconModule::originalChannelName[12]; static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; -// Internal: look up sidecar entry by packet ID. -static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, MeshBeaconModule_TXSettings *settings) +static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset *preset, + uint16_t *slot) { if (!p) return false; - for (auto &entry : targetRadioSettings) { if (entry.inUse && entry.id == p->id) { - if (settings) - *settings = entry.settings; + if (preset) + *preset = entry.preset; + if (slot) + *slot = entry.slot; return true; } } return false; } +// --------------------------------------------------------------------------- +// MeshBeaconModule base +// --------------------------------------------------------------------------- + MeshBeaconModule::MeshBeaconModule() { originalModemPreset = config.lora.modem_preset; originalLoraChannel = config.lora.channel_num; - strncpy(originalBeaconChannelName, channels.getPrimary().name, sizeof(originalBeaconChannelName)); + strncpy(originalChannelName, channels.getPrimary().name, sizeof(originalChannelName)); } -void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, MeshBeaconModule_TXSettings settings) +void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, + uint16_t slot) { if (!p) return; - - MeshBeaconModule_TargetRadioSettings *slot = nullptr; + MeshBeaconModule_TargetRadioSettings *target = nullptr; for (auto &entry : targetRadioSettings) { if (entry.inUse && entry.id == p->id) { - slot = &entry; + target = &entry; break; } - if (!slot && !entry.inUse) - slot = &entry; + if (!target && !entry.inUse) + target = &entry; } - - // All 8 slots full: evict slot 0 (silent overwrite) - if (!slot) - slot = &targetRadioSettings[0]; - - slot->inUse = true; - slot->id = p->id; - slot->settings = settings; + if (!target) + target = &targetRadioSettings[0]; + target->inUse = true; + target->id = p->id; + target->preset = preset; + target->slot = slot; } bool MeshBeaconModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p) { - return getTargetRadioSettings(p, nullptr); + return getTargetRadioSettings(p, nullptr, nullptr); } void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) { if (!p) return; - for (auto &entry : targetRadioSettings) { if (entry.inUse && entry.id == p->id) { entry.inUse = false; @@ -88,48 +83,52 @@ void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) } } -bool MeshBeaconModule::configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p) +bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p) { - // TODO(beacon): Drive from broadcast_on_preset / broadcast_on_channel in MeshBeaconConfig - // rather than the per-packet sidecar table. meshtastic_ChannelSettings *c = (meshtastic_ChannelSettings *)&channels.getPrimary(); - MeshBeaconModule_TXSettings target; - if (p && p->from == NODENUM_BEACON && getTargetRadioSettings(p, &target) && - (target.preset != config.lora.modem_preset || target.slot != config.lora.channel_num)) { - LOG_INFO("Beacon: reconfiguring radio for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, - p->decoded.payload.size); - - config.lora.modem_preset = target.preset; - config.lora.channel_num = target.slot; + meshtastic_Config_LoRaConfig_ModemPreset targetPreset; + uint16_t targetSlot; + + if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot) && + (targetPreset != config.lora.modem_preset || targetSlot != config.lora.channel_num)) { + + LOG_INFO("Beacon: switch radio for packet %#08lx to preset=%d slot=%u", p->id, targetPreset, targetSlot); + config.lora.modem_preset = targetPreset; + config.lora.channel_num = targetSlot; memset(c->name, 0, sizeof(c->name)); - switch (target.preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: - strncpy(c->name, "ShortTurbo", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - strncpy(c->name, "ShortFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - strncpy(c->name, "ShortSlow", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - strncpy(c->name, "MediumFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - strncpy(c->name, "MediumSlow", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - strncpy(c->name, "LongFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - strncpy(c->name, "LongMod", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - strncpy(c->name, "LongSlow", sizeof(c->name)); - break; - default: - break; + const auto &bcfg = moduleConfig.payload_variant.mesh_beacon; + if (bcfg.has_broadcast_on_channel && strlen(bcfg.broadcast_on_channel.name) > 0) { + strncpy(c->name, bcfg.broadcast_on_channel.name, sizeof(c->name)); + } else { + switch (targetPreset) { + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: + strncpy(c->name, "ShortTurbo", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: + strncpy(c->name, "ShortFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: + strncpy(c->name, "ShortSlow", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: + strncpy(c->name, "MediumFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: + strncpy(c->name, "MediumSlow", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: + strncpy(c->name, "LongFast", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: + strncpy(c->name, "LongMod", sizeof(c->name)); + break; + case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: + strncpy(c->name, "LongSlow", sizeof(c->name)); + break; + default: + break; + } } channels.fixupChannel(channels.getPrimaryIndex()); @@ -137,18 +136,14 @@ bool MeshBeaconModule::configureRadioForPacket(RadioInterface *iface, meshtastic iface->reconfigure(); return true; - } else if ((!p || !getTargetRadioSettings(p, nullptr)) && + } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && (config.lora.modem_preset != originalModemPreset || config.lora.channel_num != originalLoraChannel)) { - if (p) - LOG_INFO("Beacon: reconfiguring radio for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, - p->decoded.payload.size); - else - LOG_INFO("Beacon: restoring radio config after beacon TX"); + LOG_INFO("Beacon: restoring radio config after beacon TX"); config.lora.modem_preset = originalModemPreset; config.lora.channel_num = originalLoraChannel; memset(c->name, 0, sizeof(c->name)); - strncpy(c->name, originalBeaconChannelName, sizeof(c->name)); + strncpy(c->name, originalChannelName, sizeof(c->name)); channels.fixupChannel(channels.getPrimaryIndex()); iface->reconfigure(); @@ -157,184 +152,122 @@ bool MeshBeaconModule::configureRadioForPacket(RadioInterface *iface, meshtastic return false; } -MeshBeaconModule_TXSettings MeshBeaconModule::stripTargetRadioSettings(meshtastic_MeshPacket *p) -{ - // TODO(beacon): Remove entirely. Beacon payloads are structured MeshBeacon protobufs, - // not freeform text with embedded #PPNN routing directives. - MeshBeaconModule_TXSettings s = { - .preset = originalModemPreset, - .slot = originalLoraChannel, - }; - - if (!p || p->decoded.payload.size < 4 || p->decoded.payload.bytes[0] != '#') - return s; - - if (p->decoded.payload.size < sizeof(p->decoded.payload.bytes)) - p->decoded.payload.bytes[p->decoded.payload.size] = 0; - else - p->decoded.payload.bytes[sizeof(p->decoded.payload.bytes) - 1] = 0; - - char *msg = strchr((char *)p->decoded.payload.bytes, ' '); - if (!msg || !*msg) - return s; - *msg++ = 0; - - const char presetString[3] = {p->decoded.payload.bytes[1], p->decoded.payload.bytes[2], 0}; - char *slotString = (char *)&p->decoded.payload.bytes[3]; - if (!*slotString) - return s; - - for (char *c = slotString; *c; c++) { - if (!strchr("1234567890", *c)) - return s; - } - - if (!strncmp(presetString, "ST", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; - else if (!strncmp(presetString, "SF", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; - else if (!strncmp(presetString, "SS", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW; - else if (!strncmp(presetString, "MF", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - else if (!strncmp(presetString, "MS", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; - else if (!strncmp(presetString, "LF", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - else if (!strncmp(presetString, "LM", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE; - else if (!strncmp(presetString, "LS", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; - else - return s; - - s.slot = strtoul(slotString, nullptr, 10); - - p->decoded.payload.size = 1; - for (char *a = msg, *b = (char *)p->decoded.payload.bytes; (*b++ = *a++);) - p->decoded.payload.size++; - - return s; -} - // --------------------------------------------------------------------------- -// MeshBeaconNodeInfoModule -// TODO(beacon): Remove this class. Replaced by real-node broadcasts. +// MeshBeaconBroadcastModule // --------------------------------------------------------------------------- -MeshBeaconNodeInfoModule *meshBeaconNodeInfoModule; +MeshBeaconBroadcastModule *meshBeaconBroadcastModule; -bool MeshBeaconNodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *pptr) +MeshBeaconBroadcastModule::MeshBeaconBroadcastModule() : MeshBeaconModule(), concurrency::OSThread("MeshBeaconBroadcast") { - return true; + setIntervalFromNow(setStartDelay()); } -void MeshBeaconNodeInfoModule::sendBeaconNodeInfo() +void MeshBeaconBroadcastModule::sendBeacon() { - // TODO(beacon): Remove - replaced by MeshBeaconBroadcastModule sending structured MeshBeacon packets. - LOG_INFO("Beacon: send NodeInfo for mesh beacon"); - static meshtastic_User u = { - .hw_model = meshtastic_HardwareModel_PRIVATE_HW, - .is_licensed = false, - .role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE, - .public_key = - { - .size = 32, - .bytes = {0x39, 0x37, 0x58, 0xe4, 0x05, 0x34, 0x7d, 0xe0, 0x49, 0x73, 0xec, 0xaf, 0xbc, 0x8e, 0x07, 0xe8, - 0x66, 0x57, 0xe4, 0xa1, 0x2d, 0x53, 0x0e, 0x26, 0x51, 0x1f, 0x1a, 0x6c, 0xbf, 0xe8, 0x5e, 0x04}, - }, - .has_is_unmessagable = true, - .is_unmessagable = true, - }; - // TODO(beacon): long_name, short_name, id should come from config (broadcast_node_id / device name) - strncpy(u.id, "!mesh_bcn", sizeof(u.id)); - strncpy(u.long_name, "Mesh Beacon Node", sizeof(u.long_name)); - strncpy(u.short_name, "BCN", sizeof(u.short_name)); - meshtastic_MeshPacket *p = allocDataProtobuf(u); + const auto &bcfg = moduleConfig.payload_variant.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; + } + beacon.offer_preset = bcfg.broadcast_offer_preset; + beacon.offer_region = bcfg.broadcast_offer_region; + + meshtastic_MeshPacket *p = allocDataProtobuf(beacon); + if (!p) { + LOG_WARN("Beacon: failed to allocate packet"); + return; + } + p->to = NODENUM_BROADCAST; - p->from = NODENUM_BEACON; - p->hop_limit = 0; - p->decoded.want_response = false; + p->from = (bcfg.broadcast_send_as_node != 0) ? bcfg.broadcast_send_as_node : nodeDB->getNodeNum(); + p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + p->hop_limit = 3; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + p->want_ack = false; + p->rx_time = getValidTime(RTCQualityFromNet); - // TODO(beacon): The second copy on a different preset comes from broadcast_on_preset config. - meshtastic_MeshPacket *p_LF20 = packetPool.allocCopy(*p); - service->sendToMesh(p, RX_SRC_LOCAL, false); - - setTargetRadioSettings(p_LF20, { - .preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, - .slot = 20, - }); - service->sendToMesh(p_LF20, RX_SRC_LOCAL, false); -} + if (bcfg.has_broadcast_on_preset && + (bcfg.broadcast_on_preset != config.lora.modem_preset || + (bcfg.has_broadcast_on_channel && bcfg.broadcast_on_channel.channel_num != config.lora.channel_num))) { + uint16_t targetSlot = bcfg.has_broadcast_on_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num; + setTargetRadioSettings(p, bcfg.broadcast_on_preset, targetSlot); + } -MeshBeaconNodeInfoModule::MeshBeaconNodeInfoModule() - : ProtobufModule("nodeinfo_beacon", meshtastic_PortNum_NODEINFO_APP, &meshtastic_User_msg), - concurrency::OSThread("MeshBeaconNodeInfo") -{ - MeshBeaconModule(); - setIntervalFromNow(setStartDelay()); + LOG_INFO("Beacon: broadcast from=%#08lx msg='%.40s'", p->from, beacon.message); + service->sendToMesh(p, RX_SRC_LOCAL, false); } -int32_t MeshBeaconNodeInfoModule::runOnce() +int32_t MeshBeaconBroadcastModule::runOnce() { - if (airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { - sendBeaconNodeInfo(); + const auto &bcfg = moduleConfig.payload_variant.mesh_beacon; + if (bcfg.broadcast_enabled && airTime->isTxAllowedAirUtil() && + config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { + sendBeacon(); } - // TODO(beacon): Use broadcast_interval_secs from MeshBeaconConfig (min 3600 s, max 259200 s). - return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); + + uint32_t interval = bcfg.broadcast_interval_secs; + if (interval < 3600) + interval = 3600; + if (interval > 259200) + interval = 259200; + return interval * 1000; } // --------------------------------------------------------------------------- -// MeshBeaconMessageModule -// TODO(beacon): Split into MeshBeaconBroadcastModule + MeshBeaconListenerModule (see header). +// MeshBeaconListenerModule // --------------------------------------------------------------------------- -MeshBeaconMessageModule *meshBeaconMessageModule; +MeshBeaconListenerModule *meshBeaconListenerModule; +MeshBeaconListenerModule::BeaconOffer MeshBeaconListenerModule::lastReceivedOffer; -ProcessMessage MeshBeaconMessageModule::handleReceived(const meshtastic_MeshPacket &mp) +MeshBeaconListenerModule::MeshBeaconListenerModule() + : ProtobufModule("beacon_listen", meshtastic_PortNum_MESH_BEACON_APP, &meshtastic_MeshBeacon_msg) { - // TODO(beacon): On the broadcaster side: read broadcast_message / broadcast_offer_channel / - // broadcast_offer_preset from config, build a MeshBeacon protobuf, and send it periodically - // via the OSThread. This relay-incoming-message logic is replaced entirely. - // - // TODO(beacon): On the listener side: decode MeshBeacon protobuf, deliver text portion as a - // TEXT_MESSAGE_APP packet to the inbox, store offered channel / preset in a local cache for - // client app retrieval. Do NOT apply offered settings automatically. -#ifdef DEBUG_PORT - auto &d = mp.decoded; -#endif - meshtastic_MeshPacket *p = packetPool.allocCopy(mp); - MeshBeaconModule_TXSettings s = stripTargetRadioSettings(p); - if (!p->decoded.payload.size || p->decoded.payload.bytes[0] == '#') - return ProcessMessage::STOP; - p->to = NODENUM_BROADCAST; - p->decoded.source = p->from; - p->from = NODENUM_BEACON; - p->channel = channels.getPrimaryIndex(); - p->hop_limit = 0; - p->hop_start = 0; - p->rx_rssi = 0; - p->rx_snr = 0; - p->priority = meshtastic_MeshPacket_Priority_HIGH; - p->want_ack = false; - if (s.preset != originalModemPreset || s.slot != originalLoraChannel) { - setTargetRadioSettings(p, s); - } - p->rx_time = getValidTime(RTCQualityFromNet); - service->sendToMesh(p, RX_SRC_LOCAL, false); - - powerFSM.trigger(EVENT_RECEIVED_MSG); - notifyObservers(&mp); + lastReceivedOffer = {}; +} - return ProcessMessage::CONTINUE; +bool MeshBeaconListenerModule::wantPacket(const meshtastic_MeshPacket *p) +{ + return moduleConfig.which_payload_variant == meshtastic_ModuleConfig_mesh_beacon_tag && + moduleConfig.payload_variant.mesh_beacon.listen_enabled && p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP; } -bool MeshBeaconMessageModule::wantPacket(const meshtastic_MeshPacket *p) +bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) { - // TODO(beacon): Replace with: p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP - // No special named channel needed — any node with beacons_listen=true receives beacon packets. - meshtastic_Channel *c = &channels.getByIndex(p->channel); - return c->role == meshtastic_Channel_Role_SECONDARY && strlen(c->settings.name) == strlen("Beacon") && - !strcmp(c->settings.name, "Beacon") && p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; + if (!b || strlen(b->message) == 0) + return false; + + LOG_INFO("Beacon: received from %#08lx: '%.40s'", mp.from, b->message); + + // Deliver text to the local inbox as a TEXT_MESSAGE_APP packet. + meshtastic_MeshPacket *txt = packetPool.allocCopy(mp); + txt->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + txt->to = nodeDB->getNodeNum(); + memset(txt->decoded.payload.bytes, 0, sizeof(txt->decoded.payload.bytes)); + txt->decoded.payload.size = (pb_size_t)strnlen(b->message, sizeof(b->message) - 1); + memcpy(txt->decoded.payload.bytes, b->message, txt->decoded.payload.size); + service->handleToRadio(*txt); + packetPool.release(txt); + + // Cache any offer for the client app — never auto-applied. + if (b->has_offer_channel || b->offer_preset != 0) { + lastReceivedOffer.valid = true; + lastReceivedOffer.sender = mp.from; + lastReceivedOffer.has_channel = b->has_offer_channel; + if (b->has_offer_channel) + lastReceivedOffer.channel = b->offer_channel; + lastReceivedOffer.region = b->offer_region; + lastReceivedOffer.preset = b->offer_preset; + lastReceivedOffer.received_at = getValidTime(RTCQualityFromNet); + LOG_INFO("Beacon: stored offer from %#08lx (preset=%d)", mp.from, b->offer_preset); + } + + powerFSM.trigger(EVENT_RECEIVED_MSG); + notifyObservers(&mp); + return false; } diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index 2d138bfd1df..f2d4ccb7f40 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -2,34 +2,21 @@ #include "Observer.h" #include "ProtobufModule.h" #include "RadioInterface.h" -#include "SinglePortModule.h" +#include "concurrency/OSThread.h" +#include "mesh/generated/meshtastic/mesh_beacon.pb.h" +#include "mesh/generated/meshtastic/module_config.pb.h" -// TODO(beacon): Replace with config-driven approach - beacon_on_preset / beacon_on_channel -// The radio settings for a single outgoing beacon packet +// Sidecar entry pairing a packet ID with target radio settings for beacon TX. typedef struct { + bool inUse; + PacketId id; meshtastic_Config_LoRaConfig_ModemPreset preset; uint16_t slot; -} MeshBeaconModule_TXSettings; +} MeshBeaconModule_TargetRadioSettings; /** - * Base class for the beacon module. - * Holds the radio-switching sidecar table and static helpers shared by - * MeshBeaconNodeInfoModule and MeshBeaconMessageModule. - * - * TODO(beacon): Remove virtual-node / NODENUM_BEACON approach entirely. - * Beacons originate from the real local node. The NodeInfo sub-module - * and the fixed NODENUM are carryover from MeshTips and will be removed. - * - * TODO(beacon): Replace stripTargetRadioSettings() text-prefix parsing (#PPNN) - * with structured config fields (broadcast_on_preset, broadcast_on_channel). - * - * TODO(beacon): Replace TEXT_MESSAGE_APP portnum filter with MESH_BEACON_APP portnum - * once that portnum is added to portnums.proto and generated code is regenerated. - * - * TODO(beacon): Add MeshBeaconConfig to module_config.proto (see plan in session memory). - * - * TODO(beacon): Add AdminMessage handling so broadcast_message, intervals, and - * offer_channel / offer_preset can be set remotely via admin. + * Base class: holds the radio-switching sidecar table and static helpers. + * The sidecar avoids touching MeshPacket proto fields for per-packet radio state. */ class MeshBeaconModule { @@ -37,24 +24,22 @@ class MeshBeaconModule MeshBeaconModule(); /** - * Configure the radio to send the target packet, or restore to default config if p is NULL. - * Returns true if the radio was reconfigured (caller should insert a new transmit delay - * to let the new channel clear before sending). - * - * TODO(beacon): Drive target settings from broadcast_on_preset / broadcast_on_channel in - * MeshBeaconConfig rather than a per-packet sidecar table lookup. + * Reconfigure the radio for beacon TX, or restore to original config if p is NULL. + * Returns true if the radio was reconfigured (caller must re-run transmit delay for CCA). + * Driven by broadcast_on_preset / broadcast_on_channel from MeshBeaconConfig. */ - static bool configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p); + static bool reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p); /** * Associate target radio settings with an outgoing packet by its ID. - * The sidecar table holds up to 8 entries; the oldest is evicted on overflow. + * Sidecar holds 8 entries; evicts slot 0 on overflow. */ - static void setTargetRadioSettings(const meshtastic_MeshPacket *p, MeshBeaconModule_TXSettings settings); + static void setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, + uint16_t slot); /** * Returns true if the sidecar table contains an entry for this packet's ID. - * Used by RadioLibInterface to gate the channel-active check on non-default-preset packets. + * Used by RadioLibInterface to gate the channel-active check. */ static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p); @@ -64,66 +49,55 @@ class MeshBeaconModule */ static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); - /** - * Parse and strip the #PPNN command prefix from an incoming text payload. - * Returns target radio settings derived from the prefix (or defaults if no valid prefix). - * Mutates p->decoded.payload in-place: strips the prefix, leaving only the message body. - * - * TODO(beacon): Remove entirely - beacon payloads will be structured MeshBeacon protobufs, - * not freeform text with embedded routing directives. Radio target comes from config, not payload. - */ - MeshBeaconModule_TXSettings stripTargetRadioSettings(meshtastic_MeshPacket *p); + protected: + static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; + static uint16_t originalLoraChannel; + static char originalChannelName[12]; // matches ChannelSettings.name max_size }; /** - * Periodic NodeInfo broadcaster for the beacon virtual node. - * - * TODO(beacon): Remove this class entirely. Beacons originate from the real node; there is - * no virtual nodenum. The periodic broadcast is replaced by MeshBeaconBroadcastModule (OSThread). + * Broadcaster: periodically sends MeshBeacon packets on the configured preset/channel. + * Active only when moduleConfig.mesh_beacon.broadcast_enabled is true. */ -class MeshBeaconNodeInfoModule : private MeshBeaconModule, public ProtobufModule, private concurrency::OSThread +class MeshBeaconBroadcastModule : private MeshBeaconModule, private concurrency::OSThread { public: - MeshBeaconNodeInfoModule(); + MeshBeaconBroadcastModule(); protected: - virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *p) override; - - void sendBeaconNodeInfo(); - virtual int32_t runOnce() override; + + private: + void sendBeacon(); }; -extern MeshBeaconNodeInfoModule *meshBeaconNodeInfoModule; +extern MeshBeaconBroadcastModule *meshBeaconBroadcastModule; /** - * Text message relay / listener for the beacon module. - * - * TODO(beacon): Split into two separate classes: - * - MeshBeaconBroadcastModule (OSThread) — periodically sends MeshBeacon protobuf packets - * on broadcast_on_preset / broadcast_on_channel with the configured broadcast_message, - * broadcast_offer_channel, and broadcast_offer_preset. Only active when beacon_broadcast=true. - * - MeshBeaconListenerModule (SinglePortModule on MESH_BEACON_APP) — receives MeshBeacon - * packets, delivers text to the text message inbox, and stores the offered channel/preset - * for retrieval by a client app. Only active when beacons_listen=true. - * Does NOT auto-apply offered channel or preset — client app must do this explicitly. - * - * TODO(beacon): wantPacket() currently filters on a "Beacon" named SECONDARY channel + - * TEXT_MESSAGE_APP. Replace with MESH_BEACON_APP portnum filter (no special channel needed). - * - * TODO(beacon): handleReceived() currently relays incoming messages from another node. - * Replace with originating logic: read broadcast_message from config, build MeshBeacon - * proto, set target radio settings from broadcast_on_preset, send periodically. + * Listener: receives MESH_BEACON_APP packets, delivers text to the local inbox, + * and caches any offered channel/preset for the client app to retrieve. + * Does NOT auto-apply offered settings — client app must do so explicitly. + * Active only when moduleConfig.mesh_beacon.listen_enabled is true. */ -class MeshBeaconMessageModule : private MeshBeaconModule, - public SinglePortModule, - public Observable +class MeshBeaconListenerModule : public ProtobufModule, public Observable { public: - MeshBeaconMessageModule() : MeshBeaconModule(), SinglePortModule("beacon", meshtastic_PortNum_TEXT_MESSAGE_APP) {} + MeshBeaconListenerModule(); - protected: - virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; + struct BeaconOffer { + bool valid; + NodeNum sender; + bool has_channel; + meshtastic_ChannelSettings channel; + meshtastic_Config_LoRaConfig_RegionCode region; + meshtastic_Config_LoRaConfig_ModemPreset preset; + uint32_t received_at; + }; + // Last received offer — accessible to admin/API for client app retrieval. + static BeaconOffer lastReceivedOffer; + + protected: + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) override; virtual bool wantPacket(const meshtastic_MeshPacket *p) override; }; -extern MeshBeaconMessageModule *meshBeaconMessageModule; +extern MeshBeaconListenerModule *meshBeaconListenerModule; diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index d88edd062fa..26f288a9144 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -155,8 +155,8 @@ void setupModules() meshTipsMessageModule = new MeshTipsMessageModule(); #endif #if !MESHTASTIC_EXCLUDE_BEACON - meshBeaconNodeInfoModule = new MeshBeaconNodeInfoModule(); - meshBeaconMessageModule = new MeshBeaconMessageModule(); + meshBeaconBroadcastModule = new MeshBeaconBroadcastModule(); + meshBeaconListenerModule = new MeshBeaconListenerModule(); #endif #if !MESHTASTIC_EXCLUDE_GPS positionModule = new PositionModule(); From a05d38a10fd5a52a248955861e6ca543df1b8690 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 2 Jun 2026 22:08:09 +0100 Subject: [PATCH 05/24] feat(beacon): wire RadioLibInterface hooks + admin validation (phases 6-7) --- src/mesh/RadioLibInterface.cpp | 16 ++++++++++++++++ src/modules/AdminModule.cpp | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index b1efef352af..3d719fe4a29 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -11,6 +11,9 @@ #if !MESHTASTIC_EXCLUDE_TIPS #include "modules/MeshTipsModule.h" #endif +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif #include #include @@ -371,6 +374,9 @@ void RadioLibInterface::onNotify(uint32_t notification) handleTransmitInterrupt(); #if !MESHTASTIC_EXCLUDE_TIPS MeshTipsModule::configureRadioForPacket(this, txQueue.getFront()); +#endif +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::reconfigureForBeaconTX(this, txQueue.getFront()); #endif startReceive(); setTransmitDelay(); @@ -398,11 +404,17 @@ void RadioLibInterface::onNotify(uint32_t notification) } else if (MeshTipsModule::configureRadioForPacket(this, txp)) { // We just switched radio config, so wait to ensure the new channel is available setTransmitDelay(); +#endif +#if !MESHTASTIC_EXCLUDE_BEACON + } else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) { + setTransmitDelay(); #endif } else { if (isChannelActive()) { // check if there is currently a LoRa packet on the channel #if !MESHTASTIC_EXCLUDE_TIPS if (!MeshTipsModule::hasTargetRadioSettings(txp)) +#elif !MESHTASTIC_EXCLUDE_BEACON + if (!MeshBeaconModule::hasTargetRadioSettings(txp)) #endif { startReceive(); // try receiving this packet, afterwards we'll be trying to transmit again @@ -541,6 +553,10 @@ void RadioLibInterface::completeSending() #if !MESHTASTIC_EXCLUDE_TIPS MeshTipsModule::clearTargetRadioSettings(p); #endif +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::clearTargetRadioSettings(p); + MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); +#endif // We are done sending that packet, release it packetPool.release(p); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index d79261d5dd9..cc40eec1d8b 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -34,6 +34,9 @@ #ifdef MESHTASTIC_ENCRYPTED_STORAGE #include "security/EncryptedStorage.h" #endif +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" @@ -309,6 +312,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta case meshtastic_AdminMessage_set_module_config_tag: LOG_DEBUG("Client set module config"); +#if !MESHTASTIC_EXCLUDE_BEACON + // broadcast_send_as_node: remote admins may only set this to their own node ID. + if (mp.from != 0 && r->set_module_config.which_payload_variant == meshtastic_ModuleConfig_mesh_beacon_tag) { + auto &b = const_cast(r->set_module_config.payload_variant.mesh_beacon); + if (b.broadcast_send_as_node != 0 && b.broadcast_send_as_node != mp.from) { + LOG_WARN("Beacon: rejecting broadcast_send_as_node %#08lx from node %#08lx (must match sender)", + b.broadcast_send_as_node, mp.from); + b.broadcast_send_as_node = moduleConfig.payload_variant.mesh_beacon.broadcast_send_as_node; + } + } +#endif if (!handleSetModuleConfig(r->set_module_config)) { myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); } @@ -1146,6 +1160,24 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) moduleConfig.has_traffic_management = true; moduleConfig.traffic_management = c.payload_variant.traffic_management; break; +#if !MESHTASTIC_EXCLUDE_BEACON + case meshtastic_ModuleConfig_mesh_beacon_tag: { + LOG_INFO("Set module config: MeshBeacon"); + auto &b = const_cast(c.payload_variant.mesh_beacon); + // Enforce message length limit. + if (strnlen(b.broadcast_message, sizeof(b.broadcast_message)) >= 100) + b.broadcast_message[99] = '\0'; + // Enforce interval bounds (0 means unset/use default). + if (b.broadcast_interval_secs != 0 && b.broadcast_interval_secs < 3600) + b.broadcast_interval_secs = 3600; + if (b.broadcast_interval_secs > 259200) + b.broadcast_interval_secs = 259200; + moduleConfig.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag; + moduleConfig.payload_variant.mesh_beacon = b; + shouldReboot = false; + break; + } +#endif } saveChanges(SEGMENT_MODULECONFIG, shouldReboot); return true; From 98e068bc611db7451c35d373e9861e15b0f9a962 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 2 Jun 2026 22:13:13 +0100 Subject: [PATCH 06/24] fix(beacon): fix LocalModuleConfig flat access (no payload_variant), add localonly proto field --- src/mesh/generated/meshtastic/localonly.pb.h | 14 ++++++++++---- src/modules/AdminModule.cpp | 6 +++--- src/modules/MeshBeaconModule.cpp | 12 ++++++------ 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 27f5ad7bfdf..7455e1e9d05 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -96,6 +96,9 @@ typedef struct _meshtastic_LocalModuleConfig { /* TAK Config */ bool has_tak; meshtastic_ModuleConfig_TAKConfig tak; + /* MeshBeacon Config */ + bool has_mesh_beacon; + meshtastic_ModuleConfig_MeshBeaconConfig mesh_beacon; } meshtastic_LocalModuleConfig; @@ -105,9 +108,9 @@ extern "C" { /* Initializer values for message structs */ #define meshtastic_LocalConfig_init_default {false, meshtastic_Config_DeviceConfig_init_default, false, meshtastic_Config_PositionConfig_init_default, false, meshtastic_Config_PowerConfig_init_default, false, meshtastic_Config_NetworkConfig_init_default, false, meshtastic_Config_DisplayConfig_init_default, false, meshtastic_Config_LoRaConfig_init_default, false, meshtastic_Config_BluetoothConfig_init_default, 0, false, meshtastic_Config_SecurityConfig_init_default} -#define meshtastic_LocalModuleConfig_init_default {false, meshtastic_ModuleConfig_MQTTConfig_init_default, false, meshtastic_ModuleConfig_SerialConfig_init_default, false, meshtastic_ModuleConfig_ExternalNotificationConfig_init_default, false, meshtastic_ModuleConfig_StoreForwardConfig_init_default, false, meshtastic_ModuleConfig_RangeTestConfig_init_default, false, meshtastic_ModuleConfig_TelemetryConfig_init_default, false, meshtastic_ModuleConfig_CannedMessageConfig_init_default, 0, false, meshtastic_ModuleConfig_AudioConfig_init_default, false, meshtastic_ModuleConfig_RemoteHardwareConfig_init_default, false, meshtastic_ModuleConfig_NeighborInfoConfig_init_default, false, meshtastic_ModuleConfig_AmbientLightingConfig_init_default, false, meshtastic_ModuleConfig_DetectionSensorConfig_init_default, false, meshtastic_ModuleConfig_PaxcounterConfig_init_default, false, meshtastic_ModuleConfig_StatusMessageConfig_init_default, false, meshtastic_ModuleConfig_TrafficManagementConfig_init_default, false, meshtastic_ModuleConfig_TAKConfig_init_default} +#define meshtastic_LocalModuleConfig_init_default {false, meshtastic_ModuleConfig_MQTTConfig_init_default, false, meshtastic_ModuleConfig_SerialConfig_init_default, false, meshtastic_ModuleConfig_ExternalNotificationConfig_init_default, false, meshtastic_ModuleConfig_StoreForwardConfig_init_default, false, meshtastic_ModuleConfig_RangeTestConfig_init_default, false, meshtastic_ModuleConfig_TelemetryConfig_init_default, false, meshtastic_ModuleConfig_CannedMessageConfig_init_default, 0, false, meshtastic_ModuleConfig_AudioConfig_init_default, false, meshtastic_ModuleConfig_RemoteHardwareConfig_init_default, false, meshtastic_ModuleConfig_NeighborInfoConfig_init_default, false, meshtastic_ModuleConfig_AmbientLightingConfig_init_default, false, meshtastic_ModuleConfig_DetectionSensorConfig_init_default, false, meshtastic_ModuleConfig_PaxcounterConfig_init_default, false, meshtastic_ModuleConfig_StatusMessageConfig_init_default, false, meshtastic_ModuleConfig_TrafficManagementConfig_init_default, false, meshtastic_ModuleConfig_TAKConfig_init_default, false, meshtastic_ModuleConfig_MeshBeaconConfig_init_default} #define meshtastic_LocalConfig_init_zero {false, meshtastic_Config_DeviceConfig_init_zero, false, meshtastic_Config_PositionConfig_init_zero, false, meshtastic_Config_PowerConfig_init_zero, false, meshtastic_Config_NetworkConfig_init_zero, false, meshtastic_Config_DisplayConfig_init_zero, false, meshtastic_Config_LoRaConfig_init_zero, false, meshtastic_Config_BluetoothConfig_init_zero, 0, false, meshtastic_Config_SecurityConfig_init_zero} -#define meshtastic_LocalModuleConfig_init_zero {false, meshtastic_ModuleConfig_MQTTConfig_init_zero, false, meshtastic_ModuleConfig_SerialConfig_init_zero, false, meshtastic_ModuleConfig_ExternalNotificationConfig_init_zero, false, meshtastic_ModuleConfig_StoreForwardConfig_init_zero, false, meshtastic_ModuleConfig_RangeTestConfig_init_zero, false, meshtastic_ModuleConfig_TelemetryConfig_init_zero, false, meshtastic_ModuleConfig_CannedMessageConfig_init_zero, 0, false, meshtastic_ModuleConfig_AudioConfig_init_zero, false, meshtastic_ModuleConfig_RemoteHardwareConfig_init_zero, false, meshtastic_ModuleConfig_NeighborInfoConfig_init_zero, false, meshtastic_ModuleConfig_AmbientLightingConfig_init_zero, false, meshtastic_ModuleConfig_DetectionSensorConfig_init_zero, false, meshtastic_ModuleConfig_PaxcounterConfig_init_zero, false, meshtastic_ModuleConfig_StatusMessageConfig_init_zero, false, meshtastic_ModuleConfig_TrafficManagementConfig_init_zero, false, meshtastic_ModuleConfig_TAKConfig_init_zero} +#define meshtastic_LocalModuleConfig_init_zero {false, meshtastic_ModuleConfig_MQTTConfig_init_zero, false, meshtastic_ModuleConfig_SerialConfig_init_zero, false, meshtastic_ModuleConfig_ExternalNotificationConfig_init_zero, false, meshtastic_ModuleConfig_StoreForwardConfig_init_zero, false, meshtastic_ModuleConfig_RangeTestConfig_init_zero, false, meshtastic_ModuleConfig_TelemetryConfig_init_zero, false, meshtastic_ModuleConfig_CannedMessageConfig_init_zero, 0, false, meshtastic_ModuleConfig_AudioConfig_init_zero, false, meshtastic_ModuleConfig_RemoteHardwareConfig_init_zero, false, meshtastic_ModuleConfig_NeighborInfoConfig_init_zero, false, meshtastic_ModuleConfig_AmbientLightingConfig_init_zero, false, meshtastic_ModuleConfig_DetectionSensorConfig_init_zero, false, meshtastic_ModuleConfig_PaxcounterConfig_init_zero, false, meshtastic_ModuleConfig_StatusMessageConfig_init_zero, false, meshtastic_ModuleConfig_TrafficManagementConfig_init_zero, false, meshtastic_ModuleConfig_TAKConfig_init_zero, false, meshtastic_ModuleConfig_MeshBeaconConfig_init_zero} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_LocalConfig_device_tag 1 @@ -136,6 +139,7 @@ extern "C" { #define meshtastic_LocalModuleConfig_statusmessage_tag 15 #define meshtastic_LocalModuleConfig_traffic_management_tag 16 #define meshtastic_LocalModuleConfig_tak_tag 17 +#define meshtastic_LocalModuleConfig_mesh_beacon_tag 18 /* Struct field encoding specification for nanopb */ #define meshtastic_LocalConfig_FIELDLIST(X, a) \ @@ -176,7 +180,8 @@ X(a, STATIC, OPTIONAL, MESSAGE, detection_sensor, 13) \ X(a, STATIC, OPTIONAL, MESSAGE, paxcounter, 14) \ X(a, STATIC, OPTIONAL, MESSAGE, statusmessage, 15) \ X(a, STATIC, OPTIONAL, MESSAGE, traffic_management, 16) \ -X(a, STATIC, OPTIONAL, MESSAGE, tak, 17) +X(a, STATIC, OPTIONAL, MESSAGE, tak, 17) \ +X(a, STATIC, OPTIONAL, MESSAGE, mesh_beacon, 18) #define meshtastic_LocalModuleConfig_CALLBACK NULL #define meshtastic_LocalModuleConfig_DEFAULT NULL #define meshtastic_LocalModuleConfig_mqtt_MSGTYPE meshtastic_ModuleConfig_MQTTConfig @@ -195,6 +200,7 @@ X(a, STATIC, OPTIONAL, MESSAGE, tak, 17) #define meshtastic_LocalModuleConfig_statusmessage_MSGTYPE meshtastic_ModuleConfig_StatusMessageConfig #define meshtastic_LocalModuleConfig_traffic_management_MSGTYPE meshtastic_ModuleConfig_TrafficManagementConfig #define meshtastic_LocalModuleConfig_tak_MSGTYPE meshtastic_ModuleConfig_TAKConfig +#define meshtastic_LocalModuleConfig_mesh_beacon_MSGTYPE meshtastic_ModuleConfig_MeshBeaconConfig extern const pb_msgdesc_t meshtastic_LocalConfig_msg; extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; @@ -206,7 +212,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size #define meshtastic_LocalConfig_size 757 -#define meshtastic_LocalModuleConfig_size 820 +#define meshtastic_LocalModuleConfig_size 1098 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index cc40eec1d8b..187c3fa7b50 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -319,7 +319,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta if (b.broadcast_send_as_node != 0 && b.broadcast_send_as_node != mp.from) { LOG_WARN("Beacon: rejecting broadcast_send_as_node %#08lx from node %#08lx (must match sender)", b.broadcast_send_as_node, mp.from); - b.broadcast_send_as_node = moduleConfig.payload_variant.mesh_beacon.broadcast_send_as_node; + b.broadcast_send_as_node = moduleConfig.mesh_beacon.broadcast_send_as_node; } } #endif @@ -1172,8 +1172,8 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) b.broadcast_interval_secs = 3600; if (b.broadcast_interval_secs > 259200) b.broadcast_interval_secs = 259200; - moduleConfig.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag; - moduleConfig.payload_variant.mesh_beacon = b; + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon = b; shouldReboot = false; break; } diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index fc33db6e8d9..d4bf421739c 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -97,7 +97,7 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ config.lora.channel_num = targetSlot; memset(c->name, 0, sizeof(c->name)); - const auto &bcfg = moduleConfig.payload_variant.mesh_beacon; + const auto &bcfg = moduleConfig.mesh_beacon; if (bcfg.has_broadcast_on_channel && strlen(bcfg.broadcast_on_channel.name) > 0) { strncpy(c->name, bcfg.broadcast_on_channel.name, sizeof(c->name)); } else { @@ -165,7 +165,7 @@ MeshBeaconBroadcastModule::MeshBeaconBroadcastModule() : MeshBeaconModule(), con void MeshBeaconBroadcastModule::sendBeacon() { - const auto &bcfg = moduleConfig.payload_variant.mesh_beacon; + const auto &bcfg = moduleConfig.mesh_beacon; meshtastic_MeshBeacon beacon = meshtastic_MeshBeacon_init_zero; strncpy(beacon.message, bcfg.broadcast_message, sizeof(beacon.message) - 1); @@ -191,7 +191,7 @@ void MeshBeaconBroadcastModule::sendBeacon() p->want_ack = false; p->rx_time = getValidTime(RTCQualityFromNet); - if (bcfg.has_broadcast_on_preset && + if (bcfg.broadcast_on_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN && (bcfg.broadcast_on_preset != config.lora.modem_preset || (bcfg.has_broadcast_on_channel && bcfg.broadcast_on_channel.channel_num != config.lora.channel_num))) { uint16_t targetSlot = bcfg.has_broadcast_on_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num; @@ -204,7 +204,7 @@ void MeshBeaconBroadcastModule::sendBeacon() int32_t MeshBeaconBroadcastModule::runOnce() { - const auto &bcfg = moduleConfig.payload_variant.mesh_beacon; + const auto &bcfg = moduleConfig.mesh_beacon; if (bcfg.broadcast_enabled && airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { sendBeacon(); @@ -233,8 +233,8 @@ MeshBeaconListenerModule::MeshBeaconListenerModule() bool MeshBeaconListenerModule::wantPacket(const meshtastic_MeshPacket *p) { - return moduleConfig.which_payload_variant == meshtastic_ModuleConfig_mesh_beacon_tag && - moduleConfig.payload_variant.mesh_beacon.listen_enabled && p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP; + return moduleConfig.has_mesh_beacon && moduleConfig.mesh_beacon.listen_enabled && + p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP; } bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) From 66c089c6d1cba43c308be2ce21820f91349e88e1 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Tue, 2 Jun 2026 22:30:40 +0100 Subject: [PATCH 07/24] feat(beacon): fix broadcaster inheritance, add preset/region validation + proto cache - MeshBeaconBroadcastModule now inherits ProtobufModule (alongside private MeshBeaconModule + OSThread), giving it allocDataPacket() and setStartDelay() without extra includes. - Payload cache: rebuildCache() encodes the MeshBeacon protobuf once and stores it in payloadCache[]/payloadCacheSize; sendBeacon() only calls rebuildCache() when payloadCacheDirty==true. AdminModule calls invalidateCache() after saving new config so the next broadcast picks up changes. - Region/preset validation in handleSetModuleConfig (mesh_beacon_tag): broadcast_on_preset is validated against the device's current region via RadioInterface::validateConfigLora(); broadcast_offer_region is validated via RadioInterface::validateConfigRegion(). Invalid values are zeroed with a LOG_WARN before saving. --- src/modules/AdminModule.cpp | 23 +++++++++++++++++++++++ src/modules/MeshBeaconModule.cpp | 28 ++++++++++++++++++++-------- src/modules/MeshBeaconModule.h | 15 ++++++++++++++- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 187c3fa7b50..a5dc2d31583 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1172,9 +1172,32 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) b.broadcast_interval_secs = 3600; if (b.broadcast_interval_secs > 259200) b.broadcast_interval_secs = 259200; + // Validate broadcast_on_preset against the device's current region. + if (b.broadcast_on_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) { + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.modem_preset = b.broadcast_on_preset; + if (!RadioInterface::validateConfigLora(probe)) { + LOG_WARN("Beacon: broadcast_on_preset %d invalid for region, clearing", b.broadcast_on_preset); + b.broadcast_on_preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; + b.has_broadcast_on_channel = false; + } + } + // Validate broadcast_offer_region is a known region code. + if (b.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { + meshtastic_Config_LoRaConfig probe = config.lora; + probe.region = b.broadcast_offer_region; + if (!RadioInterface::validateConfigRegion(probe)) { + LOG_WARN("Beacon: broadcast_offer_region %d invalid, clearing", b.broadcast_offer_region); + b.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; + } + } moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon = b; shouldReboot = false; + // Payload content changed — invalidate the broadcaster's cache. + if (meshBeaconBroadcastModule) + meshBeaconBroadcastModule->invalidateCache(); break; } #endif diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index d4bf421739c..020c5801584 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -158,34 +158,46 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ MeshBeaconBroadcastModule *meshBeaconBroadcastModule; -MeshBeaconBroadcastModule::MeshBeaconBroadcastModule() : MeshBeaconModule(), concurrency::OSThread("MeshBeaconBroadcast") +MeshBeaconBroadcastModule::MeshBeaconBroadcastModule() + : MeshBeaconModule(), ProtobufModule("beacon_tx", meshtastic_PortNum_MESH_BEACON_APP, &meshtastic_MeshBeacon_msg), + concurrency::OSThread("MeshBeaconBroadcast") { setIntervalFromNow(setStartDelay()); } -void MeshBeaconBroadcastModule::sendBeacon() +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; } beacon.offer_preset = bcfg.broadcast_offer_preset; beacon.offer_region = bcfg.broadcast_offer_region; + payloadCacheSize = (pb_size_t)pb_encode_to_bytes(payloadCache, sizeof(payloadCache), &meshtastic_MeshBeacon_msg, &beacon); + payloadCacheDirty = false; + LOG_DEBUG("Beacon: payload cache rebuilt (%u bytes)", payloadCacheSize); +} + +void MeshBeaconBroadcastModule::sendBeacon() +{ + const auto &bcfg = moduleConfig.mesh_beacon; + + if (payloadCacheDirty) + rebuildCache(); - meshtastic_MeshPacket *p = allocDataProtobuf(beacon); + meshtastic_MeshPacket *p = allocDataPacket(); if (!p) { LOG_WARN("Beacon: failed to allocate packet"); return; } - + memcpy(p->decoded.payload.bytes, payloadCache, payloadCacheSize); + p->decoded.payload.size = payloadCacheSize; + p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; p->to = NODENUM_BROADCAST; p->from = (bcfg.broadcast_send_as_node != 0) ? bcfg.broadcast_send_as_node : nodeDB->getNodeNum(); - p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; p->hop_limit = 3; p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; p->want_ack = false; @@ -198,7 +210,7 @@ void MeshBeaconBroadcastModule::sendBeacon() setTargetRadioSettings(p, bcfg.broadcast_on_preset, targetSlot); } - LOG_INFO("Beacon: broadcast from=%#08lx msg='%.40s'", p->from, beacon.message); + LOG_INFO("Beacon: broadcast from=%#08lx msg='%.40s'", p->from, bcfg.broadcast_message); service->sendToMesh(p, RX_SRC_LOCAL, false); } diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index f2d4ccb7f40..371a7b87219 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -1,4 +1,5 @@ #pragma once +#include "MeshRadio.h" #include "Observer.h" #include "ProtobufModule.h" #include "RadioInterface.h" @@ -58,17 +59,29 @@ class MeshBeaconModule /** * Broadcaster: periodically sends MeshBeacon packets on the configured preset/channel. * Active only when moduleConfig.mesh_beacon.broadcast_enabled is true. + * Inherits ProtobufModule to access allocDataProtobuf + setStartDelay. */ -class MeshBeaconBroadcastModule : private MeshBeaconModule, private concurrency::OSThread +class MeshBeaconBroadcastModule : private MeshBeaconModule, + public ProtobufModule, + private concurrency::OSThread { public: MeshBeaconBroadcastModule(); + // Mark the cached payload dirty (call after config change). + void invalidateCache() { payloadCacheDirty = true; } + protected: + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &, meshtastic_MeshBeacon *) override { return false; } virtual int32_t runOnce() override; private: void sendBeacon(); + void rebuildCache(); + + bool payloadCacheDirty = true; + uint8_t payloadCache[meshtastic_MeshBeacon_size]; + pb_size_t payloadCacheSize = 0; }; extern MeshBeaconBroadcastModule *meshBeaconBroadcastModule; From e0a369da7548c29346f0e81cd73bd61df4ef52d7 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 01:15:13 +0100 Subject: [PATCH 08/24] feat(beacon): add unit tests for MeshBeaconModule and AdminModule configuration validation --- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/modules/AdminModule.cpp | 5 +- src/modules/AdminModule.h | 4 + src/modules/MeshBeaconModule.cpp | 3 +- src/modules/MeshBeaconModule.h | 2 +- test/test_mesh_beacon/test_main.cpp | 761 ++++++++++++++++++ 6 files changed, 771 insertions(+), 6 deletions(-) create mode 100644 test/test_mesh_beacon/test_main.cpp diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 474b332a8da..ae05fd406a6 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -452,7 +452,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2432 +#define meshtastic_BackupPreferences_size 2710 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index a5dc2d31583..e32cc748bc8 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1185,9 +1185,8 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) } // Validate broadcast_offer_region is a known region code. if (b.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { - meshtastic_Config_LoRaConfig probe = config.lora; - probe.region = b.broadcast_offer_region; - if (!RadioInterface::validateConfigRegion(probe)) { + const RegionInfo *r = getRegion(b.broadcast_offer_region); + if (r->code != b.broadcast_offer_region) { LOG_WARN("Beacon: broadcast_offer_region %d invalid, clearing", b.broadcast_offer_region); b.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET; } diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index aeb41471c05..864b845c421 100644 --- a/src/modules/AdminModule.h +++ b/src/modules/AdminModule.h @@ -64,7 +64,11 @@ class AdminModule : public ProtobufModule, public Obser protected: void handleSetConfig(const meshtastic_Config &c, bool fromOthers); +#ifdef PIO_UNIT_TESTING + protected: +#else private: +#endif bool handleSetModuleConfig(const meshtastic_ModuleConfig &c); void handleSetChannel(); diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 020c5801584..cf15a0dfe75 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -4,6 +4,7 @@ #include "NodeDB.h" #include "RTC.h" #include "RadioInterface.h" +#include "Router.h" #include "configuration.h" #include "main.h" #include @@ -211,7 +212,7 @@ void MeshBeaconBroadcastModule::sendBeacon() } LOG_INFO("Beacon: broadcast from=%#08lx msg='%.40s'", p->from, bcfg.broadcast_message); - service->sendToMesh(p, RX_SRC_LOCAL, false); + router->send(p); } int32_t MeshBeaconBroadcastModule::runOnce() diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index 371a7b87219..4077e802d5a 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -75,7 +75,7 @@ class MeshBeaconBroadcastModule : private MeshBeaconModule, virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &, meshtastic_MeshBeacon *) override { return false; } virtual int32_t runOnce() override; - private: + protected: void sendBeacon(); void rebuildCache(); diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp new file mode 100644 index 00000000000..68f5eb7795e --- /dev/null +++ b/test/test_mesh_beacon/test_main.cpp @@ -0,0 +1,761 @@ +/** + * Unit tests for MeshBeaconModule: + * - AdminModule::handleSetModuleConfig validation (invalid/valid inputs) + * - MeshBeaconBroadcastModule payload cache lifecycle + * - MeshBeaconBroadcastModule::sendBeacon sends a correctly formed packet + * - MeshBeaconListenerModule offer caching and empty-message guard + */ + +#include "TestUtil.h" +#include + +#if defined(ARCH_PORTDUINO) +#define BEACON_TEST_ENTRY extern "C" +#else +#define BEACON_TEST_ENTRY +#endif + +#if !MESHTASTIC_EXCLUDE_BEACON + +#include "MeshRadio.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "RadioInterface.h" +#include "airtime.h" +#include "modules/AdminModule.h" +#include "modules/MeshBeaconModule.h" +#include +#include +#include +#include + +namespace +{ + +constexpr NodeNum kLocalNode = 0xAAAA0001; +constexpr NodeNum kRemoteNode = 0xBBBB0002; + +// --------------------------------------------------------------------------- +// Minimal MockMeshService — stubs out side-effecting virtuals. +// handleToRadio is non-virtual so it runs the real implementation; we guard +// against the router->sendLocal path by setting a MockRouter below. +// --------------------------------------------------------------------------- +class MockMeshService : public MeshService +{ + public: + void sendClientNotification(meshtastic_ClientNotification *n) override { releaseClientNotificationToPool(n); } +}; + +// --------------------------------------------------------------------------- +// MockRouter: captures every packet handed to send() instead of transmitting. +// --------------------------------------------------------------------------- +class MockRouter : public Router +{ + public: + ~MockRouter() + { + delete cryptLock; + cryptLock = nullptr; + } + + ErrorCode send(meshtastic_MeshPacket *p) override + { + sentPackets.push_back(*p); + packetPool.release(p); + return ERRNO_OK; + } + + std::vector sentPackets; +}; + +// --------------------------------------------------------------------------- +// AdminModuleTestShim — exposes protected handleSetModuleConfig. +// --------------------------------------------------------------------------- +class AdminModuleTestShim : public AdminModule +{ + public: + using AdminModule::handleSetModuleConfig; +}; + +// --------------------------------------------------------------------------- +// MeshBeaconBroadcastModuleTestShim — exposes private internals for testing. +// --------------------------------------------------------------------------- +class MeshBeaconBroadcastModuleTestShim : public MeshBeaconBroadcastModule +{ + public: + using MeshBeaconBroadcastModule::payloadCache; + using MeshBeaconBroadcastModule::payloadCacheDirty; + using MeshBeaconBroadcastModule::payloadCacheSize; + using MeshBeaconBroadcastModule::rebuildCache; + using MeshBeaconBroadcastModule::runOnce; + using MeshBeaconBroadcastModule::sendBeacon; +}; + +// --------------------------------------------------------------------------- +// MeshBeaconListenerModuleTestShim — exposes handleReceivedProtobuf. +// --------------------------------------------------------------------------- +class MeshBeaconListenerModuleTestShim : public MeshBeaconListenerModule +{ + public: + using MeshBeaconListenerModule::handleReceivedProtobuf; + using MeshBeaconListenerModule::wantPacket; +}; + +// --------------------------------------------------------------------------- +// Globals managed by setUp / tearDown. +// --------------------------------------------------------------------------- +static MockMeshService *mockSvc = nullptr; +static MockRouter *mockRouter = nullptr; +static AdminModuleTestShim *testAdmin = nullptr; +static AirTime *testAirTime = nullptr; + +// --------------------------------------------------------------------------- +// Helper: build a ModuleConfig wrapper for the beacon case (mirrors the wire +// format used by set_module_config admin messages). +// --------------------------------------------------------------------------- +static meshtastic_ModuleConfig makeBeaconModuleConfig(meshtastic_ModuleConfig_MeshBeaconConfig bcfg) +{ + meshtastic_ModuleConfig mc = meshtastic_ModuleConfig_init_zero; + mc.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag; + mc.payload_variant.mesh_beacon = bcfg; + return mc; +} + +// --------------------------------------------------------------------------- +// Helper: reset module/device config to a known baseline. +// --------------------------------------------------------------------------- +static void resetConfig() +{ + moduleConfig = meshtastic_LocalModuleConfig_init_zero; + config = meshtastic_LocalConfig_init_zero; + + // Device is an EU_868 node with LONG_FAST — the starting point. + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_EU_868; + config.lora.use_preset = true; + config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + // Allow TX unconditionally so airtime checks don't block sendBeacon(). + config.lora.override_duty_cycle = true; + config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; + + myNodeInfo.my_node_num = kLocalNode; + + initRegion(); +} + +// =========================================================================== +// Group 1: AdminModule config validation — bad inputs must be sanitised +// =========================================================================== + +// broadcast_on_preset=SHORT_TURBO is not in EU_868's preset list → zeroed out. +static void test_adminValidation_turboPresetOnEU868_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_enabled = true; + bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); + TEST_ASSERT_EQUAL_MESSAGE(_meshtastic_Config_LoRaConfig_ModemPreset_MIN, moduleConfig.mesh_beacon.broadcast_on_preset, + "SHORT_TURBO must be cleared for EU_868"); +} + +// Same check for LONG_TURBO. +static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL(_meshtastic_Config_LoRaConfig_ModemPreset_MIN, moduleConfig.mesh_beacon.broadcast_on_preset); +} + +// A turbo preset is valid on US (which uses PROFILE_STD) → must be kept. +static void test_adminValidation_turboPresetOnUS_isAccepted(void) +{ + resetConfig(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + initRegion(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); +} + +// broadcast_offer_region with an unknown code (255) → cleared to UNSET. +static void test_adminValidation_unknownOfferRegion_isCleared(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_offer_region = (meshtastic_Config_LoRaConfig_RegionCode)255; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, moduleConfig.mesh_beacon.broadcast_offer_region); +} + +// A valid broadcast_offer_region (US) → preserved as-is. +static void test_adminValidation_validOfferRegion_isPreserved(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_offer_region); +} + +// broadcast_message exactly 100 chars (i.e. length 100 including terminator +// means the last byte is at index 99) → last char must be NUL after truncation. +static void test_adminValidation_messageTooLong_isTruncatedAt99(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + // Fill with 'A' up to the full array size; admin must enforce ≤99 chars. + memset(bcfg.broadcast_message, 'A', sizeof(bcfg.broadcast_message)); + bcfg.broadcast_message[sizeof(bcfg.broadcast_message) - 1] = '\0'; // pb_decode guarantee + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + // Byte at index 99 must be NUL (length capped at 99). + TEST_ASSERT_EQUAL('\0', moduleConfig.mesh_beacon.broadcast_message[99]); + // Bytes before it should still be 'A'. + TEST_ASSERT_EQUAL('A', moduleConfig.mesh_beacon.broadcast_message[0]); +} + +// broadcast_interval_secs below minimum → clamped up to 3600. +static void test_adminValidation_intervalTooLow_isClamped(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_interval_secs = 60; // way below minimum + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL_UINT32(3600, moduleConfig.mesh_beacon.broadcast_interval_secs); +} + +// broadcast_interval_secs above maximum → clamped down to 259200. +static void test_adminValidation_intervalTooHigh_isClamped(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_interval_secs = 999999; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL_UINT32(259200, moduleConfig.mesh_beacon.broadcast_interval_secs); +} + +// Zero interval (unset) is special-cased and must not be clamped. +static void test_adminValidation_intervalZero_isNotClamped(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_interval_secs = 0; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL_UINT32(0, moduleConfig.mesh_beacon.broadcast_interval_secs); +} + +// After a successful save the broadcaster's cache must be invalidated. +static void test_adminValidation_validSave_invalidatesCache(void) +{ + resetConfig(); + + // Prime the broadcaster with a clean state so the dirty flag is known. + std::unique_ptr bcast(new MeshBeaconBroadcastModuleTestShim()); + meshBeaconBroadcastModule = bcast.get(); + bcast->payloadCacheDirty = false; // pretend it was freshly built + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_enabled = true; + strncpy(bcfg.broadcast_message, "hello", sizeof(bcfg.broadcast_message) - 1); + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE_MESSAGE(bcast->payloadCacheDirty, "Config save must mark payload cache dirty"); + + meshBeaconBroadcastModule = nullptr; +} + +// =========================================================================== +// Group 2: Broadcaster payload cache +// =========================================================================== + +// rebuildCache encodes a non-empty payload when message is set. +static void test_broadcaster_rebuildCache_producesNonEmptyPayload(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "Test beacon", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + TEST_ASSERT_TRUE(bcast.payloadCacheDirty); + + bcast.rebuildCache(); + + TEST_ASSERT_FALSE_MESSAGE(bcast.payloadCacheDirty, "rebuildCache must clear dirty flag"); + TEST_ASSERT_GREATER_THAN_MESSAGE(0, (int)bcast.payloadCacheSize, "rebuildCache must produce a non-empty payload"); +} + +// The encoded bytes decode back to the same message field. +static void test_broadcaster_rebuildCache_payloadDecodesCorrectly(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + const char *msg = "Hello, Meshtastic!"; + strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + + // Decode the cached bytes back into a MeshBeacon struct. + meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; + pb_istream_t stream = pb_istream_from_buffer(bcast.payloadCache, bcast.payloadCacheSize); + bool ok = pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); + + TEST_ASSERT_TRUE_MESSAGE(ok, "Cached payload must decode without error"); + TEST_ASSERT_EQUAL_STRING(msg, decoded.message); +} + +// Offer fields round-trip through the cache. +static void test_broadcaster_rebuildCache_offerFieldsEncoded(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "offer-test", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + + meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; + pb_istream_t stream = pb_istream_from_buffer(bcast.payloadCache, bcast.payloadCacheSize); + pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); + + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, decoded.offer_region); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, decoded.offer_preset); +} + +// invalidateCache sets payloadCacheDirty. +static void test_broadcaster_invalidateCache_setsDirtyFlag(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + TEST_ASSERT_FALSE(bcast.payloadCacheDirty); + + bcast.invalidateCache(); + TEST_ASSERT_TRUE(bcast.payloadCacheDirty); +} + +// Calling rebuildCache twice without invalidation must leave dirty=false. +static void test_broadcaster_rebuildCache_idempotent(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "idem", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + pb_size_t firstSize = bcast.payloadCacheSize; + bcast.rebuildCache(); // second call — should be identical + pb_size_t secondSize = bcast.payloadCacheSize; + + TEST_ASSERT_FALSE(bcast.payloadCacheDirty); + TEST_ASSERT_EQUAL(firstSize, secondSize); +} + +// =========================================================================== +// Group 3: Broadcaster sendBeacon — packet structure +// =========================================================================== + +// sendBeacon with broadcast_send_as_node=0 uses the local node number. +static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_enabled = true; + moduleConfig.mesh_beacon.broadcast_send_as_node = 0; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-local", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); +} + +// sendBeacon respects a custom broadcast_send_as_node. +static void test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_enabled = true; + moduleConfig.mesh_beacon.broadcast_send_as_node = kRemoteNode; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "from-remote", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(kRemoteNode, mockRouter->sentPackets[0].from); +} + +// sendBeacon must address the packet to NODENUM_BROADCAST. +static void test_broadcaster_sendBeacon_addressedToBroadcast(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "bcast-addr", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL_UINT32(NODENUM_BROADCAST, mockRouter->sentPackets[0].to); +} + +// sendBeacon uses MESH_BEACON_APP portnum. +static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "portnum-check", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); +} + +// sendBeacon payload decodes back to the correct message string. +static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + const char *msg = "Greetings from the beacon"; + strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + const meshtastic_MeshPacket &p = mockRouter->sentPackets[0]; + meshtastic_MeshBeacon decoded = meshtastic_MeshBeacon_init_zero; + pb_istream_t stream = pb_istream_from_buffer(p.decoded.payload.bytes, p.decoded.payload.size); + bool ok = pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decoded); + + TEST_ASSERT_TRUE_MESSAGE(ok, "Sent payload must decode without error"); + TEST_ASSERT_EQUAL_STRING(msg, decoded.message); +} + +// runOnce with broadcast_enabled=true must send exactly one packet. +static void test_broadcaster_runOnce_sendsWhenEnabled(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_enabled = true; + moduleConfig.mesh_beacon.broadcast_interval_secs = 3600; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "runOnce-enabled", + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.runOnce(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); +} + +// runOnce with broadcast_enabled=false must not send anything. +static void test_broadcaster_runOnce_silentWhenDisabled(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_enabled = false; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "runOnce-disabled", + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.runOnce(); + + TEST_ASSERT_EQUAL_UINT32(0, mockRouter->sentPackets.size()); +} + +// =========================================================================== +// Group 4: Listener — offer caching and guards +// =========================================================================== + +// Helper: build a decoded MESH_BEACON_APP packet carrying the given MeshBeacon. +static meshtastic_MeshPacket makeBeaconPacket(const meshtastic_MeshBeacon &b, NodeNum from = kRemoteNode) +{ + meshtastic_MeshPacket p = meshtastic_MeshPacket_init_zero; + p.from = from; + p.to = NODENUM_BROADCAST; + p.id = 0xDEAD0001; + p.which_payload_variant = meshtastic_MeshPacket_decoded_tag; + p.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + p.decoded.payload.size = + (pb_size_t)pb_encode_to_bytes(p.decoded.payload.bytes, sizeof(p.decoded.payload.bytes), &meshtastic_MeshBeacon_msg, &b); + return p; +} + +// Receiving a beacon with an offer stores it in lastReceivedOffer. +static void test_listener_receiveWithOffer_cachesOffer(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + strncpy(b.message, "Join us on US/MEDIUM_FAST", sizeof(b.message) - 1); + b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; + b.offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Offer with preset must be cached"); + TEST_ASSERT_EQUAL(kRemoteNode, MeshBeaconListenerModule::lastReceivedOffer.sender); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, MeshBeaconListenerModule::lastReceivedOffer.region); +} + +// Receiving a beacon with a channel offer stores the has_channel flag. +static void test_listener_receiveWithChannelOffer_setsHasChannel(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + strncpy(b.message, "Channel offer test", sizeof(b.message) - 1); + b.has_offer_channel = true; + b.offer_channel.channel_num = 5; + strncpy(b.offer_channel.name, "TestNet", sizeof(b.offer_channel.name) - 1); + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid); + TEST_ASSERT_TRUE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.has_channel, + "has_channel must be set when offer_channel is present"); + TEST_ASSERT_EQUAL_UINT32(5, MeshBeaconListenerModule::lastReceivedOffer.channel.channel_num); +} + +// An empty message must be silently dropped; cache must stay invalid. +static void test_listener_emptyMessage_isDropped(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + // message field intentionally left blank + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Empty message must not update offer cache"); +} + +// A null MeshBeacon pointer must be handled gracefully. +static void test_listener_nullBeacon_isDropped(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + bool result = listener.handleReceivedProtobuf(mp, nullptr); + + TEST_ASSERT_FALSE_MESSAGE(result, "Null beacon must return false"); + TEST_ASSERT_FALSE(MeshBeaconListenerModule::lastReceivedOffer.valid); +} + +// Receiving a beacon with no offer fields must not mark the cache valid. +static void test_listener_receiveWithNoOffer_cacheStaysInvalid(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + strncpy(b.message, "No offer here", sizeof(b.message) - 1); + // offer_preset == 0, has_offer_channel == false + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "No offer fields → cache must stay invalid"); +} + +// wantPacket returns false when listen_enabled is false. +static void test_listener_wantPacket_falseWhenDisabled(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = false; + + MeshBeaconListenerModuleTestShim listener; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + + TEST_ASSERT_FALSE(listener.wantPacket(&mp)); +} + +// wantPacket returns true when listen_enabled is true and portnum matches. +static void test_listener_wantPacket_trueWhenEnabled(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + + meshtastic_MeshPacket mp = meshtastic_MeshPacket_init_zero; + mp.decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + + TEST_ASSERT_TRUE(listener.wantPacket(&mp)); +} + +} // namespace + +// =========================================================================== +// Unity lifecycle +// =========================================================================== + +void setUp(void) +{ + testAirTime = new AirTime(); + airTime = testAirTime; + + mockSvc = new MockMeshService(); + service = mockSvc; + + mockRouter = new MockRouter(); + router = mockRouter; + + testAdmin = new AdminModuleTestShim(); +} + +void tearDown(void) +{ + meshBeaconBroadcastModule = nullptr; + + delete testAdmin; + testAdmin = nullptr; + + service = nullptr; + delete mockSvc; + mockSvc = nullptr; + + router = nullptr; + delete mockRouter; + mockRouter = nullptr; + + airTime = nullptr; + delete testAirTime; + testAirTime = nullptr; +} + +BEACON_TEST_ENTRY void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + + // Admin validation + RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared); + RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared); + RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted); + RUN_TEST(test_adminValidation_unknownOfferRegion_isCleared); + RUN_TEST(test_adminValidation_validOfferRegion_isPreserved); + RUN_TEST(test_adminValidation_messageTooLong_isTruncatedAt99); + RUN_TEST(test_adminValidation_intervalTooLow_isClamped); + RUN_TEST(test_adminValidation_intervalTooHigh_isClamped); + RUN_TEST(test_adminValidation_intervalZero_isNotClamped); + RUN_TEST(test_adminValidation_validSave_invalidatesCache); + + // Broadcaster cache + RUN_TEST(test_broadcaster_rebuildCache_producesNonEmptyPayload); + RUN_TEST(test_broadcaster_rebuildCache_payloadDecodesCorrectly); + RUN_TEST(test_broadcaster_rebuildCache_offerFieldsEncoded); + RUN_TEST(test_broadcaster_invalidateCache_setsDirtyFlag); + RUN_TEST(test_broadcaster_rebuildCache_idempotent); + + // Broadcaster send + RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset); + RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); + RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast); + RUN_TEST(test_broadcaster_sendBeacon_usesBeaconPortnum); + RUN_TEST(test_broadcaster_sendBeacon_payloadDecodesCorrectly); + RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled); + RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled); + + // Listener + RUN_TEST(test_listener_receiveWithOffer_cachesOffer); + RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel); + RUN_TEST(test_listener_emptyMessage_isDropped); + RUN_TEST(test_listener_nullBeacon_isDropped); + RUN_TEST(test_listener_receiveWithNoOffer_cacheStaysInvalid); + RUN_TEST(test_listener_wantPacket_falseWhenDisabled); + RUN_TEST(test_listener_wantPacket_trueWhenEnabled); + + exit(UNITY_END()); +} + +BEACON_TEST_ENTRY void loop() {} + +#else // MESHTASTIC_EXCLUDE_BEACON + +void setUp(void) {} +void tearDown(void) {} + +BEACON_TEST_ENTRY void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + exit(UNITY_END()); +} + +BEACON_TEST_ENTRY void loop() {} + +#endif From 819cfb8a19343023bffa658bf08c411da42cb63f Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 03:51:37 +0100 Subject: [PATCH 09/24] remove old meshtips --- src/mesh/Default.h | 1 + src/mesh/RadioLibInterface.cpp | 18 +- src/modules/MeshBeaconModule.cpp | 64 +++--- src/modules/MeshTipsModule.cpp | 312 ---------------------------- src/modules/MeshTipsModule.h | 95 --------- src/modules/Modules.cpp | 3 - test/test_mesh_beacon/test_main.cpp | 28 ++- 7 files changed, 52 insertions(+), 469 deletions(-) delete mode 100644 src/modules/MeshTipsModule.cpp delete mode 100644 src/modules/MeshTipsModule.h diff --git a/src/mesh/Default.h b/src/mesh/Default.h index 97f87fc7bfc..4dd9d20c383 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -27,6 +27,7 @@ #define default_screen_on_secs IF_ROUTER(1, 60 * 10) #define default_node_info_broadcast_secs 3 * 60 * 60 #define default_neighbor_info_broadcast_secs 6 * 60 * 60 +#define default_mesh_beacon_min_broadcast_interval_secs 3600 #define min_node_info_broadcast_secs 60 * 60 // No regular broadcasts of more than once an hour #define min_neighbor_info_broadcast_secs 4 * 60 * 60 #define default_map_publish_interval_secs 60 * 60 diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index 3d719fe4a29..aba948b1dbb 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -8,9 +8,6 @@ #include "error.h" #include "main.h" #include "mesh-pb-constants.h" -#if !MESHTASTIC_EXCLUDE_TIPS -#include "modules/MeshTipsModule.h" -#endif #if !MESHTASTIC_EXCLUDE_BEACON #include "modules/MeshBeaconModule.h" #endif @@ -372,9 +369,6 @@ void RadioLibInterface::onNotify(uint32_t notification) switch (notification) { case ISR_TX: handleTransmitInterrupt(); -#if !MESHTASTIC_EXCLUDE_TIPS - MeshTipsModule::configureRadioForPacket(this, txQueue.getFront()); -#endif #if !MESHTASTIC_EXCLUDE_BEACON MeshBeaconModule::reconfigureForBeaconTX(this, txQueue.getFront()); #endif @@ -400,20 +394,13 @@ void RadioLibInterface::onNotify(uint32_t notification) if (delay_remaining > 0) { // There's still some delay pending on this packet, so resume waiting for it to elapse notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, false); -#if !MESHTASTIC_EXCLUDE_TIPS - } else if (MeshTipsModule::configureRadioForPacket(this, txp)) { - // We just switched radio config, so wait to ensure the new channel is available - setTransmitDelay(); -#endif #if !MESHTASTIC_EXCLUDE_BEACON } else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) { setTransmitDelay(); #endif } else { if (isChannelActive()) { // check if there is currently a LoRa packet on the channel -#if !MESHTASTIC_EXCLUDE_TIPS - if (!MeshTipsModule::hasTargetRadioSettings(txp)) -#elif !MESHTASTIC_EXCLUDE_BEACON +#if !MESHTASTIC_EXCLUDE_BEACON if (!MeshBeaconModule::hasTargetRadioSettings(txp)) #endif { @@ -550,9 +537,6 @@ void RadioLibInterface::completeSending() if (!isFromUs(p)) txRelay++; printPacket("Completed sending", p); -#if !MESHTASTIC_EXCLUDE_TIPS - MeshTipsModule::clearTargetRadioSettings(p); -#endif #if !MESHTASTIC_EXCLUDE_BEACON MeshBeaconModule::clearTargetRadioSettings(p); MeshBeaconModule::reconfigureForBeaconTX(this, nullptr); diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index cf15a0dfe75..4fbfd031b64 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -1,5 +1,6 @@ #include "MeshBeaconModule.h" #include "Default.h" +#include "DisplayFormatters.h" #include "MeshService.h" #include "NodeDB.h" #include "RTC.h" @@ -102,34 +103,7 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ if (bcfg.has_broadcast_on_channel && strlen(bcfg.broadcast_on_channel.name) > 0) { strncpy(c->name, bcfg.broadcast_on_channel.name, sizeof(c->name)); } else { - switch (targetPreset) { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: - strncpy(c->name, "ShortTurbo", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - strncpy(c->name, "ShortFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - strncpy(c->name, "ShortSlow", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - strncpy(c->name, "MediumFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - strncpy(c->name, "MediumSlow", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - strncpy(c->name, "LongFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - strncpy(c->name, "LongMod", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - strncpy(c->name, "LongSlow", sizeof(c->name)); - break; - default: - break; - } + strncpy(c->name, DisplayFormatters::getModemPresetDisplayName(targetPreset, false, true), sizeof(c->name) - 1); } channels.fixupChannel(channels.getPrimaryIndex()); @@ -186,17 +160,31 @@ void MeshBeaconBroadcastModule::sendBeacon() { const auto &bcfg = moduleConfig.mesh_beacon; - if (payloadCacheDirty) - rebuildCache(); - meshtastic_MeshPacket *p = allocDataPacket(); if (!p) { LOG_WARN("Beacon: failed to allocate packet"); return; } - memcpy(p->decoded.payload.bytes, payloadCache, payloadCacheSize); - p->decoded.payload.size = payloadCacheSize; - p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + + // Use MESH_BEACON_APP only when radio settings are being offered to listeners; + // otherwise fall back to TEXT_MESSAGE_APP so standard clients receive the text + // without needing a MESH_BEACON_APP decoder. broadcast_on_* controls which + // radio config to use for TX and has no bearing on the portnum. + bool hasRadioContent = (bcfg.broadcast_offer_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) || + bcfg.has_broadcast_offer_channel || + (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET); + if (hasRadioContent) { + if (payloadCacheDirty) + rebuildCache(); + memcpy(p->decoded.payload.bytes, payloadCache, payloadCacheSize); + p->decoded.payload.size = payloadCacheSize; + p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + } else { + pb_size_t msgLen = (pb_size_t)strnlen(bcfg.broadcast_message, sizeof(bcfg.broadcast_message) - 1); + memcpy(p->decoded.payload.bytes, bcfg.broadcast_message, msgLen); + p->decoded.payload.size = msgLen; + p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + } p->to = NODENUM_BROADCAST; p->from = (bcfg.broadcast_send_as_node != 0) ? bcfg.broadcast_send_as_node : nodeDB->getNodeNum(); p->hop_limit = 3; @@ -223,12 +211,8 @@ int32_t MeshBeaconBroadcastModule::runOnce() sendBeacon(); } - uint32_t interval = bcfg.broadcast_interval_secs; - if (interval < 3600) - interval = 3600; - if (interval > 259200) - interval = 259200; - return interval * 1000; + return Default::getConfiguredOrMinimumValue(bcfg.broadcast_interval_secs, default_mesh_beacon_min_broadcast_interval_secs) * + 1000; } // --------------------------------------------------------------------------- diff --git a/src/modules/MeshTipsModule.cpp b/src/modules/MeshTipsModule.cpp deleted file mode 100644 index 45677a36651..00000000000 --- a/src/modules/MeshTipsModule.cpp +++ /dev/null @@ -1,312 +0,0 @@ -#include "MeshTipsModule.h" -#include "Default.h" -#include "MeshService.h" -#include "RTC.h" -#include "RadioInterface.h" -#include "configuration.h" -#include "main.h" -#include -#include - -#define NODENUM_TIPS 0x00000004 - -static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; // original modem preset -static uint16_t originalLoraChannel; // original frequency slot -char originalChannelName[sizeof(((meshtastic_ChannelSettings *)nullptr)->name)]; // original channel name - -typedef struct { - bool inUse; - PacketId id; - MeshTipsModule_TXSettings settings; -} MeshTipsModule_TargetRadioSettings; - -static MeshTipsModule_TargetRadioSettings targetRadioSettings[8]; - -static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, MeshTipsModule_TXSettings *settings) -{ - if (!p) - return false; - - for (auto &entry : targetRadioSettings) { - if (entry.inUse && entry.id == p->id) { - if (settings) - *settings = entry.settings; - return true; - } - } - return false; -} - -MeshTipsModule::MeshTipsModule() -{ - originalModemPreset = config.lora.modem_preset; - originalLoraChannel = config.lora.channel_num; - strncpy(originalChannelName, channels.getPrimary().name, sizeof(originalChannelName)); -} - -void MeshTipsModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, MeshTipsModule_TXSettings settings) -{ - if (!p) - return; - - MeshTipsModule_TargetRadioSettings *slot = nullptr; - for (auto &entry : targetRadioSettings) { - if (entry.inUse && entry.id == p->id) { - slot = &entry; - break; - } - if (!slot && !entry.inUse) - slot = &entry; - } - - if (!slot) - slot = &targetRadioSettings[0]; - - slot->inUse = true; - slot->id = p->id; - slot->settings = settings; -} - -bool MeshTipsModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p) -{ - return getTargetRadioSettings(p, nullptr); -} - -void MeshTipsModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) -{ - if (!p) - return; - - for (auto &entry : targetRadioSettings) { - if (entry.inUse && entry.id == p->id) { - entry.inUse = false; - return; - } - } -} - -bool MeshTipsModule::configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p) -{ - meshtastic_ChannelSettings *c = (meshtastic_ChannelSettings *)&channels.getPrimary(); - MeshTipsModule_TXSettings target; - if (p && p->from == NODENUM_TIPS && getTargetRadioSettings(p, &target) && - (target.preset != config.lora.modem_preset || target.slot != config.lora.channel_num)) { - LOG_INFO("Reconfiguring for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, p->decoded.payload.size); - - config.lora.modem_preset = target.preset; - config.lora.channel_num = target.slot; - memset(c->name, 0, sizeof(c->name)); - - switch (target.preset) { - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO: - strncpy(c->name, "ShortTurbo", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST: - strncpy(c->name, "ShortFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW: - strncpy(c->name, "ShortSlow", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST: - strncpy(c->name, "MediumFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW: - strncpy(c->name, "MediumSlow", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST: - strncpy(c->name, "LongFast", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE: - strncpy(c->name, "LongMod", sizeof(c->name)); - break; - case meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW: - strncpy(c->name, "LongSlow", sizeof(c->name)); - break; - } - - channels.fixupChannel(channels.getPrimaryIndex()); - p->channel = channels.getHash(channels.getPrimaryIndex()); - iface->reconfigure(); - - return true; - } else if ((!p || !getTargetRadioSettings(p, nullptr)) && - (config.lora.modem_preset != originalModemPreset || config.lora.channel_num != originalLoraChannel)) { - if (p) - LOG_INFO("Reconfiguring for TX of packet %#08lx (from=%#08lx size=%lu)", p->id, p->from, p->decoded.payload.size); - else - LOG_INFO("Restoring radio config after tips TX"); - - config.lora.modem_preset = originalModemPreset; - config.lora.channel_num = originalLoraChannel; - memset(c->name, 0, sizeof(c->name)); - strncpy(c->name, originalChannelName, sizeof(c->name)); - - channels.fixupChannel(channels.getPrimaryIndex()); - iface->reconfigure(); - return true; - } - return false; -} - -MeshTipsModule_TXSettings MeshTipsModule::stripTargetRadioSettings(meshtastic_MeshPacket *p) -{ - MeshTipsModule_TXSettings s = { - .preset = originalModemPreset, - .slot = originalLoraChannel, - }; - - if (!p || p->decoded.payload.size < 4 || p->decoded.payload.bytes[0] != '#') - return s; - - // Clamp final byte of payload while parsing the command prefix. - if (p->decoded.payload.size < sizeof(p->decoded.payload.bytes)) - p->decoded.payload.bytes[p->decoded.payload.size] = 0; - else - p->decoded.payload.bytes[sizeof(p->decoded.payload.bytes) - 1] = 0; - - char *msg = strchr((char *)p->decoded.payload.bytes, ' '); - if (!msg || !*msg) - return s; - *msg++ = 0; - - const char presetString[3] = {p->decoded.payload.bytes[1], p->decoded.payload.bytes[2], 0}; - char *slotString = (char *)&p->decoded.payload.bytes[3]; - if (!*slotString) - return s; - - for (char *c = slotString; *c; c++) { - if (!strchr("1234567890", *c)) - return s; - } - - if (!strncmp(presetString, "ST", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; - else if (!strncmp(presetString, "SF", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_FAST; - else if (!strncmp(presetString, "SS", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_SLOW; - else if (!strncmp(presetString, "MF", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; - else if (!strncmp(presetString, "MS", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_SLOW; - else if (!strncmp(presetString, "LF", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; - else if (!strncmp(presetString, "LM", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_MODERATE; - else if (!strncmp(presetString, "LS", 2)) - s.preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; - else - return s; - - s.slot = strtoul(slotString, nullptr, 10); - - p->decoded.payload.size = 1; - // don't use strcpy, because strcpy has undefined behaviour if src & dst overlap - for (char *a = msg, *b = (char *)p->decoded.payload.bytes; (*b++ = *a++);) - p->decoded.payload.size++; - - return s; -} - -MeshTipsNodeInfoModule *meshTipsNodeInfoModule; - -bool MeshTipsNodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *pptr) -{ - // do nothing if we receive nodeinfo, because we only care about sending our own - return true; -} - -void MeshTipsNodeInfoModule::sendTipsNodeInfo() -{ - LOG_INFO("Send NodeInfo for mesh tips"); - static meshtastic_User u = { - .hw_model = meshtastic_HardwareModel_PRIVATE_HW, - .is_licensed = false, - .role = meshtastic_Config_DeviceConfig_Role_CLIENT_MUTE, - .public_key = - { - .size = 32, - .bytes = {0x39, 0x37, 0x58, 0xe4, 0x05, 0x34, 0x7d, 0xe0, 0x49, 0x73, 0xec, 0xaf, 0xbc, 0x8e, 0x07, 0xe8, - 0x66, 0x57, 0xe4, 0xa1, 0x2d, 0x53, 0x0e, 0x26, 0x51, 0x1f, 0x1a, 0x6c, 0xbf, 0xe8, 0x5e, 0x04}, - }, - .has_is_unmessagable = true, - .is_unmessagable = true, - }; - strncpy(u.id, "!mesh_tips", sizeof(u.id)); - strncpy(u.long_name, "WLG Mesh Tips Robot", sizeof(u.long_name)); - strncpy(u.short_name, "TIPS", sizeof(u.short_name)); - meshtastic_MeshPacket *p = allocDataProtobuf(u); - p->to = NODENUM_BROADCAST; - p->from = NODENUM_TIPS; - p->hop_limit = 0; - p->decoded.want_response = false; - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - - meshtastic_MeshPacket *p_LF20 = packetPool.allocCopy(*p); - service->sendToMesh(p, RX_SRC_LOCAL, false); - - setTargetRadioSettings(p_LF20, { - .preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, - .slot = 20, - }); - service->sendToMesh(p_LF20, RX_SRC_LOCAL, false); -} - -MeshTipsNodeInfoModule::MeshTipsNodeInfoModule() - : ProtobufModule("nodeinfo_tips", meshtastic_PortNum_NODEINFO_APP, &meshtastic_User_msg), - concurrency::OSThread("MeshTipsNodeInfo") -{ - MeshTipsModule(); - - setIntervalFromNow(setStartDelay()); // Send our initial owner announcement 30 seconds - // after we start (to give network time to setup) -} - -int32_t MeshTipsNodeInfoModule::runOnce() -{ - if (airTime->isTxAllowedAirUtil() && config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { - sendTipsNodeInfo(); - } - return Default::getConfiguredOrDefaultMs(config.device.node_info_broadcast_secs, default_node_info_broadcast_secs); -} - -MeshTipsMessageModule *meshTipsMessageModule; - -ProcessMessage MeshTipsMessageModule::handleReceived(const meshtastic_MeshPacket &mp) -{ -#ifdef DEBUG_PORT - auto &d = mp.decoded; -#endif - - meshtastic_MeshPacket *p = packetPool.allocCopy(mp); - MeshTipsModule_TXSettings s = stripTargetRadioSettings(p); - if (!p->decoded.payload.size || p->decoded.payload.bytes[0] == '#') - return ProcessMessage::STOP; - p->to = NODENUM_BROADCAST; - p->decoded.source = p->from; - p->from = NODENUM_TIPS; - p->channel = channels.getPrimaryIndex(); - p->hop_limit = 0; - p->hop_start = 0; - p->rx_rssi = 0; - p->rx_snr = 0; - p->priority = meshtastic_MeshPacket_Priority_HIGH; - p->want_ack = false; - if (s.preset != originalModemPreset || s.slot != originalLoraChannel) { - setTargetRadioSettings(p, s); - } - p->rx_time = getValidTime(RTCQualityFromNet); - service->sendToMesh(p, RX_SRC_LOCAL, false); - - powerFSM.trigger(EVENT_RECEIVED_MSG); - notifyObservers(&mp); - - return ProcessMessage::CONTINUE; // No other module should be caring about this message -} - -bool MeshTipsMessageModule::wantPacket(const meshtastic_MeshPacket *p) -{ - meshtastic_Channel *c = &channels.getByIndex(p->channel); - return c->role == meshtastic_Channel_Role_SECONDARY && strlen(c->settings.name) == strlen("Tips") && - !strcmp(c->settings.name, "Tips") && p->decoded.portnum == meshtastic_PortNum_TEXT_MESSAGE_APP; -} diff --git a/src/modules/MeshTipsModule.h b/src/modules/MeshTipsModule.h deleted file mode 100644 index 9779015e865..00000000000 --- a/src/modules/MeshTipsModule.h +++ /dev/null @@ -1,95 +0,0 @@ -#pragma once -#include "Observer.h" -#include "ProtobufModule.h" -#include "RadioInterface.h" -#include "SinglePortModule.h" - -typedef struct { - meshtastic_Config_LoRaConfig_ModemPreset preset; - uint16_t slot; -} MeshTipsModule_TXSettings; - -/** - * Base class for the tips robot - */ -class MeshTipsModule -{ - public: - /** - * Constructor - */ - MeshTipsModule(); - - /** - * Configure the radio to send the target packet, or return to default config if p is NULL - */ - static bool configureRadioForPacket(RadioInterface *iface, meshtastic_MeshPacket *p); - - /** - * Remember per-packet target radio settings for locally generated tips packets. - */ - static void setTargetRadioSettings(const meshtastic_MeshPacket *p, MeshTipsModule_TXSettings settings); - - /** - * Check whether a packet has locally tracked target radio settings. - */ - static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p); - - /** - * Forget locally tracked target radio settings for a packet. - */ - static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); - - /** - * Get target modem settings - */ - MeshTipsModule_TXSettings stripTargetRadioSettings(meshtastic_MeshPacket *p); -}; - -/** - * Tips module for sending tips into the mesh - */ -class MeshTipsNodeInfoModule : private MeshTipsModule, public ProtobufModule, private concurrency::OSThread -{ - public: - /** - * Constructor - */ - MeshTipsNodeInfoModule(); - - protected: - virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_User *p) override; - - /** - * Send NodeInfo to the mesh - */ - void sendTipsNodeInfo(); - - /** Does our periodic broadcast */ - virtual int32_t runOnce() override; -}; -extern MeshTipsNodeInfoModule *meshTipsNodeInfoModule; - -/** - * Text message handling for the tips robot - */ -class MeshTipsMessageModule : private MeshTipsModule, public SinglePortModule, public Observable -{ - public: - /** - * Constructor - */ - MeshTipsMessageModule() : MeshTipsModule(), SinglePortModule("tips", meshtastic_PortNum_TEXT_MESSAGE_APP) {} - - protected: - /** - * Called to handle a particular incoming message - */ - virtual ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override; - - /** - * Indicate whether this module wants to process the packet - */ - virtual bool wantPacket(const meshtastic_MeshPacket *p) override; -}; -extern MeshTipsMessageModule *meshTipsMessageModule; diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 26f288a9144..e545400b741 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -28,9 +28,6 @@ #if !MESHTASTIC_EXCLUDE_NODEINFO #include "modules/NodeInfoModule.h" #endif -#if !MESHTASTIC_EXCLUDE_TIPS -#include "modules/MeshTipsModule.h" -#endif #if !MESHTASTIC_EXCLUDE_BEACON #include "modules/MeshBeaconModule.h" #endif diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 68f5eb7795e..8a64ab3ca37 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -436,12 +436,13 @@ static void test_broadcaster_sendBeacon_addressedToBroadcast(void) TEST_ASSERT_EQUAL_UINT32(NODENUM_BROADCAST, mockRouter->sentPackets[0].to); } -// sendBeacon uses MESH_BEACON_APP portnum. +// sendBeacon uses MESH_BEACON_APP portnum when a modem preset is offered. static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; strncpy(moduleConfig.mesh_beacon.broadcast_message, "portnum-check", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); @@ -450,13 +451,35 @@ static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); } -// sendBeacon payload decodes back to the correct message string. +// sendBeacon falls back to TEXT_MESSAGE_APP when no radio settings are offered, +// even when broadcast_on_preset is configured (that controls TX radio, not portnum). +static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + const char *msg = "plain-text-beacon"; + strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + // broadcast_on_preset set, but no offer — should still be TEXT_MESSAGE_APP + moduleConfig.mesh_beacon.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + const meshtastic_MeshPacket &p = mockRouter->sentPackets[0]; + TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, p.decoded.portnum); + TEST_ASSERT_EQUAL_UINT32(strlen(msg), p.decoded.payload.size); + TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)p.decoded.payload.bytes, p.decoded.payload.size); +} + +// sendBeacon payload decodes back to the correct message string (MESH_BEACON_APP path). static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; const char *msg = "Greetings from the beacon"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; bcast.sendBeacon(); @@ -726,6 +749,7 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast); RUN_TEST(test_broadcaster_sendBeacon_usesBeaconPortnum); + RUN_TEST(test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum); RUN_TEST(test_broadcaster_sendBeacon_payloadDecodesCorrectly); RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled); RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled); From 74af32366a808787b0545fcce1c47f7be9b0951e Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 04:55:45 +0100 Subject: [PATCH 10/24] more validation in NodeDB and AdminModule, and userprefs for baked in goodness --- src/mesh/NodeDB.cpp | 54 ++++++++++++++++++++++++++++++++ src/modules/AdminModule.cpp | 24 ++++++++++---- src/modules/MeshBeaconModule.cpp | 29 +++++++++++++++-- src/modules/MeshBeaconModule.h | 1 + src/modules/Modules.cpp | 4 --- userPrefs.jsonc | 12 +++++++ 6 files changed, 111 insertions(+), 13 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 2481eb8ca6b..aff9a161768 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1177,6 +1177,59 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.ambient_lighting.green = (myNodeInfo.my_node_num & 0x00FF00) >> 8; moduleConfig.ambient_lighting.blue = myNodeInfo.my_node_num & 0x0000FF; +#if !MESHTASTIC_EXCLUDE_BEACON + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + moduleConfig.mesh_beacon.broadcast_enabled = false; +#ifdef USERPREFS_MESH_BEACON_LISTEN_ENABLED + moduleConfig.mesh_beacon.listen_enabled = USERPREFS_MESH_BEACON_LISTEN_ENABLED; +#endif +#ifdef USERPREFS_MESH_BEACON_BROADCAST_ENABLED + moduleConfig.mesh_beacon.broadcast_enabled = USERPREFS_MESH_BEACON_BROADCAST_ENABLED; +#endif +#ifdef USERPREFS_MESH_BEACON_MESSAGE + strncpy(moduleConfig.mesh_beacon.broadcast_message, USERPREFS_MESH_BEACON_MESSAGE, + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); +#endif +#ifdef USERPREFS_MESH_BEACON_INTERVAL_SECS + moduleConfig.mesh_beacon.broadcast_interval_secs = USERPREFS_MESH_BEACON_INTERVAL_SECS; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_PRESET + moduleConfig.mesh_beacon.broadcast_offer_preset = USERPREFS_MESH_BEACON_OFFER_PRESET; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_REGION + moduleConfig.mesh_beacon.broadcast_offer_region = USERPREFS_MESH_BEACON_OFFER_REGION; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME + moduleConfig.mesh_beacon.has_broadcast_offer_channel = true; + strncpy(moduleConfig.mesh_beacon.broadcast_offer_channel.name, USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME, + sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.name) - 1); +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK + moduleConfig.mesh_beacon.has_broadcast_offer_channel = true; + static const uint8_t beaconOfferPsk[] = USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK; + memcpy(moduleConfig.mesh_beacon.broadcast_offer_channel.psk.bytes, beaconOfferPsk, sizeof(beaconOfferPsk)); + moduleConfig.mesh_beacon.broadcast_offer_channel.psk.size = sizeof(beaconOfferPsk); +#endif +#ifdef USERPREFS_MESH_BEACON_ON_PRESET + moduleConfig.mesh_beacon.broadcast_on_preset = USERPREFS_MESH_BEACON_ON_PRESET; +#endif +#ifdef USERPREFS_MESH_BEACON_ON_REGION + moduleConfig.mesh_beacon.broadcast_on_region = USERPREFS_MESH_BEACON_ON_REGION; +#endif +#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NAME + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, USERPREFS_MESH_BEACON_ON_CHANNEL_NAME, + sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); +#endif +#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_PSK + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + static const uint8_t beaconOnPsk[] = USERPREFS_MESH_BEACON_ON_CHANNEL_PSK; + memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconOnPsk, sizeof(beaconOnPsk)); + moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconOnPsk); +#endif +#endif // !MESHTASTIC_EXCLUDE_BEACON + initModuleConfigIntervals(); } @@ -2377,6 +2430,7 @@ bool NodeDB::saveToDiskNoRetry(int saveWhat) moduleConfig.has_audio = true; moduleConfig.has_paxcounter = true; moduleConfig.has_statusmessage = true; + moduleConfig.has_mesh_beacon = true; success &= saveProto(moduleConfigFileName, meshtastic_LocalModuleConfig_size, &meshtastic_LocalModuleConfig_msg, &moduleConfig); diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index e32cc748bc8..7b14111500e 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1167,22 +1167,34 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) // Enforce message length limit. if (strnlen(b.broadcast_message, sizeof(b.broadcast_message)) >= 100) b.broadcast_message[99] = '\0'; - // Enforce interval bounds (0 means unset/use default). - if (b.broadcast_interval_secs != 0 && b.broadcast_interval_secs < 3600) - b.broadcast_interval_secs = 3600; - if (b.broadcast_interval_secs > 259200) - b.broadcast_interval_secs = 259200; - // Validate broadcast_on_preset against the device's current region. + // Enforce interval minimum (0 means unset/use default). + if (b.broadcast_interval_secs != 0 && b.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs) + b.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs; + // Validate broadcast_on_preset against broadcast_on_region (or current region if unset). if (b.broadcast_on_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) { meshtastic_Config_LoRaConfig probe = config.lora; probe.use_preset = true; probe.modem_preset = b.broadcast_on_preset; + if (b.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + probe.region = b.broadcast_on_region; if (!RadioInterface::validateConfigLora(probe)) { LOG_WARN("Beacon: broadcast_on_preset %d invalid for region, clearing", b.broadcast_on_preset); b.broadcast_on_preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; b.has_broadcast_on_channel = false; } } + // Validate broadcast_offer_preset against broadcast_offer_region (or current region if unset). + if (b.broadcast_offer_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) { + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.modem_preset = b.broadcast_offer_preset; + if (b.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + probe.region = b.broadcast_offer_region; + if (!RadioInterface::validateConfigLora(probe)) { + LOG_WARN("Beacon: broadcast_offer_preset %d invalid for region, clearing", b.broadcast_offer_preset); + b.broadcast_offer_preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; + } + } // Validate broadcast_offer_region is a known region code. if (b.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) { const RegionInfo *r = getRegion(b.broadcast_offer_region); diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 4fbfd031b64..d043e0c7ce9 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -14,6 +14,7 @@ // Static members meshtastic_Config_LoRaConfig_ModemPreset MeshBeaconModule::originalModemPreset; uint16_t MeshBeaconModule::originalLoraChannel; +meshtastic_Config_LoRaConfig_RegionCode MeshBeaconModule::originalRegion; char MeshBeaconModule::originalChannelName[12]; static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; @@ -43,6 +44,7 @@ MeshBeaconModule::MeshBeaconModule() { originalModemPreset = config.lora.modem_preset; originalLoraChannel = config.lora.channel_num; + originalRegion = config.lora.region; strncpy(originalChannelName, channels.getPrimary().name, sizeof(originalChannelName)); } @@ -94,12 +96,31 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot) && (targetPreset != config.lora.modem_preset || targetSlot != config.lora.channel_num)) { - LOG_INFO("Beacon: switch radio for packet %#08lx to preset=%d slot=%u", p->id, targetPreset, targetSlot); + const auto &bcfg = moduleConfig.mesh_beacon; + meshtastic_Config_LoRaConfig_RegionCode targetRegion; + if (bcfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) + targetRegion = bcfg.broadcast_on_region; + else + targetRegion = config.lora.region; + + // Guard: skip TX if preset is invalid for the target region. + meshtastic_Config_LoRaConfig broadcastOnSetting = config.lora; + broadcastOnSetting.use_preset = true; + broadcastOnSetting.modem_preset = targetPreset; + broadcastOnSetting.region = targetRegion; + if (!RadioInterface::validateConfigLora(broadcastOnSetting)) { + LOG_WARN("Beacon: preset %d invalid for region %d, skipping radio switch", targetPreset, targetRegion); + return false; + } + + LOG_INFO("Beacon: switch radio for packet %#08lx to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot, + targetRegion); config.lora.modem_preset = targetPreset; config.lora.channel_num = targetSlot; + if (targetRegion != config.lora.region) + config.lora.region = targetRegion; memset(c->name, 0, sizeof(c->name)); - const auto &bcfg = moduleConfig.mesh_beacon; if (bcfg.has_broadcast_on_channel && strlen(bcfg.broadcast_on_channel.name) > 0) { strncpy(c->name, bcfg.broadcast_on_channel.name, sizeof(c->name)); } else { @@ -112,11 +133,13 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ return true; } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && - (config.lora.modem_preset != originalModemPreset || config.lora.channel_num != originalLoraChannel)) { + (config.lora.modem_preset != originalModemPreset || config.lora.channel_num != originalLoraChannel || + config.lora.region != originalRegion)) { LOG_INFO("Beacon: restoring radio config after beacon TX"); config.lora.modem_preset = originalModemPreset; config.lora.channel_num = originalLoraChannel; + config.lora.region = originalRegion; memset(c->name, 0, sizeof(c->name)); strncpy(c->name, originalChannelName, sizeof(c->name)); diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index 4077e802d5a..0af14cf1190 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -53,6 +53,7 @@ class MeshBeaconModule protected: static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; static uint16_t originalLoraChannel; + static meshtastic_Config_LoRaConfig_RegionCode originalRegion; static char originalChannelName[12]; // matches ChannelSettings.name max_size }; diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index e545400b741..569a60a5a84 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -147,10 +147,6 @@ void setupModules() #if !MESHTASTIC_EXCLUDE_NODEINFO nodeInfoModule = new NodeInfoModule(); #endif -#if !MESHTASTIC_EXCLUDE_TIPS - meshTipsNodeInfoModule = new MeshTipsNodeInfoModule(); - meshTipsMessageModule = new MeshTipsMessageModule(); -#endif #if !MESHTASTIC_EXCLUDE_BEACON meshBeaconBroadcastModule = new MeshBeaconBroadcastModule(); meshBeaconListenerModule = new MeshBeaconListenerModule(); diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 57ede8bbf91..5cf12cf02cb 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -57,6 +57,18 @@ // "USERPREFS_MQTT_ENCRYPTION_ENABLED": "true", // "USERPREFS_MQTT_TLS_ENABLED": "false", // "USERPREFS_MQTT_ROOT_TOPIC": "event/REPLACEME", + // "USERPREFS_MESH_BEACON_LISTEN_ENABLED": "true", // Accept incoming beacons and deliver their text to the local inbox + // "USERPREFS_MESH_BEACON_BROADCAST_ENABLED": "true", // Periodically broadcast beacons from this node + // "USERPREFS_MESH_BEACON_MESSAGE": "Join us on NarrowSlow!!!", // Text payload included in every beacon broadcast + // "USERPREFS_MESH_BEACON_INTERVAL_SECS": "3600", // Broadcast interval in seconds (min 3600 = 1 hour) + // "USERPREFS_MESH_BEACON_OFFER_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW", // Modem preset advertised in the beacon payload (listeners can adopt this) + // "USERPREFS_MESH_BEACON_OFFER_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_N_868", // Region advertised in the beacon payload + // "USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME": "'MyChannel'", // Channel name advertised in the beacon payload + // "USERPREFS_MESH_BEACON_OFFER_CHANNEL_PSK": "{ 0x38, 0x4b, 0xbc, 0xc0, 0x1d, 0xc0, 0x22, 0xd1, 0x81, 0xbf, 0x36, 0xb8, 0x61, 0x21, 0xe1, 0xfb, 0x96, 0xb7, 0x2e, 0x55, 0xbf, 0x74, 0x22, 0x7e, 0x9d, 0x6a, 0xfb, 0x48, 0xd6, 0x4c, 0xb1, 0xa1 }", // PSK for the offered channel (32-byte AES-256) + // "USERPREFS_MESH_BEACON_ON_PRESET": "meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST", // Modem preset to use when transmitting beacons (radio temporarily switched for TX) + // "USERPREFS_MESH_BEACON_ON_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", // Region to use when transmitting beacons + // "USERPREFS_MESH_BEACON_ON_CHANNEL_NAME": "'LongFast'", // Channel name to use on the TX radio config - uses default if unset. + // "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK": "{ 0x01 }", // PSK for the TX channel (0x01 = Meshtastic default PSK) // "USERPREFS_RINGTONE_NAG_SECS": "60", // "USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS": "43200", // "USERPREFS_UI_TEST_LOG": "true", // Test-only: emits `Screen: frame N/M name=... reason=...` log per UI transition (for the mcp-server ui test tier); off in release builds. From 7e8b74990c9d6e7e8510656161fc9fe55f7c83f4 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 05:18:36 +0100 Subject: [PATCH 11/24] copilot is my gravity --- src/mesh/NodeDB.cpp | 6 ++++- src/modules/AdminModule.cpp | 5 ++-- src/modules/MeshBeaconModule.cpp | 41 ++++++++++++++++++++++++++------ 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index aff9a161768..a9d111bf868 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1192,7 +1192,11 @@ void NodeDB::installDefaultModuleConfig() sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); #endif #ifdef USERPREFS_MESH_BEACON_INTERVAL_SECS - moduleConfig.mesh_beacon.broadcast_interval_secs = USERPREFS_MESH_BEACON_INTERVAL_SECS; + moduleConfig.mesh_beacon.broadcast_interval_secs = + (USERPREFS_MESH_BEACON_INTERVAL_SECS != 0 && + USERPREFS_MESH_BEACON_INTERVAL_SECS < default_mesh_beacon_min_broadcast_interval_secs) + ? default_mesh_beacon_min_broadcast_interval_secs + : USERPREFS_MESH_BEACON_INTERVAL_SECS; #endif #ifdef USERPREFS_MESH_BEACON_OFFER_PRESET moduleConfig.mesh_beacon.broadcast_offer_preset = USERPREFS_MESH_BEACON_OFFER_PRESET; diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 7b14111500e..9f1f45a320b 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1164,9 +1164,8 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) case meshtastic_ModuleConfig_mesh_beacon_tag: { LOG_INFO("Set module config: MeshBeacon"); auto &b = const_cast(c.payload_variant.mesh_beacon); - // Enforce message length limit. - if (strnlen(b.broadcast_message, sizeof(b.broadcast_message)) >= 100) - b.broadcast_message[99] = '\0'; + // Hard cap at 100 chars. + b.broadcast_message[100] = '\0'; // Enforce interval minimum (0 means unset/use default). if (b.broadcast_interval_secs != 0 && b.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs) b.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs; diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index d043e0c7ce9..5ad342656a6 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -89,7 +89,7 @@ void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p) { - meshtastic_ChannelSettings *c = (meshtastic_ChannelSettings *)&channels.getPrimary(); + meshtastic_ChannelSettings *c = &channels.getByIndex(channels.getPrimaryIndex()).settings; meshtastic_Config_LoRaConfig_ModemPreset targetPreset; uint16_t targetSlot; @@ -113,6 +113,13 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ return false; } + // Snapshot current (non-beacon) settings so we restore to the latest config. + originalModemPreset = config.lora.modem_preset; + originalLoraChannel = config.lora.channel_num; + originalRegion = config.lora.region; + strncpy(originalChannelName, c->name, sizeof(originalChannelName) - 1); + originalChannelName[sizeof(originalChannelName) - 1] = '\0'; + LOG_INFO("Beacon: switch radio for packet %#08lx to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot, targetRegion); config.lora.modem_preset = targetPreset; @@ -122,10 +129,13 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ memset(c->name, 0, sizeof(c->name)); if (bcfg.has_broadcast_on_channel && strlen(bcfg.broadcast_on_channel.name) > 0) { - strncpy(c->name, bcfg.broadcast_on_channel.name, sizeof(c->name)); + strncpy(c->name, bcfg.broadcast_on_channel.name, sizeof(c->name) - 1); + } else if (originalChannelName[0] != '\0') { + strncpy(c->name, originalChannelName, sizeof(c->name) - 1); } else { strncpy(c->name, DisplayFormatters::getModemPresetDisplayName(targetPreset, false, true), sizeof(c->name) - 1); } + c->name[sizeof(c->name) - 1] = '\0'; channels.fixupChannel(channels.getPrimaryIndex()); p->channel = channels.getHash(channels.getPrimaryIndex()); @@ -171,6 +181,10 @@ void MeshBeaconBroadcastModule::rebuildCache() 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.offer_preset = bcfg.broadcast_offer_preset; beacon.offer_region = bcfg.broadcast_offer_region; @@ -183,6 +197,14 @@ void MeshBeaconBroadcastModule::sendBeacon() { const auto &bcfg = moduleConfig.mesh_beacon; + bool hasRadioContent = (bcfg.broadcast_offer_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) || + bcfg.has_broadcast_offer_channel || + (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET); + if (bcfg.broadcast_message[0] == '\0' && !hasRadioContent) { + LOG_DEBUG("Beacon: nothing to send (empty message, no offer), skipping"); + return; + } + meshtastic_MeshPacket *p = allocDataPacket(); if (!p) { LOG_WARN("Beacon: failed to allocate packet"); @@ -193,9 +215,6 @@ void MeshBeaconBroadcastModule::sendBeacon() // otherwise fall back to TEXT_MESSAGE_APP so standard clients receive the text // without needing a MESH_BEACON_APP decoder. broadcast_on_* controls which // radio config to use for TX and has no bearing on the portnum. - bool hasRadioContent = (bcfg.broadcast_offer_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) || - bcfg.has_broadcast_offer_channel || - (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET); if (hasRadioContent) { if (payloadCacheDirty) rebuildCache(); @@ -234,7 +253,10 @@ int32_t MeshBeaconBroadcastModule::runOnce() sendBeacon(); } - return Default::getConfiguredOrMinimumValue(bcfg.broadcast_interval_secs, default_mesh_beacon_min_broadcast_interval_secs) * + const uint32_t intervalSecs = + Default::getConfiguredOrDefault(bcfg.broadcast_interval_secs, default_mesh_beacon_min_broadcast_interval_secs); + return static_cast( + Default::getConfiguredOrMinimumValue(intervalSecs, default_mesh_beacon_min_broadcast_interval_secs)) * 1000; } @@ -266,6 +288,10 @@ bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacke // Deliver text to the local inbox as a TEXT_MESSAGE_APP packet. meshtastic_MeshPacket *txt = packetPool.allocCopy(mp); + if (!txt) { + LOG_WARN("Beacon: failed to alloc inbox copy"); + return false; + } txt->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; txt->to = nodeDB->getNodeNum(); memset(txt->decoded.payload.bytes, 0, sizeof(txt->decoded.payload.bytes)); @@ -275,7 +301,8 @@ bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacke packetPool.release(txt); // Cache any offer for the client app — never auto-applied. - if (b->has_offer_channel || b->offer_preset != 0) { + if (b->has_offer_channel || b->offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || + b->offer_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) { lastReceivedOffer.valid = true; lastReceivedOffer.sender = mp.from; lastReceivedOffer.has_channel = b->has_offer_channel; From 112c4371cc96dfe304008c288bf9b2ffb177c134 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 06:20:49 +0100 Subject: [PATCH 12/24] mmmmm... beacon --- src/mesh/NodeDB.cpp | 2 + src/mesh/generated/meshtastic/admin.pb.h | 28 +---- src/mesh/generated/meshtastic/config.pb.h | 20 +--- src/mesh/generated/meshtastic/mesh.pb.h | 13 +-- .../generated/meshtastic/mesh_beacon.pb.h | 7 +- .../generated/meshtastic/module_config.pb.h | 10 +- src/modules/AdminModule.cpp | 8 +- src/modules/MeshBeaconModule.cpp | 109 +++++++++++------- src/modules/MeshBeaconModule.h | 4 +- test/test_mesh_beacon/test_main.cpp | 98 +++++++++++++--- variants/native/portduino/platformio.ini | 3 + 11 files changed, 174 insertions(+), 128 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index a9d111bf868..97eca56ef16 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1199,6 +1199,7 @@ void NodeDB::installDefaultModuleConfig() : USERPREFS_MESH_BEACON_INTERVAL_SECS; #endif #ifdef USERPREFS_MESH_BEACON_OFFER_PRESET + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = USERPREFS_MESH_BEACON_OFFER_PRESET; #endif #ifdef USERPREFS_MESH_BEACON_OFFER_REGION @@ -1216,6 +1217,7 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.mesh_beacon.broadcast_offer_channel.psk.size = sizeof(beaconOfferPsk); #endif #ifdef USERPREFS_MESH_BEACON_ON_PRESET + moduleConfig.mesh_beacon.has_broadcast_on_preset = true; moduleConfig.mesh_beacon.broadcast_on_preset = USERPREFS_MESH_BEACON_ON_PRESET; #endif #ifdef USERPREFS_MESH_BEACON_ON_REGION diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index a099d7d5e5b..c9f5435551d 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -198,28 +198,6 @@ typedef struct _meshtastic_LockdownAuth { way to reset the session clock is a reboot, which costs a boot from the on-flash, HMAC-bound counter. */ uint32_t max_session_seconds; - /* Disable lockdown mode. Requires a valid passphrase in the same - message (the device must prove the operator owns it before - reverting at-rest encryption). On success the firmware decrypts - every stored config / channel / nodedb file back to plaintext, - removes the wrapped DEK, unlock token, monotonic-counter, and - backoff files, and reboots out of lockdown. - - This is the inverse of the provision/unlock path: it is how the - client app's "lockdown mode" toggle returns a device to normal - operation. - - NOT reversed by this operation: APPROTECT. Once the debug port - lockout has been burned (on silicon where it is effective) it is - permanent — disabling lockdown decrypts your data and removes the - access gates, but the SWD/JTAG port stays locked for the life of - the device (recoverable only via a full chip erase over a debug - probe, which destroys all data). Clients should make this - irreversibility clear at the moment lockdown is first enabled. - - When true the passphrase field is still required; boots_remaining, - valid_until_epoch, max_session_seconds, and lock_now are ignored. */ - bool disable; } meshtastic_LockdownAuth; /* Parameters for setting up Meshtastic for ameteur radio usage */ @@ -582,7 +560,6 @@ extern "C" { #define meshtastic_LockdownAuth_valid_until_epoch_tag 3 #define meshtastic_LockdownAuth_lock_now_tag 4 #define meshtastic_LockdownAuth_max_session_seconds_tag 5 -#define meshtastic_LockdownAuth_disable_tag 6 #define meshtastic_HamParameters_call_sign_tag 1 #define meshtastic_HamParameters_tx_power_tag 2 #define meshtastic_HamParameters_frequency_tag 3 @@ -781,8 +758,7 @@ X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \ X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \ X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \ X(a, STATIC, SINGULAR, BOOL, lock_now, 4) \ -X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) \ -X(a, STATIC, SINGULAR, BOOL, disable, 6) +X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) #define meshtastic_LockdownAuth_CALLBACK NULL #define meshtastic_LockdownAuth_DEFAULT NULL @@ -898,7 +874,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_size 511 #define meshtastic_HamParameters_size 47 #define meshtastic_KeyVerificationAdmin_size 25 -#define meshtastic_LockdownAuth_size 56 +#define meshtastic_LockdownAuth_size 54 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 1f49ef9f373..1d66f082335 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -360,21 +360,7 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset { /* Narrow Slow Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. Comparable link budget and data rate to LONG_FAST. */ - meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13, - /* Tiny Fast - Preset optimized for compliance with Amateur Radio restrictions with 20kHz bandwidth. - Many regions limit data transmission bandwidth in lower amateur bands (2 Meter). - Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths. - Only compatible with SX127x and SX126x chipsets. - Comparable link budget and data rate to LONG_FAST. */ - meshtastic_Config_LoRaConfig_ModemPreset_TINY_FAST = 14, - /* Tiny Slow - Preset optimized for compliance with Amateur Radio restrictions with 20kHz bandwidth. - Many regions limit data transmission bandwidth in lower amateur bands (2 Meter). - Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths. - Only compatible with SX127x and SX126x chipsets. - Comparable link budget and data rate to LONG_MODERATE. */ - meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW = 15 + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13 } meshtastic_Config_LoRaConfig_ModemPreset; typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode { @@ -767,8 +753,8 @@ extern "C" { #define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM+1)) #define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST -#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW -#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW+1)) +#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW +#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW+1)) #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 5b27f01d115..920cc3868bd 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -668,14 +668,7 @@ typedef enum _meshtastic_LockdownStatus_State { token's TTL. */ meshtastic_LockdownStatus_State_UNLOCKED = 3, /* Passphrase rejected. backoff_seconds is non-zero when rate-limited. */ - meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4, - /* Lockdown is supported by this firmware but not currently active - (no passphrase has been provisioned, or it was disabled via - AdminMessage.lockdown_auth.disable). The device is operating in - normal, non-encrypted mode. Clients render the lockdown-mode - toggle as OFF on receiving this. Distinct from NEEDS_PROVISION, - which is only used during an in-progress enable flow. */ - meshtastic_LockdownStatus_State_DISABLED = 5 + meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4 } meshtastic_LockdownStatus_State; /* Struct definitions */ @@ -1553,8 +1546,8 @@ extern "C" { #define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1)) #define _meshtastic_LockdownStatus_State_MIN meshtastic_LockdownStatus_State_STATE_UNSPECIFIED -#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_DISABLED -#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_DISABLED+1)) +#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_UNLOCK_FAILED +#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_UNLOCK_FAILED+1)) #define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource #define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource diff --git a/src/mesh/generated/meshtastic/mesh_beacon.pb.h b/src/mesh/generated/meshtastic/mesh_beacon.pb.h index f81d245226e..028d8269f50 100644 --- a/src/mesh/generated/meshtastic/mesh_beacon.pb.h +++ b/src/mesh/generated/meshtastic/mesh_beacon.pb.h @@ -27,6 +27,7 @@ typedef struct _meshtastic_MeshBeacon { meshtastic_Config_LoRaConfig_RegionCode offer_region; /* Optional modem preset being advertised. Combined with offer_region, tells a client "there is a mesh on this preset/region". */ + bool has_offer_preset; meshtastic_Config_LoRaConfig_ModemPreset offer_preset; } meshtastic_MeshBeacon; @@ -36,8 +37,8 @@ extern "C" { #endif /* Initializer values for message structs */ -#define meshtastic_MeshBeacon_init_default {"", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN} -#define meshtastic_MeshBeacon_init_zero {"", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN} +#define meshtastic_MeshBeacon_init_default {"", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN} +#define meshtastic_MeshBeacon_init_zero {"", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN} /* Field tags (for use in manual encoding/decoding) */ #define meshtastic_MeshBeacon_message_tag 1 @@ -50,7 +51,7 @@ extern "C" { X(a, STATIC, SINGULAR, STRING, message, 1) \ X(a, STATIC, OPTIONAL, MESSAGE, offer_channel, 2) \ X(a, STATIC, SINGULAR, UENUM, offer_region, 3) \ -X(a, STATIC, SINGULAR, UENUM, offer_preset, 4) +X(a, STATIC, OPTIONAL, UENUM, offer_preset, 4) #define meshtastic_MeshBeacon_CALLBACK NULL #define meshtastic_MeshBeacon_DEFAULT NULL #define meshtastic_MeshBeacon_offer_channel_MSGTYPE meshtastic_ChannelSettings diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index fad461db54e..4c8b13fe78c 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -473,6 +473,7 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { /* Optional region to advertise in the MeshBeacon offer_region field. */ meshtastic_Config_LoRaConfig_RegionCode broadcast_offer_region; /* Optional modem preset to advertise in the MeshBeacon offer_preset field. */ + bool has_broadcast_offer_preset; meshtastic_Config_LoRaConfig_ModemPreset broadcast_offer_preset; /* Channel settings (name + PSK) to use when sending beacons. If unset, beacons go out on the primary channel. */ @@ -482,6 +483,7 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { meshtastic_Config_LoRaConfig_RegionCode broadcast_on_region; /* Modem preset to use when sending beacons. If different from current config, the radio is temporarily switched for TX. */ + bool has_broadcast_on_preset; meshtastic_Config_LoRaConfig_ModemPreset broadcast_on_preset; /* How often to broadcast, in seconds. Min 3600 (1 h), max 259200 (72 h). Default 3600. */ uint32_t broadcast_interval_secs; @@ -642,7 +644,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_default {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_default {""} -#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, 0, 0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, 0, 0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_ModuleConfig_TAKConfig_init_default {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_default {0, "", _meshtastic_RemoteHardwarePinType_MIN} #define meshtastic_ModuleConfig_init_zero {0, {meshtastic_ModuleConfig_MQTTConfig_init_zero}} @@ -662,7 +664,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_zero {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_zero {""} -#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, 0, 0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, 0, 0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} #define meshtastic_ModuleConfig_TAKConfig_init_zero {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_zero {0, "", _meshtastic_RemoteHardwarePinType_MIN} @@ -1049,10 +1051,10 @@ X(a, STATIC, SINGULAR, UINT32, broadcast_send_as_node, 3) \ X(a, STATIC, SINGULAR, STRING, broadcast_message, 4) \ X(a, STATIC, OPTIONAL, MESSAGE, broadcast_offer_channel, 5) \ X(a, STATIC, SINGULAR, UENUM, broadcast_offer_region, 6) \ -X(a, STATIC, SINGULAR, UENUM, broadcast_offer_preset, 7) \ +X(a, STATIC, OPTIONAL, UENUM, broadcast_offer_preset, 7) \ X(a, STATIC, OPTIONAL, MESSAGE, broadcast_on_channel, 8) \ X(a, STATIC, SINGULAR, UENUM, broadcast_on_region, 9) \ -X(a, STATIC, SINGULAR, UENUM, broadcast_on_preset, 10) \ +X(a, STATIC, OPTIONAL, UENUM, broadcast_on_preset, 10) \ X(a, STATIC, SINGULAR, UINT32, broadcast_interval_secs, 11) #define meshtastic_ModuleConfig_MeshBeaconConfig_CALLBACK NULL #define meshtastic_ModuleConfig_MeshBeaconConfig_DEFAULT NULL diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index 9f1f45a320b..10b1fcb9bf9 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -1170,7 +1170,7 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) if (b.broadcast_interval_secs != 0 && b.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs) b.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs; // Validate broadcast_on_preset against broadcast_on_region (or current region if unset). - if (b.broadcast_on_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) { + if (b.has_broadcast_on_preset) { meshtastic_Config_LoRaConfig probe = config.lora; probe.use_preset = true; probe.modem_preset = b.broadcast_on_preset; @@ -1178,12 +1178,12 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) probe.region = b.broadcast_on_region; if (!RadioInterface::validateConfigLora(probe)) { LOG_WARN("Beacon: broadcast_on_preset %d invalid for region, clearing", b.broadcast_on_preset); - b.broadcast_on_preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; + b.has_broadcast_on_preset = false; b.has_broadcast_on_channel = false; } } // Validate broadcast_offer_preset against broadcast_offer_region (or current region if unset). - if (b.broadcast_offer_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) { + if (b.has_broadcast_offer_preset) { meshtastic_Config_LoRaConfig probe = config.lora; probe.use_preset = true; probe.modem_preset = b.broadcast_offer_preset; @@ -1191,7 +1191,7 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c) probe.region = b.broadcast_offer_region; if (!RadioInterface::validateConfigLora(probe)) { LOG_WARN("Beacon: broadcast_offer_preset %d invalid for region, clearing", b.broadcast_offer_preset); - b.broadcast_offer_preset = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; + b.has_broadcast_offer_preset = false; } } // Validate broadcast_offer_region is a known region code. diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 5ad342656a6..cbc785d80a1 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -3,6 +3,7 @@ #include "DisplayFormatters.h" #include "MeshService.h" #include "NodeDB.h" +#include "PowerFSM.h" #include "RTC.h" #include "RadioInterface.h" #include "Router.h" @@ -15,7 +16,7 @@ meshtastic_Config_LoRaConfig_ModemPreset MeshBeaconModule::originalModemPreset; uint16_t MeshBeaconModule::originalLoraChannel; meshtastic_Config_LoRaConfig_RegionCode MeshBeaconModule::originalRegion; -char MeshBeaconModule::originalChannelName[12]; +meshtastic_ChannelSettings MeshBeaconModule::originalPrimaryChannel; static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; @@ -24,7 +25,7 @@ static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Co { if (!p) return false; - for (auto &entry : targetRadioSettings) { + for (const auto &entry : targetRadioSettings) { if (entry.inUse && entry.id == p->id) { if (preset) *preset = entry.preset; @@ -45,7 +46,7 @@ MeshBeaconModule::MeshBeaconModule() originalModemPreset = config.lora.modem_preset; originalLoraChannel = config.lora.channel_num; originalRegion = config.lora.region; - strncpy(originalChannelName, channels.getPrimary().name, sizeof(originalChannelName)); + originalPrimaryChannel = channels.getPrimary(); } void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, @@ -93,8 +94,12 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ meshtastic_Config_LoRaConfig_ModemPreset targetPreset; uint16_t targetSlot; - if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot) && - (targetPreset != config.lora.modem_preset || targetSlot != config.lora.channel_num)) { + const auto channelDiffers = [&](const meshtastic_ChannelSettings &target) { + return strncmp(c->name, target.name, sizeof(c->name)) != 0 || c->psk.size != target.psk.size || + memcmp(c->psk.bytes, target.psk.bytes, c->psk.size) != 0 || c->channel_num != target.channel_num; + }; + + if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot) && true) { const auto &bcfg = moduleConfig.mesh_beacon; meshtastic_Config_LoRaConfig_RegionCode targetRegion; @@ -103,6 +108,23 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ else targetRegion = config.lora.region; + meshtastic_ChannelSettings targetChannel = *c; + if (bcfg.has_broadcast_on_channel) { + targetChannel.channel_num = bcfg.broadcast_on_channel.channel_num; + if (bcfg.broadcast_on_channel.name[0] != '\0') + strncpy(targetChannel.name, bcfg.broadcast_on_channel.name, sizeof(targetChannel.name) - 1); + if (bcfg.broadcast_on_channel.psk.size > 0) + targetChannel.psk = bcfg.broadcast_on_channel.psk; + } else if (targetChannel.name[0] == '\0') { + strncpy(targetChannel.name, DisplayFormatters::getModemPresetDisplayName(targetPreset, false, true), + sizeof(targetChannel.name) - 1); + } + targetChannel.name[sizeof(targetChannel.name) - 1] = '\0'; + + if (targetPreset == config.lora.modem_preset && targetSlot == config.lora.channel_num && + targetRegion == config.lora.region && !channelDiffers(targetChannel)) + return false; + // Guard: skip TX if preset is invalid for the target region. meshtastic_Config_LoRaConfig broadcastOnSetting = config.lora; broadcastOnSetting.use_preset = true; @@ -117,8 +139,7 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ originalModemPreset = config.lora.modem_preset; originalLoraChannel = config.lora.channel_num; originalRegion = config.lora.region; - strncpy(originalChannelName, c->name, sizeof(originalChannelName) - 1); - originalChannelName[sizeof(originalChannelName) - 1] = '\0'; + originalPrimaryChannel = *c; LOG_INFO("Beacon: switch radio for packet %#08lx to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot, targetRegion); @@ -126,16 +147,7 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ config.lora.channel_num = targetSlot; if (targetRegion != config.lora.region) config.lora.region = targetRegion; - memset(c->name, 0, sizeof(c->name)); - - if (bcfg.has_broadcast_on_channel && strlen(bcfg.broadcast_on_channel.name) > 0) { - strncpy(c->name, bcfg.broadcast_on_channel.name, sizeof(c->name) - 1); - } else if (originalChannelName[0] != '\0') { - strncpy(c->name, originalChannelName, sizeof(c->name) - 1); - } else { - strncpy(c->name, DisplayFormatters::getModemPresetDisplayName(targetPreset, false, true), sizeof(c->name) - 1); - } - c->name[sizeof(c->name) - 1] = '\0'; + *c = targetChannel; channels.fixupChannel(channels.getPrimaryIndex()); p->channel = channels.getHash(channels.getPrimaryIndex()); @@ -150,8 +162,8 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ config.lora.modem_preset = originalModemPreset; config.lora.channel_num = originalLoraChannel; config.lora.region = originalRegion; - memset(c->name, 0, sizeof(c->name)); - strncpy(c->name, originalChannelName, sizeof(c->name)); + *c = originalPrimaryChannel; + c->name[sizeof(c->name) - 1] = '\0'; channels.fixupChannel(channels.getPrimaryIndex()); iface->reconfigure(); @@ -186,6 +198,7 @@ void MeshBeaconBroadcastModule::rebuildCache() // 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; payloadCacheSize = (pb_size_t)pb_encode_to_bytes(payloadCache, sizeof(payloadCache), &meshtastic_MeshBeacon_msg, &beacon); @@ -197,8 +210,7 @@ void MeshBeaconBroadcastModule::sendBeacon() { const auto &bcfg = moduleConfig.mesh_beacon; - bool hasRadioContent = (bcfg.broadcast_offer_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) || - bcfg.has_broadcast_offer_channel || + bool hasRadioContent = bcfg.has_broadcast_offer_preset || bcfg.has_broadcast_offer_channel || (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET); if (bcfg.broadcast_message[0] == '\0' && !hasRadioContent) { LOG_DEBUG("Beacon: nothing to send (empty message, no offer), skipping"); @@ -234,11 +246,17 @@ void MeshBeaconBroadcastModule::sendBeacon() p->want_ack = false; p->rx_time = getValidTime(RTCQualityFromNet); - if (bcfg.broadcast_on_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN && - (bcfg.broadcast_on_preset != config.lora.modem_preset || - (bcfg.has_broadcast_on_channel && bcfg.broadcast_on_channel.channel_num != config.lora.channel_num))) { - uint16_t targetSlot = bcfg.has_broadcast_on_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num; - setTargetRadioSettings(p, bcfg.broadcast_on_preset, targetSlot); + const meshtastic_Config_LoRaConfig_ModemPreset targetPreset = + bcfg.has_broadcast_on_preset ? bcfg.broadcast_on_preset : config.lora.modem_preset; + const uint16_t targetSlot = bcfg.has_broadcast_on_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num; + const bool channelOverrideConfigured = + bcfg.has_broadcast_on_channel && (bcfg.broadcast_on_channel.name[0] != '\0' || bcfg.broadcast_on_channel.psk.size > 0 || + bcfg.broadcast_on_channel.channel_num != config.lora.channel_num); + if ((bcfg.has_broadcast_on_preset && targetPreset != config.lora.modem_preset) || + (bcfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && + bcfg.broadcast_on_region != config.lora.region) || + channelOverrideConfigured) { + setTargetRadioSettings(p, targetPreset, targetSlot); } LOG_INFO("Beacon: broadcast from=%#08lx msg='%.40s'", p->from, bcfg.broadcast_message); @@ -281,28 +299,32 @@ bool MeshBeaconListenerModule::wantPacket(const meshtastic_MeshPacket *p) bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) { - if (!b || strlen(b->message) == 0) + const bool hasOfferContent = + b && (b->has_offer_channel || b->offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || b->has_offer_preset); + if (!b || (strlen(b->message) == 0 && !hasOfferContent)) return false; - LOG_INFO("Beacon: received from %#08lx: '%.40s'", mp.from, b->message); + if (strlen(b->message) > 0) + LOG_INFO("Beacon: received from %#08lx: '%.40s'", mp.from, b->message); - // Deliver text to the local inbox as a TEXT_MESSAGE_APP packet. - meshtastic_MeshPacket *txt = packetPool.allocCopy(mp); - if (!txt) { - LOG_WARN("Beacon: failed to alloc inbox copy"); - return false; + if (strlen(b->message) > 0) { + // Deliver text to the local inbox as a TEXT_MESSAGE_APP packet. + meshtastic_MeshPacket *txt = packetPool.allocCopy(mp); + if (!txt) { + LOG_WARN("Beacon: failed to alloc inbox copy"); + return false; + } + txt->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + txt->to = nodeDB->getNodeNum(); + memset(txt->decoded.payload.bytes, 0, sizeof(txt->decoded.payload.bytes)); + txt->decoded.payload.size = (pb_size_t)strnlen(b->message, sizeof(b->message) - 1); + memcpy(txt->decoded.payload.bytes, b->message, txt->decoded.payload.size); + service->handleToRadio(*txt); + packetPool.release(txt); } - txt->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; - txt->to = nodeDB->getNodeNum(); - memset(txt->decoded.payload.bytes, 0, sizeof(txt->decoded.payload.bytes)); - txt->decoded.payload.size = (pb_size_t)strnlen(b->message, sizeof(b->message) - 1); - memcpy(txt->decoded.payload.bytes, b->message, txt->decoded.payload.size); - service->handleToRadio(*txt); - packetPool.release(txt); // Cache any offer for the client app — never auto-applied. - if (b->has_offer_channel || b->offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || - b->offer_preset != _meshtastic_Config_LoRaConfig_ModemPreset_MIN) { + if (hasOfferContent) { lastReceivedOffer.valid = true; lastReceivedOffer.sender = mp.from; lastReceivedOffer.has_channel = b->has_offer_channel; @@ -314,7 +336,8 @@ bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacke LOG_INFO("Beacon: stored offer from %#08lx (preset=%d)", mp.from, b->offer_preset); } - powerFSM.trigger(EVENT_RECEIVED_MSG); + if (strlen(b->message) > 0) + powerFSM.trigger(EVENT_RECEIVED_MSG); notifyObservers(&mp); return false; } diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index 0af14cf1190..58bafd38a83 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -54,7 +54,7 @@ class MeshBeaconModule static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; static uint16_t originalLoraChannel; static meshtastic_Config_LoRaConfig_RegionCode originalRegion; - static char originalChannelName[12]; // matches ChannelSettings.name max_size + static meshtastic_ChannelSettings originalPrimaryChannel; }; /** @@ -81,7 +81,7 @@ class MeshBeaconBroadcastModule : private MeshBeaconModule, void rebuildCache(); bool payloadCacheDirty = true; - uint8_t payloadCache[meshtastic_MeshBeacon_size]; + uint8_t payloadCache[meshtastic_MeshBeacon_size] = {}; pb_size_t payloadCacheSize = 0; }; extern MeshBeaconBroadcastModule *meshBeaconBroadcastModule; diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 8a64ab3ca37..bb59e8aabbf 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -153,13 +153,13 @@ static void test_adminValidation_turboPresetOnEU868_isCleared(void) meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; bcfg.broadcast_enabled = true; + bcfg.has_broadcast_on_preset = true; bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); TEST_ASSERT_TRUE(moduleConfig.has_mesh_beacon); - TEST_ASSERT_EQUAL_MESSAGE(_meshtastic_Config_LoRaConfig_ModemPreset_MIN, moduleConfig.mesh_beacon.broadcast_on_preset, - "SHORT_TURBO must be cleared for EU_868"); + TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.has_broadcast_on_preset, "SHORT_TURBO must be cleared for EU_868"); } // Same check for LONG_TURBO. @@ -168,11 +168,12 @@ static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_on_preset = true; bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_EQUAL(_meshtastic_Config_LoRaConfig_ModemPreset_MIN, moduleConfig.mesh_beacon.broadcast_on_preset); + TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); } // A turbo preset is valid on US (which uses PROFILE_STD) → must be kept. @@ -183,10 +184,12 @@ static void test_adminValidation_turboPresetOnUS_isAccepted(void) initRegion(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_on_preset = true; bcfg.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO; testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_on_preset); TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); } @@ -216,21 +219,20 @@ static void test_adminValidation_validOfferRegion_isPreserved(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_offer_region); } -// broadcast_message exactly 100 chars (i.e. length 100 including terminator -// means the last byte is at index 99) → last char must be NUL after truncation. -static void test_adminValidation_messageTooLong_isTruncatedAt99(void) +// broadcast_message is hard-capped at 100 chars. +static void test_adminValidation_messageTooLong_isTruncatedAt100(void) { resetConfig(); meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; - // Fill with 'A' up to the full array size; admin must enforce ≤99 chars. + // Fill with 'A' up to the full array size; admin must enforce ≤100 chars. memset(bcfg.broadcast_message, 'A', sizeof(bcfg.broadcast_message)); bcfg.broadcast_message[sizeof(bcfg.broadcast_message) - 1] = '\0'; // pb_decode guarantee testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - // Byte at index 99 must be NUL (length capped at 99). - TEST_ASSERT_EQUAL('\0', moduleConfig.mesh_beacon.broadcast_message[99]); + // Byte at index 100 must be NUL (length capped at 100). + TEST_ASSERT_EQUAL('\0', moduleConfig.mesh_beacon.broadcast_message[100]); // Bytes before it should still be 'A'. TEST_ASSERT_EQUAL('A', moduleConfig.mesh_beacon.broadcast_message[0]); } @@ -248,8 +250,8 @@ static void test_adminValidation_intervalTooLow_isClamped(void) TEST_ASSERT_EQUAL_UINT32(3600, moduleConfig.mesh_beacon.broadcast_interval_secs); } -// broadcast_interval_secs above maximum → clamped down to 259200. -static void test_adminValidation_intervalTooHigh_isClamped(void) +// broadcast_interval_secs above the recommended range is preserved as-is. +static void test_adminValidation_intervalTooHigh_isPreserved(void) { resetConfig(); @@ -258,7 +260,22 @@ static void test_adminValidation_intervalTooHigh_isClamped(void) testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); - TEST_ASSERT_EQUAL_UINT32(259200, moduleConfig.mesh_beacon.broadcast_interval_secs); + TEST_ASSERT_EQUAL_UINT32(999999, moduleConfig.mesh_beacon.broadcast_interval_secs); +} + +// LONG_FAST is a valid preset and must be preserved when explicitly configured. +static void test_adminValidation_longFastOfferPreset_isPreserved(void) +{ + resetConfig(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.has_broadcast_offer_preset = true; + bcfg.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_TRUE(moduleConfig.mesh_beacon.has_broadcast_offer_preset); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, moduleConfig.mesh_beacon.broadcast_offer_preset); } // Zero interval (unset) is special-cased and must not be clamped. @@ -341,6 +358,7 @@ static void test_broadcaster_rebuildCache_offerFieldsEncoded(void) resetConfig(); moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; strncpy(moduleConfig.mesh_beacon.broadcast_message, "offer-test", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); @@ -442,6 +460,7 @@ static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) resetConfig(); moduleConfig.has_mesh_beacon = true; strncpy(moduleConfig.mesh_beacon.broadcast_message, "portnum-check", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; @@ -460,6 +479,7 @@ static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) const char *msg = "plain-text-beacon"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); // broadcast_on_preset set, but no offer — should still be TEXT_MESSAGE_APP + moduleConfig.mesh_beacon.has_broadcast_on_preset = true; moduleConfig.mesh_beacon.broadcast_on_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; @@ -479,6 +499,7 @@ static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void) moduleConfig.has_mesh_beacon = true; const char *msg = "Greetings from the beacon"; strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; MeshBeaconBroadcastModuleTestShim bcast; @@ -494,6 +515,21 @@ static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void) TEST_ASSERT_EQUAL_STRING(msg, decoded.message); } +// Offer-only beacons must still be emitted on MESH_BEACON_APP. +static void test_broadcaster_sendBeacon_offerOnly_isSent(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); +} + // runOnce with broadcast_enabled=true must send exactly one packet. static void test_broadcaster_runOnce_sendsWhenEnabled(void) { @@ -555,6 +591,7 @@ static void test_listener_receiveWithOffer_cachesOffer(void) meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; strncpy(b.message, "Join us on US/MEDIUM_FAST", sizeof(b.message) - 1); + b.has_offer_preset = true; b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST; b.offer_region = meshtastic_Config_LoRaConfig_RegionCode_US; @@ -592,8 +629,8 @@ static void test_listener_receiveWithChannelOffer_setsHasChannel(void) TEST_ASSERT_EQUAL_UINT32(5, MeshBeaconListenerModule::lastReceivedOffer.channel.channel_num); } -// An empty message must be silently dropped; cache must stay invalid. -static void test_listener_emptyMessage_isDropped(void) +// An empty message with no offer content must be silently dropped. +static void test_listener_emptyMessageWithoutOffer_isDropped(void) { resetConfig(); moduleConfig.has_mesh_beacon = true; @@ -603,7 +640,6 @@ static void test_listener_emptyMessage_isDropped(void) MeshBeaconListenerModule::lastReceivedOffer = {}; meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; - b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; // message field intentionally left blank meshtastic_MeshPacket mp = makeBeaconPacket(b); @@ -612,6 +648,27 @@ static void test_listener_emptyMessage_isDropped(void) TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Empty message must not update offer cache"); } +// Offer-only beacons must still update the cached offer. +static void test_listener_offerOnly_isCached(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + b.has_offer_preset = true; + b.offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + TEST_ASSERT_TRUE(MeshBeaconListenerModule::lastReceivedOffer.valid); + TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset); +} + // A null MeshBeacon pointer must be handled gracefully. static void test_listener_nullBeacon_isDropped(void) { @@ -641,7 +698,7 @@ static void test_listener_receiveWithNoOffer_cacheStaysInvalid(void) meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; strncpy(b.message, "No offer here", sizeof(b.message) - 1); - // offer_preset == 0, has_offer_channel == false + // has_offer_preset == false, has_offer_channel == false meshtastic_MeshPacket mp = makeBeaconPacket(b); listener.handleReceivedProtobuf(mp, &b); @@ -731,10 +788,11 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted); RUN_TEST(test_adminValidation_unknownOfferRegion_isCleared); RUN_TEST(test_adminValidation_validOfferRegion_isPreserved); - RUN_TEST(test_adminValidation_messageTooLong_isTruncatedAt99); + RUN_TEST(test_adminValidation_messageTooLong_isTruncatedAt100); RUN_TEST(test_adminValidation_intervalTooLow_isClamped); - RUN_TEST(test_adminValidation_intervalTooHigh_isClamped); + RUN_TEST(test_adminValidation_intervalTooHigh_isPreserved); RUN_TEST(test_adminValidation_intervalZero_isNotClamped); + RUN_TEST(test_adminValidation_longFastOfferPreset_isPreserved); RUN_TEST(test_adminValidation_validSave_invalidatesCache); // Broadcaster cache @@ -751,13 +809,15 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_sendBeacon_usesBeaconPortnum); RUN_TEST(test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum); RUN_TEST(test_broadcaster_sendBeacon_payloadDecodesCorrectly); + RUN_TEST(test_broadcaster_sendBeacon_offerOnly_isSent); RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled); RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled); // Listener RUN_TEST(test_listener_receiveWithOffer_cachesOffer); RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel); - RUN_TEST(test_listener_emptyMessage_isDropped); + RUN_TEST(test_listener_emptyMessageWithoutOffer_isDropped); + RUN_TEST(test_listener_offerOnly_isCached); RUN_TEST(test_listener_nullBeacon_isDropped); RUN_TEST(test_listener_receiveWithNoOffer_cacheStaysInvalid); RUN_TEST(test_listener_wantPacket_falseWhenDisabled); diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 642dfdb10aa..6f7566d2c4b 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -13,6 +13,9 @@ build_src_filter = ${portduino_base.build_src_filter} [env:native] extends = native_base +lib_deps = + ${native_base.lib_deps} + throwtheswitch/Unity @ ^2.6.1 ; The pkg-config commands below optionally add link flags. ; the || : is just a "or run the null command" to avoid returning an error code build_flags = ${native_base.build_flags} From 41bd325df9572302b74042cb54eee1a4f18235d9 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 06:28:18 +0100 Subject: [PATCH 13/24] oops --- variants/native/portduino/platformio.ini | 3 --- 1 file changed, 3 deletions(-) diff --git a/variants/native/portduino/platformio.ini b/variants/native/portduino/platformio.ini index 6f7566d2c4b..642dfdb10aa 100644 --- a/variants/native/portduino/platformio.ini +++ b/variants/native/portduino/platformio.ini @@ -13,9 +13,6 @@ build_src_filter = ${portduino_base.build_src_filter} [env:native] extends = native_base -lib_deps = - ${native_base.lib_deps} - throwtheswitch/Unity @ ^2.6.1 ; The pkg-config commands below optionally add link flags. ; the || : is just a "or run the null command" to avoid returning an error code build_flags = ${native_base.build_flags} From 6ef0b5a59145d2b403efc24ade31feac97b51e80 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 07:17:22 +0100 Subject: [PATCH 14/24] Enhance unit tests for MeshBeaconModule with detailed validation checks and output formatting --- test/test_mesh_beacon/test_main.cpp | 195 ++++++++++++++++++++++------ 1 file changed, 157 insertions(+), 38 deletions(-) diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index bb59e8aabbf..2357919e7f4 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -29,6 +29,18 @@ #include #include +// --------------------------------------------------------------------------- +// Test output helper — Unity swallows printf; TEST_MESSAGE is the only output +// that appears in results. Use TEST_MSG_FMT for formatted diagnostic lines. +// --------------------------------------------------------------------------- +#define MSG_BUF_LEN 256 +#define TEST_MSG_FMT(fmt, ...) \ + do { \ + char _buf[MSG_BUF_LEN]; \ + snprintf(_buf, sizeof(_buf), fmt, ##__VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + namespace { @@ -65,6 +77,10 @@ class MockRouter : public Router return ERRNO_OK; } + // Locally-addressed packets land here instead of send(). Release immediately + // rather than queuing into fromRadioQueue (which is never drained in tests). + void enqueueReceivedMessage(meshtastic_MeshPacket *p) override { packetPool.release(p); } + std::vector sentPackets; }; @@ -146,7 +162,11 @@ static void resetConfig() // Group 1: AdminModule config validation — bad inputs must be sanitised // =========================================================================== -// broadcast_on_preset=SHORT_TURBO is not in EU_868's preset list → zeroed out. +/** + * Verify SHORT_TURBO is rejected when the region is EU_868 (turbo presets are not in that + * region's allowed preset set). Important to catch regressions where admin stores unlawful + * radio settings that would violate regional radio regulations. + */ static void test_adminValidation_turboPresetOnEU868_isCleared(void) { resetConfig(); @@ -162,7 +182,10 @@ static void test_adminValidation_turboPresetOnEU868_isCleared(void) TEST_ASSERT_FALSE_MESSAGE(moduleConfig.mesh_beacon.has_broadcast_on_preset, "SHORT_TURBO must be cleared for EU_868"); } -// Same check for LONG_TURBO. +/** + * Verify LONG_TURBO is also cleared for EU_868, not just SHORT_TURBO. + * Important to confirm rejection covers the entire turbo preset family rather than one variant. + */ static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) { resetConfig(); @@ -176,7 +199,10 @@ static void test_adminValidation_longTurboPresetOnEU868_isCleared(void) TEST_ASSERT_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); } -// A turbo preset is valid on US (which uses PROFILE_STD) → must be kept. +/** + * Verify a turbo preset passes validation for US (PROFILE_STD allows all presets). + * Important because the same preset that is illegal in EU_868 must be preserved in permissive regions. + */ static void test_adminValidation_turboPresetOnUS_isAccepted(void) { resetConfig(); @@ -193,7 +219,11 @@ static void test_adminValidation_turboPresetOnUS_isAccepted(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_SHORT_TURBO, moduleConfig.mesh_beacon.broadcast_on_preset); } -// broadcast_offer_region with an unknown code (255) → cleared to UNSET. +/** + * Verify an out-of-range region code (255) is sanitised to UNSET rather than stored verbatim. + * Important to prevent invalid proto enum values from reaching the broadcaster and being broadcast + * over the air. + */ static void test_adminValidation_unknownOfferRegion_isCleared(void) { resetConfig(); @@ -206,7 +236,10 @@ static void test_adminValidation_unknownOfferRegion_isCleared(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_UNSET, moduleConfig.mesh_beacon.broadcast_offer_region); } -// A valid broadcast_offer_region (US) → preserved as-is. +/** + * Verify a known-good offer region (US) is written through unchanged after admin validation. + * Important as a positive-path control alongside the rejection tests. + */ static void test_adminValidation_validOfferRegion_isPreserved(void) { resetConfig(); @@ -219,7 +252,10 @@ static void test_adminValidation_validOfferRegion_isPreserved(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, moduleConfig.mesh_beacon.broadcast_offer_region); } -// broadcast_message is hard-capped at 100 chars. +/** + * Verify broadcast_message is hard-capped at 100 characters (NUL forced at index 100). + * Important to prevent oversized beacon payloads from abusing airtime across the mesh. + */ static void test_adminValidation_messageTooLong_isTruncatedAt100(void) { resetConfig(); @@ -237,7 +273,10 @@ static void test_adminValidation_messageTooLong_isTruncatedAt100(void) TEST_ASSERT_EQUAL('A', moduleConfig.mesh_beacon.broadcast_message[0]); } -// broadcast_interval_secs below minimum → clamped up to 3600. +/** + * Verify any non-zero interval below 3600 s is clamped up to the 1-hour minimum. + * Important to prevent high-rate beacon floods from a misconfigured or malicious client. + */ static void test_adminValidation_intervalTooLow_isClamped(void) { resetConfig(); @@ -250,7 +289,10 @@ static void test_adminValidation_intervalTooLow_isClamped(void) TEST_ASSERT_EQUAL_UINT32(3600, moduleConfig.mesh_beacon.broadcast_interval_secs); } -// broadcast_interval_secs above the recommended range is preserved as-is. +/** + * Verify an interval above the minimum is stored as-is without modification. + * Important to confirm the clamp is one-sided (lower bound only, no upper bound enforced). + */ static void test_adminValidation_intervalTooHigh_isPreserved(void) { resetConfig(); @@ -263,7 +305,10 @@ static void test_adminValidation_intervalTooHigh_isPreserved(void) TEST_ASSERT_EQUAL_UINT32(999999, moduleConfig.mesh_beacon.broadcast_interval_secs); } -// LONG_FAST is a valid preset and must be preserved when explicitly configured. +/** + * Verify LONG_FAST (enum value 0) survives admin validation without being treated as 'absent'. + * Important to guard the has_broadcast_offer_preset presence-flag fix against zero-value erasure. + */ static void test_adminValidation_longFastOfferPreset_isPreserved(void) { resetConfig(); @@ -278,7 +323,11 @@ static void test_adminValidation_longFastOfferPreset_isPreserved(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, moduleConfig.mesh_beacon.broadcast_offer_preset); } -// Zero interval (unset) is special-cased and must not be clamped. +/** + * Verify that interval 0 (the documented 'use default' sentinel) is not raised to 3600 by the clamp. + * Important because 0 and 3600 have different runtime semantics in runOnce via + * Default::getConfiguredOrDefault. + */ static void test_adminValidation_intervalZero_isNotClamped(void) { resetConfig(); @@ -291,7 +340,10 @@ static void test_adminValidation_intervalZero_isNotClamped(void) TEST_ASSERT_EQUAL_UINT32(0, moduleConfig.mesh_beacon.broadcast_interval_secs); } -// After a successful save the broadcaster's cache must be invalidated. +/** + * Verify that saving a new beacon config marks the broadcaster's payload cache dirty. + * Important so the next TX re-encodes from the latest config rather than a pre-save stale snapshot. + */ static void test_adminValidation_validSave_invalidatesCache(void) { resetConfig(); @@ -316,7 +368,10 @@ static void test_adminValidation_validSave_invalidatesCache(void) // Group 2: Broadcaster payload cache // =========================================================================== -// rebuildCache encodes a non-empty payload when message is set. +/** + * Verify rebuildCache produces at least one encoded byte when broadcast_message is set. + * Important as the most basic liveness check for the protobuf encoding path. + */ static void test_broadcaster_rebuildCache_producesNonEmptyPayload(void) { resetConfig(); @@ -332,7 +387,10 @@ static void test_broadcaster_rebuildCache_producesNonEmptyPayload(void) TEST_ASSERT_GREATER_THAN_MESSAGE(0, (int)bcast.payloadCacheSize, "rebuildCache must produce a non-empty payload"); } -// The encoded bytes decode back to the same message field. +/** + * Verify the cached bytes round-trip through pb_decode back to the original message string. + * Important to catch any protobuf field-tag or wire-type regression in the encoding path. + */ static void test_broadcaster_rebuildCache_payloadDecodesCorrectly(void) { resetConfig(); @@ -352,7 +410,10 @@ static void test_broadcaster_rebuildCache_payloadDecodesCorrectly(void) TEST_ASSERT_EQUAL_STRING(msg, decoded.message); } -// Offer fields round-trip through the cache. +/** + * Verify offer_region and offer_preset are present in the encoded cache payload. + * Important to confirm the offer-carrying path correctly uses the has_broadcast_offer_preset flag. + */ static void test_broadcaster_rebuildCache_offerFieldsEncoded(void) { resetConfig(); @@ -373,7 +434,10 @@ static void test_broadcaster_rebuildCache_offerFieldsEncoded(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_MEDIUM_FAST, decoded.offer_preset); } -// invalidateCache sets payloadCacheDirty. +/** + * Verify invalidateCache flips payloadCacheDirty back to true after a successful rebuild. + * Important to confirm the cache-invalidation contract relied on by admin saves and config observers. + */ static void test_broadcaster_invalidateCache_setsDirtyFlag(void) { resetConfig(); @@ -387,7 +451,10 @@ static void test_broadcaster_invalidateCache_setsDirtyFlag(void) TEST_ASSERT_TRUE(bcast.payloadCacheDirty); } -// Calling rebuildCache twice without invalidation must leave dirty=false. +/** + * Verify calling rebuildCache a second time without an intervening invalidation is a no-op. + * Important to prevent spurious re-encodes when config-observer callbacks fire multiple times. + */ static void test_broadcaster_rebuildCache_idempotent(void) { resetConfig(); @@ -408,7 +475,10 @@ static void test_broadcaster_rebuildCache_idempotent(void) // Group 3: Broadcaster sendBeacon — packet structure // =========================================================================== -// sendBeacon with broadcast_send_as_node=0 uses the local node number. +/** + * Verify the 'from' field defaults to the local node number when broadcast_send_as_node is 0. + * Important for correct source attribution in peer node tables that receive the beacon. + */ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) { resetConfig(); @@ -424,7 +494,10 @@ static void test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset(void) TEST_ASSERT_EQUAL_UINT32(kLocalNode, mockRouter->sentPackets[0].from); } -// sendBeacon respects a custom broadcast_send_as_node. +/** + * Verify broadcast_send_as_node overrides the 'from' field in the emitted packet. + * Important for the admin use-case of broadcasting a beacon on behalf of another node. + */ static void test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet(void) { resetConfig(); @@ -440,7 +513,10 @@ static void test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet(void) TEST_ASSERT_EQUAL_UINT32(kRemoteNode, mockRouter->sentPackets[0].from); } -// sendBeacon must address the packet to NODENUM_BROADCAST. +/** + * Verify the 'to' field is always NODENUM_BROADCAST regardless of other settings. + * Important because beacons are mesh-wide announcements and must never be addressed to a single peer. + */ static void test_broadcaster_sendBeacon_addressedToBroadcast(void) { resetConfig(); @@ -454,7 +530,10 @@ static void test_broadcaster_sendBeacon_addressedToBroadcast(void) TEST_ASSERT_EQUAL_UINT32(NODENUM_BROADCAST, mockRouter->sentPackets[0].to); } -// sendBeacon uses MESH_BEACON_APP portnum when a modem preset is offered. +/** + * Verify MESH_BEACON_APP portnum is used when the packet carries a radio offer payload. + * Important so receivers use the structured protobuf decoder rather than treating it as raw text. + */ static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) { resetConfig(); @@ -470,8 +549,11 @@ static void test_broadcaster_sendBeacon_usesBeaconPortnum(void) TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); } -// sendBeacon falls back to TEXT_MESSAGE_APP when no radio settings are offered, -// even when broadcast_on_preset is configured (that controls TX radio, not portnum). +/** + * Verify TEXT_MESSAGE_APP portnum is used when no offer content is present, even if + * broadcast_on_preset is set (that field governs which radio config to use for TX, not portnum). + * Important so standard clients display plain-text beacons without needing a MESH_BEACON_APP decoder. + */ static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) { resetConfig(); @@ -492,7 +574,10 @@ static void test_broadcaster_sendBeacon_fallsBackToTextMessagePortnum(void) TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)p.decoded.payload.bytes, p.decoded.payload.size); } -// sendBeacon payload decodes back to the correct message string (MESH_BEACON_APP path). +/** + * Verify the MESH_BEACON_APP payload decodes back to the original message string. + * Important to catch encode/decode regressions in the full sendBeacon → wire → pb_decode round-trip. + */ static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void) { resetConfig(); @@ -515,7 +600,11 @@ static void test_broadcaster_sendBeacon_payloadDecodesCorrectly(void) TEST_ASSERT_EQUAL_STRING(msg, decoded.message); } -// Offer-only beacons must still be emitted on MESH_BEACON_APP. +/** + * Verify a beacon with offer fields but no message text is still emitted on MESH_BEACON_APP. + * Important because offer-only beacons are a valid use case that the early-return guard must not + * suppress. + */ static void test_broadcaster_sendBeacon_offerOnly_isSent(void) { resetConfig(); @@ -530,7 +619,10 @@ static void test_broadcaster_sendBeacon_offerOnly_isSent(void) TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); } -// runOnce with broadcast_enabled=true must send exactly one packet. +/** + * Verify runOnce sends exactly one packet when broadcast_enabled is true. + * Important to confirm the OSThread timer callback drives the full send path end-to-end. + */ static void test_broadcaster_runOnce_sendsWhenEnabled(void) { resetConfig(); @@ -546,7 +638,10 @@ static void test_broadcaster_runOnce_sendsWhenEnabled(void) TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); } -// runOnce with broadcast_enabled=false must not send anything. +/** + * Verify runOnce transmits nothing when broadcast_enabled is false. + * Important to confirm the feature can be cleanly disabled via remote admin without rebooting. + */ static void test_broadcaster_runOnce_silentWhenDisabled(void) { resetConfig(); @@ -579,7 +674,10 @@ static meshtastic_MeshPacket makeBeaconPacket(const meshtastic_MeshBeacon &b, No return p; } -// Receiving a beacon with an offer stores it in lastReceivedOffer. +/** + * Verify a beacon carrying preset and region offer fields is stored in lastReceivedOffer. + * Important to confirm the client app's offer cache is populated correctly for join-offer UI flows. + */ static void test_listener_receiveWithOffer_cachesOffer(void) { resetConfig(); @@ -604,7 +702,10 @@ static void test_listener_receiveWithOffer_cachesOffer(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_RegionCode_US, MeshBeaconListenerModule::lastReceivedOffer.region); } -// Receiving a beacon with a channel offer stores the has_channel flag. +/** + * Verify a beacon with a full ChannelSettings offer sets has_channel and copies the channel struct. + * Important because the client app checks has_channel before rendering a channel join offer. + */ static void test_listener_receiveWithChannelOffer_setsHasChannel(void) { resetConfig(); @@ -629,7 +730,10 @@ static void test_listener_receiveWithChannelOffer_setsHasChannel(void) TEST_ASSERT_EQUAL_UINT32(5, MeshBeaconListenerModule::lastReceivedOffer.channel.channel_num); } -// An empty message with no offer content must be silently dropped. +/** + * Verify a beacon with neither message text nor offer fields is silently discarded. + * Important to avoid spurious cache updates and wasted inbox copies from empty-payload packets. + */ static void test_listener_emptyMessageWithoutOffer_isDropped(void) { resetConfig(); @@ -648,7 +752,10 @@ static void test_listener_emptyMessageWithoutOffer_isDropped(void) TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "Empty message must not update offer cache"); } -// Offer-only beacons must still update the cached offer. +/** + * Verify a LONG_FAST offer (preset enum value 0) with no message still populates the offer cache. + * Important to guard the has_offer_preset fix — LONG_FAST must not be treated as 'no offer present'. + */ static void test_listener_offerOnly_isCached(void) { resetConfig(); @@ -669,7 +776,10 @@ static void test_listener_offerOnly_isCached(void) TEST_ASSERT_EQUAL(meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST, MeshBeaconListenerModule::lastReceivedOffer.preset); } -// A null MeshBeacon pointer must be handled gracefully. +/** + * Verify a null MeshBeacon pointer is handled gracefully and returns false without a crash. + * Important to guard against the ProtobufModule base class passing nullptr on a decode failure. + */ static void test_listener_nullBeacon_isDropped(void) { resetConfig(); @@ -686,7 +796,10 @@ static void test_listener_nullBeacon_isDropped(void) TEST_ASSERT_FALSE(MeshBeaconListenerModule::lastReceivedOffer.valid); } -// Receiving a beacon with no offer fields must not mark the cache valid. +/** + * Verify a text-only beacon (no offer fields set) does not mark the offer cache valid. + * Important to prevent the client from showing a join dialog in response to plain-text beacons. + */ static void test_listener_receiveWithNoOffer_cacheStaysInvalid(void) { resetConfig(); @@ -706,7 +819,10 @@ static void test_listener_receiveWithNoOffer_cacheStaysInvalid(void) TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "No offer fields → cache must stay invalid"); } -// wantPacket returns false when listen_enabled is false. +/** + * Verify wantPacket returns false for MESH_BEACON_APP when listen_enabled is false. + * Important to confirm the module opts out of processing when its config flag is cleared. + */ static void test_listener_wantPacket_falseWhenDisabled(void) { resetConfig(); @@ -721,7 +837,10 @@ static void test_listener_wantPacket_falseWhenDisabled(void) TEST_ASSERT_FALSE(listener.wantPacket(&mp)); } -// wantPacket returns true when listen_enabled is true and portnum matches. +/** + * Verify wantPacket returns true for MESH_BEACON_APP packets when listen_enabled is true. + * Important as a basic routing sanity check confirming the module is registered for its portnum. + */ static void test_listener_wantPacket_trueWhenEnabled(void) { resetConfig(); @@ -782,7 +901,7 @@ BEACON_TEST_ENTRY void setup() initializeTestEnvironment(); UNITY_BEGIN(); - // Admin validation + TEST_MESSAGE("=== AdminModule config validation ==="); RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted); @@ -795,14 +914,14 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_adminValidation_longFastOfferPreset_isPreserved); RUN_TEST(test_adminValidation_validSave_invalidatesCache); - // Broadcaster cache + TEST_MESSAGE("=== Broadcaster payload cache ==="); RUN_TEST(test_broadcaster_rebuildCache_producesNonEmptyPayload); RUN_TEST(test_broadcaster_rebuildCache_payloadDecodesCorrectly); RUN_TEST(test_broadcaster_rebuildCache_offerFieldsEncoded); RUN_TEST(test_broadcaster_invalidateCache_setsDirtyFlag); RUN_TEST(test_broadcaster_rebuildCache_idempotent); - // Broadcaster send + TEST_MESSAGE("=== Broadcaster sendBeacon ==="); RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset); RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast); @@ -813,7 +932,7 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled); RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled); - // Listener + TEST_MESSAGE("=== Listener offer caching ==="); RUN_TEST(test_listener_receiveWithOffer_cachesOffer); RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel); RUN_TEST(test_listener_emptyMessageWithoutOffer_isDropped); From 2a8a643261d3d0fd29bddf9992d263e178b8868a Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 08:47:28 +0100 Subject: [PATCH 15/24] new lines. Why not? --- test/test_mesh_beacon/test_main.cpp | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 2357919e7f4..212dc33a227 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -901,7 +901,9 @@ BEACON_TEST_ENTRY void setup() initializeTestEnvironment(); UNITY_BEGIN(); + TEST_MESSAGE(""); TEST_MESSAGE("=== AdminModule config validation ==="); + RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_turboPresetOnUS_isAccepted); @@ -914,14 +916,18 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_adminValidation_longFastOfferPreset_isPreserved); RUN_TEST(test_adminValidation_validSave_invalidatesCache); + TEST_MESSAGE(""); TEST_MESSAGE("=== Broadcaster payload cache ==="); + RUN_TEST(test_broadcaster_rebuildCache_producesNonEmptyPayload); RUN_TEST(test_broadcaster_rebuildCache_payloadDecodesCorrectly); RUN_TEST(test_broadcaster_rebuildCache_offerFieldsEncoded); RUN_TEST(test_broadcaster_invalidateCache_setsDirtyFlag); RUN_TEST(test_broadcaster_rebuildCache_idempotent); + TEST_MESSAGE(""); TEST_MESSAGE("=== Broadcaster sendBeacon ==="); + RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset); RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); RUN_TEST(test_broadcaster_sendBeacon_addressedToBroadcast); @@ -932,7 +938,9 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled); RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled); + TEST_MESSAGE(""); TEST_MESSAGE("=== Listener offer caching ==="); + RUN_TEST(test_listener_receiveWithOffer_cachesOffer); RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel); RUN_TEST(test_listener_emptyMessageWithoutOffer_isDropped); From d27a9e6011ec0b78291c62b60d7cfdb04eaeeb75 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 11:44:51 +0100 Subject: [PATCH 16/24] finally --- test/test_mesh_beacon/test_main.cpp | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 212dc33a227..472f593e5c0 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -24,6 +24,7 @@ #include "airtime.h" #include "modules/AdminModule.h" #include "modules/MeshBeaconModule.h" +#include #include #include #include @@ -37,7 +38,7 @@ #define TEST_MSG_FMT(fmt, ...) \ do { \ char _buf[MSG_BUF_LEN]; \ - snprintf(_buf, sizeof(_buf), fmt, ##__VA_ARGS__); \ + println(_buf, sizeof(_buf), "/n", ##__VA_ARGS__); \ TEST_MESSAGE(_buf); \ } while (0) @@ -901,8 +902,7 @@ BEACON_TEST_ENTRY void setup() initializeTestEnvironment(); UNITY_BEGIN(); - TEST_MESSAGE(""); - TEST_MESSAGE("=== AdminModule config validation ==="); + printf("\n=== AdminModule config validation ===\n"); RUN_TEST(test_adminValidation_turboPresetOnEU868_isCleared); RUN_TEST(test_adminValidation_longTurboPresetOnEU868_isCleared); @@ -916,8 +916,7 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_adminValidation_longFastOfferPreset_isPreserved); RUN_TEST(test_adminValidation_validSave_invalidatesCache); - TEST_MESSAGE(""); - TEST_MESSAGE("=== Broadcaster payload cache ==="); + printf("\n=== Broadcaster payload cache ===\n"); RUN_TEST(test_broadcaster_rebuildCache_producesNonEmptyPayload); RUN_TEST(test_broadcaster_rebuildCache_payloadDecodesCorrectly); @@ -925,8 +924,7 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_invalidateCache_setsDirtyFlag); RUN_TEST(test_broadcaster_rebuildCache_idempotent); - TEST_MESSAGE(""); - TEST_MESSAGE("=== Broadcaster sendBeacon ==="); + printf("\n=== Broadcaster sendBeacon ===\n"); RUN_TEST(test_broadcaster_sendBeacon_fromIsLocalNodeWhenUnset); RUN_TEST(test_broadcaster_sendBeacon_fromIsCustomNodeWhenSet); @@ -938,8 +936,7 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_runOnce_sendsWhenEnabled); RUN_TEST(test_broadcaster_runOnce_silentWhenDisabled); - TEST_MESSAGE(""); - TEST_MESSAGE("=== Listener offer caching ==="); + printf("\n=== Listener offer caching === \n"); RUN_TEST(test_listener_receiveWithOffer_cachesOffer); RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel); From f23fbd62e78d46b28ae53b8f5f02a0f51b793712 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Wed, 3 Jun 2026 22:09:28 +0100 Subject: [PATCH 17/24] legacy mode activate! --- src/mesh/NodeDB.cpp | 4 + .../generated/meshtastic/module_config.pb.h | 13 +- src/modules/MeshBeaconModule.cpp | 128 ++++++++++++------ src/modules/MeshBeaconModule.h | 6 + test/test_mesh_beacon/test_main.cpp | 101 ++++++++++++++ userPrefs.jsonc | 1 + 6 files changed, 209 insertions(+), 44 deletions(-) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 97eca56ef16..10100a5d858 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1181,6 +1181,7 @@ void NodeDB::installDefaultModuleConfig() moduleConfig.has_mesh_beacon = true; moduleConfig.mesh_beacon.listen_enabled = true; moduleConfig.mesh_beacon.broadcast_enabled = false; + moduleConfig.mesh_beacon.broadcast_legacy_split = true; #ifdef USERPREFS_MESH_BEACON_LISTEN_ENABLED moduleConfig.mesh_beacon.listen_enabled = USERPREFS_MESH_BEACON_LISTEN_ENABLED; #endif @@ -1234,6 +1235,9 @@ void NodeDB::installDefaultModuleConfig() memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconOnPsk, sizeof(beaconOnPsk)); moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconOnPsk); #endif +#ifdef USERPREFS_MESH_BEACON_LEGACY_SPLIT + moduleConfig.mesh_beacon.broadcast_legacy_split = USERPREFS_MESH_BEACON_LEGACY_SPLIT; +#endif #endif // !MESHTASTIC_EXCLUDE_BEACON initModuleConfigIntervals(); diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 4c8b13fe78c..9e83d26cbd6 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -485,8 +485,11 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { If different from current config, the radio is temporarily switched for TX. */ bool has_broadcast_on_preset; meshtastic_Config_LoRaConfig_ModemPreset broadcast_on_preset; - /* How often to broadcast, in seconds. Min 3600 (1 h), max 259200 (72 h). Default 3600. */ + /* How often to broadcast, in seconds. Min 3600 (1 h), Default 3600. */ uint32_t broadcast_interval_secs; + /* When true, offer and text are split into two separate packets so legacy nodes + receive TEXT_MESSAGE_APP text without needing a MESH_BEACON_APP decoder. */ + bool broadcast_legacy_split; } meshtastic_ModuleConfig_MeshBeaconConfig; /* TAK team/role configuration */ @@ -644,7 +647,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_default {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_default {""} -#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, 0, 0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_default {0, 0, 0, "", false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_default, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0} #define meshtastic_ModuleConfig_TAKConfig_init_default {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_default {0, "", _meshtastic_RemoteHardwarePinType_MIN} #define meshtastic_ModuleConfig_init_zero {0, {meshtastic_ModuleConfig_MQTTConfig_init_zero}} @@ -664,7 +667,7 @@ extern "C" { #define meshtastic_ModuleConfig_CannedMessageConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, _meshtastic_ModuleConfig_CannedMessageConfig_InputEventChar_MIN, 0, 0, "", 0} #define meshtastic_ModuleConfig_AmbientLightingConfig_init_zero {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StatusMessageConfig_init_zero {""} -#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, 0, 0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0} +#define meshtastic_ModuleConfig_MeshBeaconConfig_init_zero {0, 0, 0, "", false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, false, meshtastic_ChannelSettings_init_zero, _meshtastic_Config_LoRaConfig_RegionCode_MIN, false, _meshtastic_Config_LoRaConfig_ModemPreset_MIN, 0, 0} #define meshtastic_ModuleConfig_TAKConfig_init_zero {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_zero {0, "", _meshtastic_RemoteHardwarePinType_MIN} @@ -795,6 +798,7 @@ extern "C" { #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_region_tag 9 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_on_preset_tag 10 #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_interval_secs_tag 11 +#define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_legacy_split_tag 12 #define meshtastic_ModuleConfig_TAKConfig_team_tag 1 #define meshtastic_ModuleConfig_TAKConfig_role_tag 2 #define meshtastic_RemoteHardwarePin_gpio_pin_tag 1 @@ -1055,7 +1059,8 @@ X(a, STATIC, OPTIONAL, UENUM, broadcast_offer_preset, 7) \ X(a, STATIC, OPTIONAL, MESSAGE, broadcast_on_channel, 8) \ X(a, STATIC, SINGULAR, UENUM, broadcast_on_region, 9) \ X(a, STATIC, OPTIONAL, UENUM, broadcast_on_preset, 10) \ -X(a, STATIC, SINGULAR, UINT32, broadcast_interval_secs, 11) +X(a, STATIC, SINGULAR, UINT32, broadcast_interval_secs, 11) \ +X(a, STATIC, SINGULAR, BOOL, broadcast_legacy_split, 12) #define meshtastic_ModuleConfig_MeshBeaconConfig_CALLBACK NULL #define meshtastic_ModuleConfig_MeshBeaconConfig_DEFAULT NULL #define meshtastic_ModuleConfig_MeshBeaconConfig_broadcast_offer_channel_MSGTYPE meshtastic_ChannelSettings diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index cbc785d80a1..df4fcff5e95 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -210,57 +210,105 @@ void MeshBeaconBroadcastModule::sendBeacon() { const auto &bcfg = moduleConfig.mesh_beacon; - bool hasRadioContent = bcfg.has_broadcast_offer_preset || bcfg.has_broadcast_offer_channel || - (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET); - if (bcfg.broadcast_message[0] == '\0' && !hasRadioContent) { - LOG_DEBUG("Beacon: nothing to send (empty message, no offer), skipping"); - return; - } + 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); - meshtastic_MeshPacket *p = allocDataPacket(); - if (!p) { - LOG_WARN("Beacon: failed to allocate packet"); + if (!hasText && !hasRadioContent) { + LOG_DEBUG("Beacon: nothing to send (empty message, no offer), skipping"); return; } - // Use MESH_BEACON_APP only when radio settings are being offered to listeners; - // otherwise fall back to TEXT_MESSAGE_APP so standard clients receive the text - // without needing a MESH_BEACON_APP decoder. broadcast_on_* controls which - // radio config to use for TX and has no bearing on the portnum. - if (hasRadioContent) { - if (payloadCacheDirty) - rebuildCache(); - memcpy(p->decoded.payload.bytes, payloadCache, payloadCacheSize); - p->decoded.payload.size = payloadCacheSize; - p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; - } else { - pb_size_t msgLen = (pb_size_t)strnlen(bcfg.broadcast_message, sizeof(bcfg.broadcast_message) - 1); - memcpy(p->decoded.payload.bytes, bcfg.broadcast_message, msgLen); - p->decoded.payload.size = msgLen; - p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; - } - p->to = NODENUM_BROADCAST; - p->from = (bcfg.broadcast_send_as_node != 0) ? bcfg.broadcast_send_as_node : nodeDB->getNodeNum(); - p->hop_limit = 3; - p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; - p->want_ack = false; - p->rx_time = getValidTime(RTCQualityFromNet); - + // Determine whether the TX radio config actually differs from the running config. + // Used to gate sidecar insertion. const meshtastic_Config_LoRaConfig_ModemPreset targetPreset = bcfg.has_broadcast_on_preset ? bcfg.broadcast_on_preset : config.lora.modem_preset; const uint16_t targetSlot = bcfg.has_broadcast_on_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num; const bool channelOverrideConfigured = bcfg.has_broadcast_on_channel && (bcfg.broadcast_on_channel.name[0] != '\0' || bcfg.broadcast_on_channel.psk.size > 0 || bcfg.broadcast_on_channel.channel_num != config.lora.channel_num); - if ((bcfg.has_broadcast_on_preset && targetPreset != config.lora.modem_preset) || - (bcfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && - bcfg.broadcast_on_region != config.lora.region) || - channelOverrideConfigured) { - setTargetRadioSettings(p, targetPreset, targetSlot); - } + const bool presetDiffers = (bcfg.has_broadcast_on_preset && targetPreset != config.lora.modem_preset) || + (bcfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && + bcfg.broadcast_on_region != config.lora.region) || + channelOverrideConfigured; + + // Stamp common fields shared by every outgoing beacon packet. + const auto stampPacket = [&](meshtastic_MeshPacket *p) { + p->to = NODENUM_BROADCAST; + p->from = (bcfg.broadcast_send_as_node != 0) ? bcfg.broadcast_send_as_node : nodeDB->getNodeNum(); + p->hop_limit = 0; // all beacon packets are zero hopped to limit spamming. + p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; + p->want_ack = false; + p->rx_time = getValidTime(RTCQualityFromNet); + }; + + // ── Main packet ───────────────────────────────────────────────────────── + // + // broadcast_legacy_split: when both text and offer are present, split into + // A) MESH_BEACON_APP carrying only the offer (no text) on the beacon config. + // B) TEXT_MESSAGE_APP carrying only the message text on the normal config. + // This lets nodes that only decode TEXT_MESSAGE_APP still receive the text. + if (bcfg.broadcast_legacy_split && hasRadioContent && hasText || hasRadioContent && !hasText) { + // Packet A: offer-only MESH_BEACON_APP (message field intentionally empty). + 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; + + uint8_t offerBuf[meshtastic_MeshBeacon_size] = {}; + pb_size_t offerSize = (pb_size_t)pb_encode_to_bytes(offerBuf, sizeof(offerBuf), &meshtastic_MeshBeacon_msg, &offerOnly); - LOG_INFO("Beacon: broadcast from=%#08lx msg='%.40s'", p->from, bcfg.broadcast_message); - router->send(p); + meshtastic_MeshPacket *pA = allocDataPacket(); + if (!pA) { + LOG_WARN("Beacon: failed to allocate split-A packet"); + return; + } + memcpy(pA->decoded.payload.bytes, offerBuf, offerSize); + pA->decoded.payload.size = offerSize; + pA->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + stampPacket(pA); + if (presetDiffers) + setTargetRadioSettings(pA, targetPreset, targetSlot); + LOG_INFO("Beacon: split-A MESH_BEACON_APP (offer only) from=%#08lx", pA->from); + router->send(pA); + } else if (bcfg.broadcast_legacy_split && hasRadioContent && hasText || !hasRadioContent && hasText) { + // Packet B: text-only TEXT_MESSAGE_APP on the beacon radio config (no sidecar). + meshtastic_MeshPacket *pB = allocDataPacket(); + if (!pB) { + LOG_WARN("Beacon: failed to allocate split-B packet"); + return; + } + pb_size_t msgLen = (pb_size_t)strnlen(bcfg.broadcast_message, sizeof(bcfg.broadcast_message) - 1); + memcpy(pB->decoded.payload.bytes, bcfg.broadcast_message, msgLen); + pB->decoded.payload.size = msgLen; + pB->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; + stampPacket(pB); + if (presetDiffers) + setTargetRadioSettings(pB, targetPreset, targetSlot); + LOG_INFO("Beacon: split-B TEXT_MESSAGE_APP msg='%.40s' from=%#08lx", bcfg.broadcast_message, pB->from); + router->send(pB); + } else if (hasRadioContent && hasText) { + // Combined path: MESH_BEACON_APP carrying offer + optional message text. + if (payloadCacheDirty) + rebuildCache(); + meshtastic_MeshPacket *p = allocDataPacket(); + if (!p) { + LOG_WARN("Beacon: failed to allocate beacon packet"); + return; + } + memcpy(p->decoded.payload.bytes, payloadCache, payloadCacheSize); + p->decoded.payload.size = payloadCacheSize; + p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP; + stampPacket(p); + if (presetDiffers) + setTargetRadioSettings(p, targetPreset, targetSlot); + LOG_INFO("Beacon: MESH_BEACON_APP offer+msg from=%#08lx msg='%.40s'", p->from, bcfg.broadcast_message); + router->send(p); + } } int32_t MeshBeaconBroadcastModule::runOnce() diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index 58bafd38a83..50bd76a5b80 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -61,6 +61,12 @@ class MeshBeaconModule * Broadcaster: periodically sends MeshBeacon packets on the configured preset/channel. * Active only when moduleConfig.mesh_beacon.broadcast_enabled is true. * Inherits ProtobufModule to access allocDataProtobuf + setStartDelay. + * + * Packet flow: + * Normal (combined): one MESH_BEACON_APP carrying offer + message on the beacon radio config. + * Legacy split: two packets when both text and offer are present and broadcast_legacy_split=true: + * A) MESH_BEACON_APP with offer only (no text) on the beacon radio config. + * B) TEXT_MESSAGE_APP with the text on the normal radio config. */ class MeshBeaconBroadcastModule : private MeshBeaconModule, public ProtobufModule, diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 472f593e5c0..b470186aa0d 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -856,6 +856,99 @@ static void test_listener_wantPacket_trueWhenEnabled(void) TEST_ASSERT_TRUE(listener.wantPacket(&mp)); } +// =========================================================================== +// Group 6: Legacy split messages +// =========================================================================== + +/** + * Verify broadcast_legacy_split causes sendBeacon to emit exactly two packets when both + * text and offer content are present. + * Important to confirm the split path is wired end-to-end rather than short-circuiting. + */ +static void test_broadcaster_legacySplit_sendsTwoPackets(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_legacy_split = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "split-text", sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32_MESSAGE(2, mockRouter->sentPackets.size(), "Legacy split must emit exactly 2 packets"); +} + +/** + * Verify the first packet in a legacy-split send is MESH_BEACON_APP (the offer packet). + * Important so receivers without a MESH_BEACON_APP decoder still get the text from packet B. + */ +static void test_broadcaster_legacySplit_firstPacketIsBeaconApp(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_legacy_split = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "split-offer-only", + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.portnum); +} + +/** + * Verify the MESH_BEACON_APP packet in a legacy-split send carries no message text. + * Important so peers' MESH_BEACON_APP handlers do not duplicate the text already in packet B. + */ +static void test_broadcaster_legacySplit_firstPacketHasNoMessageText(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_legacy_split = true; + strncpy(moduleConfig.mesh_beacon.broadcast_message, "hidden-in-split", + sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); + const meshtastic_MeshPacket &pA = mockRouter->sentPackets[0]; + meshtastic_MeshBeacon decodedA = meshtastic_MeshBeacon_init_zero; + pb_istream_t stream = pb_istream_from_buffer(pA.decoded.payload.bytes, pA.decoded.payload.size); + pb_decode(&stream, &meshtastic_MeshBeacon_msg, &decodedA); + TEST_ASSERT_EQUAL_STRING_MESSAGE("", decodedA.message, "Offer-only MESH_BEACON_APP must have empty message"); +} + +/** + * Verify the second packet in a legacy-split send is TEXT_MESSAGE_APP containing the message text. + * Important so legacy clients that only handle TEXT_MESSAGE_APP receive the human-readable text. + */ +static void test_broadcaster_legacySplit_secondPacketIsTextMessage(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.broadcast_legacy_split = true; + const char *msg = "split-B-text"; + strncpy(moduleConfig.mesh_beacon.broadcast_message, msg, sizeof(moduleConfig.mesh_beacon.broadcast_message) - 1); + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(2, mockRouter->sentPackets.size()); + const meshtastic_MeshPacket &pB = mockRouter->sentPackets[1]; + TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, pB.decoded.portnum); + TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)pB.decoded.payload.bytes, pB.decoded.payload.size); +} + } // namespace // =========================================================================== @@ -947,6 +1040,14 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_listener_wantPacket_falseWhenDisabled); RUN_TEST(test_listener_wantPacket_trueWhenEnabled); + printf("\n=== Legacy split messages ===\n"); + + RUN_TEST(test_adminValidation_statusMessageTooLong_isTruncatedAt100); + RUN_TEST(test_broadcaster_legacySplit_sendsTwoPackets); + RUN_TEST(test_broadcaster_legacySplit_firstPacketIsBeaconApp); + RUN_TEST(test_broadcaster_legacySplit_firstPacketHasNoMessageText); + RUN_TEST(test_broadcaster_legacySplit_secondPacketIsTextMessage); + exit(UNITY_END()); } diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 5cf12cf02cb..c19cdffab3d 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -69,6 +69,7 @@ // "USERPREFS_MESH_BEACON_ON_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", // Region to use when transmitting beacons // "USERPREFS_MESH_BEACON_ON_CHANNEL_NAME": "'LongFast'", // Channel name to use on the TX radio config - uses default if unset. // "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK": "{ 0x01 }", // PSK for the TX channel (0x01 = Meshtastic default PSK) + // "USERPREFS_MESH_BEACON_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 // "USERPREFS_RINGTONE_NAG_SECS": "60", // "USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS": "43200", // "USERPREFS_UI_TEST_LOG": "true", // Test-only: emits `Screen: frame N/M name=... reason=...` log per UI transition (for the mcp-server ui test tier); off in release builds. From 34fbac5c1750051b8871417a021f2260452f4908 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:51:25 +0100 Subject: [PATCH 18/24] Update protobufs (#16) Co-authored-by: NomDeTom <116762865+NomDeTom@users.noreply.github.com> --- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- src/mesh/generated/meshtastic/module_config.pb.h | 13 ++++++++----- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index ae05fd406a6..d573a5b5437 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -452,7 +452,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2710 +#define meshtastic_BackupPreferences_size 2712 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 7455e1e9d05..2575942bc9b 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -212,7 +212,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size #define meshtastic_LocalConfig_size 757 -#define meshtastic_LocalModuleConfig_size 1098 +#define meshtastic_LocalModuleConfig_size 1100 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 9e83d26cbd6..5ef98604be5 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -485,10 +485,13 @@ typedef struct _meshtastic_ModuleConfig_MeshBeaconConfig { If different from current config, the radio is temporarily switched for TX. */ bool has_broadcast_on_preset; meshtastic_Config_LoRaConfig_ModemPreset broadcast_on_preset; - /* How often to broadcast, in seconds. Min 3600 (1 h), Default 3600. */ + /* How often to broadcast, in seconds. Min 3600 (1 h), default 3600. */ uint32_t broadcast_interval_secs; - /* When true, offer and text are split into two separate packets so legacy nodes - receive TEXT_MESSAGE_APP text without needing a MESH_BEACON_APP decoder. */ + /* When true and both broadcast_message and offer content (preset/channel/region) are present, + the broadcaster splits them into two separate packets instead of one combined MESH_BEACON_APP: + - Packet A: MESH_BEACON_APP carrying only the offer fields (no text) on the beacon radio config. + - Packet B: TEXT_MESSAGE_APP carrying only the broadcast_message on the normal radio config. + This ensures nodes that only decode TEXT_MESSAGE_APP can still receive the human-readable text. */ bool broadcast_legacy_split; } meshtastic_ModuleConfig_MeshBeaconConfig; @@ -1131,7 +1134,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_ExternalNotificationConfig_size 42 #define meshtastic_ModuleConfig_MQTTConfig_size 224 #define meshtastic_ModuleConfig_MapReportSettings_size 14 -#define meshtastic_ModuleConfig_MeshBeaconConfig_size 274 +#define meshtastic_ModuleConfig_MeshBeaconConfig_size 276 #define meshtastic_ModuleConfig_NeighborInfoConfig_size 10 #define meshtastic_ModuleConfig_PaxcounterConfig_size 30 #define meshtastic_ModuleConfig_RangeTestConfig_size 12 @@ -1142,7 +1145,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_TAKConfig_size 4 #define meshtastic_ModuleConfig_TelemetryConfig_size 50 #define meshtastic_ModuleConfig_TrafficManagementConfig_size 52 -#define meshtastic_ModuleConfig_size 278 +#define meshtastic_ModuleConfig_size 280 #define meshtastic_RemoteHardwarePin_size 21 #ifdef __cplusplus From 98c14b4070828e7f205d440dcca6575004fdabd4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Jun 2026 00:57:36 +0100 Subject: [PATCH 19/24] Update protobufs (#17) Co-authored-by: NomDeTom <116762865+NomDeTom@users.noreply.github.com> --- src/mesh/generated/meshtastic/admin.pb.h | 28 +++++++++++++++++++++-- src/mesh/generated/meshtastic/config.pb.h | 20 +++++++++++++--- src/mesh/generated/meshtastic/mesh.pb.h | 13 ++++++++--- 3 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/mesh/generated/meshtastic/admin.pb.h b/src/mesh/generated/meshtastic/admin.pb.h index c9f5435551d..a099d7d5e5b 100644 --- a/src/mesh/generated/meshtastic/admin.pb.h +++ b/src/mesh/generated/meshtastic/admin.pb.h @@ -198,6 +198,28 @@ typedef struct _meshtastic_LockdownAuth { way to reset the session clock is a reboot, which costs a boot from the on-flash, HMAC-bound counter. */ uint32_t max_session_seconds; + /* Disable lockdown mode. Requires a valid passphrase in the same + message (the device must prove the operator owns it before + reverting at-rest encryption). On success the firmware decrypts + every stored config / channel / nodedb file back to plaintext, + removes the wrapped DEK, unlock token, monotonic-counter, and + backoff files, and reboots out of lockdown. + + This is the inverse of the provision/unlock path: it is how the + client app's "lockdown mode" toggle returns a device to normal + operation. + + NOT reversed by this operation: APPROTECT. Once the debug port + lockout has been burned (on silicon where it is effective) it is + permanent — disabling lockdown decrypts your data and removes the + access gates, but the SWD/JTAG port stays locked for the life of + the device (recoverable only via a full chip erase over a debug + probe, which destroys all data). Clients should make this + irreversibility clear at the moment lockdown is first enabled. + + When true the passphrase field is still required; boots_remaining, + valid_until_epoch, max_session_seconds, and lock_now are ignored. */ + bool disable; } meshtastic_LockdownAuth; /* Parameters for setting up Meshtastic for ameteur radio usage */ @@ -560,6 +582,7 @@ extern "C" { #define meshtastic_LockdownAuth_valid_until_epoch_tag 3 #define meshtastic_LockdownAuth_lock_now_tag 4 #define meshtastic_LockdownAuth_max_session_seconds_tag 5 +#define meshtastic_LockdownAuth_disable_tag 6 #define meshtastic_HamParameters_call_sign_tag 1 #define meshtastic_HamParameters_tx_power_tag 2 #define meshtastic_HamParameters_frequency_tag 3 @@ -758,7 +781,8 @@ X(a, STATIC, SINGULAR, BYTES, passphrase, 1) \ X(a, STATIC, SINGULAR, UINT32, boots_remaining, 2) \ X(a, STATIC, SINGULAR, UINT32, valid_until_epoch, 3) \ X(a, STATIC, SINGULAR, BOOL, lock_now, 4) \ -X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) +X(a, STATIC, SINGULAR, UINT32, max_session_seconds, 5) \ +X(a, STATIC, SINGULAR, BOOL, disable, 6) #define meshtastic_LockdownAuth_CALLBACK NULL #define meshtastic_LockdownAuth_DEFAULT NULL @@ -874,7 +898,7 @@ extern const pb_msgdesc_t meshtastic_SHTXX_config_msg; #define meshtastic_AdminMessage_size 511 #define meshtastic_HamParameters_size 47 #define meshtastic_KeyVerificationAdmin_size 25 -#define meshtastic_LockdownAuth_size 54 +#define meshtastic_LockdownAuth_size 56 #define meshtastic_NodeRemoteHardwarePinsResponse_size 496 #define meshtastic_SCD30_config_size 27 #define meshtastic_SCD4X_config_size 29 diff --git a/src/mesh/generated/meshtastic/config.pb.h b/src/mesh/generated/meshtastic/config.pb.h index 1d66f082335..1f49ef9f373 100644 --- a/src/mesh/generated/meshtastic/config.pb.h +++ b/src/mesh/generated/meshtastic/config.pb.h @@ -360,7 +360,21 @@ typedef enum _meshtastic_Config_LoRaConfig_ModemPreset { /* Narrow Slow Moderate range preset optimized for EU 868MHz band with 62.5kHz bandwidth. Comparable link budget and data rate to LONG_FAST. */ - meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13 + meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW = 13, + /* Tiny Fast + Preset optimized for compliance with Amateur Radio restrictions with 20kHz bandwidth. + Many regions limit data transmission bandwidth in lower amateur bands (2 Meter). + Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths. + Only compatible with SX127x and SX126x chipsets. + Comparable link budget and data rate to LONG_FAST. */ + meshtastic_Config_LoRaConfig_ModemPreset_TINY_FAST = 14, + /* Tiny Slow + Preset optimized for compliance with Amateur Radio restrictions with 20kHz bandwidth. + Many regions limit data transmission bandwidth in lower amateur bands (2 Meter). + Note: TCXO with tight tolerances (±5 ppm or better) is *absolutely required* at these narrow bandwidths. + Only compatible with SX127x and SX126x chipsets. + Comparable link budget and data rate to LONG_MODERATE. */ + meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW = 15 } meshtastic_Config_LoRaConfig_ModemPreset; typedef enum _meshtastic_Config_LoRaConfig_FEM_LNA_Mode { @@ -753,8 +767,8 @@ extern "C" { #define _meshtastic_Config_LoRaConfig_RegionCode_ARRAYSIZE ((meshtastic_Config_LoRaConfig_RegionCode)(meshtastic_Config_LoRaConfig_RegionCode_ITU2_125CM+1)) #define _meshtastic_Config_LoRaConfig_ModemPreset_MIN meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST -#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW -#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_NARROW_SLOW+1)) +#define _meshtastic_Config_LoRaConfig_ModemPreset_MAX meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW +#define _meshtastic_Config_LoRaConfig_ModemPreset_ARRAYSIZE ((meshtastic_Config_LoRaConfig_ModemPreset)(meshtastic_Config_LoRaConfig_ModemPreset_TINY_SLOW+1)) #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MIN meshtastic_Config_LoRaConfig_FEM_LNA_Mode_DISABLED #define _meshtastic_Config_LoRaConfig_FEM_LNA_Mode_MAX meshtastic_Config_LoRaConfig_FEM_LNA_Mode_NOT_PRESENT diff --git a/src/mesh/generated/meshtastic/mesh.pb.h b/src/mesh/generated/meshtastic/mesh.pb.h index 920cc3868bd..5b27f01d115 100644 --- a/src/mesh/generated/meshtastic/mesh.pb.h +++ b/src/mesh/generated/meshtastic/mesh.pb.h @@ -668,7 +668,14 @@ typedef enum _meshtastic_LockdownStatus_State { token's TTL. */ meshtastic_LockdownStatus_State_UNLOCKED = 3, /* Passphrase rejected. backoff_seconds is non-zero when rate-limited. */ - meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4 + meshtastic_LockdownStatus_State_UNLOCK_FAILED = 4, + /* Lockdown is supported by this firmware but not currently active + (no passphrase has been provisioned, or it was disabled via + AdminMessage.lockdown_auth.disable). The device is operating in + normal, non-encrypted mode. Clients render the lockdown-mode + toggle as OFF on receiving this. Distinct from NEEDS_PROVISION, + which is only used during an in-progress enable flow. */ + meshtastic_LockdownStatus_State_DISABLED = 5 } meshtastic_LockdownStatus_State; /* Struct definitions */ @@ -1546,8 +1553,8 @@ extern "C" { #define _meshtastic_LogRecord_Level_ARRAYSIZE ((meshtastic_LogRecord_Level)(meshtastic_LogRecord_Level_CRITICAL+1)) #define _meshtastic_LockdownStatus_State_MIN meshtastic_LockdownStatus_State_STATE_UNSPECIFIED -#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_UNLOCK_FAILED -#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_UNLOCK_FAILED+1)) +#define _meshtastic_LockdownStatus_State_MAX meshtastic_LockdownStatus_State_DISABLED +#define _meshtastic_LockdownStatus_State_ARRAYSIZE ((meshtastic_LockdownStatus_State)(meshtastic_LockdownStatus_State_DISABLED+1)) #define meshtastic_Position_location_source_ENUMTYPE meshtastic_Position_LocSource #define meshtastic_Position_altitude_source_ENUMTYPE meshtastic_Position_AltSource From f2cb73068c6358aa5a0f2d14cb42a0e97826f7e4 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Mon, 15 Jun 2026 11:05:49 +0100 Subject: [PATCH 20/24] better logic, fixed a test --- src/modules/MeshBeaconModule.cpp | 30 +++++++++++++++++++++-------- test/test_mesh_beacon/test_main.cpp | 1 - 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index df4fcff5e95..38d8efd9cb8 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -242,13 +242,23 @@ void MeshBeaconBroadcastModule::sendBeacon() p->rx_time = getValidTime(RTCQualityFromNet); }; - // ── Main packet ───────────────────────────────────────────────────────── + // ── Main packet(s) ────────────────────────────────────────────────────── // - // broadcast_legacy_split: when both text and offer are present, split into - // A) MESH_BEACON_APP carrying only the offer (no text) on the beacon config. - // B) TEXT_MESSAGE_APP carrying only the message text on the normal config. - // This lets nodes that only decode TEXT_MESSAGE_APP still receive the text. - if (bcfg.broadcast_legacy_split && hasRadioContent && hasText || hasRadioContent && !hasText) { + // broadcast_legacy_split: when both text and offer are present, send TWO packets, + // A) MESH_BEACON_APP carrying only the offer (no text) on the beacon config, and + // B) TEXT_MESSAGE_APP carrying only the message text on the normal config, + // so nodes that only decode TEXT_MESSAGE_APP still receive the text. Otherwise a + // single packet is sent (offer-only, text-only, or the combined offer+text path). + // + // These are independent decisions, NOT a mutually-exclusive if/else chain: the split + // case must emit both A and B. Conditions are spelled out as named booleans to avoid + // the && / || precedence trap (and a prior bug where the split case dropped the text). + const bool splitBoth = bcfg.broadcast_legacy_split && hasRadioContent && hasText; + const bool sendOfferOnly = splitBoth || (hasRadioContent && !hasText); + const bool sendTextOnly = splitBoth || (!hasRadioContent && hasText); + const bool sendCombined = !bcfg.broadcast_legacy_split && hasRadioContent && hasText; + + if (sendOfferOnly) { // Packet A: offer-only MESH_BEACON_APP (message field intentionally empty). meshtastic_MeshBeacon offerOnly = meshtastic_MeshBeacon_init_zero; if (bcfg.has_broadcast_offer_channel) { @@ -275,7 +285,9 @@ void MeshBeaconBroadcastModule::sendBeacon() setTargetRadioSettings(pA, targetPreset, targetSlot); LOG_INFO("Beacon: split-A MESH_BEACON_APP (offer only) from=%#08lx", pA->from); router->send(pA); - } else if (bcfg.broadcast_legacy_split && hasRadioContent && hasText || !hasRadioContent && hasText) { + } + + if (sendTextOnly) { // Packet B: text-only TEXT_MESSAGE_APP on the beacon radio config (no sidecar). meshtastic_MeshPacket *pB = allocDataPacket(); if (!pB) { @@ -291,7 +303,9 @@ void MeshBeaconBroadcastModule::sendBeacon() setTargetRadioSettings(pB, targetPreset, targetSlot); LOG_INFO("Beacon: split-B TEXT_MESSAGE_APP msg='%.40s' from=%#08lx", bcfg.broadcast_message, pB->from); router->send(pB); - } else if (hasRadioContent && hasText) { + } + + if (sendCombined) { // Combined path: MESH_BEACON_APP carrying offer + optional message text. if (payloadCacheDirty) rebuildCache(); diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index b470186aa0d..ab1c92bcabc 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -1042,7 +1042,6 @@ BEACON_TEST_ENTRY void setup() printf("\n=== Legacy split messages ===\n"); - RUN_TEST(test_adminValidation_statusMessageTooLong_isTruncatedAt100); RUN_TEST(test_broadcaster_legacySplit_sendsTwoPackets); RUN_TEST(test_broadcaster_legacySplit_firstPacketIsBeaconApp); RUN_TEST(test_broadcaster_legacySplit_firstPacketHasNoMessageText); From 785a0c7e9e4a51517bddb61ba13598dbc074c05e Mon Sep 17 00:00:00 2001 From: nomdetom Date: Mon, 15 Jun 2026 12:00:45 +0100 Subject: [PATCH 21/24] updated for packet signing fixed a test added guards for licensed/ham mode --- src/mesh/RadioLibInterface.cpp | 9 ++++ src/modules/MeshBeaconModule.cpp | 72 ++++++++++++++++++++++------- src/modules/MeshBeaconModule.h | 8 ++++ test/test_mesh_beacon/test_main.cpp | 34 ++++++++++++++ 4 files changed, 106 insertions(+), 17 deletions(-) diff --git a/src/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index aba948b1dbb..893646ad3d8 100644 --- a/src/mesh/RadioLibInterface.cpp +++ b/src/mesh/RadioLibInterface.cpp @@ -395,6 +395,15 @@ void RadioLibInterface::onNotify(uint32_t notification) // There's still some delay pending on this packet, so resume waiting for it to elapse notifyLater(delay_remaining, TRANSMIT_DELAY_COMPLETED, false); #if !MESHTASTIC_EXCLUDE_BEACON + } else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) { + // The beacon's target radio config is invalid (bad preset/region, or an + // unlicensed node keying up on a ham-only region). Drop the packet — never + // transmit it on the current (home) config — and move on to the next queued packet. + LOG_DEBUG("Beacon: invalid TX radio config, dropping packet 0x%08x", txp->id); + meshtastic_MeshPacket *bad = txQueue.dequeue(); + MeshBeaconModule::clearTargetRadioSettings(bad); + packetPool.release(bad); + setTransmitDelay(); } else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) { setTransmitDelay(); #endif diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 38d8efd9cb8..8bfbb072b1e 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -88,6 +88,32 @@ void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) } } +bool MeshBeaconModule::beaconTxConfigInvalid(const meshtastic_MeshPacket *p) +{ + meshtastic_Config_LoRaConfig_ModemPreset preset; + if (!getTargetRadioSettings(p, &preset, nullptr)) + return false; // not a beacon-switch packet — nothing to validate, normal traffic unaffected + + const auto &bcfg = moduleConfig.mesh_beacon; + const meshtastic_Config_LoRaConfig_RegionCode region = + (bcfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? bcfg.broadcast_on_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. + const RegionInfo *r = getRegion(region); + if (r && r->profile->licensedOnly && !owner.is_licensed) + return true; + + // Preset must be valid for the target region. + meshtastic_Config_LoRaConfig probe = config.lora; + probe.use_preset = true; + probe.modem_preset = preset; + probe.region = region; + return !RadioInterface::validateConfigLora(probe); +} + bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p) { meshtastic_ChannelSettings *c = &channels.getByIndex(channels.getPrimaryIndex()).settings; @@ -99,7 +125,7 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ memcmp(c->psk.bytes, target.psk.bytes, c->psk.size) != 0 || c->channel_num != target.channel_num; }; - if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot) && true) { + if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot)) { const auto &bcfg = moduleConfig.mesh_beacon; meshtastic_Config_LoRaConfig_RegionCode targetRegion; @@ -125,13 +151,12 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ targetRegion == config.lora.region && !channelDiffers(targetChannel)) return false; - // Guard: skip TX if preset is invalid for the target region. - meshtastic_Config_LoRaConfig broadcastOnSetting = config.lora; - broadcastOnSetting.use_preset = true; - broadcastOnSetting.modem_preset = targetPreset; - broadcastOnSetting.region = targetRegion; - if (!RadioInterface::validateConfigLora(broadcastOnSetting)) { - LOG_WARN("Beacon: preset %d invalid for region %d, skipping radio switch", targetPreset, targetRegion); + // Guard: never key up on an invalid target config — bad preset for the region, or an + // unlicensed node keying up on a ham-only region. Refuse the switch here so we never + // transmit on it; the radio driver drops the packet outright (see RadioLibInterface, + // beaconTxConfigInvalid) rather than letting it fall through onto the current config. + if (beaconTxConfigInvalid(p)) { + LOG_DEBUG("Beacon: target preset %d/region %d invalid (or ham mismatch), not switching", targetPreset, targetRegion); return false; } @@ -235,6 +260,13 @@ void MeshBeaconBroadcastModule::sendBeacon() // Stamp common fields shared by every outgoing beacon packet. const auto stampPacket = [&](meshtastic_MeshPacket *p) { p->to = NODENUM_BROADCAST; + // broadcast_send_as_node overrides the source NodeNum. NOTE: this is a *node-ID* spoof + // only — it rewrites the 'from' field but does NOT forge any signature. Once 'from' is + // not us, the packet is no longer isFromUs(), so Router::perhapsEncode() skips XEdDSA + // signing and receivers get an unsigned packet attributed to another node. + // When broadcast_send_as_node == 0 the beacon is genuinely from us and Router::perhapsEncode() + // signs it under the same XEdDSA broadcast policy as normal channel messages (signed iff the + // payload + signature fits the Data payload) — beacons need no special-case signing here. p->from = (bcfg.broadcast_send_as_node != 0) ? bcfg.broadcast_send_as_node : nodeDB->getNodeNum(); p->hop_limit = 0; // all beacon packets are zero hopped to limit spamming. p->priority = meshtastic_MeshPacket_Priority_BACKGROUND; @@ -363,14 +395,20 @@ bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacke { const bool hasOfferContent = b && (b->has_offer_channel || b->offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || b->has_offer_preset); - if (!b || (strlen(b->message) == 0 && !hasOfferContent)) + const pb_size_t msgLen = b ? (pb_size_t)strnlen(b->message, sizeof(b->message) - 1) : 0; + const bool hasText = msgLen > 0; + if (!b || (!hasText && !hasOfferContent)) return false; - if (strlen(b->message) > 0) + if (hasText) LOG_INFO("Beacon: received from %#08lx: '%.40s'", mp.from, b->message); - if (strlen(b->message) > 0) { - // Deliver text to the local inbox as a TEXT_MESSAGE_APP packet. + if (hasText) { + // Surface the text to the locally connected phone/client ONLY, as a TEXT_MESSAGE_APP + // packet. This is a local inbox delivery, NOT a mesh retransmit: we use sendToPhone() + // (queues toward the phone) rather than handleToRadio(), which would re-inject the + // packet into the mesh from this node — amplifying traffic and re-attributing the text + // to us. The original 'from' (the real beaconer) is preserved for the phone. meshtastic_MeshPacket *txt = packetPool.allocCopy(mp); if (!txt) { LOG_WARN("Beacon: failed to alloc inbox copy"); @@ -379,10 +417,9 @@ bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacke txt->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP; txt->to = nodeDB->getNodeNum(); memset(txt->decoded.payload.bytes, 0, sizeof(txt->decoded.payload.bytes)); - txt->decoded.payload.size = (pb_size_t)strnlen(b->message, sizeof(b->message) - 1); + txt->decoded.payload.size = msgLen; memcpy(txt->decoded.payload.bytes, b->message, txt->decoded.payload.size); - service->handleToRadio(*txt); - packetPool.release(txt); + service->sendToPhone(txt); // takes ownership of txt — do not release } // Cache any offer for the client app — never auto-applied. @@ -394,11 +431,12 @@ bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacke lastReceivedOffer.channel = b->offer_channel; lastReceivedOffer.region = b->offer_region; lastReceivedOffer.preset = b->offer_preset; - lastReceivedOffer.received_at = getValidTime(RTCQualityFromNet); + lastReceivedOffer.received_at = + getValidTime(RTCQualityFromNet); // 0 if no RTC fix yet — consumers must not treat 0 as valid LOG_INFO("Beacon: stored offer from %#08lx (preset=%d)", mp.from, b->offer_preset); } - if (strlen(b->message) > 0) + if (hasText) powerFSM.trigger(EVENT_RECEIVED_MSG); notifyObservers(&mp); return false; diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index 50bd76a5b80..c82e5a4728f 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -50,6 +50,14 @@ 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). + */ + static bool beaconTxConfigInvalid(const meshtastic_MeshPacket *p); + protected: static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; static uint16_t originalLoraChannel; diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index ab1c92bcabc..25a215c7a5f 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -820,6 +820,39 @@ static void test_listener_receiveWithNoOffer_cacheStaysInvalid(void) TEST_ASSERT_FALSE_MESSAGE(MeshBeaconListenerModule::lastReceivedOffer.valid, "No offer fields → cache must stay invalid"); } +/** + * Verify received beacon text is delivered to the local phone queue and NOT re-injected + * into the mesh. Regression guard for the bug where handleToRadio() rebroadcast the text + * from this node (amplification + re-attribution). The fix uses sendToPhone(). + */ +static void test_listener_textMessage_deliveredToPhoneNotMesh(void) +{ + resetConfig(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + strncpy(b.message, "hello mesh", sizeof(b.message) - 1); + + meshtastic_MeshPacket mp = makeBeaconPacket(b); + listener.handleReceivedProtobuf(mp, &b); + + // The listener must NOT send anything onto the mesh. + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, mockRouter->sentPackets.size(), + "Received beacon text must not be re-injected into the mesh"); + + // It must hand exactly one TEXT_MESSAGE_APP packet to the phone, preserving the real sender. + meshtastic_MeshPacket *toPhone = service->getForPhone(); + TEST_ASSERT_NOT_NULL_MESSAGE(toPhone, "Beacon text must be delivered to the phone queue"); + TEST_ASSERT_EQUAL(meshtastic_PortNum_TEXT_MESSAGE_APP, toPhone->decoded.portnum); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(kRemoteNode, toPhone->from, "Phone must see the original beaconer as sender"); + TEST_ASSERT_EQUAL_STRING("hello mesh", (const char *)toPhone->decoded.payload.bytes); + service->releaseToPool(toPhone); +} + /** * Verify wantPacket returns false for MESH_BEACON_APP when listen_enabled is false. * Important to confirm the module opts out of processing when its config flag is cleared. @@ -1037,6 +1070,7 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_listener_offerOnly_isCached); RUN_TEST(test_listener_nullBeacon_isDropped); RUN_TEST(test_listener_receiveWithNoOffer_cacheStaysInvalid); + RUN_TEST(test_listener_textMessage_deliveredToPhoneNotMesh); RUN_TEST(test_listener_wantPacket_falseWhenDisabled); RUN_TEST(test_listener_wantPacket_trueWhenEnabled); From 290f55393253a22c4beabe91de37a8f184029083 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Mon, 15 Jun 2026 12:24:00 +0100 Subject: [PATCH 22/24] channel numbers --- src/mesh/NodeDB.cpp | 4 ++++ userPrefs.jsonc | 1 + 2 files changed, 5 insertions(+) diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 10100a5d858..f8f00621649 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1235,6 +1235,10 @@ void NodeDB::installDefaultModuleConfig() memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconOnPsk, sizeof(beaconOnPsk)); moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconOnPsk); #endif +#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NUM + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + moduleConfig.mesh_beacon.broadcast_on_channel.channel_num = USERPREFS_MESH_BEACON_ON_CHANNEL_NUM; +#endif #ifdef USERPREFS_MESH_BEACON_LEGACY_SPLIT moduleConfig.mesh_beacon.broadcast_legacy_split = USERPREFS_MESH_BEACON_LEGACY_SPLIT; #endif diff --git a/userPrefs.jsonc b/userPrefs.jsonc index c19cdffab3d..c1a2904d2b1 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -69,6 +69,7 @@ // "USERPREFS_MESH_BEACON_ON_REGION": "meshtastic_Config_LoRaConfig_RegionCode_EU_868", // Region to use when transmitting beacons // "USERPREFS_MESH_BEACON_ON_CHANNEL_NAME": "'LongFast'", // Channel name to use on the TX radio config - uses default if unset. // "USERPREFS_MESH_BEACON_ON_CHANNEL_PSK": "{ 0x01 }", // PSK for the TX channel (0x01 = Meshtastic default PSK) + // "USERPREFS_MESH_BEACON_ON_CHANNEL_NUM": "0", // LoRa channel/frequency slot to use on the TX radio config - zero is default, 20 is standard for US LongFast, etc. // "USERPREFS_MESH_BEACON_LEGACY_SPLIT": "true", // When both text and offer are present, split into a separate MESH_BEACON_APP (offer) and TEXT_MESSAGE_APP (text) for legacy client compatibility // "USERPREFS_RINGTONE_NAG_SECS": "60", // "USERPREFS_NODEINFO_REPLY_SUPPRESS_SECS": "43200", From fe6f2d0982e92798459a57c6399a0ad177a200a3 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Mon, 15 Jun 2026 15:23:59 +0100 Subject: [PATCH 23/24] beacon: encrypt on the beacon channel PSK; fix split note When broadcast_on_channel overrides the primary channel's name/PSK, the beacon was encrypted with the PRIMARY PSK: perhapsEncode keys encryption off the primary slot, but the radio-thread channel switch happens only after encryption. sendBeaconPacket() now installs the beacon channel into the primary slot for the synchronous duration of send() (cooperative threading => no interleaving) so encryption/hash use the beacon channel, then restores it. A shared beaconChannelSettings() helper builds the channel for both the encrypt-time swap and the RF-time swap so the key+hash cannot drift. Also: correct the legacy-split comments (both packets go out on the same beacon radio settings, not the normal config) and merge the two consecutive `if (hasText)` blocks in the listener (cppcheck duplicateCondition). Tests: add channelPskOverride_swapsBeaconChannelAndRestores and noChannelOverride_doesNotSwapPrimary; MockRouter snapshots the primary channel at send() time. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/modules/MeshBeaconModule.cpp | 76 ++++++++++++++++------- src/modules/MeshBeaconModule.h | 20 +++++- test/test_mesh_beacon/test_main.cpp | 96 +++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 26 deletions(-) diff --git a/src/modules/MeshBeaconModule.cpp b/src/modules/MeshBeaconModule.cpp index 8bfbb072b1e..18134788485 100644 --- a/src/modules/MeshBeaconModule.cpp +++ b/src/modules/MeshBeaconModule.cpp @@ -114,6 +114,24 @@ bool MeshBeaconModule::beaconTxConfigInvalid(const meshtastic_MeshPacket *p) return !RadioInterface::validateConfigLora(probe); } +meshtastic_ChannelSettings MeshBeaconModule::beaconChannelSettings(const meshtastic_ChannelSettings &base, + meshtastic_Config_LoRaConfig_ModemPreset preset) +{ + const auto &bcfg = moduleConfig.mesh_beacon; + meshtastic_ChannelSettings ch = base; + if (bcfg.has_broadcast_on_channel) { + ch.channel_num = bcfg.broadcast_on_channel.channel_num; + if (bcfg.broadcast_on_channel.name[0] != '\0') + strncpy(ch.name, bcfg.broadcast_on_channel.name, sizeof(ch.name) - 1); + if (bcfg.broadcast_on_channel.psk.size > 0) + ch.psk = bcfg.broadcast_on_channel.psk; + } else 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) { meshtastic_ChannelSettings *c = &channels.getByIndex(channels.getPrimaryIndex()).settings; @@ -134,18 +152,7 @@ bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_ else targetRegion = config.lora.region; - meshtastic_ChannelSettings targetChannel = *c; - if (bcfg.has_broadcast_on_channel) { - targetChannel.channel_num = bcfg.broadcast_on_channel.channel_num; - if (bcfg.broadcast_on_channel.name[0] != '\0') - strncpy(targetChannel.name, bcfg.broadcast_on_channel.name, sizeof(targetChannel.name) - 1); - if (bcfg.broadcast_on_channel.psk.size > 0) - targetChannel.psk = bcfg.broadcast_on_channel.psk; - } else if (targetChannel.name[0] == '\0') { - strncpy(targetChannel.name, DisplayFormatters::getModemPresetDisplayName(targetPreset, false, true), - sizeof(targetChannel.name) - 1); - } - targetChannel.name[sizeof(targetChannel.name) - 1] = '\0'; + meshtastic_ChannelSettings targetChannel = beaconChannelSettings(*c, targetPreset); if (targetPreset == config.lora.modem_preset && targetSlot == config.lora.channel_num && targetRegion == config.lora.region && !channelDiffers(targetChannel)) @@ -231,6 +238,32 @@ void MeshBeaconBroadcastModule::rebuildCache() LOG_DEBUG("Beacon: payload cache rebuilt (%u bytes)", payloadCacheSize); } +void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset) +{ + const auto &bcfg = moduleConfig.mesh_beacon; + const bool cryptoOverride = + bcfg.has_broadcast_on_channel && (bcfg.broadcast_on_channel.name[0] != '\0' || bcfg.broadcast_on_channel.psk.size > 0); + if (!cryptoOverride) { + router->send(p); + return; + } + + // perhapsEncode() keys encryption (and the channel-hash hint) off the PRIMARY channel slot, and + // the radio-thread channel switch only happens AFTER encryption — so a beacon on an override + // channel would otherwise be encrypted with the PRIMARY PSK, not the beacon channel's. Install the + // beacon channel into the primary slot for the synchronous duration of send(), then restore. + // Meshtastic threading is cooperative (no preemption between the swap and restore). + meshtastic_Channel &primary = channels.getByIndex(channels.getPrimaryIndex()); + const meshtastic_ChannelSettings saved = primary.settings; + primary.settings = beaconChannelSettings(saved, targetPreset); + channels.fixupChannel(channels.getPrimaryIndex()); + + router->send(p); // encrypts with the beacon channel's key and stamps its hash + + primary.settings = saved; + channels.fixupChannel(channels.getPrimaryIndex()); +} + void MeshBeaconBroadcastModule::sendBeacon() { const auto &bcfg = moduleConfig.mesh_beacon; @@ -276,10 +309,9 @@ void MeshBeaconBroadcastModule::sendBeacon() // ── Main packet(s) ────────────────────────────────────────────────────── // - // broadcast_legacy_split: when both text and offer are present, send TWO packets, - // A) MESH_BEACON_APP carrying only the offer (no text) on the beacon config, and - // B) TEXT_MESSAGE_APP carrying only the message text on the normal config, - // so nodes that only decode TEXT_MESSAGE_APP still receive the text. Otherwise a + // broadcast_legacy_split: when both text and offer are present, send TWO packets — A) + // MESH_BEACON_APP (offer only) and B) TEXT_MESSAGE_APP (text only) — both on the SAME beacon + // radio settings, so nodes that only decode TEXT_MESSAGE_APP still receive the text. Otherwise a // single packet is sent (offer-only, text-only, or the combined offer+text path). // // These are independent decisions, NOT a mutually-exclusive if/else chain: the split @@ -316,11 +348,11 @@ void MeshBeaconBroadcastModule::sendBeacon() if (presetDiffers) setTargetRadioSettings(pA, targetPreset, targetSlot); LOG_INFO("Beacon: split-A MESH_BEACON_APP (offer only) from=%#08lx", pA->from); - router->send(pA); + sendBeaconPacket(pA, targetPreset); } if (sendTextOnly) { - // Packet B: text-only TEXT_MESSAGE_APP on the beacon radio config (no sidecar). + // Packet B: text-only TEXT_MESSAGE_APP, sent on the same beacon radio settings as packet A. meshtastic_MeshPacket *pB = allocDataPacket(); if (!pB) { LOG_WARN("Beacon: failed to allocate split-B packet"); @@ -334,7 +366,7 @@ void MeshBeaconBroadcastModule::sendBeacon() if (presetDiffers) setTargetRadioSettings(pB, targetPreset, targetSlot); LOG_INFO("Beacon: split-B TEXT_MESSAGE_APP msg='%.40s' from=%#08lx", bcfg.broadcast_message, pB->from); - router->send(pB); + sendBeaconPacket(pB, targetPreset); } if (sendCombined) { @@ -353,7 +385,7 @@ void MeshBeaconBroadcastModule::sendBeacon() if (presetDiffers) setTargetRadioSettings(p, targetPreset, targetSlot); LOG_INFO("Beacon: MESH_BEACON_APP offer+msg from=%#08lx msg='%.40s'", p->from, bcfg.broadcast_message); - router->send(p); + sendBeaconPacket(p, targetPreset); } } @@ -400,10 +432,8 @@ bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacke if (!b || (!hasText && !hasOfferContent)) return false; - if (hasText) - LOG_INFO("Beacon: received from %#08lx: '%.40s'", mp.from, b->message); - if (hasText) { + LOG_INFO("Beacon: received from %#08lx: '%.40s'", mp.from, b->message); // Surface the text to the locally connected phone/client ONLY, as a TEXT_MESSAGE_APP // packet. This is a local inbox delivery, NOT a mesh retransmit: we use sendToPhone() // (queues toward the phone) rather than handleToRadio(), which would re-inject the diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h index c82e5a4728f..258ad6f99b5 100644 --- a/src/modules/MeshBeaconModule.h +++ b/src/modules/MeshBeaconModule.h @@ -59,6 +59,15 @@ class MeshBeaconModule static bool beaconTxConfigInvalid(const meshtastic_MeshPacket *p); protected: + /** + * Build the ChannelSettings the beacon transmits on: the base (primary) channel overlaid with + * any broadcast_on_channel overrides, defaulting an empty name to the target preset's display + * name. Shared by the encrypt-time channel swap and the radio-thread RF swap so the channel + * key + hash are identical at both points. + */ + static meshtastic_ChannelSettings beaconChannelSettings(const meshtastic_ChannelSettings &base, + meshtastic_Config_LoRaConfig_ModemPreset preset); + static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; static uint16_t originalLoraChannel; static meshtastic_Config_LoRaConfig_RegionCode originalRegion; @@ -72,9 +81,10 @@ class MeshBeaconModule * * Packet flow: * Normal (combined): one MESH_BEACON_APP carrying offer + message on the beacon radio config. - * Legacy split: two packets when both text and offer are present and broadcast_legacy_split=true: - * A) MESH_BEACON_APP with offer only (no text) on the beacon radio config. - * B) TEXT_MESSAGE_APP with the text on the normal radio config. + * Legacy split: two packets when both text and offer are present and broadcast_legacy_split=true, + * both sent on the same beacon radio settings: + * A) MESH_BEACON_APP with offer only (no text). + * B) TEXT_MESSAGE_APP with the text (for clients that only decode TEXT_MESSAGE_APP). */ class MeshBeaconBroadcastModule : private MeshBeaconModule, public ProtobufModule, @@ -94,6 +104,10 @@ class MeshBeaconBroadcastModule : private MeshBeaconModule, void sendBeacon(); void rebuildCache(); + // Send one beacon packet. When broadcast_on_channel overrides the primary channel's name/PSK, + // the packet is encrypted with the beacon channel's key (not the primary's) — see definition. + void sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset); + bool payloadCacheDirty = true; uint8_t payloadCache[meshtastic_MeshBeacon_size] = {}; pb_size_t payloadCacheSize = 0; diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 25a215c7a5f..111106eda55 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -73,6 +73,11 @@ class MockRouter : public Router ErrorCode send(meshtastic_MeshPacket *p) override { + // Capture the primary channel as seen AT send() time. sendBeaconPacket() temporarily swaps + // the beacon channel into the primary slot around this call so perhapsEncode keys off it, + // so this snapshot reflects which channel the packet would actually be encrypted on. + if (channelFile.channels_count > 0) + primaryAtSend.push_back(channels.getByIndex(channels.getPrimaryIndex()).settings); sentPackets.push_back(*p); packetPool.release(p); return ERRNO_OK; @@ -83,6 +88,7 @@ class MockRouter : public Router void enqueueReceivedMessage(meshtastic_MeshPacket *p) override { packetPool.release(p); } std::vector sentPackets; + std::vector primaryAtSend; }; // --------------------------------------------------------------------------- @@ -159,6 +165,22 @@ 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. +static void installTestPrimaryChannel(const char *name, const uint8_t *psk, size_t pskLen) +{ + channelFile.channels_count = 1; + meshtastic_Channel &ch = channelFile.channels[0]; + ch = meshtastic_Channel_init_zero; + ch.index = 0; + ch.has_settings = true; + ch.role = meshtastic_Channel_Role_PRIMARY; + strncpy(ch.settings.name, name, sizeof(ch.settings.name) - 1); + ch.settings.psk.size = (pb_size_t)pskLen; + memcpy(ch.settings.psk.bytes, psk, pskLen); + channels.onConfigChanged(); // set primaryIndex + recompute hashes +} + // =========================================================================== // Group 1: AdminModule config validation — bad inputs must be sanitised // =========================================================================== @@ -982,6 +1004,75 @@ static void test_broadcaster_legacySplit_secondPacketIsTextMessage(void) TEST_ASSERT_EQUAL_STRING_LEN(msg, (const char *)pB.decoded.payload.bytes, pB.decoded.payload.size); } +// =========================================================================== +// Group 7: Beacon-channel PSK swap (broadcast_on_channel override) +// =========================================================================== + +/** + * When broadcast_on_channel overrides the primary channel's name/PSK, the packet must be encrypted + * on the BEACON channel, not the primary. perhapsEncode keys off the primary slot, so sendBeaconPacket + * temporarily installs the beacon channel there for the send and restores it after. Verify both: the + * primary slot IS the beacon channel during send(), and it is restored afterwards (no leak). + */ +static void test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; // gives the beacon radio content to send + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, "BeaconCh", + sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); + static const uint8_t beaconPsk[16] = {0xBB, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, + 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f}; + moduleConfig.mesh_beacon.broadcast_on_channel.psk.size = sizeof(beaconPsk); + memcpy(moduleConfig.mesh_beacon.broadcast_on_channel.psk.bytes, beaconPsk, sizeof(beaconPsk)); + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + // During send(), the primary slot must hold the BEACON channel (so encryption uses its PSK). + TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); + const meshtastic_ChannelSettings &atSend = mockRouter->primaryAtSend[0]; + TEST_ASSERT_EQUAL_STRING_MESSAGE("BeaconCh", atSend.name, "primary must be the beacon channel during send"); + TEST_ASSERT_EQUAL_UINT(sizeof(beaconPsk), atSend.psk.size); + TEST_ASSERT_EQUAL_UINT8_MESSAGE(0xBB, atSend.psk.bytes[0], "encryption must use the beacon channel PSK"); + + // After send(), the primary channel must be restored to the original (no leak into normal traffic). + const meshtastic_ChannelSettings &after = channels.getByIndex(channels.getPrimaryIndex()).settings; + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", after.name, "primary channel must be restored after send"); + TEST_ASSERT_EQUAL_UINT(sizeof(homePsk), after.psk.size); + TEST_ASSERT_EQUAL_UINT8(0xAA, after.psk.bytes[0]); +} + +/** + * Without a broadcast_on_channel override, the beacon must transmit on the primary channel unchanged + * (no swap). Guards against the swap firing — and churning the channel table — when it isn't needed. + */ +static void test_broadcaster_noChannelOverride_doesNotSwapPrimary(void) +{ + resetConfig(); + static const uint8_t homePsk[16] = {0xAA, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, + 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}; + installTestPrimaryChannel("Home", homePsk, sizeof(homePsk)); + + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_SLOW; + // No broadcast_on_channel override. + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.sendBeacon(); + + TEST_ASSERT_TRUE_MESSAGE(mockRouter->primaryAtSend.size() >= 1, "expected at least one send"); + TEST_ASSERT_EQUAL_STRING_MESSAGE("Home", mockRouter->primaryAtSend[0].name, + "primary must stay unchanged when no channel override is set"); +} + } // namespace // =========================================================================== @@ -1081,6 +1172,11 @@ BEACON_TEST_ENTRY void setup() RUN_TEST(test_broadcaster_legacySplit_firstPacketHasNoMessageText); RUN_TEST(test_broadcaster_legacySplit_secondPacketIsTextMessage); + printf("\n=== Beacon-channel PSK swap ===\n"); + + RUN_TEST(test_broadcaster_channelPskOverride_swapsBeaconChannelAndRestores); + RUN_TEST(test_broadcaster_noChannelOverride_doesNotSwapPrimary); + exit(UNITY_END()); } From 5eeaa88371b1439b22ff2ef3230ed243efed5107 Mon Sep 17 00:00:00 2001 From: nomdetom Date: Mon, 15 Jun 2026 16:19:52 +0100 Subject: [PATCH 24/24] test/beacon: drain toPhoneQueue in tearDown to fix LSan leak abort MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The listener delivers received text via MeshService::sendToPhone(), which enqueues the packet into toPhoneQueue and takes ownership. Nothing dequeues it in tests, so the three listener tests carrying message text stranded a MeshPacket each — 1272 bytes / 3 allocations that LeakSanitizer flagged at process exit, aborting the coverage run (surfaced by pio as [ERRORED] / SIGHUP even though all 40 assertions passed). Drain the phone queue in tearDown (getForPhone()/releaseToPool) so the packets return to packetPool. Suite is now GREEN with no sanitizer abort. Co-Authored-By: Claude Opus 4.8 (1M context) --- test/test_mesh_beacon/test_main.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp index 111106eda55..1bb907a7aed 100644 --- a/test/test_mesh_beacon/test_main.cpp +++ b/test/test_mesh_beacon/test_main.cpp @@ -1100,6 +1100,15 @@ void tearDown(void) delete testAdmin; testAdmin = nullptr; + // Drain any packets the listener delivered via sendToPhone() (toPhoneQueue takes ownership and + // nothing else dequeues them in tests) so they are returned to packetPool — otherwise they leak + // and LeakSanitizer aborts the process at exit. + if (mockSvc) { + meshtastic_MeshPacket *p; + while ((p = mockSvc->getForPhone()) != nullptr) + mockSvc->releaseToPool(p); + } + service = nullptr; delete mockSvc; mockSvc = nullptr;