Files
Andrew YongandThomas Göttgens 93d15a5368 Add AS3935 lightning sensor support (#10931)
* 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>
2026-08-19 10:17:02 +00:00

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