Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
2 changes: 1 addition & 1 deletion components/airgradient-sensors/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ sensor type is as simple as leaving its pointer null.
| `drivers/sunlight` | SenseAir Sunlight | Modbus RTU over serial | CO2 |
| `drivers/s12` | SenseAir S12 | I2C | CO2; reads a single big-endian 16-bit register (default 0x06/0x07 = filtered, pressure-compensated). Supports background calibration and an EEPROM-backed automatic-background-calibration period; no integrated temp/hum |
| `drivers/stcc4` | Sensirion STCC4 | I2C | CO2 with integrated temp/hum (`supports_temp_hum() = true`) |
| `drivers/scd4x` | Sensirion SCD41 | I2C | CO2 with integrated temp/hum and configurable automatic self-calibration period. Thin adapter around the shared `embedded-i2c-scd4x` driver. Singleton — only one instance on the bus, since the underlying driver keeps the I2C handle and address in file-scope globals |
| `drivers/scd4x` | Sensirion SCD41 | I2C | CO2 with integrated temp/hum, configurable automatic self-calibration period, and opt-in retained periodic-measurement reattachment after deep sleep. Thin adapter around the shared `embedded-i2c-scd4x` driver. Singleton — only one instance on the bus, since the underlying driver keeps the I2C handle and address in file-scope globals |
| `drivers/sgp41` | Sensirion SGP41 | I2C | TVOC + NOx |
| `drivers/alpha_sense` | AlphaSense O3 / NO2 | I2C (dual ADS1115) | Electrochemical front-end |
| `drivers/dps368` | Infineon DPS368 | I2C | Pressure |
Expand Down
23 changes: 22 additions & 1 deletion components/airgradient-sensors/drivers/scd4x/scd4x.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ SCD4x::SCD4x(i2c_master_bus_handle_t i2c_bus, uint8_t address)
: _i2c_bus(i2c_bus), _address(address), _measuring(false),
_last_temp_hum{MeasuresInvalid::TEMPERATURE, MeasuresInvalid::HUMIDITY} {}

