Skip to content
Closed
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
229 changes: 217 additions & 12 deletions src/modules/DetectionSensorModule.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@
#include "configuration.h"
#include "main.h"
#include <Throttle.h>

#ifdef ARCH_NRF52
#include "sleep.h"
// Reserved values for GPREGRET are: 0xF5 (NRF52_MAGIC_LFS_IS_CORRUPT)
// and for the bootloader 0xB1, 0xA8, 0x4E, 0x57, 0x6D, lets choose free ones,
// although the bootloader shouldn't be called in the following soft reset.
constexpr uint32_t BOOT_FROM_COLD = 0x00;
constexpr uint32_t BOOT_FROM_TIMEOUT = 0xC0;
constexpr uint32_t BOOT_FROM_GPIOEVENT = 0xFE;
#elif defined(ESP32_WITH_EXT0)
#include "esp32/clk.h"
#include "sleep.h"
#include "soc/rtc.h"
#include <driver/rtc_io.h>
RTC_DATA_ATTR static uint32_t time_acc_s = 0;
RTC_DATA_ATTR static uint32_t sleepTime_s;
#endif

DetectionSensorModule *detectionSensorModule;

#define GPIO_POLLING_INTERVAL 100
Expand Down Expand Up @@ -65,26 +83,143 @@ int32_t DetectionSensorModule::runOnce()
return disable();

