Merge remote-tracking branch 'origin/develop' into pr10715-fix

# Conflicts:
#	src/graphics/Screen.cpp
#	src/modules/Telemetry/Sensor/PMSA003ISensor.h
#	src/modules/Telemetry/Sensor/SCD30Sensor.h
#	src/modules/Telemetry/Sensor/SCD4XSensor.h
#	src/modules/Telemetry/Sensor/SEN5XSensor.cpp
#	src/modules/Telemetry/Sensor/SEN5XSensor.h
#	src/modules/Telemetry/Sensor/SFA30Sensor.h
This commit is contained in:
Thomas Göttgens
2026-07-03 11:12:16 +02:00
535 changed files with 20722 additions and 20884 deletions
+190 -25
View File
@@ -2,6 +2,7 @@
#include "Channels.h"
#include "MeshService.h"
#include "NodeDB.h"
#include "PositionPrecision.h"
#include "PowerFSM.h"
#include "RTC.h"
#include "SPILock.h"
@@ -34,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"
@@ -85,7 +89,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
assert(r);
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// While storage is locked, drop every admin payload — both local and
// While storage is locked, drop every admin payload - both local and
// remote (PKC, mesh-relayed). Lockdown unlock is the prerequisite for
// any admin operation: operators must authenticate via lockdown_auth
// first. The lockdown_auth path itself is handled synchronously in
@@ -96,9 +100,9 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
// operator has even unlocked it.
// Only gate when lockdown is ACTIVE. A lockdown-capable build that hasn't
// been provisioned (or was disabled) is not unlocked either, but must
// still serve admin normally — so check isLockdownActive() first.
// still serve admin normally - so check isLockdownActive() first.
if (EncryptedStorage::isLockdownActive() && !EncryptedStorage::isUnlocked()) {
LOG_WARN("AdminModule: dropping admin payload — storage locked");
LOG_WARN("AdminModule: dropping admin payload - storage locked");
return handled;
}
#endif
@@ -132,7 +136,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
// local packets into the remote-PKC key check.
//
// Under MESHTASTIC_PHONEAPI_ACCESS_CONTROL, the per-connection auth
// gate lives in PhoneAPI::handleToRadioPacket — any local admin
// gate lives in PhoneAPI::handleToRadioPacket - any local admin
// payload other than lockdown_auth is dropped there if the
// originating connection is unauthorized. By the time we reach
// this branch the connection has already proven the passphrase,
@@ -164,7 +168,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
// Note: PKC admin does NOT automatically authorize the
// originating local PhoneAPI connection for content
// redaction purposes. PKC and the per-connection lockdown
// auth slot are independent gates — operators using PKC
// auth slot are independent gates - operators using PKC
// admin from a local app should still send lockdown_auth
// separately to unlock the redacted FromRadio stream.
// (The previous auto-authorize path read a shared
@@ -180,8 +184,11 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
// without the user doing so deliberately.
LOG_INFO("PKC admin valid, but not auto-favoriting node %x because role==CLIENT_BASE", mp.from);
} else {
LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from);
nodeInfoLiteSetBit(remoteNode, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
if (nodeDB->setProtectedFlag(remoteNode, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) {
LOG_INFO("PKC admin valid. Auto-favoriting node %x", mp.from);
} else {
LOG_WARN("PKC admin valid, but auto-favorite refused for node %x (protected-node cap)", mp.from);
}
}
}
} else {
@@ -210,7 +217,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
#ifdef MESHTASTIC_ENCRYPTED_STORAGE
// lockdown_auth is handled synchronously in
// PhoneAPI::handleToRadioPacket — see handleLockdownAuthInline. A
// PhoneAPI::handleToRadioPacket - see handleLockdownAuthInline. A
// packet should not normally reach AdminModule under that flag set,
// but if it ever does (e.g. injected via a non-PhoneAPI path), drop
// it silently rather than leaking a partial response.
@@ -294,7 +301,7 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
break;
}
// Hardware supports 2.4 GHz — apply the config.
// Hardware supports 2.4 GHz - apply the config.
// Fail closed: null instance is treated as incapable.
if (RadioLibInterface::instance && RadioLibInterface::instance->wideLora()) {
LOG_DEBUG("LORA_24 requested, radio hardware supports 2.4 GHz, applying");
@@ -309,6 +316,17 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
case meshtastic_AdminMessage_set_module_config_tag:
LOG_DEBUG("Client set module config");
#if !MESHTASTIC_EXCLUDE_BEACON
// broadcast_send_as_node: remote admins may only set this to their own node ID.
if (mp.from != 0 && r->set_module_config.which_payload_variant == meshtastic_ModuleConfig_mesh_beacon_tag) {
auto &b = r->set_module_config.payload_variant.mesh_beacon;
if (b.broadcast_send_as_node != 0 && b.broadcast_send_as_node != mp.from) {
LOG_WARN("Beacon: rejecting broadcast_send_as_node 0x%08x from node 0x%08x (must match sender)",
b.broadcast_send_as_node, mp.from);
b.broadcast_send_as_node = moduleConfig.mesh_beacon.broadcast_send_as_node;
}
}
#endif
if (!handleSetModuleConfig(r->set_module_config)) {
myReply = allocErrorResponse(meshtastic_Routing_Error_BAD_REQUEST, &mp);
}
@@ -471,10 +489,15 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
LOG_INFO("Client received set_favorite_node command");
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_favorite_node);
if (node != NULL) {
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true);
saveChanges(SEGMENT_NODEDATABASE, false);
if (screen)
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_FAVORITE_MASK, true)) {
saveChanges(SEGMENT_NODEDATABASE, false);
if (screen)
screen->setFrames(graphics::Screen::FOCUS_PRESERVE); // <-- Rebuild screens
} else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "favorite", r->set_favorite_node, MAX_NUM_NODES - 2);
} else {
LOG_WARN("Remote set_favorite_node for 0x%08x refused: protected-node cap", r->set_favorite_node);
}
}
break;
}
@@ -491,13 +514,19 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta
}
case meshtastic_AdminMessage_set_ignored_node_tag: {
LOG_INFO("Client received set_ignored_node command");
meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(r->set_ignored_node);
// Unlike the sibling node-targeted admin commands, create the entry if
// it's absent so the block sticks for a node we've not heard from yet
// (e.g. one a remote admin asks us to block) with no NodeInfo or key.
meshtastic_NodeInfoLite *node = nodeDB->getOrCreateMeshNode(r->set_ignored_node);
if (node != NULL) {
nodeInfoLiteSetBit(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true);
nodeDB->eraseNodeSatellites(node->num);
node->public_key.size = 0;
memset(node->public_key.bytes, 0, sizeof(node->public_key.bytes));
saveChanges(SEGMENT_NODEDATABASE, false);
if (nodeDB->setProtectedFlag(node, NODEINFO_BITFIELD_IS_IGNORED_MASK, true)) {
nodeDB->eraseNodeSatellites(node->num);
saveChanges(SEGMENT_NODEDATABASE, false);
} else if (mp.from == 0) { // local request from the phone - tell the user why it didn't take
sendWarning(NodeDB::PROTECTED_CAP_WARN_FMT, "ignore", r->set_ignored_node, MAX_NUM_NODES - 2);
} else {
LOG_WARN("Remote set_ignored_node for 0x%08x refused: protected-node cap", r->set_ignored_node);
}
}
break;
}
@@ -991,6 +1020,14 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
}
#endif
#if !MESHTASTIC_EXCLUDE_GPS
// Enable gps if it was previously disabled due to region not being set
if (!requiresReboot && config.lora.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && gps != nullptr &&
!gps->isEnabled() && config.position.gps_mode == meshtastic_Config_PositionConfig_GpsMode_ENABLED) {
gps->enable();
}
#endif
config.lora = validatedLora; // Finally, return the validated config back to the main config
break;
@@ -1000,16 +1037,24 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
config.has_bluetooth = true;
config.bluetooth = c.payload_variant.bluetooth;
break;
case meshtastic_Config_security_tag:
case meshtastic_Config_security_tag: {
LOG_INFO("Set config: Security");
config.security = c.payload_variant.security;
meshtastic_Config_SecurityConfig incoming = c.payload_variant.security;
// Preserve our keypair when a SET omits the private key but we already hold one: regenerating would
// change our NodeNum (== crc32(public_key)) and orphan us on the mesh. A SET without the key is a
// partial/legacy client, not an identity reset (that goes through factory_reset). Done outside the
// PKI guard so non-PKI builds keep their key bytes too.
if (incoming.private_key.size != 32 && config.security.private_key.size == 32) {
LOG_WARN("Security set omitted private key; preserving existing identity keypair");
incoming.private_key = config.security.private_key;
incoming.public_key = config.security.public_key;
}
config.security = incoming;
#if !(MESHTASTIC_EXCLUDE_PKI_KEYGEN) && !(MESHTASTIC_EXCLUDE_PKI)
// Only regenerate keys if the private key is not 32 bytes
// First provisioning (no key) generates one; a private key supplied without its public key derives it.
if (config.security.private_key.size != 32) {
nodeDB->generateCryptoKeyPair();
}
// If user provided a private key of correct size but no public key, generate the public key from private key
else if (config.security.private_key.size == 32 && config.security.public_key.size == 0) {
} else if (config.security.public_key.size == 0) {
nodeDB->generateCryptoKeyPair(config.security.private_key.bytes);
}
#endif
@@ -1026,6 +1071,7 @@ void AdminModule::handleSetConfig(const meshtastic_Config &c, bool fromOthers)
requiresReboot = true;
break;
}
case meshtastic_Config_device_ui_tag:
// NOOP! This is handled by handleStoreDeviceUIConfig
break;
@@ -1146,6 +1192,91 @@ bool AdminModule::handleSetModuleConfig(const meshtastic_ModuleConfig &c)
moduleConfig.has_traffic_management = true;
moduleConfig.traffic_management = c.payload_variant.traffic_management;
break;
#if !MESHTASTIC_EXCLUDE_BEACON
case meshtastic_ModuleConfig_mesh_beacon_tag: {
LOG_INFO("Set module config: MeshBeacon");
// Sanitize a local copy rather than const_cast-ing the const input (UB if a truly-const
// object is ever passed); the validated copy is assigned into moduleConfig below.
auto beaconCfg = c.payload_variant.mesh_beacon;
// Hard cap at 100 chars.
beaconCfg.broadcast_message[100] = '\0';
// Enforce interval minimum (0 means unset/use default).
if (beaconCfg.broadcast_interval_secs != 0 &&
beaconCfg.broadcast_interval_secs < default_mesh_beacon_min_broadcast_interval_secs)
beaconCfg.broadcast_interval_secs = default_mesh_beacon_min_broadcast_interval_secs;
// Validate broadcast_on_preset against broadcast_on_region (or current region if unset).
if (beaconCfg.has_broadcast_on_preset) {
meshtastic_Config_LoRaConfig probe = config.lora;
probe.use_preset = true;
probe.modem_preset = beaconCfg.broadcast_on_preset;
if (beaconCfg.broadcast_on_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)
probe.region = beaconCfg.broadcast_on_region;
if (!RadioInterface::validateConfigLora(probe)) {
LOG_WARN("Beacon: broadcast_on_preset %d invalid for region, clearing", beaconCfg.broadcast_on_preset);
beaconCfg.has_broadcast_on_preset = false;
beaconCfg.has_broadcast_on_channel = false;
}
}
// Validate broadcast_offer_preset against broadcast_offer_region (or current region if unset).
if (beaconCfg.has_broadcast_offer_preset) {
meshtastic_Config_LoRaConfig probe = config.lora;
probe.use_preset = true;
probe.modem_preset = beaconCfg.broadcast_offer_preset;
if (beaconCfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)
probe.region = beaconCfg.broadcast_offer_region;
if (!RadioInterface::validateConfigLora(probe)) {
LOG_WARN("Beacon: broadcast_offer_preset %d invalid for region, clearing", beaconCfg.broadcast_offer_preset);
beaconCfg.has_broadcast_offer_preset = false;
}
}
// Validate broadcast_offer_region is a known region code.
if (beaconCfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
const RegionInfo *r = getRegion(beaconCfg.broadcast_offer_region);
if (r->code != beaconCfg.broadcast_offer_region) {
LOG_WARN("Beacon: broadcast_offer_region %d invalid, clearing", beaconCfg.broadcast_offer_region);
beaconCfg.broadcast_offer_region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
}
}
// Validate each multi-target entry the same way as the single-target broadcast_on_* fields,
// so a bad preset/region is cleared on write rather than relying on the runtime TX drop.
for (pb_size_t i = 0; i < beaconCfg.broadcast_targets_count; i++) {
auto &t = beaconCfg.broadcast_targets[i];
// Region must be a known region code (UNSET = use running config at TX time).
if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) {
const RegionInfo *r = getRegion(t.region);
if (r->code != t.region) {
LOG_WARN("Beacon: broadcast_targets[%u] region %d invalid, clearing", i, t.region);
t.region = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
}
}
// Preset must be valid for the target region (or current region if unset).
if (t.has_preset) {
meshtastic_Config_LoRaConfig probe = config.lora;
probe.use_preset = true;
probe.modem_preset = t.preset;
if (t.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET)
probe.region = t.region;
if (!RadioInterface::validateConfigLora(probe)) {
LOG_WARN("Beacon: broadcast_targets[%u] preset %d invalid for region, clearing", i, t.preset);
t.has_preset = false;
t.has_channel_index = false;
}
}
// channel_index must reference a real channel-table slot.
if (t.has_channel_index && t.channel_index >= MAX_NUM_CHANNELS) {
LOG_WARN("Beacon: broadcast_targets[%u] channel_index %u out of range, clearing", i, t.channel_index);
t.has_channel_index = false;
}
}
moduleConfig.has_mesh_beacon = true;
moduleConfig.mesh_beacon = beaconCfg;
shouldReboot = false;
// Payload content changed - invalidate the broadcaster's cache.
if (meshBeaconBroadcastModule)
meshBeaconBroadcastModule->invalidateCache();
break;
}
#endif
}
saveChanges(SEGMENT_MODULECONFIG, shouldReboot);
return true;
@@ -1157,7 +1288,26 @@ void AdminModule::handleSetChannel(const meshtastic_Channel &cc)
if (channels.ensureLicensedOperation()) {
sendWarning(licensedModeMessage);
}
// Refresh derived state (primaryIndex in particular) BEFORE the precision clamp below. usesPublicKey()
// resolves a secondary channel's key against the primary, so it must see the post-update primaryIndex;
// running the clamp first could evaluate secondaries against the previous primary and skip the clamp/warning.
channels.onConfigChanged(); // tell the radios about this change
// Persist the public-key precision clamp for all channels that may be affected (e.g. secondaries
// that inherit a now-public primary key) and warn the client once if anything was coarsened.
bool clamped = false;
for (uint8_t i = 0; i < channels.getNumChannels(); i++) {
meshtastic_Channel &ch = channels.getByIndex(i);
if (ch.role == meshtastic_Channel_Role_DISABLED || !ch.settings.has_module_settings)
continue;
uint32_t allowed = getPositionPrecisionForChannel(i);
if (allowed != ch.settings.module_settings.position_precision) {
ch.settings.module_settings.position_precision = allowed;
clamped = true;
}
}
if (clamped)
sendWarning(publicChannelPrecisionMessage);
saveChanges(SEGMENT_CHANNELS, false);
}
@@ -1337,6 +1487,13 @@ void AdminModule::handleGetModuleConfig(const meshtastic_MeshPacket &req, const
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_traffic_management_tag;
res.get_module_config_response.payload_variant.traffic_management = moduleConfig.traffic_management;
break;
#if !MESHTASTIC_EXCLUDE_BEACON
case meshtastic_AdminMessage_ModuleConfigType_MESHBEACON_CONFIG:
configName = "MeshBeacon";
res.get_module_config_response.which_payload_variant = meshtastic_ModuleConfig_mesh_beacon_tag;
res.get_module_config_response.payload_variant.mesh_beacon = moduleConfig.mesh_beacon;
break;
#endif
}
LOG_INFO("Get module config: %s", configName);
@@ -1385,6 +1542,13 @@ void AdminModule::handleGetNodeRemoteHardwarePins(const meshtastic_MeshPacket &r
void AdminModule::handleGetDeviceMetadata(const meshtastic_MeshPacket &req)
{
#if WARM_NODE_COUNT > 0 && MESHTASTIC_NODEDB_MIGRATION_VERBOSE
// Debug aid: dump the warm tier to the console on a local metadata request
// (e.g. `meshtastic --info` over USB/BLE). Gated to req.from == 0 so remote
// or admin polling can't spam the console.
if (nodeDB && req.from == 0)
nodeDB->warmStore.dumpToLog("admin get_metadata");
#endif
meshtastic_AdminMessage r = meshtastic_AdminMessage_init_default;
r.get_device_metadata_response = getDeviceMetadata();
r.which_payload_variant = meshtastic_AdminMessage_get_device_metadata_response_tag;
@@ -1712,6 +1876,7 @@ void disableBluetooth()
#ifdef ARCH_ESP32
if (nimbleBluetooth)
nimbleBluetooth->deinit();
esp32ReleaseBluetoothMemoryIfUnused();
#elif defined(ARCH_NRF52)
if (nrf52Bluetooth)
nrf52Bluetooth->shutdown();
+9
View File
@@ -22,6 +22,8 @@ struct AdminModule_ObserverData {
*/
class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Observable<AdminModule_ObserverData *>
{
friend class AdminModuleTestShim; // native unit tests reach handleSetConfig + hasOpenEditTransaction
public:
/** Constructor
* name is for debugging output
@@ -64,7 +66,11 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
protected:
void handleSetConfig(const meshtastic_Config &c, bool fromOthers);
#ifdef PIO_UNIT_TESTING
protected:
#else
private:
#endif
bool handleSetModuleConfig(const meshtastic_ModuleConfig &c);
void handleSetChannel();
@@ -88,6 +94,9 @@ class AdminModule : public ProtobufModule<meshtastic_AdminMessage>, public Obser
static constexpr const char *licensedModeMessage =
"Licensed mode activated, removing admin channel and encryption from all channels";
static constexpr const char *publicChannelPrecisionMessage =
"Precise position is not allowed on a public (open / known-key) channel; reduced to coarse precision";
extern AdminModule *adminModule;
void disableBluetooth();
+1 -1
View File
@@ -6,6 +6,6 @@ AtakPluginModule::AtakPluginModule() : SinglePortModule("atak", meshtastic_PortN
ProcessMessage AtakPluginModule::handleReceived(const meshtastic_MeshPacket &mp)
{
(void)mp; // Passthrough — no processing needed, apps handle compression/decompression
(void)mp; // Passthrough - no processing needed, apps handle compression/decompression
return ProcessMessage::CONTINUE;
}
+12 -4
View File
@@ -84,7 +84,11 @@ void CannedMessageModule::LaunchWithDestination(NodeNum newDest, uint8_t newChan
// Do NOT override explicit broadcast replies
// Only reuse lastDest in LaunchRepeatDestination()
dest = newDest;
if (newDest == 0) {
dest = NODENUM_BROADCAST;
} else {
dest = newDest;
}
channel = newChannel;
lastDest = dest;
@@ -124,7 +128,11 @@ void CannedMessageModule::LaunchFreetextWithDestination(NodeNum newDest, uint8_t
// Do NOT override explicit broadcast replies
// Only reuse lastDest in LaunchRepeatDestination()
dest = newDest;
if (newDest == 0) {
dest = NODENUM_BROADCAST;
} else {
dest = newDest;
}
channel = newChannel;
lastDest = dest;
@@ -1361,7 +1369,7 @@ int32_t CannedMessageModule::runOnce()
case INPUT_BROKER_RIGHT:
break;
default:
// Only insert ASCII printable characters (32–126)
// Only insert ASCII printable characters (32-126)
if (this->payload >= 32 && this->payload <= 126) {
requestFocus();
if (this->cursor == this->freetext.length()) {
@@ -2120,7 +2128,7 @@ static float getSnrLimit(meshtastic_Config_LoRaConfig_ModemPreset preset)
}
}
// Return Good/Fair/Bad label and set 1–5 bars based on SNR and RSSI
// Return Good/Fair/Bad label and set 1-5 bars based on SNR and RSSI
static const char *getSignalGrade(float snr, int32_t rssi, float snrLimit, int &bars)
{
// 5-bar logic: strength inside Good/Fair/Bad category
+32 -17
View File
@@ -127,7 +127,10 @@ int32_t ExternalNotificationModule::runOnce()
#endif
#ifdef HAS_DRV2605
drv.go();
// Only trigger DRV2605 if vibration alerts are enabled
if (moduleConfig.external_notification.alert_message_vibra || moduleConfig.external_notification.alert_bell_vibra) {
drv.go();
}
#endif
}
@@ -144,7 +147,7 @@ int32_t ExternalNotificationModule::runOnce()
}
#endif
// now let the PWM buzzer play
if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz()) {
if (moduleConfig.external_notification.use_pwm && config.device.buzzer_gpio && canBuzz() && buzzerShouldAlert) {
if (rtttl::isPlaying()) {
rtttl::play();
} else if (isNagging && (nagCycleCutoff >= millis())) {
@@ -225,9 +228,18 @@ void ExternalNotificationModule::setExternalState(uint8_t index, bool on)
#endif
#ifdef HAS_DRV2605
// Only trigger DRV2605 when setting vibration motor
bool shouldTriggerDRV = false;
if (on) {
if (index == 1 &&
(moduleConfig.external_notification.alert_message_vibra || moduleConfig.external_notification.alert_bell_vibra)) {
shouldTriggerDRV = true;
}
}
if (shouldTriggerDRV) {
drv.go();
} else {
} else if (!on && index == 1) {
drv.stop();
}
#endif
@@ -266,6 +278,7 @@ void ExternalNotificationModule::stopNow()
// Prevent the state machine from immediately re-triggering outputs after a manual stop.
isNagging = false;
buzzerShouldAlert = false;
nagCycleCutoff = UINT32_MAX;
#ifdef HAS_I2S
@@ -404,8 +417,10 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP
// Alert GPIO Buzzer when receiving a bell = alertBellBuzzer: true
// Alert GPIO Buzzer when receiving a message = alertMessageBuzzer: true
const bool buzzerShouldAlert = canBuzz() && ((moduleConfig.external_notification.alert_bell_buzzer && containsBell) ||
(moduleConfig.external_notification.alert_message_buzzer && !is_muted));
// If you are already buzzing, keep going
buzzerShouldAlert =
buzzerShouldAlert || (canBuzz() && ((moduleConfig.external_notification.alert_bell_buzzer && containsBell) ||
(moduleConfig.external_notification.alert_message_buzzer && !is_muted)));
if (genericShouldAlert || vibraShouldAlert || buzzerShouldAlert) {
nagCycleCutoff = millis() + (moduleConfig.external_notification.nag_timeout
@@ -422,6 +437,18 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP
if (vibraShouldAlert) {
LOG_INFO("externalNotificationModule - Vibra alert");
#ifdef HAS_DRV2605
// Set DRV2605 waveform when vibration alert is triggered
drv.setWaveform(0, 16); // Long buzzer 100%
drv.setWaveform(1, 0); // Pause
drv.setWaveform(2, 16);
drv.setWaveform(3, 0);
drv.setWaveform(4, 16);
drv.setWaveform(5, 0);
drv.setWaveform(6, 16);
drv.setWaveform(7, 0);
drv.go();
#endif
setExternalState(1, true);
}
@@ -431,18 +458,6 @@ ProcessMessage ExternalNotificationModule::handleReceived(const meshtastic_MeshP
LOG_INFO("Message buzzer was suppressed because buzzer mode DIRECT_MSG_ONLY");
} else {
// Buzz if buzzer mode is not in DIRECT_MSG_ONLY or is DM to us
#ifdef HAS_DRV2605
drv.setWaveform(0, 16); // Long buzzer 100%
drv.setWaveform(1, 0); // Pause
drv.setWaveform(2, 16);
drv.setWaveform(3, 0);
drv.setWaveform(4, 16);
drv.setWaveform(5, 0);
drv.setWaveform(6, 16);
drv.setWaveform(7, 0);
drv.go();
#endif
if (moduleConfig.external_notification.use_i2s_as_buzzer) {
#ifdef HAS_I2S
audioThread->beginRttl(rtttlConfig.ringtone, strlen_P(rtttlConfig.ringtone));
+1
View File
@@ -90,6 +90,7 @@ class ExternalNotificationModule : public SinglePortModule, private concurrency:
bool isNagging = false;
bool isSilenced = false;
bool buzzerShouldAlert = false;
virtual AdminMessageHandleResult handleAdminMessageForModule(const meshtastic_MeshPacket &mp,
meshtastic_AdminMessage *request,
+4 -4
View File
@@ -23,7 +23,7 @@ constexpr uint32_t RUN_INTERVAL_MS = 5 * 60 * 1000UL; // Emit micro-summary ever
// Persistence
// Note: this only needs incrementing if the published arrangement changes. For testing purposes, or prior to widespread release,
// it can stay the same even if the internal layout changes.
constexpr uint32_t HISTOGRAM_STATE_MAGIC = 0x48535432; // 'HST2' — layout v2
constexpr uint32_t HISTOGRAM_STATE_MAGIC = 0x48535432; // 'HST2' - layout v2
constexpr uint8_t HISTOGRAM_STATE_VERSION = 1;
constexpr const char *HISTOGRAM_STATE_FILE = "/prefs/hopScalingState.bin";
@@ -282,7 +282,7 @@ void HopScalingModule::rollHour()
}
// 2. Walk scaled hop buckets to produce a hop-limit recommendation.
// effectiveMin: walk threshold — first hop whose cumulative count reaches this.
// effectiveMin: walk threshold - first hop whose cumulative count reaches this.
// effectiveMax: ceiling on the one-hop extension check with GENEROUS politeness.
const uint16_t effectiveMin = TARGET_AFFECTED_NODES;
const uint16_t effectiveMax = MAX_TARGET_NODES;
@@ -351,7 +351,7 @@ void HopScalingModule::rollHour()
// 5. Tick down the hold counter; once it reaches zero, halve filteringDenominator toward
// samplingDenominator once per rollHour() (= once per hour) rather than a single jump:
// avoids a sudden large change in the hop-walk count when samplingDenominator cascaded
// down significantly during the hold period. No new hold is placed on each step — the
// down significantly during the hold period. No new hold is placed on each step - the
// 13-roll hold already guaranteed that re-admitted nodes have full seenHoursAgo history;
// further pacing is provided naturally by the 1-step-per-hour rate. denominatorHistory
// is updated automatically by the shift at the top of rollHour(), so no backfill here.
@@ -405,7 +405,7 @@ void HopScalingModule::trimIfNeeded()
// down yet) are left untouched: eviction at samplingDenominator retains exactly those
// entries, so the old higher gate remains accurate for those historical hours.
// Slots below the new value must be raised because the eviction removed entries that
// had been admitted at the looser old gate — the remaining entries represent a 1/N
// had been admitted at the looser old gate - the remaining entries represent a 1/N
// subsample where N is the new filteringDenominator, not the old smaller value.
for (uint8_t h = 0; h < 13; h++)
denominatorHistory[h] = std::max(denominatorHistory[h], filteringDenominator);
+18 -18
View File
@@ -16,13 +16,13 @@
*
* Memory layout: 512 bytes total (128 entries × 4 bytes/entry, no padding)
* - 16-bit XOR-fold hash of node ID
* - 3-bit hops away (0–7)
* - 3-bit hops away (0-7)
* - 13-bit hourly seen bitmap
* All three fields are packed into a single 32-bit Record; sizeof(Record) == 4.
*
* Sampling:
* - A node is added only when passesFilter(hashNodeId(nodeId), samplingDenominator),
* i.e. (hash16(nodeId) & (samplingDenominator – 1)) == 0 (hash-space subsample, not raw ID)
* i.e. (hash16(nodeId) & (samplingDenominator - 1)) == 0 (hash-space subsample, not raw ID)
* - samplingDenominator starts at 1 (sample all), doubles when the list exceeds FILL_HIGH_PCT
* - filteringDenominator tracks samplingDenominator upward immediately but does not drop back
* down until FILTER_DENOM_HOLD_MS (13 h) have elapsed since the last scale-up
@@ -72,18 +72,18 @@ class HopScalingModule : private concurrency::OSThread
// This value is deliberately equal to the seenHoursAgo window (13 hours / 13 bits).
// Invariant: every entry that existed when a scale-up fired had seenHoursAgo != 0 at
// that moment (trimIfNeeded() evicts stale entries before doubling the denominator),
// so it remains seenInLast13h for at most 13 more rollHour() calls — exactly the
// so it remains seenInLast13h for at most 13 more rollHour() calls - exactly the
// hold duration. That means entries from the scale-up event keep counts.total above
// the scale-down threshold for the entire hold period under normal (active) mesh
// conditions. On a genuinely quieting mesh the scale-down CAN fire before the hold
// expires — each firing halves samplingDenominator but filteringDenominator stays
// expires - each firing halves samplingDenominator but filteringDenominator stays
// elevated, so the hop recommendation correctly stays conservative (MAX_HOP) while
// the cascade runs. The cascade is bounded at DENOM_MIN (7 halvings from DENOM_MAX);
// when the hold finally expires, step 5 of rollHour() halves filteringDenominator
// once per hour (rather than jumping directly to samplingDenominator) until the two
// converge, giving the hop-walk a gradual, 1-step-per-hour descent.
static constexpr uint32_t FILTER_DENOM_HOLD_MS = 13UL * 60UL * 60UL * 1000UL; // 13 h (documentation only)
// Number of rollHour() calls the hold spans — equals the seenHoursAgo window width.
// Number of rollHour() calls the hold spans - equals the seenHoursAgo window width.
// filteringDenomHoldRollsRemaining is initialised to this value on scale-up and
// decremented once per rollHour(); step-down begins when it reaches zero.
static constexpr uint8_t FILTER_DENOM_HOLD_ROLLS = 13u;
@@ -132,15 +132,15 @@ class HopScalingModule : private concurrency::OSThread
struct MeshTrendStats {
/// Estimated node count per hour slot (h=0 is the just-completed hour, h=12 is 12 h ago).
uint16_t scaledPerHour[13] = {};
/// Nodes heard only this hour with no prior bitmap history — indicates new arrivals.
/// Nodes heard only this hour with no prior bitmap history - indicates new arrivals.
uint16_t newThisHour = 0;
/// Nodes heard this hour that also appeared in at least one older hour — stable regulars.
/// Nodes heard this hour that also appeared in at least one older hour - stable regulars.
uint16_t returningThisHour = 0;
/// Nodes heard last hour but silent this hour — potential departures.
/// Nodes heard last hour but silent this hour - potential departures.
uint16_t lapsedSinceLastHour = 0;
/// Nodes absent from the last 4 hours but still present in some older hour (5–13 h) — quieting down.
/// Nodes absent from the last 4 hours but still present in some older hour (5-13 h) - quieting down.
uint16_t olderThan4h = 0;
/// Nodes whose only remaining history is the 13th hour (bit 12 only) — about to age out entirely.
/// Nodes whose only remaining history is the 13th hour (bit 12 only) - about to age out entirely.
uint16_t agingOut = 0;
};
@@ -257,11 +257,11 @@ class HopScalingModule : private concurrency::OSThread
//
// Two separate denominators control two distinct gates:
//
// samplingDenominator — admission gate. A node is added/updated only when
// samplingDenominator - admission gate. A node is added/updated only when
// passesFilter(hash, samplingDenominator). Lower value = more permissive =
// more nodes enter = represents recent mesh state.
//
// filteringDenominator — counting gate. The hop-walk tally in rollHour() only
// filteringDenominator - counting gate. The hop-walk tally in rollHour() only
// counts entries that pass passesFilter(hash, filteringDenominator). It moves
// up with samplingDenominator immediately (scale-up) but is held at the
// elevated value for FILTER_DENOM_HOLD_MS (13 h) after any scale-up before it
@@ -272,20 +272,20 @@ class HopScalingModule : private concurrency::OSThread
// that also pass F is exactly D/F. Therefore:
// raw_count × F = (total × D/F) × F = total × D
// The population estimate is the same whether we count with D or with F.
// The hold period is not about accuracy — it is about stability: it prevents the
// The hold period is not about accuracy - it is about stability: it prevents the
// hop recommendation from reacting to recently-admitted nodes that have not yet
// accumulated enough seenHoursAgo history to be statistically reliable.
//
// denominatorHistory[h] — the filteringDenominator used to both gate and scale
// denominatorHistory[h] - the filteringDenominator used to both gate and scale
// hourlyRaw[h]. Invariant: denominatorHistory[h] always equals the
// filteringDenominator that was active when seenHoursAgo bit h was set.
// rollHour() advances the array at the very start (before the tally loop), then
// gates hourlyRaw[h] per-slot by denominatorHistory[h] — each slot's raw count
// gates hourlyRaw[h] per-slot by denominatorHistory[h] - each slot's raw count
// and multiplier are therefore always consistent, even when filteringDenominator
// changes between rolls (e.g. hold expiry). On scale-up (trimIfNeeded()), the
// entire array is backfilled uniformly with the new filteringDenominator to
// preserve the invariant retroactively for all 13 slots. Initialised to
// DENOM_MIN (1); scaledPerHour slots that draw from a 1 entry are unscaled —
// DENOM_MIN (1); scaledPerHour slots that draw from a 1 entry are unscaled -
// correct for a fresh instance with no prior history.
// -----------------------------------------------------------------------
uint8_t samplingDenominator = DENOM_MIN;
@@ -321,7 +321,7 @@ class HopScalingModule : private concurrency::OSThread
// Record field semantics:
// nodeHash → XOR-fold of full 32-bit node ID to 16 bits
// hops_away → hop distance (0–7)
// hops_away → hop distance (0-7)
// seenHoursAgo → 13-bit per-hour seen bitmap
// bit 0 = seen in the current / most-recent hour
// bit 12 = seen 12 hours ago
@@ -338,7 +338,7 @@ class HopScalingModule : private concurrency::OSThread
static bool passesFilter(uint16_t nodeHash, uint8_t denom) { return (nodeHash & static_cast<uint16_t>(denom - 1u)) == 0u; }
public:
// Clock — public so tests can share the same timebase via HopScalingModule::s_testNowMs
// Clock - public so tests can share the same timebase via HopScalingModule::s_testNowMs
#ifdef PIO_UNIT_TESTING
static uint32_t nowMs() { return s_testNowMs; }
#else
+611
View File
@@ -0,0 +1,611 @@
#include "MeshBeaconModule.h"
#include "Default.h"
#include "DisplayFormatters.h"
#include "NodeDB.h"
#include "RTC.h"
#include "RadioInterface.h"
#include "Router.h"
#include "TransmitHistory.h"
#include "configuration.h"
#include "main.h"
#include <Throttle.h>
#include <string.h>
// Static members
meshtastic_Config_LoRaConfig_ModemPreset MeshBeaconModule::originalModemPreset;
uint16_t MeshBeaconModule::originalLoraChannel;
meshtastic_Config_LoRaConfig_RegionCode MeshBeaconModule::originalRegion;
meshtastic_ChannelSettings MeshBeaconModule::originalPrimaryChannel;
static MeshBeaconModule_TargetRadioSettings targetRadioSettings[8];
static bool getTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset *preset,
uint16_t *slot, bool *legacyHopOverride = nullptr,
meshtastic_Config_LoRaConfig_RegionCode *region = nullptr, bool *has_channel = nullptr,
meshtastic_ChannelSettings *channel = nullptr)
{
if (!p)
return false;
for (const auto &entry : targetRadioSettings) {
if (entry.inUse && entry.id == p->id) {
if (preset)
*preset = entry.preset;
if (slot)
*slot = entry.slot;
if (legacyHopOverride)
*legacyHopOverride = entry.legacyHopOverride;
if (region)
*region = entry.region;
if (has_channel)
*has_channel = entry.has_channel;
if (channel && entry.has_channel)
*channel = entry.channel;
return true;
}
}
return false;
}
// ---------------------------------------------------------------------------
// MeshBeaconModule base
// ---------------------------------------------------------------------------
MeshBeaconModule::MeshBeaconModule()
{
originalModemPreset = config.lora.modem_preset;
originalLoraChannel = config.lora.channel_num;
originalRegion = config.lora.region;
originalPrimaryChannel = channels.getPrimary();
}
void MeshBeaconModule::setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset,
uint16_t slot, bool legacyHopOverride,
meshtastic_Config_LoRaConfig_RegionCode region, bool has_channel,
const meshtastic_ChannelSettings *channel)
{
if (!p)
return;
MeshBeaconModule_TargetRadioSettings *target = nullptr;
for (auto &entry : targetRadioSettings) {
if (entry.inUse && entry.id == p->id) {
target = &entry;
break;
}
if (!target && !entry.inUse)
target = &entry;
}
if (!target)
target = &targetRadioSettings[0];
target->inUse = true;
target->id = p->id;
target->preset = preset;
target->slot = slot;
target->legacyHopOverride = legacyHopOverride;
target->region = region;
target->has_channel = has_channel;
if (has_channel && channel)
target->channel = *channel;
}
bool MeshBeaconModule::hasTargetRadioSettings(const meshtastic_MeshPacket *p)
{
return getTargetRadioSettings(p, nullptr, nullptr);
}
void MeshBeaconModule::clearTargetRadioSettings(const meshtastic_MeshPacket *p)
{
if (!p)
return;
for (auto &entry : targetRadioSettings) {
if (entry.inUse && entry.id == p->id) {
entry.inUse = false;
return;
}
}
}
bool MeshBeaconModule::beaconTxConfigInvalid(const meshtastic_MeshPacket *p)
{
meshtastic_Config_LoRaConfig_ModemPreset preset;
meshtastic_Config_LoRaConfig_RegionCode sidecarRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
if (!getTargetRadioSettings(p, &preset, nullptr, nullptr, &sidecarRegion))
return false; // not a beacon-switch packet - nothing to validate, normal traffic unaffected
const meshtastic_Config_LoRaConfig_RegionCode region =
(sidecarRegion != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? sidecarRegion : config.lora.region;
// An unlicensed node must never key up on a ham-only (licensed-only) region. The reverse is
// allowed: a licensed (ham) node may operate in a non-ham region - and the switch only touches
// preset/region/channel, never owner.is_licensed, so it cannot deactivate licensed mode.
const RegionInfo *r = getRegion(region);
if (r && r->profile->licensedOnly && !owner.is_licensed)
return true;
// Preset must be valid for the target region.
meshtastic_Config_LoRaConfig probe = config.lora;
probe.use_preset = true;
probe.modem_preset = preset;
probe.region = region;
return !RadioInterface::validateConfigLora(probe);
}
meshtastic_ChannelSettings MeshBeaconModule::beaconChannelSettings(const meshtastic_ChannelSettings &base,
meshtastic_Config_LoRaConfig_ModemPreset preset,
const meshtastic_ChannelSettings *overrideChannel)
{
meshtastic_ChannelSettings ch = base;
if (overrideChannel) {
ch.channel_num = overrideChannel->channel_num;
if (overrideChannel->name[0] != '\0')
strncpy(ch.name, overrideChannel->name, sizeof(ch.name) - 1);
if (overrideChannel->psk.size > 0)
ch.psk = overrideChannel->psk;
}
// If no usable name survived (no override, or a blank-named one), default to the preset's
// display name so the beacon channel is identifiable rather than borrowing the primary's name.
if (ch.name[0] == '\0')
strncpy(ch.name, DisplayFormatters::getModemPresetDisplayName(preset, false, true), sizeof(ch.name) - 1);
ch.name[sizeof(ch.name) - 1] = '\0';
return ch;
}
bool MeshBeaconModule::reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p)
{
// True while a beacon radio switch is in effect and still needs undoing. We track the switch
// explicitly rather than inferring it from "live config differs from the snapshot", because that
// heuristic both missed cases (a channel name/PSK swap that left preset/slot/region unchanged would
// never be restored) and fired falsely (a legitimate non-beacon channel edit would be reverted on
// the next TX). With the flag the restore fires for ANY field we changed and only when we changed
// it - including on TX-failure paths, which route through this same restore call.
static bool radioSwitched = false;
meshtastic_ChannelSettings *primaryCh = &channels.getByIndex(channels.getPrimaryIndex()).settings;
meshtastic_Config_LoRaConfig_ModemPreset targetPreset;
uint16_t targetSlot;
const auto channelDiffers = [&](const meshtastic_ChannelSettings &target) {
return strncmp(primaryCh->name, target.name, sizeof(primaryCh->name)) != 0 || primaryCh->psk.size != target.psk.size ||
memcmp(primaryCh->psk.bytes, target.psk.bytes, primaryCh->psk.size) != 0 ||
primaryCh->channel_num != target.channel_num;
};
bool legacyHopOverride = false;
meshtastic_Config_LoRaConfig_RegionCode sidecarRegion = meshtastic_Config_LoRaConfig_RegionCode_UNSET;
bool sidecarHasChannel = false;
meshtastic_ChannelSettings sidecarChannel = {};
if (p && getTargetRadioSettings(p, &targetPreset, &targetSlot, &legacyHopOverride, &sidecarRegion, &sidecarHasChannel,
&sidecarChannel)) {
// Legacy compatibility: older firmware (pre-v2.7.20) drops hop_start==0 packets via the
// pre-hop check before decryption, so they can't see has_bitfield to validate them.
// Setting hop_start=1 (with hop_limit remaining 0) makes the packet pass the old check
// while still being zero-hop (hop_limit=0 prevents any rebroadcast).
if (legacyHopOverride)
p->hop_start = 1;
const meshtastic_Config_LoRaConfig_RegionCode targetRegion =
(sidecarRegion != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? sidecarRegion : config.lora.region;
const meshtastic_ChannelSettings *overrideCh = sidecarHasChannel ? &sidecarChannel : nullptr;
meshtastic_ChannelSettings targetChannel = beaconChannelSettings(*primaryCh, targetPreset, overrideCh);
if (targetPreset == config.lora.modem_preset && targetSlot == config.lora.channel_num &&
targetRegion == config.lora.region && !channelDiffers(targetChannel))
return false;
// Guard: never key up on an invalid target config - bad preset for the region, or an
// unlicensed node keying up on a ham-only region. Refuse the switch here so we never
// transmit on it; the radio driver drops the packet outright (see RadioLibInterface,
// beaconTxConfigInvalid) rather than letting it fall through onto the current config.
if (beaconTxConfigInvalid(p)) {
LOG_DEBUG("Beacon: target preset %d/region %d invalid (or ham mismatch), not switching", targetPreset, targetRegion);
return false;
}
// Snapshot current (non-beacon) settings so we restore to the latest config. Skip while a
// switch is already active, so a second switch before the restore can't capture the beacon
// config as the "home" we later restore to.
if (!radioSwitched) {
originalModemPreset = config.lora.modem_preset;
originalLoraChannel = config.lora.channel_num;
originalRegion = config.lora.region;
originalPrimaryChannel = *primaryCh;
}
LOG_INFO("Beacon: switch radio for packet 0x%08x to preset=%d slot=%u region=%d", p->id, targetPreset, targetSlot,
targetRegion);
config.lora.modem_preset = targetPreset;
config.lora.channel_num = targetSlot;
if (targetRegion != config.lora.region)
config.lora.region = targetRegion;
*primaryCh = targetChannel;
channels.fixupChannel(channels.getPrimaryIndex());
p->channel = channels.getHash(channels.getPrimaryIndex());
iface->reconfigure();
radioSwitched = true;
return true;
} else if ((!p || !getTargetRadioSettings(p, nullptr, nullptr)) && radioSwitched) {
LOG_INFO("Beacon: restoring radio config after beacon TX");
config.lora.modem_preset = originalModemPreset;
config.lora.channel_num = originalLoraChannel;
config.lora.region = originalRegion;
*primaryCh = originalPrimaryChannel;
primaryCh->name[sizeof(primaryCh->name) - 1] = '\0';
channels.fixupChannel(channels.getPrimaryIndex());
iface->reconfigure();
radioSwitched = false;
return true;
}
return false;
}
// ---------------------------------------------------------------------------
// MeshBeaconBroadcastModule
// ---------------------------------------------------------------------------
MeshBeaconBroadcastModule *meshBeaconBroadcastModule;
MeshBeaconBroadcastModule::MeshBeaconBroadcastModule()
: MeshBeaconModule(), ProtobufModule("beacon_tx", meshtastic_PortNum_MESH_BEACON_APP, &meshtastic_MeshBeacon_msg),
concurrency::OSThread("MeshBeaconBroadcast")
{
setIntervalFromNow(setStartDelay());
}
void MeshBeaconBroadcastModule::rebuildCache()
{
const auto &bcfg = moduleConfig.mesh_beacon;
meshtastic_MeshBeacon beacon = meshtastic_MeshBeacon_init_zero;
strncpy(beacon.message, bcfg.broadcast_message, sizeof(beacon.message) - 1);
if (bcfg.has_broadcast_offer_channel) {
beacon.has_offer_channel = true;
beacon.offer_channel = bcfg.broadcast_offer_channel;
// PSK is included intentionally: this beacon is a public join-invitation.
// The offered channel is not secret - the PSK here is a convenience token,
// not a security boundary. Operators who want a private channel must
// distribute the PSK out-of-band and leave offer_channel unset.
}
beacon.has_offer_preset = bcfg.has_broadcast_offer_preset;
beacon.offer_preset = bcfg.broadcast_offer_preset;
beacon.offer_region = bcfg.broadcast_offer_region;
// Note: an empty config legitimately encodes to 0 bytes, and pb_encode_to_bytes can't distinguish
// that from a (here effectively impossible - buffer is max-sized) failure, so we always clear the
// dirty flag. The combined send is gated on payloadCacheSize > 0, so an empty payload is never TX'd.
payloadCacheSize = (pb_size_t)pb_encode_to_bytes(payloadCache, sizeof(payloadCache), &meshtastic_MeshBeacon_msg, &beacon);
payloadCacheDirty = false;
LOG_DEBUG("Beacon: payload cache rebuilt (%u bytes)", payloadCacheSize);
}
void MeshBeaconBroadcastModule::sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset,
bool has_channel, const meshtastic_ChannelSettings *overrideChannel)
{
const bool cryptoOverride =
has_channel && overrideChannel && (overrideChannel->name[0] != '\0' || overrideChannel->psk.size > 0);
if (!cryptoOverride) {
router->send(p);
return;
}
// perhapsEncode() keys encryption (and the channel-hash hint) off the PRIMARY channel slot, and
// the radio-thread channel switch only happens AFTER encryption - so a beacon on an override
// channel would otherwise be encrypted with the PRIMARY PSK, not the beacon channel's. Install the
// beacon channel into the primary slot for the synchronous duration of send(), then restore.
// Meshtastic threading is cooperative (no preemption between the swap and restore).
meshtastic_Channel &primary = channels.getByIndex(channels.getPrimaryIndex());
const meshtastic_ChannelSettings saved = primary.settings;
primary.settings = beaconChannelSettings(saved, targetPreset, overrideChannel);
channels.fixupChannel(channels.getPrimaryIndex());
router->send(p); // encrypts with the beacon channel's key and stamps its hash
primary.settings = saved;
channels.fixupChannel(channels.getPrimaryIndex());
}
void MeshBeaconBroadcastModule::sendBeacon()
{
const auto &bcfg = moduleConfig.mesh_beacon;
const bool hasText = bcfg.broadcast_message[0] != '\0';
const bool hasRadioContent = bcfg.has_broadcast_offer_preset || bcfg.has_broadcast_offer_channel ||
(bcfg.broadcast_offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET);
if (!hasText && !hasRadioContent) {
LOG_DEBUG("Beacon: nothing to send (empty message, no offer), skipping");
return;
}
// Stamp common fields shared by every outgoing beacon packet.
const auto stampPacket = [&](meshtastic_MeshPacket *p) {
p->to = NODENUM_BROADCAST;
p->from = nodeDB->getNodeNum();
// broadcast_send_as_node: commented out pending further review.
// Spoof notes preserved for when this is re-enabled:
// broadcast_send_as_node overrides the source NodeNum. NOTE: this is a *node-ID* spoof
// only - it rewrites the 'from' field but does NOT forge any signature. Once 'from' is
// not us, the packet is no longer isFromUs(), so Router::perhapsEncode() skips XEdDSA
// signing and receivers get an unsigned packet attributed to another node.
// When broadcast_send_as_node == 0 the beacon is genuinely from us and Router::perhapsEncode()
// signs it under the same XEdDSA broadcast policy as normal channel messages.
// When broadcast_send_as_node rewrites p->from, perhapsEncode() sees isFromUs()=false and
// skips setting has_bitfield - must be set explicitly so receivers can classify hop_start
// correctly and so ok_to_mqtt is honoured on the spoofed packet.
// if (bcfg.broadcast_send_as_node != 0) {
// p->from = bcfg.broadcast_send_as_node;
// p->decoded.has_bitfield = true;
// p->decoded.bitfield |= (config.lora.config_ok_to_mqtt << BITFIELD_OK_TO_MQTT_SHIFT);
// }
p->hop_limit = 0; // all beacon packets are zero hopped to limit spamming.
p->priority = meshtastic_MeshPacket_Priority_BACKGROUND;
p->want_ack = false;
p->rx_time = getValidTime(RTCQualityFromNet);
};
// ── Packet type decisions ────────────────────────────────────────────────
//
// FLAG_LEGACY_SPLIT: when both text and offer are present, send TWO packets - A)
// MESH_BEACON_APP (offer only) and B) TEXT_MESSAGE_APP (text only) - both on the SAME beacon
// radio settings, so nodes that only decode TEXT_MESSAGE_APP still receive the text. Otherwise a
// single packet is sent (offer-only, text-only, or the combined offer+text path).
//
// These are independent decisions, NOT a mutually-exclusive if/else chain: the split
// case must emit both A and B. Conditions are spelled out as named booleans to avoid
// the && / || precedence trap (and a prior bug where the split case dropped the text).
const bool legacySplit = bcfg.flags & MESH_BEACON_FLAG_LEGACY_SPLIT;
const bool splitBoth = legacySplit && hasRadioContent && hasText;
const bool sendOfferOnly = splitBoth || (hasRadioContent && !hasText);
const bool sendTextOnly = splitBoth || (!hasRadioContent && hasText);
const bool sendCombined = !legacySplit && hasRadioContent && hasText;
// Build offer payload once - shared across all targets.
uint8_t offerBuf[meshtastic_MeshBeacon_size] = {};
pb_size_t offerSize = 0;
if (sendOfferOnly) {
meshtastic_MeshBeacon offerOnly = meshtastic_MeshBeacon_init_zero;
if (bcfg.has_broadcast_offer_channel) {
offerOnly.has_offer_channel = true;
offerOnly.offer_channel = bcfg.broadcast_offer_channel;
}
offerOnly.has_offer_preset = bcfg.has_broadcast_offer_preset;
offerOnly.offer_preset = bcfg.broadcast_offer_preset;
offerOnly.offer_region = bcfg.broadcast_offer_region;
offerSize = (pb_size_t)pb_encode_to_bytes(offerBuf, sizeof(offerBuf), &meshtastic_MeshBeacon_msg, &offerOnly);
if (offerSize == 0)
LOG_WARN("Beacon: offer encode failed, skipping offer packet(s)");
}
if (sendCombined && payloadCacheDirty)
rebuildCache();
// ── Per-target loop ──────────────────────────────────────────────────────
//
// If broadcast_targets is populated, iterate over those. Otherwise use the single-target
// broadcast_on_preset / broadcast_on_region / broadcast_on_channel fields. The two paths are
// equal options; they differ only in how the TX channel is named (single-target embeds a
// ChannelSettings inline; a target references a channel-table slot by channel_index).
struct EffTarget {
meshtastic_Config_LoRaConfig_ModemPreset preset;
uint16_t slot;
meshtastic_Config_LoRaConfig_RegionCode region;
bool has_channel;
meshtastic_ChannelSettings channel;
};
const bool useTargetList = bcfg.broadcast_targets_count > 0;
const int targetCount = useTargetList ? (int)bcfg.broadcast_targets_count : 1;
// Dedup state: the beacon payload is identical across targets, so two targets that resolve to
// the same effective radio config (preset + resolved region + channel) would just re-broadcast
// the same packet - wasted airtime and a redundant radio switch each. We skip the later one.
// Keyed on the *resolved* values so an explicit "current region" dedups against an UNSET one.
EffTarget sent[4];
meshtastic_Config_LoRaConfig_RegionCode sentRegion[4];
int sentCount = 0;
const auto sameEffectiveTarget = [](const EffTarget &a, meshtastic_Config_LoRaConfig_RegionCode ar, const EffTarget &b,
meshtastic_Config_LoRaConfig_RegionCode br) {
if (a.preset != b.preset || ar != br || a.has_channel != b.has_channel)
return false;
if (!a.has_channel)
return true; // both fall back to the default channel for the (same) preset
return a.slot == b.slot && strncmp(a.channel.name, b.channel.name, sizeof(a.channel.name)) == 0 &&
a.channel.psk.size == b.channel.psk.size &&
memcmp(a.channel.psk.bytes, b.channel.psk.bytes, a.channel.psk.size) == 0;
};
for (int ti = 0; ti < targetCount; ti++) {
EffTarget tgt = {};
if (useTargetList) {
const auto &bt = bcfg.broadcast_targets[ti];
tgt.preset = bt.has_preset ? bt.preset : config.lora.modem_preset;
tgt.region = bt.region;
// Resolve the channel from the device's channel table by index. A slot is only usable
// if it is actually configured (has a name or PSK - its key is needed to encrypt). An
// out-of-range index, or a blank slot, falls back to the default channel for the target
// preset (see beaconChannelSettings), exactly as an unset channel_index would.
tgt.has_channel = false;
tgt.slot = config.lora.channel_num;
if (bt.has_channel_index) {
if (bt.channel_index >= (uint32_t)channels.getNumChannels()) {
LOG_WARN("Beacon: target %d channel_index %u out of range, using default channel for preset", ti,
bt.channel_index);
} else {
const meshtastic_ChannelSettings &cs = channels.getByIndex(bt.channel_index).settings;
if (cs.name[0] != '\0' || cs.psk.size > 0) {
tgt.has_channel = true;
tgt.channel = cs;
tgt.slot = cs.channel_num;
} else {
LOG_DEBUG("Beacon: target %d channel_index %u is a blank slot, using default channel for preset", ti,
bt.channel_index);
}
}
}
} else {
tgt.preset = bcfg.has_broadcast_on_preset ? bcfg.broadcast_on_preset : config.lora.modem_preset;
tgt.region = bcfg.broadcast_on_region;
tgt.has_channel = bcfg.has_broadcast_on_channel;
if (tgt.has_channel)
tgt.channel = bcfg.broadcast_on_channel;
tgt.slot = tgt.has_channel ? bcfg.broadcast_on_channel.channel_num : config.lora.channel_num;
}
// Skip a target whose effective radio config duplicates one already sent this cycle.
const meshtastic_Config_LoRaConfig_RegionCode resolvedRegion =
(tgt.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET) ? tgt.region : config.lora.region;
bool duplicate = false;
for (int si = 0; si < sentCount; si++) {
if (sameEffectiveTarget(tgt, resolvedRegion, sent[si], sentRegion[si])) {
duplicate = true;
break;
}
}
if (duplicate) {
LOG_DEBUG("Beacon: target %d duplicates an earlier target's radio config, skipping", ti);
continue;
}
sent[sentCount] = tgt;
sentRegion[sentCount] = resolvedRegion;
sentCount++;
const bool channelOverrideConfigured = tgt.has_channel && (tgt.channel.name[0] != '\0' || tgt.channel.psk.size > 0 ||
tgt.channel.channel_num != config.lora.channel_num);
const bool presetDiffers =
(tgt.preset != config.lora.modem_preset) ||
(tgt.region != meshtastic_Config_LoRaConfig_RegionCode_UNSET && tgt.region != config.lora.region) ||
channelOverrideConfigured;
const meshtastic_ChannelSettings *chPtr = tgt.has_channel ? &tgt.channel : nullptr;
const auto applyTarget = [&](meshtastic_MeshPacket *p) {
if (presetDiffers || legacySplit)
setTargetRadioSettings(p, tgt.preset, tgt.slot, legacySplit, tgt.region, tgt.has_channel, chPtr);
sendBeaconPacket(p, tgt.preset, tgt.has_channel, chPtr);
};
if (sendOfferOnly && offerSize > 0) {
meshtastic_MeshPacket *pA = allocDataPacket();
if (!pA) {
LOG_WARN("Beacon: failed to allocate split-A packet (target %d)", ti);
return;
}
memcpy(pA->decoded.payload.bytes, offerBuf, offerSize);
pA->decoded.payload.size = offerSize;
pA->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP;
stampPacket(pA);
LOG_INFO("Beacon: split-A MESH_BEACON_APP (offer only) from=0x%08x target=%d", pA->from, ti);
applyTarget(pA);
}
if (sendTextOnly) {
meshtastic_MeshPacket *pB = allocDataPacket();
if (!pB) {
LOG_WARN("Beacon: failed to allocate split-B packet (target %d)", ti);
return;
}
pb_size_t msgLen = (pb_size_t)strnlen(bcfg.broadcast_message, sizeof(bcfg.broadcast_message) - 1);
memcpy(pB->decoded.payload.bytes, bcfg.broadcast_message, msgLen);
pB->decoded.payload.size = msgLen;
pB->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
stampPacket(pB);
LOG_INFO("Beacon: split-B TEXT_MESSAGE_APP msg='%.40s' from=0x%08x target=%d", bcfg.broadcast_message, pB->from, ti);
applyTarget(pB);
}
if (sendCombined && payloadCacheSize > 0) {
meshtastic_MeshPacket *p = allocDataPacket();
if (!p) {
LOG_WARN("Beacon: failed to allocate beacon packet (target %d)", ti);
return;
}
memcpy(p->decoded.payload.bytes, payloadCache, payloadCacheSize);
p->decoded.payload.size = payloadCacheSize;
p->decoded.portnum = meshtastic_PortNum_MESH_BEACON_APP;
stampPacket(p);
LOG_INFO("Beacon: MESH_BEACON_APP offer+msg from=0x%08x msg='%.40s' target=%d", p->from, bcfg.broadcast_message, ti);
applyTarget(p);
}
}
}
int32_t MeshBeaconBroadcastModule::runOnce()
{
const auto &bcfg = moduleConfig.mesh_beacon;
const uint32_t intervalSecs =
Default::getConfiguredOrDefault(bcfg.broadcast_interval_secs, default_mesh_beacon_min_broadcast_interval_secs);
const uint32_t intervalMs =
Default::getConfiguredOrMinimumValue(intervalSecs, default_mesh_beacon_min_broadcast_interval_secs) * 1000;
if ((bcfg.flags & MESH_BEACON_FLAG_BROADCAST_ENABLED) && airTime->isTxAllowedAirUtil() &&
config.device.role != meshtastic_Config_DeviceConfig_Role_CLIENT_HIDDEN) {
// Throttle against the reboot-safe transmit history (mirrors NodeInfoModule): skip if we
// broadcast within the interval, even across a reboot. 0 = never sent → send now.
uint32_t lastSent = transmitHistory ? transmitHistory->getLastSentToMeshMillis(meshtastic_PortNum_MESH_BEACON_APP) : 0;
if (lastSent == 0 || !Throttle::isWithinTimespanMs(lastSent, intervalMs)) {
// Record the send BEFORE transmitting: the LoRa TX is a high-current event that can
// brown out a marginal supply, and if that reboots us mid-transmit we still want the
// "sent" marker persisted so we don't re-broadcast immediately on every boot.
if (transmitHistory)
transmitHistory->setLastSentToMesh(meshtastic_PortNum_MESH_BEACON_APP);
sendBeacon();
}
}
return static_cast<int32_t>(intervalMs);
}
// ---------------------------------------------------------------------------
// MeshBeaconListenerModule
// ---------------------------------------------------------------------------
MeshBeaconListenerModule *meshBeaconListenerModule;
MeshBeaconListenerModule::BeaconOffer MeshBeaconListenerModule::lastReceivedOffer;
MeshBeaconListenerModule::MeshBeaconListenerModule()
: ProtobufModule("beacon_listen", meshtastic_PortNum_MESH_BEACON_APP, &meshtastic_MeshBeacon_msg)
{
lastReceivedOffer = {};
}
bool MeshBeaconListenerModule::wantPacket(const meshtastic_MeshPacket *p)
{
return moduleConfig.has_mesh_beacon && (moduleConfig.mesh_beacon.flags & MESH_BEACON_FLAG_LISTEN_ENABLED) &&
p->decoded.portnum == meshtastic_PortNum_MESH_BEACON_APP;
}
bool MeshBeaconListenerModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b)
{
const bool hasOfferContent =
b && (b->has_offer_channel || b->offer_region != meshtastic_Config_LoRaConfig_RegionCode_UNSET || b->has_offer_preset);
const pb_size_t msgLen = b ? (pb_size_t)strnlen(b->message, sizeof(b->message) - 1) : 0;
const bool hasText = msgLen > 0;
if (!b || (!hasText && !hasOfferContent))
return false;
// NOTE: we deliberately do NOT unwrap the text into a synthesized TEXT_MESSAGE_APP for the
// phone. The original MESH_BEACON_APP packet already flows to the client (we return CONTINUE),
// so a beacon-aware client renders `message` directly - injecting a copy would only duplicate
// it. Broadcasters that need non-beacon-aware clients to see the text use FLAG_LEGACY_SPLIT,
// which sends a real TEXT_MESSAGE_APP over RF. We also do not fire EVENT_RECEIVED_MSG: a beacon
// is an advisory broadcast, not a personal message, and must not wake the device from sleep.
if (hasText)
LOG_INFO("Beacon: received from 0x%08x: '%.40s'", mp.from, b->message);
// Cache any offer for the client app - never auto-applied.
if (hasOfferContent) {
lastReceivedOffer.valid = true;
lastReceivedOffer.sender = mp.from;
lastReceivedOffer.has_channel = b->has_offer_channel;
if (b->has_offer_channel)
lastReceivedOffer.channel = b->offer_channel;
lastReceivedOffer.region = b->offer_region;
lastReceivedOffer.preset = b->offer_preset;
lastReceivedOffer.received_at =
getValidTime(RTCQualityFromNet); // 0 if no RTC fix yet - consumers must not treat 0 as valid
LOG_INFO("Beacon: stored offer from 0x%08x (preset=%d)", mp.from, b->offer_preset);
}
notifyObservers(&mp);
return false;
}
+163
View File
@@ -0,0 +1,163 @@
#pragma once
#include "MeshRadio.h"
#include "Observer.h"
#include "ProtobufModule.h"
#include "RadioInterface.h"
#include "concurrency/OSThread.h"
#include "mesh/generated/meshtastic/mesh_beacon.pb.h"
#include "mesh/generated/meshtastic/module_config.pb.h"
// Short aliases for the MeshBeaconConfig.flags bitfield (see module_config.proto MeshBeaconConfig.Flags).
#define MESH_BEACON_FLAG_LISTEN_ENABLED meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LISTEN_ENABLED
#define MESH_BEACON_FLAG_BROADCAST_ENABLED meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_BROADCAST_ENABLED
#define MESH_BEACON_FLAG_LEGACY_SPLIT meshtastic_ModuleConfig_MeshBeaconConfig_Flags_FLAG_LEGACY_SPLIT
// Sidecar entry pairing a packet ID with target radio settings for beacon TX.
typedef struct {
bool inUse;
PacketId id;
meshtastic_Config_LoRaConfig_ModemPreset preset;
uint16_t slot;
// When true, reconfigureForBeaconTX sets hop_start=1 so pre-2.7.20 firmware
// (which drops hop_start==0 packets) accepts the zero-hop beacon.
bool legacyHopOverride;
// Per-target radio settings. UNSET region means use current lora.region.
meshtastic_Config_LoRaConfig_RegionCode region;
bool has_channel;
meshtastic_ChannelSettings channel;
} MeshBeaconModule_TargetRadioSettings;
/**
* Base class: holds the radio-switching sidecar table and static helpers.
* The sidecar avoids touching MeshPacket proto fields for per-packet radio state.
*/
class MeshBeaconModule
{
public:
MeshBeaconModule();
/**
* Reconfigure the radio for beacon TX, or restore to original config if p is NULL.
* Returns true if the radio was reconfigured (caller must re-run transmit delay for CCA).
* Driven by broadcast_on_preset / broadcast_on_channel from MeshBeaconConfig.
*/
static bool reconfigureForBeaconTX(RadioInterface *iface, meshtastic_MeshPacket *p);
/**
* Associate target radio settings with an outgoing packet by its ID.
* Sidecar holds 8 entries; evicts slot 0 on overflow.
*/
static void
setTargetRadioSettings(const meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset preset, uint16_t slot,
bool legacyHopOverride = false,
meshtastic_Config_LoRaConfig_RegionCode region = meshtastic_Config_LoRaConfig_RegionCode_UNSET,
bool has_channel = false, const meshtastic_ChannelSettings *channel = nullptr);
/**
* Returns true if the sidecar table contains an entry for this packet's ID.
* Used by RadioLibInterface to gate the channel-active check.
*/
static bool hasTargetRadioSettings(const meshtastic_MeshPacket *p);
/**
* Remove the sidecar entry for this packet after it has been sent.
* Called from RadioLibInterface::completeSending().
*/
static void clearTargetRadioSettings(const meshtastic_MeshPacket *p);
/**
* True if p is tagged for a beacon radio switch whose target config must NOT be transmitted:
* preset invalid for the target region, or an unlicensed node would key up on a ham-only
* (licensed-only) region. The radio driver drops such packets rather than sending them on the
* current config. False for any packet without a sidecar entry (normal traffic is never affected).
*/
static bool beaconTxConfigInvalid(const meshtastic_MeshPacket *p);
protected:
/**
* Build the ChannelSettings the beacon transmits on: the base (primary) channel overlaid with
* any broadcast_on_channel overrides, defaulting an empty name to the target preset's display
* name. Shared by the encrypt-time channel swap and the radio-thread RF swap so the channel
* key + hash are identical at both points.
*/
static meshtastic_ChannelSettings beaconChannelSettings(const meshtastic_ChannelSettings &base,
meshtastic_Config_LoRaConfig_ModemPreset preset,
const meshtastic_ChannelSettings *overrideChannel = nullptr);
static meshtastic_Config_LoRaConfig_ModemPreset originalModemPreset;
static uint16_t originalLoraChannel;
static meshtastic_Config_LoRaConfig_RegionCode originalRegion;
static meshtastic_ChannelSettings originalPrimaryChannel;
};
/**
* Broadcaster: periodically sends MeshBeacon packets on the configured preset/channel.
* Active only when the FLAG_BROADCAST_ENABLED bit is set in moduleConfig.mesh_beacon.flags.
* Inherits ProtobufModule to access allocDataProtobuf + setStartDelay.
*
* Packet flow:
* Normal (combined): one MESH_BEACON_APP carrying offer + message on the beacon radio config.
* Legacy split: two packets when both text and offer are present and FLAG_LEGACY_SPLIT is set,
* both sent on the same beacon radio settings:
* A) MESH_BEACON_APP with offer only (no text).
* B) TEXT_MESSAGE_APP with the text (for clients that only decode TEXT_MESSAGE_APP).
*/
class MeshBeaconBroadcastModule : private MeshBeaconModule,
public ProtobufModule<meshtastic_MeshBeacon>,
private concurrency::OSThread
{
public:
MeshBeaconBroadcastModule();
// Mark the cached payload dirty (call after config change).
void invalidateCache() { payloadCacheDirty = true; }
protected:
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &, meshtastic_MeshBeacon *) override { return false; }
virtual int32_t runOnce() override;
protected:
void sendBeacon();
void rebuildCache();
// Send one beacon packet. When overrideChannel is set and has a name/PSK override,
// the packet is encrypted with that channel's key (not the primary's).
void sendBeaconPacket(meshtastic_MeshPacket *p, meshtastic_Config_LoRaConfig_ModemPreset targetPreset,
bool has_channel = false, const meshtastic_ChannelSettings *overrideChannel = nullptr);
bool payloadCacheDirty = true;
uint8_t payloadCache[meshtastic_MeshBeacon_size] = {};
pb_size_t payloadCacheSize = 0;
};
extern MeshBeaconBroadcastModule *meshBeaconBroadcastModule;
/**
* Listener: receives MESH_BEACON_APP packets and caches any offered channel/preset for the client
* app to retrieve. It does NOT unwrap the text into a separate message - the original beacon packet
* already reaches the client (handler returns CONTINUE), which reads `message` from it directly.
* Does NOT auto-apply offered settings - client app must do so explicitly.
* Active only when the FLAG_LISTEN_ENABLED bit is set in moduleConfig.mesh_beacon.flags.
*/
class MeshBeaconListenerModule : public ProtobufModule<meshtastic_MeshBeacon>, public Observable<const meshtastic_MeshPacket *>
{
public:
MeshBeaconListenerModule();
struct BeaconOffer {
bool valid;
NodeNum sender;
bool has_channel;
meshtastic_ChannelSettings channel;
meshtastic_Config_LoRaConfig_RegionCode region;
meshtastic_Config_LoRaConfig_ModemPreset preset;
uint32_t received_at;
};
// Last received offer - accessible to admin/API for client app retrieval.
static BeaconOffer lastReceivedOffer;
protected:
virtual bool handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshtastic_MeshBeacon *b) override;
virtual bool wantPacket(const meshtastic_MeshPacket *p) override;
};
extern MeshBeaconListenerModule *meshBeaconListenerModule;
+8 -2
View File
@@ -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
@@ -128,8 +131,7 @@ void setupModules()
#endif
#if HAS_TRAFFIC_MANAGEMENT && !MESHTASTIC_EXCLUDE_TRAFFIC_MANAGEMENT
// Instantiate only when enabled to avoid extra memory use and background work.
if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled) {
if (moduleConfig.has_traffic_management) {
trafficManagementModule = new TrafficManagementModule();
}
#endif
@@ -144,6 +146,10 @@ void setupModules()
#if !MESHTASTIC_EXCLUDE_NODEINFO
nodeInfoModule = new NodeInfoModule();
#endif
#if !MESHTASTIC_EXCLUDE_BEACON
meshBeaconBroadcastModule = new MeshBeaconBroadcastModule();
meshBeaconListenerModule = new MeshBeaconListenerModule();
#endif
#if !MESHTASTIC_EXCLUDE_GPS
positionModule = new PositionModule();
#endif
+5 -5
View File
@@ -14,11 +14,11 @@ NOTE: For debugging only
*/
void NeighborInfoModule::printNeighborInfo(const char *header, const meshtastic_NeighborInfo *np)
{
LOG_DEBUG("%s NEIGHBORINFO PACKET from Node 0x%x to Node 0x%x (last sent by 0x%x)", header, np->node_id, nodeDB->getNodeNum(),
np->last_sent_by_id);
LOG_DEBUG("%s NEIGHBORINFO PACKET from Node 0x%08x to Node 0x%08x (last sent by 0x%08x)", header, np->node_id,
nodeDB->getNodeNum(), np->last_sent_by_id);
LOG_DEBUG("Packet contains %d neighbors", np->neighbors_count);
for (int i = 0; i < np->neighbors_count; i++) {
LOG_DEBUG("Neighbor %d: node_id=0x%x, snr=%.2f", i, np->neighbors[i].node_id, np->neighbors[i].snr);
LOG_DEBUG("Neighbor %d: node_id=0x%08x, snr=%.2f", i, np->neighbors[i].node_id, np->neighbors[i].snr);
}
}
@@ -30,7 +30,7 @@ void NeighborInfoModule::printNodeDBNeighbors()
{
LOG_DEBUG("Our NodeDB contains %d neighbors", neighbors.size());
for (size_t i = 0; i < neighbors.size(); i++) {
LOG_DEBUG("Node %d: node_id=0x%x, snr=%.2f", i, neighbors[i].node_id, neighbors[i].snr);
LOG_DEBUG("Node %d: node_id=0x%08x, snr=%.2f", i, neighbors[i].node_id, neighbors[i].snr);
}
}
@@ -93,7 +93,7 @@ void NeighborInfoModule::cleanUpNeighbors()
// broadcast interval cannot use isWithinTimespanMs() as it->last_rx_time is
// seconds since 1970
if ((now - it->last_rx_time > it->node_broadcast_interval_secs * 2) && (it->node_id != my_node_id)) {
LOG_DEBUG("Remove neighbor with node ID 0x%x", it->node_id);
LOG_DEBUG("Remove neighbor with node ID 0x%08x", it->node_id);
it = std::vector<meshtastic_Neighbor>::reverse_iterator(
neighbors.erase(std::next(it).base())); // Erase the element and update the iterator
} else {
+4 -2
View File
@@ -51,8 +51,10 @@ bool NodeInfoModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, mes
}
NodeNum sourceNum = getFrom(&mp);
const meshtastic_NodeInfoLite *node = nodeDB->getMeshNode(sourceNum);
if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed) {
LOG_WARN("Dropping unsigned NodeInfo from node 0x%08x that previously signed", sourceNum);
// Broadcasts only: senders never sign unicast NodeInfo, so dropping it would break exchanges
// with signer nodes. Backstops ingress that skips Router's downgrade drop (e.g. decoded MQTT).
if (node && nodeInfoLiteHasXeddsaSigned(node) && !mp.xeddsa_signed && isBroadcast(mp.to)) {
LOG_WARN("Dropping unsigned NodeInfo broadcast from node 0x%08x that previously signed", sourceNum);
return true;
}
+56 -4
View File
@@ -339,7 +339,7 @@ meshtastic_MeshPacket *PositionModule::allocAtakPli()
strncpy(takPacket.device_callsign, owner.long_name, sizeof(takPacket.device_callsign) - 1);
takPacket.device_callsign[sizeof(takPacket.device_callsign) - 1] = '\0';
// CoT uid — ATAK drops PLI entities with empty uid; derive stable "!<nodenum>" id.
// CoT uid - ATAK drops PLI entities with empty uid; derive stable "!<nodenum>" id.
snprintf(takPacket.uid, sizeof(takPacket.uid), "!%08x", nodeDB->getNodeNum());
// Encode TAKPacketV2 protobuf, leaving room for flags byte prefix
@@ -432,6 +432,42 @@ void PositionModule::sendOurPosition(NodeNum dest, bool wantReplies, uint8_t cha
#define RUNONCE_INTERVAL 5000;
bool PositionModule::positionUnchangedSinceLastSend(const meshtastic_PositionLite &selfPos, bool useConfiguredPrecision)
{
if (lastGpsLatitude == 0 && lastGpsLongitude == 0)
return false; // no prior broadcast to compare against
// Broadcast channel = the one sendOurPosition() would pick (first with non-zero on-wire
// precision). Default nodes gauge movement at that on-wire (public-clamped) resolution;
// trackers use their own configured (unclamped) precision so finer moves still count.
uint32_t precisionBits = 0;
for (uint8_t ch = 0; ch < 8; ch++) {
if (getPositionPrecisionForChannel(ch) == 0)
continue;
precisionBits =
useConfiguredPrecision ? getPositionPrecisionForChannel(channels.getByIndex(ch)) : getPositionPrecisionForChannel(ch);
break;
}
return positionWithinPrecisionCell(selfPos.latitude_i, selfPos.longitude_i, lastGpsLatitude, lastGpsLongitude, precisionBits);
}
bool PositionModule::positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int32_t bLat, int32_t bLon, uint32_t precision)
{
if (precision == 0 || precision >= 32)
return false; // sharing disabled or full precision: no coarse cell to hold within
return truncateCoordinate(aLat, precision) == truncateCoordinate(bLat, precision) &&
truncateCoordinate(aLon, precision) == truncateCoordinate(bLon, precision);
}
uint32_t PositionModule::effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs)
{
if (stationary && stationaryFloorMs > configuredIntervalMs)
return stationaryFloorMs;
return configuredIntervalMs;
}
int32_t PositionModule::runOnce()
{
if (sleepOnNextExecution == true) {
@@ -458,7 +494,23 @@ int32_t PositionModule::runOnce()
bool waitingForFreshPosition = (lastGpsSend == 0) && !config.position.fixed_position && !nodeDB->hasLocalPositionSinceBoot();
if (lastGpsSend == 0 || msSinceLastSend >= intervalMs) {
// Hold to the 12h floor when fixed_position (every role: pinning yourself forfeits the
// exception) or when stationary. A real move still goes out early via smart-broadcast below.
// Not-fixed exceptions: lost-and-found broadcasts freely; trackers judge movement at their
// own (unclamped) precision rather than the on-wire one (useConfiguredPrecision).
const auto role = config.device.role;
bool stationary = config.position.fixed_position;
if (!stationary && role != meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND && nodeDB->hasValidPosition(node)) {
const bool isTracker =
IS_ONE_OF(role, meshtastic_Config_DeviceConfig_Role_TRACKER, meshtastic_Config_DeviceConfig_Role_TAK_TRACKER);
meshtastic_PositionLite selfPos;
if (nodeDB->copyNodePosition(node->num, selfPos))
stationary = positionUnchangedSinceLastSend(selfPos, /*useConfiguredPrecision=*/isTracker);
}
uint32_t effectiveIntervalMs =
effectiveBroadcastIntervalMs(intervalMs, stationary, (uint32_t)default_position_stationary_broadcast_secs * 1000UL);
if (lastGpsSend == 0 || msSinceLastSend >= effectiveIntervalMs) {
if (waitingForFreshPosition) {
#ifdef GPS_DEBUG
LOG_DEBUG("Skip initial position send; no fresh position since boot");
@@ -525,7 +577,7 @@ void PositionModule::sendLostAndFoundText()
p->decoded.portnum = meshtastic_PortNum_TEXT_MESSAGE_APP;
p->want_ack = false;
if (written < 0) {
// snprintf encoding error — send an empty payload rather than uninitialized bytes.
// snprintf encoding error - send an empty payload rather than uninitialized bytes.
p->decoded.payload.size = 0;
} else {
// Clamp to buffer capacity (snprintf returns "would-have-written" which can exceed the buffer).
@@ -546,7 +598,7 @@ static inline void computeImpreciseLatLon(int32_t inLat, int32_t inLon, uint8_t
// Build mask for top 'precisionBits' bits of a 32-bit unsigned field
const uint32_t mask = (precisionBits == 32) ? UINT32_MAX : (UINT32_MAX << (32 - precisionBits));
// Note: latitude_i/longitude_i are stored as signed 32-bit in meshtastic code but
// the bitmask logic used previously operated as unsigned—preserve that behavior by
// the bitmask logic used previously operated as unsigned-preserve that behavior by
// casting to uint32_t for masking, then back to int32_t.
uint32_t lat_u = static_cast<uint32_t>(inLat) & mask;
uint32_t lon_u = static_cast<uint32_t>(inLon) & mask;
+14
View File
@@ -38,6 +38,14 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
void handleNewPosition();
// Pure broadcast-policy helpers, split out so they're unit-testable without the module.
// True when two coordinates truncate to the same precision cell (so a re-broadcast would be a
// duplicate). precision 0 or >=32 returns false: no coarse cell to hold within, never suppress.
static bool positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int32_t bLat, int32_t bLon, uint32_t precision);
// Effective min interval: stationary positions are held to stationaryFloorMs (when that is the
// longer of the two); otherwise the normal configured interval.
static uint32_t effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs);
protected:
/** Called to handle a particular incoming message
@@ -57,6 +65,12 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
private:
meshtastic_MeshPacket *allocPositionPacket();
struct SmartPosition getDistanceTraveledSinceLastSend(meshtastic_PositionLite currentPosition);
// True when our position is unchanged since the last broadcast: it truncates to the same
// precision grid cell, so re-sending would be a duplicate that traffic management dedups
// downstream anyway. Used to hold stationary broadcasts to a 12h floor. useConfiguredPrecision
// gauges movement at our own configured (unclamped) precision rather than the on-wire
// (public-clamped) precision - trackers report finer movement.
bool positionUnchangedSinceLastSend(const meshtastic_PositionLite &selfPos, bool useConfiguredPrecision);
meshtastic_MeshPacket *allocAtakPli();
void trySetRtc(meshtastic_Position p, bool isLocal, bool forceUpdate = false);
uint32_t precision;
+1 -1
View File
@@ -141,7 +141,7 @@ ProcessMessage RangeTestModuleRadio::handleReceived(const meshtastic_MeshPacket
/*
auto &p = mp.decoded;
LOG_DEBUG("Received text msg self=0x%0x, from=0x%0x, to=0x%0x, id=%d, msg=%.*s",
LOG_DEBUG("Received text msg self=0x%08x, from=0x%08x, to=0x%08x, id=%d, msg=%.*s",
LOG_INFO.getNodeNum(), mp.from, mp.to, mp.id, p.payload.size, p.payload.bytes);
*/
+3 -3
View File
@@ -59,14 +59,14 @@ static bool replybotRateLimited(uint32_t from, uint32_t cooldownMs)
return false;
}
}
// No entry found – insert new sender into the ring
// No entry found - insert new sender into the ring
replybotCooldown[replybotCooldownIdx].from = from;
replybotCooldown[replybotCooldownIdx].lastMs = now;
replybotCooldownIdx = (replybotCooldownIdx + 1) % REPLYBOT_COOLDOWN_SLOTS;
return false;
}
// Constructor – registers a single text port and marks the module promiscuous
// Constructor - registers a single text port and marks the module promiscuous
// so that broadcast messages on the primary channel are visible.
ReplyBotModule::ReplyBotModule() : SinglePortModule("replybot", meshtastic_PortNum_TEXT_MESSAGE_APP)
{
@@ -122,7 +122,7 @@ ProcessMessage ReplyBotModule::handleReceived(const meshtastic_MeshPacket &mp)
return ProcessMessage::CONTINUE;
}
// Compute hop count indicator – if the relay_node is non‑zero we know
// Compute hop count indicator - if the relay_node is non‑zero we know
// there was at least one relay. Some firmware builds support a hop_start
// field which could be used for more accurate counts, but here we use
// the available relay_node flag only.
+1 -1
View File
@@ -12,7 +12,7 @@ meshtastic_MeshPacket *ReplyModule::allocReply()
auto req = *currentRequest;
auto &p = req.decoded;
// The incoming message is in p.payload
LOG_INFO("Received message from=0x%0x, id=%d, msg=%.*s", req.from, req.id, p.payload.size, p.payload.bytes);
LOG_INFO("Received message from=0x%08x, id=0x%08x, msg=%.*s", req.from, req.id, p.payload.size, p.payload.bytes);
#endif
const char *replyStr = "Message Received";
+1 -1
View File
@@ -377,7 +377,7 @@ ProcessMessage SerialModuleRadio::handleReceived(const meshtastic_MeshPacket &mp
}
auto &p = mp.decoded;
// LOG_DEBUG("Received text msg self=0x%0x, from=0x%0x, to=0x%0x, id=%d, msg=%.*s",
// LOG_DEBUG("Received text msg self=0x%08x, from=0x%08x, to=0x%08x, id=%d, msg=%.*s",
// nodeDB->getNodeNum(), mp.from, mp.to, mp.id, p.payload.size, p.payload.bytes);
if (isFromUs(&mp)) {
+24
View File
@@ -131,6 +131,12 @@ int32_t StatusLEDModule::runOnce()
CHARGE_LED_state = LED_STATE_OFF;
}
}
} else {
#if defined(LED_HEARTBEAT)
// If we are using the heartbeat, as in the Thinknode M4, we need to explicitly turn off the charge LED
// This probably implies that in the future we need to stop re-using this bool for multiple purposes.
CHARGE_LED_state = LED_STATE_OFF;
#endif
}
// If we want a LED to be dedicated to the simple hearbeat, we can use that instead of the charge LED
#if defined(LED_HEARTBEAT)
@@ -158,6 +164,7 @@ int32_t StatusLEDModule::runOnce()
}
}
#endif
#ifdef LED_PAIRING
if (!config.bluetooth.enabled || PAIRING_LED_starttime + 30 * 1000 < millis() || doing_fast_blink) {
PAIRING_LED_state = LED_STATE_OFF;
} else if (ble_state == unpaired) {
@@ -172,6 +179,7 @@ int32_t StatusLEDModule::runOnce()
} else {
PAIRING_LED_state = LED_STATE_ON;
}
#endif
// Override if disabled in config
if (config.device.led_heartbeat_disabled) {
@@ -207,9 +215,25 @@ int32_t StatusLEDModule::runOnce()
#ifdef PCA_LED_ENABLE
io.digitalWrite(PCA_LED_ENABLE, CHARGE_LED_state);
#endif
#ifdef LED_POWER
#ifdef LED_POWER_CRITICAL
if (LED_POWER != LED_POWER_CRITICAL) {
if (power_state == critical) {
digitalWrite(LED_POWER, 0);
digitalWrite(LED_POWER_CRITICAL, CHARGE_LED_state);
} else {
digitalWrite(LED_POWER, CHARGE_LED_state);
digitalWrite(LED_POWER_CRITICAL, 0);
}
} else {
digitalWrite(LED_POWER, CHARGE_LED_state);
}
#else
digitalWrite(LED_POWER, CHARGE_LED_state);
#endif
#endif
#ifdef LED_PAIRING
digitalWrite(LED_PAIRING, PAIRING_LED_state);
#endif
+1 -1
View File
@@ -17,7 +17,7 @@
// NEOPIXEL_STATUS_POWER_PIN (required to enable the power/charge pixel)
// NEOPIXEL_STATUS_POWER_COLOR (optional, default red 0xFF0000)
// NEOPIXEL_STATUS_PAIRING_PIN / _COLOR (default blue 0x0000FF)
// Each pixel is a standalone 1-LED strand on its own GPIO — this mirrors how
// Each pixel is a standalone 1-LED strand on its own GPIO - this mirrors how
// boards like the LilyGo T-Echo-Card expose three independent WS2812s.
#if defined(NEOPIXEL_STATUS_POWER_PIN) || defined(NEOPIXEL_STATUS_PAIRING_PIN)
#include <Adafruit_NeoPixel.h>
@@ -262,6 +262,11 @@ void AirQualityTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSta
return;
}
// Same String(float) stack-overflow guard as EnvironmentTelemetry: form_formaldehyde is the only
// float rendered here, and its raw bytes are unvalidated on decode.
telemetry.variant.air_quality_metrics.form_formaldehyde =
UnitConversions::displaySafeFloat(telemetry.variant.air_quality_metrics.form_formaldehyde);
const auto &m = telemetry.variant.air_quality_metrics;
// Check if any telemetry field has valid data
+26 -3
View File
@@ -119,6 +119,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c
#include "Sensor/T1000xSensor.h"
#endif
#if __has_include(<Adafruit_SPA06_003.h>)
#include "Sensor/SPA06Sensor.h"
#endif
#ifdef SENSECAP_INDICATOR
#include "Sensor/IndicatorSensor.h"
#endif
@@ -167,12 +171,15 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner)
// Not a real I2C device, uses UART
addSensor<IndicatorSensor>(i2cScanner, ScanI2C::DeviceType::NONE);
#endif
addSensor<RCWL9620Sensor>(i2cScanner, ScanI2C::DeviceType::RCWL9620);
addSensor<CGRadSensSensor>(i2cScanner, ScanI2C::DeviceType::CGRADSENS);
#if HAS_SPA06 && __has_include(<Adafruit_SPA06_003.h>)
addSensor<SPA06Sensor>(i2cScanner, ScanI2C::DeviceType::SPA06);
#endif
#endif
#endif
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR_EXTERNAL
addSensor<RCWL9620Sensor>(i2cScanner, ScanI2C::DeviceType::RCWL9620);
addSensor<CGRadSensSensor>(i2cScanner, ScanI2C::DeviceType::CGRADSENS);
#if __has_include(<DFRobot_LarkWeatherStation.h>)
addSensor<DFRobotLarkSensor>(i2cScanner, ScanI2C::DeviceType::DFROBOT_LARK);
#endif
@@ -368,6 +375,22 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt
return;
}
// Bound the float metrics before String(float) renders them (see UnitConversions::displaySafeFloat);
// the stored packet is always the environment variant.
{
auto &e = telemetry.variant.environment_metrics;
e.temperature = UnitConversions::displaySafeFloat(e.temperature);
e.relative_humidity = UnitConversions::displaySafeFloat(e.relative_humidity);
e.barometric_pressure = UnitConversions::displaySafeFloat(e.barometric_pressure);
e.voltage = UnitConversions::displaySafeFloat(e.voltage);
e.current = UnitConversions::displaySafeFloat(e.current);
e.lux = UnitConversions::displaySafeFloat(e.lux);
e.white_lux = UnitConversions::displaySafeFloat(e.white_lux);
e.weight = UnitConversions::displaySafeFloat(e.weight);
e.distance = UnitConversions::displaySafeFloat(e.distance);
e.radiation = UnitConversions::displaySafeFloat(e.radiation);
}
const auto &m = telemetry.variant.environment_metrics;
// Check if any telemetry field has valid data
@@ -436,7 +459,7 @@ void EnvironmentTelemetryModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiSt
bool isCooldownOver = (now - lastAlertTime > 60000);
if (isOwnTelemetry && bannerMsg && isCooldownOver) {
LOG_INFO("drawFrame: IAQ %d (own) — showing banner: %s", m.iaq, bannerMsg);
LOG_INFO("drawFrame: IAQ %d (own) - showing banner: %s", m.iaq, bannerMsg);
screen->showSimpleBanner(bannerMsg, 3000);
// Only buzz if IAQ is over 200
@@ -39,4 +39,4 @@ class PMSA003ISensor : public TelemetrySensor
#endif
};
#endif
#endif
+1 -1
View File
@@ -52,4 +52,4 @@ class SCD30Sensor : public TelemetrySensor
meshtastic_AdminMessage *response) override;
};
#endif
#endif
+1 -1
View File
@@ -62,4 +62,4 @@ class SCD4XSensor : public TelemetrySensor
meshtastic_AdminMessage *response) override;
};
#endif
#endif
+2 -2
View File
@@ -338,7 +338,7 @@ bool SEN5XSensor::vocStateToSensor()
// Note: send command already takes into account the CRC
// buffer size increment needed
if (!sendCommand(SEN5X_RW_VOCS_STATE, vocState, SEN5X_VOC_STATE_BUFFER_SIZE)) {
LOG_ERROR("%s: Error sending VOC's state command'", sensorName);
LOG_ERROR("%s: Error sending VOC's state command", sensorName);
return false;
}
@@ -354,7 +354,7 @@ bool SEN5XSensor::vocStateFromSensor()
LOG_INFO("%s: Getting VOC state from sensor", sensorName);
// Ask VOCs state from the sensor
if (!sendCommand(SEN5X_RW_VOCS_STATE)) {
LOG_ERROR("%s: Error sending VOC's state command'", sensorName);
LOG_ERROR("%s: Error sending VOC's state command", sensorName);
return false;
}
+1 -1
View File
@@ -170,4 +170,4 @@ See: https://sensirion.com/resource/application_note/low_power_mode/sen5x
meshtastic_AdminMessage *response) override;
};
#endif
#endif
@@ -0,0 +1,60 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<Adafruit_SPA06_003.h>)
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "SPA06Sensor.h"
#include "TelemetrySensor.h"
#include <Adafruit_SPA06_003.h>
SPA06Sensor::SPA06Sensor()
: TelemetrySensor(meshtastic_TelemetrySensorType_SPA06, "SPA06"), spa_temp(nullptr), spa_pressure(nullptr)
{
}
bool SPA06Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev)
{
LOG_INFO("Init sensor: %s", sensorName);
status = spa06.begin(dev->address.address, bus);
if (!status) {
return status;
}
// Set moderate precision for faster sampling
spa06.setPressureOversampling(SPA06_003_OVERSAMPLE_8); // 8x oversampling
spa06.setTemperatureOversampling(SPA06_003_OVERSAMPLE_8); // 8x oversampling
// Set measurement rate. 1 Hz is ample for telemetry (polled every tens of seconds)
// and draws far less power than 32 Hz, while staying in continuous mode so getMetrics()
// remains non-blocking (fresh data always available).
spa06.setPressureMeasureRate(SPA06_003_RATE_1); // 1 Hz
spa06.setTemperatureMeasureRate(SPA06_003_RATE_1); // 1 Hz
spa06.setMeasurementMode(SPA06_003_MEAS_CONTINUOUS_BOTH);
spa_temp = spa06.getTemperatureSensor();
spa_pressure = spa06.getPressureSensor();
initI2CSensor();
return status;
}
bool SPA06Sensor::getMetrics(meshtastic_Telemetry *measurement)
{
if (!spa_temp || !spa_pressure) {
return false;
}
sensors_event_t temp, press;
if (!spa_temp->getEvent(&temp) || !spa_pressure->getEvent(&press)) {
LOG_DEBUG("SPA06 getEvents no data");
return false;
}
measurement->variant.environment_metrics.has_temperature = true;
measurement->variant.environment_metrics.has_barometric_pressure = true;
measurement->variant.environment_metrics.temperature = temp.temperature;
measurement->variant.environment_metrics.barometric_pressure = press.pressure;
return true;
}
#endif
@@ -0,0 +1,22 @@
#include "configuration.h"
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<Adafruit_SPA06_003.h>)
#include "../mesh/generated/meshtastic/telemetry.pb.h"
#include "TelemetrySensor.h"
#include <Adafruit_SPA06_003.h>
class SPA06Sensor : public TelemetrySensor
{
private:
Adafruit_SPA06_003 spa06;
Adafruit_Sensor *spa_temp;
Adafruit_Sensor *spa_pressure;
public:
SPA06Sensor();
virtual bool getMetrics(meshtastic_Telemetry *measurement) override;
virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override;
};
#endif
+11
View File
@@ -1,5 +1,7 @@
#pragma once
#include <cmath>
class UnitConversions
{
public:
@@ -7,4 +9,13 @@ class UnitConversions
static float MetersPerSecondToKnots(float metersPerSecond);
static float MetersPerSecondToMilesPerHour(float metersPerSecond);
static float HectoPascalToInchesOfMercury(float hectoPascal);
// Bound a float before Arduino String(float) renders it: its fixed char[33] + dtostrf overflow
// near FLT_MAX (stack smash). Clamp to +/-1e9 (<=10 digits) and drop non-finite values.
static inline float displaySafeFloat(float v)
{
if (!std::isfinite(v))
return 0.0f;
return v < -1e9f ? -1e9f : (v > 1e9f ? 1e9f : v);
}
};
+1 -1
View File
@@ -15,7 +15,7 @@ ProcessMessage TextMessageModule::handleReceived(const meshtastic_MeshPacket &mp
{
#if defined(DEBUG_PORT) && !defined(DEBUG_MUTE)
auto &p = mp.decoded;
LOG_INFO("Received text msg from=0x%0x, id=0x%x, msg=%.*s", mp.from, mp.id, p.payload.size, p.payload.bytes);
LOG_INFO("Received text msg from=0x%08x, id=0x%08x, msg=%.*s", mp.from, mp.id, p.payload.size, p.payload.bytes);
#endif
// add packet ID to the rolling list of packets
textPacketList[textPacketListIndex] = mp.id;
+13 -1
View File
@@ -8,6 +8,10 @@
#include "meshUtils.h"
#include <vector>
#if HAS_TRAFFIC_MANAGEMENT
#include "modules/TrafficManagementModule.h"
#endif
extern graphics::Screen *screen;
TraceRouteModule *traceRouteModule;
@@ -323,6 +327,14 @@ void TraceRouteModule::maybeSetNextHop(NodeNum target, uint8_t nextHopByte)
LOG_INFO("Updating next-hop for 0x%08x to 0x%02x based on traceroute", target, nextHopByte);
node->next_hop = nextHopByte;
}
#if HAS_TRAFFIC_MANAGEMENT
// Mirror into the TMM overflow cache. Traceroute is the highest-confidence
// source (full known route), and this captures the target even when it isn't
// in the hot NodeDB - same rationale as the ACK-confirmed path in NextHopRouter.
if (trafficManagementModule)
trafficManagementModule->setNextHop(target, nextHopByte);
#endif
}
void TraceRouteModule::processUpgradedPacket(const meshtastic_MeshPacket &mp)
@@ -440,7 +452,7 @@ void TraceRouteModule::printRoute(meshtastic_RouteDiscovery *r, uint32_t origin,
// If there's a route back (or we are the destination as then the route is complete), print it
if (r->route_back_count > 0 || origin == nodeDB->getNodeNum()) {
route += "\n";
if (r->snr_towards_count > 0 && origin == nodeDB->getNodeNum())
if (origin == nodeDB->getNodeNum() && r->snr_back_count > 0 && r->snr_back[r->snr_back_count - 1] != INT8_MIN)
route += vformat("(%.2fdB) 0x%x <-- ", (float)r->snr_back[r->snr_back_count - 1] / 4, origin);
else
route += "...";
File diff suppressed because it is too large Load Diff
+138 -262
View File
@@ -20,9 +20,12 @@
* - Router hop preservation (maintain hop_limit for router-to-router traffic)
*
* Memory Optimization:
* Uses a unified cache with cuckoo hashing for O(1) lookups and 56% memory reduction
* compared to separate per-feature caches. Timestamps are stored as 8-bit relative
* offsets from a rolling epoch to further reduce memory footprint.
* Uses one flat unified cache (plain array, linear scan) shared by all
* per-node features instead of separate per-feature caches. Timestamps are
* stored as free-running modular tick counters (pos: 8-bit 360 s/tick;
* rate+unknown: paired 4-bit nibbles in one byte) for a 10-byte entry.
* LoRa packet rates are low enough that an O(n) scan of ~1000 entries is
* negligible next to packet processing.
*/
class TrafficManagementModule : public MeshModule, private concurrency::OSThread
{
@@ -30,7 +33,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
TrafficManagementModule();
~TrafficManagementModule();
// Singleton — no copying or moving
// Singleton - no copying or moving
TrafficManagementModule(const TrafficManagementModule &) = delete;
TrafficManagementModule &operator=(const TrafficManagementModule &) = delete;
@@ -38,6 +41,24 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
void resetStats();
void recordRouterHopPreserved();
// Next-hop overflow cache (routing hint).
// setNextHop: store a confirmed last-byte next hop for `dest`. Called by
// NextHopRouter from its ACK-confirmed decision (see sniffReceived). The
// byte must come from a bidirectionally-verified relay, not one-way inference.
// getNextHopHint: return the cached next-hop byte for `dest`, 0 if unknown.
// clearNextHop: forget any cached next hop for `dest` (setNextHop refuses to store
// 0, so this is the way NextHopRouter decays a stale/failing overflow route).
void setNextHop(NodeNum dest, uint8_t nextHopByte);
uint8_t getNextHopHint(NodeNum dest);
void clearNextHop(NodeNum dest);
// Warm-start the next-hop cache from persisted NodeInfoLite hints so confirmed
// hops survive later hot-store (NodeDB) eviction. Idempotent; runs once after
// nodeDB is populated (lazily on first maintenance pass).
// @return true if it actually ran (prereqs met / nothing to do); false if
// prerequisites (cache, nodeDB) weren't ready yet, so the caller should retry.
bool preloadNextHopsFromNodeDB();
/**
* Check if this packet should have its hops exhausted.
* Called from perhapsRebroadcast() to force hop_limit = 0 regardless of
@@ -48,182 +69,126 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
return exhaustRequested && exhaustRequestedFrom == getFrom(&mp) && exhaustRequestedId == mp.id;
}
// Injectable monotonic clock (ms). All TMM time reads go through clockMs() so unit tests can
// advance a virtual timebase instead of sleeping real seconds across the 6 min/360 s tick.
// Mirrors HopScalingModule::s_testNowMs. Writable from tests as TrafficManagementModule::s_testNowMs;
// ignored in production (clockMs() returns millis()).
inline static uint32_t s_testNowMs = 0;
#ifdef PIO_UNIT_TESTING
static uint32_t clockMs() { return s_testNowMs; }
#else
static uint32_t clockMs() { return millis(); }
#endif
protected:
ProcessMessage handleReceived(const meshtastic_MeshPacket &mp) override;
bool wantPacket(const meshtastic_MeshPacket *p) override { return true; }
void alterReceived(meshtastic_MeshPacket &mp) override;
int32_t runOnce() override;
// Protected so test shims can force epoch rollover behavior.
void resetEpoch(uint32_t nowMs);
// Protected so test shims can flush per-node traffic state.
void flushCache();
// Introspection for tests: the cached device role for a node, or -1 if the node has
// no cache entry (distinguishes "not tracked / evicted" from CLIENT == 0).
int peekCachedRole(NodeNum node);
private:
// =========================================================================
// Unified Cache Entry (10 bytes) - Same for ALL platforms
// =========================================================================
//
// A single compact structure used across ESP32, NRF52, and all other platforms.
// Memory: 10 bytes × 2048 entries = 20KB
//
// Position Fingerprinting:
// Instead of storing full coordinates (8 bytes) or a computed hash,
// we store an 8-bit fingerprint derived deterministically from the
// truncated lat/lon. This extracts the lower 4 significant bits from
// each coordinate: fingerprint = (lat_low4 << 4) | lon_low4
//
// Benefits over hash:
// - Adjacent grid cells have sequential fingerprints (no collision)
// - Two positions only collide if 16+ grid cells apart in BOTH dimensions
// - Deterministic: same input always produces same output
//
// Adaptive Timestamp Resolution:
// All timestamps use 8-bit values with adaptive resolution calculated
// from config at startup. Resolution = max(60, min(339, interval/2)).
// - Min 60 seconds ensures reasonable precision
// - Max 339 seconds allows ~24 hour range (255 * 339 = 86445 sec)
// - interval/2 ensures at least 2 ticks per configured interval
//
// Layout:
// [0-3] node - NodeNum (4 bytes)
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (1 byte)
// [5] rate_count - Packets in current window (1 byte)
// [6] unknown_count - Unknown packets count (1 byte)
// [7] pos_time - Position timestamp (1 byte, adaptive resolution)
// [8] rate_time - Rate window start (1 byte, adaptive resolution)
// [9] unknown_time - Unknown tracking start (1 byte, adaptive resolution)
// [0-3] node - NodeNum (4 bytes, 0 = empty slot)
// [4] pos_fingerprint - 4 bits lat + 4 bits lon (0 = no position seen)
// [5] rate_count - [7:6] role[3:2] | [5:0] packets in rate window (0 = no window active)
// [6] unknown_count - [7:6] role[1:0] | [5:0] unknown packets in window (0 = no window active)
// [7] pos_time - Position tick (uint8, free-running 360 s/tick)
// [8] rate_unknown_time - [7:4] rate nibble (300 s/tick) | [3:0] unknown nibble (60 s/tick)
// [9] next_hop - Last-byte relay to reach `node` (0 = none)
//
// The 4-bit device role (bits [7:6] of rate_count paired with [7:6] of unknown_count)
// caches the sender's meshtastic_Config_DeviceConfig_Role as a third fallback after the
// hot store and warm store, for nodes evicted from both. Read/written via
// resolveSenderRole(). Max encodable value is 15.
//
// Presence sentinels (no epoch, no +1 offset needed):
// pos active: pos_fingerprint != 0
// rate active: getRateCount() != 0 (low 6 bits only)
// unknown active: getUnknownCount() != 0 (low 6 bits only)
//
// next_hop: routing hint written only from ACK-confirmed NextHopRouter decisions.
// No TTL - keeps the slot alive across maintenance sweeps.
//
#if _meshtastic_Config_DeviceConfig_Role_MAX > 15
#warning "Device role enum max exceeds 15 - TMM 4-bit role cache (rate_count[7:6]/unknown_count[7:6]) will truncate new values"
#endif
struct __attribute__((packed)) UnifiedCacheEntry {
NodeNum node; // 4 bytes - Node identifier (0 = empty slot)
uint8_t pos_fingerprint; // 1 byte - Lower 4 bits of lat + lon
uint8_t rate_count; // 1 byte - Packet count (saturates at 255)
uint8_t unknown_count; // 1 byte - Unknown packet count (saturates at 255)
uint8_t pos_time; // 1 byte - Position timestamp (adaptive resolution)
uint8_t rate_time; // 1 byte - Rate window start (adaptive resolution)
uint8_t unknown_time; // 1 byte - Unknown tracking start (adaptive resolution)
NodeNum node;
uint8_t pos_fingerprint;
uint8_t rate_count; // [7:6] = role[3:2], [5:0] = count (max 63)
uint8_t unknown_count; // [7:6] = role[1:0], [5:0] = count (max 63)
uint8_t pos_time;
uint8_t rate_unknown_time;
uint8_t next_hop;
uint8_t getRateCount() const { return rate_count & 0x3F; }
void setRateCount(uint8_t c) { rate_count = static_cast<uint8_t>((rate_count & 0xC0) | (c & 0x3F)); }
uint8_t getUnknownCount() const { return unknown_count & 0x3F; }
void setUnknownCount(uint8_t c) { unknown_count = static_cast<uint8_t>((unknown_count & 0xC0) | (c & 0x3F)); }
uint8_t getCachedRole() const { return static_cast<uint8_t>(((rate_count >> 6) << 2) | (unknown_count >> 6)); }
void setCachedRole(uint8_t role)
{
rate_count = static_cast<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
unknown_count = static_cast<uint8_t>((unknown_count & 0x3F) | ((role & 0x03) << 6));
}
uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; }
uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; }
void setRateTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0xF0) | (t & 0x0F)); }
};
static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes");
// =========================================================================
// Cuckoo Hash Table Implementation
// Flat unified cache
// =========================================================================
//
// Cuckoo hashing provides O(1) worst-case lookup time using two hash functions.
// Each key can be in one of two possible locations (h1 or h2). On collision,
// the existing entry is "kicked" to its alternate location.
// Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at
// most cacheSize() × 10 B - microseconds at LoRa packet rates, not worth a
// hash table. Insertion on a full cache evicts the stalest entry,
// preferring entries without a next_hop hint (those are the long-tail
// routing state this cache exists to keep).
//
// Benefits over linear scan:
// - O(1) lookup vs O(n) - critical at packet processing rates
// - O(1) insertion (amortized) with simple eviction on cycles
// - ~95% load factor achievable
//
// Cache size rounds to power-of-2 for fast modulo via bitmask.
// TRAFFIC_MANAGEMENT_CACHE_SIZE=2000 → cacheSize()=2048
//
static constexpr uint16_t cacheSize();
static constexpr uint16_t cacheMask();
static constexpr uint16_t cacheSize() { return TRAFFIC_MANAGEMENT_CACHE_SIZE; }
// Hash functions for cuckoo hashing
inline uint16_t cuckooHash1(NodeNum node) const { return node & cacheMask(); }
inline uint16_t cuckooHash2(NodeNum node) const { return ((node * 2654435769u) >> (32 - cuckooHashBits())) & cacheMask(); }
static constexpr uint8_t cuckooHashBits();
// NodeInfo cache configuration (PSRAM path):
// - Payload lives in PSRAM
// - DRAM keeps packed 12-bit tags with 4-way bucketed cuckoo hashing
// (Fan et al., CoNEXT 2014). Tag value 0 is reserved as "empty".
static constexpr uint16_t kNodeInfoIndexMetadataBudgetBytes = 3072; // 3KB DRAM tag store
static constexpr uint8_t kNodeInfoTargetOccupancyPercent = 95;
static constexpr uint8_t kNodeInfoBucketSize = 4;
static constexpr uint8_t kNodeInfoTagBits = 12;
static constexpr uint16_t kNodeInfoTagMask = static_cast<uint16_t>((1u << kNodeInfoTagBits) - 1u);
static constexpr uint16_t kNodeInfoIndexSlotsRaw =
static_cast<uint16_t>((kNodeInfoIndexMetadataBudgetBytes * 8u) / kNodeInfoTagBits);
static constexpr uint16_t kNodeInfoIndexSlots =
static_cast<uint16_t>(kNodeInfoIndexSlotsRaw - (kNodeInfoIndexSlotsRaw % kNodeInfoBucketSize));
static constexpr uint16_t kNodeInfoTargetEntries =
static_cast<uint16_t>((kNodeInfoIndexSlots * kNodeInfoTargetOccupancyPercent) / 100u);
static_assert((kNodeInfoIndexSlots % kNodeInfoBucketSize) == 0, "NodeInfo slot count must align to bucket size");
static_assert(kNodeInfoTargetEntries < (1u << kNodeInfoTagBits), "NodeInfo tag bits must encode payload index");
static constexpr uint16_t nodeInfoTargetEntries();
static constexpr uint16_t nodeInfoIndexMetadataBudgetBytes();
static constexpr uint8_t nodeInfoTargetOccupancyPercent();
static constexpr uint8_t nodeInfoBucketSize();
static constexpr uint8_t nodeInfoTagBits();
static constexpr uint16_t nodeInfoTagMask();
static constexpr uint16_t nodeInfoIndexSlots();
static constexpr uint16_t nodeInfoBucketCount();
static constexpr uint16_t nodeInfoBucketMask();
static constexpr uint8_t nodeInfoBucketHashBits();
inline uint16_t nodeInfoHash1(NodeNum node) const { return node & nodeInfoBucketMask(); }
inline uint16_t nodeInfoHash2(NodeNum node) const
{
return ((node * 2246822519u) >> (32 - nodeInfoBucketHashBits())) & nodeInfoBucketMask();
}
// NodeInfo cache configuration (PSRAM path): a flat PSRAM array of payload
// entries, linear scan keyed by `node`, LRU eviction by lastObservedMs.
// NodeInfo traffic is low-rate, so a full scan per lookup/insert is fine.
static constexpr uint16_t kNodeInfoCacheEntries = 2000;
static constexpr uint16_t nodeInfoTargetEntries() { return kNodeInfoCacheEntries; }
// =========================================================================
// Adaptive Timestamp Resolution
// Free-Running Tick Counters
// =========================================================================
//
// All timestamps use 8-bit values with adaptive resolution calculated from
// config at startup. This allows ~24 hour range while maintaining precision.
// Timestamps are stored as free-running modular tick counters derived from
// millis(). No epoch anchor needed: modular subtraction gives correct age
// as long as the true age stays below the counter period.
//
// Resolution formula: max(60, min(339, interval/2))
// - 60 sec minimum ensures reasonable precision
// - 339 sec maximum allows 24 hour range (255 * 339 ≈ 86400 sec)
// - interval/2 ensures at least 2 ticks per configured interval
// pos_time : uint8 (256 ticks × 360 s = 25.6 h period; max window 12 h = 120 ticks)
// rate_time : nibble (16 ticks × 300 s = 80 min period; max window 1 h = 12 ticks)
// unknown_time: nibble (16 ticks × 60 s = 16 min period; max window 12 min = 12 ticks)
//
// Since config changes require reboot, resolution is calculated once.
// Presence sentinels (no +1 offset needed; count fields serve as guards):
// pos active: pos_fingerprint != 0 (0 is reserved sentinel; computePositionFingerprint() remaps computed-0 → 0xFF)
// rate active: getRateCount() != 0 (low 6 bits; high 2 bits are cached role)
// unknown active: getUnknownCount() != 0
//
uint32_t cacheEpochMs = 0;
uint16_t posTimeResolution = 60; // Seconds per tick for position
uint16_t rateTimeResolution = 60; // Seconds per tick for rate limiting
uint16_t unknownTimeResolution = 60; // Seconds per tick for unknown tracking
static constexpr uint32_t kPosTimeTickMs = 360'000UL; // 6 min/tick
static constexpr uint32_t kRateTimeTickMs = 300'000UL; // 5 min/tick
static constexpr uint32_t kUnknownTimeTickMs = 60'000UL; // 1 min/tick
// Calculate resolution from configured interval (called once at startup)
static uint16_t calcTimeResolution(uint32_t intervalSecs)
{
// Resolution = interval/2 to ensure at least 2 ticks per interval
// Clamped to [60, 339] for min precision and max 24h range
uint32_t res = (intervalSecs > 0) ? (intervalSecs / 2) : 60;
if (res < 60)
res = 60;
if (res > 339)
res = 339;
return static_cast<uint16_t>(res);
}
// Convert to/from 8-bit relative timestamps with given resolution
uint8_t toRelativeTime(uint32_t nowMs, uint16_t resolutionSecs) const
{
uint32_t ticks = (nowMs - cacheEpochMs) / (resolutionSecs * 1000UL);
return (ticks > UINT8_MAX) ? UINT8_MAX : static_cast<uint8_t>(ticks);
}
uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const
{
return cacheEpochMs + (static_cast<uint32_t>(ticks) * resolutionSecs * 1000UL);
}
// Convenience wrappers for each timestamp type
uint8_t toRelativePosTime(uint32_t nowMs) const { return toRelativeTime(nowMs, posTimeResolution); }
uint32_t fromRelativePosTime(uint8_t t) const { return fromRelativeTime(t, posTimeResolution); }
uint8_t toRelativeRateTime(uint32_t nowMs) const { return toRelativeTime(nowMs, rateTimeResolution); }
uint32_t fromRelativeRateTime(uint8_t t) const { return fromRelativeTime(t, rateTimeResolution); }
uint8_t toRelativeUnknownTime(uint32_t nowMs) const { return toRelativeTime(nowMs, unknownTimeResolution); }
uint32_t fromRelativeUnknownTime(uint8_t t) const { return fromRelativeTime(t, unknownTimeResolution); }
// Epoch reset when any timestamp approaches overflow
// With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max)
bool needsEpochReset(uint32_t nowMs) const
{
uint16_t maxRes = posTimeResolution;
if (rateTimeResolution > maxRes)
maxRes = rateTimeResolution;
if (unknownTimeResolution > maxRes)
maxRes = unknownTimeResolution;
return (nowMs - cacheEpochMs) > (200UL * maxRes * 1000UL);
}
static uint8_t currentPosTick() { return static_cast<uint8_t>(clockMs() / kPosTimeTickMs); }
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((clockMs() / kUnknownTimeTickMs) & 0x0F); }
// =========================================================================
// Position Fingerprint
// =========================================================================
@@ -246,7 +211,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
// =========================================================================
mutable concurrency::Lock cacheLock; // Protects all cache access
UnifiedCacheEntry *cache = nullptr; // Cuckoo hash table (unified for all platforms)
UnifiedCacheEntry *cache = nullptr; // Flat unified cache (linear scan; all platforms)
bool cacheFromPsram = false; // Tracks allocator for correct deallocation
struct NodeInfoPayloadEntry {
@@ -278,11 +243,8 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
uint8_t decodedBitfield;
};
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM
NodeInfoPayloadEntry *nodeInfoPayload = nullptr; // NodeInfo payloads in PSRAM (flat array, linear scan)
bool nodeInfoPayloadFromPsram = false; // Tracks allocator for correct deallocation
uint8_t *nodeInfoIndex = nullptr; // Packed 12-bit NodeInfo tags in DRAM
uint16_t nodeInfoAllocHint = 0;
uint16_t nodeInfoEvictCursor = 0;
meshtastic_TrafficManagementStats stats;
@@ -293,29 +255,37 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
NodeNum exhaustRequestedFrom = 0;
PacketId exhaustRequestedId = 0;
// One-shot guard: warm-start next-hop cache from NodeDB on first maintenance pass.
bool nextHopPreloaded = false;
// =========================================================================
// Cache Operations
// =========================================================================
// Find or create entry for node using cuckoo hashing
// Returns nullptr if cache is full and eviction fails
// Find or create entry for node (linear scan; stalest-first eviction when full)
UnifiedCacheEntry *findOrCreateEntry(NodeNum node, bool *isNew);
// Find existing entry (no creation)
UnifiedCacheEntry *findEntry(NodeNum node);
// NodeInfo cache operations (bucketed cuckoo index + PSRAM payloads)
// Resolve a sender's advertised device role for the position hot path. The tier-3
// cache (this entry's getCachedRole) is authoritative and is kept fresh by
// updateCachedRoleFromNodeInfo() - updated when NodeDB learns a role, not re-derived
// per packet. Only on first tracking (isNew) do we scan NodeDB (hot store → warm
// store, via getNodeRole) to seed the cache, so a resident special-role node is
// correct from its first position; after that the read is O(1) and survives the node
// aging out of both NodeDB stores. Caller must hold cacheLock; entry may be null
// (→ NodeDB scan only).
meshtastic_Config_DeviceConfig_Role resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew);
// Refresh the tier-3 role cache from an observed NodeInfo (the same event that updates
// NodeDB's role). Reads role from the packet's User payload; updates only nodes already
// tracked (no entry creation). Takes cacheLock.
void updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp);
// NodeInfo cache operations (flat PSRAM payload array, linear scan)
const NodeInfoPayloadEntry *findNodeInfoEntry(NodeNum node) const;
NodeInfoPayloadEntry *findOrCreateNodeInfoEntry(NodeNum node, bool *usedEmptySlot);
uint16_t findNodeInfoPayloadIndex(NodeNum node) const;
bool removeNodeInfoIndexEntry(NodeNum node, uint16_t payloadIndex);
uint16_t allocateNodeInfoPayloadSlot();
uint16_t evictNodeInfoPayloadSlot();
bool tryInsertNodeInfoEntryInBucket(uint16_t bucket, uint16_t tag);
uint16_t encodeNodeInfoTag(uint16_t payloadIndex) const;
uint16_t decodeNodeInfoPayloadIndex(uint16_t tag) const;
uint16_t getNodeInfoTag(uint16_t slot) const;
void setNodeInfoTag(uint16_t slot, uint16_t tag);
uint16_t countNodeInfoEntriesLocked() const;
void cacheNodeInfoPacket(const meshtastic_MeshPacket &mp);
@@ -333,101 +303,7 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread
void incrementStat(uint32_t *field);
};
// =========================================================================
// Compile-time Cache Size Calculations
// =========================================================================
//
// Round TRAFFIC_MANAGEMENT_CACHE_SIZE up to next power of 2 for efficient
// cuckoo hash indexing (allows bitmask instead of modulo).
//
// These use C++11-compatible constexpr (single return statement).
//
namespace detail
{
// Helper: round up to next power of 2 using bit manipulation
constexpr uint16_t nextPow2(uint16_t n)
{
return n == 0 ? 0 : (((n - 1) | ((n - 1) >> 1) | ((n - 1) >> 2) | ((n - 1) >> 4) | ((n - 1) >> 8)) + 1);
}
// Helper: floor(log2(n)) for n >= 0, C++11-compatible constexpr.
constexpr uint8_t log2Floor(uint16_t n)
{
return n <= 1 ? 0 : static_cast<uint8_t>(1 + log2Floor(static_cast<uint16_t>(n >> 1)));
}
// Helper: ceil(log2(n)) for n >= 1, C++11-compatible constexpr.
constexpr uint8_t log2Ceil(uint16_t n)
{
return n <= 1 ? 0 : static_cast<uint8_t>(1 + log2Floor(static_cast<uint16_t>(n - 1)));
}
} // namespace detail
constexpr uint16_t TrafficManagementModule::cacheSize()
{
return detail::nextPow2(TRAFFIC_MANAGEMENT_CACHE_SIZE);
}
constexpr uint16_t TrafficManagementModule::cacheMask()
{
return cacheSize() > 0 ? cacheSize() - 1 : 0;
}
constexpr uint8_t TrafficManagementModule::cuckooHashBits()
{
return detail::log2Floor(cacheSize());
}
constexpr uint16_t TrafficManagementModule::nodeInfoTargetEntries()
{
return kNodeInfoTargetEntries;
}
constexpr uint16_t TrafficManagementModule::nodeInfoIndexMetadataBudgetBytes()
{
return kNodeInfoIndexMetadataBudgetBytes;
}
constexpr uint8_t TrafficManagementModule::nodeInfoTargetOccupancyPercent()
{
return kNodeInfoTargetOccupancyPercent;
}
constexpr uint8_t TrafficManagementModule::nodeInfoBucketSize()
{
return kNodeInfoBucketSize;
}
constexpr uint8_t TrafficManagementModule::nodeInfoTagBits()
{
return kNodeInfoTagBits;
}
constexpr uint16_t TrafficManagementModule::nodeInfoTagMask()
{
return kNodeInfoTagMask;
}
constexpr uint16_t TrafficManagementModule::nodeInfoIndexSlots()
{
return kNodeInfoIndexSlots;
}
constexpr uint16_t TrafficManagementModule::nodeInfoBucketCount()
{
return static_cast<uint16_t>(nodeInfoIndexSlots() / nodeInfoBucketSize());
}
constexpr uint16_t TrafficManagementModule::nodeInfoBucketMask()
{
return nodeInfoBucketCount() > 0 ? nodeInfoBucketCount() - 1 : 0;
}
constexpr uint8_t TrafficManagementModule::nodeInfoBucketHashBits()
{
return detail::log2Floor(nodeInfoBucketCount());
}
static_assert(TRAFFIC_MANAGEMENT_CACHE_SIZE <= UINT16_MAX, "cacheSize() returns uint16_t");
extern TrafficManagementModule *trafficManagementModule;
+6 -1
View File
@@ -4,6 +4,7 @@
#include "configuration.h"
#include "graphics/SharedUIDisplay.h"
#include "graphics/draw/CompassRenderer.h"
#include "meshUtils.h"
#if HAS_SCREEN
#include "gps/RTC.h"
@@ -19,7 +20,7 @@ ProcessMessage WaypointModule::handleReceived(const meshtastic_MeshPacket &mp)
{
#if defined(DEBUG_PORT) && !defined(DEBUG_MUTE)
auto &p = mp.decoded;
LOG_INFO("Received waypoint msg from=0x%0x, id=0x%x, msg=%.*s", mp.from, mp.id, p.payload.size, p.payload.bytes);
LOG_INFO("Received waypoint msg from=0x%08x, id=0x%08x, msg=%.*s", mp.from, mp.id, p.payload.size, p.payload.bytes);
#endif
// We only store/display messages destined for us.
// Keep a copy of the most recent text message.
@@ -92,6 +93,10 @@ void WaypointModule::drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state,
return;
}
// Sanitize before these reach the OLED renderer (defense-in-depth vs PB_VALIDATE_UTF8).
sanitizeUtf8(wp.name, sizeof(wp.name));
sanitizeUtf8(wp.description, sizeof(wp.description));
// Get timestamp info. Will pass as a field to drawColumns
char lastStr[20];
getTimeAgoStr(sinceReceived(&mp), lastStr, sizeof(lastStr));