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>
This commit is contained in:
Andrew Yong
2026-08-19 10:17:02 +00:00
committed by GitHub
co-authored by Thomas Göttgens
parent 74119c088b
commit 93d15a5368
11 changed files with 568 additions and 3 deletions
+34
View File
@@ -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.