if (firstTime) {

#ifdef DETECTION_SENSOR_EN
pinMode(DETECTION_SENSOR_EN, OUTPUT);
digitalWrite(DETECTION_SENSOR_EN, HIGH);
#endif

// This is the first time the OSThread library has called this function, so do some setup
firstTime = false;

if (moduleConfig.detection_sensor.monitor_pin > 0) {
pinMode(moduleConfig.detection_sensor.monitor_pin, moduleConfig.detection_sensor.use_pullup ? INPUT_PULLUP : INPUT);
#ifdef ARCH_NRF52
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR && config.power.is_power_saving) {
nrf_gpio_pin_sense_t toSense =
(moduleConfig.detection_sensor.detection_trigger_type & 1) ? NRF_GPIO_PIN_SENSE_HIGH : NRF_GPIO_PIN_SENSE_LOW;
nrf_gpio_cfg_input(moduleConfig.detection_sensor.monitor_pin,
moduleConfig.detection_sensor.use_pullup ? NRF_GPIO_PIN_PULLUP : NRF_GPIO_PIN_NOPULL);
nrf_gpio_cfg_sense_set(moduleConfig.detection_sensor.monitor_pin, toSense);

uint32_t regret = BOOT_FROM_COLD;
if (sd_power_gpregret_get(0, &regret) != NRF_SUCCESS) {
// necessary if softdevice is not enabled yet
regret = NRF_POWER->GPREGRET;
}
if (NRF_P0->LATCH || NRF_P1->LATCH) {
LOG_INFO("Woke up from eternal sleep by GPIO.", __builtin_ctz(NRF_P0->LATCH ? NRF_P0->LATCH : NRF_P1->LATCH),
NRF_P0->LATCH ? 0 : 1);
NRF_P1->LATCH = 0xFFFFFFFF;
NRF_P0->LATCH = 0xFFFFFFFF;
sendDetectionMessage();
} else if (regret == BOOT_FROM_TIMEOUT) {
LOG_INFO("Woke up by timeout.");
sendCurrentStateMessage(getState());
} else if (regret == BOOT_FROM_GPIOEVENT) {
LOG_INFO("Woke up from interval sleep by GPIO.");
sendDetectionMessage();
} else {
// We booted fresh. Enforce sending on first detection event.
lastSentToMesh = -Default::getConfiguredOrDefaultMs(moduleConfig.detection_sensor.minimum_broadcast_secs);
}
if (!(sd_power_gpregret_clr(0, 0xFF) == NRF_SUCCESS)) {

@phaseloop phaseloop Dec 2, 2025 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For some reason SD is initialized only on Bluetooth enable sequence. I will be fixing that in #8793 but maybe a quick workaround would be to init soft device early on boot to have one common API across firmware to avoid need to checking that constantly.

NRF_POWER->GPREGRET = BOOT_FROM_COLD;
}

LOG_INFO("Cold boot. Init in power saving mode");
// Choose the minimum wakeup time (config.power.min_wake_sec) depending to your needs.
// The least time should be 5s to send just the detection message.
// Choose 45s+ for comfortable connectivity via BLE or USB.
// Device Telemetry is sent after ~62s.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will it improve power usage if for example we sent also telemetry message (if needed of course given time periods elapsed) in the minimum (5s-10s) wakeup windows instead of keeping device up for whole minute just for the telemetry timer to trigger the same?

@rbomze rbomze Dec 2, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I question if device telemetry data (i mean the radio metrics) is any worth on a device which is sleeping most of the time, because no useful radio statistic can be acquired during that time.
When speaking about telemetry data in general (temperature sensors, etc), sure. It does help a lot. See my updated text on the PR description.
What would make a lot of sense is to have battery voltage information in every telemetry data, a single byte would be enough and the receiver can track the nodes battery state, instead clogging the airtime with an additional message.

@phaseloop phaseloop Dec 2, 2025 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Radio statistics of course not - but either environment statistics or device battery is useful. I know battery stats are part of the detection message but it can not be used with monitoring software looking for generic telemetry frames.

On device boot we already send NodeInfo - if the boot reason is wakeup and node is sensor, maybe it would be worth to send telemetry packet too instead of wasting battery to just wait for arbitrary number of seconds. This could be configurable.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are 2 great use cases for the detection sensor:

  • Alerting, like sensing a door/window using a reed switch or PIR
  • In combination with another sensor for environmental metrics as a temper detector. Imagine you have a grey box measuring eg. temperature and humidity and you want to know if someone opens it up, rips it from its mounting, etc.

@phaseloop phaseloop Dec 2, 2025 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I understand, one question: If I want to monitor some remote site with alarm detection and device is waking up periodically (in addition to GPIO alarm trigger) i need to keep it up at least for 70s in order to be able to see what battery I have left there, right?

@rbomze rbomze Dec 2, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to wait for the device metrics to be sent (which include the battery voltage) it would be, but not anymore. I added the battery voltage to the state broadcast.
The state broadcast was broken anyway. Instead of the state it reported hasDetectionEvent(), so it always reported "Friendlyname status: 0".
Now you can set it to 24h or 1 week and get "Friendlyname status: high, 56%"

@rbomze rbomze Dec 2, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So essentially we can set min_wake_secs to 5s, state_broadcast_secs to 24h and minimum_broadcast_secs to 10min and we get a nice alarm sensor.
The potentially possible 0.5uA are crazy low. Even if it would be 2uA, a tiny 100mA battery would last 5.7 years, if only monitoring.
The Nordic datasheet clearly states it uses 0.4uA when off and additional 100nA sensing one pin. So 0.5uA in total. What spoils the party is potentially the LDO in front of the nRF52 and other components.
The RAK 'Nordic nRF52840 BLE Core Module', seemingly on a baseboard, was measured by Haruki with 2uA in shutdown.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, thanks for explanation! I would suggest renaming "state_broadcast_secs" to "state_broadcast_interval". Same with minimum broadcast :)

@rbomze rbomze Dec 3, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, thanks for explanation! I would suggest renaming "state_broadcast_secs" to "state_broadcast_interval". Same with minimum broadcast :)

I just renamed:
state_broadcast_secs -> state_broadcast_interval
minimum_broadcast_secs -> message_rate_limit
This all is now much more readable. I will PR to change the descriptions of the Apps and the Website too.
minimum_broadcast_secs was plain misleading or even wrong btw., because it also rate limited the detection messages.

