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:
co-authored by
Thomas Göttgens
parent
74119c088b
commit
93d15a5368
11 files changed
+568
-3
No files matched your search
@@ -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
|
||||
|
||||
@@ -317,6 +317,9 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#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
|
||||
|
||||
@@ -108,7 +108,8 @@ class ScanI2C
|
||||
SPA06,
|
||||
STC8HKB, // STC8H companion-MCU keypad (ThinkNode-M9)
|
||||
DS248X,
|
||||
HM330X
|
||||
HM330X,
|
||||
AS3935
|
||||
} DeviceType;
|
||||
|
||||
// typedef uint8_t DeviceAddress;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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(<SparkFun_AS3935.h>)
|
||||
#include "Sensor/AS3935Sensor.h"
|
||||
#endif
|
||||
|
||||
#if __has_include(<SparkFun_Qwiic_Scale_NAU7802_Arduino_Library.h>)
|
||||
#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(<DFRobot_RainfallSensor.h>)
|
||||
addSensor<DFRobotGravitySensor>(i2cScanner, ScanI2C::DeviceType::DFROBOT_RAIN);
|
||||
#endif
|
||||
#if __has_include(<SparkFun_AS3935.h>)
|
||||
addSensor<AS3935Sensor>(i2cScanner, ScanI2C::DeviceType::AS3935);
|
||||
#endif
|
||||
#if __has_include(<Adafruit_AHTX0.h>)
|
||||
addSensor<AHT10Sensor>(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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
#include "configuration.h"
|
||||
|
||||
#if !MESHTASTIC_EXCLUDE_ENVIRONMENTAL_SENSOR && __has_include(<SparkFun_AS3935.h>)
|
||||
|
||||
#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 <SparkFun_AS3935.h>
|
||||
#include <pb_decode.h>
|
||||
#include <pb_encode.h>
|
||||
|
||||
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<Print *>(&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
|
||||
@@ -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(<SparkFun_AS3935.h>)
|
||||
|
||||
#include "../mesh/generated/meshtastic/telemetry.pb.h"
|
||||
#include "RollingCounter.h"
|
||||
#include "TelemetrySensor.h"
|
||||
#include <SparkFun_AS3935.h>
|
||||
|
||||
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
|
||||
@@ -0,0 +1,85 @@
|
||||
#pragma once
|
||||
|
||||
#include "UptimeClock.h"
|
||||
#include "mesh/Throttle.h"
|
||||
#include <string.h>
|
||||
|
||||
/**
|
||||
* 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 <uint32_t WindowMs, uint32_t BucketMs> 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;
|
||||
};
|
||||
@@ -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 <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() {}
|
||||
Reference in New Issue
Block a user