diff --git a/components/airgradient-sensors/README.md b/components/airgradient-sensors/README.md index 3f9f7f6..7b6e489 100644 --- a/components/airgradient-sensors/README.md +++ b/components/airgradient-sensors/README.md @@ -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 | diff --git a/components/airgradient-sensors/drivers/scd4x/scd4x.cpp b/components/airgradient-sensors/drivers/scd4x/scd4x.cpp index 3ae69ee..d152fd6 100644 --- a/components/airgradient-sensors/drivers/scd4x/scd4x.cpp +++ b/components/airgradient-sensors/drivers/scd4x/scd4x.cpp @@ -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. @@ -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. @@ -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; } diff --git a/components/airgradient-sensors/drivers/scd4x/scd4x.h b/components/airgradient-sensors/drivers/scd4x/scd4x.h index fe15a1e..699fb91 100644 --- a/components/airgradient-sensors/drivers/scd4x/scd4x.h +++ b/components/airgradient-sensors/drivers/scd4x/scd4x.h @@ -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; @@ -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; diff --git a/products/go/docs/power_management.md b/products/go/docs/power_management.md index d019f71..5a35476 100644 --- a/products/go/docs/power_management.md +++ b/products/go/docs/power_management.md @@ -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 @@ -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): diff --git a/products/go/docs/sensor_producer.md b/products/go/docs/sensor_producer.md index 8a97ca1..0a52064 100644 --- a/products/go/docs/sensor_producer.md +++ b/products/go/docs/sensor_producer.md @@ -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 diff --git a/products/go/main/go_hardware_board.cpp b/products/go/main/go_hardware_board.cpp index a3ae2c8..097670c 100644 --- a/products/go/main/go_hardware_board.cpp +++ b/products/go/main/go_hardware_board.cpp @@ -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()) { @@ -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; } @@ -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); diff --git a/products/go/main/go_orchestrator.cpp b/products/go/main/go_orchestrator.cpp index 91c4fac..d44a924 100644 --- a/products/go/main/go_orchestrator.cpp +++ b/products/go/main/go_orchestrator.cpp @@ -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(); } @@ -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. @@ -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. diff --git a/products/go/main/go_sensor_producer.cpp b/products/go/main/go_sensor_producer.cpp index 7c330b0..e033357 100644 --- a/products/go/main/go_sensor_producer.cpp +++ b/products/go/main/go_sensor_producer.cpp @@ -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. diff --git a/products/go/main/go_sensor_producer.h b/products/go/main/go_sensor_producer.h index 38dee13..1da97db 100644 --- a/products/go/main/go_sensor_producer.h +++ b/products/go/main/go_sensor_producer.h @@ -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 diff --git a/products/go/tests/go_app_stubs.cpp b/products/go/tests/go_app_stubs.cpp index 1070676..08b5e09 100644 --- a/products/go/tests/go_app_stubs.cpp +++ b/products/go/tests/go_app_stubs.cpp @@ -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() {} diff --git a/products/go/tests/go_orchestrator.tests.cpp b/products/go/tests/go_orchestrator.tests.cpp index 282597a..384384f 100644 --- a/products/go/tests/go_orchestrator.tests.cpp +++ b/products/go/tests/go_orchestrator.tests.cpp @@ -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; @@ -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; @@ -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; diff --git a/products/go/tests/go_orchestrator_stubs.cpp b/products/go/tests/go_orchestrator_stubs.cpp index 4a2d52d..5c32e2b 100644 --- a/products/go/tests/go_orchestrator_stubs.cpp +++ b/products/go/tests/go_orchestrator_stubs.cpp @@ -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; @@ -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; @@ -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; @@ -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; @@ -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; @@ -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 { diff --git a/products/go/tests/go_sensor_producer.tests.cpp b/products/go/tests/go_sensor_producer.tests.cpp index 769c69f..6f43936 100644 --- a/products/go/tests/go_sensor_producer.tests.cpp +++ b/products/go/tests/go_sensor_producer.tests.cpp @@ -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 // ----------------------------------------------------------------------- diff --git a/vhub/Go.vhub.json b/vhub/Go.vhub.json index 7380136..422ab62 100644 --- a/vhub/Go.vhub.json +++ b/vhub/Go.vhub.json @@ -1,6 +1,6 @@ { "schema_version": "1.0", - "rev": "edd5794", + "rev": "05583a8-track3", "product": { "slug": "airgradient-go", "name": "AirGradient Go", @@ -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",