return Default::getConfiguredOrDefaultMs(config.power.min_wake_secs, 90);
} else
#elif defined(ESP32_WITH_EXT0)
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR && config.power.is_power_saving) {

esp_sleep_wakeup_cause_t wakeCause = esp_sleep_get_wakeup_cause();
uint64_t timeNow_us;
uint32_t timeDiff_s;

switch (wakeCause) {
case ESP_SLEEP_WAKEUP_EXT0:
case ESP_SLEEP_WAKEUP_EXT1:
LOG_INFO("Woke up from interval sleep by GPIO. Sending detection message.");
timeNow_us = rtc_time_slowclk_to_us(rtc_time_get(), esp_clk_slowclk_cal_get());
timeDiff_s = (timeNow_us / 1000000 - sleepTime_s) + config.power.min_wake_secs;
// LOG_INFO("sleeptime (effectivly lastSentToMesh): %u", timeDiff_s - config.power.min_wake_secs);
// LOG_INFO("time since last run (effectivly lastSentToMesh): %us", timeDiff_s);

if (timeDiff_s + time_acc_s > moduleConfig.detection_sensor.minimum_broadcast_secs) {
// LOG_INFO("sending as %u > %u", timeDiff_s + time_acc_s,
// moduleConfig.detection_sensor.minimum_broadcast_secs);
time_acc_s = 0;
sendDetectionMessage();
} else {
// LOG_INFO("not sending as did not reach broadcast threshold. %us < %us", timeDiff_s + time_acc_s,
// moduleConfig.detection_sensor.minimum_broadcast_secs);
time_acc_s += timeDiff_s;
}
break;
case ESP_SLEEP_WAKEUP_TIMER:
LOG_INFO("Woke up by timeout. Sending state message.");
sendCurrentStateMessage(getState());
break;
case ESP_SLEEP_WAKEUP_TOUCHPAD:
case ESP_SLEEP_WAKEUP_ULP:
default:
// We booted fresh. Enforce sending on first detection event.
LOG_INFO("Cold boot. Init in power saving mode");
break;
}

if (rtc_gpio_is_valid_gpio((gpio_num_t)moduleConfig.detection_sensor.monitor_pin)) {
if (moduleConfig.detection_sensor.use_pullup) {
rtc_gpio_pulldown_dis((gpio_num_t)moduleConfig.detection_sensor.monitor_pin);
rtc_gpio_pullup_en((gpio_num_t)moduleConfig.detection_sensor.monitor_pin);
} else {
rtc_gpio_pulldown_dis((gpio_num_t)moduleConfig.detection_sensor.monitor_pin);
rtc_gpio_pullup_dis((gpio_num_t)moduleConfig.detection_sensor.monitor_pin);
}
if (esp_sleep_enable_ext0_wakeup((gpio_num_t)moduleConfig.detection_sensor.monitor_pin,
(moduleConfig.detection_sensor.detection_trigger_type & 1) ? 1 : 0) !=
ESP_OK)
LOG_ERROR("error enabling ext0 on gpio %d", moduleConfig.detection_sensor.monitor_pin);
return Default::getConfiguredOrDefaultMs(config.power.min_wake_secs, 90);
} else {
LOG_WARN("Enabled, but specified pin is not an RTC pin. Disabling module");
printRtcPins();
return disable();
}
} else
#elif defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32H2)
Comment thread
rbomze marked this conversation as resolved.
LOG_WARN("Enabled, but used ESP32 variant lacks necessary EXT0. Disabling module");
return disable();
#endif
pinMode(moduleConfig.detection_sensor.monitor_pin,
moduleConfig.detection_sensor.use_pullup ? INPUT_PULLUP : INPUT);
} else {
LOG_WARN("Detection Sensor Module: Set to enabled but no monitor pin is set. Disable module");
LOG_WARN("Enabled, but no monitor pin is set. Disabling module");
return disable();
}
LOG_INFO("Detection Sensor Module: init");
LOG_INFO("Init in default mode");

return setStartDelay();
}

// LOG_DEBUG("Detection Sensor Module: Current pin state: %i", digitalRead(moduleConfig.detection_sensor.monitor_pin));
#if defined(ARCH_NRF52) || defined(ESP32_WITH_EXT0)
if ((config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR && config.power.is_power_saving)) {
// If 'State Broadcast Interval' (moduleConfig.detection_sensor.state_broadcast_secs) is specified it will be used, if
// unset the sleep will last 'forever', interrupted by specified GPIO event
// nRF52: Using a timeout the module enters a low power loop, without it will shutdown with least power consumption
// ESP32: Always using deep sleep with RTC
// TODO: check why DELAY_FOREVER does not work on ESP
uint32_t nightyNightMs =
Default::getConfiguredOrDefault(moduleConfig.detection_sensor.state_broadcast_secs * 1000, portMAX_DELAY);

#ifdef ESP32_WITH_EXT0
sleepTime_s = rtc_time_slowclk_to_us(rtc_time_get(), esp_clk_slowclk_cal_get()) / 1000000;
#endif
doDeepSleep(nightyNightMs, false, true);
}
#endif

