From a0fac16fc921e7742f1c3802f2e80cd8608c7aef Mon Sep 17 00:00:00 2001 From: Andrew Yong Date: Thu, 9 Jul 2026 15:03:43 +0800 Subject: [PATCH] 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 Signed-off-by: Andrew Yong --- platformio.ini | 2 + protobufs | 2 +- src/configuration.h | 3 + src/detect/ScanI2C.h | 1 + src/detect/ScanI2CTwoWire.cpp | 35 +++++ src/modules/Modules.cpp | 2 +- .../Telemetry/EnvironmentTelemetry.cpp | 19 ++- src/modules/Telemetry/EnvironmentTelemetry.h | 12 ++ src/modules/Telemetry/Sensor/AS3935Sensor.cpp | 121 ++++++++++++++++++ src/modules/Telemetry/Sensor/AS3935Sensor.h | 32 +++++ 10 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 src/modules/Telemetry/Sensor/AS3935Sensor.cpp create mode 100644 src/modules/Telemetry/Sensor/AS3935Sensor.h diff --git a/platformio.ini b/platformio.ini index 8e8487f3251..07e0ccbbb3d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -180,6 +180,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/protobufs b/protobufs index ba16bfc731a..f10075ca982 160000 --- a/protobufs +++ b/protobufs @@ -1 +1 @@ -Subproject commit ba16bfc731ab7e23f6be5a8e73358b1973e73822 +Subproject commit f10075ca9821e3acb1fc67f9bdd918b256e25204 diff --git a/src/configuration.h b/src/configuration.h index aaba2bbfd98..f86af4c5f8f 100644 --- a/src/configuration.h +++ b/src/configuration.h @@ -287,6 +287,9 @@ along with this program. If not, see . #define LTR553ALS_ADDR 0x23 #define SEN5X_ADDR 0x69 #define SCD30_ADDR 0x61 +#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 3dcbe3c6a7e..e143e55c58d 100644 --- a/src/detect/ScanI2C.h +++ b/src/detect/ScanI2C.h @@ -102,6 +102,7 @@ class ScanI2C IIS2MDCTR, ISM330DHCX, SPA06, + AS3935, } DeviceType; // typedef uint8_t DeviceAddress; diff --git a/src/detect/ScanI2CTwoWire.cpp b/src/detect/ScanI2CTwoWire.cpp index 151e559f15b..40c9a6fd616 100644 --- a/src/detect/ScanI2CTwoWire.cpp +++ b/src/detect/ScanI2CTwoWire.cpp @@ -896,6 +896,41 @@ void ScanI2CTwoWire::scanPort(I2CPort port, uint8_t *address, uint8_t asize) foundDevices[addr] = type; } } + +#ifdef AS3935_IRQ + // 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 register, and a POR-only check can't survive a warm reboot (this + // driver rewrites REG0x00 on init). Instead, write a test pattern to bits[5:1] + // and confirm it reads back - initDevice() overwrites this field right after anyway. + 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 } void ScanI2CTwoWire::scanPort(I2CPort port) diff --git a/src/modules/Modules.cpp b/src/modules/Modules.cpp index 1e938057505..ad5e356fadb 100644 --- a/src/modules/Modules.cpp +++ b/src/modules/Modules.cpp @@ -215,7 +215,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 53dc3e7be1d..be3561d09bb 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 @@ -143,6 +147,9 @@ extern void drawCommonHeader(OLEDDisplay *display, int16_t x, int16_t y, const c #include static constexpr uint16_t TX_HISTORY_KEY_ENVIRONMENT_TELEMETRY = 0x8002; +static constexpr uint32_t IMMEDIATE_SEND_MAX_STALENESS_MS = 5UL * 60UL * 1000; // 5 minutes + +EnvironmentTelemetryModule *environmentTelemetryModule; void EnvironmentTelemetryModule::i2cScanFinished(ScanI2C *i2cScanner) { @@ -186,6 +193,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 @@ -314,9 +324,15 @@ int32_t EnvironmentTelemetryModule::runOnce() } } + // 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, @@ -324,6 +340,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)) && diff --git a/src/modules/Telemetry/EnvironmentTelemetry.h b/src/modules/Telemetry/EnvironmentTelemetry.h index 0b7e0f4cb1a..4cded1f8487 100644 --- a/src/modules/Telemetry/EnvironmentTelemetry.h +++ b/src/modules/Telemetry/EnvironmentTelemetry.h @@ -42,6 +42,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 @@ -66,9 +74,13 @@ class EnvironmentTelemetryModule : private concurrency::OSThread, private: bool firstTime = 1; + bool immediateSendRequested = false; + uint32_t immediateSendRequestedAtMs = 0; meshtastic_MeshPacket *lastMeasurementPacket; uint32_t sendToPhoneIntervalMs = SECONDS_IN_MINUTE * 1000; // Send to phone every minute uint32_t lastSentToPhone = 0; }; +extern EnvironmentTelemetryModule *environmentTelemetryModule; + #endif \ No newline at end of file diff --git a/src/modules/Telemetry/Sensor/AS3935Sensor.cpp b/src/modules/Telemetry/Sensor/AS3935Sensor.cpp new file mode 100644 index 00000000000..3c40fa34ecd --- /dev/null +++ b/src/modules/Telemetry/Sensor/AS3935Sensor.cpp @@ -0,0 +1,121 @@ +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "AS3935Sensor.h" +#include "TelemetrySensor.h" +#include "modules/Telemetry/EnvironmentTelemetry.h" +#include +#include + +namespace +{ +// No attachInterrupt(): the IRQ line stays asserted until read, so polling can't miss it, +// and the I2C read itself isn't ISR-safe anyway. +constexpr int32_t AS3935_CHECK_INTERVAL_MS = DEFAULT_SENSOR_MINIMUM_WAIT_TIME_BETWEEN_READS; +constexpr uint8_t AS3935_DISTANCE_OUT_OF_RANGE = 0x3F; +// Strikes accumulate over a rolling window, reset by elapsed time rather than on +// getMetrics() (which also fires when replying to a peer's telemetry request). +constexpr uint32_t AS3935_STRIKE_WINDOW_MS = 60UL * 60UL * 1000; // 1 hour +} // namespace + +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; + } + + // Defaults match the library's own example, except outdoor mode and unmasked + // disturbers (kept visible in the log). + lightning->setIndoorOutdoor(OUTDOOR); + lightning->setNoiseLevel(2); + lightning->watchdogThreshold(2); + lightning->spikeRejection(2); + lightning->maskDisturber(false); + lightning->lightningThreshold(1); + +#ifdef AS3935_IRQ + pinMode(AS3935_IRQ, INPUT); +#endif + + windowStartMs = millis(); + initI2CSensor(); + return status; +} + +int32_t AS3935Sensor::runOnce() +{ +#ifdef AS3935_IRQ + if (digitalRead(AS3935_IRQ) == HIGH) { + classifyPendingIrq(); + } +#endif + if (!Throttle::isWithinTimespanMs(windowStartMs, AS3935_STRIKE_WINDOW_MS)) { + strikeCountWindow = 0; + lastDistanceKm = -1; + windowStartMs = millis(); + } + return AS3935_CHECK_INTERVAL_MS; +} + +void AS3935Sensor::classifyPendingIrq() +{ + uint8_t interruptReason = lightning->readInterruptReg(); + switch (interruptReason) { + case LIGHTNING: { + strikeCountWindow++; + uint8_t distance = lightning->distanceToStorm(); + if (distance != AS3935_DISTANCE_OUT_OF_RANGE) { + lastDistanceKm = distance; + LOG_INFO("%s: lightning strike detected, distance=%dkm", sensorName, distance); + } else { + LOG_INFO("%s: lightning strike detected, distance unknown (out of range)", sensorName); + } + // No debounce here - EnvironmentTelemetryModule's airtime gate already paces every send. + if (environmentTelemetryModule) { + environmentTelemetryModule->requestImmediateSend(); + } + break; + } + case DISTURBER_DETECT: + LOG_DEBUG("%s: disturber detected (ignored)", sensorName); + break; + case NOISE_TO_HIGH: + LOG_DEBUG("%s: noise floor too high", sensorName); + break; + default: + break; + } +} + +bool AS3935Sensor::getMetrics(meshtastic_Telemetry *measurement) +{ + measurement->variant.environment_metrics.has_lightning_strike_count_1h = true; + measurement->variant.environment_metrics.lightning_strike_count_1h = strikeCountWindow; + if (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 00000000000..cb8353c65d2 --- /dev/null +++ b/src/modules/Telemetry/Sensor/AS3935Sensor.h @@ -0,0 +1,32 @@ +#pragma once + +#ifndef _MT_AS3935SENSOR_H +#define _MT_AS3935SENSOR_H +#include "configuration.h" + +#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include() + +#include "../mesh/generated/meshtastic/telemetry.pb.h" +#include "TelemetrySensor.h" +#include + +class AS3935Sensor : public TelemetrySensor +{ + private: + SparkFun_AS3935 *lightning = nullptr; + uint32_t strikeCountWindow = 0; + float lastDistanceKm = -1; // sentinel: no valid distance captured this window + uint32_t windowStartMs = 0; + + void classifyPendingIrq(); + + public: + AS3935Sensor(); + ~AS3935Sensor(); + virtual bool initDevice(TwoWire *bus, ScanI2C::FoundDevice *dev) override; + virtual bool getMetrics(meshtastic_Telemetry *measurement) override; + virtual int32_t runOnce() override; +}; + +#endif +#endif