bool SCD4x::init() {
bool SCD4x::init() { return init(false); }

bool SCD4x::init(bool resume_periodic_measurement) {
_measuring = false;

// Probe I2C bus to verify device exists (with retry for boot timing).
// Done with the ESP-IDF master-probe API directly so we fail fast without
// touching the Sensirion HAL globals if the sensor is absent.
Expand Down Expand Up @@ -48,6 +52,19 @@ bool SCD4x::init() {
sensirion_i2c_hal_set_bus_handle(_i2c_bus);
scd4x_init(_address);

if (resume_periodic_measurement) {
bool data_ready = false;
const int16_t resume_err = scd4x_get_data_ready_status(&data_ready);
if (resume_err == 0 && data_ready) {
_measuring = true;
ESP_LOGI(TAG, "Reattached to existing periodic measurement");
return true;
}

ESP_LOGW(TAG, "Unable to resume periodic measurement (err=%d ready=%d); reinitializing",
resume_err, data_ready);
}

// Clean-state sequence (mirrors embedded-i2c-scd4x/example-usage):
// wake_up -> stop_periodic_measurement -> reinit
// Wake-up is not acknowledged reliably, so ignore its return value.
Expand Down Expand Up @@ -105,6 +122,10 @@ bool SCD4x::init() {
return false;
}

if (resume_periodic_measurement) {
RTOS::delay_ms(FIRST_MEASUREMENT_DELAY_MS);
}

ESP_LOGI(TAG, "SCD4x initialized, periodic measurement started");
return true;
}
Expand Down
12 changes: 12 additions & 0 deletions components/airgradient-sensors/drivers/scd4x/scd4x.h
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,17 @@ class SCD4x : public CO2Sensor {

// CO2Sensor interface implementation
bool init() override;

/**
* @brief Initialize and optionally resume retained periodic measurement.
*
* When resume_periodic_measurement is true, initialization first checks for
* an unread sample from the measurement mode retained across deep sleep. A
* ready sensor is reattached without stop, reinit, or start commands. If the
* retained state is unavailable, clean initialization runs and waits for the
* first sample before returning.
*/
bool init(bool resume_periodic_measurement);
bool read(CO2Data &out) override;
bool supports_temp_hum() const override;
TempHumData temp_hum_data() override;
Expand Down Expand Up @@ -110,6 +121,7 @@ class SCD4x : public CO2Sensor {
static constexpr int INIT_PROBE_DELAY_MS = 100;
static constexpr int START_MEASUREMENT_RETRIES = 3;
static constexpr int START_MEASUREMENT_RETRY_DELAY_MS = 100;
static constexpr int FIRST_MEASUREMENT_DELAY_MS = 5000;

// Clean-state sequence timings (per Sensirion example)
static constexpr int WAKE_UP_DELAY_MS = 30;
Expand Down
16 changes: 14 additions & 2 deletions products/go/docs/power_management.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ sleep) matches the configured interval.

The caller must set `RtcAppState::sensors_warm` via
`should_hold_pm_sensor()` and call `save_state()` **before** `enter_sleep()`.
The same decision controls sensor-producer shutdown: held sleeps stop the task
with `sleep_pm=false`, while longer sleeps use `sleep_pm=true`. This keeps the
SPS30's internal measurement state consistent with the persisted warm flag.

### Retained Uptime

Expand Down Expand Up @@ -208,8 +211,17 @@ On the next timer wake the fast path reads `RtcAppState::sensors_warm`:
| `true` | `release_sleep_gpio_holds()` calls `gpio_hold_dis()` on the pin, `SPS30::init(skip_reset=true)` re-attaches without resetting, **warmup loop skipped entirely** — boot drops from ~14–17 s to ~4–7 s |
| `false` | Normal cold boot: full `SPS30::init()` with `CMD_RESET`, 10 s interruptible warmup |

For sleeps ≥ 20 s the sensor powers off normally and the full warmup runs on
wake — the power saved by sleeping far outweighs the warmup cost.
The same warm flag lets SCD4x reattach to periodic measurement retained across
deep sleep. When an unread sample is ready, the driver skips wake, stop,
reinitialization, and start commands. If retained state cannot be confirmed,
the driver performs clean initialization and waits for the first periodic
sample before the fast path continues. S12 requires no special handling, and
STCC4 already detects retained continuous measurement during initialization.

For sleeps ≥ 20 s the SPS30 powers off normally and the full warmup runs on
wake — the power saved by sleeping far outweighs the warmup cost. These cold PM
wakes intentionally use normal SCD4x initialization because the PM warmup gives
the restarted CO2 measurement enough time to become ready.

`should_hold_pm_sensor(duration_ms)` is pure logic (testable on host):

Expand Down
13 changes: 11 additions & 2 deletions products/go/docs/sensor_producer.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,19 @@ sensor_producer.request_prepare(); // blocks task for ~10 s warmup
// BLE, local HTTP, or UI requests CO2 calibration asynchronously:
sensor_producer.request_co2_calibration();

// Clean shutdown before deep sleep.
sensor_producer.stop();
// Short sleep: stop the task but keep SPS30 measuring for a warm wake.
sensor_producer.stop(false);

// Long sleep or PM power-off: stop the task and put SPS30 to sleep.
sensor_producer.stop(true);
```

The orchestrator selects the shutdown mode from the planned sleep duration.
Short sleeps that hold PM power pass `sleep_pm=false` and persist
`sensors_warm=true`. Long sleeps pass `sleep_pm=true` and persist
`sensors_warm=false`, ensuring that the next timer boot performs the full PM
warmup.

## Event Output

`SensorProducer` posts `EventType::SensorDataReady` to the orchestrator queue
Expand Down
6 changes: 3 additions & 3 deletions products/go/main/go_hardware_board.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ static constexpr uint16_t FG_FCC_SANITY_MAX_MAH = 8500;
// Private helper: CO2 sensor detection
// ===========================================================================

static CO2Sensor *init_co2_sensor(i2c_master_bus_handle_t i2c_bus) {
static CO2Sensor *init_co2_sensor(i2c_master_bus_handle_t i2c_bus, bool sensors_warm) {
// 1. SenseAir S12 (no integrated T/RH)
auto *s12 = new S12(i2c_bus, I2C_ADDR_S12);
if (s12->init()) {
Expand All @@ -96,7 +96,7 @@ static CO2Sensor *init_co2_sensor(i2c_master_bus_handle_t i2c_bus) {

// 2. Sensirion SCD4x (with integrated T/RH)
auto *scd4x = new SCD4x(i2c_bus, I2C_ADDR_SCD4X);
if (scd4x->init()) {
if (scd4x->init(sensors_warm)) {
AG_LOGI(TAG, "CO2 sensor: SCD4x selected");
return scd4x;
}
Expand Down Expand Up @@ -413,7 +413,7 @@ SensorManager &GoHardwareBoard::sensors(bool warm) {
AG_LOGE(TAG, "DPS368 init failed");
}

s->co2 = init_co2_sensor(_i2c_bus);
s->co2 = init_co2_sensor(_i2c_bus, warm);

if (_variant == BoardVariant::V1) {
auto *sht40 = new SHT40(_i2c_bus, I2C_ADDR_SHT40);
Expand Down
8 changes: 5 additions & 3 deletions products/go/main/go_orchestrator.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2796,7 +2796,7 @@ void Orchestrator::pause_provisioning_sensitive_services() {
return;
}
AG_LOGI(TAG, "pausing network-sensitive services");
_svc.sensor_producer.stop();
_svc.sensor_producer.stop(/*sleep_pm=*/true);
if (is_gps_active()) {
_svc.gps_service.stop_and_idle_gnss();
}
Expand Down Expand Up @@ -3081,8 +3081,10 @@ void Orchestrator::prepare_for_sleep(uint32_t sleep_duration_ms) {
// without NVS or sensor reads.
save_rtc_display_snapshot(values);

const bool hold_pm_sensor = _svc.power_service.should_hold_pm_sensor(sleep_duration_ms);

_svc.ble_service.deinit();
_svc.sensor_producer.stop();
_svc.sensor_producer.stop(/*sleep_pm=*/!hold_pm_sensor);

// Active GPS: stop task only — leave TAU1113 tracking for hot-start.
// Inactive GPS: stop task and send GNSS stop before sleep.
Expand All @@ -3108,7 +3110,7 @@ void Orchestrator::prepare_for_sleep(uint32_t sleep_duration_ms) {

// Persist RTC state with the warm-sensor flag for the next wake cycle.
RtcAppState state = snapshot_state();
state.sensors_warm = _svc.power_service.should_hold_pm_sensor(sleep_duration_ms);
state.sensors_warm = hold_pm_sensor;
_svc.power_service.save_state(state);

// Reset external watchdog last — gives it the full timeout window during sleep.
Expand Down
10 changes: 6 additions & 4 deletions products/go/main/go_sensor_producer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@ bool SensorProducer::start() {
return true;
}

void SensorProducer::stop() {
void SensorProducer::stop(bool sleep_pm) {
_running = false;
// Leave the PM sensor asleep on teardown — the task is about to be deleted,
// so the caller (sole owner during shutdown) sleeps it synchronously.
_manager.pm_sleep();
if (sleep_pm) {
// Leave the PM sensor asleep on teardown — the task is about to be deleted,
// so the caller (sole owner during shutdown) sleeps it synchronously.
_manager.pm_sleep();
}
if (_task_handle != nullptr) {
// Send a zero-value notification to unblock task_notify_wait so the task
// can check _running and exit the loop cleanly when possible.
Expand Down
6 changes: 3 additions & 3 deletions products/go/main/go_sensor_producer.h
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ class SensorProducer {
/// @return true if the task was created successfully.
bool start();

/// Stop the sensor task. Sets _running = false then deletes the task.
/// Safe because SensorManager holds no mutexes.
void stop();
/// Stop the sensor task and leave the PM sensor in the requested state.
/// @param sleep_pm true to sleep PM; false to keep it measuring.
void stop(bool sleep_pm);

/// Trigger one measurement cycle with the given iteration count.
/// Non-blocking: returns immediately after signalling the task via
Expand Down
2 changes: 1 addition & 1 deletion products/go/tests/go_app_stubs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ bool SensorProducer::start() {
return true;
}

void SensorProducer::stop() { test_spy::sensor_stopped = true; }
void SensorProducer::stop(bool /*sleep_pm*/) { test_spy::sensor_stopped = true; }

void SensorProducer::request_measurement(uint8_t /*iterations*/, SensorGroup /*groups*/) {}
void SensorProducer::request_co2_calibration() {}
Expand Down
32 changes: 32 additions & 0 deletions products/go/tests/go_orchestrator.tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ static constexpr uint32_t TEST_OTA_WIFI_CHECK_INTERVAL_MS = 3'600'000;
namespace test_spy {
extern bool sensor_started;
extern bool sensor_stopped;
extern bool sensor_stop_sleep_pm;
extern bool measurement_requested;
extern uint8_t last_iterations;
extern SensorGroup last_groups;
Expand Down Expand Up @@ -111,6 +112,7 @@ extern bool pm_power_set;
extern bool pm_power_on;
extern uint32_t pm_power_set_count;
extern bool pm_sleep_requested;
extern bool hold_pm_sensor_to_return;
extern bool self_test_requested;
extern bool ensure_pmid_healthy_called;
extern uint32_t ensure_pmid_healthy_count;
Expand Down Expand Up @@ -4218,6 +4220,36 @@ TEST_CASE("prepare_for_sleep: stops all services, saves state, and deep sleeps d
CHECK_FALSE(test_spy::route_ended);
}

TEST_CASE("prepare_for_sleep: short sleep keeps PM measuring and saves warm state",
"[Orchestrator][sleep][pm]") {
TestFixture f;
auto orch = f.make_orchestrator();

test_spy::hold_pm_sensor_to_return = true;

A::prepare_for_sleep(orch, 10000);

CHECK(test_spy::sensor_stopped);
CHECK_FALSE(test_spy::sensor_stop_sleep_pm);
CHECK(test_spy::state_saved);
CHECK(test_spy::last_saved_state.sensors_warm);
}

TEST_CASE("prepare_for_sleep: long sleep sleeps PM and saves cold state",
"[Orchestrator][sleep][pm]") {
TestFixture f;
auto orch = f.make_orchestrator();

test_spy::hold_pm_sensor_to_return = false;

A::prepare_for_sleep(orch, 60000);

CHECK(test_spy::sensor_stopped);
CHECK(test_spy::sensor_stop_sleep_pm);
CHECK(test_spy::state_saved);
CHECK_FALSE(test_spy::last_saved_state.sensors_warm);
}

TEST_CASE("prepare_for_sleep: flushes and closes route file when tracking is active",
"[Orchestrator][sleep]") {
TestFixture f;
Expand Down
12 changes: 10 additions & 2 deletions products/go/tests/go_orchestrator_stubs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ namespace test_spy {
// --- SensorProducer ---
bool sensor_started = false;
bool sensor_stopped = false;
bool sensor_stop_sleep_pm = false;
bool measurement_requested = false;
uint8_t last_iterations = 0;
SensorGroup last_groups = SensorGroup::None;
Expand Down Expand Up @@ -243,6 +244,7 @@ bool pm_power_set = false;
bool pm_power_on = false;
uint32_t pm_power_set_count = 0;
bool pm_sleep_requested = false;
bool hold_pm_sensor_to_return = false;
bool self_test_requested = false;
bool ensure_pmid_healthy_called = false;
uint32_t ensure_pmid_healthy_count = 0;
Expand All @@ -252,6 +254,7 @@ uint32_t recover_pm_sensor_count = 0;
void reset() {
sensor_started = false;
sensor_stopped = false;
sensor_stop_sleep_pm = false;
measurement_requested = false;
last_iterations = 0;
co2_calibration_requested = false;
Expand Down Expand Up @@ -430,6 +433,7 @@ void reset() {
pm_power_on = false;
pm_power_set_count = 0;
pm_sleep_requested = false;
hold_pm_sensor_to_return = false;
ensure_pmid_healthy_called = false;
ensure_pmid_healthy_count = 0;
recover_pm_sensor_called = false;
Expand All @@ -455,7 +459,10 @@ bool SensorProducer::start() {
return true;
}

void SensorProducer::stop() { test_spy::sensor_stopped = true; }
void SensorProducer::stop(bool sleep_pm) {
test_spy::sensor_stopped = true;
test_spy::sensor_stop_sleep_pm = sleep_pm;
}

void SensorProducer::request_measurement(uint8_t iterations, SensorGroup groups) {
test_spy::measurement_requested = true;
Expand Down Expand Up @@ -688,7 +695,8 @@ PowerService::SleepDecision PowerService::decide_sleep(const GoSettings & /*sett
}

bool PowerService::should_hold_pm_sensor(uint32_t sleep_duration_ms) const {
return _config.pin_pm_power >= 0 && sleep_duration_ms < _config.sensor_hold_max_sleep_ms;
(void)sleep_duration_ms;
return test_spy::hold_pm_sensor_to_return;
}

bool PowerService::should_sleep_pm_sensor(uint32_t measure_interval_ms) const {
Expand Down
8 changes: 8 additions & 0 deletions products/go/tests/go_sensor_producer.tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,14 @@ TEST_CASE("SensorProducer handlers", "[SensorProducer]") {
SensorProducer producer(manager, &event_queue_sentinel, {});
SensorProducerTestAccess access(producer);

SECTION("stop keeps PM measuring when requested") { producer.stop(false); }

SECTION("stop sleeps PM when requested") {
REQUIRE_CALL(mock_pm, sleep()).RETURN(true);

producer.stop(true);
}

// -----------------------------------------------------------------------
// handle_calibration
// -----------------------------------------------------------------------
Expand Down
11 changes: 10 additions & 1 deletion vhub/Go.vhub.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"rev": "edd5794",
"rev": "05583a8-track3",
"product": {
"slug": "airgradient-go",
"name": "AirGradient Go",
Expand Down Expand Up @@ -504,6 +504,15 @@
"expected_result": "Switching connected Portable to Offline sends disc=op_offline and disconnects BLE. During a two-minute BLE scan and Wi-Fi capture, no AirGradient Go advertisement, Go SoftAP, STA association, mDNS, HTTP, Cloud measurement POST, Cloud configuration GET, provisioning, or Cloud OTA firmware download traffic appears. While unlocked, complete Home > Menu > Settings > Back > Home and observe at least three on-device measurements with no deep-sleep entry log. Then set the measurement interval to 3 seconds, lock the device, and observe it for two minutes; measurements continue and no deep-sleep entry occurs.",
"notes": "Record device BLE address, serial-derived names, and Wi-Fi MAC before entry. Use BLE scanner, AP association log or Wi-Fi capture, serial, and Cloud server logs. Restore mode, lock, and interval."
},
{
"id": "offline.sleep.sensor-continuity",
"category": "Offline Mode",
"sub_category": "Radio and sleep behavior",
"applies_to": ["V1"],
"description": "Offline interval changes preserve PM and CO2 measurements without I2C shutdown faults",
"expected_result": "Starting from Portable, set interval=10, switch to Offline, lock, and observe three complete deep-sleep/timer-wake cycles. Each wake logs \"run_fast_path: entering fast-path boot (sensors_warm=1)\" and \"fast-path: sensors warm — skipping warmup\"; the SPS30 fan remains running and every completed measurement has valid PM2.5 and CO2. Wake and change the interval to 30, then 60, observing three locked Offline cycles at each value, and finally return to 10 for three more cycles. For every sleep shorter than 20000 ms, the following wake uses sensors_warm=1, skips warmup, and keeps the fan running. For every sleep of at least 20000 ms, the following wake uses sensors_warm=0, performs the full warmup, and restarts the fan. Every completed cycle has valid PM2.5 and CO2. Across the full sequence, serial contains no I2C timeout, ESP_ERR_INVALID_STATE, store-access fault, ISR panic, or unexpected reboot.",
"notes": "Run on a production V1 with SCD4x and capture serial continuously through the 10->30->60->10 sequence. The 30-second interval can produce either warm or cold wakes because boot and warmup time are subtracted before sleep; judge each wake from the logged sleep duration. Record interval changes, PM fan behavior, sensors_warm values, warmup logs, and PM2.5/CO2 readings. Restore the original interval and mode."
},
{
"id": "offline.sleep.tracking-continuity",
"category": "Offline Mode",
Expand Down
Loading