if (!Throttle::isWithinTimespanMs(lastSentToMesh,
Default::getConfiguredOrDefaultMs(moduleConfig.detection_sensor.minimum_broadcast_secs))) {
Expand All @@ -97,7 +232,7 @@ int32_t DetectionSensorModule::runOnce()
sendDetectionMessage();
return DELAYED_INTERVAL;
case DetectionSensorVerdictSendState:
sendCurrentStateMessage(isDetected);
sendCurrentStateMessage(getState());
return DELAYED_INTERVAL;
case DetectionSensorVerdictNoop:
break;
Expand All @@ -110,7 +245,7 @@ int32_t DetectionSensorModule::runOnce()
!Throttle::isWithinTimespanMs(lastSentToMesh,
Default::getConfiguredOrDefaultMs(moduleConfig.detection_sensor.state_broadcast_secs,
default_telemetry_broadcast_interval_secs))) {
sendCurrentStateMessage(hasDetectionEvent());
sendCurrentStateMessage(getState());
return DELAYED_INTERVAL;
}
return GPIO_POLLING_INTERVAL;
Expand All @@ -124,6 +259,9 @@ void DetectionSensorModule::sendDetectionMessage()
meshtastic_MeshPacket *p = allocDataPacket();
p->want_ack = false;
p->decoded.payload.size = strlen(message);
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;

memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
if (moduleConfig.detection_sensor.send_bell && p->decoded.payload.size < meshtastic_Constants_DATA_PAYLOAD_LEN) {
p->decoded.payload.bytes[p->decoded.payload.size] = 7; // Bell character
Expand All @@ -142,10 +280,14 @@ void DetectionSensorModule::sendDetectionMessage()
void DetectionSensorModule::sendCurrentStateMessage(bool state)
{
char *message = new char[40];
sprintf(message, "%s state: %i", moduleConfig.detection_sensor.name, state);
sprintf(message, "%s state %s, %d%%", moduleConfig.detection_sensor.name, state ? "high" : "low",
powerStatus->getBatteryChargePercent());
meshtastic_MeshPacket *p = allocDataPacket();
p->want_ack = false;
p->decoded.payload.size = strlen(message);
if (config.device.role == meshtastic_Config_DeviceConfig_Role_SENSOR)
p->priority = meshtastic_MeshPacket_Priority_RELIABLE;

memcpy(p->decoded.payload.bytes, message, p->decoded.payload.size);
lastSentToMesh = millis();
if (!channels.isDefaultChannel(0)) {
Expand All @@ -156,9 +298,72 @@ void DetectionSensorModule::sendCurrentStateMessage(bool state)
delete[] message;
}

boolean DetectionSensorModule::getState()
{
return digitalRead(moduleConfig.detection_sensor.monitor_pin);
}

bool DetectionSensorModule::hasDetectionEvent()
{
bool currentState = digitalRead(moduleConfig.detection_sensor.monitor_pin);
// LOG_DEBUG("Detection Sensor Module: Current state: %i", currentState);
bool currentState = getState();
return (moduleConfig.detection_sensor.detection_trigger_type & 1) ? currentState : !currentState;
}
}

#ifdef ARCH_NRF52
boolean DetectionSensorModule::shouldLoop()
{
return moduleConfig.detection_sensor.enabled && config.power.is_power_saving && moduleConfig.detection_sensor.monitor_pin > 0;
}

void DetectionSensorModule::lpDelay()
{
sd_power_mode_set(NRF_POWER_MODE_LOWPWR);
delay(moduleConfig.detection_sensor.minimum_broadcast_secs - config.power.min_wake_secs);
}

void DetectionSensorModule::lpLoop(uint32_t msecToWake)
{
for (uint32_t i = msecToWake / 100;; i--) {
delay(100);
if (hasDetectionEvent() &&
!Throttle::isWithinTimespanMs(
lastSentToMesh, Default::getConfiguredOrDefaultMs(moduleConfig.detection_sensor.minimum_broadcast_secs))) {
if (!(sd_power_gpregret_clr(0, 0xFF) == NRF_SUCCESS &&
sd_power_gpregret_set(0, BOOT_FROM_GPIOEVENT) == NRF_SUCCESS)) {
// necessary if softdevice is not enabled yet or never was
NRF_POWER->GPREGRET = BOOT_FROM_GPIOEVENT;
}
break;
}
if (i == 0) {
if (!(sd_power_gpregret_clr(0, 0xFF) == NRF_SUCCESS && sd_power_gpregret_set(0, BOOT_FROM_TIMEOUT) == NRF_SUCCESS)) {
NRF_POWER->GPREGRET = BOOT_FROM_TIMEOUT;
}
break;
}
}
}
#elif defined(ESP32_WITH_EXT0)
void DetectionSensorModule::printRtcPins()
{
#if defined(CONFIG_IDF_TARGET_ESP32)
LOG_WARN("Available GPIOs for wakeup: 0,2,4,12-15,25-27,32-39");
#elif defined(CONFIG_IDF_TARGET_ESP32S2)
LOG_WARN("Available GPIOs for wakeup: 0-21");
#elif defined(CONFIG_IDF_TARGET_ESP32S3)
LOG_WARN("Available GPIOs for wakeup: 0-21");
#endif
}

bool DetectionSensorModule::skipGPIO(int gpio)
{
return moduleConfig.detection_sensor.enabled && config.power.is_power_saving &&
moduleConfig.detection_sensor.monitor_pin == gpio;
}
#else
bool DetectionSensorModule::skipGPIO(int gpio)
{
// always return false for ESP32s without EXT0 (e.g ESP32c6)
return false;
}
#endif
15 changes: 15 additions & 0 deletions src/modules/DetectionSensorModule.h
Original file line number Diff line number Diff line change
@@ -1,13 +1,26 @@
#pragma once
#include "SinglePortModule.h"

#if (defined(CONFIG_IDF_TARGET_ESP32) || defined(CONFIG_IDF_TARGET_ESP32S2) || defined(CONFIG_IDF_TARGET_ESP32S3)) && \
!(defined(CONFIG_IDF_TARGET_ESP32C3) || defined(CONFIG_IDF_TARGET_ESP32C6) || defined(CONFIG_IDF_TARGET_ESP32H2))
#define ESP32_WITH_EXT0
#endif

class DetectionSensorModule : public SinglePortModule, private concurrency::OSThread
{
public:
DetectionSensorModule() : SinglePortModule("detection", meshtastic_PortNum_DETECTION_SENSOR_APP), OSThread("DetectionSensor")
{
}

#ifdef ARCH_NRF52
boolean shouldLoop();
void lpDelay();
void lpLoop(uint32_t msecToWake);
#else
bool skipGPIO(int gpio);
#endif

protected:
virtual int32_t runOnce() override;

Expand All @@ -18,6 +31,8 @@ class DetectionSensorModule : public SinglePortModule, private concurrency::OSTh
void sendDetectionMessage();
void sendCurrentStateMessage(bool state);
bool hasDetectionEvent();
boolean getState();
void printRtcPins();
};

extern DetectionSensorModule *detectionSensorModule;
15 changes: 11 additions & 4 deletions src/platform/esp32/main-esp32.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
#include <nvs.h>
#include <nvs_flash.h>

#ifndef DMESHTASTIC_EXCLUDE_DETECTIONSENSOR
#include "modules/DetectionSensorModule.h"
#endif

#if !defined(CONFIG_IDF_TARGET_ESP32S2) && !MESHTASTIC_EXCLUDE_BLUETOOTH
void setBluetoothEnable(bool enable)
{
Expand Down Expand Up @@ -225,13 +229,16 @@ void cpuDeepSleep(uint32_t msecToWake)
34, 35, 37};

for (int i = 0; i < sizeof(rtcGpios); i++)
rtc_gpio_isolate((gpio_num_t)rtcGpios[i]);
#ifndef DMESHTASTIC_EXCLUDE_DETECTIONSENSOR
if (detectionSensorModule == nullptr || !detectionSensorModule->skipGPIO(rtcGpios[i]))
#endif
rtc_gpio_isolate((gpio_num_t)rtcGpios[i]);
#endif

// FIXME, disable internal rtc pullups/pulldowns on the non isolated pins. for inputs that we aren't using
// to detect wake and in normal operation the external part drives them hard.
// FIXME, disable internal rtc pullups/pulldowns on the non isolated pins. for inputs that we aren't using
// to detect wake and in normal operation the external part drives them hard.
#ifdef BUTTON_PIN
// Only GPIOs which are have RTC functionality can be used in this bit map: 0,2,4,12-15,25-27,32-39.
// Only GPIOs which are have RTC functionality can be used in this bit map: 0,2,4,12-15,25-27,32-39.
#if SOC_RTCIO_HOLD_SUPPORTED && SOC_PM_SUPPORT_EXT_WAKEUP
uint64_t gpioMask = (1ULL << (config.device.button_gpio ? config.device.button_gpio : BUTTON_PIN));
#endif
Expand Down
Loading
Loading