mirror of
https://github.com/meshtastic/firmware.git
synced 2026-09-14 23:38:19 -04:00
* Add AS3935 lightning sensor support Implements meshtastic/firmware#10774: an AS3935Sensor (TelemetrySensor subclass) that reports lightning_strike_count_1h and lightning_distance_km on the normal environment telemetry interval, like a rain gauge - strikes are counted over a fixed rolling ~1h window and read non-destructively, so replying to a peer's telemetry request in between broadcasts can't silently drop counted strikes. The AS3935's IRQ pin (opt-in per board via AS3935_IRQ) is polled with a plain digitalRead() in runOnce(), deliberately not attachInterrupt(): the IRQ line is a level that stays asserted until its interrupt register is read, so polling can't miss an event regardless of timing, matching the SparkFun library's own reference examples. An interrupt would also buy nothing here even setting that aside - classification requires an I2C read (readInterruptReg(), which itself calls delay(2) per the datasheet's settle-time requirement), and blocking I2C/delay() calls aren't safe from ISR context on any of this codebase's target platforms, so the ISR could only ever set a flag for later draining - no less work than just polling the pin directly on the next tick. A genuine lightning classification also requests an immediate out-of-cycle send via a new EnvironmentTelemetryModule:: requestImmediateSend() hook. There's no fixed debounce on the request itself - EnvironmentTelemetryModule's existing airtime/duty-cycle gate already paces every send, so it sends as often as airtime allows rather than an arbitrary fixed rate. The request does expire after 5 minutes unfulfilled, so it can't fire an arbitrarily stale broadcast if airtime was blocked for a long stretch. The AS3935's I2C addresses (0x01-0x03) fall inside the range this codebase's I2C scanner otherwise skips as reserved, so detection is a small dedicated probe gated behind AS3935_IRQ and respecting the caller's address filter, rather than a change to the general scan loop. Presence is confirmed via a register write/readback round-trip rather than a fixed expected value, since the AS3935 has no WHOAMI register and a power-on-reset-only check can't survive a warm reboot that doesn't power-cycle the sensor (initDevice() permanently rewrites that register on first configuration). Generated files under src/mesh/generated/ are intentionally excluded from this commit - they're regenerated from the protobufs submodule by update_protobufs.yml, and hand edits get overwritten and conflict once the companion protobufs PR merges and the submodule pointer updates. Assisted-by: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Andrew Yong <me@ndoo.sg> * fix(as3935): calibration and telemetry logging initDevice() never called the library's calibrateOsc(). The AS3935's internal oscillators are calibrated against the antenna's resonance, which the AFE/watchdog/spike-rejection thresholds depend on; without it, only a directly-driven IRQ pin (bypassing detection entirely) reacted during testing. The sensor could already have a historical detection event latching the IRQ pin high before our initialization. Added an explicit drain read after the IRQ pin is configured, so the sensor doesn't start out stuck asserting IRQ. EnvironmentTelemetryModule::sendTelemetry() logs every other environment metric category on send but was missing lightning; added a matching log line. Assisted-by: Claude Sonnet 5 <noreply@anthropic.com> Signed-off-by: Andrew Yong <me@ndoo.sg> * Support AS3935 without an IRQ line, make the antenna trim configurable Detection no longer requires AS3935_IRQ. The probe is gated like the other environmental sensors, so an I2C-only breakout is found on any board. Where AS3935_IRQ is defined the pin still gates the I2C read, otherwise runOnce() polls the interrupt register, which latches until read. Antenna tuning capacitance moves to AdminMessage.sensor_config.as3935_config, persisted to /prefs/as3935.dat and defaulting to 96pF. The chip does not retain it across power loss. Disturbers are masked in the chip, since runOnce() now polls every second. The lightning telemetry log is guarded so nodes without the sensor no longer log it on every send. Requires meshtastic/protobufs#981. * Revert protobufs pointer to the develop baseline The submodule bump conflicts on merge and the generated headers come from an out of band CI job, so the pointer moves with that job rather than in this branch. * Report lightning strikes over a true rolling hour strikeCountWindow was zeroed on a fixed interval, so lightning_strike_count_1h reported strikes since the last reset rather than over the preceding hour. RollingCounter is a fixed memory sliding window: one counter per bucket, nothing stored per event, so a storm cannot grow it. The ring holds one bucket more than the window needs so none is recycled while part of it is still inside, and the oldest bucket contributes only the fraction still in range. Both are needed to hold the span at exactly the window length rather than letting it drift by a bucket either way. Expiry is exact to one bucket rather than to the event, which is below the 5 minute floor on mesh telemetry sends. The distance expires with the last strike in the window instead of on the interval reset. Covered by test/test_rolling_counter. * Widen the RollingCounter edge weighting to 64 bit counts * inWindow is a 32 bit product, so a bucket holding more than 2^32 / BucketMs events wraps. At a 5 minute width that is about 14k: a bucket of 50000 reported 11367 instead of 40000 once it reached the window edge. Below the threshold nothing changes, so lightning was unaffected, but the helper is meant to be reused by counters with far higher rates. test_large_burst_at_window_edge covers it. The existing burst test sampled only inside the window, where the bucket is whole and never weighted. * Trim RollingCounter comments to the house limit --------- Signed-off-by: Andrew Yong <me@ndoo.sg> Co-authored-by: Thomas Göttgens <tgoettgens@gmail.com>
143 lines
4.1 KiB
C++
143 lines
4.1 KiB
C++
// 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 <unity.h>
|
|
|
|
static constexpr uint32_t kMinute = 60UL * 1000;
|
|
static constexpr uint32_t kWindow = 60 * kMinute;
|
|
static constexpr uint32_t kBucket = 5 * kMinute;
|
|
|
|
using Counter = RollingCounter<kWindow, kBucket>;
|
|
|
|
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() {}
|