* Add software LoRa IRQ polling and an MCP23017 expander HAL for radios behind an I2C expander
Some boards route the SX126x control lines (NRESET/DIO1/BUSY) through an I2C
GPIO expander instead of native GPIOs, and don't wire the expander's /INT to
the MCU, so RadioLib cannot take a hardware DIO1 interrupt.
Two reusable pieces, both gated behind USE_MCP23017 / LORA_DIO1_SOFTWARE_POLL
so builds that don't define them are unaffected:
- ExtensionIOMCP23017 + MCP23017LockingArduinoHal: a RadioLib HAL that maps
virtual pins (MCP23017_VPIN_BASE .. +15) onto an MCP23017's GPIO, so
RESET/BUSY/DIO1 reads and writes become I2C transactions while the real SPI
GPIOs (SCK/MOSI/MISO/CS) pass straight through. A failed I2C read skips the
read-modify-write rather than clobbering the rest of the bank.
- LORA_DIO1_SOFTWARE_POLL: with no DIO1 interrupt available, the SX126x
interface polls the radio IRQ status register from the radio thread and
synthesizes the ISR_TX/ISR_RX events, filtering noisy preamble/header IRQs so
they can't starve TX. A 1 ms ISR_POLL_TICK drives the poll; TX timers may
overwrite the pending tick (pollMissedIrqs() is the backup that bounds the
latency of the busy-Rx contention window). Behaviour is unchanged on all
boards that keep the hardware interrupt.
* Add Meshnology W10 AIOT Dev Kit variant (SX1262 via MCP23017 expander)
ESP32-S3R8 + EBYTE E22-900MM22S (SX1262) + AXP2101 PMIC + Quectel L76KB GPS +
SPI TFT, with the radio's RESET/DIO1/BUSY and the LCD reset routed through an
MCP23017 I2C expander at 0x20. The expander's /INT is not wired to the ESP32,
so DIO1 uses the software poll. Pins come from the board schematic and the
vendor firmware; the expander is an MCP23017 despite the V1.1 schematic's stale
'TCA9555' label (the V1.2 placement diagram and all vendor code use the MCP23017
register map).
A local pins_arduino.h shadows the generic esp32s3 variant to drop RGB_BUILTIN,
which otherwise pulls the Arduino RMT HAL into the link and fails against this
build's trimmed FreeRTOS config.
Reports HardwareModel 140 (MESHNOLOGY_W10, already present in the protobufs).
Hardware-verified on a real W10 AIOT Dev Kit: AXP2101 PMU, MCP23017, SX1262
init + a full over-the-air TX/RX round trip through the expander, PCF85063 RTC,
SHT41 and QMI8658 auto-detected, and an L76KB GPS fix.
* meshnology-w10: enable ES8311 speaker for notification tones
Bring up the ES8311 codec (I2C 0x18) -> NS4150 amp -> speaker so the board can
play notification tones / ringtones over the I2S buzzer path (the
use_i2s_as_buzzer external-notification option). Codec2 voice stays out of
scope: it is SX1280-only and this is a sub-GHz board.
- variant.h: HAS_I2S + DAC_I2S pins (MCLK=1, BCK=2, WS=4, DOUT=5, DIN=3)
- platformio.ini: arduino-audio-driver + ESP8266Audio + ESP8266SAM
- extra_variants/meshnology_w10/variant.cpp: lateInitVariant() configures the
ES8311, mirroring the other Meshtastic ES8311 boards. Kept codec-only (no
main.h) so the audio driver's 'using namespace audio_driver' does not pull a
conflicting GpioPin into scope alongside Meshtastic's class GpioPin.
- AudioThread.h: toggle the NS4150 amp (MCP23017 EXIO_PA_CTRL) around playback.
Verified on hardware: a test tune played audibly through the speaker.
* meshnology-w10: address review feedback on the MCP23017 driver
- ExtensionIOMCP23017: serialize register access with a mutex, since the radio
HAL (BUSY/DIO1/RESET) and AudioThread (amp enable) now reach the expander from
different threads and the read-modify-write paths are not atomic.
- digitalRead: return a fail-safe HIGH on a failed read instead of LOW, so a
transient I2C error on the LoRa BUSY line can't look like 'ready' and let
RadioLib start an SPI transaction early. (DIO1 is polled via the radio IRQ
register, not this pin.)
- enablePinChangeInterrupt: use the checked readReg() overload and skip on a
failed read, matching the other read-modify-write helpers.
- meshnology_w10 variant.cpp: log a warning if an ES8311 register write NACKs
instead of silently leaving the codec half-configured.
- RadioInterface.cpp: guard the MCP23017 HAL branch with ARCH_ESP32 to match the
include and driver-file guards.
* Replace board-model audio/sleep ifdefs with reusable variant capability macros
Two shared-code spots keyed off specific hardware models; move the board-specific
detail into opt-in macros the variants define, so the core code stays generic and
new boards can opt into the behavior class without touching shared files.
- AudioThread amp control: drop the T_LORA_PAGER / MESHNOLOGY_W10 ifdefs. A board
with an I2S amp now defines AUDIO_AMP_ENABLE(on) in its variant.h (T-LoRa Pager
and Meshnology W10 do), and AudioThread just calls it around playback. The
expander includes are keyed on USE_XL9555 / USE_MCP23017 (capabilities) instead
of the model name.
- Light-sleep / DIO1 wakeup: drop the SENSECAP_INDICATOR check. Boards whose
LORA_DIO1 is an expander pin (not an ESP32 GPIO) define LORA_DIO1_EXTENDED_IO
(SenseCAP Indicator and Meshnology W10), and sleep.cpp keys off that for both
the light-sleep skip and the DIO1 GPIO-wakeup skip. This also fixes a latent
issue on the SenseCAP: it previously reached the GPIO-wakeup path and called
gpio_pulldown_en() on a virtual expander pin; it now skips that cleanly.
Builds verified on meshnology_w10, tlora-pager, and seeed-sensecap-indicator.
* meshnology-w10: silence cppcheck constParameterPointer on the ISR-callback helper
isIsrTxCallback() takes a function pointer it only compares; cppcheck's
constParameterPointer wants it 'pointer to const', which is meaningless for a
function pointer (the codebase already globally suppresses the sibling
constParameterCallback). Inline-suppress it to keep the check green.
* HopScaling: qualify the member assignment as this->count
cppcheck (CI's version) flags 'count = newCount;' at the end of trimIfNeeded()
as uselessAssignmentArg ('assignment of function parameter has no effect') - a
false positive, since count is a member that outlives the call. Write it as
this->count (matching the Step-1 assignment above) so the analyzer sees a member
write. This finding comes in via the develop merge, not this board; fixing it
here to unblock the PR's cppcheck check.
494 lines
21 KiB
C++
494 lines
21 KiB
C++
#include "HopScalingModule.h"
|
|
#include "SafeFile.h"
|
|
#include "meshUtils.h"
|
|
|
|
#if HAS_VARIABLE_HOPS
|
|
|
|
#include "FSCommon.h"
|
|
#include "NodeDB.h"
|
|
#include "SPILock.h"
|
|
#include "concurrency/LockGuard.h"
|
|
#include "mesh-pb-constants.h"
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
|
|
namespace
|
|
{
|
|
// Module scheduling
|
|
constexpr uint32_t INITIAL_DELAY_MS = 30 * 1000UL; // Startup grace period before first run
|
|
constexpr uint32_t RUN_INTERVAL_MS = 5 * 60 * 1000UL; // Emit micro-summary every 5 minutes
|
|
// RUNS_PER_HOUR is a public class constant in HopScalingModule.h
|
|
|
|
// Persistence
|
|
// Note: this only needs incrementing if the published arrangement changes. For testing purposes, or prior to widespread release,
|
|
// it can stay the same even if the internal layout changes.
|
|
constexpr uint32_t HISTOGRAM_STATE_MAGIC = 0x48535432; // 'HST2' - layout v2
|
|
constexpr uint8_t HISTOGRAM_STATE_VERSION = 1;
|
|
constexpr const char *HISTOGRAM_STATE_FILE = "/prefs/hopScalingState.bin";
|
|
|
|
#pragma pack(push, 1)
|
|
struct PersistedHistogram {
|
|
uint32_t magic;
|
|
uint8_t version;
|
|
uint8_t samplingDenominator;
|
|
uint8_t filteringDenominator;
|
|
uint8_t filterDenomHoldRollsRemaining; // rollHour() calls remaining in the hold; 0 when expired/not active
|
|
uint16_t hashSeed;
|
|
Record entries[HopScalingModule::CAPACITY]; // full 512-byte array; count derived on load
|
|
};
|
|
#pragma pack(pop)
|
|
|
|
} // namespace
|
|
|
|
HopScalingModule *hopScalingModule;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Lifecycle
|
|
// ---------------------------------------------------------------------------
|
|
|
|
HopScalingModule::HopScalingModule() : concurrency::OSThread("HopScaling")
|
|
{
|
|
clear();
|
|
loadFromDisk();
|
|
setIntervalFromNow(INITIAL_DELAY_MS);
|
|
}
|
|
|
|
void HopScalingModule::clear()
|
|
{
|
|
memset(entries, 0, sizeof(entries));
|
|
this->count = 0;
|
|
samplingDenominator = DENOM_MIN;
|
|
filteringDenominator = DENOM_MIN;
|
|
filteringDenomHoldRollsRemaining = 0;
|
|
lastPerHopCounts = {};
|
|
lastSuggestedHop = MAX_HOP;
|
|
lastPoliteNumer = POLITENESS_DEFAULT;
|
|
lastTrendStats = {};
|
|
memset(denominatorHistory, DENOM_MIN, sizeof(denominatorHistory));
|
|
#ifndef PIO_UNIT_TESTING
|
|
hashSeed = static_cast<uint16_t>(random());
|
|
#else
|
|
hashSeed = 0; // deterministic in unit tests
|
|
#endif
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Persistence
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void HopScalingModule::saveToDisk() const
|
|
{
|
|
#ifdef FSCom
|
|
FSCom.mkdir("/prefs");
|
|
PersistedHistogram state{};
|
|
state.magic = HISTOGRAM_STATE_MAGIC;
|
|
state.version = HISTOGRAM_STATE_VERSION;
|
|
state.samplingDenominator = samplingDenominator;
|
|
state.filteringDenominator = filteringDenominator;
|
|
state.filterDenomHoldRollsRemaining = filteringDenomHoldRollsRemaining;
|
|
state.hashSeed = hashSeed;
|
|
// Save all CAPACITY slots; count is reconstructed on load by scanning seenHoursAgo.
|
|
memcpy(state.entries, entries, sizeof(state.entries));
|
|
auto file = SafeFile(HISTOGRAM_STATE_FILE, true);
|
|
const size_t written = file.write(reinterpret_cast<const uint8_t *>(&state), sizeof(state));
|
|
if (file.close() && written == sizeof(state)) {
|
|
LOG_DEBUG("[HOPSCALE] Saved: count=%u samp=1/%u filt=1/%u holdRollsRemaining=%u", count, samplingDenominator,
|
|
filteringDenominator, state.filterDenomHoldRollsRemaining);
|
|
} else {
|
|
LOG_WARN("[HOPSCALE] Failed to write %s (%u of %u bytes)", HISTOGRAM_STATE_FILE, static_cast<unsigned>(written),
|
|
static_cast<unsigned>(sizeof(state)));
|
|
}
|
|
#endif
|
|
}
|
|
|
|
void HopScalingModule::loadFromDisk()
|
|
{
|
|
#ifdef FSCom
|
|
concurrency::LockGuard g(spiLock);
|
|
auto file = FSCom.open(HISTOGRAM_STATE_FILE, FILE_O_READ);
|
|
if (!file)
|
|
return;
|
|
PersistedHistogram state{};
|
|
const bool readOk = (file.read(reinterpret_cast<uint8_t *>(&state), sizeof(state)) == sizeof(state));
|
|
file.close();
|
|
// Validate magic, version, denom range, denom power-of-two invariant, and hold counter.
|
|
if (!readOk || state.magic != HISTOGRAM_STATE_MAGIC || state.version != HISTOGRAM_STATE_VERSION ||
|
|
state.samplingDenominator < DENOM_MIN || state.samplingDenominator > DENOM_MAX ||
|
|
state.filteringDenominator < state.samplingDenominator || state.filteringDenominator > DENOM_MAX ||
|
|
!is_pow_of_2(state.samplingDenominator) || !is_pow_of_2(state.filteringDenominator) ||
|
|
state.filterDenomHoldRollsRemaining > FILTER_DENOM_HOLD_ROLLS) {
|
|
LOG_DEBUG("[HOPSCALE] No valid persisted state (magic=%08x ver=%u samp=%u filt=%u hold=%u), starting fresh", state.magic,
|
|
state.version, state.samplingDenominator, state.filteringDenominator, state.filterDenomHoldRollsRemaining);
|
|
return;
|
|
}
|
|
// Derive count by scanning: active entries have seenHoursAgo != 0; pack them to the front.
|
|
|
|
uint8_t restored = 0;
|
|
for (uint8_t i = 0; i < CAPACITY && restored < CAPACITY; i++) {
|
|
if (state.entries[i].seenHoursAgo != 0u) {
|
|
entries[restored++] = state.entries[i];
|
|
}
|
|
}
|
|
count = restored;
|
|
samplingDenominator = state.samplingDenominator;
|
|
filteringDenominator = state.filteringDenominator;
|
|
filteringDenomHoldRollsRemaining = state.filterDenomHoldRollsRemaining;
|
|
// denominatorHistory can't be recovered; initialise all slots to filteringDenominator so
|
|
// the first few post-reboot scaledPerHour values use a safe (slightly conservative) multiplier.
|
|
memset(denominatorHistory, filteringDenominator, sizeof(denominatorHistory));
|
|
hashSeed = state.hashSeed;
|
|
LOG_INFO("[HOPSCALE] Restored: count=%u samp=1/%u filt=1/%u holdRollsRemaining=%u", count, samplingDenominator,
|
|
filteringDenominator, state.filterDenomHoldRollsRemaining);
|
|
#endif
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Core API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void HopScalingModule::samplePacketForHistogram(uint32_t nodeId, uint8_t hopCount)
|
|
{
|
|
const uint16_t hash = hashNodeId(nodeId);
|
|
|
|
if (!passesFilter(hash, samplingDenominator))
|
|
return;
|
|
|
|
hopCount = std::min(hopCount, MAX_HOP);
|
|
|
|
// Update an existing entry
|
|
Record *entry = nullptr;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
if (entries[i].nodeHash == hash) {
|
|
entry = &entries[i];
|
|
break;
|
|
}
|
|
}
|
|
if (entry) {
|
|
entry->hops_away = hopCount;
|
|
markCurrentHour(*entry);
|
|
return;
|
|
}
|
|
|
|
// New node: trim if necessary before allocating a slot
|
|
if (getFillPercentage() >= FILL_HIGH_PCT) {
|
|
trimIfNeeded();
|
|
}
|
|
|
|
if (count < CAPACITY) {
|
|
entries[count].nodeHash = hash;
|
|
entries[count].hops_away = hopCount;
|
|
entries[count].seenHoursAgo = 1u; // mark current hour
|
|
this->count++;
|
|
} else {
|
|
LOG_WARN("[HOPSCALE] Histogram full at samp=1/%u (DENOM_MAX=%u); dropping node hash=0x%04x; hop recommendation may be "
|
|
"skewed!!!",
|
|
samplingDenominator, DENOM_MAX, hash);
|
|
}
|
|
}
|
|
|
|
void HopScalingModule::rollHour()
|
|
{
|
|
// Advance denominatorHistory before the tally so each slot h holds the filteringDenominator
|
|
// that was active when seenHoursAgo bit h was set. hourlyRaw[h] is then gated per-slot by
|
|
// denominatorHistory[h], giving a correct population estimate for each historical hour even
|
|
// when filteringDenominator changes between rolls. Scale-up backfills the entire array so
|
|
// the invariant holds retroactively (see trimIfNeeded()).
|
|
for (uint8_t h = 12; h > 0; h--)
|
|
denominatorHistory[h] = denominatorHistory[h - 1];
|
|
denominatorHistory[0] = filteringDenominator;
|
|
|
|
// 1. Tally per-hop counts and per-slot hourly activity in one pass.
|
|
// hourlyRaw[h]: gated per-slot by denominatorHistory[h] so the raw count and its
|
|
// multiplier are always consistent, even across filteringDenominator transitions.
|
|
// counts.*: gated uniformly by the current filteringDenominator for a consistent
|
|
// population estimate used by the hop-walk recommendation (step 2).
|
|
PerHopCounts counts{};
|
|
uint16_t hourlyRaw[13] = {};
|
|
uint16_t trendNewThisHour = 0;
|
|
uint16_t trendReturning = 0;
|
|
uint16_t trendLapsed = 0;
|
|
uint16_t trendOlderThan4h = 0;
|
|
uint16_t trendAgingOut = 0;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
const uint16_t hash = entries[i].nodeHash;
|
|
const uint32_t seen = entries[i].seenHoursAgo;
|
|
|
|
// Per-slot hourly activity: gate each slot by its own denominator.
|
|
for (uint8_t h = 0; h < 13; h++) {
|
|
if ((seen & (1u << h)) && passesFilter(hash, denominatorHistory[h]))
|
|
hourlyRaw[h]++;
|
|
}
|
|
|
|
// Hop counts and trend stats: uniform current-denominator gate.
|
|
if (!passesFilter(hash, filteringDenominator))
|
|
continue;
|
|
|
|
if (seenInLast13h(entries[i])) {
|
|
counts.perHop[entries[i].hops_away]++;
|
|
counts.total++;
|
|
}
|
|
const bool heardThisHour = (seen & 1u) != 0u;
|
|
const bool heardLastHour = (seen & 2u) != 0u;
|
|
const bool hasOlderHistory = (seen >> 1u) != 0u;
|
|
const bool recentlySilent = (seen & 0xFu) == 0u;
|
|
if (heardThisHour && !hasOlderHistory)
|
|
trendNewThisHour++;
|
|
else if (heardThisHour && hasOlderHistory)
|
|
trendReturning++;
|
|
if (!heardThisHour && heardLastHour)
|
|
trendLapsed++;
|
|
if (recentlySilent && (seen & 0x1FF0u) != 0u)
|
|
trendOlderThan4h++;
|
|
if (seen == (1u << 12u))
|
|
trendAgingOut++;
|
|
}
|
|
lastPerHopCounts = counts;
|
|
|
|
// 1b. Compute politeness factor from the 0-2 h vs 1-3 h activity ratio.
|
|
{
|
|
const uint32_t recent = static_cast<uint32_t>(hourlyRaw[0]) + hourlyRaw[1];
|
|
const uint32_t older = static_cast<uint32_t>(hourlyRaw[1]) + hourlyRaw[2];
|
|
if (older > 1 && recent > 1) {
|
|
const uint32_t r = static_cast<uint32_t>(recent) * ACTIVITY_WEIGHT_SCALE;
|
|
const uint32_t o = static_cast<uint32_t>(older);
|
|
if (r < o * ACTIVITY_WEIGHT_GENEROUS_MAX_NUMER)
|
|
lastPoliteNumer = POLITENESS_GENEROUS;
|
|
else if (r > o * ACTIVITY_WEIGHT_STRICT_MIN_NUMER)
|
|
lastPoliteNumer = POLITENESS_STRICT;
|
|
else
|
|
lastPoliteNumer = POLITENESS_DEFAULT;
|
|
} else {
|
|
lastPoliteNumer = POLITENESS_DEFAULT;
|
|
}
|
|
}
|
|
|
|
// 1c. Scale and cache trend stats (denominatorHistory already advanced above).
|
|
{
|
|
MeshTrendStats t{};
|
|
for (uint8_t h = 0; h < 13; h++) {
|
|
const uint32_t s = static_cast<uint32_t>(hourlyRaw[h]) * denominatorHistory[h];
|
|
t.scaledPerHour[h] = static_cast<uint16_t>(std::min<uint32_t>(s, UINT16_MAX));
|
|
}
|
|
auto scale = [&](uint16_t raw) -> uint16_t {
|
|
return static_cast<uint16_t>(std::min<uint32_t>(static_cast<uint32_t>(raw) * filteringDenominator, UINT16_MAX));
|
|
};
|
|
t.newThisHour = scale(trendNewThisHour);
|
|
t.returningThisHour = scale(trendReturning);
|
|
t.lapsedSinceLastHour = scale(trendLapsed);
|
|
t.olderThan4h = scale(trendOlderThan4h);
|
|
t.agingOut = scale(trendAgingOut);
|
|
lastTrendStats = t;
|
|
}
|
|
|
|
// 2. Walk scaled hop buckets to produce a hop-limit recommendation.
|
|
// effectiveMin: walk threshold - first hop whose cumulative count reaches this.
|
|
// effectiveMax: ceiling on the one-hop extension check with GENEROUS politeness.
|
|
const uint16_t effectiveMin = TARGET_AFFECTED_NODES;
|
|
const uint16_t effectiveMax = MAX_TARGET_NODES;
|
|
uint8_t suggested = MAX_HOP;
|
|
if (counts.total > 0) {
|
|
uint32_t cumulative = 0;
|
|
for (uint8_t hop = 0; hop <= MAX_HOP; hop++) {
|
|
cumulative += static_cast<uint32_t>(counts.perHop[hop]) * filteringDenominator;
|
|
if (cumulative >= effectiveMin) {
|
|
suggested = hop;
|
|
break;
|
|
}
|
|
}
|
|
if (suggested < MAX_HOP) {
|
|
const uint32_t atNext = static_cast<uint32_t>(counts.perHop[suggested + 1]) * filteringDenominator;
|
|
// politeLimit = effectiveMin + gap * politeNumer / POLITENESS_DENOM
|
|
// Multiply both sides by POLITENESS_DENOM to stay in integers.
|
|
const uint32_t gap = static_cast<uint32_t>(effectiveMax) - static_cast<uint32_t>(effectiveMin);
|
|
if ((cumulative + atNext) * POLITENESS_DENOM <=
|
|
static_cast<uint32_t>(effectiveMin) * POLITENESS_DENOM + gap * lastPoliteNumer) {
|
|
suggested++;
|
|
}
|
|
}
|
|
}
|
|
lastSuggestedHop = suggested;
|
|
|
|
// 3. Log scaled per-hop counts and recommendation.
|
|
{
|
|
uint16_t scaled[MAX_HOP + 1];
|
|
for (uint8_t h = 0; h <= MAX_HOP; h++) {
|
|
const uint32_t s = static_cast<uint32_t>(counts.perHop[h]) * filteringDenominator;
|
|
scaled[h] = static_cast<uint16_t>(std::min<uint32_t>(s, UINT16_MAX));
|
|
}
|
|
const uint32_t scaledTotal = static_cast<uint32_t>(counts.total) * filteringDenominator;
|
|
memcpy(lastScaledPerHop, scaled, sizeof(lastScaledPerHop));
|
|
LOG_INFO("[HOPSCALE] rollHour: entries=%u/128 samp=1/%u filt=1/%u counted=%u est=%u suggestedHop=%u polite=%u/4", count,
|
|
samplingDenominator, filteringDenominator, counts.total, static_cast<unsigned>(scaledTotal), suggested,
|
|
lastPoliteNumer);
|
|
|
|
const auto &ts = lastTrendStats;
|
|
LOG_INFO("[HOPSCALE] scaledSeenPerHour (h0=now): [%u %u %u %u %u %u %u %u %u %u %u %u %u]", ts.scaledPerHour[0],
|
|
ts.scaledPerHour[1], ts.scaledPerHour[2], ts.scaledPerHour[3], ts.scaledPerHour[4], ts.scaledPerHour[5],
|
|
ts.scaledPerHour[6], ts.scaledPerHour[7], ts.scaledPerHour[8], ts.scaledPerHour[9], ts.scaledPerHour[10],
|
|
ts.scaledPerHour[11], ts.scaledPerHour[12]);
|
|
LOG_INFO("[HOPSCALE] trend: new=%u returning=%u lapsed=%u olderThan4h=%u agingOut=%u", ts.newThisHour,
|
|
ts.returningThisHour, ts.lapsedSinceLastHour, ts.olderThan4h, ts.agingOut);
|
|
}
|
|
|
|
// 4. Scale-down check: if fewer than FILL_LOW_PCT% of capacity pass the filteringDenominator
|
|
// gate and are active, halve samplingDenominator to admit more nodes.
|
|
// Note: during a filteringDenominator hold period, lowering samplingDenominator does not
|
|
// immediately improve counts.total (new admissions don't pass the elevated
|
|
// filteringDenominator). On a genuinely quieting mesh this check can therefore fire on
|
|
// consecutive hours, cascading samplingDenominator toward DENOM_MIN. This is intentional:
|
|
// rapid re-admission allows quick recovery if the mesh returns. The hop recommendation
|
|
// stays conservative (MAX_HOP) throughout because filteringDenominator remains elevated;
|
|
// step 5 below re-synchronises the denominators once the hold expires.
|
|
if (counts.total * 100u < static_cast<uint32_t>(CAPACITY) * FILL_LOW_PCT) {
|
|
if (samplingDenominator > DENOM_MIN) {
|
|
samplingDenominator = static_cast<uint8_t>(samplingDenominator / 2u);
|
|
LOG_INFO("[HOPSCALE] Scale-down: sampling denom halved to %u (filter denom=%u)", samplingDenominator,
|
|
filteringDenominator);
|
|
}
|
|
}
|
|
|
|
// 5. Tick down the hold counter; once it reaches zero, halve filteringDenominator toward
|
|
// samplingDenominator once per rollHour() (= once per hour) rather than a single jump:
|
|
// avoids a sudden large change in the hop-walk count when samplingDenominator cascaded
|
|
// down significantly during the hold period. No new hold is placed on each step - the
|
|
// 13-roll hold already guaranteed that re-admitted nodes have full seenHoursAgo history;
|
|
// further pacing is provided naturally by the 1-step-per-hour rate. denominatorHistory
|
|
// is updated automatically by the shift at the top of rollHour(), so no backfill here.
|
|
if (filteringDenominator > samplingDenominator) {
|
|
if (filteringDenomHoldRollsRemaining > 0)
|
|
filteringDenomHoldRollsRemaining--;
|
|
if (filteringDenomHoldRollsRemaining == 0) {
|
|
const uint8_t stepped = static_cast<uint8_t>(filteringDenominator / 2u);
|
|
filteringDenominator = (stepped > samplingDenominator) ? stepped : samplingDenominator;
|
|
LOG_INFO("[HOPSCALE] Filter denom stepped to %u (samp=1/%u)", filteringDenominator, samplingDenominator);
|
|
}
|
|
}
|
|
|
|
// 6. Shift all seen bitmaps left by one slot (opens a fresh slot for the new hour).
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
rollSeenBits(entries[i]);
|
|
}
|
|
|
|
if (histogramRollCount < 255)
|
|
histogramRollCount++;
|
|
|
|
saveToDisk();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void HopScalingModule::trimIfNeeded()
|
|
{
|
|
// Step 1: evict stale entries (not seen in any of the past 13 hours).
|
|
uint8_t newCount = 0;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
if (seenInLast13h(entries[i])) {
|
|
if (i != newCount) {
|
|
entries[newCount] = entries[i];
|
|
}
|
|
newCount++;
|
|
}
|
|
}
|
|
this->count = newCount;
|
|
|
|
// Step 2: if still too full, double the sampling denominator and remove non-matching entries.
|
|
if (getFillPercentage() >= FILL_HIGH_PCT && samplingDenominator < DENOM_MAX) {
|
|
samplingDenominator = static_cast<uint8_t>(
|
|
std::min<uint16_t>(static_cast<uint16_t>(samplingDenominator) * 2u, static_cast<uint16_t>(DENOM_MAX)));
|
|
filteringDenominator = std::max(filteringDenominator, samplingDenominator);
|
|
filteringDenomHoldRollsRemaining = FILTER_DENOM_HOLD_ROLLS;
|
|
// Raise any denominatorHistory slot that is below the new filteringDenominator.
|
|
// Slots already above it (recorded during a prior scale-up that hasn't fully stepped
|
|
// down yet) are left untouched: eviction at samplingDenominator retains exactly those
|
|
// entries, so the old higher gate remains accurate for those historical hours.
|
|
// Slots below the new value must be raised because the eviction removed entries that
|
|
// had been admitted at the looser old gate - the remaining entries represent a 1/N
|
|
// subsample where N is the new filteringDenominator, not the old smaller value.
|
|
for (uint8_t h = 0; h < 13; h++)
|
|
denominatorHistory[h] = std::max(denominatorHistory[h], filteringDenominator);
|
|
LOG_INFO("[HOPSCALE] Scale-up: samp denom doubled to %u (filt=%u)", samplingDenominator, filteringDenominator);
|
|
|
|
newCount = 0;
|
|
for (uint8_t i = 0; i < count; i++) {
|
|
if (passesFilter(entries[i].nodeHash, samplingDenominator)) {
|
|
if (i != newCount) {
|
|
entries[newCount] = entries[i];
|
|
}
|
|
newCount++;
|
|
}
|
|
}
|
|
this->count = newCount;
|
|
}
|
|
}
|
|
|
|
void HopScalingModule::logStatusReport(bool didHourlyUpdate) const
|
|
{
|
|
const bool histActive = (histogramRollCount > 0 && count > 0);
|
|
const auto &histCounts = lastPerHopCounts;
|
|
const uint8_t runsRemaining = didHourlyUpdate ? RUNS_PER_HOUR : (RUNS_PER_HOUR - runsSinceLastHourlyUpdate);
|
|
const uint8_t minsUntilRollover = runsRemaining * (RUN_INTERVAL_MS / (60 * 1000UL));
|
|
|
|
LOG_INFO("[HOPSCALE] hop=%u histActive=%u fill=%u%% samp=1/%u filt=1/%u entries=%u lastCounted=%u polite=%u/4 "
|
|
"nextRoll=%umin",
|
|
lastRequiredHop, histActive ? 1u : 0u, getFillPercentage(), samplingDenominator, filteringDenominator, count,
|
|
histCounts.total, lastPoliteNumer, minsUntilRollover);
|
|
|
|
LOG_INFO("[HOPSCALE] nodes perHop: [%u %u %u %u %u %u %u %u]", histCounts.perHop[0], histCounts.perHop[1],
|
|
histCounts.perHop[2], histCounts.perHop[3], histCounts.perHop[4], histCounts.perHop[5], histCounts.perHop[6],
|
|
histCounts.perHop[7]);
|
|
LOG_INFO("[HOPSCALE] last scaled perHop: [%u %u %u %u %u %u %u %u]", lastScaledPerHop[0], lastScaledPerHop[1],
|
|
lastScaledPerHop[2], lastScaledPerHop[3], lastScaledPerHop[4], lastScaledPerHop[5], lastScaledPerHop[6],
|
|
lastScaledPerHop[7]);
|
|
}
|
|
|
|
int32_t HopScalingModule::runOnce()
|
|
{
|
|
const bool isFirstRun = !hasCompletedInitialRun;
|
|
bool didHourlyUpdate = false;
|
|
|
|
if (isFirstRun) {
|
|
hasCompletedInitialRun = true;
|
|
runsSinceLastHourlyUpdate = 0;
|
|
didHourlyUpdate = true;
|
|
} else {
|
|
runsSinceLastHourlyUpdate++;
|
|
if (runsSinceLastHourlyUpdate >= RUNS_PER_HOUR) {
|
|
runsSinceLastHourlyUpdate = 0;
|
|
didHourlyUpdate = true;
|
|
}
|
|
}
|
|
|
|
if (didHourlyUpdate && !isFirstRun) {
|
|
rollHour();
|
|
}
|
|
|
|
if (didHourlyUpdate) {
|
|
uint8_t suggested = (histogramRollCount > 0 && count > 0) ? lastSuggestedHop : HOP_MAX;
|
|
// Role-based hop floor: TRACKER/TAK_TRACKER always reach at least 2 hops,
|
|
// SENSOR reaches at least 1, so these reporting roles remain reachable even
|
|
// on a dense mesh where the histogram recommends a lower hop count.
|
|
uint8_t roleFloor = 0;
|
|
switch (config.device.role) {
|
|
case meshtastic_Config_DeviceConfig_Role_TRACKER:
|
|
case meshtastic_Config_DeviceConfig_Role_TAK_TRACKER:
|
|
roleFloor = 2;
|
|
break;
|
|
case meshtastic_Config_DeviceConfig_Role_SENSOR:
|
|
roleFloor = 1;
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
lastRequiredHop = std::max(suggested, roleFloor);
|
|
}
|
|
|
|
logStatusReport(didHourlyUpdate);
|
|
|
|
return RUN_INTERVAL_MS;
|
|
}
|
|
|
|
#endif
|