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() {}