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/Channels.h b/src/mesh/Channels.h index 9ba84e2eeec..1ca79e9f55d 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -99,6 +99,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: @@ -118,11 +123,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/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/NodeDB.cpp b/src/mesh/NodeDB.cpp index 2481eb8ca6b..f8f00621649 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1177,6 +1177,73 @@ 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; + moduleConfig.mesh_beacon.broadcast_legacy_split = true; +#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 != 0 && + USERPREFS_MESH_BEACON_INTERVAL_SECS < default_mesh_beacon_min_broadcast_interval_secs) + ? default_mesh_beacon_min_broadcast_interval_secs + : USERPREFS_MESH_BEACON_INTERVAL_SECS; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_PRESET + moduleConfig.mesh_beacon.has_broadcast_offer_preset = true; + moduleConfig.mesh_beacon.broadcast_offer_preset = USERPREFS_MESH_BEACON_OFFER_PRESET; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_REGION + moduleConfig.mesh_beacon.broadcast_offer_region = USERPREFS_MESH_BEACON_OFFER_REGION; +#endif +#ifdef USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME + moduleConfig.mesh_beacon.has_broadcast_offer_channel = true; + strncpy(moduleConfig.mesh_beacon.broadcast_offer_channel.name, USERPREFS_MESH_BEACON_OFFER_CHANNEL_NAME, + sizeof(moduleConfig.mesh_beacon.broadcast_offer_channel.name) - 1); +#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.has_broadcast_on_preset = true; + moduleConfig.mesh_beacon.broadcast_on_preset = USERPREFS_MESH_BEACON_ON_PRESET; +#endif +#ifdef USERPREFS_MESH_BEACON_ON_REGION + moduleConfig.mesh_beacon.broadcast_on_region = USERPREFS_MESH_BEACON_ON_REGION; +#endif +#ifdef USERPREFS_MESH_BEACON_ON_CHANNEL_NAME + moduleConfig.mesh_beacon.has_broadcast_on_channel = true; + strncpy(moduleConfig.mesh_beacon.broadcast_on_channel.name, USERPREFS_MESH_BEACON_ON_CHANNEL_NAME, + sizeof(moduleConfig.mesh_beacon.broadcast_on_channel.name) - 1); +#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 +#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 +#endif // !MESHTASTIC_EXCLUDE_BEACON + initModuleConfigIntervals(); } @@ -2377,6 +2444,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/mesh/RadioLibInterface.cpp b/src/mesh/RadioLibInterface.cpp index daf98696eff..893646ad3d8 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_BEACON +#include "modules/MeshBeaconModule.h" +#endif #include #include @@ -366,6 +369,9 @@ void RadioLibInterface::onNotify(uint32_t notification) switch (notification) { case ISR_TX: handleTransmitInterrupt(); +#if !MESHTASTIC_EXCLUDE_BEACON + MeshBeaconModule::reconfigureForBeaconTX(this, txQueue.getFront()); +#endif startReceive(); setTransmitDelay(); break; @@ -388,9 +394,27 @@ 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_BEACON + } else if (MeshBeaconModule::beaconTxConfigInvalid(txp)) { + // The beacon's target radio config is invalid (bad preset/region, or an + // unlicensed node keying up on a ham-only region). Drop the packet — never + // transmit it on the current (home) config — and move on to the next queued packet. + LOG_DEBUG("Beacon: invalid TX radio config, dropping packet 0x%08x", txp->id); + meshtastic_MeshPacket *bad = txQueue.dequeue(); + MeshBeaconModule::clearTargetRadioSettings(bad); + packetPool.release(bad); + setTransmitDelay(); + } else if (MeshBeaconModule::reconfigureForBeaconTX(this, txp)) { + setTransmitDelay(); +#endif } else { 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 !MESHTASTIC_EXCLUDE_BEACON + if (!MeshBeaconModule::hasTargetRadioSettings(txp)) +#endif + { + 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 @@ -522,6 +546,10 @@ void RadioLibInterface::completeSending() if (!isFromUs(p)) txRelay++; printPacket("Completed sending", p); +#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/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 474b332a8da..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 2432 +#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 27f5ad7bfdf..2575942bc9b 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 1100 #ifdef __cplusplus } /* extern "C" */ 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..028d8269f50 --- /dev/null +++ b/src/mesh/generated/meshtastic/mesh_beacon.pb.h @@ -0,0 +1,72 @@ +/* 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". */ + bool has_offer_preset; + 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, 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 +#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, OPTIONAL, 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..5ef98604be5 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,50 @@ 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. */ + 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. */ + 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. */ + bool has_broadcast_on_preset; + meshtastic_Config_LoRaConfig_ModemPreset broadcast_on_preset; + /* How often to broadcast, in seconds. Min 3600 (1 h), default 3600. */ + uint32_t broadcast_interval_secs; + /* 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; + /* TAK team/role configuration */ typedef struct _meshtastic_ModuleConfig_TAKConfig { /* Team color. @@ -516,6 +562,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 +621,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 +650,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, 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 +670,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, 0} #define meshtastic_ModuleConfig_TAKConfig_init_zero {_meshtastic_Team_MIN, _meshtastic_MemberRole_MIN} #define meshtastic_RemoteHardwarePin_init_zero {0, "", _meshtastic_RemoteHardwarePinType_MIN} @@ -735,6 +790,18 @@ 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_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 @@ -759,6 +826,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 +845,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 +865,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 +1051,24 @@ 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, 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, 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 +#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 +1099,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 +1121,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 +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 276 #define meshtastic_ModuleConfig_NeighborInfoConfig_size 10 #define meshtastic_ModuleConfig_PaxcounterConfig_size 30 #define meshtastic_ModuleConfig_RangeTestConfig_size 12 @@ -1054,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 227 +#define meshtastic_ModuleConfig_size 280 #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/AdminModule.cpp b/src/modules/AdminModule.cpp index 8715e202dbe..27d1327dc69 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -35,6 +35,9 @@ #ifdef MESHTASTIC_ENCRYPTED_STORAGE #include "security/EncryptedStorage.h" #endif +#if !MESHTASTIC_EXCLUDE_BEACON +#include "modules/MeshBeaconModule.h" +#endif #if !MESHTASTIC_EXCLUDE_MQTT #include "mqtt/MQTT.h" @@ -310,6 +313,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.mesh_beacon.broadcast_send_as_node; + } + } +#endif if (!handleSetModuleConfig(r->set_module_config)) { myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp); } @@ -1147,6 +1161,57 @@ 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); + // 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; + // Validate broadcast_on_preset against broadcast_on_region (or current region if unset). + if (b.has_broadcast_on_preset) { + 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.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.has_broadcast_offer_preset) { + 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.has_broadcast_offer_preset = false; + } + } + // 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); + 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; + } + } + 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 } saveChanges(SEGMENT_MODULECONFIG, shouldReboot); return true; diff --git a/src/modules/AdminModule.h b/src/modules/AdminModule.h index 1d5c64423bc..d072ab2fe74 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 new file mode 100644 index 00000000000..18134788485 --- /dev/null +++ b/src/modules/MeshBeaconModule.cpp @@ -0,0 +1,473 @@ +#include "MeshBeaconModule.h" +#include "Default.h" +#include "DisplayFormatters.h" +#include "MeshService.h" +#include "NodeDB.h" +#include "PowerFSM.h" +#include "RTC.h" +#include "RadioInterface.h" +#include "Router.h" +#include "configuration.h" +#include "main.h" +#include +#include + +// Static members +meshtastic_Config_LoRaConfig_ModemPreset MeshBeaconModule::originalModemPreset; +uint16_t MeshBeaconModule::originalLoraChannel; +meshtastic_Config_LoRaConfig_RegionCode MeshBeaconModule::originalRegion; +meshtastic_ChannelSettings MeshBeaconModule::originalPrimaryChannel; + +static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8]; + +static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset *preset, + uint16_t *slot) +{ + if (!p) + return false; + for (const auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + if (preset) + *preset = entry.preset; + if (slot) + *slot = entry.slot; + return true; + } + } + return false; +} + +// --------------------------------------------------------------------------- +// MeshBeaconModule base +// --------------------------------------------------------------------------- + +MeshBeaconModule::MeshBeaconModule() +{ + originalModemPreset = config.lora.modem_preset; + originalLoraChannel = config.lora.channel_num; + originalRegion = config.lora.region; + originalPrimaryChannel = channels.getPrimary(); +} + +void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, + uint16_t slot) +{ + if (!p) + return; + MeshBeaconModule_TargetRadioSettings *target = nullptr; + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + target = &entry; + break; + } + if (!target && !entry.inUse) + target = &entry; + } + if (!target) + target = &targetRadioSettings[0]; + target->inUse = true; + target->id = p->id; + target->preset = preset; + target->slot = slot; +} + +bool MeshBeaconModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + return getTargetRadioSettings(p, nullptr, nullptr); +} + +void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p) +{ + if (!p) + return; + for (auto &entry : targetRadioSettings) { + if (entry.inUse && entry.id == p->id) { + entry.inUse = false; + return; + } + } +} + +bool MeshBeaconModule::beaconTxConfigInvalid(const meshtastic_MeshPacket *p) +{ + meshtastic_Config_LoRaConfig_ModemPreset preset; + 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); +} + +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; + meshtastic_Config_LoRaConfig_ModemPreset targetPreset; + uint16_t targetSlot; + + 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)) { + + 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; + + meshtastic_ChannelSettings targetChannel = beaconChannelSettings(*c, targetPreset); + + if (targetPreset == config.lora.modem_preset && targetSlot == config.lora.channel_num && + targetRegion == config.lora.region && !channelDiffers(targetChannel)) + return false; + + // Guard: never key up on an invalid target config — bad preset for the region, or an + // unlicensed node keying up on a ham-only region. Refuse the switch here so we never + // transmit on it; the radio driver drops the packet outright (see RadioLibInterface, + // beaconTxConfigInvalid) rather than letting it fall through onto the current config. + if (beaconTxConfigInvalid(p)) { + LOG_DEBUG("Beacon: target preset %d/region %d invalid (or ham mismatch), not switching", targetPreset, targetRegion); + return false; + } + + // Snapshot current (non-beacon) settings so we restore to the latest config. + originalModemPreset = config.lora.modem_preset; + originalLoraChannel = config.lora.channel_num; + originalRegion = config.lora.region; + originalPrimaryChannel = *c; + + 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; + *c = targetChannel; + + channels.fixupChannel(channels.getPrimaryIndex()); + p->channel = channels.getHash(channels.getPrimaryIndex()); + iface->reconfigure(); + return true; + + } else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && + (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; + *c = originalPrimaryChannel; + c->name[sizeof(c->name) - 1] = '\0'; + + channels.fixupChannel(channels.getPrimaryIndex()); + iface->reconfigure(); + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// MeshBeaconBroadcastModule +// --------------------------------------------------------------------------- + +MeshBeaconBroadcastModule *meshBeaconBroadcastModule; + +MeshBeaconBroadcastModule::MeshBeaconBroadcastModule() + : MeshBeaconModule(), ProtobufModule("beacon_tx", meshtastic_PortNum_MESH_BEACON_APP, &meshtastic_MeshBeacon_msg), + concurrency::OSThread("MeshBeaconBroadcast") +{ + setIntervalFromNow(setStartDelay()); +} + +void MeshBeaconBroadcastModule::rebuildCache() +{ + const auto &bcfg = moduleConfig.mesh_beacon; + meshtastic_MeshBeacon beacon = meshtastic_MeshBeacon_init_zero; + strncpy(beacon.message, bcfg.broadcast_message, sizeof(beacon.message) - 1); + if (bcfg.has_broadcast_offer_channel) { + beacon.has_offer_channel = true; + beacon.offer_channel = bcfg.broadcast_offer_channel; + // PSK is included intentionally: this beacon is a public join-invitation. + // The offered channel is not secret — the PSK here is a convenience token, + // not a security boundary. Operators who want a private channel must + // distribute the PSK out-of-band and leave offer_channel unset. + } + beacon.has_offer_preset = bcfg.has_broadcast_offer_preset; + beacon.offer_preset = bcfg.broadcast_offer_preset; + beacon.offer_region = bcfg.broadcast_offer_region; + payloadCacheSize = (pb_size_t)pb_encode_to_bytes(payloadCache, sizeof(payloadCache), &meshtastic_MeshBeacon_msg, &beacon); + payloadCacheDirty = false; + LOG_DEBUG("Beacon: payload cache rebuilt (%u bytes)", payloadCacheSize); +} + +void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset) +{ + 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; + + const bool hasText = bcfg.broadcast_message[0] != '\0'; + const bool hasRadioContent = bcfg.has_broadcast_offer_preset || bcfg.has_broadcast_offer_channel || + (bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET); + + if (!hasText && !hasRadioContent) { + LOG_DEBUG("Beacon: nothing to send (empty message, no offer), skipping"); + return; + } + + // 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); + 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; + // 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; + p->want_ack = false; + p->rx_time = getValidTime(RTCQualityFromNet); + }; + + // ── Main packet(s) ────────────────────────────────────────────────────── + // + // 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 + // 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) { + 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); + + 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); + sendBeaconPacket(pA, targetPreset); + } + + if (sendTextOnly) { + // 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"); + 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); + sendBeaconPacket(pB, targetPreset); + } + + if (sendCombined) { + // 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); + sendBeaconPacket(p, targetPreset); + } +} + +int32_t MeshBeaconBroadcastModule::runOnce() +{ + const auto &bcfg = moduleConfig.mesh_beacon; + if (bcfg.broadcast_enabled && airTime->isTxAllowedAirUtil() && + config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) { + sendBeacon(); + } + + 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; +} + +// --------------------------------------------------------------------------- +// MeshBeaconListenerModule +// --------------------------------------------------------------------------- + +MeshBeaconListenerModule *meshBeaconListenerModule; +MeshBeaconListenerModule::BeaconOffer MeshBeaconListenerModule::lastReceivedOffer; + +MeshBeaconListenerModule::MeshBeaconListenerModule() + : ProtobufModule("beacon_listen", meshtastic_PortNum_MESH_BEACON_APP, &meshtastic_MeshBeacon_msg) +{ + lastReceivedOffer = {}; +} + +bool MeshBeaconListenerModule::wantPacket(const meshtastic_MeshPacket *p) +{ + return moduleConfig.has_mesh_beacon && moduleConfig.mesh_beacon.listen_enabled && + p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP; +} + +bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) +{ + const bool hasOfferContent = + b && (b->has_offer_channel || b->offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || b->has_offer_preset); + const pb_size_t msgLen = b ? (pb_size_t)strnlen(b->message, sizeof(b->message) - 1) : 0; + const bool hasText = msgLen > 0; + if (!b || (!hasText && !hasOfferContent)) + return false; + + 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 + // 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"); + 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 = msgLen; + memcpy(txt->decoded.payload.bytes, b->message, txt->decoded.payload.size); + service->sendToPhone(txt); // takes ownership of txt — do not release + } + + // Cache any offer for the client app — never auto-applied. + if (hasOfferContent) { + lastReceivedOffer.valid = true; + lastReceivedOffer.sender = mp.from; + lastReceivedOffer.has_channel = b->has_offer_channel; + if (b->has_offer_channel) + lastReceivedOffer.channel = b->offer_channel; + lastReceivedOffer.region = b->offer_region; + lastReceivedOffer.preset = b->offer_preset; + lastReceivedOffer.received_at = + getValidTime(RTCQualityFromNet); // 0 if no RTC fix yet — consumers must not treat 0 as valid + LOG_INFO("Beacon: stored offer from %#08lx (preset=%d)", mp.from, b->offer_preset); + } + + if (hasText) + powerFSM.trigger(EVENT_RECEIVED_MSG); + notifyObservers(&mp); + return false; +} diff --git a/src/modules/MeshBeaconModule.h b/src/modules/MeshBeaconModule.h new file mode 100644 index 00000000000..258ad6f99b5 --- /dev/null +++ b/src/modules/MeshBeaconModule.h @@ -0,0 +1,145 @@ +#pragma once +#include "MeshRadio.h" +#include "Observer.h" +#include "ProtobufModule.h" +#include "RadioInterface.h" +#include "concurrency/OSThread.h" +#include "mesh/generated/meshtastic/mesh_beacon.pb.h" +#include "mesh/generated/meshtastic/module_config.pb.h" + +// 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_TargetRadioSettings; + +/** + * Base class: holds the radio-switching sidecar table and static helpers. + * The sidecar avoids touching MeshPacket proto fields for per-packet radio state. + */ +class MeshBeaconModule +{ + public: + MeshBeaconModule(); + + /** + * Reconfigure the radio for beacon TX, or restore to original config if p is NULL. + * Returns true if the radio was reconfigured (caller must re-run transmit delay for CCA). + * Driven by broadcast_on_preset / broadcast_on_channel from MeshBeaconConfig. + */ + static bool reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p); + + /** + * Associate target radio settings with an outgoing packet by its ID. + * Sidecar holds 8 entries; evicts slot 0 on overflow. + */ + static void setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, + uint16_t slot); + + /** + * Returns true if the sidecar table contains an entry for this packet's ID. + * Used by RadioLibInterface to gate the channel-active check. + */ + static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p); + + /** + * Remove the sidecar entry for this packet after it has been sent. + * Called from RadioLibInterface::completeSending(). + */ + static void clearTargetRadioSettings(const meshtastic_MeshPacket *p); + + /** + * True if p is tagged for a beacon radio switch whose target config must NOT be transmitted: + * preset invalid for the target region, or an unlicensed node would key up on a ham-only + * (licensed-only) region. The radio driver drops such packets rather than sending them on the + * current config. False for any packet without a sidecar entry (normal traffic is never affected). + */ + static bool beaconTxConfigInvalid(const meshtastic_MeshPacket *p); + + protected: + /** + * Build the ChannelSettings the beacon transmits on: the base (primary) channel overlaid with + * any broadcast_on_channel overrides, defaulting an empty name to the target preset's display + * name. Shared by the encrypt-time channel swap and the radio-thread RF swap so the channel + * key + hash are identical at both points. + */ + static meshtastic_ChannelSettings beaconChannelSettings(const meshtastic_ChannelSettings &base, + meshtastic_Config_LoRaConfig_ModemPreset preset); + + static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset; + static uint16_t originalLoraChannel; + static meshtastic_Config_LoRaConfig_RegionCode originalRegion; + static meshtastic_ChannelSettings originalPrimaryChannel; +}; + +/** + * Broadcaster: periodically sends MeshBeacon packets on the configured preset/channel. + * Active only when 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, + * 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, + private concurrency::OSThread +{ + public: + MeshBeaconBroadcastModule(); + + // Mark the cached payload dirty (call after config change). + void invalidateCache() { payloadCacheDirty = true; } + + protected: + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &, meshtastic_MeshBeacon *) override { return false; } + virtual int32_t runOnce() override; + + protected: + void sendBeacon(); + void rebuildCache(); + + // Send one beacon packet. When 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; +}; +extern MeshBeaconBroadcastModule *meshBeaconBroadcastModule; + +/** + * 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 MeshBeaconListenerModule : public ProtobufModule, public Observable +{ + public: + MeshBeaconListenerModule(); + + struct BeaconOffer { + bool valid; + NodeNum sender; + bool has_channel; + meshtastic_ChannelSettings channel; + meshtastic_Config_LoRaConfig_RegionCode region; + meshtastic_Config_LoRaConfig_ModemPreset preset; + uint32_t received_at; + }; + + // Last received offer — accessible to admin/API for client app retrieval. + static BeaconOffer lastReceivedOffer; + + protected: + virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) override; + virtual bool wantPacket(const meshtastic_MeshPacket *p) override; +}; +extern MeshBeaconListenerModule *meshBeaconListenerModule; diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 5cc94a68fca..569a60a5a84 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_BEACON +#include "modules/MeshBeaconModule.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_BEACON + meshBeaconBroadcastModule = new MeshBeaconBroadcastModule(); + meshBeaconListenerModule = new MeshBeaconListenerModule(); +#endif #if !MESHTASTIC_EXCLUDE_GPS positionModule = new PositionModule(); #endif diff --git a/test/test_mesh_beacon/test_main.cpp b/test/test_mesh_beacon/test_main.cpp new file mode 100644 index 00000000000..1bb907a7aed --- /dev/null +++ b/test/test_mesh_beacon/test_main.cpp @@ -0,0 +1,1208 @@ +/** + * 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 +#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]; \ + println(_buf, sizeof(_buf), "/n", ##__VA_ARGS__); \ + TEST_MESSAGE(_buf); \ + } while (0) + +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 + { + // 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; + } + + // 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; + std::vector primaryAtSend; +}; + +// --------------------------------------------------------------------------- +// 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(); +} + +// 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 +// =========================================================================== + +/** + * 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(); + + 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_FALSE_MESSAGE(moduleConfig.mesh_beacon.has_broadcast_on_preset, "SHORT_TURBO must be cleared for EU_868"); +} + +/** + * 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(); + + 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_FALSE(moduleConfig.mesh_beacon.has_broadcast_on_preset); +} + +/** + * 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(); + config.lora.region = meshtastic_Config_LoRaConfig_RegionCode_US; + 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); +} + +/** + * 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(); + + 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); +} + +/** + * 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(); + + 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); +} + +/** + * 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(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + // 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 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]); +} + +/** + * 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(); + + 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); +} + +/** + * 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(); + + meshtastic_ModuleConfig_MeshBeaconConfig bcfg = meshtastic_ModuleConfig_MeshBeaconConfig_init_zero; + bcfg.broadcast_interval_secs = 999999; + + testAdmin->handleSetModuleConfig(makeBeaconModuleConfig(bcfg)); + + TEST_ASSERT_EQUAL_UINT32(999999, moduleConfig.mesh_beacon.broadcast_interval_secs); +} + +/** + * 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(); + + 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); +} + +/** + * 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(); + + 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); +} + +/** + * 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(); + + // 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 +// =========================================================================== + +/** + * 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(); + 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"); +} + +/** + * 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(); + 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); +} + +/** + * 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(); + 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); + + 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); +} + +/** + * 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(); + moduleConfig.has_mesh_beacon = true; + + MeshBeaconBroadcastModuleTestShim bcast; + bcast.rebuildCache(); + TEST_ASSERT_FALSE(bcast.payloadCacheDirty); + + bcast.invalidateCache(); + TEST_ASSERT_TRUE(bcast.payloadCacheDirty); +} + +/** + * 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(); + 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 +// =========================================================================== + +/** + * 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(); + 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); +} + +/** + * 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(); + 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); +} + +/** + * 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(); + 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); +} + +/** + * 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(); + 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; + bcast.sendBeacon(); + + TEST_ASSERT_EQUAL_UINT32(1, mockRouter->sentPackets.size()); + TEST_ASSERT_EQUAL(meshtastic_PortNum_MESH_BEACON_APP, mockRouter->sentPackets[0].decoded.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(); + 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.has_broadcast_on_preset = true; + 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); +} + +/** + * 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(); + 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; + 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); +} + +/** + * 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(); + 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); +} + +/** + * 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(); + 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()); +} + +/** + * 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(); + 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; +} + +/** + * 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(); + 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.has_offer_preset = true; + 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); +} + +/** + * 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(); + 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); +} + +/** + * 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(); + moduleConfig.has_mesh_beacon = true; + moduleConfig.mesh_beacon.listen_enabled = true; + + MeshBeaconListenerModuleTestShim listener; + MeshBeaconListenerModule::lastReceivedOffer = {}; + + meshtastic_MeshBeacon b = meshtastic_MeshBeacon_init_zero; + // 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"); +} + +/** + * 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(); + 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); +} + +/** + * 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(); + 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); +} + +/** + * 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(); + 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); + // has_offer_preset == false, 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"); +} + +/** + * 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. + */ +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)); +} + +/** + * 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(); + 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)); +} + +// =========================================================================== +// 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); +} + +// =========================================================================== +// 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 + +// =========================================================================== +// 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; + + // 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; + + router = nullptr; + delete mockRouter; + mockRouter = nullptr; + + airTime = nullptr; + delete testAirTime; + testAirTime = nullptr; +} + +BEACON_TEST_ENTRY void setup() +{ + delay(10); + initializeTestEnvironment(); + UNITY_BEGIN(); + + printf("\n=== AdminModule config validation ===\n"); + + 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_isTruncatedAt100); + RUN_TEST(test_adminValidation_intervalTooLow_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); + + printf("\n=== Broadcaster payload cache ===\n"); + + 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); + + printf("\n=== Broadcaster sendBeacon ===\n"); + + 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_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); + + printf("\n=== Listener offer caching === \n"); + + RUN_TEST(test_listener_receiveWithOffer_cachesOffer); + RUN_TEST(test_listener_receiveWithChannelOffer_setsHasChannel); + 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_textMessage_deliveredToPhoneNotMesh); + RUN_TEST(test_listener_wantPacket_falseWhenDisabled); + RUN_TEST(test_listener_wantPacket_trueWhenEnabled); + + printf("\n=== Legacy split messages ===\n"); + + RUN_TEST(test_broadcaster_legacySplit_sendsTwoPackets); + RUN_TEST(test_broadcaster_legacySplit_firstPacketIsBeaconApp); + 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()); +} + +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 diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 57ede8bbf91..c1a2904d2b1 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -57,6 +57,20 @@ // "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_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", // "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.