From c51c01607d8972a4e8a228c6c45edb5e0606dac2 Mon Sep 17 00:00:00 2001 From: Tom <116762865+NomDeTom@users.noreply.github.com> Date: Sun, 21 Jun 2026 02:35:14 +0100 Subject: [PATCH] Traffic Management module: dedup, rate limiting, role-aware policing (#10706) Adds the Traffic Management module (TMM) plus the NodeDB/warm-store and next-hop foundations it builds on: - Unified per-node cache (flat array, 8-bit relative ticks) shared by all features; role-aware throttles for tracker / lost-and-found. - Position deduplication: drop unchanged position rebroadcasts within a configurable interval; precision driven off the channel ceiling (clamped to the public-key max on well-known channels). Enabled by default at 11h. - Per-node rate limiting and unknown-packet filtering (config-driven; a non-zero companion field enables each feature -- no bool toggles). - NodeInfo direct response from cache with role-based hop clamps. - Persistent next-hop overflow store: confirmed hops have no TTL, are seeded from NodeInfoLite at boot, and survive hot-store eviction. - Three-tier sender-role resolution (hot NodeInfoLite -> warm store -> TMM cache). Role is cached write-time (seeded on first track, refreshed from NodeInfo), pins its cache entry like a next-hop hint, and is evicted last. - Warm store caches device role + protected category across reboot/eviction. - PositionModule stationary floor for tracker / lost-and-found. - PSRAM gating for warm/satellite/TMM cache sizes; STM32WL excluded. Protobufs: TrafficManagementConfig trimmed to the five uint32 fields actually used; submodule repointed to protobufs develop. Co-authored-by: Claude Sonnet 4.6 --- protobufs | 2 +- src/mesh/Channels.cpp | 18 + src/mesh/Channels.h | 6 + src/mesh/Default.h | 13 +- src/mesh/NodeDB.cpp | 24 +- src/mesh/NodeDB.h | 5 + src/mesh/PositionPrecision.cpp | 10 +- src/mesh/PositionPrecision.h | 6 + src/mesh/Router.cpp | 19 +- src/mesh/generated/meshtastic/deviceonly.pb.h | 2 +- src/mesh/generated/meshtastic/localonly.pb.h | 2 +- .../generated/meshtastic/module_config.pb.h | 61 +- src/mesh/mesh-pb-constants.h | 42 +- src/modules/AdminModule.cpp | 4 + src/modules/Modules.cpp | 3 +- src/modules/PositionModule.cpp | 54 +- src/modules/PositionModule.h | 14 + src/modules/TrafficManagementModule.cpp | 550 ++++++++------- src/modules/TrafficManagementModule.h | 230 +++---- test/test_position_module/test_main.cpp | 81 +++ test/test_traffic_management/test_main.cpp | 637 ++++++++++++++---- userPrefs.jsonc | 1 + variants/esp32s3/heltec_v4/variant.h | 3 - variants/esp32s3/heltec_v4_r8/variant.h | 3 - variants/esp32s3/station-g2/variant.h | 3 - variants/native/portduino/variant.h | 3 - 26 files changed, 1186 insertions(+), 610 deletions(-) create mode 100644 test/test_position_module/test_main.cpp diff --git a/protobufs b/protobufs index f16a13e43..03314e639 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit f16a13e43393f3c6a88a1da36e75dfc29f448d3e +Subproject commit 03314e63950bda910695eaeb4ba8169dae0425ef diff --git a/src/mesh/Channels.cpp b/src/mesh/Channels.cpp index 457e64461..5b5875971 100644 --- a/src/mesh/Channels.cpp +++ b/src/mesh/Channels.cpp @@ -440,6 +440,24 @@ bool Channels::usesPublicKey(ChannelIndex chIndex) return (psk.size == sizeof(defaultpsk) && memcmp(psk.bytes, defaultpsk, sizeof(defaultpsk) - 1) == 0); } +bool Channels::isWellKnownChannel(ChannelIndex chIndex) +{ + const auto &ch = getByIndex(chIndex); + // Absent (unencrypted) or single-byte PSK — all the well-known key indexes + if (ch.settings.psk.size > 1) + return false; + + const char *name = getName(chIndex); + for (int p = _meshtastic_Config_LoRaConfig_ModemPreset_MIN; p <= _meshtastic_Config_LoRaConfig_ModemPreset_MAX; p++) { + const char *presetName = + DisplayFormatters::getModemPresetDisplayName(static_cast(p), false, true); + // Presets without a display name fall through to "Invalid" — never a match + if (strcmp(presetName, "Invalid") != 0 && strcmp(name, presetName) == 0) + return true; + } + return false; +} + bool Channels::hasDefaultChannel() { // If we don't use a preset or the default frequency slot, or we override the frequency, we don't have a default channel diff --git a/src/mesh/Channels.h b/src/mesh/Channels.h index 9ba84e2ee..7392284f5 100644 --- a/src/mesh/Channels.h +++ b/src/mesh/Channels.h @@ -88,6 +88,12 @@ class Channels // Returns true if this channel's effective key is publicly decryptable (open or well-known/default PSK). bool usesPublicKey(ChannelIndex chIndex); + // Returns true if the channel is "well known": its PSK is absent or a + // single-byte well-known key index, AND its name is any modem-preset + // display name (e.g. a channel named "LongFast" counts even while the + // radio runs MediumFast). Broader than isDefaultChannel, which only + // matches the current preset's name and PSK byte 1. + bool isWellKnownChannel(ChannelIndex chIndex); // Returns true if we can be reached via a channel with the default settings given a region and modem preset bool hasDefaultChannel(); diff --git a/src/mesh/Default.h b/src/mesh/Default.h index d638ca2a9..e802a0324 100644 --- a/src/mesh/Default.h +++ b/src/mesh/Default.h @@ -18,6 +18,9 @@ #define default_telemetry_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60) #define default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60) #define default_broadcast_smart_minimum_interval_secs 5 * 60 +// Floor for our own position broadcasts when stationary (unchanged beyond the broadcast +// precision) or fixed_position: identical positions get deduped by traffic management anyway. +#define default_position_stationary_broadcast_secs (12 * 60 * 60) #define min_default_broadcast_interval_secs IF_ROUTER(ONE_DAY / 2, 60 * 60) #define min_default_broadcast_smart_minimum_interval_secs 5 * 60 #define default_wait_bluetooth_secs IF_ROUTER(1, 60) @@ -34,8 +37,14 @@ enum class TrafficType { POSITION, TELEMETRY }; // Traffic management defaults -#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m) -#define default_traffic_mgmt_position_min_interval_secs (ONE_DAY / 2) // 12 hours between identical positions +#define default_traffic_mgmt_position_precision_bits 19 // ~90m grid cells (±45m) +#define default_traffic_mgmt_position_min_interval_secs (11 * 60 * 60) // 11 hours between identical positions +// Role cap: tracker-role origins may refresh a duplicate position this often (vs the 11h default). +#define default_traffic_mgmt_tracker_position_min_interval_secs (60 * 60) // 1 hour +// Role cap: lost-and-found origins may refresh a duplicate position this often, so a lost +// device updates frequently without flooding. (Quantised to the dedup tick: ~2 ticks.) +// Unlike before, lost-and-found is NOT exempt from the relayed precision clamp. +#define default_traffic_mgmt_lost_and_found_position_min_interval_secs (15 * 60) // 15 minutes // Hop scaling defaults #define default_hop_scaling_min_target_nodes 40 // walk threshold: first hop reaching this cumulative count diff --git a/src/mesh/NodeDB.cpp b/src/mesh/NodeDB.cpp index 5653011fa..27c498e97 100644 --- a/src/mesh/NodeDB.cpp +++ b/src/mesh/NodeDB.cpp @@ -1155,8 +1155,12 @@ static void installTrafficManagementDefaults(meshtastic_LocalModuleConfig &mc) { mc.has_traffic_management = true; mc.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; - mc.traffic_management.enabled = true; - mc.traffic_management.position_dedup_enabled = true; +#if HAS_TRAFFIC_MANAGEMENT + // Position dedup ships enabled at the 11-hour default window on all supported targets. + // STM32WL is excluded at compile time (HAS_TRAFFIC_MANAGEMENT=0 in mesh-pb-constants.h). + // Set position_min_interval_secs=0 at runtime to disable dedup. + mc.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; +#endif } void NodeDB::installDefaultModuleConfig() @@ -3450,6 +3454,20 @@ bool NodeDB::copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out) return false; } +meshtastic_Config_DeviceConfig_Role NodeDB::getNodeRole(NodeNum n) +{ + const meshtastic_NodeInfoLite *info = getMeshNode(n); + if (nodeInfoLiteHasUser(info)) + return info->role; +#if WARM_NODE_COUNT > 0 + // Hot-store miss: fall back to the role the warm tier cached at eviction. + uint8_t role = 0, prot = 0; + if (warmStore.lookupMeta(n, role, prot)) + return static_cast(role); +#endif + return meshtastic_Config_DeviceConfig_Role_CLIENT; +} + /// Find a node in our DB, create an empty NodeInfo if missing meshtastic_NodeInfoLite *NodeDB::getOrCreateMeshNode(NodeNum n) { @@ -3672,6 +3690,8 @@ bool NodeDB::createNewIdentity() myNodeInfo.my_node_num = newNodeNum; meshtastic_NodeInfoLite *info = getOrCreateMeshNode(getNodeNum()); + if (!info) + return false; TypeConversions::CopyUserToNodeInfoLite(info, owner); return true; diff --git a/src/mesh/NodeDB.h b/src/mesh/NodeDB.h index fa9c886f7..b7f3d7f9e 100644 --- a/src/mesh/NodeDB.h +++ b/src/mesh/NodeDB.h @@ -341,6 +341,11 @@ class NodeDB /// tier. Returns false if we don't know a key for n. bool copyPublicKey(NodeNum n, meshtastic_NodeInfoLite_public_key_t &out); + /// Resolve a node's device role — hot store (with user) first, then the role + /// cached in the warm tier, else CLIENT. Lets role-aware policy keep firing for + /// nodes that have aged out of the hot store. + meshtastic_Config_DeviceConfig_Role getNodeRole(NodeNum n); + /// last_heard of a hot-store node, or 0 if absent. Plain scan of meshNodes /// with no allocation side effects (unlike getOrCreateMeshNode). uint32_t hotNodeLastHeard(NodeNum n) const; diff --git a/src/mesh/PositionPrecision.cpp b/src/mesh/PositionPrecision.cpp index b5b29903b..4302531a5 100644 --- a/src/mesh/PositionPrecision.cpp +++ b/src/mesh/PositionPrecision.cpp @@ -28,8 +28,11 @@ uint32_t getPositionPrecisionForChannel(uint8_t channelIndex) return precision; } -static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision) +int32_t truncateCoordinate(int32_t coordinate, uint32_t precision) { + if (precision == 0 || precision >= 32) + return coordinate; + uint32_t coordinateBits = static_cast(coordinate); uint32_t truncated = coordinateBits & (UINT32_MAX << (32 - precision)); @@ -39,6 +42,11 @@ static int32_t truncateCoordinate(int32_t coordinate, uint32_t precision) return static_cast(truncated); } +int32_t truncateCoordinate(int32_t coordinate, uint8_t precision) +{ + return truncateCoordinate(coordinate, static_cast(precision)); +} + void applyPositionPrecision(meshtastic_Position &position, uint32_t precision) { if (precision == 0) { diff --git a/src/mesh/PositionPrecision.h b/src/mesh/PositionPrecision.h index 1955cf80f..8299f50a9 100644 --- a/src/mesh/PositionPrecision.h +++ b/src/mesh/PositionPrecision.h @@ -15,6 +15,12 @@ uint32_t getPositionPrecisionForChannel(const meshtastic_Channel &channel); // Configured precision, clamped to MAX_POSITION_PRECISION_PUBLIC_KEY when the channel's effective key is publicly decryptable. uint32_t getPositionPrecisionForChannel(uint8_t channelIndex); + +// Truncate a single latitude_i/longitude_i to `precision` significant bits, centered in the +// resulting grid cell (stable under GPS jitter). precision 0 or >=32 returns the value unchanged. +// The return is the coordinate (int32_t); the uint8_t overload only narrows the precision arg. +int32_t truncateCoordinate(int32_t coordinate, uint32_t precision); +int32_t truncateCoordinate(int32_t coordinate, uint8_t precision); void applyPositionPrecision(meshtastic_Position &position, uint32_t precision); bool applyPositionPrecision(meshtastic_MeshPacket &packet, uint32_t precision); bool applyPositionPrecisionForChannel(meshtastic_MeshPacket &packet, uint8_t channelIndex); diff --git a/src/mesh/Router.cpp b/src/mesh/Router.cpp index 50d0f2bcc..d52fa49f7 100644 --- a/src/mesh/Router.cpp +++ b/src/mesh/Router.cpp @@ -100,19 +100,12 @@ bool Router::shouldDecrementHopLimit(const meshtastic_MeshPacket *p) return true; } -#if HAS_TRAFFIC_MANAGEMENT - // When router_preserve_hops is enabled, preserve hops for decoded packets that are not - // position or telemetry (those have their own exhaust_hop controls). - if (moduleConfig.has_traffic_management && moduleConfig.traffic_management.enabled && - moduleConfig.traffic_management.router_preserve_hops && p->which_payload_variant == meshtastic_MeshPacket_decoded_tag && - p->decoded.portnum != meshtastic_PortNum_POSITION_APP && p->decoded.portnum != meshtastic_PortNum_TELEMETRY_APP) { - LOG_DEBUG("Router hop preserved: port=%d from=0x%08x (traffic_management)", p->decoded.portnum, getFrom(p)); - if (trafficManagementModule) { - trafficManagementModule->recordRouterHopPreserved(); - } - return false; - } -#endif + // router_preserve_hops: not suitable right now — removed from config until + // the right heuristics for when to preserve vs. exhaust hops are established. + // #if HAS_TRAFFIC_MANAGEMENT + // if (moduleConfig.has_traffic_management && + // moduleConfig.traffic_management.router_preserve_hops && ...) { ... } + // #endif // For subsequent hops, preserve hop_limit only when the previous relay is UNAMBIGUOUSLY a favorite // router. The relay_node byte is just the last byte of a 32-bit node number, so on a dense mesh it diff --git a/src/mesh/generated/meshtastic/deviceonly.pb.h b/src/mesh/generated/meshtastic/deviceonly.pb.h index 474b332a8..5bd116730 100644 --- a/src/mesh/generated/meshtastic/deviceonly.pb.h +++ b/src/mesh/generated/meshtastic/deviceonly.pb.h @@ -452,7 +452,7 @@ extern const pb_msgdesc_t meshtastic_BackupPreferences_msg; /* Maximum encoded size of messages (where known) */ /* meshtastic_NodeDatabase_size depends on runtime parameters */ #define MESHTASTIC_MESHTASTIC_DEVICEONLY_PB_H_MAX_SIZE meshtastic_BackupPreferences_size -#define meshtastic_BackupPreferences_size 2432 +#define meshtastic_BackupPreferences_size 2410 #define meshtastic_ChannelFile_size 718 #define meshtastic_DeviceState_size 1944 #define meshtastic_NodeEnvironmentEntry_size 170 diff --git a/src/mesh/generated/meshtastic/localonly.pb.h b/src/mesh/generated/meshtastic/localonly.pb.h index 27f5ad7bf..3cb4a5a57 100644 --- a/src/mesh/generated/meshtastic/localonly.pb.h +++ b/src/mesh/generated/meshtastic/localonly.pb.h @@ -206,7 +206,7 @@ extern const pb_msgdesc_t meshtastic_LocalModuleConfig_msg; /* Maximum encoded size of messages (where known) */ #define MESHTASTIC_MESHTASTIC_LOCALONLY_PB_H_MAX_SIZE meshtastic_LocalModuleConfig_size #define meshtastic_LocalConfig_size 757 -#define meshtastic_LocalModuleConfig_size 820 +#define meshtastic_LocalModuleConfig_size 798 #ifdef __cplusplus } /* extern "C" */ diff --git a/src/mesh/generated/meshtastic/module_config.pb.h b/src/mesh/generated/meshtastic/module_config.pb.h index 25937e972..c7edfa0aa 100644 --- a/src/mesh/generated/meshtastic/module_config.pb.h +++ b/src/mesh/generated/meshtastic/module_config.pb.h @@ -232,34 +232,23 @@ typedef struct _meshtastic_ModuleConfig_PaxcounterConfig { /* Config for the Traffic Management module. Provides packet inspection and traffic shaping to help reduce channel utilization */ typedef struct _meshtastic_ModuleConfig_TrafficManagementConfig { - /* Master enable for traffic management module */ - bool enabled; - /* Enable position deduplication to drop redundant position broadcasts */ - bool position_dedup_enabled; - /* Number of bits of precision for position deduplication (0-32) */ - uint32_t position_precision_bits; - /* Minimum interval in seconds between position updates from the same node */ + /* Minimum interval in seconds between position updates from the same node. + A non-zero value implicitly enables the suppression window; 0 disables it. */ uint32_t position_min_interval_secs; - /* Enable direct response to NodeInfo requests from local cache */ - bool nodeinfo_direct_response; - /* Minimum hop distance from requestor before responding to NodeInfo requests */ + /* Maximum hop distance from the requestor at which direct NodeInfo responses + are served from the local cache. A non-zero value implicitly enables direct + response; 0 disables it. */ uint32_t nodeinfo_direct_response_max_hops; - /* Enable per-node rate limiting to throttle chatty nodes */ - bool rate_limit_enabled; - /* Time window in seconds for rate limiting calculations */ + /* Time window in seconds for per-node rate limiting. + A non-zero value implicitly enables rate limiting; 0 disables it. */ uint32_t rate_limit_window_secs; - /* Maximum packets allowed per node within the rate limit window */ + /* Maximum packets allowed per node within the rate limit window. + A non-zero value implicitly enables rate limiting; 0 disables it. */ uint32_t rate_limit_max_packets; - /* Enable dropping of unknown/undecryptable packets per rate_limit_window_secs */ - bool drop_unknown_enabled; - /* Number of unknown packets before dropping from a node */ + /* Maximum unknown/undecryptable packets per rate window before the source + is dropped. A non-zero value implicitly enables unknown-packet filtering; + 0 disables it. */ uint32_t unknown_packet_threshold; - /* Set hop_limit to 0 for relayed telemetry broadcasts (own packets unaffected) */ - bool exhaust_hop_telemetry; - /* Set hop_limit to 0 for relayed position broadcasts (own packets unaffected) */ - bool exhaust_hop_position; - /* Preserve hop_limit for router-to-router traffic */ - bool router_preserve_hops; } meshtastic_ModuleConfig_TrafficManagementConfig; /* Serial Config */ @@ -588,7 +577,7 @@ extern "C" { #define meshtastic_ModuleConfig_DetectionSensorConfig_init_default {0, 0, 0, 0, "", 0, _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_MIN, 0} #define meshtastic_ModuleConfig_AudioConfig_init_default {0, 0, _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MIN, 0, 0, 0, 0} #define meshtastic_ModuleConfig_PaxcounterConfig_init_default {0, 0, 0, 0} -#define meshtastic_ModuleConfig_TrafficManagementConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} +#define meshtastic_ModuleConfig_TrafficManagementConfig_init_default {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_SerialConfig_init_default {0, 0, 0, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MIN, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MIN, 0} #define meshtastic_ModuleConfig_ExternalNotificationConfig_init_default {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StoreForwardConfig_init_default {0, 0, 0, 0, 0, 0} @@ -607,7 +596,7 @@ extern "C" { #define meshtastic_ModuleConfig_DetectionSensorConfig_init_zero {0, 0, 0, 0, "", 0, _meshtastic_ModuleConfig_DetectionSensorConfig_TriggerType_MIN, 0} #define meshtastic_ModuleConfig_AudioConfig_init_zero {0, 0, _meshtastic_ModuleConfig_AudioConfig_Audio_Baud_MIN, 0, 0, 0, 0} #define meshtastic_ModuleConfig_PaxcounterConfig_init_zero {0, 0, 0, 0} -#define meshtastic_ModuleConfig_TrafficManagementConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} +#define meshtastic_ModuleConfig_TrafficManagementConfig_init_zero {0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_SerialConfig_init_zero {0, 0, 0, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Baud_MIN, 0, _meshtastic_ModuleConfig_SerialConfig_Serial_Mode_MIN, 0} #define meshtastic_ModuleConfig_ExternalNotificationConfig_init_zero {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0} #define meshtastic_ModuleConfig_StoreForwardConfig_init_zero {0, 0, 0, 0, 0, 0} @@ -656,20 +645,11 @@ extern "C" { #define meshtastic_ModuleConfig_PaxcounterConfig_paxcounter_update_interval_tag 2 #define meshtastic_ModuleConfig_PaxcounterConfig_wifi_threshold_tag 3 #define meshtastic_ModuleConfig_PaxcounterConfig_ble_threshold_tag 4 -#define meshtastic_ModuleConfig_TrafficManagementConfig_enabled_tag 1 -#define meshtastic_ModuleConfig_TrafficManagementConfig_position_dedup_enabled_tag 2 -#define meshtastic_ModuleConfig_TrafficManagementConfig_position_precision_bits_tag 3 #define meshtastic_ModuleConfig_TrafficManagementConfig_position_min_interval_secs_tag 4 -#define meshtastic_ModuleConfig_TrafficManagementConfig_nodeinfo_direct_response_tag 5 #define meshtastic_ModuleConfig_TrafficManagementConfig_nodeinfo_direct_response_max_hops_tag 6 -#define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_enabled_tag 7 #define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_window_secs_tag 8 #define meshtastic_ModuleConfig_TrafficManagementConfig_rate_limit_max_packets_tag 9 -#define meshtastic_ModuleConfig_TrafficManagementConfig_drop_unknown_enabled_tag 10 #define meshtastic_ModuleConfig_TrafficManagementConfig_unknown_packet_threshold_tag 11 -#define meshtastic_ModuleConfig_TrafficManagementConfig_exhaust_hop_telemetry_tag 12 -#define meshtastic_ModuleConfig_TrafficManagementConfig_exhaust_hop_position_tag 13 -#define meshtastic_ModuleConfig_TrafficManagementConfig_router_preserve_hops_tag 14 #define meshtastic_ModuleConfig_SerialConfig_enabled_tag 1 #define meshtastic_ModuleConfig_SerialConfig_echo_tag 2 #define meshtastic_ModuleConfig_SerialConfig_rxd_tag 3 @@ -867,20 +847,11 @@ X(a, STATIC, SINGULAR, INT32, ble_threshold, 4) #define meshtastic_ModuleConfig_PaxcounterConfig_DEFAULT NULL #define meshtastic_ModuleConfig_TrafficManagementConfig_FIELDLIST(X, a) \ -X(a, STATIC, SINGULAR, BOOL, enabled, 1) \ -X(a, STATIC, SINGULAR, BOOL, position_dedup_enabled, 2) \ -X(a, STATIC, SINGULAR, UINT32, position_precision_bits, 3) \ X(a, STATIC, SINGULAR, UINT32, position_min_interval_secs, 4) \ -X(a, STATIC, SINGULAR, BOOL, nodeinfo_direct_response, 5) \ X(a, STATIC, SINGULAR, UINT32, nodeinfo_direct_response_max_hops, 6) \ -X(a, STATIC, SINGULAR, BOOL, rate_limit_enabled, 7) \ X(a, STATIC, SINGULAR, UINT32, rate_limit_window_secs, 8) \ X(a, STATIC, SINGULAR, UINT32, rate_limit_max_packets, 9) \ -X(a, STATIC, SINGULAR, BOOL, drop_unknown_enabled, 10) \ -X(a, STATIC, SINGULAR, UINT32, unknown_packet_threshold, 11) \ -X(a, STATIC, SINGULAR, BOOL, exhaust_hop_telemetry, 12) \ -X(a, STATIC, SINGULAR, BOOL, exhaust_hop_position, 13) \ -X(a, STATIC, SINGULAR, BOOL, router_preserve_hops, 14) +X(a, STATIC, SINGULAR, UINT32, unknown_packet_threshold, 11) #define meshtastic_ModuleConfig_TrafficManagementConfig_CALLBACK NULL #define meshtastic_ModuleConfig_TrafficManagementConfig_DEFAULT NULL @@ -1053,7 +1024,7 @@ extern const pb_msgdesc_t meshtastic_RemoteHardwarePin_msg; #define meshtastic_ModuleConfig_StoreForwardConfig_size 24 #define meshtastic_ModuleConfig_TAKConfig_size 4 #define meshtastic_ModuleConfig_TelemetryConfig_size 50 -#define meshtastic_ModuleConfig_TrafficManagementConfig_size 52 +#define meshtastic_ModuleConfig_TrafficManagementConfig_size 30 #define meshtastic_ModuleConfig_size 227 #define meshtastic_RemoteHardwarePin_size 21 diff --git a/src/mesh/mesh-pb-constants.h b/src/mesh/mesh-pb-constants.h index 28dbe3bb7..375be3ae5 100644 --- a/src/mesh/mesh-pb-constants.h +++ b/src/mesh/mesh-pb-constants.h @@ -99,23 +99,20 @@ static inline int get_max_num_nodes() #elif defined(ARCH_PORTDUINO) #define MAX_NUM_NODES 250 // native host: no flash/RAM constraint; match the ESP32-S3 top tier #else -#define MAX_NUM_NODES 120 // nRF52840 (28 KB LittleFS) and generic ESP32 +#define MAX_NUM_NODES 120 // nRF52840 and generic ESP32 (inc. ESP32C3 etc.) #endif // platform #endif // MAX_NUM_NODES /// Per-map cap (position/telemetry/environment/status): only the freshest /// MAX_SATELLITE_NODES nodes keep satellite payloads, the rest just the -/// NodeInfoLite header. RAM-bound: the four maps live in internal SRAM (not -/// PSRAM). PSRAM-equipped ESP32-S3 (and native) keep the full 250; other ESP32 -/// (no-PSRAM, incl. S3) get 80 -- ~32 KB worst case, affordable now the warm -/// tier is trimmed; nRF52840 and other tight parts stay at 40. +/// NodeInfoLite header. RAM-bound (the maps are internal-SRAM, not PSRAM), so +/// flash-rich hosts get a cap >= their hot store (satellites for every node, as +/// before the cap existed) while constrained parts stay at 40. #ifndef MAX_SATELLITE_NODES -#if defined(ARCH_PORTDUINO) || (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) -#define MAX_SATELLITE_NODES 250 // native / PSRAM-equipped ESP32-S3 -#elif defined(ARCH_ESP32) -#define MAX_SATELLITE_NODES 80 // no-PSRAM ESP32 (incl. ESP32-S3) +#if (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO) +#define MAX_SATELLITE_NODES 250 #else -#define MAX_SATELLITE_NODES 40 // nRF52840 (28 KB LittleFS) and other constrained parts +#define MAX_SATELLITE_NODES 40 // nRF52840, generic ESP32, and ESP32-S3 without PSRAM #endif // platform #endif // MAX_SATELLITE_NODES @@ -130,14 +127,12 @@ static inline int get_max_num_nodes() // architecture.h via configuration.h) isn't defined this early in every include // chain. Backed by the raw-flash ring below LittleFS — see WarmNodeStore.h. #define WARM_NODE_COUNT 200 -#elif defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM) -#define WARM_NODE_COUNT 2000 // ESP32-S3 with PSRAM (external); warm.dat ~80 KB +#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO) +#define WARM_NODE_COUNT 2000 // PSRAM-equipped ESP32-S3 / native host; warm.dat ~80 KB #else -// generic ESP32 and no-PSRAM ESP32-S3: ~12.5 KB in internal heap (calloc fallback in -// WarmNodeStore), leaving room for the BLE controller. PSRAM-equipped S3 takes the 2000 case above. -#define WARM_NODE_COUNT 320 -#endif // platform -#endif // WARM_NODE_COUNT +#define WARM_NODE_COUNT 320 // Generic ESP32, ESP32-S3 without PSRAM, ESP32C3 etc. +#endif // platform +#endif // WARM_NODE_COUNT /// Max number of channels allowed #define MAX_NUM_CHANNELS (member_size(meshtastic_ChannelFile, channels) / member_size(meshtastic_ChannelFile, channels[0])) @@ -157,18 +152,21 @@ static inline int get_max_num_nodes() #ifdef ARCH_STM32WL #define HAS_VARIABLE_HOPS 0 #endif + #ifndef HAS_VARIABLE_HOPS #define HAS_VARIABLE_HOPS 1 #endif // Cache size for traffic management (number of nodes to track) -// Can be overridden per-variant based on available memory +// Can be overridden per-variant by defining before this header is included. #ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE -#if HAS_TRAFFIC_MANAGEMENT -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000 -#else +#if !HAS_TRAFFIC_MANAGEMENT #define TRAFFIC_MANAGEMENT_CACHE_SIZE 0 -#endif // HAS_TRAFFIC_MANAGEMENT +#elif (defined(CONFIG_IDF_TARGET_ESP32S3) && defined(BOARD_HAS_PSRAM)) || defined(ARCH_PORTDUINO) +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 // PSRAM-equipped ESP32-S3 / native host +#else +#define TRAFFIC_MANAGEMENT_CACHE_SIZE 1000 // Generic ESP32, ESP32-S3 without PSRAM +#endif #endif // TRAFFIC_MANAGEMENT_CACHE_SIZE /// helper function for encoding a record as a protobuf, any failures to encode are fatal and we will panic diff --git a/src/modules/AdminModule.cpp b/src/modules/AdminModule.cpp index af4e3f969..8bcfcd878 100644 --- a/src/modules/AdminModule.cpp +++ b/src/modules/AdminModule.cpp @@ -481,6 +481,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta 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%x refused: protected-node cap", r->set_favorite_node); } } break; @@ -508,6 +510,8 @@ bool AdminModule::handleReceivedProtobuf(const meshtastic_MeshPacket &mp, meshta 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%x refused: protected-node cap", r->set_ignored_node); } } break; diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 5cc94a68f..89c5b9d43 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -128,8 +128,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 diff --git a/src/modules/PositionModule.cpp b/src/modules/PositionModule.cpp index ed7215a98..1a0eed933 100644 --- a/src/modules/PositionModule.cpp +++ b/src/modules/PositionModule.cpp @@ -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"); diff --git a/src/modules/PositionModule.h b/src/modules/PositionModule.h index d0a4d4603..45535e1b2 100644 --- a/src/modules/PositionModule.h +++ b/src/modules/PositionModule.h @@ -38,6 +38,14 @@ class PositionModule : public ProtobufModule, 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, 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; diff --git a/src/modules/TrafficManagementModule.cpp b/src/modules/TrafficManagementModule.cpp index a89940f2e..95e206e11 100644 --- a/src/modules/TrafficManagementModule.cpp +++ b/src/modules/TrafficManagementModule.cpp @@ -2,9 +2,11 @@ #if HAS_TRAFFIC_MANAGEMENT +#include "Channels.h" #include "Default.h" #include "MeshService.h" #include "NodeDB.h" +#include "PositionPrecision.h" #include "Router.h" #include "TypeConversions.h" #include "airtime.h" @@ -13,6 +15,7 @@ #include "mesh-pb-constants.h" #include "meshUtils.h" #include +#include #include #define TM_LOG_DEBUG(fmt, ...) LOG_DEBUG("[TM] " fmt, ##__VA_ARGS__) @@ -27,7 +30,6 @@ namespace { constexpr uint32_t kMaintenanceIntervalMs = 60 * 1000UL; // Cache cleanup interval -constexpr uint32_t kUnknownResetMs = 60 * 1000UL; // Unknown packet window // NodeInfo direct response: enforced maximum hops by device role // Both use maxHops logic (respond when hopsAway <= threshold) @@ -47,6 +49,17 @@ uint32_t secsToMs(uint32_t secs) return static_cast(ms); } +// Advertised role of the originating node (from NodeDB), or CLIENT (no exception) if unknown. +// Position filtering grants two role exceptions: trackers may refresh duplicates hourly, and +// lost-and-found is throttled only to the shortest dedup window. Both are still subject to +// the channel-precision ceiling in alterReceived(). +meshtastic_Config_DeviceConfig_Role originRole(NodeNum from) +{ + // Resolve via NodeDB: hot store (with user) → warm-tier cached role → CLIENT. The + // warm fallback keeps role exceptions firing for trackers/etc. aged out of the hot store. + return nodeDB ? nodeDB->getNodeRole(from) : meshtastic_Config_DeviceConfig_Role_CLIENT; +} + /** * Clamp precision to a valid dedup range. * Invalid values use the module default precision. @@ -64,57 +77,6 @@ uint8_t sanitizePositionPrecision(uint8_t precision) return 32; } -/** - * Check if a timestamp is within a time window. - * Handles wrap-around correctly using unsigned subtraction. - */ -bool isWithinWindow(uint32_t nowMs, uint32_t startMs, uint32_t intervalMs) -{ - if (intervalMs == 0 || startMs == 0) - return false; - return (nowMs - startMs) < intervalMs; -} - -/** - * Slide an 8-bit relative timestamp back by a wall-clock slab during epoch rebase. - * - * Entries older than the slab clamp to 0 (then reclaimed by the maintenance sweep); - * live entries keep their reconstructed age minus a sub-tick remainder. Each field - * slides by its own resolution's worth of ticks, so a single slab covers all three. - */ -inline void slideRelativeTime(uint8_t &ticks, uint32_t slabMs, uint16_t resolutionSecs) -{ - if (ticks == 0 || resolutionSecs == 0) - return; - uint32_t dec = slabMs / (static_cast(resolutionSecs) * 1000UL); - ticks = (ticks > dec) ? static_cast(ticks - dec) : 0; -} - -/** - * Truncate lat/lon to specified precision for position deduplication. - * - * The truncation works by masking off lower bits and rounding to the center - * of the resulting grid cell. This creates a stable truncated value even - * when GPS jitter causes small coordinate changes. - * - * @param value Raw latitude_i or longitude_i from position - * @param precision Number of significant bits to keep (0-32) - * @return Truncated and centered coordinate value - */ -int32_t truncateLatLon(int32_t value, uint8_t precision) -{ - if (precision == 0 || precision >= 32) - return value; - - // Create mask to zero out lower bits - uint32_t mask = UINT32_MAX << (32 - precision); - uint32_t truncated = static_cast(value) & mask; - - // Add half the truncation step to center in the grid cell - truncated += (1u << (31 - precision)); - return static_cast(truncated); -} - /** * Saturating increment for uint8_t counters. * Prevents overflow by capping at UINT8_MAX (255). @@ -176,23 +138,10 @@ TrafficManagementModule::TrafficManagementModule() : MeshModule("TrafficManageme encryptedOk = true; // Can process encrypted packets stats = meshtastic_TrafficManagementStats_init_zero; - // Initialize rolling epoch for relative timestamps - cacheEpochMs = millis(); - - // Calculate adaptive time resolutions from config (config changes require reboot) - // Resolution = max(60, min(339, interval/2)) for ~24 hour range with good precision - posTimeResolution = calcTimeResolution(Default::getConfiguredOrDefault( - moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); - rateTimeResolution = calcTimeResolution(moduleConfig.traffic_management.rate_limit_window_secs); - unknownTimeResolution = calcTimeResolution(kUnknownResetMs / 1000); // ~5 min default - const auto &cfg = moduleConfig.traffic_management; - TM_LOG_INFO("Enabled: pos_dedup=%d nodeinfo_resp=%d rate_limit=%d drop_unknown=%d exhaust_telem=%d exhaust_pos=%d " - "preserve_hops=%d", - cfg.position_dedup_enabled, cfg.nodeinfo_direct_response, cfg.rate_limit_enabled, cfg.drop_unknown_enabled, - cfg.exhaust_hop_telemetry, cfg.exhaust_hop_position, cfg.router_preserve_hops); - TM_LOG_DEBUG("Time resolutions: pos=%us, rate=%us, unknown=%us", posTimeResolution, rateTimeResolution, - unknownTimeResolution); + TM_LOG_INFO("Config: nodeinfo_max_hops=%u rate_window=%us rate_max=%u unknown_thresh=%u pos_interval=%us", + cfg.nodeinfo_direct_response_max_hops, cfg.rate_limit_window_secs, cfg.rate_limit_max_packets, + cfg.unknown_packet_threshold, cfg.position_min_interval_secs); // Allocate unified cache (10 bytes/entry for all platforms) #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 @@ -278,9 +227,9 @@ void TrafficManagementModule::resetStats() void TrafficManagementModule::recordRouterHopPreserved() { - if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) - return; - incrementStat(&stats.router_hops_preserved); + // router_preserve_hops: not suitable right now — removed from config until + // the right heuristic for when to preserve vs. exhaust is clearer. + (void)stats.router_hops_preserved; } void TrafficManagementModule::incrementStat(uint32_t *field) @@ -313,6 +262,18 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(N #endif } +int TrafficManagementModule::peekCachedRole(NodeNum node) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 + (void)node; + return -1; +#else + concurrency::LockGuard guard(&cacheLock); + const UnifiedCacheEntry *entry = findEntry(node); + return entry ? static_cast(entry->getCachedRole()) : -1; +#endif +} + /** * Find or create an entry for the given node. * @@ -326,6 +287,51 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findEntry(N * @param isNew Set to true if a new entry was created * @return Pointer to entry, or nullptr if the cache is unavailable */ +// Sender-role resolution for the position hot path. The tier-3 cache is authoritative +// here and is kept fresh by updateCachedRoleFromNodeInfo() — i.e. updated at the same +// time NodeDB learns a role, not re-derived on every packet. We only fall back to a +// NodeDB scan (tiers 1+2) the first time we start tracking a node, to seed the cache so +// a resident special-role node is correct from its very first position. Thereafter the +// read is O(1) and survives the node aging out of both NodeDB stores. +meshtastic_Config_DeviceConfig_Role TrafficManagementModule::resolveSenderRole(NodeNum from, UnifiedCacheEntry *entry, bool isNew) +{ + if (!entry) + return originRole(from); + if (isNew) { + // First time tracking this node: seed tier 3 from NodeDB (hot → warm). Stores + // CLIENT (0) too, which simply reads back as "no exception". + const meshtastic_Config_DeviceConfig_Role role = originRole(from); + entry->setCachedRole(static_cast(std::min(15, static_cast(role)))); + return role; + } + // Established entry: trust the cached role (refreshed on NodeInfo). No NodeDB scan. + return static_cast(entry->getCachedRole()); +} + +// Refresh the tier-3 role cache from an observed NodeInfo — the same event that updates +// NodeDB's role — so role changes (including demotion back to CLIENT) are picked up +// without scanning NodeDB on the position hot path. Role is read straight from the +// packet's User payload (authoritative regardless of module ordering). Only updates nodes +// we already track (findEntry, no create) so NodeInfo from non-position nodes can't pollute +// the cache; the role rides along with the node's existing position/rate/unknown state. +void TrafficManagementModule::updateCachedRoleFromNodeInfo(const meshtastic_MeshPacket &mp) +{ +#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 + if (mp.decoded.payload.size == 0) + return; + meshtastic_User user = meshtastic_User_init_zero; + if (!pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &user)) + return; + + concurrency::LockGuard guard(&cacheLock); + UnifiedCacheEntry *entry = findEntry(getFrom(&mp)); + if (entry) + entry->setCachedRole(static_cast(std::min(15, static_cast(user.role)))); +#else + (void)mp; +#endif +} + TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreateEntry(NodeNum node, bool *isNew) { #if TRAFFIC_MANAGEMENT_CACHE_SIZE == 0 @@ -341,7 +347,7 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat UnifiedCacheEntry *empty = nullptr; UnifiedCacheEntry *victim = nullptr; - bool victimHasHop = true; + bool leastPreferredVictim = true; uint8_t victimRecency = UINT8_MAX; for (uint16_t i = 0; i < cacheSize(); i++) { @@ -355,15 +361,28 @@ TrafficManagementModule::UnifiedCacheEntry *TrafficManagementModule::findOrCreat } if (empty) continue; // an empty slot beats any victim; stop scoring - const bool hasHop = e.next_hop != 0; - uint8_t recency = e.pos_time; - if (e.rate_time > recency) - recency = e.rate_time; - if (e.unknown_time > recency) - recency = e.unknown_time; - if (!victim || (hasHop == victimHasHop ? recency < victimRecency : !hasHop)) { + // "Preferred" entries are evicted last: a confirmed next-hop hint (routing overflow + // store) or a cached special (non-CLIENT) role (tracker / lost-and-found / router). + // Both are the long-tail state this cache exists to retain. + const bool preferred = e.next_hop != 0 || e.getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT; + // Age in pos-ticks (8-bit modular, wraps correctly). Entries with no + // pos state (pos_time==0) score as maximally old (age=currentPosTick()). + const uint8_t nowPosTick = currentPosTick(); + const uint8_t posAge = static_cast(nowPosTick - e.pos_time); + // Blend in rate/unknown ages scaled to pos-tick units (coarser = conservative). + const uint8_t rateAgePosScale = + static_cast(static_cast((currentRateTick() - e.getRateTime()) & 0x0F) * 5 / 3); + const uint8_t unknownAgePosScale = + static_cast(static_cast((currentUnknownTick() - e.getUnknownTime()) & 0x0F) / 6); + uint8_t recencyAge = posAge; + if (e.getRateCount() != 0 && rateAgePosScale > recencyAge) + recencyAge = rateAgePosScale; + if (e.getUnknownCount() != 0 && unknownAgePosScale > recencyAge) + recencyAge = unknownAgePosScale; + const uint8_t recency = static_cast(UINT8_MAX - recencyAge); + if (!victim || (preferred == leastPreferredVictim ? recency < victimRecency : !preferred)) { victim = &e; - victimHasHop = hasHop; + leastPreferredVictim = preferred; victimRecency = recency; } } @@ -417,7 +436,7 @@ TrafficManagementModule::NodeInfoPayloadEntry *TrafficManagementModule::findOrCr NodeInfoPayloadEntry *empty = nullptr; NodeInfoPayloadEntry *lru = nullptr; uint32_t lruAge = 0; - const uint32_t now = millis(); + const uint32_t now = clockMs(); for (uint16_t i = 0; i < nodeInfoTargetEntries(); i++) { NodeInfoPayloadEntry &e = nodeInfoPayload[i]; @@ -493,7 +512,7 @@ void TrafficManagementModule::cacheNodeInfoPacket(const meshtastic_MeshPacket &m // richer context than "just the user protobuf" when PSRAM is present. // This path is intentionally independent from NodeInfoModule/NodeDB. entry->user = user; - entry->lastObservedMs = millis(); + entry->lastObservedMs = clockMs(); entry->lastObservedRxTime = mp.rx_time; entry->sourceChannel = mp.channel; entry->hasDecodedBitfield = mp.decoded.has_bitfield; @@ -570,11 +589,11 @@ void TrafficManagementModule::clearNextHop(NodeNum dest) #endif } -void TrafficManagementModule::preloadNextHopsFromNodeDB() +bool TrafficManagementModule::preloadNextHopsFromNodeDB() { #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 if (!cache || !nodeDB) - return; + return false; // prerequisites not ready yet — caller should retry on a later pass uint16_t seeded = 0; concurrency::LockGuard guard(&cacheLock); @@ -594,6 +613,9 @@ void TrafficManagementModule::preloadNextHopsFromNodeDB() } TM_LOG_INFO("Preloaded %u next-hop hints from NodeDB", static_cast(seeded)); + return true; +#else + return true; // nothing to preload on a cache-less build; don't keep retrying #endif } @@ -601,59 +623,11 @@ void TrafficManagementModule::preloadNextHopsFromNodeDB() // Epoch Management // ============================================================================= -/** - * Reset the timestamp epoch when relative offsets approach overflow. - * - * Called when epoch age exceeds ~19 hours (approaching 8-bit minute overflow). - * Invalidates all cached per-node traffic state. - */ -void TrafficManagementModule::resetEpoch(uint32_t nowMs) +void TrafficManagementModule::flushCache() { #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - TM_LOG_DEBUG("Resetting cache epoch"); - cacheEpochMs = nowMs; - - // Full flush avoids stale dedup identity/counters surviving epoch rollover. + TM_LOG_DEBUG("Flushing cache"); memset(cache, 0, static_cast(cacheSize()) * sizeof(UnifiedCacheEntry)); -#else - (void)nowMs; -#endif -} - -/** - * Sliding-epoch rebase — preserve cached state past the 8-bit timestamp horizon. - * - * Instead of flushing the whole cache when offsets approach overflow, advance the - * epoch by a fixed slab and shift every live entry's relative timestamps back by - * the same wall-clock amount. A valid entry's window is only a handful of ticks - * wide (TTL auto-scales with resolution), so live entries comfortably survive; - * already-expired entries clamp to 0 and are reclaimed by the maintenance sweep in - * the same locked pass. Reconstructed absolute time is preserved (minus a sub-tick - * remainder), so in-flight TTL checks remain correct across the rebase. - * - * Caller must hold cacheLock. - */ -void TrafficManagementModule::rebaseEpoch(uint32_t nowMs) -{ -#if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - (void)nowMs; - - // Slab stays well below the 200-tick reset threshold so a single rebase drops - // the offset back into range (~200 -> ~72 ticks) while live entries survive. - const uint32_t slabMs = 128UL * maxResolution() * 1000UL; - cacheEpochMs += slabMs; - - TM_LOG_DEBUG("Rebasing cache epoch by %lus", static_cast(slabMs / 1000UL)); - - for (uint16_t i = 0; i < cacheSize(); i++) { - if (cache[i].node == 0) - continue; - slideRelativeTime(cache[i].pos_time, slabMs, posTimeResolution); - slideRelativeTime(cache[i].rate_time, slabMs, rateTimeResolution); - slideRelativeTime(cache[i].unknown_time, slabMs, unknownTimeResolution); - } -#else - (void)nowMs; #endif } @@ -697,7 +671,12 @@ uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncate uint8_t latBits = (static_cast(lat_truncated) >> shift) & ((1u << bitsToTake) - 1); uint8_t lonBits = (static_cast(lon_truncated) >> shift) & ((1u << bitsToTake) - 1); - return static_cast((latBits << 4) | lonBits); + const uint8_t fp = static_cast((latBits << 4) | lonBits); + // 0 is the "no position seen" sentinel for pos_fingerprint, so a real position that happens to + // hash to 0 must not collide with it (otherwise its duplicates would never dedup). Remap 0 -> 0xFF, + // mirroring NodeDB::getLastByteOfNodeNum()'s 0 -> 0xFF idiom. Cost: the 0x00 bucket merges into + // 0xFF (one extra collision in 256 — negligible; the fingerprint already collides every 16 cells). + return fp ? fp : 0xFF; } // ============================================================================= @@ -712,7 +691,7 @@ uint8_t TrafficManagementModule::computePositionFingerprint(int32_t lat_truncate // force hop_limit=0 on the rebroadcast copy, allowing one final relay hop. ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPacket &mp) { - if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + if (!moduleConfig.has_traffic_management) return ProcessMessage::CONTINUE; ignoreRequest = false; @@ -722,7 +701,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack incrementStat(&stats.packets_inspected); const auto &cfg = moduleConfig.traffic_management; - const uint32_t nowMs = millis(); + const uint32_t nowMs = TrafficManagementModule::clockMs(); // ------------------------------------------------------------------------- // Undecoded Packet Handling @@ -731,7 +710,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // a misbehaving node. Track and optionally drop repeat offenders. if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) { - if (cfg.drop_unknown_enabled && cfg.unknown_packet_threshold > 0) { + if (cfg.unknown_packet_threshold > 0) { if (shouldDropUnknown(&mp, nowMs)) { logAction("drop", &mp, "unknown"); incrementStat(&stats.unknown_packet_drops); @@ -742,9 +721,12 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack return ProcessMessage::CONTINUE; } - // Learn NodeInfo payloads into the dedicated PSRAM cache. - if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) + // Learn NodeInfo payloads into the dedicated PSRAM cache, and refresh the tier-3 + // role cache for any node we already track (keeps the dedup role exception current). + if (mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP) { cacheNodeInfoPacket(mp); + updateCachedRoleFromNodeInfo(mp); + } // ------------------------------------------------------------------------- // NodeInfo Direct Response @@ -754,8 +736,8 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // STOP prevents the request from being rebroadcast toward the target node, // and our cached response is sent back to the requestor with hop_limit=0. - if (cfg.nodeinfo_direct_response && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && mp.decoded.want_response && - !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { + if (cfg.nodeinfo_direct_response_max_hops > 0 && mp.decoded.portnum == meshtastic_PortNum_NODEINFO_APP && + mp.decoded.want_response && !isBroadcast(mp.to) && !isToUs(&mp) && !isFromUs(&mp)) { if (shouldRespondToNodeInfo(&mp, true)) { meshtastic_User requester = meshtastic_User_init_zero; if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_User_msg, &requester)) { @@ -776,7 +758,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // GPS jitter within the configured precision. if (!isFromUs(&mp) && !isToUs(&mp)) { - if (cfg.position_dedup_enabled && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) { + if (channels.isWellKnownChannel(mp.channel) && mp.decoded.portnum == meshtastic_PortNum_POSITION_APP) { meshtastic_Position pos = meshtastic_Position_init_zero; if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &pos)) { if (shouldDropPosition(&mp, &pos, nowMs)) { @@ -794,7 +776,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack // Throttle nodes sending too many packets within a time window. // Excludes routing and admin packets which are essential for mesh operation. - if (cfg.rate_limit_enabled && cfg.rate_limit_window_secs > 0 && cfg.rate_limit_max_packets > 0) { + if (cfg.rate_limit_window_secs > 0 && cfg.rate_limit_max_packets > 0) { if (mp.decoded.portnum != meshtastic_PortNum_ROUTING_APP && mp.decoded.portnum != meshtastic_PortNum_ADMIN_APP) { if (isRateLimited(mp.from, nowMs)) { logAction("drop", &mp, "rate-limit"); @@ -811,7 +793,7 @@ ProcessMessage TrafficManagementModule::handleReceived(const meshtastic_MeshPack void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) { - if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + if (!moduleConfig.has_traffic_management) return; if (mp.which_payload_variant != meshtastic_MeshPacket_decoded_tag) @@ -820,40 +802,45 @@ void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) if (isFromUs(&mp)) return; - // ------------------------------------------------------------------------- - // Relayed Broadcast Hop Exhaustion - // ------------------------------------------------------------------------- - // For relayed telemetry or position broadcasts from other nodes, optionally - // set hop_limit=0 so they don't propagate further through the mesh. + // exhaust_hop_telemetry / exhaust_hop_position / router_preserve_hops: + // not suitable right now — the right heuristics for when to exhaust or + // preserve hops need more field data before we expose them as config knobs. + // exhaustRequested stays false; perhapsRebroadcast() behaves normally. - const auto &cfg = moduleConfig.traffic_management; - const bool isTelemetry = mp.decoded.portnum == meshtastic_PortNum_TELEMETRY_APP; const bool isPosition = mp.decoded.portnum == meshtastic_PortNum_POSITION_APP; - // Only exhaust telemetry hops when channel is actually congested, mirroring the same - // airtime checks that gate self-generated telemetry in the telemetry modules. - const bool channelBusy = airTime && (!airTime->isTxAllowedChannelUtil(true) || !airTime->isTxAllowedAirUtil()); - const bool shouldExhaust = - ((channelBusy && isTelemetry && cfg.exhaust_hop_telemetry) || (isPosition && cfg.exhaust_hop_position)); - if (!shouldExhaust || !isBroadcast(mp.to)) - return; - - if (mp.hop_limit > 0) { - const char *reason = isTelemetry ? "exhaust-hop-telemetry" : "exhaust-hop-position"; - logAction("exhaust", &mp, reason); - // Adjust hop_start so downstream nodes compute correct hopsAway (hop_start - hop_limit). - // Without this, hop_limit=0 with original hop_start would show inflated hopsAway. - mp.hop_start = mp.hop_start - mp.hop_limit + 1; - mp.hop_limit = 0; - // Signal perhapsRebroadcast() to allow one final relay with hop_limit=0. - // Without this flag, perhapsRebroadcast() would skip the packet since hop_limit==0. - // The packet-scoped flag is checked in NextHopRouter::perhapsRebroadcast() - // and forces tosend->hop_limit=0, ensuring no further propagation beyond the - // next node. - exhaustRequested = true; - exhaustRequestedFrom = getFrom(&mp); - exhaustRequestedId = mp.id; - incrementStat(&stats.hop_exhausted_packets); + // ------------------------------------------------------------------------- + // Relayed Position Precision Clamp + // ------------------------------------------------------------------------- + // Clamp relayed position broadcasts to the channel's configured precision + // ceiling. Guards against forwarding more-precise coordinates than the + // channel is intended to carry (e.g. a LongFast channel set to 13-bit / + // ~1.5 km). chanPrec==0 means position sharing is disabled on the channel; + // skip — not our job to zero positions on relay. + // Ham mode (owner.is_licensed) is exempt. Lost-and-found is NOT exempt — its relayed + // positions get the same precision clamp as any node. + // Compile USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS to extend to private channels. + if (!owner.is_licensed && isPosition && isBroadcast(mp.to)) { +#ifdef USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS + const bool shouldClamp = true; +#else + const bool shouldClamp = channels.isWellKnownChannel(mp.channel); +#endif + if (shouldClamp) { + const uint32_t chanPrec = getPositionPrecisionForChannel(mp.channel); + if (chanPrec > 0) { + meshtastic_Position pos = meshtastic_Position_init_default; + if (pb_decode_from_bytes(mp.decoded.payload.bytes, mp.decoded.payload.size, &meshtastic_Position_msg, &pos)) { + const uint32_t packetPrec = pos.precision_bits > 0 ? pos.precision_bits : 32u; + if (packetPrec > chanPrec) { + applyPositionPrecision(pos, chanPrec); + mp.decoded.payload.size = pb_encode_to_bytes(mp.decoded.payload.bytes, sizeof(mp.decoded.payload.bytes), + &meshtastic_Position_msg, &pos); + logAction("clamp", &mp, "precision"); + } + } + } + } } } @@ -863,53 +850,58 @@ void TrafficManagementModule::alterReceived(meshtastic_MeshPacket &mp) int32_t TrafficManagementModule::runOnce() { - if (!moduleConfig.has_traffic_management || !moduleConfig.traffic_management.enabled) + if (!moduleConfig.has_traffic_management) return INT32_MAX; #if TRAFFIC_MANAGEMENT_CACHE_SIZE > 0 - const uint32_t nowMs = millis(); + const uint32_t nowMs = TrafficManagementModule::clockMs(); // Warm-start the next-hop cache from persisted NodeInfoLite hints once nodeDB // is populated. Done here (not in the constructor) so nodeDB has finished // loading. Takes its own lock, so call before acquiring the sweep guard below. - if (!nextHopPreloaded) { - preloadNextHopsFromNodeDB(); + // Only latch the one-shot guard once the preload actually ran; if nodeDB wasn't + // ready yet, retry on the next maintenance pass instead of skipping it forever. + if (!nextHopPreloaded && preloadNextHopsFromNodeDB()) nextHopPreloaded = true; - } - // Calculate TTLs for cache expiration + // Free-running tick counters (no epoch needed). + // TTL expressed in ticks: + // pos: 4× position_min_interval_secs (clamped to 255 ticks @ 6 min/tick) + // rate: 2× rate_limit_window_secs (clamped to 15 ticks @ 5 min/tick; only relevant when rate limits are configured) + // unknown: fixed 12 ticks @ 1 min/tick (only relevant when unknown_packet_threshold > 0) const uint32_t positionIntervalMs = secsToMs(Default::getConfiguredOrDefault( moduleConfig.traffic_management.position_min_interval_secs, default_traffic_mgmt_position_min_interval_secs)); - const uint32_t positionTtlMs = positionIntervalMs * 4; + const uint8_t posTtlTicks = + static_cast(std::min(static_cast(255), (positionIntervalMs * 4) / kPosTimeTickMs)); - const uint32_t rateIntervalMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); - const uint32_t rateTtlMs = (rateIntervalMs > 0) ? rateIntervalMs * 2 : (10 * 60 * 1000UL); + const uint32_t rateWindowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + const uint8_t rateTtlTicks = static_cast( + std::min(static_cast(15), (rateWindowMs > 0 ? rateWindowMs * 2 : 24 * kRateTimeTickMs) / kRateTimeTickMs)); - const uint32_t unknownTtlMs = kUnknownResetMs * 5; + // unknown: fixed 12-tick TTL (12 min — 4 ticks past the 5-min default window) + const uint8_t unknownTtlTicks = 12; + + const uint8_t nowPosTick = currentPosTick(); + const uint8_t nowRateTick = currentRateTick(); + const uint8_t nowUnknownTick = currentUnknownTick(); // Sweep cache and clear expired entries uint16_t activeEntries = 0; uint16_t expiredEntries = 0; - const uint32_t sweepStartMs = millis(); + const uint32_t sweepStartMs = TrafficManagementModule::clockMs(); + const auto &cfg = moduleConfig.traffic_management; concurrency::LockGuard guard(&cacheLock); - // Slide the epoch instead of flushing when offsets approach 8-bit overflow. - // Rebase preserves live entries; only already-expired ones clamp to 0 and are - // reclaimed by the sweep below in this same locked pass. - if (needsEpochReset(nowMs)) - rebaseEpoch(nowMs); - for (uint16_t i = 0; i < cacheSize(); i++) { if (cache[i].node == 0) continue; bool anyValid = false; - // Check and clear expired position data - if (cache[i].pos_time != 0) { - uint32_t posTimeMs = fromRelativePosTime(cache[i].pos_time); - if (!isWithinWindow(nowMs, posTimeMs, positionTtlMs)) { + // Check and clear expired position data (presence: pos_fingerprint != 0) + if (cache[i].pos_fingerprint != 0) { + if (static_cast(nowPosTick - cache[i].pos_time) >= posTtlTicks) { cache[i].pos_fingerprint = 0; cache[i].pos_time = 0; } else { @@ -917,31 +909,33 @@ int32_t TrafficManagementModule::runOnce() } } - // Check and clear expired rate limit data - if (cache[i].rate_time != 0) { - uint32_t rateTimeMs = fromRelativeRateTime(cache[i].rate_time); - if (!isWithinWindow(nowMs, rateTimeMs, rateTtlMs)) { - cache[i].rate_count = 0; - cache[i].rate_time = 0; + // Check and clear expired rate limit data (presence: getRateCount() != 0) + if (cache[i].getRateCount() != 0) { + if ((static_cast(nowRateTick - cache[i].getRateTime()) & 0x0F) >= rateTtlTicks) { + cache[i].setRateCount(0); + cache[i].setRateTime(0); } else { anyValid = true; } } - // Check and clear expired unknown tracking data - if (cache[i].unknown_time != 0) { - uint32_t unknownTimeMs = fromRelativeUnknownTime(cache[i].unknown_time); - if (!isWithinWindow(nowMs, unknownTimeMs, unknownTtlMs)) { - cache[i].unknown_count = 0; - cache[i].unknown_time = 0; + // Check and clear expired unknown tracking data (presence: getUnknownCount() != 0) + if (cache[i].getUnknownCount() != 0) { + if ((static_cast(nowUnknownTick - cache[i].getUnknownTime()) & 0x0F) >= unknownTtlTicks) { + cache[i].setUnknownCount(0); + cache[i].setUnknownTime(0); } else { anyValid = true; } } - // A confirmed next-hop hint has no TTL of its own and keeps the slot alive, - // so an aged-out routing hint outlives the dedup/rate/unknown state. - if (cache[i].next_hop != 0) + // Two fields have no TTL of their own and pin the slot, so they outlive the + // dedup/rate/unknown state: + // - a confirmed next-hop hint (the routing overflow store), and + // - a cached special (non-CLIENT) role, so a tracker / lost-and-found / router + // keeps its dedup-window exception across quiet periods rather than reverting + // to CLIENT the moment its timed state expires. + if (cache[i].next_hop != 0 || cache[i].getCachedRole() != meshtastic_Config_DeviceConfig_Role_CLIENT) anyValid = true; // If all data expired, free the slot entirely @@ -955,7 +949,7 @@ int32_t TrafficManagementModule::runOnce() TM_LOG_DEBUG("Maintenance: %u active, %u expired, %u/%u slots, %lums elapsed", activeEntries, expiredEntries, static_cast(activeEntries), static_cast(cacheSize()), - static_cast(millis() - sweepStartMs)); + static_cast(TrafficManagementModule::clockMs() - sweepStartMs)); #if defined(ARCH_ESP32) && defined(BOARD_HAS_PSRAM) if (nodeInfoPayload) { @@ -984,18 +978,21 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, if (!pos->has_latitude_i || !pos->has_longitude_i) return false; - uint8_t precision = Default::getConfiguredOrDefault(moduleConfig.traffic_management.position_precision_bits, - default_traffic_mgmt_position_precision_bits); - precision = sanitizePositionPrecision(precision); + // Precision is driven by the channel's own position_precision ceiling — the same + // grid the channel uses for broadcast. Falls back to the firmware default (19-bit, + // ~90m cells) when the channel has no precision configured (chanPrec == 0). + const uint32_t chanPrec = getPositionPrecisionForChannel(p->channel); + uint8_t precision = sanitizePositionPrecision( + chanPrec > 0 ? static_cast(chanPrec) : static_cast(default_traffic_mgmt_position_precision_bits)); - const int32_t lat_truncated = truncateLatLon(pos->latitude_i, precision); - const int32_t lon_truncated = truncateLatLon(pos->longitude_i, precision); + const int32_t lat_truncated = truncateCoordinate(pos->latitude_i, precision); + const int32_t lon_truncated = truncateCoordinate(pos->longitude_i, precision); const uint8_t fingerprint = computePositionFingerprint(lat_truncated, lon_truncated, precision); // Drop gate uses the RAW configured interval: 0 means "dedup disabled" (the // contract documented below). The 12h default is only for resolution/TTL // sizing (constructor / runOnce), not for deciding whether to drop — feeding // the default here would silently turn the 0-disables-dedup contract off. - const uint32_t minIntervalMs = secsToMs(moduleConfig.traffic_management.position_min_interval_secs); + uint32_t minIntervalMs = secsToMs(moduleConfig.traffic_management.position_min_interval_secs); bool isNew = false; concurrency::LockGuard guard(&cacheLock); @@ -1003,19 +1000,46 @@ bool TrafficManagementModule::shouldDropPosition(const meshtastic_MeshPacket *p, if (!entry) return false; - // Compare fingerprint and check time window - // When minIntervalMs == 0, deduplication is disabled (withinInterval = false means never drop) - const bool hasPositionState = !isNew && entry->pos_time != 0; + // Role exceptions keyed on the originating node's advertised role, resolved across + // all three tiers (hot store → warm store → TMM live cache). The position path is + // the one place that needs sender-role, and it also keeps tier 3 warm so the + // exception survives the node aging out of both NodeDB stores — important in the + // common dedup-only config, where isRateLimited()'s role write never runs. + const meshtastic_Config_DeviceConfig_Role role = resolveSenderRole(p->from, entry, isNew); + if (role == meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND) { + // Lost-and-found may refresh a duplicate position at most every ~15 min (cap, never + // lengthens; quantised to ~2 dedup ticks). Only when dedup is active — never tighten + // past an operator who disabled it (0). + const uint32_t lostFoundCapMs = secsToMs(default_traffic_mgmt_lost_and_found_position_min_interval_secs); + if (minIntervalMs != 0 && minIntervalMs > lostFoundCapMs) + minIntervalMs = lostFoundCapMs; + } else if (role == meshtastic_Config_DeviceConfig_Role_TRACKER || role == meshtastic_Config_DeviceConfig_Role_TAK_TRACKER) { + // Trackers may refresh a duplicate position as often as hourly (cap, never lengthens). + const uint32_t trackerCapMs = secsToMs(default_traffic_mgmt_tracker_position_min_interval_secs); + if (minIntervalMs > trackerCapMs) + minIntervalMs = trackerCapMs; + } + + // Compare fingerprint and check time window. + // When minIntervalMs == 0, deduplication is disabled (withinInterval = false means never drop). + // Presence: pos_fingerprint != 0; computePositionFingerprint() remaps 0 -> 0xFF so zero means unseen. + const bool hasPositionState = !isNew && entry->pos_fingerprint != 0; const bool samePosition = hasPositionState && entry->pos_fingerprint == fingerprint; + const uint8_t nowPosTick = currentPosTick(); + // Clamp to [1, 255]: intervals shorter than one tick still dedup within the same tick. + const uint8_t windowTicks = + (minIntervalMs == 0) ? 0 + : static_cast(std::min(static_cast(UINT8_MAX), + std::max(static_cast(1), minIntervalMs / kPosTimeTickMs))); const bool withinInterval = - hasPositionState && (minIntervalMs != 0) && isWithinWindow(nowMs, fromRelativePosTime(entry->pos_time), minIntervalMs); + hasPositionState && (windowTicks != 0) && (static_cast(nowPosTick - entry->pos_time) < windowTicks); TM_LOG_DEBUG("Position dedup 0x%08x: fp=0x%02x prev=0x%02x same=%d within=%d new=%d", p->from, fingerprint, entry->pos_fingerprint, samePosition, withinInterval, isNew); - // Update cache entry + // Update cache entry (raw tick; 0 is a valid tick value) entry->pos_fingerprint = fingerprint; - entry->pos_time = toRelativePosTime(nowMs); + entry->pos_time = nowPosTick; // Drop only if same position AND within the minimum interval return samePosition && withinInterval; @@ -1104,7 +1128,7 @@ bool TrafficManagementModule::shouldRespondToNodeInfo(const meshtastic_MeshPacke reply->decoded.bitfield |= BITFIELD_OK_TO_MQTT_MASK; if (hasCachedUser && cachedLastObservedMs != 0) { - uint32_t ageMs = millis() - cachedLastObservedMs; + uint32_t ageMs = clockMs() - cachedLastObservedMs; TM_LOG_DEBUG("NodeInfo PSRAM hit node=0x%08x age=%lu ms src_ch=%u req_ch=%u rx_time=%lu", p->to, static_cast(ageMs), static_cast(cachedSourceChannel), static_cast(p->channel), static_cast(cachedLastObservedRxTime)); @@ -1169,25 +1193,32 @@ bool TrafficManagementModule::isRateLimited(NodeNum from, uint32_t nowMs) if (!entry) return false; - // Check if window has expired - if (isNew || !isWithinWindow(nowMs, fromRelativeRateTime(entry->rate_time), windowMs)) { - entry->rate_time = toRelativeRateTime(nowMs); - entry->rate_count = 1; + // Window ticks: clamp to [1,15] so zero windowMs (config error) opens a new window. + const uint8_t windowTicks = static_cast(std::min(static_cast(15), windowMs / kRateTimeTickMs)); + const uint8_t nowRateTick = currentRateTick(); + const bool windowExpired = + isNew || entry->getRateCount() == 0 || + ((static_cast(nowRateTick - entry->getRateTime()) & 0x0F) >= std::max(static_cast(1), windowTicks)); + if (windowExpired) { + entry->setRateTime(nowRateTick); + entry->setRateCount(1); return false; } - // Increment counter (saturates at 255) - saturatingIncrement(entry->rate_count); + // Increment counter, saturating at 63 (6-bit field max). + const uint8_t cur = entry->getRateCount(); + if (cur < 0x3F) + entry->setRateCount(static_cast(cur + 1)); - // Check against threshold (uint8_t max is 255, but config is uint32_t) + // Threshold capped at 60 so a saturated reading (63) always exceeds it. uint32_t threshold = moduleConfig.traffic_management.rate_limit_max_packets; - if (threshold > 255) - threshold = 255; + if (threshold > 60) + threshold = 60; - bool limited = entry->rate_count > threshold; - if (limited || entry->rate_count == threshold) { - TM_LOG_DEBUG("Rate limit 0x%08x: count=%u threshold=%u -> %s", from, entry->rate_count, threshold, - limited ? "DROP" : "at-limit"); + const uint8_t count = entry->getRateCount(); + bool limited = count > threshold; + if (limited || count == threshold) { + TM_LOG_DEBUG("Rate limit 0x%08x: count=%u threshold=%u -> %s", from, count, threshold, limited ? "DROP" : "at-limit"); } return limited; #endif @@ -1200,12 +1231,11 @@ bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, (void)nowMs; return false; #else - if (!moduleConfig.traffic_management.drop_unknown_enabled || moduleConfig.traffic_management.unknown_packet_threshold == 0) + if (moduleConfig.traffic_management.unknown_packet_threshold == 0) return false; - uint32_t windowMs = kUnknownResetMs; - if (moduleConfig.traffic_management.rate_limit_window_secs > 0) - windowMs = secsToMs(moduleConfig.traffic_management.rate_limit_window_secs); + // Fixed 5-tick (5 min) unknown window; capped at 12 ticks (12 min max). + static constexpr uint8_t kUnknownWindowTicks = 5; bool isNew = false; concurrency::LockGuard guard(&cacheLock); @@ -1213,25 +1243,31 @@ bool TrafficManagementModule::shouldDropUnknown(const meshtastic_MeshPacket *p, if (!entry) return false; - // Check if window has expired - if (isNew || !isWithinWindow(nowMs, fromRelativeUnknownTime(entry->unknown_time), windowMs)) { - entry->unknown_time = toRelativeUnknownTime(nowMs); - entry->unknown_count = 0; + // Check if window has expired (presence: getUnknownCount() != 0) + const uint8_t nowUnknownTick = currentUnknownTick(); + const bool windowExpired = isNew || entry->getUnknownCount() == 0 || + ((static_cast(nowUnknownTick - entry->getUnknownTime()) & 0x0F) >= kUnknownWindowTicks); + if (windowExpired) { + entry->setUnknownTime(nowUnknownTick); + entry->setUnknownCount(0); } - // Increment counter (saturates at 255). Same saturation handling as - // isRateLimited: without it, a clamped threshold of 255 can never fire. - const bool alreadySaturated = (entry->unknown_count == UINT8_MAX); - saturatingIncrement(entry->unknown_count); + // Increment counter, saturating at 63 (6-bit field max). With threshold + // capped at 60, a saturated reading always exceeds the limit — no special + // already-saturated edge case needed. + const uint8_t cur = entry->getUnknownCount(); + if (cur < 0x3F) + entry->setUnknownCount(static_cast(cur + 1)); - // Check against threshold + // Threshold capped at 60 so a saturated reading (63) always exceeds it. uint32_t threshold = moduleConfig.traffic_management.unknown_packet_threshold; - if (threshold > 255) - threshold = 255; + if (threshold > 60) + threshold = 60; - bool drop = entry->unknown_count > threshold || (alreadySaturated && threshold == 255); - if (drop || entry->unknown_count == threshold) { - TM_LOG_DEBUG("Unknown packets 0x%08x: count=%u threshold=%u -> %s", p->from, entry->unknown_count, threshold, + const uint8_t count = entry->getUnknownCount(); + bool drop = count > threshold; + if (drop || count == threshold) { + TM_LOG_DEBUG("Unknown packets 0x%08x: count=%u threshold=%u -> %s", p->from, count, threshold, drop ? "DROP" : "at-limit"); } return drop; diff --git a/src/modules/TrafficManagementModule.h b/src/modules/TrafficManagementModule.h index b0e97d89c..231849e8f 100644 --- a/src/modules/TrafficManagementModule.h +++ b/src/modules/TrafficManagementModule.h @@ -22,9 +22,10 @@ * Memory Optimization: * 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 8-bit relative offsets from a rolling epoch to further reduce - * memory footprint. LoRa packet rates are low enough that an O(n) scan of - * ~1000 11-byte entries is negligible next to packet processing. + * 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 { @@ -54,7 +55,9 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // 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). - void preloadNextHopsFromNodeDB(); + // @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. @@ -66,81 +69,90 @@ 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); - // Sliding-epoch rebase: advance the epoch and shift live entries back by the - // same wall-clock amount instead of flushing, so cached state survives past the - // ~19h horizon. Caller must hold cacheLock. - void rebaseEpoch(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 (11 bytes) - Same for ALL platforms + // Unified Cache Entry (10 bytes) - Same for ALL platforms // ========================================================================= // - // A single compact structure used across ESP32, NRF52, and all other platforms. - // Memory: 11 bytes × TRAFFIC_MANAGEMENT_CACHE_SIZE entries (default 1000 = 11KB) - // - // 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) - // [10] next_hop - Last-byte relay to reach `node` (1 byte, 0 = none) + // [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) // - // next_hop semantics: - // A routing hint: the last byte of the NodeNum to use as next hop to reach - // `node`. Written ONLY from NextHopRouter's ACK-confirmed decision (a - // bidirectionally-verified relay), never inferred one-way from relayed - // traffic. The TMM cache acts as an overflow store for confirmed next-hops - // that have aged out of the hot NodeDB (NodeInfoLite). Unlike the other - // fields it has no TTL of its own — it keeps its slot alive (see runOnce) - // and is refreshed only on the next confirmed exchange. + // 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) - uint8_t next_hop; // 1 byte - Last-byte relay to reach `node` (0 = none). See note below. + 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((rate_count & 0xC0) | (c & 0x3F)); } + uint8_t getUnknownCount() const { return unknown_count & 0x3F; } + void setUnknownCount(uint8_t c) { unknown_count = static_cast((unknown_count & 0xC0) | (c & 0x3F)); } + uint8_t getCachedRole() const { return static_cast(((rate_count >> 6) << 2) | (unknown_count >> 6)); } + void setCachedRole(uint8_t role) + { + rate_count = static_cast((rate_count & 0x3F) | ((role >> 2) << 6)); + unknown_count = static_cast((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((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); } + void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast((rate_unknown_time & 0xF0) | (t & 0x0F)); } }; - static_assert(sizeof(UnifiedCacheEntry) == 11, "UnifiedCacheEntry should be 11 bytes"); + static_assert(sizeof(UnifiedCacheEntry) == 10, "UnifiedCacheEntry should be 10 bytes"); // ========================================================================= // Flat unified cache // ========================================================================= // // Plain array, linear scan (same idiom as WarmNodeStore). A lookup walks at - // most cacheSize() × 11 B — microseconds at LoRa packet rates, not worth a + // 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). @@ -154,82 +166,29 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread 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(res); - } - - // Convert to/from 8-bit relative timestamps with given resolution. - // - // All stored timestamps carry a uniform +1 "presence" offset: a value of 0 is - // reserved for "no timestamp recorded" (which is also the zero-initialized - // state), and stored values 1..255 encode raw ticks 0..254. This keeps the - // 0-means-empty sentinel consistent with memset/calloc zeroing across every - // sub-store, so the maintenance sweep's `_time != 0` presence checks are - // unambiguous (a timestamp recorded in the first tick after the epoch is no - // longer mistaken for an empty slot). The offset is applied here and removed - // on read, so it cancels out in all window math. - 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(ticks + 1); - } - uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const - { - return (ticks == 0) ? cacheEpochMs : cacheEpochMs + (static_cast(ticks - 1) * resolutionSecs * 1000UL); - } - - // Convenience wrappers for each timestamp type (the +1 presence offset lives - // in the shared converters above, so these are plain pass-throughs). - 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); } - - // Coarsest of the per-feature resolutions (seconds per tick). - uint16_t maxResolution() const - { - uint16_t maxRes = posTimeResolution; - if (rateTimeResolution > maxRes) - maxRes = rateTimeResolution; - if (unknownTimeResolution > maxRes) - maxRes = unknownTimeResolution; - return maxRes; - } - - // True when relative offsets approach 8-bit overflow. - // With max resolution of 339 sec, 200 ticks = ~19 hours (safe margin for 24h max). - bool needsEpochReset(uint32_t nowMs) const { return (nowMs - cacheEpochMs) > (200UL * maxResolution() * 1000UL); } + static uint8_t currentPosTick() { return static_cast(clockMs() / kPosTimeTickMs); } + static uint8_t currentRateTick() { return static_cast((clockMs() / kRateTimeTickMs) & 0x0F); } + static uint8_t currentUnknownTick() { return static_cast((clockMs() / kUnknownTimeTickMs) & 0x0F); } // ========================================================================= // Position Fingerprint // ========================================================================= @@ -309,6 +268,21 @@ class TrafficManagementModule : public MeshModule, private concurrency::OSThread // Find existing entry (no creation) UnifiedCacheEntry *findEntry(NodeNum node); + // 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); diff --git a/test/test_position_module/test_main.cpp b/test/test_position_module/test_main.cpp new file mode 100644 index 000000000..778779af6 --- /dev/null +++ b/test/test_position_module/test_main.cpp @@ -0,0 +1,81 @@ +#include "TestUtil.h" +#include "modules/PositionModule.h" +#include + +// These exercise PositionModule's pure broadcast-policy helpers (stationary detection and the +// interval floor). They take plain values, so no device globals or fake clock are needed. + +// Coordinates sharing the top `precision` bits land in the same grid cell. +static void test_withinPrecisionCell_jitterStaysInCell() +{ + // At precision 16 the top 16 bits define the cell; the low 16 bits are GPS jitter. + TEST_ASSERT_TRUE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x1234ABCD, 0x2234EF01, 16)); +} + +static void test_withinPrecisionCell_movingLatLeavesCell() +{ + TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12350000, 0x22340000, 16)); +} + +static void test_withinPrecisionCell_movingLonLeavesCell() +{ + TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22350000, 16)); +} + +// precision 0 means position sharing is off — never treat as stationary/suppressible. +static void test_withinPrecisionCell_zeroPrecisionNeverSuppresses() +{ + TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22340000, 0)); +} + +// Full precision (>=32): any difference matters, and identical full-precision coords still aren't +// "stationary" because there's no coarse cell to hold within. +static void test_withinPrecisionCell_fullPrecisionNeverSuppresses() +{ + TEST_ASSERT_FALSE(PositionModule::positionWithinPrecisionCell(0x12340000, 0x22340000, 0x12340000, 0x22340000, 32)); +} + +static void test_effectiveInterval_stationaryRaisesToFloor() +{ + TEST_ASSERT_EQUAL_UINT32(43200000U, PositionModule::effectiveBroadcastIntervalMs(60000U, true, 43200000U)); +} + +static void test_effectiveInterval_movingKeepsConfigured() +{ + TEST_ASSERT_EQUAL_UINT32(60000U, PositionModule::effectiveBroadcastIntervalMs(60000U, false, 43200000U)); +} + +// A configured interval already longer than the floor is never shortened. +static void test_effectiveInterval_longConfiguredWinsOverFloor() +{ + TEST_ASSERT_EQUAL_UINT32(50000000U, PositionModule::effectiveBroadcastIntervalMs(50000000U, true, 43200000U)); +} + +static void test_effectiveInterval_zeroFloorIsNoOp() +{ + TEST_ASSERT_EQUAL_UINT32(60000U, PositionModule::effectiveBroadcastIntervalMs(60000U, true, 0U)); +} + +void setUp(void) {} + +void tearDown(void) {} + +extern "C" { +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_withinPrecisionCell_jitterStaysInCell); + RUN_TEST(test_withinPrecisionCell_movingLatLeavesCell); + RUN_TEST(test_withinPrecisionCell_movingLonLeavesCell); + RUN_TEST(test_withinPrecisionCell_zeroPrecisionNeverSuppresses); + RUN_TEST(test_withinPrecisionCell_fullPrecisionNeverSuppresses); + RUN_TEST(test_effectiveInterval_stationaryRaisesToFloor); + RUN_TEST(test_effectiveInterval_movingKeepsConfigured); + RUN_TEST(test_effectiveInterval_longConfiguredWinsOverFloor); + RUN_TEST(test_effectiveInterval_zeroFloorIsNoOp); + exit(UNITY_END()); +} + +void loop() {} +} diff --git a/test/test_traffic_management/test_main.cpp b/test/test_traffic_management/test_main.cpp index 7f314ae4b..1844653ab 100644 --- a/test/test_traffic_management/test_main.cpp +++ b/test/test_traffic_management/test_main.cpp @@ -13,6 +13,7 @@ #include "airtime.h" #include "mesh/CryptoEngine.h" +#include "mesh/Default.h" #include "mesh/MeshService.h" #include "mesh/NodeDB.h" #include "mesh/Router.h" @@ -78,6 +79,13 @@ class MockNodeDB : public NodeDB // Role the TMM should see for the cached node (sender-role-aware throttles). void setCachedNodeRole(meshtastic_Config_DeviceConfig_Role role) { cachedNode.role = role; } + // Direct mutable access to the cached node for fine-grained bitfield manipulation in tests. + meshtastic_NodeInfoLite &cachedNodeForTest() + { + hasCachedNode = true; + return cachedNode; + } + // Seed a node into the hot-store buffer at index 1 (index 0 is reserved for // "self"). Respects the fixed-buffer invariant: `meshNodes` is a buffer of // MAX_NUM_NODES slots with `numMeshNodes` as the logical count — we grow the @@ -149,8 +157,9 @@ class TrafficManagementModuleTestShim : public TrafficManagementModule { public: using TrafficManagementModule::alterReceived; + using TrafficManagementModule::flushCache; using TrafficManagementModule::handleReceived; - using TrafficManagementModule::resetEpoch; + using TrafficManagementModule::peekCachedRole; using TrafficManagementModule::runOnce; bool ignoreRequestFlag() const { return ignoreRequest; } @@ -163,7 +172,6 @@ static void resetTrafficConfig() moduleConfig = meshtastic_LocalModuleConfig_init_zero; moduleConfig.has_traffic_management = true; moduleConfig.traffic_management = meshtastic_ModuleConfig_TrafficManagementConfig_init_zero; - moduleConfig.traffic_management.enabled = true; config = meshtastic_LocalConfig_init_zero; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; @@ -179,6 +187,10 @@ static void resetTrafficConfig() mockNodeDB->resetNodes(); mockNodeDB->clearCachedNode(); nodeDB = mockNodeDB; + + // Virtual clock base (1 h in, so tick subtraction never underflows). Tests advance time by + // bumping TrafficManagementModule::s_testNowMs instead of sleeping real seconds across a tick. + TrafficManagementModule::s_testNowMs = 3600000; } static meshtastic_MeshPacket makeDecodedPacket(meshtastic_PortNum port, NodeNum from, NodeNum to = NODENUM_BROADCAST) @@ -261,6 +273,16 @@ static void installWellKnownPrimaryChannel() config.lora.modem_preset = meshtastic_Config_LoRaConfig_ModemPreset_LONG_FAST; } +// Install the well-known primary channel AND set a specific position_precision so +// shouldDropPosition() uses that precision ceiling rather than the default fallback. +// precision=0 means "no channel ceiling" and falls back to the firmware default (19 bits). +static void installWellKnownPrimaryChannelWithPrecision(uint32_t precision) +{ + installWellKnownPrimaryChannel(); + channelFile.channels[0].settings.has_module_settings = true; + channelFile.channels[0].settings.module_settings.position_precision = precision; +} + static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longName, const char *shortName) { meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); @@ -275,6 +297,21 @@ static meshtastic_MeshPacket makeNodeInfoPacket(NodeNum from, const char *longNa return packet; } +static meshtastic_MeshPacket makeNodeInfoPacketWithRole(NodeNum from, meshtastic_Config_DeviceConfig_Role role) +{ + meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_NODEINFO_APP, from, NODENUM_BROADCAST); + + meshtastic_User user = meshtastic_User_init_zero; + snprintf(user.id, sizeof(user.id), "!%08x", from); + strncpy(user.long_name, "rolenode", sizeof(user.long_name) - 1); + strncpy(user.short_name, "rn", sizeof(user.short_name) - 1); + user.role = role; + + packet.decoded.payload.size = + pb_encode_to_bytes(packet.decoded.payload.bytes, sizeof(packet.decoded.payload.bytes), &meshtastic_User_msg, &user); + return packet; +} + /** * Verify the module is a no-op when traffic management is disabled. * Important so config toggles cannot accidentally change routing behavior. @@ -300,7 +337,6 @@ static void test_tm_moduleDisabled_doesNothing(void) */ static void test_tm_unknownPackets_dropOnNPlusOne(void) { - moduleConfig.traffic_management.drop_unknown_enabled = true; moduleConfig.traffic_management.unknown_packet_threshold = 2; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); @@ -324,9 +360,8 @@ static void test_tm_unknownPackets_dropOnNPlusOne(void) */ static void test_tm_positionDedup_dropsDuplicateWithinWindow(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -349,9 +384,8 @@ static void test_tm_positionDedup_dropsDuplicateWithinWindow(void) */ static void test_tm_positionDedup_allowsMovedPosition(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -372,7 +406,6 @@ static void test_tm_positionDedup_allowsMovedPosition(void) */ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void) { - moduleConfig.traffic_management.rate_limit_enabled = true; moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 3; TrafficManagementModuleTestShim module; @@ -397,12 +430,10 @@ static void test_tm_rateLimit_dropsOnlyAfterThreshold(void) */ static void test_tm_fromUs_bypassesPositionAndRateFilters(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; - moduleConfig.traffic_management.rate_limit_enabled = true; moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 1; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket positionPacket = makePositionPacket(kLocalNode, 374221234, -1220845678); @@ -428,12 +459,10 @@ static void test_tm_fromUs_bypassesPositionAndRateFilters(void) */ static void test_tm_localDestination_bypassesTransitFilters(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; - moduleConfig.traffic_management.rate_limit_enabled = true; moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 1; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket position1 = makePositionPacket(kRemoteNode, 374221234, -1220845678, kLocalNode); @@ -461,7 +490,6 @@ static void test_tm_localDestination_bypassesTransitFilters(void) */ static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_ROUTER; mockNodeDB->setCachedNode(kTargetNode); @@ -486,7 +514,6 @@ static void test_tm_nodeinfo_routerClamp_skipsWhenTooManyHops(void) */ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; config.lora.config_ok_to_mqtt = true; @@ -532,7 +559,6 @@ static void test_tm_nodeinfo_directResponse_respondsFromCache(void) */ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); @@ -568,7 +594,6 @@ static void test_tm_nodeinfo_directResponse_learnsRequestorNodeInfo(void) */ static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); @@ -595,7 +620,6 @@ static void test_tm_nodeinfo_clientClamp_skipsWhenNotDirect(void) */ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->clearCachedNode(); @@ -629,7 +653,6 @@ static void test_tm_nodeinfo_directResponse_withoutNodeDbEntry_skips(void) */ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; config.lora.config_ok_to_mqtt = true; @@ -683,7 +706,6 @@ static void test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfie */ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(void) { - moduleConfig.traffic_management.nodeinfo_direct_response = true; moduleConfig.traffic_management.nodeinfo_direct_response_max_hops = 10; config.device.role = meshtastic_Config_DeviceConfig_Role_CLIENT; mockNodeDB->setCachedNode(kTargetNode); @@ -711,15 +733,13 @@ static void test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb(voi #endif /** - * Verify relayed telemetry broadcasts are hop-exhausted when enabled AND the - * channel is congested (telemetry exhaustion is gated on channel utilization, - * unlike position exhaustion). - * Important to prevent further mesh propagation while still allowing one relay step. + * Verify relayed telemetry broadcasts are NOT hop-exhausted. + * exhaust_hop_telemetry / exhaust_hop_position have been removed from the config + * as "not suitable right now" — alterReceived must leave hop_limit unchanged. */ -static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void) +static void test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; - ScopedBusyAirTime busyChannel; + ScopedBusyAirTime busyChannel; // congestion present but exhaust is disabled TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); packet.hop_start = 5; @@ -728,20 +748,19 @@ static void test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast(void) module.alterReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); - TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); - TEST_ASSERT_EQUAL_UINT8(3, packet.hop_start); - TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); - TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); + TEST_ASSERT_EQUAL_UINT8(3, packet.hop_limit); // unchanged + TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); // unchanged + TEST_ASSERT_FALSE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); } /** - * Verify hop exhaustion skips unicast and local-origin packets. - * Important to avoid mutating traffic that should retain normal forwarding behavior. + * Verify alterReceived does not modify unicast or local-origin packets. + * The precision clamp (the only active alterReceived path) only fires for + * broadcast position packets from remote nodes — these should be untouched. */ static void test_tm_alterReceived_skipsLocalAndUnicast(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; - ScopedBusyAirTime busyChannel; // congestion satisfied, so only the skip conditions are under test TrafficManagementModuleTestShim module; meshtastic_MeshPacket unicast = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, kTargetNode); @@ -768,9 +787,9 @@ static void test_tm_alterReceived_skipsLocalAndUnicast(void) */ static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; - moduleConfig.traffic_management.position_min_interval_secs = 1; + // 360 s = 1 pos-tick (kPosTimeTickMs); advance the virtual clock past one tick period. + moduleConfig.traffic_management.position_min_interval_secs = 360; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -779,7 +798,7 @@ static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) ProcessMessage r1 = module.handleReceived(first); ProcessMessage r2 = module.handleReceived(second); - testDelay(1200); + TrafficManagementModule::s_testNowMs += 360001; // advance past one 6-min pos-tick (virtual clock) ProcessMessage r3 = module.handleReceived(third); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -795,9 +814,9 @@ static void test_tm_positionDedup_allowsDuplicateAfterIntervalExpires(void) */ static void test_tm_positionDedup_intervalZero_neverDrops(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; + // position_min_interval_secs=0 disables the drop gate (shouldDropPosition returns false for any packet). moduleConfig.traffic_management.position_min_interval_secs = 0; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -818,9 +837,9 @@ static void test_tm_positionDedup_intervalZero_neverDrops(void) */ static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 99; + // Channel precision=99 is out of range; sanitizePositionPrecision falls back to default (19 bits). moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(99); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -836,18 +855,21 @@ static void test_tm_positionDedup_precisionAbove32_usesDefaultPrecision(void) } /** - * Verify precision=32 does not collapse all positions to one fingerprint. - * Important to prevent false duplicate drops at the full-precision boundary. + * Verify the dedup fingerprint does not collapse positions that are distinct at the + * channel's *effective* precision. Dedup only runs on well-known (public) channels, + * where precision is capped at MAX_POSITION_PRECISION_PUBLIC_KEY (15) regardless of the + * channel's configured value — so the requested 32 is clamped to 15. Positions must + * therefore differ in the top 15 bits (>= 2^17 raw units) to read as distinct; here + * they differ by 2^18, well clear of the precision-15 grid, so neither is dropped. */ -static void test_tm_positionDedup_precision32_allowsDistinctPositions(void) +static void test_tm_positionDedup_distinctAtClampedChannelPrecision(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 32; moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(32); // clamped to 15 on a public channel TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); - meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221235, -1220845677); + meshtastic_MeshPacket second = makePositionPacket(kRemoteNode, 374221234 + (1 << 18), -1220845678 + (1 << 18)); ProcessMessage r1 = module.handleReceived(first); ProcessMessage r2 = module.handleReceived(second); @@ -859,17 +881,15 @@ static void test_tm_positionDedup_precision32_allowsDistinctPositions(void) } /** - * Verify precision=0 falls back to the default precision (same contract as - * >32: getConfiguredOrDefault + sanitizePositionPrecision treat 0 as unset). - * Important so invalid config does not collapse all positions into one - * fingerprint — positions in different default-precision grid cells must - * still be distinct. + * Verify channel precision=0 (no channel ceiling set) falls back to the firmware + * default precision (19 bits / ~90 m cells). Positions more than one default grid + * cell apart must remain distinct, not collapse into one fingerprint. */ -static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void) +static void test_tm_positionDedup_precisionZero_channelFallsBackToDefault(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 0; moduleConfig.traffic_management.position_min_interval_secs = 300; + // precision=0 in the channel → getPositionPrecisionForChannel returns 0 → falls back to default 19 bits. + installWellKnownPrimaryChannelWithPrecision(0); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); @@ -888,20 +908,19 @@ static void test_tm_positionDedup_precisionZero_allowsDistinctPositions(void) * Verify epoch reset invalidates stale position identity for dedup. * Important so reset paths cannot leak prior packet identity into new windows. */ -static void test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset(void) +static void test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); - meshtastic_MeshPacket afterReset = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterFlush = makePositionPacket(kRemoteNode, 374221234, -1220845678); meshtastic_MeshPacket duplicate = makePositionPacket(kRemoteNode, 374221234, -1220845678); ProcessMessage r1 = module.handleReceived(first); - module.resetEpoch(millis()); - ProcessMessage r2 = module.handleReceived(afterReset); + module.flushCache(); + ProcessMessage r2 = module.handleReceived(afterFlush); ProcessMessage r3 = module.handleReceived(duplicate); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -917,12 +936,10 @@ static void test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset(vo */ static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero(void) { - moduleConfig.traffic_management.position_dedup_enabled = true; - moduleConfig.traffic_management.position_precision_bits = 16; moduleConfig.traffic_management.position_min_interval_secs = 300; - moduleConfig.traffic_management.rate_limit_enabled = true; moduleConfig.traffic_management.rate_limit_window_secs = 60; moduleConfig.traffic_management.rate_limit_max_packets = 10; + installWellKnownPrimaryChannelWithPrecision(16); TrafficManagementModuleTestShim module; meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode); @@ -946,15 +963,15 @@ static void test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero */ static void test_tm_rateLimit_resetsAfterWindowExpires(void) { - moduleConfig.traffic_management.rate_limit_enabled = true; - moduleConfig.traffic_management.rate_limit_window_secs = 1; + // 300 s = 1 rate-tick (kRateTimeTickMs); advance the virtual clock past one tick period. + moduleConfig.traffic_management.rate_limit_window_secs = 300; moduleConfig.traffic_management.rate_limit_max_packets = 1; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode); ProcessMessage r1 = module.handleReceived(packet); ProcessMessage r2 = module.handleReceived(packet); - testDelay(1200); + TrafficManagementModule::s_testNowMs += 300001; // advance past one 5-min rate-tick (virtual clock) ProcessMessage r3 = module.handleReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -969,15 +986,13 @@ static void test_tm_rateLimit_resetsAfterWindowExpires(void) */ static void test_tm_unknownPackets_resetAfterWindowExpires(void) { - moduleConfig.traffic_management.drop_unknown_enabled = true; moduleConfig.traffic_management.unknown_packet_threshold = 1; - moduleConfig.traffic_management.rate_limit_window_secs = 1; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); ProcessMessage r1 = module.handleReceived(packet); ProcessMessage r2 = module.handleReceived(packet); - testDelay(1200); + TrafficManagementModule::s_testNowMs += 300001; // advance past 5 unknown-ticks (5 × 60s) (virtual clock) ProcessMessage r3 = module.handleReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); @@ -988,17 +1003,17 @@ static void test_tm_unknownPackets_resetAfterWindowExpires(void) } /** - * Verify unknown threshold values above 255 clamp to the counter ceiling. - * Important to align config semantics with saturating counter storage. + * Verify unknown threshold values above the implementation cap (60) are clamped. + * The counter is a 6-bit field (saturates at 63); threshold is capped at 60 so a + * saturated reading always exceeds it. A config value of 300 should behave as 60. */ static void test_tm_unknownPackets_thresholdAbove255_clamps(void) { - moduleConfig.traffic_management.drop_unknown_enabled = true; moduleConfig.traffic_management.unknown_packet_threshold = 300; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode); - for (int i = 0; i < 255; i++) { + for (int i = 0; i < 60; i++) { ProcessMessage result = module.handleReceived(packet); TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); } @@ -1010,14 +1025,11 @@ static void test_tm_unknownPackets_thresholdAbove255_clamps(void) } /** - * Verify relayed position broadcasts can also be hop-exhausted — under the - * same pressure gate as telemetry (here: channel congestion). - * Important because telemetry and position use separate exhaust flags. + * Verify relayed position broadcasts are NOT hop-exhausted. + * exhaust_hop_position has been removed — alterReceived must leave hop_limit unchanged. */ -static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void) +static void test_tm_alterReceived_positionBroadcast_hopLimitUnchanged(void) { - moduleConfig.traffic_management.exhaust_hop_position = true; - ScopedBusyAirTime busyChannel; TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makePositionPacket(kRemoteNode, 374221234, -1220845678, NODENUM_BROADCAST); packet.hop_start = 5; @@ -1026,19 +1038,17 @@ static void test_tm_alterReceived_exhaustsRelayedPositionBroadcast(void) module.alterReceived(packet); meshtastic_TrafficManagementStats stats = module.getStats(); - TEST_ASSERT_EQUAL_UINT8(0, packet.hop_limit); - TEST_ASSERT_EQUAL_UINT8(4, packet.hop_start); - TEST_ASSERT_TRUE(module.shouldExhaustHops(packet)); - TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); + TEST_ASSERT_EQUAL_UINT8(2, packet.hop_limit); // unchanged + TEST_ASSERT_EQUAL_UINT8(5, packet.hop_start); // unchanged + TEST_ASSERT_FALSE(module.shouldExhaustHops(packet)); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); } /** - * Verify hop exhaustion ignores undecoded/encrypted packets. + * Verify alterReceived ignores undecoded/encrypted packets. * Important so we never mutate packets that were not decoded by this module. */ static void test_tm_alterReceived_skipsUndecodedPackets(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; - ScopedBusyAirTime busyChannel; // congestion satisfied, so only the undecoded skip is under test TrafficManagementModuleTestShim module; meshtastic_MeshPacket packet = makeUnknownPacket(kRemoteNode, NODENUM_BROADCAST); packet.hop_start = 5; @@ -1054,20 +1064,19 @@ static void test_tm_alterReceived_skipsUndecodedPackets(void) } /** - * Verify exhaustRequested is per-packet and resets on next handleReceived(). - * Important so a prior packet cannot leak hop-exhaust state into later packets. + * Verify shouldExhaustHops() always returns false — exhaust_hop_* features are + * removed, so the exhaustRequested flag is never set. + * Guards against accidental re-enablement without updating the flag logic. */ -static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void) +static void test_tm_alterReceived_exhaustFlagAlwaysFalse(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; - ScopedBusyAirTime busyChannel; // telemetry exhaust only fires under congestion TrafficManagementModuleTestShim module; meshtastic_MeshPacket telemetry = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); telemetry.hop_start = 5; telemetry.hop_limit = 3; module.alterReceived(telemetry); - TEST_ASSERT_TRUE(module.shouldExhaustHops(telemetry)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry)); meshtastic_MeshPacket text = makeDecodedPacket(meshtastic_PortNum_TEXT_MESSAGE_APP, kRemoteNode); ProcessMessage result = module.handleReceived(text); @@ -1075,42 +1084,40 @@ static void test_tm_alterReceived_resetExhaustFlagOnNextPacket(void) TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(result)); TEST_ASSERT_FALSE(module.shouldExhaustHops(telemetry)); - TEST_ASSERT_EQUAL_UINT32(1, stats.hop_exhausted_packets); + TEST_ASSERT_EQUAL_UINT32(0, stats.hop_exhausted_packets); } /** - * Verify exhaust requests are packet-scoped (from + id). - * Important so stale state from one packet cannot influence unrelated packets - * that pass through duplicate/rebroadcast paths before handleReceived(). + * Verify shouldExhaustHops() returns false for any packet regardless of from/id. + * Since exhaust is removed, the from+id scope check is moot — this guards that + * the always-false invariant holds across multiple distinct packets. */ static void test_tm_alterReceived_exhaustFlag_isPacketScoped(void) { - moduleConfig.traffic_management.exhaust_hop_telemetry = true; - ScopedBusyAirTime busyChannel; // telemetry exhaust only fires under congestion TrafficManagementModuleTestShim module; - meshtastic_MeshPacket exhausted = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); - exhausted.id = 0x1010; - exhausted.hop_start = 5; - exhausted.hop_limit = 3; - module.alterReceived(exhausted); + meshtastic_MeshPacket p1 = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kRemoteNode, NODENUM_BROADCAST); + p1.id = 0x1010; + p1.hop_start = 5; + p1.hop_limit = 3; + module.alterReceived(p1); - meshtastic_MeshPacket unrelated = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST); - unrelated.id = 0x2020; - unrelated.hop_start = 4; - unrelated.hop_limit = 0; + meshtastic_MeshPacket p2 = makeDecodedPacket(meshtastic_PortNum_TELEMETRY_APP, kTargetNode, NODENUM_BROADCAST); + p2.id = 0x2020; + p2.hop_start = 4; + p2.hop_limit = 0; - TEST_ASSERT_TRUE(module.shouldExhaustHops(exhausted)); - TEST_ASSERT_FALSE(module.shouldExhaustHops(unrelated)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(p1)); + TEST_ASSERT_FALSE(module.shouldExhaustHops(p2)); } /** - * Verify runOnce() returns sleep-forever interval when module is disabled. - * Important to ensure the maintenance thread is effectively inert when off. + * Verify runOnce() returns sleep-forever interval when has_traffic_management is false. + * TMM has no runtime enable flag — the presence bit is the only runtime gate. */ static void test_tm_runOnce_disabledReturnsMaxInterval(void) { - moduleConfig.traffic_management.enabled = false; + moduleConfig.has_traffic_management = false; TrafficManagementModuleTestShim module; int32_t interval = module.runOnce(); @@ -1218,6 +1225,381 @@ static void test_tm_nextHop_keptAliveAcrossMaintenanceSweep(void) TEST_ASSERT_EQUAL_UINT8(0x42, module.getNextHopHint(kTargetNode)); } + +// --------------------------------------------------------------------------- +// Role exceptions: TRACKER and LOST_AND_FOUND +// --------------------------------------------------------------------------- + +/** + * Verify TRACKER role caps the dedup window at 1 hour. + * A duplicate position that would normally be blocked for 11 h (default) must + * be forwarded once the 1-hour tracker cap expires. + */ +static void test_tm_trackerRole_capsDedupWindowAtOneHour(void) +{ + // Operator interval is 11 h — longer than the tracker cap. + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); // still within 1-hour cap + // Advance past 1 hour (tracker cap = 3600 s; pos-tick = 360 s → 10 ticks). + TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; + ProcessMessage r3 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify TAK_TRACKER role receives the same 1-hour dedup cap as TRACKER. + * Both roles share the same exception branch; this guards against future divergence. + */ +static void test_tm_takTrackerRole_capsDedupWindowAtOneHour(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TAK_TRACKER); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); + TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; + ProcessMessage r3 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify the tracker role exception survives the node being evicted from BOTH the + * hot and warm NodeDB stores — the TMM unified cache is the third fallback. The + * role is cached on the entry while NodeDB still knows the node; once NodeDB + * forgets it (getNodeRole → CLIENT), the cached role must keep the 1-hour cap + * applied instead of reverting to the 11-hour default interval. + */ +static void test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole(void) +{ + // Operator interval is 11 h — longer than the tracker cap. + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + // First packet: NodeDB knows the role; it is cached onto the TMM entry. + ProcessMessage r1 = module.handleReceived(first); + + // Node ages out of NodeDB entirely (hot + warm). getNodeRole now returns CLIENT. + mockNodeDB->clearCachedNode(); + + ProcessMessage r2 = module.handleReceived(dup); // within 1-hour cap — still drop + // Advance past the tracker cap (3600 s) but stay well under the 11-hour default. + // Without the cached-role fallback this would still be inside the 11-hour window + // (CLIENT → no exception) and wrongly drop; with it, the 1-hour cap lets it pass. + TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; + ProcessMessage r3 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify a role change observed via NodeInfo updates the cached role (write-time update), + * so an exception is dropped when a node is demoted from TRACKER back to CLIENT. The role + * is read from the NodeInfo's User payload — the same event that updates NodeDB — not by + * re-scanning NodeDB on the position path. + */ +static void test_tm_roleChange_viaNodeInfo_dropsTrackerException(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + // First position seeds the TMM entry with TRACKER (from NodeDB on isNew). + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + ProcessMessage r1 = module.handleReceived(first); + + // The node demotes to CLIENT and announces it. The NodeInfo refresh must overwrite + // the cached TRACKER role with CLIENT — even though the packet payload, not NodeDB, + // is the source of truth here (NodeDB role left stale on purpose to prove the path). + meshtastic_MeshPacket info = makeNodeInfoPacketWithRole(kRemoteNode, meshtastic_Config_DeviceConfig_Role_CLIENT); + module.handleReceived(info); + + // Past the 1-hour tracker cap but within the 11-hour CLIENT interval. With the stale + // TRACKER role this would pass; after the demotion it must drop (full interval applies). + TrafficManagementModule::s_testNowMs += (default_traffic_mgmt_tracker_position_min_interval_secs * 1000UL) + 1; + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + ProcessMessage r2 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify a cached special (non-CLIENT) role pins the TMM entry through the maintenance + * sweep: once the node's position state has expired and been cleared, the entry — and its + * role — must still survive (role has no TTL), the same way a confirmed next-hop hint does. + */ +static void test_tm_specialRole_pinsEntryThroughSweep(void) +{ + // Short position interval → short pos TTL so the sweep clears pos state quickly. + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + module.handleReceived(makePositionPacket(kRemoteNode, 374221234, -1220845678)); + TEST_ASSERT_EQUAL_INT(static_cast(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(kRemoteNode)); + + // Advance well past the position TTL, then sweep: pos state is cleared but the role + // (no TTL) must keep the entry alive. Without the role pin the entry would be reclaimed + // and peekCachedRole would return -1. + TrafficManagementModule::s_testNowMs += 60UL * 60UL * 1000UL; // 1 h + module.runOnce(); + + TEST_ASSERT_EQUAL_INT(static_cast(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(kRemoteNode)); +} + +/** + * Verify special-role entries are evicted last. A tracker that is the OLDEST entry must + * survive cache pressure that evicts many newer CLIENT entries, because a cached + * special role marks the entry "preferred" (like a next-hop hint) in victim selection. + */ +static void test_tm_specialRole_evictedLastUnderPressure(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + TrafficManagementModuleTestShim module; + + // Oldest entry: a tracker. Seed its role from NodeDB on first position. + const NodeNum tracker = 0xAA0000FF; + mockNodeDB->setCachedNode(tracker); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + module.handleReceived(makePositionPacket(tracker, 100, 200)); + TEST_ASSERT_EQUAL_INT(static_cast(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(tracker)); + + mockNodeDB->clearCachedNode(); // every subsequent filler resolves to CLIENT (unprotected) + + // Fill past capacity with newer CLIENT nodes, forcing many evictions. The tracker is + // the stalest entry but is "preferred", so an unprotected client must always be the + // victim instead — the tracker must never be evicted. + for (uint32_t i = 0; i < static_cast(TRAFFIC_MANAGEMENT_CACHE_SIZE) + 50; i++) { + const NodeNum filler = 0x01000000u + i; + module.handleReceived(makePositionPacket(filler, 300 + static_cast(i), 400 + static_cast(i))); + } + + TEST_ASSERT_EQUAL_INT(static_cast(meshtastic_Config_DeviceConfig_Role_TRACKER), module.peekCachedRole(tracker)); +} + +/** + * Verify the tracker cap never lengthens an operator-configured interval shorter + * than the cap default. The cap is a ceiling, not a floor. + */ +static void test_tm_trackerRole_doesNotLengthenShorterOperatorInterval(void) +{ + // Operator set 5-minute interval — shorter than the 1-hour tracker cap. + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_TRACKER); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterShortInterval = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); + // The 300 s operator interval rounds to 1 pos-tick (360 s) — dedup is tick-granular. + // Advance past one full tick to verify the window is 1 tick (not the 10-tick tracker cap). + TrafficManagementModule::s_testNowMs += 360'000UL + 1; + ProcessMessage r3 = module.handleReceived(afterShortInterval); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify LOST_AND_FOUND role caps duplicate-position dedup at ~15 min (2 pos-ticks), + * not the old one-tick fast-announce. A configured 11-hour interval is shortened to the + * 15-min cap; a duplicate one tick later still drops, but one past the 2-tick cap passes. + */ +static void test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes(void) +{ + // Long interval that would normally suppress duplicates for 11 h. + moduleConfig.traffic_management.position_min_interval_secs = default_traffic_mgmt_position_min_interval_secs; + installWellKnownPrimaryChannelWithPrecision(16); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterOneTick = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterCap = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); // same tick — drop + // One pos-tick later: STILL within the 15-min (~2-tick) cap, unlike the old 1-tick exception. + // (360 s = kPosTimeTickMs, kept as a literal because the constant is private to the module.) + TrafficManagementModule::s_testNowMs += 360'000UL + 1; + ProcessMessage r3 = module.handleReceived(afterOneTick); // still drop + // Jump past the full cap (>= 2 ticks since the last packet): now it passes. + TrafficManagementModule::s_testNowMs += 2 * 360'000UL; + ProcessMessage r4 = module.handleReceived(afterCap); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r3)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r4)); + TEST_ASSERT_EQUAL_UINT32(2, stats.position_dedup_drops); +} + +/** + * Verify a node with unknown role (absent from NodeDB) is not granted any role + * exception: the full configured interval applies, so a duplicate inside that + * window is dropped exactly as for an ordinary CLIENT. + */ +static void test_tm_unknownRole_noDbEntry_appliesFullInterval(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + // No cached node — getMeshNode returns nullptr for kRemoteNode. + mockNodeDB->clearCachedNode(); + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket afterShort = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); // within 300-s window — must drop + // The 300 s operator interval rounds to 1 pos-tick (360 s) — dedup is tick-granular. + // Advance past one full tick to confirm the packet passes without any tracker exception. + TrafficManagementModule::s_testNowMs += 360'000UL + 1; + ProcessMessage r3 = module.handleReceived(afterShort); + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r3)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify a node present in NodeDB but without user info (HAS_USER bit clear) + * is not granted a role exception: it falls back to CLIENT and the full + * configured interval applies. + */ +static void test_tm_unknownRole_noUserBit_appliesFullInterval(void) +{ + moduleConfig.traffic_management.position_min_interval_secs = 300; + installWellKnownPrimaryChannelWithPrecision(16); + + // Node is in NodeDB but the HAS_USER bit is NOT set — role must be ignored. + mockNodeDB->setCachedNode(kRemoteNode); + // Clear the HAS_USER bit that setCachedNode sets, leaving role at CLIENT default. + mockNodeDB->cachedNodeForTest().bitfield &= ~NODEINFO_BITFIELD_HAS_USER_MASK; + mockNodeDB->cachedNodeForTest().role = meshtastic_Config_DeviceConfig_Role_TRACKER; + + TrafficManagementModuleTestShim module; + + meshtastic_MeshPacket first = makePositionPacket(kRemoteNode, 374221234, -1220845678); + meshtastic_MeshPacket dup = makePositionPacket(kRemoteNode, 374221234, -1220845678); + + ProcessMessage r1 = module.handleReceived(first); + ProcessMessage r2 = module.handleReceived(dup); // within 300-s window — must drop + meshtastic_TrafficManagementStats stats = module.getStats(); + + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::CONTINUE), static_cast(r1)); + TEST_ASSERT_EQUAL_INT(static_cast(ProcessMessage::STOP), static_cast(r2)); + TEST_ASSERT_EQUAL_UINT32(1, stats.position_dedup_drops); +} + +/** + * Verify a LOST_AND_FOUND origin now GETS the relayed precision clamp — the + * anti-dox exemption was removed, so a relayed position more precise than the + * channel setting is clamped down to the channel ceiling like any other node's. + */ +static void test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp(void) +{ + // Set channel precision ceiling to 13 bits. Must be <= MAX_POSITION_PRECISION_PUBLIC_KEY + // (15) — well-known channels have a public PSK (size==1), so getPositionPrecisionForChannel + // clamps any value above 15 via usesPublicKey(). + installWellKnownPrimaryChannelWithPrecision(13); + + mockNodeDB->setCachedNode(kRemoteNode); + mockNodeDB->setCachedNodeRole(meshtastic_Config_DeviceConfig_Role_LOST_AND_FOUND); + + TrafficManagementModuleTestShim module; + + // Full-precision packet — 32 bits — exceeds the channel cap. + const uint32_t fullPrecision = 32; + meshtastic_MeshPacket packet = makePositionPacketWithPrecision(kRemoteNode, 374221234, -1220845678, fullPrecision); + packet.hop_start = 3; + packet.hop_limit = 2; // relayed (hop_limit < hop_start) so clamp logic applies + + module.alterReceived(packet); + + meshtastic_Position out; + TEST_ASSERT_TRUE(decodePositionPayload(packet, out)); + // Clamped to channel ceiling (13 bits) — lost-and-found is no longer exempt. + // Note: precision must be <= MAX_POSITION_PRECISION_PUBLIC_KEY (15); well-known + // channels always have a public PSK so getPositionPrecisionForChannel caps at 15. + TEST_ASSERT_EQUAL_UINT32(13, out.precision_bits); +} } // namespace void setUp(void) @@ -1254,21 +1636,21 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nodeinfo_directResponse_psramCacheRespondsAndPreservesBitfield); RUN_TEST(test_tm_nodeinfo_directResponse_psramMissDoesNotFallbackToNodeDb); #endif - RUN_TEST(test_tm_alterReceived_exhaustsRelayedTelemetryBroadcast); + RUN_TEST(test_tm_alterReceived_telemetryBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsLocalAndUnicast); RUN_TEST(test_tm_positionDedup_allowsDuplicateAfterIntervalExpires); RUN_TEST(test_tm_positionDedup_intervalZero_neverDrops); RUN_TEST(test_tm_positionDedup_precisionAbove32_usesDefaultPrecision); - RUN_TEST(test_tm_positionDedup_precision32_allowsDistinctPositions); - RUN_TEST(test_tm_positionDedup_precisionZero_allowsDistinctPositions); - RUN_TEST(test_tm_positionDedup_epochReset_doesNotDropFirstPacketAfterReset); + RUN_TEST(test_tm_positionDedup_distinctAtClampedChannelPrecision); + RUN_TEST(test_tm_positionDedup_precisionZero_channelFallsBackToDefault); + RUN_TEST(test_tm_positionDedup_cacheFlush_doesNotDropFirstPacketAfterFlush); RUN_TEST(test_tm_positionDedup_priorRateState_doesNotDropFirstFingerprintZero); RUN_TEST(test_tm_rateLimit_resetsAfterWindowExpires); RUN_TEST(test_tm_unknownPackets_resetAfterWindowExpires); RUN_TEST(test_tm_unknownPackets_thresholdAbove255_clamps); - RUN_TEST(test_tm_alterReceived_exhaustsRelayedPositionBroadcast); + RUN_TEST(test_tm_alterReceived_positionBroadcast_hopLimitUnchanged); RUN_TEST(test_tm_alterReceived_skipsUndecodedPackets); - RUN_TEST(test_tm_alterReceived_resetExhaustFlagOnNextPacket); + RUN_TEST(test_tm_alterReceived_exhaustFlagAlwaysFalse); RUN_TEST(test_tm_alterReceived_exhaustFlag_isPacketScoped); RUN_TEST(test_tm_runOnce_disabledReturnsMaxInterval); RUN_TEST(test_tm_runOnce_enabledReturnsMaintenanceInterval); @@ -1276,6 +1658,17 @@ TM_TEST_ENTRY void setup() RUN_TEST(test_tm_nextHop_servedAfterNodeDbRoll); RUN_TEST(test_tm_nextHop_preloadDoesNotClobberLearned); RUN_TEST(test_tm_nextHop_keptAliveAcrossMaintenanceSweep); + RUN_TEST(test_tm_trackerRole_capsDedupWindowAtOneHour); + RUN_TEST(test_tm_takTrackerRole_capsDedupWindowAtOneHour); + RUN_TEST(test_tm_trackerRole_survivesNodeDbEvictionViaCachedRole); + RUN_TEST(test_tm_roleChange_viaNodeInfo_dropsTrackerException); + RUN_TEST(test_tm_specialRole_pinsEntryThroughSweep); + RUN_TEST(test_tm_specialRole_evictedLastUnderPressure); + RUN_TEST(test_tm_trackerRole_doesNotLengthenShorterOperatorInterval); + RUN_TEST(test_tm_lostAndFoundRole_capsDedupAtFifteenMinutes); + RUN_TEST(test_tm_lostAndFoundRole_getsAlterReceivedPrecisionClamp); + RUN_TEST(test_tm_unknownRole_noDbEntry_appliesFullInterval); + RUN_TEST(test_tm_unknownRole_noUserBit_appliesFullInterval); exit(UNITY_END()); } diff --git a/userPrefs.jsonc b/userPrefs.jsonc index 57ede8bbf..118588e19 100644 --- a/userPrefs.jsonc +++ b/userPrefs.jsonc @@ -24,6 +24,7 @@ // "USERPREFS_CONFIG_OWNER_SHORT_NAME": "MLN", // "USERPREFS_CONFIG_DEVICE_ROLE": "meshtastic_Config_DeviceConfig_Role_CLIENT", // Defaults to CLIENT. ROUTER*, and LOST AND FOUND roles are restricted. // "USERPREFS_EVENT_MODE": "1", + // "USERPREFS_TMM_APPLY_TO_PRIVATE_CHANNELS": "1", // Extend TMM position dedup and precision clamping to private/custom-key channels (default: well-known channels only) // "USERPREFS_FIRMWARE_EDITION": "meshtastic_FirmwareEdition_BURNING_MAN", // "USERPREFS_FIXED_BLUETOOTH": "121212", // "USERPREFS_FIXED_GPS": "", diff --git a/variants/esp32s3/heltec_v4/variant.h b/variants/esp32s3/heltec_v4/variant.h index 5a806653e..d35cce554 100644 --- a/variants/esp32s3/heltec_v4/variant.h +++ b/variants/esp32s3/heltec_v4/variant.h @@ -33,9 +33,6 @@ #ifndef HAS_TRAFFIC_MANAGEMENT #define HAS_TRAFFIC_MANAGEMENT 1 #endif -#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 -#endif // ---- GC1109 RF FRONT END CONFIGURATION ---- // The Heltec V4.2 uses a GC1109 FEM chip with integrated PA and LNA diff --git a/variants/esp32s3/heltec_v4_r8/variant.h b/variants/esp32s3/heltec_v4_r8/variant.h index d59f0ae2c..b7d8897a5 100644 --- a/variants/esp32s3/heltec_v4_r8/variant.h +++ b/variants/esp32s3/heltec_v4_r8/variant.h @@ -31,9 +31,6 @@ #ifndef HAS_TRAFFIC_MANAGEMENT #define HAS_TRAFFIC_MANAGEMENT 1 #endif -#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 -#endif // ---- KCT8103L RF FRONT END CONFIGURATION ---- // The Heltec V4.3 uses a KCT8103L FEM chip with integrated PA and LNA diff --git a/variants/esp32s3/station-g2/variant.h b/variants/esp32s3/station-g2/variant.h index 9e844588f..fdd7c280d 100755 --- a/variants/esp32s3/station-g2/variant.h +++ b/variants/esp32s3/station-g2/variant.h @@ -14,9 +14,6 @@ Board Information: https://wiki.uniteng.com/en/meshtastic/station-g2 #ifndef HAS_TRAFFIC_MANAGEMENT #define HAS_TRAFFIC_MANAGEMENT 1 #endif -#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 -#endif /* #define BATTERY_PIN 4 // A battery voltage measurement pin, voltage divider connected here to measure battery voltage diff --git a/variants/native/portduino/variant.h b/variants/native/portduino/variant.h index fb36e2588..7ccf2e34b 100644 --- a/variants/native/portduino/variant.h +++ b/variants/native/portduino/variant.h @@ -16,6 +16,3 @@ #ifndef HAS_VARIABLE_HOPS #define HAS_VARIABLE_HOPS 1 #endif -#ifndef TRAFFIC_MANAGEMENT_CACHE_SIZE -#define TRAFFIC_MANAGEMENT_CACHE_SIZE 2048 -#endif