mirror of
https://github.com/meshtastic/firmware.git
synced 2026-08-01 10:58:30 -04:00
352 lines
18 KiB
C++
352 lines
18 KiB
C++
#pragma once
|
||
|
||
#include "MeshTypes.h"
|
||
#include "concurrency/OSThread.h"
|
||
#include "configuration.h"
|
||
#include "mesh/Default.h"
|
||
#include "mesh/mesh-pb-constants.h"
|
||
#include <algorithm>
|
||
#include <cstdint>
|
||
#include <cstring>
|
||
|
||
#if HAS_VARIABLE_HOPS
|
||
|
||
/**
|
||
* HopScalingModule: Sampled hop-distance histogram for mesh-aware hop limit recommendations.
|
||
*
|
||
* Memory layout: 512 bytes total (128 entries × 4 bytes/entry, no padding)
|
||
* - 16-bit XOR-fold hash of node ID
|
||
* - 3-bit hops away (0-7)
|
||
* - 13-bit hourly seen bitmap
|
||
* All three fields are packed into a single 32-bit Record; sizeof(Record) == 4.
|
||
*
|
||
* Sampling:
|
||
* - A node is added only when passesFilter(hashNodeId(nodeId), samplingDenominator),
|
||
* i.e. (hash16(nodeId) & (samplingDenominator - 1)) == 0 (hash-space subsample, not raw ID)
|
||
* - samplingDenominator starts at 1 (sample all), doubles when the list exceeds FILL_HIGH_PCT
|
||
* - filteringDenominator tracks samplingDenominator upward immediately but does not drop back
|
||
* down until FILTER_DENOM_HOLD_MS (13 h) have elapsed since the last scale-up
|
||
*
|
||
* Hourly rollover (rollHour()):
|
||
* - Summarises per-hop node counts for entries matching filteringDenominator and seen in the
|
||
* last 13 hours
|
||
* - Scales each hop bucket by filteringDenominator and walks the buckets to recommend a hop
|
||
* limit, matching the same algorithm used in HopScalingModule
|
||
* - Shifts the 13-bit seen bitmap left by one slot to open a fresh slot for the new hour;
|
||
* nodes not seen in 13 consecutive hours have all seen bits cleared (stale)
|
||
* - Checks for scale-down: if fewer than FILL_LOW_PCT of capacity pass filteringDenominator,
|
||
* samplingDenominator is halved (filteringDenominator is held until the 13-h lock expires)
|
||
*
|
||
* Thread-safety: all access is single-threaded via the main loop cooperative scheduler.
|
||
*/
|
||
|
||
struct Record {
|
||
uint32_t nodeHash : 16;
|
||
uint32_t hops_away : 3;
|
||
uint32_t seenHoursAgo : 13;
|
||
};
|
||
static_assert(sizeof(Record) == 4);
|
||
|
||
class HopScalingModule : private concurrency::OSThread
|
||
{
|
||
public:
|
||
// -----------------------------------------------------------------------
|
||
// Capacity and memory layout
|
||
// -----------------------------------------------------------------------
|
||
static constexpr size_t CAPACITY = 128;
|
||
static constexpr size_t ENTRY_BYTES = sizeof(Record);
|
||
static constexpr size_t TOTAL_BYTES = CAPACITY * ENTRY_BYTES;
|
||
|
||
// Denominator limits (must be powers of 2)
|
||
static constexpr uint8_t DENOM_MIN = 1;
|
||
static constexpr uint8_t DENOM_MAX = 128;
|
||
|
||
static constexpr uint8_t MAX_HOP = 7;
|
||
|
||
// Fill-level thresholds (percent of CAPACITY)
|
||
static constexpr uint8_t FILL_HIGH_PCT = 80;
|
||
static constexpr uint8_t FILL_LOW_PCT = 20;
|
||
|
||
// How long filteringDenominator is held at an elevated level before it may drop.
|
||
//
|
||
// This value is deliberately equal to the seenHoursAgo window (13 hours / 13 bits).
|
||
// Invariant: every entry that existed when a scale-up fired had seenHoursAgo != 0 at
|
||
// that moment (trimIfNeeded() evicts stale entries before doubling the denominator),
|
||
// so it remains seenInLast13h for at most 13 more rollHour() calls - exactly the
|
||
// hold duration. That means entries from the scale-up event keep counts.total above
|
||
// the scale-down threshold for the entire hold period under normal (active) mesh
|
||
// conditions. On a genuinely quieting mesh the scale-down CAN fire before the hold
|
||
// expires - each firing halves samplingDenominator but filteringDenominator stays
|
||
// elevated, so the hop recommendation correctly stays conservative (MAX_HOP) while
|
||
// the cascade runs. The cascade is bounded at DENOM_MIN (7 halvings from DENOM_MAX);
|
||
// when the hold finally expires, step 5 of rollHour() halves filteringDenominator
|
||
// once per hour (rather than jumping directly to samplingDenominator) until the two
|
||
// converge, giving the hop-walk a gradual, 1-step-per-hour descent.
|
||
static constexpr uint32_t FILTER_DENOM_HOLD_MS = 13UL * 60UL * 60UL * 1000UL; // 13 h (documentation only)
|
||
// Number of rollHour() calls the hold spans - equals the seenHoursAgo window width.
|
||
// filteringDenomHoldRollsRemaining is initialised to this value on scale-up and
|
||
// decremented once per rollHour(); step-down begins when it reaches zero.
|
||
static constexpr uint8_t FILTER_DENOM_HOLD_ROLLS = 13u;
|
||
|
||
// Hop-walk: target cumulative affected-node count when choosing a hop limit
|
||
static constexpr uint16_t TARGET_AFFECTED_NODES = default_hop_scaling_min_target_nodes;
|
||
|
||
// Clamp bounds enforced on min_target_nodes / max_target_nodes
|
||
static constexpr uint16_t MIN_TARGET_NODES_FLOOR = default_hop_scaling_min_target_nodes_floor;
|
||
static constexpr uint16_t MAX_TARGET_NODES_CEILING = default_hop_scaling_max_target_nodes_ceiling;
|
||
static constexpr uint16_t MAX_TARGET_NODES = default_hop_scaling_max_target_nodes;
|
||
|
||
// Politeness factors for the one-hop extension check in the hop walk.
|
||
// Stored as integer numerators over POLITENESS_DENOM (4):
|
||
// politeLimit = min + gap * politeNumer / POLITENESS_DENOM
|
||
// STRICT → min + 25% of gap; DEFAULT → midpoint; GENEROUS → max
|
||
static constexpr uint8_t POLITENESS_DENOM = 4u;
|
||
static constexpr uint8_t POLITENESS_GENEROUS = 4u; // 4/4 = 1.00
|
||
static constexpr uint8_t POLITENESS_DEFAULT = 2u; // 2/4 = 0.50
|
||
static constexpr uint8_t POLITENESS_STRICT = 1u; // 1/4 = 0.25
|
||
|
||
// Activity weight thresholds (ratio of 0-2 h window vs 1-3 h window).
|
||
// Cross-multiply form: recent * ACTIVITY_WEIGHT_SCALE vs older * threshold_numer.
|
||
// GENEROUS if recent*10 < older*9 (ratio < 0.9); STRICT if recent*10 > older*12 (ratio > 1.2)
|
||
static constexpr uint8_t ACTIVITY_WEIGHT_SCALE = 10u;
|
||
static constexpr uint8_t ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER = 9u;
|
||
static constexpr uint8_t ACTIVITY_WEIGHT_STRICT_MIN_NUMER = 12u;
|
||
|
||
// Scheduling: number of 5-minute runOnce() ticks that make up one hourly rollover
|
||
static constexpr uint8_t RUNS_PER_HOUR = 12;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Types
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// Per-hop node counts produced at each hourly rollover.
|
||
struct PerHopCounts {
|
||
uint16_t perHop[MAX_HOP + 1] = {};
|
||
uint16_t total = 0;
|
||
};
|
||
|
||
/// Mesh activity trend stats produced at each hourly rollover.
|
||
/// All counts are scaled by filteringDenominator (i.e. estimated full-mesh population).
|
||
///
|
||
/// Bitmap interpretation (before the hourly shift): bit 0 = just-completed hour, bit 12 = 12 h ago.
|
||
struct MeshTrendStats {
|
||
/// Estimated node count per hour slot (h=0 is the just-completed hour, h=12 is 12 h ago).
|
||
uint16_t scaledPerHour[13] = {};
|
||
/// Nodes heard only this hour with no prior bitmap history - indicates new arrivals.
|
||
uint16_t newThisHour = 0;
|
||
/// Nodes heard this hour that also appeared in at least one older hour - stable regulars.
|
||
uint16_t returningThisHour = 0;
|
||
/// Nodes heard last hour but silent this hour - potential departures.
|
||
uint16_t lapsedSinceLastHour = 0;
|
||
/// Nodes absent from the last 4 hours but still present in some older hour (5-13 h) - quieting down.
|
||
uint16_t olderThan4h = 0;
|
||
/// Nodes whose only remaining history is the 13th hour (bit 12 only) - about to age out entirely.
|
||
uint16_t agingOut = 0;
|
||
};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Lifecycle
|
||
// -----------------------------------------------------------------------
|
||
|
||
HopScalingModule();
|
||
~HopScalingModule() = default;
|
||
|
||
/// Reset all entries and state.
|
||
void clear();
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Core API
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// Record a received packet.
|
||
/// Adds or updates an entry when passesFilter(hashNodeId(nodeId), samplingDenominator),
|
||
/// i.e. when the 16-bit XOR-fold hash of the node ID falls in the 1/samplingDenominator
|
||
/// subsample of the hash space. This is NOT a raw nodeId modulo check.
|
||
/// Marks the current hour as seen and updates the stored hop count to the last observed value.
|
||
/// Triggers a trim pass if the list exceeds FILL_HIGH_PCT after the insertion.
|
||
void samplePacketForHistogram(uint32_t nodeId, uint8_t hopCount);
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Accessors
|
||
// -----------------------------------------------------------------------
|
||
|
||
uint8_t getLastRequiredHop() const { return lastRequiredHop; }
|
||
uint8_t getEntryCount() const { return count; }
|
||
uint8_t getFillPercentage() const { return static_cast<uint8_t>((static_cast<uint16_t>(count) * 100u) / CAPACITY); }
|
||
uint8_t getSamplingDenominator() const { return samplingDenominator; }
|
||
uint8_t getFilteringDenominator() const { return filteringDenominator; }
|
||
float getPoliteness() const { return lastPoliteNumer / static_cast<float>(POLITENESS_DENOM); }
|
||
const PerHopCounts &getLastPerHopCounts() const { return lastPerHopCounts; }
|
||
uint8_t getLastSuggestedHop() const { return lastSuggestedHop; }
|
||
const MeshTrendStats &getLastTrendStats() const { return lastTrendStats; }
|
||
|
||
// Compatibility accessors used by tests
|
||
uint8_t getCompactHistogramEntryCount() const { return getEntryCount(); }
|
||
uint8_t getCompactHistogramDenominator() const { return getSamplingDenominator(); }
|
||
uint8_t getCompactHistogramFilterDenominator() const { return getFilteringDenominator(); }
|
||
uint8_t getCompactHistogramSuggestedHop() const { return getLastSuggestedHop(); }
|
||
size_t getCompactHistogramAllSampleCount() const { return getEntryCount(); }
|
||
|
||
/// Force both sampling and filtering denominators to a specific value.
|
||
/// Intended for unit tests that need a deterministic starting denominator.
|
||
void setSamplingDenominator(uint8_t d)
|
||
{
|
||
samplingDenominator = (d < DENOM_MIN) ? DENOM_MIN : (d > DENOM_MAX ? DENOM_MAX : d);
|
||
filteringDenominator = samplingDenominator;
|
||
filteringDenomHoldRollsRemaining = 0;
|
||
}
|
||
|
||
#ifdef PIO_UNIT_TESTING
|
||
// Writable from tests as HopScalingModule::s_testNowMs; drives nowMs() in PIO_UNIT_TESTING builds.
|
||
inline static uint32_t s_testNowMs = 0;
|
||
/// Override the per-session hash seed. Use in tests that need a specific sampling distribution.
|
||
void setHashSeed(uint16_t seed) { hashSeed = seed; }
|
||
uint16_t getHashSeed() const { return hashSeed; }
|
||
/// Expose hashNodeId for tests that need to compute which node IDs pass a given denominator.
|
||
uint16_t hashNodeIdPublic(uint32_t nodeId) const { return hashNodeId(nodeId); }
|
||
#endif
|
||
|
||
protected:
|
||
int32_t runOnce() override;
|
||
|
||
private:
|
||
#ifdef PIO_UNIT_TESTING
|
||
friend class HopScalingTestShim;
|
||
#endif
|
||
|
||
/// Perform hourly rollover.
|
||
/// 1. Tallies per-hop counts for entries matching filteringDenominator and seen in 13 h.
|
||
/// 2. Walks the scaled hop buckets and returns the recommended hop limit.
|
||
/// 3. Logs scaled per-hop counts and recommendation.
|
||
/// 4. Checks for scale-down (< FILL_LOW_PCT of capacity pass filteringDenominator).
|
||
/// 5. Decrements filteringDenomHoldRollsRemaining (if > 0); once it reaches zero, halves
|
||
/// filteringDenominator once toward samplingDenominator per rollHour() call.
|
||
/// 6. Shifts all seen bitmaps left by one hour slot.
|
||
void rollHour();
|
||
// -----------------------------------------------------------------------
|
||
// Persistence
|
||
// -----------------------------------------------------------------------
|
||
|
||
/// Persist the histogram state (entries, denominators, hold-timer) to flash.
|
||
/// No-op on platforms without a filesystem. Performs a full delete-and-rewrite of
|
||
/// the state file on each call; avoid calling more frequently than once per rollHour().
|
||
void saveToDisk() const;
|
||
|
||
/// Restore histogram state from flash. Safe to call even when no file exists.
|
||
/// Call once after construction, before the first rollHour(), to warm-start the
|
||
/// histogram across reboots without waiting 13 hours for data to re-accumulate.
|
||
/// The restored entries are available immediately for sampling, but the first
|
||
/// rollHour() (triggered by the second runOnce() tick) is needed before a warm-start
|
||
/// recommendation replaces the HOP_MAX boot default.
|
||
void loadFromDisk();
|
||
|
||
/// Remove stale entries (seen-bits all zero) and, if the list is still crowded,
|
||
/// double samplingDenominator and filteringDenominator and remove non-matching entries.
|
||
void trimIfNeeded();
|
||
|
||
void logStatusReport(bool didHourlyUpdate) const;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Histogram storage
|
||
// -----------------------------------------------------------------------
|
||
Record entries[CAPACITY] = {};
|
||
uint8_t count = 0;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Denominator state
|
||
//
|
||
// Two separate denominators control two distinct gates:
|
||
//
|
||
// samplingDenominator - admission gate. A node is added/updated only when
|
||
// passesFilter(hash, samplingDenominator). Lower value = more permissive =
|
||
// more nodes enter = represents recent mesh state.
|
||
//
|
||
// filteringDenominator - counting gate. The hop-walk tally in rollHour() only
|
||
// counts entries that pass passesFilter(hash, filteringDenominator). It moves
|
||
// up with samplingDenominator immediately (scale-up) but is held at the
|
||
// elevated value for FILTER_DENOM_HOLD_MS (13 h) after any scale-up before it
|
||
// may drop back down (scale-down).
|
||
//
|
||
// Why the estimate is invariant: passesFilter uses a hash-based uniform subsample.
|
||
// For any two powers-of-two denominators D ≤ F, the fraction of D-sampled entries
|
||
// that also pass F is exactly D/F. Therefore:
|
||
// raw_count × F = (total × D/F) × F = total × D
|
||
// The population estimate is the same whether we count with D or with F.
|
||
// The hold period is not about accuracy - it is about stability: it prevents the
|
||
// hop recommendation from reacting to recently-admitted nodes that have not yet
|
||
// accumulated enough seenHoursAgo history to be statistically reliable.
|
||
//
|
||
// denominatorHistory[h] - the filteringDenominator used to both gate and scale
|
||
// hourlyRaw[h]. Invariant: denominatorHistory[h] always equals the
|
||
// filteringDenominator that was active when seenHoursAgo bit h was set.
|
||
// rollHour() advances the array at the very start (before the tally loop), then
|
||
// gates hourlyRaw[h] per-slot by denominatorHistory[h] - each slot's raw count
|
||
// and multiplier are therefore always consistent, even when filteringDenominator
|
||
// changes between rolls (e.g. hold expiry). On scale-up (trimIfNeeded()), the
|
||
// entire array is backfilled uniformly with the new filteringDenominator to
|
||
// preserve the invariant retroactively for all 13 slots. Initialised to
|
||
// DENOM_MIN (1); scaledPerHour slots that draw from a 1 entry are unscaled -
|
||
// correct for a fresh instance with no prior history.
|
||
// -----------------------------------------------------------------------
|
||
uint8_t samplingDenominator = DENOM_MIN;
|
||
uint8_t filteringDenominator = DENOM_MIN;
|
||
uint8_t filteringDenomHoldRollsRemaining = 0; // counts down from FILTER_DENOM_HOLD_ROLLS to 0; step-down fires at 0
|
||
uint8_t denominatorHistory[13] = {};
|
||
uint16_t hashSeed = 0;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Cached hourly results
|
||
// -----------------------------------------------------------------------
|
||
PerHopCounts lastPerHopCounts = {};
|
||
uint16_t lastScaledPerHop[MAX_HOP + 1] = {};
|
||
uint8_t lastSuggestedHop = MAX_HOP;
|
||
uint8_t lastPoliteNumer = POLITENESS_DEFAULT;
|
||
MeshTrendStats lastTrendStats = {};
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Hop recommendation state
|
||
// -----------------------------------------------------------------------
|
||
uint8_t lastRequiredHop = HOP_MAX;
|
||
uint8_t histogramRollCount = 0;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Scheduler state
|
||
// -----------------------------------------------------------------------
|
||
bool hasCompletedInitialRun = false;
|
||
uint8_t runsSinceLastHourlyUpdate = 0;
|
||
|
||
// -----------------------------------------------------------------------
|
||
// Inline record helpers
|
||
// -----------------------------------------------------------------------
|
||
|
||
// Record field semantics:
|
||
// nodeHash → XOR-fold of full 32-bit node ID to 16 bits
|
||
// hops_away → hop distance (0-7)
|
||
// seenHoursAgo → 13-bit per-hour seen bitmap
|
||
// bit 0 = seen in the current / most-recent hour
|
||
// bit 12 = seen 12 hours ago
|
||
// Shifts left on each rollHour(); 0 means not seen in 13 h.
|
||
|
||
/// XOR-fold + golden-ratio hash of a 32-bit node ID to 16 bits, mixed with the session seed.
|
||
/// Multiplying by floor(2^32 / φ) gives uniform avalanche; XORing the seed ensures different
|
||
/// devices (or the same device after a clear()) sample a different subset of node IDs.
|
||
/// For seed=0 the function is deterministic, which is used in PIO_UNIT_TESTING builds.
|
||
uint16_t hashNodeId(uint32_t nodeId) const { return static_cast<uint16_t>((nodeId * 2654435761u) >> 16) ^ hashSeed; }
|
||
static bool seenInLast13h(const Record &r) { return r.seenHoursAgo != 0u; }
|
||
static void markCurrentHour(Record &r) { r.seenHoursAgo |= 1u; }
|
||
static void rollSeenBits(Record &r) { r.seenHoursAgo = (r.seenHoursAgo << 1u) & 0x1FFFu; }
|
||
static bool passesFilter(uint16_t nodeHash, uint8_t denom) { return (nodeHash & static_cast<uint16_t>(denom - 1u)) == 0u; }
|
||
|
||
public:
|
||
// Clock - public so tests can share the same timebase via HopScalingModule::s_testNowMs
|
||
#ifdef PIO_UNIT_TESTING
|
||
static uint32_t nowMs() { return s_testNowMs; }
|
||
#else
|
||
static uint32_t nowMs() { return millis(); }
|
||
#endif
|
||
};
|
||
|
||
extern HopScalingModule *hopScalingModule;
|
||
|
||
#endif
|