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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
GitHub
Claude Sonnet 4.6
parent
d8d92b7b71
commit
c51c01607d
+1
-1
Submodule protobufs updated: f16a13e433...03314e6395
@@ -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<meshtastic_Config_LoRaConfig_ModemPreset>(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
|
||||
|
||||
@@ -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();
|
||||
|
||||
+10
-1
@@ -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)
|
||||
@@ -35,7 +38,13 @@ 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_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
|
||||
|
||||
+22
-2
@@ -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<meshtastic_Config_DeviceConfig_Role>(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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<uint32_t>(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<int32_t>(truncated);
|
||||
}
|
||||
|
||||
int32_t truncateCoordinate(int32_t coordinate, uint8_t precision)
|
||||
{
|
||||
return truncateCoordinate(coordinate, static_cast<uint32_t>(precision));
|
||||
}
|
||||
|
||||
void applyPositionPrecision(meshtastic_Position &position, uint32_t precision)
|
||||
{
|
||||
if (precision == 0) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
+6
-13
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" */
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,12 +127,10 @@ 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
|
||||
#define WARM_NODE_COUNT 320 // Generic ESP32, ESP32-S3 without PSRAM, ESP32C3 etc.
|
||||
#endif // platform
|
||||
#endif // WARM_NODE_COUNT
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -38,6 +38,14 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
|
||||
|
||||
void handleNewPosition();
|
||||
|
||||
// Pure broadcast-policy helpers, split out so they're unit-testable without the module.
|
||||
// True when two coordinates truncate to the same precision cell (so a re-broadcast would be a
|
||||
// duplicate). precision 0 or >=32 returns false: no coarse cell to hold within, never suppress.
|
||||
static bool positionWithinPrecisionCell(int32_t aLat, int32_t aLon, int32_t bLat, int32_t bLon, uint32_t precision);
|
||||
// Effective min interval: stationary positions are held to stationaryFloorMs (when that is the
|
||||
// longer of the two); otherwise the normal configured interval.
|
||||
static uint32_t effectiveBroadcastIntervalMs(uint32_t configuredIntervalMs, bool stationary, uint32_t stationaryFloorMs);
|
||||
|
||||
protected:
|
||||
/** Called to handle a particular incoming message
|
||||
|
||||
@@ -57,6 +65,12 @@ class PositionModule : public ProtobufModule<meshtastic_Position>, private concu
|
||||
private:
|
||||
meshtastic_MeshPacket *allocPositionPacket();
|
||||
struct SmartPosition getDistanceTraveledSinceLastSend(meshtastic_PositionLite currentPosition);
|
||||
// True when our position is unchanged since the last broadcast: it truncates to the same
|
||||
// precision grid cell, so re-sending would be a duplicate that traffic management dedups
|
||||
// downstream anyway. Used to hold stationary broadcasts to a 12h floor. useConfiguredPrecision
|
||||
// gauges movement at our own configured (unclamped) precision rather than the on-wire
|
||||
// (public-clamped) precision — trackers report finer movement.
|
||||
bool positionUnchangedSinceLastSend(const meshtastic_PositionLite &selfPos, bool useConfiguredPrecision);
|
||||
meshtastic_MeshPacket *allocAtakPli();
|
||||
void trySetRtc(meshtastic_Position p, bool isLocal, bool forceUpdate = false);
|
||||
uint32_t precision;
|
||||
|
||||
@@ -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 <Arduino.h>
|
||||
#include <algorithm>
|
||||
#include <cstring>
|
||||
|
||||
#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<uint32_t>(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<uint32_t>(resolutionSecs) * 1000UL);
|
||||
ticks = (ticks > dec) ? static_cast<uint8_t>(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<uint32_t>(value) & mask;
|
||||
|
||||
// Add half the truncation step to center in the grid cell
|
||||
truncated += (1u << (31 - precision));
|
||||
return static_cast<int32_t>(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<int>(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<uint8_t>(std::min(15, static_cast<int>(role))));
|
||||
return role;
|
||||
}
|
||||
// Established entry: trust the cached role (refreshed on NodeInfo). No NodeDB scan.
|
||||
return static_cast<meshtastic_Config_DeviceConfig_Role>(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<uint8_t>(std::min(15, static_cast<int>(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<uint8_t>(nowPosTick - e.pos_time);
|
||||
// Blend in rate/unknown ages scaled to pos-tick units (coarser = conservative).
|
||||
const uint8_t rateAgePosScale =
|
||||
static_cast<uint8_t>(static_cast<uint8_t>((currentRateTick() - e.getRateTime()) & 0x0F) * 5 / 3);
|
||||
const uint8_t unknownAgePosScale =
|
||||
static_cast<uint8_t>(static_cast<uint8_t>((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_t>(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<unsigned>(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<size_t>(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<unsigned long>(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<uint32_t>(lat_truncated) >> shift) & ((1u << bitsToTake) - 1);
|
||||
uint8_t lonBits = (static_cast<uint32_t>(lon_truncated) >> shift) & ((1u << bitsToTake) - 1);
|
||||
|
||||
return static_cast<uint8_t>((latBits << 4) | lonBits);
|
||||
const uint8_t fp = static_cast<uint8_t>((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<uint8_t>(std::min(static_cast<uint32_t>(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<uint8_t>(
|
||||
std::min(static_cast<uint32_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<unsigned>(activeEntries), static_cast<unsigned>(cacheSize()),
|
||||
static_cast<unsigned long>(millis() - sweepStartMs));
|
||||
static_cast<unsigned long>(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<uint8_t>(chanPrec) : static_cast<uint8_t>(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<uint8_t>(std::min(static_cast<uint32_t>(UINT8_MAX),
|
||||
std::max(static_cast<uint32_t>(1), minIntervalMs / kPosTimeTickMs)));
|
||||
const bool withinInterval =
|
||||
hasPositionState && (minIntervalMs != 0) && isWithinWindow(nowMs, fromRelativePosTime(entry->pos_time), minIntervalMs);
|
||||
hasPositionState && (windowTicks != 0) && (static_cast<uint8_t>(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<unsigned long>(ageMs), static_cast<unsigned>(cachedSourceChannel),
|
||||
static_cast<unsigned>(p->channel), static_cast<unsigned long>(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<uint8_t>(std::min(static_cast<uint32_t>(15), windowMs / kRateTimeTickMs));
|
||||
const uint8_t nowRateTick = currentRateTick();
|
||||
const bool windowExpired =
|
||||
isNew || entry->getRateCount() == 0 ||
|
||||
((static_cast<uint8_t>(nowRateTick - entry->getRateTime()) & 0x0F) >= std::max(static_cast<uint8_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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;
|
||||
|
||||
@@ -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<uint8_t>((rate_count & 0xC0) | (c & 0x3F)); }
|
||||
uint8_t getUnknownCount() const { return unknown_count & 0x3F; }
|
||||
void setUnknownCount(uint8_t c) { unknown_count = static_cast<uint8_t>((unknown_count & 0xC0) | (c & 0x3F)); }
|
||||
uint8_t getCachedRole() const { return static_cast<uint8_t>(((rate_count >> 6) << 2) | (unknown_count >> 6)); }
|
||||
void setCachedRole(uint8_t role)
|
||||
{
|
||||
rate_count = static_cast<uint8_t>((rate_count & 0x3F) | ((role >> 2) << 6));
|
||||
unknown_count = static_cast<uint8_t>((unknown_count & 0x3F) | ((role & 0x03) << 6));
|
||||
}
|
||||
uint8_t getRateTime() const { return (rate_unknown_time >> 4) & 0x0F; }
|
||||
uint8_t getUnknownTime() const { return rate_unknown_time & 0x0F; }
|
||||
void setRateTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0x0F) | ((t & 0x0F) << 4)); }
|
||||
void setUnknownTime(uint8_t t) { rate_unknown_time = static_cast<uint8_t>((rate_unknown_time & 0xF0) | (t & 0x0F)); }
|
||||
};
|
||||
static_assert(sizeof(UnifiedCacheEntry) == 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<uint16_t>(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<uint8_t>(ticks + 1);
|
||||
}
|
||||
uint32_t fromRelativeTime(uint8_t ticks, uint16_t resolutionSecs) const
|
||||
{
|
||||
return (ticks == 0) ? cacheEpochMs : cacheEpochMs + (static_cast<uint32_t>(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<uint8_t>(clockMs() / kPosTimeTickMs); }
|
||||
static uint8_t currentRateTick() { return static_cast<uint8_t>((clockMs() / kRateTimeTickMs) & 0x0F); }
|
||||
static uint8_t currentUnknownTick() { return static_cast<uint8_t>((clockMs() / kUnknownTimeTickMs) & 0x0F); }
|
||||
// =========================================================================
|
||||
// Position Fingerprint
|
||||
// =========================================================================
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#include "TestUtil.h"
|
||||
#include "modules/PositionModule.h"
|
||||
#include <unity.h>
|
||||
|
||||
// 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() {}
|
||||
}
|
||||
@@ -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<int>(ProcessMessage::CONTINUE), static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(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<int>(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<int>(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<int>(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<uint32_t>(TRAFFIC_MANAGEMENT_CACHE_SIZE) + 50; i++) {
|
||||
const NodeNum filler = 0x01000000u + i;
|
||||
module.handleReceived(makePositionPacket(filler, 300 + static_cast<int>(i), 400 + static_cast<int>(i)));
|
||||
}
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r3));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(r2));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::CONTINUE), static_cast<int>(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<int>(ProcessMessage::CONTINUE), static_cast<int>(r1));
|
||||
TEST_ASSERT_EQUAL_INT(static_cast<int>(ProcessMessage::STOP), static_cast<int>(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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user