diff --git a/platformio.ini b/platformio.ini index a1dd0d310..74916381f 100644 --- a/platformio.ini +++ b/platformio.ini @@ -193,6 +193,8 @@ lib_deps = https://github.com/DFRobot/DFRobot_RTU/archive/refs/tags/V1.0.6.zip # renovate: datasource=git-refs depName=DFRobot_RainfallSensor packageName=https://github.com/DFRobot/DFRobot_RainfallSensor gitBranch=master https://github.com/DFRobot/DFRobot_RainfallSensor/archive/38fea5e02b40a5430be6dab39a99a6f6347d667e.zip + # renovate: datasource=github-tags depName=SparkFun AS3935 packageName=sparkfun/SparkFun_AS3935_Lightning_Detector_Arduino_Library + https://github.com/sparkfun/SparkFun_AS3935_Lightning_Detector_Arduino_Library/archive/refs/tags/v1.4.9.zip # renovate: datasource=github-tags depName=INA226 packageName=robtillaart/INA226 https://github.com/RobTillaart/INA226/archive/refs/tags/0.6.6.zip # renovate: datasource=github-tags depName=SparkFun MAX3010x packageName=sparkfun/SparkFun_MAX3010x_Sensor_Library diff --git a/src/configuration.h b/src/configuration.h index 03cb12bf3..5c9cdf956 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -317,6 +317,9 @@ along with this program. If not, see . #define DS248X_ADDR_ALT6 0x1E // same as HMC5883L_ADDR #define DS248X_ADDR_ALT7 0x1F // same as BBQ10_KB_ADDR #define HM330X_ADDR 0x40 +#define AS3935_ADDR 0x03 // both address pins tied high, the common breakout-board default +#define AS3935_ADDR_ALT 0x01 +#define AS3935_ADDR_ALT2 0x02 // ----------------------------------------------------------------------------- // ACCELEROMETER diff --git a/src/detect/ScanI2C.h b/src/detect/ScanI2C.h index c36434ae7..4bb141722 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -108,7 +108,8 @@ class ScanI2C SPA06, STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9) DS248X, - HM330X + HM330X, + AS3935 } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 9085763d5..d9c9d7717 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -1095,6 +1095,40 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) } } +#if HAS_TELEMETRY && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR + // AS3935 addresses (0x01-0x03) fall in the reserved range the loop above skips; probe + // them separately rather than widening that loop for every board. + static const uint8_t as3935Candidates[] = {AS3935_ADDR_ALT, AS3935_ADDR_ALT2, AS3935_ADDR}; + for (uint8_t i = 0; i < sizeof(as3935Candidates); i++) { + // Respect the caller's address filter, same as the main loop above (line ~269). + if (asize != 0 && !in_array(address, asize, as3935Candidates[i])) + continue; + + DeviceAddress as3935Addr(port, as3935Candidates[i]); + i2cBus->beginTransmission(as3935Candidates[i]); + uint8_t as3935Err = i2cBus->endTransmission(); + if (as3935Err == 0) { + // No WHOAMI, and a POR-only check can't survive a warm reboot (initDevice rewrites + // REG0x00). Write a test pattern to bits[5:1] instead and confirm it reads back. + constexpr uint8_t AS3935_PROBE_PATTERN = 0b01010; // arbitrary, bits[5:1] + i2cBus->beginTransmission(as3935Candidates[i]); + i2cBus->write((uint8_t)0x00); // REG0x00 (AFE_GAIN) + i2cBus->write((uint8_t)(AS3935_PROBE_PATTERN << 1)); // PWD=0, gain bits = pattern + if (i2cBus->endTransmission() == 0) { + uint16_t reg0 = getRegisterValue(ScanI2CTwoWire::RegisterLocation(as3935Addr, 0x00), 1); + if (((reg0 >> 1) & 0x1F) == AS3935_PROBE_PATTERN) { + logFoundDevice("AS3935", as3935Candidates[i]); + deviceAddresses[AS3935] = as3935Addr; + foundDevices[as3935Addr] = AS3935; + break; // only one AS3935 expected per bus + } else { + LOG_DEBUG("Unexpected REG0x00 readback for AS3935: addr=0x%x val=0x%x", as3935Candidates[i], reg0); + } + } + } + } +#endif + // The QMC6309 magnetometer sits at 0x7C, above the general scan ceiling (the loop above stops at 0x77 to // avoid the reserved 0x78-0x7F block). Probe it explicitly. Gated on the SensorLib driver being present so // only boards that can actually drive the chip poke this reserved address. diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 546651c5b..a2de555e9 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -223,7 +223,7 @@ void setupModules() #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR if (moduleConfig.has_telemetry && (moduleConfig.telemetry.environment_measurement_enabled || moduleConfig.telemetry.environment_screen_enabled)) { - new EnvironmentTelemetryModule(); + environmentTelemetryModule = new EnvironmentTelemetryModule(); } #if HAS_TELEMETRY && HAS_SENSOR && !MESHTASTIC_EXCLUDE_AIR_QUALITY_SENSOR if (moduleConfig.has_telemetry && diff --git a/src/modules/Telemetry/EnvironmentTelemetry.cpp b/src/modules/Telemetry/EnvironmentTelemetry.cpp index aae103e24..a4143de29 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.cpp +++ b/src/modules/Telemetry/EnvironmentTelemetry.cpp @@ -102,6 +102,10 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include "Sensor/DFRobotGravitySensor.h" #endif +#if __has_include() +#include "Sensor/AS3935Sensor.h" +#endif + #if __has_include() #include "Sensor/NAU7802Sensor.h" #endif @@ -154,6 +158,7 @@ EnvironmentTelemetryModule::DisplaySource gDisplaySource = EnvironmentTelemetryM } // namespace static constexpr uint16_t TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY = 0x8002; +static constexpr uint32_t IMMEDIATE_SEND_MAX_STALENESS_MS = 5UL * 60UL * 1000; // 5 minutes static constexpr uint32_t LOCAL_DISPLAY_REFRESH_INTERVAL_MS = 1000; EnvironmentTelemetryModule::DisplaySource EnvironmentTelemetryModule::getDisplaySource() @@ -294,6 +299,9 @@ void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::DFROBOT_RAIN); #endif +#if __has_include() + addSensor(i2cScanner, ScanI2C::DeviceType::AS3935); +#endif #if __has_include() addSensor(i2cScanner, ScanI2C::DeviceType::AHT10); #endif @@ -434,9 +442,15 @@ int32_t EnvironmentTelemetryModule::runOnce() } refreshDisplayedMeasurement(); + // Give up on a stale immediate-send request rather than fire an arbitrarily late broadcast. + if (immediateSendRequested && + !Throttle::isWithinTimespanMs(immediateSendRequestedAtMs, IMMEDIATE_SEND_MAX_STALENESS_MS)) { + immediateSendRequested = false; + } + uint32_t lastTelemetry = transmitHistory ? transmitHistory->getLastSentToMeshMillis(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY) : 0; - if (((lastTelemetry == 0) || + if (((lastTelemetry == 0) || immediateSendRequested || !Throttle::isWithinTimespanMs( lastTelemetry, Default::getConfiguredOrDefaultMsScaled(moduleConfig.telemetry.environment_update_interval, default_telemetry_broadcast_interval_secs, numOnlineNodes, @@ -444,6 +458,7 @@ int32_t EnvironmentTelemetryModule::runOnce() airTime->isTxAllowedChannelUtil(config.device.role != meshtastic_Config_DeviceConfig_Role_SENSOR) && airTime->isTxAllowedAirUtil()) { sendTelemetry(); + immediateSendRequested = false; if (transmitHistory) transmitHistory->setLastSentToMesh(TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY); } else if (((lastSentToPhone == 0) || !Throttle::isWithinTimespanMs(lastSentToPhone, sendToPhoneIntervalMs)) && @@ -811,6 +826,10 @@ bool EnvironmentTelemetryModule::sendTelemetry(NodeNum dest, bool phoneOnly) m.variant.environment_metrics.adc_voltage_ch5, m.variant.environment_metrics.adc_voltage_ch6, m.variant.environment_metrics.adc_voltage_ch7); + if (m.variant.environment_metrics.has_lightning_strike_count_1h) + LOG_INFO("Send: lightning=%u, distance=%fkm", m.variant.environment_metrics.lightning_strike_count_1h, + m.variant.environment_metrics.lightning_distance_km); + meshtastic_MeshPacket *p = allocDataProtobuf(m); if (!p) { validTelemetry = false; diff --git a/src/modules/Telemetry/EnvironmentTelemetry.h b/src/modules/Telemetry/EnvironmentTelemetry.h index 6d5678b35..0aabe8647 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.h +++ b/src/modules/Telemetry/EnvironmentTelemetry.h @@ -56,6 +56,14 @@ class EnvironmentTelemetryModule : private concurrency::OSThread, virtual void drawFrame(OLEDDisplay *display, OLEDDisplayUiState *state, int16_t x, int16_t y) override; #endif + /** Bypass the normal broadcast throttle once, for a sensor with a noteworthy event to + * report sooner than the next scheduled send (airtime limits still apply). */ + void requestImmediateSend() + { + immediateSendRequested = true; + immediateSendRequestedAtMs = millis(); + } + protected: /** Called to handle a particular incoming message @return true if you've guaranteed you've handled this message and no other handlers should be considered for it @@ -87,6 +95,8 @@ class EnvironmentTelemetryModule : private concurrency::OSThread, bool shouldDisplayRemoteNode(NodeNum nodeNum) const; bool firstTime = 1; + bool immediateSendRequested = false; + uint32_t immediateSendRequestedAtMs = 0; meshtastic_MeshPacket *lastMeasurementPacket; uint32_t lastLocalDisplayRefreshMs = 0; uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute diff --git a/src/modules/Telemetry/Sensor/AS3935Sensor.cpp b/src/modules/Telemetry/Sensor/AS3935Sensor.cpp new file mode 100644 index 000000000..e8790fd92 --- /dev/null +++ b/src/modules/Telemetry/Sensor/AS3935Sensor.cpp @@ -0,0 +1,225 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "AS3935Sensor.h" +#include "FSCommon.h" +#include "SPILock.h" +#include "SafeFile.h" +#include "TelemetrySensor.h" +#include "modules/Telemetry/EnvironmentTelemetry.h" +#include +#include +#include + +namespace +{ +// No attachInterrupt(): the interrupt latches until read, so polling can't miss it, and the +// I2C read itself isn't ISR-safe anyway. AS3935_IRQ is optional - see runOnce(). +constexpr int32_t AS3935_CHECK_INTERVAL_MS = DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; +constexpr uint8_t AS3935_DISTANCE_OUT_OF_RANGE = 0x3F; +} // namespace + +// Fallback until an admin message sets one; 96pF is DFRobot's value for the SEN0290. +#ifndef AS3935_TUNING_CAP_PF +#define AS3935_TUNING_CAP_PF 96 +#endif +static_assert(AS3935_TUNING_CAP_PF % 8 == 0 && AS3935_TUNING_CAP_PF <= 120, + "AS3935_TUNING_CAP_PF must be a multiple of 8, at most 120 - tuneCap() silently ignores other values"); + +AS3935Sensor::AS3935Sensor() : TelemetrySensor(meshtastic_TelemetrySensorType_AS3935, "AS3935") {} + +AS3935Sensor::~AS3935Sensor() +{ + if (lightning) { +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wdelete-non-virtual-dtor" + delete lightning; +#pragma GCC diagnostic pop + lightning = nullptr; + } +} + +bool AS3935Sensor::initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) +{ + LOG_INFO("Init sensor: %s", sensorName); + + lightning = new SparkFun_AS3935(dev->address.address); + status = lightning->begin(*bus); + if (!status) { + initI2CSensor(); + return status; + } + + // Oscillators are tuned to the antenna resonance; calibration affects strike detection thresholds. + if (!lightning->calibrateOsc()) { + LOG_WARN("%s: oscillator calibration failed", sensorName); + } + + // Defaults match the library's own example, except outdoor mode. Disturbers are masked in + // the chip - runOnce() polls every second, so an unmasked noisy site never goes quiet. + lightning->setIndoorOutdoor(OUTDOOR); + lightning->setNoiseLevel(2); + lightning->watchdogThreshold(2); + lightning->spikeRejection(2); + lightning->maskDisturber(true); + lightning->lightningThreshold(1); + + // Applied last: the RCO calibration above uses the antenna oscillator as its reference. + if (!loadCalibrationData()) + as3935config.tuning_cap_pf = AS3935_TUNING_CAP_PF; + if (!setTuningCap(as3935config.tuning_cap_pf)) { + LOG_WARN("%s: bad stored cap %upF", sensorName, as3935config.tuning_cap_pf); + setTuningCap(AS3935_TUNING_CAP_PF); + } + +#ifdef AS3935_IRQ + pinMode(AS3935_IRQ, INPUT); +#endif + // Drain anything already latched, so we don't report a strike that predates us. + lightning->readInterruptReg(); + + initI2CSensor(); + return status; +} + +int32_t AS3935Sensor::runOnce() +{ +#ifdef AS3935_IRQ + // IRQ wired: only spend an I2C transaction once the pin says something is latched. + if (digitalRead(AS3935_IRQ) == HIGH) { + classifyPendingIrq(); + } +#else + // I2C-only breakout: poll the register instead, it reads back 0 when nothing is pending. + classifyPendingIrq(); +#endif + return AS3935_CHECK_INTERVAL_MS; +} + +void AS3935Sensor::classifyPendingIrq() +{ + uint8_t interruptReason = lightning->readInterruptReg(); + switch (interruptReason) { + case LIGHTNING: { + strikes.add(); + uint8_t distance = lightning->distanceToStorm(); + if (distance != AS3935_DISTANCE_OUT_OF_RANGE) { + lastDistanceKm = distance; + LOG_INFO("%s: strike %dkm", sensorName, distance); + } else { + LOG_INFO("%s: strike, distance unknown", sensorName); + } + // No debounce here - EnvironmentTelemetryModule's airtime gate already paces every send. + if (environmentTelemetryModule) { + environmentTelemetryModule->requestImmediateSend(); + } + break; + } + case NOISE_TO_HIGH: + LOG_DEBUG("%s: noise floor high", sensorName); + break; + default: + break; + } +} + +bool AS3935Sensor::setTuningCap(uint32_t pf) +{ + if (pf > 120 || pf % 8 != 0) + return false; + + lightning->tuneCap(pf); + as3935config.tuning_cap_pf = pf; + // Readback, not pf: the only evidence the register write actually landed. + LOG_INFO("%s: tuning cap %upF", sensorName, lightning->readTuneCap()); + return true; +} + +AdminMessageHandleResult AS3935Sensor::handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) +{ + AdminMessageHandleResult result; + result = AdminMessageHandleResult::NOT_HANDLED; + + switch (request->which_payload_variant) { + case meshtastic_AdminMessage_sensor_config_tag: + if (!request->sensor_config.has_as3935_config) { + result = AdminMessageHandleResult::NOT_HANDLED; + break; + } + + if (request->sensor_config.as3935_config.has_set_tuning_cap_pf) { + uint32_t pf = request->sensor_config.as3935_config.set_tuning_cap_pf; + if (!setTuningCap(pf)) { + LOG_ERROR("%s: bad cap %upF", sensorName, pf); + } else if (!saveCalibrationData()) { + LOG_WARN("%s: save failed", sensorName); + } + } + + result = AdminMessageHandleResult::HANDLED; + break; + + default: + result = AdminMessageHandleResult::NOT_HANDLED; + } + + return result; +} + +bool AS3935Sensor::saveCalibrationData() +{ + auto file = SafeFile(as3935ConfigFileName); + bool okay = false; + + LOG_INFO("%s state write to %s", sensorName, as3935ConfigFileName); + pb_ostream_t stream = {&writecb, static_cast(&file), meshtastic_AS3935Config_size}; + + if (!pb_encode(&stream, &meshtastic_AS3935Config_msg, &as3935config)) { + LOG_ERROR("Can't encode protobuf %s", PB_GET_ERROR(&stream)); + } else { + okay = true; + } + // Note: SafeFile::close() already acquires the lock and releases it internally + okay &= file.close(); + + return okay; +} + +bool AS3935Sensor::loadCalibrationData() +{ + spiLock->lock(); + auto file = FSCom.open(as3935ConfigFileName, FILE_O_READ); + bool okay = false; + if (file) { + LOG_INFO("%s state read from %s", sensorName, as3935ConfigFileName); + pb_istream_t stream = {&readcb, &file, meshtastic_AS3935Config_size}; + if (!pb_decode(&stream, &meshtastic_AS3935Config_msg, &as3935config)) { + LOG_ERROR("Can't decode protobuf %s", PB_GET_ERROR(&stream)); + } else { + okay = true; + } + file.close(); + } else { + LOG_INFO("No %s state found (File: %s)", sensorName, as3935ConfigFileName); + } + spiLock->unlock(); + return okay; +} + +bool AS3935Sensor::getMetrics(meshtastic_Telemetry *measurement) +{ + uint32_t count = strikes.sum(); + measurement->variant.environment_metrics.has_lightning_strike_count_1h = true; + measurement->variant.environment_metrics.lightning_strike_count_1h = count; + // The distance belongs to the newest strike, so it expires when that strike leaves the window. + if (count && lastDistanceKm >= 0) { + measurement->variant.environment_metrics.has_lightning_distance_km = true; + measurement->variant.environment_metrics.lightning_distance_km = lastDistanceKm; + } + return true; +} + +#endif diff --git a/src/modules/Telemetry/Sensor/AS3935Sensor.h b/src/modules/Telemetry/Sensor/AS3935Sensor.h new file mode 100644 index 000000000..37e25ab40 --- /dev/null +++ b/src/modules/Telemetry/Sensor/AS3935Sensor.h @@ -0,0 +1,44 @@ +#pragma once + +#ifndef _MT_AS3935SENSOR_H +#define _MT_AS3935SENSOR_H +#include "MeshModule.h" +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "RollingCounter.h" +#include "TelemetrySensor.h" +#include + +class AS3935Sensor : public TelemetrySensor +{ + private: + SparkFun_AS3935 *lightning = nullptr; + RollingCounter<60UL * 60 * 1000, 5UL * 60 * 1000> strikes; + float lastDistanceKm = -1; // sentinel: no valid distance captured yet + + void classifyPendingIrq(); + + protected: + const char *as3935ConfigFileName = "/prefs/as3935.dat"; + meshtastic_AS3935Config as3935config = meshtastic_AS3935Config_init_zero; + bool saveCalibrationData(); + bool loadCalibrationData(); + + public: + AS3935Sensor(); + ~AS3935Sensor(); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual int32_t runOnce() override; + // Antenna trim in pF. Rejects anything but a multiple of 8 up to 120, which + // tuneCap() would silently ignore. + bool setTuningCap(uint32_t pf); + AdminMessageHandleResult handleAdminMessage(const meshtastic_MeshPacket &mp, meshtastic_AdminMessage *request, + meshtastic_AdminMessage *response) override; +}; + +#endif +#endif diff --git a/src/modules/Telemetry/Sensor/RollingCounter.h b/src/modules/Telemetry/Sensor/RollingCounter.h new file mode 100644 index 000000000..1d2d8bae0 --- /dev/null +++ b/src/modules/Telemetry/Sensor/RollingCounter.h @@ -0,0 +1,85 @@ +#pragma once + +#include "UptimeClock.h" +#include "mesh/Throttle.h" +#include + +/** + * Sliding-window event counter in fixed memory, one counter per bucket. The spare bucket and the + * weighted oldest bucket are what hold sum() at exactly WindowMs rather than a bucket either way. + * RollingCounter<60UL * 60 * 1000, 5UL * 60 * 1000> strikes; // last hour in 5min steps + */ +template class RollingCounter +{ + static_assert(BucketMs > 0, "BucketMs must be non-zero"); + static_assert(WindowMs % BucketMs == 0, "WindowMs must be a whole number of buckets"); + static_assert(WindowMs / BucketMs + 1 <= UINT8_MAX, "too many buckets"); + + // One more than WindowMs needs, so the oldest is never recycled while still in the window. + static constexpr uint8_t BUCKETS = WindowMs / BucketMs + 1; + + public: + /// Record events happening now. + void add(uint32_t events = 1) + { + advance(); + counts[head] += events; + } + + /// Events within the last WindowMs. + uint32_t sum() + { + advance(); + + // The current bucket plus every fully enclosed one: WindowMs - BucketMs, plus however + // far the current bucket has filled. + uint32_t total = 0; + for (uint8_t age = 0; age <= BUCKETS - 2; age++) + total += counts[(head + BUCKETS - age) % BUCKETS]; + + // The oldest bucket covers the remainder. Counting only the part still inside is what + // holds the total at exactly WindowMs as the current bucket fills. + uint32_t elapsed = Time::getMillis() - bucketStartMs; + uint32_t inWindow = elapsed < BucketMs ? BucketMs - elapsed : 0; + // 64-bit: the product overflows 32 bits once a bucket holds more than 2^32 / BucketMs + // events, which is only ~14k at a 5 minute width. + total += (uint32_t)(((uint64_t)counts[(head + 1) % BUCKETS] * inWindow + BucketMs / 2) / BucketMs); + return total; + } + + void reset() + { + memset(counts, 0, sizeof(counts)); + head = 0; + bucketStartMs = Time::getMillis(); + started = true; + } + + private: + void advance() + { + if (!started) { + reset(); + return; + } + if (!Throttle::hasElapsed(bucketStartMs, BucketMs)) + return; + + uint32_t steps = (Time::getMillis() - bucketStartMs) / BucketMs; + if (steps >= BUCKETS) { // idle longer than the ring, nothing survives + reset(); + return; + } + bucketStartMs += steps * BucketMs; + while (steps--) { + head = (head + 1) % BUCKETS; + counts[head] = 0; + } + } + + uint32_t counts[BUCKETS] = {}; + uint32_t bucketStartMs = 0; + uint8_t head = 0; + // Explicit rather than bucketStartMs == 0, which is a real time value after a rollover. + bool started = false; +}; diff --git a/test/test_rolling_counter/test_main.cpp b/test/test_rolling_counter/test_main.cpp new file mode 100644 index 000000000..e8704e0c3 --- /dev/null +++ b/test/test_rolling_counter/test_main.cpp @@ -0,0 +1,142 @@ +// Unit tests for RollingCounter. The case that matters is the span sum() covers: an +// under-sized ring reports WindowMs - BucketMs, and counting the edge bucket whole reports more. +#include "Arduino.h" +#include "TestUtil.h" +#include "UptimeClock.h" +#include "modules/Telemetry/Sensor/RollingCounter.h" +#include + +static constexpr uint32_t kMinute = 60UL * 1000; +static constexpr uint32_t kWindow = 60 * kMinute; +static constexpr uint32_t kBucket = 5 * kMinute; + +using Counter = RollingCounter; + +void setUp() +{ + Time::setTestMillis(1000); +} + +void tearDown() +{ + Time::useRealClock(); +} + +// Everything added inside the window is still counted at the far edge. +void test_counts_within_window() +{ + Counter c; + for (int i = 0; i < 10; i++) { + c.add(); + Time::advanceTestMillis(kMinute); + } + TEST_ASSERT_EQUAL_UINT32(10, c.sum()); +} + +// Expiry is exact to one bucket, not to the event: nothing records where inside a bucket an event +// fell, so it is wholly counted to WindowMs, wholly gone by WindowMs + BucketMs, decaying between. +void test_expires_within_one_bucket_of_the_hour() +{ + Counter c; + c.add(100); + + Time::advanceTestMillis(kWindow - kMinute); + TEST_ASSERT_EQUAL_UINT32(100, c.sum()); // 59 minutes old, wholly inside + + uint32_t previous = 100; + for (int i = 0; i < 7; i++) { // walk a full bucket past the hour + Time::advanceTestMillis(kMinute); + uint32_t current = c.sum(); + TEST_ASSERT_LESS_OR_EQUAL_UINT32(previous, current); // decays, never grows back + previous = current; + } + TEST_ASSERT_EQUAL_UINT32(0, previous); +} + +// The span must not shrink to 55 minutes as the current bucket fills. One event per +// minute for well over an hour means a correct 60-minute window always holds 60. +void test_span_stays_sixty_minutes() +{ + Counter c; + for (int i = 0; i < 60; i++) { + c.add(); + Time::advanceTestMillis(kMinute); + } + // Steady state: sample at every minute across two more bucket widths. A ring that + // under-covers dips to 55, one that over-covers climbs to 65. + for (int i = 0; i < 20; i++) { + TEST_ASSERT_EQUAL_UINT32(60, c.sum()); + c.add(); + Time::advanceTestMillis(kMinute); + } +} + +// Buckets must not be recycled while any part of them is still inside the window. +void test_bucket_not_dropped_early() +{ + Counter c; + c.add(7); // lands in the first bucket + + // Step to just under an hour in bucket-sized hops; the batch stays counted throughout. + for (uint32_t elapsed = 0; elapsed + kBucket < kWindow; elapsed += kBucket) { + Time::advanceTestMillis(kBucket); + TEST_ASSERT_EQUAL_UINT32(7, c.sum()); + } +} + +// Going quiet for longer than the ring leaves nothing behind, and the counter still works. +void test_long_idle_gap() +{ + Counter c; + c.add(3); + Time::advanceTestMillis(5 * kWindow); + TEST_ASSERT_EQUAL_UINT32(0, c.sum()); + + c.add(2); + TEST_ASSERT_EQUAL_UINT32(2, c.sum()); +} + +// A burst far larger than the bucket count still costs the same fixed memory, and is carried +// whole while it is inside the window. +void test_burst_survives_whole() +{ + Counter c; + c.add(50000); + Time::advanceTestMillis(kWindow - kMinute); + TEST_ASSERT_EQUAL_UINT32(50000, c.sum()); +} + +// Weighting the edge bucket must not overflow: 50000 * 240000 exceeds 32 bits, and a 32-bit +// product wraps to 11367 instead of 40000. Four of the bucket's five minutes are still inside. +void test_large_burst_at_window_edge() +{ + Counter c; + c.add(50000); + Time::advanceTestMillis(kWindow + kMinute); + TEST_ASSERT_EQUAL_UINT32(40000, c.sum()); +} + +void test_reset_clears() +{ + Counter c; + c.add(5); + c.reset(); + TEST_ASSERT_EQUAL_UINT32(0, c.sum()); +} + +void setup() +{ + initializeTestEnvironment(); + UNITY_BEGIN(); + RUN_TEST(test_counts_within_window); + RUN_TEST(test_expires_within_one_bucket_of_the_hour); + RUN_TEST(test_span_stays_sixty_minutes); + RUN_TEST(test_bucket_not_dropped_early); + RUN_TEST(test_long_idle_gap); + RUN_TEST(test_burst_survives_whole); + RUN_TEST(test_large_burst_at_window_edge); + RUN_TEST(test_reset_clears); + exit(UNITY_END()); +} + +void loop() {}