From cf0d753947d2219a96d41d8db4df794b499c5e4d Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 24 Jul 2026 17:33:00 +0300 Subject: [PATCH 1/3] feat(go): report retained uptime --- components/airgradient-common/CMakeLists.txt | 1 + components/airgradient-common/include/rtos.h | 18 +++ components/airgradient-common/rtos.cpp | 19 ++- .../airgradient-common/tests/rtos.tests.cpp | 24 ++++ components/airgradient-local-server/spec.md | 9 +- .../tests/measures_json.tests.cpp | 10 +- .../types/system_info.h | 8 +- products/go/docs/local_server.md | 14 ++- products/go/docs/orchestrator.md | 23 ++-- products/go/docs/power_management.md | 13 ++ products/go/main/CMakeLists.txt | 1 + products/go/main/go_app.cpp | 2 + products/go/main/go_local_api.cpp | 8 +- products/go/main/go_local_api.h | 2 +- products/go/main/go_orchestrator.cpp | 6 +- products/go/main/go_orchestrator.h | 1 - products/go/main/go_uptime.cpp | 54 +++++++++ products/go/main/go_uptime.h | 26 ++++ products/go/tests/CMakeLists.txt | 4 + products/go/tests/go_local_api.tests.cpp | 33 ++++- products/go/tests/go_orchestrator.tests.cpp | 16 +-- products/go/tests/go_uptime.tests.cpp | 113 ++++++++++++++++++ 22 files changed, 355 insertions(+), 50 deletions(-) create mode 100644 products/go/main/go_uptime.cpp create mode 100644 products/go/main/go_uptime.h create mode 100644 products/go/tests/go_uptime.tests.cpp diff --git a/components/airgradient-common/CMakeLists.txt b/components/airgradient-common/CMakeLists.txt index dcb1088..15f1b5f 100644 --- a/components/airgradient-common/CMakeLists.txt +++ b/components/airgradient-common/CMakeLists.txt @@ -2,4 +2,5 @@ idf_component_register( SRCS "rtos.cpp" "common.cpp" "ag_i2c.cpp" "aqi.cpp" "measurement_corrections.cpp" INCLUDE_DIRS "include" REQUIRES freertos esp_timer esp_system heap airgradient-gpio esp_app_format driver + PRIV_REQUIRES esp_hw_support ) diff --git a/components/airgradient-common/include/rtos.h b/components/airgradient-common/include/rtos.h index edc68b6..b4bd817 100644 --- a/components/airgradient-common/include/rtos.h +++ b/components/airgradient-common/include/rtos.h @@ -68,6 +68,17 @@ class RTOS { */ static uint64_t get_time_ms(); + /** + * @brief Get monotonic RTC-domain time in milliseconds. + * + * Unlike get_time_ms(), this clock continues across deep sleep while the RTC + * domain remains powered. It is not GPS-adjustable wall time. + * + * @return Retained monotonic time in milliseconds, or 0 from the default + * host implementation. + */ + static uint64_t get_retained_time_ms(); + /** * @brief Set singleton instance (primarily for testing) * @param rtos Pointer to RTOS implementation @@ -248,6 +259,12 @@ class RTOS { */ virtual uint64_t get_time_ms_impl() = 0; + /** + * @brief Virtual implementation of get_retained_time_ms (mockable). + * @return Retained monotonic time in milliseconds; defaults to 0 on host. + */ + virtual uint64_t get_retained_time_ms_impl(); + /** * @brief Virtual implementation of task_notify_wait (mockable). * @@ -302,6 +319,7 @@ class FreeRTOS : public RTOS { public: void delay_ms_impl(uint32_t ms) override; uint64_t get_time_ms_impl() override; + uint64_t get_retained_time_ms_impl() override; }; /** diff --git a/components/airgradient-common/rtos.cpp b/components/airgradient-common/rtos.cpp index f5d3856..c82bc84 100644 --- a/components/airgradient-common/rtos.cpp +++ b/components/airgradient-common/rtos.cpp @@ -8,9 +8,11 @@ #include "rtos.h" #ifndef TEST_HOST -#include "esp_timer.h" #include #include + +#include "esp_rtc_time.h" +#include "esp_timer.h" #else #include #include @@ -45,6 +47,13 @@ void RTOS::delay_ms(uint32_t ms) { get_instance()->delay_ms_impl(ms); } uint64_t RTOS::get_time_ms() { return get_instance()->get_time_ms_impl(); } +uint64_t RTOS::get_retained_time_ms() { + RTOS *rtos = get_instance(); + return rtos != nullptr ? rtos->get_retained_time_ms_impl() : 0; +} + +uint64_t RTOS::get_retained_time_ms_impl() { return 0; } + // FreeRTOS implementation void FreeRTOS::delay_ms_impl(uint32_t ms) { #ifndef TEST_HOST @@ -60,6 +69,14 @@ uint64_t FreeRTOS::get_time_ms_impl() { #endif } +uint64_t FreeRTOS::get_retained_time_ms_impl() { +#ifndef TEST_HOST + return esp_rtc_get_time_us() / 1000ULL; +#else + return 0; +#endif +} + // --------------------------------------------------------------------------- // Task lifecycle (no-op stubs in TEST_HOST) // --------------------------------------------------------------------------- diff --git a/components/airgradient-common/tests/rtos.tests.cpp b/components/airgradient-common/tests/rtos.tests.cpp index 70d7ff0..be90d89 100644 --- a/components/airgradient-common/tests/rtos.tests.cpp +++ b/components/airgradient-common/tests/rtos.tests.cpp @@ -22,6 +22,13 @@ class TestRTOS : public RTOS { uint64_t get_time_ms_impl() override { return 0; } }; +class RetainedTimeRTOS final : public TestRTOS { +public: + uint64_t get_retained_time_ms_impl() override { return retained_time_ms; } + + uint64_t retained_time_ms = 0; +}; + class ScopedRtosInstance { public: explicit ScopedRtosInstance(RTOS &rtos) { RTOS::set_instance(&rtos); } @@ -30,6 +37,23 @@ class ScopedRtosInstance { } // namespace +TEST_CASE("RTOS retained clock has a default host value", "[rtos][time]") { + RTOS::set_instance(nullptr); + REQUIRE(RTOS::get_retained_time_ms() == 0); + + TestRTOS rtos; + ScopedRtosInstance instance(rtos); + REQUIRE(RTOS::get_retained_time_ms() == 0); +} + +TEST_CASE("RTOS retained clock dispatches to the installed host instance", "[rtos][time]") { + RetainedTimeRTOS rtos; + rtos.retained_time_ms = 1234; + ScopedRtosInstance instance(rtos); + + REQUIRE(RTOS::get_retained_time_ms() == 1234); +} + TEST_CASE("RTOS queue_send reports successful admission", "[rtos][queue]") { TestRTOS rtos; ScopedRtosInstance instance(rtos); diff --git a/components/airgradient-local-server/spec.md b/components/airgradient-local-server/spec.md index c04f6ad..2273756 100644 --- a/components/airgradient-local-server/spec.md +++ b/components/airgradient-local-server/spec.md @@ -200,7 +200,7 @@ struct SystemInfo { char model[32] = {}; // "model" char firmware[16] = {}; // "firmware" std::optional wifi_rssi; // "wifiRssi" (dBm; omitted when unavailable) - uint32_t boot = 0; // "boot": measurement-cycle counter; resets on restart + uint32_t boot = 0; // "boot": saturated uptime in completed minutes }; ``` @@ -437,9 +437,10 @@ already clear and renamed only where it misled or was opaque (see "temp": 24.3, "humidity": 47.1, "tvocIndex": 101, "noxIndex": 1 } ``` -`boot` is a measurement-cycle counter that resets on restart (a low value -indicates a recent reboot); it is **not** a timestamp. It mirrors the legacy -`boot` field. The deprecated legacy `bootCount` duplicate is intentionally not +`boot` is device uptime floored to completed minutes and saturated to the +`uint32_t` wire range. It starts at `0`, advances independently of measurement +delivery, and is **not** a timestamp. Products define which reset boundaries +retain uptime. The deprecated legacy `bootCount` duplicate is intentionally not emitted. **Deferred groups** (present in `Measures` but not exposed in v1; add as flat diff --git a/components/airgradient-local-server/tests/measures_json.tests.cpp b/components/airgradient-local-server/tests/measures_json.tests.cpp index 491b35a..410f439 100644 --- a/components/airgradient-local-server/tests/measures_json.tests.cpp +++ b/components/airgradient-local-server/tests/measures_json.tests.cpp @@ -6,6 +6,7 @@ */ #include +#include #include #include @@ -47,7 +48,7 @@ TEST_CASE("measures: identity always present, no measurement keys when invalid", REQUIRE(std::strcmp(cJSON_GetObjectItem(root, "serialNumber")->valuestring, "aabbccddeeff") == 0); REQUIRE(cJSON_IsString(cJSON_GetObjectItem(root, "model"))); REQUIRE(cJSON_IsString(cJSON_GetObjectItem(root, "firmware"))); - // boot is always emitted (measurement-cycle counter). + // boot is always emitted (uptime in completed minutes). REQUIRE(cJSON_IsNumber(cJSON_GetObjectItem(root, "boot"))); // No wifiRssi when unavailable. @@ -103,13 +104,14 @@ TEST_CASE("measures: wifiRssi emitted only when present", "[measures]") { cJSON_Delete(root); } -TEST_CASE("measures: boot reflects the cycle counter", "[measures]") { +TEST_CASE("measures: boot retains the uint32 uptime wire range", "[measures]") { Measures m; SystemInfo info = make_info(); - info.boot = 6; + info.boot = std::numeric_limits::max(); cJSON *root = serialize_and_parse(m, info); - REQUIRE(cJSON_GetObjectItem(root, "boot")->valueint == 6); + REQUIRE(cJSON_GetObjectItem(root, "boot")->valuedouble == + static_cast(std::numeric_limits::max())); cJSON_Delete(root); } diff --git a/components/airgradient-local-server/types/system_info.h b/components/airgradient-local-server/types/system_info.h index a5310a7..20c24c0 100644 --- a/components/airgradient-local-server/types/system_info.h +++ b/components/airgradient-local-server/types/system_info.h @@ -11,9 +11,9 @@ #include #include -// Device identity and link info embedded in the GET /api/v1/measures -// payload. The Home Assistant integration reads `model` from here to drive -// its model-based field / action / range mapping. +// Device identity, link info, and uptime embedded in the GET /api/v1/measures +// payload. The Home Assistant integration reads `model` from here to drive its +// model-based field / action / range mapping. // // wifi_rssi is optional: std::nullopt when the link quality is unavailable, // in which case the key is omitted from the measures payload. @@ -22,7 +22,7 @@ struct SystemInfo { char model[32] = {}; // "model" char firmware[16] = {}; // "firmware" std::optional wifi_rssi; // "wifiRssi" (dBm; omitted when unavailable) - uint32_t boot = 0; // "boot": measurement-cycle counter; resets on restart + uint32_t boot = 0; // "boot": saturated uptime in completed minutes }; #endif // AG_LOCAL_SERVER_SYSTEM_INFO_H diff --git a/products/go/docs/local_server.md b/products/go/docs/local_server.md index 73a9676..3dd4ccb 100644 --- a/products/go/docs/local_server.md +++ b/products/go/docs/local_server.md @@ -13,6 +13,7 @@ incomplete. |---|---| | `products/go/main/go_local_api.h` | Product providers, snapshots, access state, and fixed request FIFO | | `products/go/main/go_local_api.cpp` | Go measures/config mapping, validation, admission, and queue signaling | +| `products/go/main/go_uptime.h`, `go_uptime.cpp` | RTC-retained monotonic uptime calculation | | `products/go/main/go_orchestrator.h` | Local endpoint state, retry deadline, and orchestration declarations | | `products/go/main/go_orchestrator.cpp` | Request processing, persistence, OTA policy, and Stationary lifecycle | | `products/go/main/go_wifi.h` | Shared listener and local mDNS lifecycle API | @@ -29,7 +30,7 @@ incomplete. | `WifiManager` | `airgradient-wifi` (`services/wifi_manager.h`) | Stationary connectivity and `_airgradient._tcp` mDNS lifecycle | | `GoSettings`, `ConfigStore` | product (`go_settings.h`) | Authoritative configuration, validation, and persistence | | `SensorProducer` | product (`go_sensor_producer.h`) | Asynchronous CO2 calibration execution | -| `RTOS` | `airgradient-common` (`rtos.h`) | Snapshot mutex, event queue, and lifecycle timers | +| `RTOS` | `airgradient-common` (`rtos.h`) | Snapshot mutex, event queue, lifecycle timers, and retained monotonic time | ## Public API @@ -44,7 +45,7 @@ incomplete. | `get_config()` | `LocalServerConfig` | Supply the active five-key Go config snapshot | | `submit_config(partial)` | `ConfigSubmitResult` | Validate and admit a non-blocking config request | | `trigger(action)` | `ActionResult` | Admit a fire-and-forget action request | -| `publish_measurement_snapshot(...)` | `void` | Publish corrected common measures and the boot counter | +| `publish_measurement_snapshot(...)` | `void` | Publish corrected common measures | | `publish_config_snapshot(settings)` | `void` | Publish active supported configuration | | `publish_wifi_rssi(rssi)` | `void` | Publish or omit the online RSSI sample | | `set_access(access)`, `access()` | `void`, `ConfigAccess` | Gate writes/actions or expose cached GET data during OTA | @@ -109,6 +110,15 @@ latest corrected measures. The required fields are `serialNumber`, `model`, `firmware`, and `boot`. `wifiRssi` is present only while Stationary Wi-Fi is online. +`boot` is computed when system information is requested. It is retained +monotonic uptime floored to completed minutes: `0` throughout the first minute, +then `1` at 60,000 ms. Deep-sleep time counts because both the session start and +ESP32-C5 retained clock continue across deep sleep. Power-on, software, OTA, +panic, watchdog, brownout, and other non-deep-sleep resets start a new session. +The value advances without measurements and saturates at `UINT32_MAX`. Local +Server is currently its only consumer; the uptime module is independent of the +transport and can be reused by BLE or cloud. + The optional sensor fields are `co2`, `pm01`, `pm25`, `pm10`, `pm003Count`, `temp`, `humidity`, `tvocIndex`, `tvocRaw`, `noxIndex`, and `noxRaw`. Each field passes its field-specific validator before serialization; invalid fields are diff --git a/products/go/docs/orchestrator.md b/products/go/docs/orchestrator.md index 8eef63f..09ac558 100644 --- a/products/go/docs/orchestrator.md +++ b/products/go/docs/orchestrator.md @@ -208,7 +208,6 @@ The orchestrator owns the authoritative application state: | `_gps_enabled` | `bool` | `true` | Whether GPS data is used (derived from `GpsMode` setting) | | `_tracking_active` | `bool` | `false` | True while a route is being logged | | `_tracking_session_id` | `uint32_t` | `0` | 5-digit session ID; 0 = no active session | -| `_boot_count` | `uint32_t` | `0` | Completed measurement cycles since the current CPU restart; published in local measures snapshots | | `_provisioning_sensitive_services_paused` | `bool` | `false` | True while sensor producer / GPS / PM rail are paused for the active provisioning transport; gates sensor / BMS / PM / snackbar-refresh deadlines | | `_local_api_activation_retry_deadline_ms` | `uint32_t` | `0` | Absolute 5 s retry deadline for local HTTP or mDNS activation; 0 when inactive | | `_setup_session_active` | `bool` | `false` | True between Stationary setup entry (`Screen::Info` or pre-online `Screen::Provisioning`) and the leave-to-Home / leave-to-Portable boundary; gates power-button short-press, auto-lock, and background-render suppression | @@ -520,14 +519,16 @@ network-side contract. ### Local API Integration `GoApp` constructs `GoLocalApiService` as the `LocalServer` measures, config, -and action provider. The service never reads live orchestrator state from the -HTTP server task. It returns mutex-protected snapshots instead: - -- `init()` publishes the active settings, corrected measurement view, boot - count, and an absent Wi-Fi RSSI. -- Every `SensorDataReady` increments the boot count and publishes the new - corrected measurement snapshot. Cloud, storage, and BLE continue to receive - raw measurements. +and action provider. The service returns mutex-protected orchestrator snapshots +and computes retained uptime when system information is requested: + +- `init()` publishes the active settings, corrected measurement view, and an + absent Wi-Fi RSSI. +- Every `SensorDataReady` publishes the new corrected measurement snapshot. + Measurements do not affect uptime. Cloud, storage, and BLE continue to + receive raw measurements. +- `get_system_info()` reads the retained uptime independently of snapshot + publication, measurement validity, and correction reapplication. - Every activated settings candidate republishes config and measurements, so a correction change immediately updates both local GET resources. - Local endpoint activation and reconnect publish the current RSSI. Disconnect, @@ -919,6 +920,10 @@ device is locked and the first measurement is complete: 7. power_service.reset_ext_watchdog() — maximize timeout window during sleep ``` +Uptime needs no `prepare_for_sleep()` checkpoint. Its RTC-retained start +timestamp is compared with a retained monotonic clock that continues while the +CPU is in deep sleep. + `save_rtc_display_snapshot()` is called after `update(values, true)` so the snapshot reflects exactly what was last rendered. It is intentionally before `stop()` — the values are still valid at that point. `deep_sleep()` is called diff --git a/products/go/docs/power_management.md b/products/go/docs/power_management.md index 65ee5ea..f93c797 100644 --- a/products/go/docs/power_management.md +++ b/products/go/docs/power_management.md @@ -179,6 +179,19 @@ sleep) matches the configured interval. The caller must set `RtcAppState::sensors_warm` via `should_hold_pm_sensor()` and call `save_state()` **before** `enter_sleep()`. +### Retained Uptime + +Go stores one invalid-sentinel-initialized uptime start timestamp in RTC data. +`GoApp::run()` initializes it before boot-path selection. The ESP32-C5 retained +monotonic clock continues through intentional deep sleep, so the reported +completed-minute uptime includes both awake and deep-sleep time without a +per-sleep checkpoint or accumulation of requested sleep durations. + +Deep sleep preserves the start timestamp. Power-on, software, OTA, panic, +watchdog, brownout, and other non-deep-sleep resets reload its invalid +initializer and begin a new session. Uptime is not part of `RtcAppState`, and +`PowerService` does not update it during sleep entry. + ## PM Sensor Warm-Hold For short deep sleeps (< `sensor_hold_max_sleep_ms`, default 20 s) the SPS30 diff --git a/products/go/main/CMakeLists.txt b/products/go/main/CMakeLists.txt index 5d90613..8a35b4a 100644 --- a/products/go/main/CMakeLists.txt +++ b/products/go/main/CMakeLists.txt @@ -19,6 +19,7 @@ idf_component_register( "go_storage.cpp" "go_ui.cpp" "go_ulp.cpp" + "go_uptime.cpp" "go_text_wrap.cpp" "go_wifi.cpp" "led/go_led.cpp" diff --git a/products/go/main/go_app.cpp b/products/go/main/go_app.cpp index 1e7306c..2a29823 100644 --- a/products/go/main/go_app.cpp +++ b/products/go/main/go_app.cpp @@ -47,6 +47,7 @@ inline esp_reset_reason_t esp_reset_reason() { return ESP_RST_UNKNOWN; } #include "go_storage.h" #include "go_ui.h" #include "go_ulp.h" +#include "go_uptime.h" #include "go_wifi.h" #include "gps/gps_service.h" #include "measurement_corrections.h" @@ -119,6 +120,7 @@ GoApp::GoApp(GoBoard &board) : _board(board) {} // =========================================================================== void GoApp::run() { + go_uptime_init(); RTOS::delay_ms(100); log_heap(TAG, "boot:run-entry"); diff --git a/products/go/main/go_local_api.cpp b/products/go/main/go_local_api.cpp index 58e9fb8..1010d77 100644 --- a/products/go/main/go_local_api.cpp +++ b/products/go/main/go_local_api.cpp @@ -14,6 +14,7 @@ #include #include "go_events.h" +#include "go_uptime.h" #include "measurement_corrections.h" namespace { @@ -137,8 +138,9 @@ SystemInfo GoLocalApiService::get_system_info() { if (!lock()) { return SystemInfo{}; } - const SystemInfo system_info = _system_info; + SystemInfo system_info = _system_info; _mutex.unlock(); + system_info.boot = go_uptime_minutes(); return system_info; } @@ -221,14 +223,12 @@ ActionResult GoLocalApiService::trigger(ActionId action) { return {ActionStatus::Dispatched}; } -void GoLocalApiService::publish_measurement_snapshot(const MeasuresAGo &corrected, - uint32_t boot_count) { +void GoLocalApiService::publish_measurement_snapshot(const MeasuresAGo &corrected) { const Measures measures = map_measures(corrected); if (!lock()) { return; } _measures = measures; - _system_info.boot = boot_count; _mutex.unlock(); } diff --git a/products/go/main/go_local_api.h b/products/go/main/go_local_api.h index 8933a4c..e108c8d 100644 --- a/products/go/main/go_local_api.h +++ b/products/go/main/go_local_api.h @@ -64,7 +64,7 @@ class GoLocalApiService final : public MeasuresProvider, ConfigSubmitResult submit_config(const LocalServerConfig &partial) override; ActionResult trigger(ActionId action) override; - void publish_measurement_snapshot(const MeasuresAGo &corrected, uint32_t boot_count); + void publish_measurement_snapshot(const MeasuresAGo &corrected); void publish_config_snapshot(const GoSettings &settings); void publish_wifi_rssi(std::optional wifi_rssi); diff --git a/products/go/main/go_orchestrator.cpp b/products/go/main/go_orchestrator.cpp index 814bc5b..879a58a 100644 --- a/products/go/main/go_orchestrator.cpp +++ b/products/go/main/go_orchestrator.cpp @@ -209,7 +209,6 @@ void Orchestrator::init(WakeCause cause, const BootHandoff &handoff) { if (handoff.measurement_completed) { _first_measurement_done = true; } - _boot_count = handoff.measurement_completed ? 1U : 0U; // Cold-boot splash gate: run_interactive seeds UIManager via show_info() // before this point, so detect the splash from the current screen rather @@ -774,8 +773,7 @@ void Orchestrator::on_sensor_data(const MeasuresAGo &data) { _raw_measures.power.battery_voltage = _latest_power.battery_voltage; _raw_measures.power.charging_voltage = _latest_power.charging_voltage; _corrected_measures = apply_measurement_corrections(_raw_measures, _settings.corrections); - ++_boot_count; - _svc.local_api.publish_measurement_snapshot(_corrected_measures, _boot_count); + _svc.local_api.publish_measurement_snapshot(_corrected_measures); AG_LOGI(TAG, "Measurement corrections: temp %.2f -> %.2f, humidity %.2f -> %.2f, " "pm25 %.1f -> %.1f", @@ -2508,7 +2506,7 @@ bool Orchestrator::activate_local_endpoint() { void Orchestrator::publish_local_snapshots() { _svc.local_api.publish_config_snapshot(_settings); - _svc.local_api.publish_measurement_snapshot(_corrected_measures, _boot_count); + _svc.local_api.publish_measurement_snapshot(_corrected_measures); } void Orchestrator::publish_local_wifi_snapshot() { diff --git a/products/go/main/go_orchestrator.h b/products/go/main/go_orchestrator.h index a53029c..cba8880 100644 --- a/products/go/main/go_orchestrator.h +++ b/products/go/main/go_orchestrator.h @@ -111,7 +111,6 @@ class Orchestrator { // --- Cached data --- MeasuresAGo _raw_measures{}; ///< Authoritative sensor results for cloud/storage MeasuresAGo _corrected_measures{}; ///< Derived user-facing measurement view - uint32_t _boot_count = 0; ///< Completed measurement cycles since CPU restart GpsData _latest_gps{}; PowerSnapshot _latest_power{}; diff --git a/products/go/main/go_uptime.cpp b/products/go/main/go_uptime.cpp new file mode 100644 index 0000000..5db60b0 --- /dev/null +++ b/products/go/main/go_uptime.cpp @@ -0,0 +1,54 @@ +/** + * AirGradient Go -- retained monotonic uptime + * + * AirGradient + * https://airgradient.com + * + * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License + */ + +#include "go_uptime.h" + +#include + +#ifndef TEST_HOST +#include "esp_attr.h" +#else +#define RTC_DATA_ATTR +#endif + +#include "rtos.h" + +namespace { + +constexpr uint64_t INVALID_START_TIME_MS = std::numeric_limits::max(); +constexpr uint64_t MILLISECONDS_PER_MINUTE = 60'000ULL; + +RTC_DATA_ATTR uint64_t s_start_time_ms = INVALID_START_TIME_MS; + +} // namespace + +void go_uptime_init() { + const uint64_t now_ms = RTOS::get_retained_time_ms(); + if (s_start_time_ms == INVALID_START_TIME_MS || now_ms < s_start_time_ms) { + s_start_time_ms = now_ms; + } +} + +uint32_t go_uptime_minutes() { + const uint64_t now_ms = RTOS::get_retained_time_ms(); + if (s_start_time_ms == INVALID_START_TIME_MS || now_ms < s_start_time_ms) { + s_start_time_ms = now_ms; + return 0; + } + + const uint64_t completed_minutes = (now_ms - s_start_time_ms) / MILLISECONDS_PER_MINUTE; + if (completed_minutes > std::numeric_limits::max()) { + return std::numeric_limits::max(); + } + return static_cast(completed_minutes); +} + +#ifdef TEST_HOST +void go_uptime_reset_retained_state_for_test() { s_start_time_ms = INVALID_START_TIME_MS; } +#endif diff --git a/products/go/main/go_uptime.h b/products/go/main/go_uptime.h new file mode 100644 index 0000000..1b51bd6 --- /dev/null +++ b/products/go/main/go_uptime.h @@ -0,0 +1,26 @@ +/** + * AirGradient Go -- retained monotonic uptime + * + * AirGradient + * https://airgradient.com + * + * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License + */ + +#ifndef GO_UPTIME_H +#define GO_UPTIME_H + +#include + +/** Initialize the retained uptime session start when it is not already valid. */ +void go_uptime_init(); + +/** Return completed uptime minutes, saturated to the uint32_t wire range. */ +uint32_t go_uptime_minutes(); + +#ifdef TEST_HOST +/** Simulate reloading the RTC_DATA_ATTR initializer after a non-deep-sleep reset. */ +void go_uptime_reset_retained_state_for_test(); +#endif + +#endif // GO_UPTIME_H diff --git a/products/go/tests/CMakeLists.txt b/products/go/tests/CMakeLists.txt index eb5f9cb..0bdb85e 100644 --- a/products/go/tests/CMakeLists.txt +++ b/products/go/tests/CMakeLists.txt @@ -271,6 +271,7 @@ catch_discover_tests(go_ui_tests) add_library(go_orchestrator_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_orchestrator.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_local_api.cpp" + "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_ui.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_settings.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/measurement_corrections.cpp" @@ -554,6 +555,7 @@ catch_discover_tests(go_gps_types_tests) add_library(go_app_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_app.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_local_api.cpp" + "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_settings.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/measurement_corrections.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/common.cpp" @@ -699,6 +701,7 @@ catch_discover_tests(go_cloud_tests) add_library(go_local_api_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_local_api.cpp" + "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/measurement_corrections.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/rtos.cpp" ) @@ -715,6 +718,7 @@ target_compile_definitions(go_local_api_test_support PUBLIC TEST_HOST) add_executable(go_local_api_tests go_local_api.tests.cpp + go_uptime.tests.cpp ) target_link_libraries(go_local_api_tests PRIVATE diff --git a/products/go/tests/go_local_api.tests.cpp b/products/go/tests/go_local_api.tests.cpp index fb80f33..7728027 100644 --- a/products/go/tests/go_local_api.tests.cpp +++ b/products/go/tests/go_local_api.tests.cpp @@ -14,6 +14,7 @@ #include "go_events.h" #include "go_local_api.h" +#include "go_uptime.h" #include "rtos.h" class GoLocalApiServiceTestAccess { @@ -35,6 +36,7 @@ class TestRtos final : public RTOS { public: void delay_ms_impl(uint32_t) override {} uint64_t get_time_ms_impl() override { return 0; } + uint64_t get_retained_time_ms_impl() override { return retained_time_ms; } bool queue_send_impl(RtosQueueHandle queue_handle, const void *item, uint32_t timeout_ms) override { @@ -46,12 +48,15 @@ class TestRtos final : public RTOS { } bool reject_queue_send = false; + uint64_t retained_time_ms = 0; uint32_t last_send_timeout_ms = UINT32_MAX; }; struct Fixture { Fixture() { RTOS::set_instance(&rtos); + go_uptime_reset_retained_state_for_test(); + go_uptime_init(); event_queue = RTOS::queue_create(EVENT_QUEUE_DEPTH, sizeof(Event)); REQUIRE(event_queue != nullptr); @@ -64,6 +69,7 @@ struct Fixture { ~Fixture() { service.reset(); RTOS::queue_delete(event_queue); + go_uptime_reset_retained_state_for_test(); RTOS::set_instance(nullptr); } @@ -176,6 +182,8 @@ TEST_CASE("Go local API initializes safe snapshots") { TEST_CASE("Go local API truncates identity while preserving termination") { TestRtos rtos; RTOS::set_instance(&rtos); + go_uptime_reset_retained_state_for_test(); + go_uptime_init(); RtosQueueHandle queue = RTOS::queue_create(EVENT_QUEUE_DEPTH, sizeof(Event)); REQUIRE(queue != nullptr); @@ -193,6 +201,7 @@ TEST_CASE("Go local API truncates identity while preserving termination") { CHECK(std::strlen(info.firmware) == sizeof(info.firmware) - 1); RTOS::queue_delete(queue); + go_uptime_reset_retained_state_for_test(); RTOS::set_instance(nullptr); } @@ -213,7 +222,7 @@ TEST_CASE("Go local API publishes corrected supported measures field by field") corrected.power.battery_voltage = 4.1f; corrected.pressure.pressure = 1013.0f; - fixture.service->publish_measurement_snapshot(corrected, 7); + fixture.service->publish_measurement_snapshot(corrected); const Measures measures = fixture.service->get_measures(); CHECK(measures.co2.co2 == 612); CHECK(measures.pm_a.pm_01 == 1.1f); @@ -231,20 +240,18 @@ TEST_CASE("Go local API publishes corrected supported measures field by field") CHECK_FALSE(measures.temp_hum_b.is_valid()); CHECK_FALSE(measures.pm_b.is_valid()); CHECK_FALSE(measures.electrode.is_valid()); - CHECK(fixture.service->get_system_info().boot == 7); corrected.pm_a.pm_25 = std::numeric_limits::infinity(); corrected.temp_hum_a.temperature = std::numeric_limits::quiet_NaN(); corrected.tvoc_nox.nox_raw = MeasuresInvalid::NOX; - fixture.service->publish_measurement_snapshot(corrected, 8); + fixture.service->publish_measurement_snapshot(corrected); const Measures replaced = fixture.service->get_measures(); CHECK_FALSE(replaced.pm_a.is_pm_25_valid()); CHECK_FALSE(replaced.temp_hum_a.is_temp_valid()); CHECK_FALSE(replaced.tvoc_nox.is_nox_raw_valid()); CHECK(replaced.pm_a.pm_01 == 1.1f); - CHECK(fixture.service->get_system_info().boot == 8); - fixture.service->publish_measurement_snapshot(MeasuresAGo{}, 9); + fixture.service->publish_measurement_snapshot(MeasuresAGo{}); const Measures invalid = fixture.service->get_measures(); CHECK_FALSE(invalid.co2.is_valid()); CHECK_FALSE(invalid.pm_a.is_pm_01_valid()); @@ -259,6 +266,22 @@ TEST_CASE("Go local API publishes corrected supported measures field by field") CHECK_FALSE(invalid.tvoc_nox.is_nox_raw_valid()); } +TEST_CASE("Go local API uptime advances independently of measurements") { + Fixture fixture; + + CHECK(fixture.service->get_system_info().boot == 0); + fixture.rtos.retained_time_ms = 60'000; + CHECK(fixture.service->get_system_info().boot == 1); + + MeasuresAGo corrected{}; + corrected.co2.co2 = 612; + fixture.service->publish_measurement_snapshot(corrected); + CHECK(fixture.service->get_system_info().boot == 1); + + fixture.rtos.retained_time_ms = 120'000; + CHECK(fixture.service->get_system_info().boot == 2); +} + TEST_CASE("Go local API publishes optional RSSI independently") { Fixture fixture; fixture.service->publish_wifi_rssi(-61); diff --git a/products/go/tests/go_orchestrator.tests.cpp b/products/go/tests/go_orchestrator.tests.cpp index fa1b7ca..7fcac52 100644 --- a/products/go/tests/go_orchestrator.tests.cpp +++ b/products/go/tests/go_orchestrator.tests.cpp @@ -431,7 +431,6 @@ class OrchestratorTestAccess { static bool tracking_active(const Orchestrator &o) { return o._tracking_active; } static uint32_t tracking_session_id(const Orchestrator &o) { return o._tracking_session_id; } static bool first_measurement_done(const Orchestrator &o) { return o._first_measurement_done; } - static uint32_t boot_count(const Orchestrator &o) { return o._boot_count; } static const MeasuresAGo &cached_measures(const Orchestrator &o) { return o._raw_measures; } static const MeasuresAGo &raw_measures(const Orchestrator &o) { return o._raw_measures; } static const MeasuresAGo &corrected_measures(const Orchestrator &o) { @@ -6637,9 +6636,9 @@ LocalServerConfig local_pm_standard(const char *value) { } // namespace -TEST_CASE("local snapshots publish initial settings and boot handoff", +TEST_CASE("local snapshots publish initial settings and measurement handoff", "[Orchestrator][local-api][snapshot][init]") { - SECTION("ordinary interactive init starts at boot zero") { + SECTION("ordinary interactive init publishes settings") { TestFixture f; f.settings.pm_use_usaqi = true; f.settings.use_fahrenheit = true; @@ -6654,11 +6653,10 @@ TEST_CASE("local snapshots publish initial settings and boot handoff", CHECK(*config.temperature_unit == "f"); CHECK_FALSE(*config.cloud_connection); CHECK(*config.configuration_control == "local"); - CHECK(f.local_api.get_system_info().boot == 0); CHECK_FALSE(f.local_api.get_system_info().wifi_rssi.has_value()); } - SECTION("completed fast-path handoff is corrected and counted as cycle one") { + SECTION("completed fast-path handoff is corrected") { TestFixture f; f.settings.corrections.temperature.algorithm = LinearCorrectionAlgorithm::Custom; f.settings.corrections.temperature.scaling_factor = 2.0f; @@ -6673,18 +6671,16 @@ TEST_CASE("local snapshots publish initial settings and boot handoff", orch.init(WakeCause::Timer, handoff); CHECK(f.local_api.get_measures().temp_hum_a.temperature == 21.0f); - CHECK(f.local_api.get_system_info().boot == 1); CHECK(A::raw_measures(orch).temp_hum_a.temperature == 10.0f); } } -TEST_CASE("local measurement snapshot counts invalid and valid sensor cycles", +TEST_CASE("local measurement snapshot replaces invalid and valid sensor data", "[Orchestrator][local-api][snapshot][measurement]") { TestFixture f; auto orch = f.make_orchestrator(); A::on_sensor_data(orch, MeasuresAGo{}); - CHECK(f.local_api.get_system_info().boot == 1); CHECK_FALSE(f.local_api.get_measures().co2.is_valid()); MeasuresAGo raw{}; @@ -6694,7 +6690,6 @@ TEST_CASE("local measurement snapshot counts invalid and valid sensor cycles", raw.temp_hum_a.humidity = 50.0f; A::on_sensor_data(orch, raw); - CHECK(f.local_api.get_system_info().boot == 2); CHECK(f.local_api.get_measures().co2.co2 == 612); CHECK(f.local_api.get_measures().pm_a.pm_25 == 10.0f); CHECK(test_spy::last_cached_measurement.pm_a.pm_25 == 10.0f); @@ -6835,7 +6830,7 @@ TEST_CASE("local authoritative validation drops a newly conflicting candidate", CHECK(*f.local_api.get_config().configuration_control == "both"); } -TEST_CASE("local correction activation republishes corrected data without incrementing boot", +TEST_CASE("local correction activation republishes corrected data", "[Orchestrator][local-api][snapshot][correction]") { TestFixture f; auto orch = f.make_orchestrator(); @@ -6861,7 +6856,6 @@ TEST_CASE("local correction activation republishes corrected data without increm CHECK(A::raw_measures(orch).temp_hum_a.temperature == 10.0f); CHECK(f.local_api.get_measures().temp_hum_a.temperature == 21.0f); - CHECK(f.local_api.get_system_info().boot == 1); } TEST_CASE("Stationary Wi-Fi transitions publish RSSI without clearing queued work", diff --git a/products/go/tests/go_uptime.tests.cpp b/products/go/tests/go_uptime.tests.cpp new file mode 100644 index 0000000..262d0b6 --- /dev/null +++ b/products/go/tests/go_uptime.tests.cpp @@ -0,0 +1,113 @@ +/** + * AirGradient Go -- retained monotonic uptime host tests + */ + +#include +#include + +#include + +#include "go_uptime.h" +#include "rtos.h" + +namespace { + +constexpr uint64_t MILLISECONDS_PER_MINUTE = 60'000ULL; + +class TestRtos final : public RTOS { +public: + void delay_ms_impl(uint32_t) override {} + uint64_t get_time_ms_impl() override { return 0; } + uint64_t get_retained_time_ms_impl() override { return retained_time_ms; } + + uint64_t retained_time_ms = 0; +}; + +class Fixture { +public: + Fixture() { + RTOS::set_instance(&rtos); + go_uptime_reset_retained_state_for_test(); + } + + ~Fixture() { + go_uptime_reset_retained_state_for_test(); + RTOS::set_instance(nullptr); + } + + TestRtos rtos; +}; + +} // namespace + +TEST_CASE("Go uptime floors completed minutes at boundaries", "[go][uptime]") { + Fixture fixture; + go_uptime_init(); + + fixture.rtos.retained_time_ms = MILLISECONDS_PER_MINUTE - 1; + CHECK(go_uptime_minutes() == 0); + + fixture.rtos.retained_time_ms = MILLISECONDS_PER_MINUTE; + CHECK(go_uptime_minutes() == 1); + + fixture.rtos.retained_time_ms = (2 * MILLISECONDS_PER_MINUTE) - 1; + CHECK(go_uptime_minutes() == 1); + + fixture.rtos.retained_time_ms = 2 * MILLISECONDS_PER_MINUTE; + CHECK(go_uptime_minutes() == 2); +} + +TEST_CASE("Go uptime advances across a simulated deep-sleep wake", "[go][uptime]") { + Fixture fixture; + fixture.rtos.retained_time_ms = 10'000; + go_uptime_init(); + + fixture.rtos.retained_time_ms = 70'000; + CHECK(go_uptime_minutes() == 1); + + // Deep sleep restarts the CPU, but both RTC data and the retained clock continue. + fixture.rtos.retained_time_ms = 100'000; + go_uptime_init(); + fixture.rtos.retained_time_ms = 130'000; + CHECK(go_uptime_minutes() == 2); +} + +TEST_CASE("Go uptime restarts after a simulated non-deep-sleep reset", "[go][uptime]") { + Fixture fixture; + go_uptime_init(); + fixture.rtos.retained_time_ms = 3 * MILLISECONDS_PER_MINUTE; + REQUIRE(go_uptime_minutes() == 3); + + go_uptime_reset_retained_state_for_test(); + fixture.rtos.retained_time_ms = 200'000; + go_uptime_init(); + CHECK(go_uptime_minutes() == 0); + + fixture.rtos.retained_time_ms = 200'000 + MILLISECONDS_PER_MINUTE; + CHECK(go_uptime_minutes() == 1); +} + +TEST_CASE("Go uptime recovers when the retained clock regresses", "[go][uptime]") { + Fixture fixture; + fixture.rtos.retained_time_ms = 100'000; + go_uptime_init(); + + fixture.rtos.retained_time_ms = 50'000; + CHECK(go_uptime_minutes() == 0); + + fixture.rtos.retained_time_ms = 110'000; + CHECK(go_uptime_minutes() == 1); +} + +TEST_CASE("Go uptime saturates the uint32 minute value", "[go][uptime]") { + Fixture fixture; + go_uptime_init(); + + const uint64_t overflow_minutes = + static_cast(std::numeric_limits::max()) + 1ULL; + fixture.rtos.retained_time_ms = overflow_minutes * MILLISECONDS_PER_MINUTE; + CHECK(go_uptime_minutes() == std::numeric_limits::max()); + + fixture.rtos.retained_time_ms = std::numeric_limits::max() - 1ULL; + CHECK(go_uptime_minutes() == std::numeric_limits::max()); +} From 558940e38859d9e5e774553024916cf3ffc864e4 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 24 Jul 2026 18:23:19 +0300 Subject: [PATCH 2/3] feat(client)!: add boot to measurement posts Sample retained uptime when each cloud POST begins so delayed posts do not report stale metadata. BREAKING CHANGE: http_post_measures overloads now require a uint32_t boot argument. --- components/airgradient-client/README.md | 7 +- .../airgradient-client/services/ag_client.cpp | 19 +++--- .../airgradient-client/services/ag_client.h | 11 +-- .../services/payload_serializer.cpp | 14 +++- .../services/payload_serializer.h | 11 +-- components/airgradient-client/spec.md | 18 ++--- .../tests/ag_client.tests.cpp | 18 +++-- .../tests/payload_serializer.tests.cpp | 68 +++++++++++-------- products/go/docs/cloud_service.md | 14 +++- products/go/docs/local_server.md | 4 +- products/go/main/go_cloud.cpp | 8 ++- products/go/tests/CMakeLists.txt | 1 + products/go/tests/go_app_stubs.cpp | 9 ++- products/go/tests/go_cloud.tests.cpp | 32 +++++++-- products/go/tests/go_cloud_stubs.cpp | 11 ++- products/go/tests/go_orchestrator_stubs.cpp | 9 ++- .../main/test_airgradient_client.cpp | 3 +- 17 files changed, 172 insertions(+), 85 deletions(-) diff --git a/components/airgradient-client/README.md b/components/airgradient-client/README.md index 1886120..e4deeaf 100644 --- a/components/airgradient-client/README.md +++ b/components/airgradient-client/README.md @@ -91,15 +91,17 @@ if (!client.begin("aabbccddeeff", NetworkType::Wifi)) { // omitted by the serializer. MeasuresBasic m{}; m.temp_hum_a.temperature = 23.5f; +const uint32_t boot_minutes = 6; // Sample product uptime at POST time. -if (client.http_post_measures(m, -55) == AgClientResult::Ok) { +if (client.http_post_measures(m, -55, boot_minutes) == AgClientResult::Ok) { // shipped } ``` The same call works with `Measures` (full) and `MeasuresAGo` via overloads — the appropriate overload is selected at the call site by -type. +type. The caller supplies `boot` as a `uint32_t` device uptime value for every +HTTP measurement POST. ## JSON Payload Contract @@ -112,6 +114,7 @@ average. | Field family | JSON properties | Precision | |---|---|---| | Wi-Fi signal | `wifi` | Integer | +| Device uptime | `boot` | Unsigned 32-bit integer | | CO2 | `rco2` | Integer | | Temperature / humidity | `atmp`, `rhum` | 2 decimals | | PM atmospheric mass | `pm01`, `pm02`, `pm10` | 1 decimal | diff --git a/components/airgradient-client/services/ag_client.cpp b/components/airgradient-client/services/ag_client.cpp index 5f0ab07..dba6b9c 100644 --- a/components/airgradient-client/services/ag_client.cpp +++ b/components/airgradient-client/services/ag_client.cpp @@ -140,19 +140,22 @@ AgClientResult AgClient::http_fetch_config(char *config_out, size_t config_size, return result; } -AgClientResult AgClient::http_post_measures(const Measures &measures, int signal) { - return _do_http_post_measures(_make_input(measures), signal); +AgClientResult AgClient::http_post_measures(const Measures &measures, int signal, uint32_t boot) { + return _do_http_post_measures(_make_input(measures), signal, boot); } -AgClientResult AgClient::http_post_measures(const MeasuresBasic &measures, int signal) { - return _do_http_post_measures(_make_input(measures), signal); +AgClientResult AgClient::http_post_measures(const MeasuresBasic &measures, int signal, + uint32_t boot) { + return _do_http_post_measures(_make_input(measures), signal, boot); } -AgClientResult AgClient::http_post_measures(const MeasuresAGo &measures, int signal) { - return _do_http_post_measures(_make_input(measures), signal); +AgClientResult AgClient::http_post_measures(const MeasuresAGo &measures, int signal, + uint32_t boot) { + return _do_http_post_measures(_make_input(measures), signal, boot); } -AgClientResult AgClient::_do_http_post_measures(const MeasuresInput &input, int signal) { +AgClientResult AgClient::_do_http_post_measures(const MeasuresInput &input, int signal, + uint32_t boot) { if (_network != NetworkType::Wifi) { abort_unsupported("http_post_measures", "called on non-WiFi network"); } @@ -169,7 +172,7 @@ AgClientResult AgClient::_do_http_post_measures(const MeasuresInput &input, int char body[POST_BODY_BUFFER_SIZE]; size_t body_len = 0; - if (!serialize_measures_json(input, signal, body, sizeof(body), &body_len)) { + if (!serialize_measures_json(input, signal, boot, body, sizeof(body), &body_len)) { AG_LOGE(TAG, "http_post_measures: JSON serialisation failed"); return AgClientResult::TransportError; } diff --git a/components/airgradient-client/services/ag_client.h b/components/airgradient-client/services/ag_client.h index 4d40e70..419bd64 100644 --- a/components/airgradient-client/services/ag_client.h +++ b/components/airgradient-client/services/ag_client.h @@ -9,6 +9,7 @@ #define AG_CLIENT_H #include +#include #include #include "../clients/coap_client.h" @@ -31,9 +32,9 @@ class AgClient { // --- HTTP (WiFi) --- AgClientResult http_fetch_config(char *config_out, size_t config_size, size_t *bytes_written); - AgClientResult http_post_measures(const Measures &measures, int signal); - AgClientResult http_post_measures(const MeasuresBasic &measures, int signal); - AgClientResult http_post_measures(const MeasuresAGo &measures, int signal); + AgClientResult http_post_measures(const Measures &measures, int signal, uint32_t boot); + AgClientResult http_post_measures(const MeasuresBasic &measures, int signal, uint32_t boot); + AgClientResult http_post_measures(const MeasuresAGo &measures, int signal, uint32_t boot); // --- CoAP (stubs, abort) --- AgClientResult coap_fetch_config(char *config_out, size_t config_size, size_t *bytes_written); @@ -71,14 +72,14 @@ class AgClient { static constexpr const char *DEFAULT_HTTP_DOMAIN = "hw.airgradient.com"; static constexpr const char *DEFAULT_COAP_HOST = "128.140.49.53"; static constexpr int DEFAULT_COAP_PORT = 5683; - static constexpr size_t POST_BODY_BUFFER_SIZE = 768; + static constexpr size_t POST_BODY_BUFFER_SIZE = 1024; static constexpr size_t URL_BUFFER_SIZE = 128; static MeasuresInput _make_input(const Measures &m); static MeasuresInput _make_input(const MeasuresBasic &m); static MeasuresInput _make_input(const MeasuresAGo &m); - AgClientResult _do_http_post_measures(const MeasuresInput &input, int signal); + AgClientResult _do_http_post_measures(const MeasuresInput &input, int signal, uint32_t boot); bool _build_fetch_config_url(char *buf, size_t size) const; bool _build_post_measures_url(char *buf, size_t size) const; diff --git a/components/airgradient-client/services/payload_serializer.cpp b/components/airgradient-client/services/payload_serializer.cpp index 2273663..16926d3 100644 --- a/components/airgradient-client/services/payload_serializer.cpp +++ b/components/airgradient-client/services/payload_serializer.cpp @@ -16,6 +16,7 @@ namespace { constexpr const char *JSON_PROP_SIGNAL = "wifi"; +constexpr const char *JSON_PROP_BOOT = "boot"; constexpr const char *JSON_PROP_CO2 = "rco2"; constexpr const char *JSON_PROP_TEMP = "atmp"; constexpr const char *JSON_PROP_RHUM = "rhum"; @@ -227,8 +228,8 @@ void serialize_electrode(cJSON *obj, const O3No2Data *e) { } // namespace -bool serialize_measures_json(const MeasuresInput &input, int signal, char *out, size_t out_size, - size_t *bytes_written) { +bool serialize_measures_json(const MeasuresInput &input, int signal, uint32_t boot, char *out, + size_t out_size, size_t *bytes_written) { if (bytes_written != nullptr) { *bytes_written = 0; } @@ -241,7 +242,14 @@ bool serialize_measures_json(const MeasuresInput &input, int signal, char *out, return false; } - add_int(doc, JSON_PROP_SIGNAL, signal); // always included + const bool metadata_added = + cJSON_AddNumberToObject(doc, JSON_PROP_SIGNAL, static_cast(signal)) != nullptr && + cJSON_AddNumberToObject(doc, JSON_PROP_BOOT, static_cast(boot)) != nullptr; + if (!metadata_added) { + cJSON_Delete(doc); + out[0] = '\0'; + return false; + } serialize_co2(doc, input.co2); serialize_temp_hum(doc, input.temp_hum_a, input.temp_hum_b); diff --git a/components/airgradient-client/services/payload_serializer.h b/components/airgradient-client/services/payload_serializer.h index de1a7a3..2228c2c 100644 --- a/components/airgradient-client/services/payload_serializer.h +++ b/components/airgradient-client/services/payload_serializer.h @@ -9,15 +9,16 @@ #define AG_PAYLOAD_SERIALIZER_H #include +#include #include "../types/client_types.h" -// Serialize MeasuresInput to AirGradient HTTP JSON. Only fields passing -// is_*_valid() are emitted; dual-channel fields are averaged when both -// channels are valid, otherwise the single valid channel is used. +// Serialize MeasuresInput and request metadata to AirGradient HTTP JSON. Only +// fields passing is_*_valid() are emitted; dual-channel fields are averaged +// when both channels are valid, otherwise the single valid channel is used. // Writes NUL-terminated JSON; returns false on alloc failure or if `out` // is too small (*bytes_written = 0). -bool serialize_measures_json(const MeasuresInput &input, int signal, char *out, size_t out_size, - size_t *bytes_written); +bool serialize_measures_json(const MeasuresInput &input, int signal, uint32_t boot, char *out, + size_t out_size, size_t *bytes_written); #endif // AG_PAYLOAD_SERIALIZER_H diff --git a/components/airgradient-client/spec.md b/components/airgradient-client/spec.md index b3d9e7b..12f8c67 100644 --- a/components/airgradient-client/spec.md +++ b/components/airgradient-client/spec.md @@ -102,7 +102,7 @@ auto result = client.http_fetch_config(config_buf, sizeof(config_buf), &written) if (result == AgClientResult::NotRegistered) { /* device not on server */ } if (result == AgClientResult::BufferTooSmall) { /* config too large */ } -result = client.http_post_measures(measures, signal); +result = client.http_post_measures(measures, signal, boot); // CoAP (Cellular only --- aborts until cellular backends are implemented) result = client.coap_fetch_config(config_buf, sizeof(config_buf), &written); @@ -177,7 +177,7 @@ public: AgClientResult http_fetch_config(char *config_out, size_t config_size, size_t *bytes_written); AgClientResult http_post_measures(const AgClientMeasuresType &measures, - int signal); + int signal, uint32_t boot); // --- CoAP (Cellular only --- aborts on WiFi) --- supports batch AgClientResult coap_fetch_config(char *config_out, size_t config_size, @@ -216,7 +216,7 @@ private: bool build_fetch_config_url(char *buf, size_t size) const; bool build_post_measures_url(char *buf, size_t size) const; bool serialize_json(const AgClientMeasuresType &measures, - int signal, char *buf, size_t size, + int signal, uint32_t boot, char *buf, size_t size, size_t *bytes_written) const; #ifdef TEST_HOST @@ -457,6 +457,7 @@ JSON property names. | `electrode.no2_ae` | `measure3` | Single (full `Measures` only) | | `electrode.afe_temp` | `measure4` | Single (full `Measures` only) | | signal (parameter) | `wifi` | Always included | +| boot (parameter) | `boot` | Always included | Only valid fields are included (using `is_*_valid()` methods from `measures_types.h`). If a `Measures` variant does not have a field (e.g., @@ -556,9 +557,9 @@ sequenceDiagram participant Serializer as PayloadSerializer participant Http as HttpClient - Caller->>AgClient: http_post_measures(measures, signal) + Caller->>AgClient: http_post_measures(measures, signal, boot) AgClient->>AgClient: build_post_measures_url() - AgClient->>Serializer: serialize_json(measures, signal) + AgClient->>Serializer: serialize_json(measures, signal, boot) Serializer-->>AgClient: JSON buffer AgClient->>Http: post(url, cert, "application/json", body, len, status) Http-->>AgClient: bool + status_code @@ -743,13 +744,13 @@ TEST_CASE("http_post_measures serializes correct JSON") { m.temp_hum_a.temperature = 23.5f; m.co2.co2 = 450; - auto result = client.http_post_measures(m, -55); + auto result = client.http_post_measures(m, -55, 6); REQUIRE(result == AgClientResult::Ok); // Assert mock_http received: // URL: https://hw.airgradient.com/sensors/airgradient:aabbccddeeff/measures // Content-Type: application/json - // Body: {"atmp":23.5,"rco2":450,"wifi":-55} + // Body: {"wifi":-55,"boot":6,"rco2":450,"atmp":23.5} // (no pm, no tvoc, no humidity --- those were set to invalid) } @@ -803,7 +804,8 @@ AG server semantics. JSON - Dual-channel averaging (two valid PM2.5 values produce arithmetic mean; one valid produces that value; neither valid omits field) -- Measures with no valid fields produce minimal JSON (`{"wifi":-55}`) +- Measures with no valid fields produce minimal JSON + (`{"wifi":-55,"boot":0}`) - All `Measures` variants (`Measures`, `MeasuresBasic`, `MeasuresAGo`) serialize without error diff --git a/components/airgradient-client/tests/ag_client.tests.cpp b/components/airgradient-client/tests/ag_client.tests.cpp index f2ddc6f..9b477b5 100644 --- a/components/airgradient-client/tests/ag_client.tests.cpp +++ b/components/airgradient-client/tests/ag_client.tests.cpp @@ -64,7 +64,7 @@ TEST_CASE("http_post_measures builds correct URL and content type", "[ag_client] f.mock_http.next_transport_ok = true; f.mock_http.next_status = 200; - const auto result = f.client.http_post_measures(m, -55); + const auto result = f.client.http_post_measures(m, -55, 7); REQUIRE(result == AgClientResult::Ok); REQUIRE(f.mock_http.post_call_count == 1); REQUIRE(f.mock_http.last_url == @@ -75,6 +75,8 @@ TEST_CASE("http_post_measures builds correct URL and content type", "[ag_client] cJSON *doc = cJSON_Parse(body.c_str()); REQUIRE(doc != nullptr); REQUIRE(cJSON_GetObjectItem(doc, "wifi") != nullptr); + REQUIRE(cJSON_GetObjectItem(doc, "boot") != nullptr); + REQUIRE(cJSON_GetObjectItem(doc, "boot")->valuedouble == 7.0); cJSON_Delete(doc); } @@ -82,21 +84,21 @@ TEST_CASE("http_post_measures maps 429 to Ok", "[ag_client]") { ClientFixture f; const auto m = make_invalid_basic(); f.mock_http.next_status = 429; - REQUIRE(f.client.http_post_measures(m, 0) == AgClientResult::Ok); + REQUIRE(f.client.http_post_measures(m, 0, 0) == AgClientResult::Ok); } TEST_CASE("http_post_measures returns ServerError on 500", "[ag_client]") { ClientFixture f; const auto m = make_invalid_basic(); f.mock_http.next_status = 500; - REQUIRE(f.client.http_post_measures(m, 0) == AgClientResult::ServerError); + REQUIRE(f.client.http_post_measures(m, 0, 0) == AgClientResult::ServerError); } TEST_CASE("http_post_measures returns TransportError when HTTP fails", "[ag_client]") { ClientFixture f; const auto m = make_invalid_basic(); f.mock_http.next_transport_ok = false; - REQUIRE(f.client.http_post_measures(m, 0) == AgClientResult::TransportError); + REQUIRE(f.client.http_post_measures(m, 0, 0) == AgClientResult::TransportError); } TEST_CASE("http_post_measures accepts MeasuresAGo overload", "[ag_client]") { @@ -116,12 +118,13 @@ TEST_CASE("http_post_measures accepts MeasuresAGo overload", "[ag_client]") { m.power.battery_voltage = 4.0f; f.mock_http.next_status = 200; - REQUIRE(f.client.http_post_measures(m, -50) == AgClientResult::Ok); + REQUIRE(f.client.http_post_measures(m, -50, 8) == AgClientResult::Ok); std::string body(f.mock_http.last_post_body.begin(), f.mock_http.last_post_body.end()); cJSON *doc = cJSON_Parse(body.c_str()); REQUIRE(doc != nullptr); REQUIRE(cJSON_GetObjectItem(doc, "volt") != nullptr); + REQUIRE(cJSON_GetObjectItem(doc, "boot")->valuedouble == 8.0); cJSON_Delete(doc); } @@ -134,7 +137,7 @@ TEST_CASE("http_post_measures accepts full Measures overload", "[ag_client]") { // Rest zero-initialised -- transport-only test. f.mock_http.next_status = 200; - REQUIRE(f.client.http_post_measures(m, -40) == AgClientResult::Ok); + REQUIRE(f.client.http_post_measures(m, -40, 9) == AgClientResult::Ok); std::string body(f.mock_http.last_post_body.begin(), f.mock_http.last_post_body.end()); cJSON *doc = cJSON_Parse(body.c_str()); @@ -142,6 +145,7 @@ TEST_CASE("http_post_measures accepts full Measures overload", "[ag_client]") { cJSON *atmp = cJSON_GetObjectItem(doc, "atmp"); REQUIRE(atmp != nullptr); REQUIRE(atmp->valuedouble == 21.0); // dual-channel average + REQUIRE(cJSON_GetObjectItem(doc, "boot")->valuedouble == 9.0); cJSON_Delete(doc); } @@ -231,7 +235,7 @@ TEST_CASE("http_post_measures maps 201 to Ok", "[ag_client]") { ClientFixture f; const auto m = make_invalid_basic(); f.mock_http.next_status = 201; - REQUIRE(f.client.http_post_measures(m, 0) == AgClientResult::Ok); + REQUIRE(f.client.http_post_measures(m, 0, 0) == AgClientResult::Ok); } TEST_CASE("http_fetch_config: truncation beats 400 status", "[ag_client]") { diff --git a/components/airgradient-client/tests/payload_serializer.tests.cpp b/components/airgradient-client/tests/payload_serializer.tests.cpp index 9fc64ab..eb12ee4 100644 --- a/components/airgradient-client/tests/payload_serializer.tests.cpp +++ b/components/airgradient-client/tests/payload_serializer.tests.cpp @@ -6,6 +6,7 @@ */ #include +#include #include #include @@ -107,17 +108,30 @@ std::string raw_number_str(const char *json, const char *key) { } // namespace -TEST_CASE("serializer always includes signal", "[payload_serializer]") { +TEST_CASE("serializer always includes signal and boot", "[payload_serializer]") { const auto m = make_invalid_measures(); const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, -55, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, -55, 7, buf, sizeof(buf), &written)); REQUIRE(written > 0); ParsedJson p(buf); REQUIRE(p.doc != nullptr); REQUIRE(p.has("wifi")); REQUIRE(p.number("wifi") == -55); + REQUIRE(p.has("boot")); + REQUIRE(p.number("boot") == 7); +} + +TEST_CASE("serializer preserves the uint32 boot range", "[payload_serializer]") { + MeasuresInput in; + char buf[64]; + size_t written = 0; + const uint32_t boot = std::numeric_limits::max(); + + REQUIRE(serialize_measures_json(in, -42, boot, buf, sizeof(buf), &written)); + ParsedJson p(buf); + REQUIRE(p.number("boot") == static_cast(boot)); } TEST_CASE("serializer omits invalid fields", "[payload_serializer]") { @@ -125,7 +139,7 @@ TEST_CASE("serializer omits invalid fields", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, -55, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, -55, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE_FALSE(p.has("rco2")); REQUIRE_FALSE(p.has("atmp")); @@ -145,7 +159,7 @@ TEST_CASE("serializer includes valid fields", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, -55, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, -55, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("rco2")); REQUIRE(p.number("rco2") == 450); @@ -165,7 +179,7 @@ TEST_CASE("dual-channel PM averaging when both valid", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("pm02")); REQUIRE_THAT(p.number("pm02"), Catch::Matchers::WithinAbs(15.0, 0.001)); @@ -179,7 +193,7 @@ TEST_CASE("dual-channel PM uses valid channel when only one valid", "[payload_se const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("pm02")); REQUIRE_THAT(p.number("pm02"), Catch::Matchers::WithinAbs(10.0, 0.001)); @@ -193,7 +207,7 @@ TEST_CASE("dual-channel temperature averaging", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("atmp")); REQUIRE_THAT(p.number("atmp"), Catch::Matchers::WithinAbs(21.0, 0.001)); @@ -207,7 +221,7 @@ TEST_CASE("electrode fields serialised when valid", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("measure0")); REQUIRE(p.has("measure3")); @@ -226,7 +240,7 @@ TEST_CASE("basic-variant view omits dual channel and electrode fields", "[payloa const auto in = input_basic_view(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, -50, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, -50, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("wifi")); REQUIRE(p.has("atmp")); @@ -242,7 +256,7 @@ TEST_CASE("serializer returns false when buffer too small", "[payload_serializer const auto in = input_from_full(m); char tiny[4]; size_t written = 0; - REQUIRE_FALSE(serialize_measures_json(in, -55, tiny, sizeof(tiny), &written)); + REQUIRE_FALSE(serialize_measures_json(in, -55, 0, tiny, sizeof(tiny), &written)); REQUIRE(written == 0); } @@ -253,22 +267,22 @@ TEST_CASE("serializer rejects invalid output args", "[payload_serializer]") { size_t written = 99; SECTION("null out") { - REQUIRE_FALSE(serialize_measures_json(in, 0, nullptr, sizeof(buf), &written)); + REQUIRE_FALSE(serialize_measures_json(in, 0, 0, nullptr, sizeof(buf), &written)); REQUIRE(written == 0); } SECTION("zero out_size") { - REQUIRE_FALSE(serialize_measures_json(in, 0, buf, 0, &written)); + REQUIRE_FALSE(serialize_measures_json(in, 0, 0, buf, 0, &written)); REQUIRE(written == 0); } } -TEST_CASE("serializer with all-null input emits only signal", "[payload_serializer]") { +TEST_CASE("serializer with all-null input emits request metadata", "[payload_serializer]") { MeasuresInput in; // every pointer default-null char buf[64]; size_t written = 0; - REQUIRE(serialize_measures_json(in, -42, buf, sizeof(buf), &written)); - REQUIRE(std::string(buf) == "{\"wifi\":-42}"); - REQUIRE(written == std::strlen("{\"wifi\":-42}")); + REQUIRE(serialize_measures_json(in, -42, 6, buf, sizeof(buf), &written)); + REQUIRE(std::string(buf) == "{\"wifi\":-42,\"boot\":6}"); + REQUIRE(written == std::strlen("{\"wifi\":-42,\"boot\":6}")); } TEST_CASE("dual-channel field omitted when neither channel is valid", "[payload_serializer]") { @@ -277,7 +291,7 @@ TEST_CASE("dual-channel field omitted when neither channel is valid", "[payload_ const auto in = input_from_full(m); char buf[256]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE_FALSE(p.has("pm01")); REQUIRE_FALSE(p.has("pm02")); @@ -296,7 +310,7 @@ TEST_CASE("PM standard-particle fields serialised when valid", "[payload_seriali const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("pm01Standard")); REQUIRE_THAT(p.number("pm01Standard"), Catch::Matchers::WithinAbs(4.0, 0.001)); @@ -311,7 +325,7 @@ TEST_CASE("PM standard-particle fields omitted when invalid", "[payload_serializ const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE_FALSE(p.has("pm01Standard")); REQUIRE_FALSE(p.has("pm02Standard")); @@ -326,7 +340,7 @@ TEST_CASE("PM standard-particle dual-channel averaging", "[payload_serializer]") const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("pm02Standard")); REQUIRE_THAT(p.number("pm02Standard"), Catch::Matchers::WithinAbs(20.0, 0.001)); @@ -343,7 +357,7 @@ TEST_CASE("PM particle-count fields serialised when valid", "[payload_serializer const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("pm005Count")); REQUIRE_THAT(p.number("pm005Count"), Catch::Matchers::WithinAbs(100.0, 0.001)); @@ -362,7 +376,7 @@ TEST_CASE("PM particle-count fields omitted when invalid", "[payload_serializer] const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE_FALSE(p.has("pm003Count")); REQUIRE_FALSE(p.has("pm005Count")); @@ -382,7 +396,7 @@ TEST_CASE("PM particle-count dual-channel averaging", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE(p.has("pm01Count")); REQUIRE_THAT(p.number("pm01Count"), Catch::Matchers::WithinAbs(200.0, 0.001)); @@ -398,7 +412,7 @@ TEST_CASE("temp and humidity rounded to 2 decimals", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); // 23.4567 -> 23.46, 42.125 -> 42.13 (round-half-away-from-zero on 42.125 may // collapse to 42.12 with float repr; assert via parsed double tolerance) @@ -423,7 +437,7 @@ TEST_CASE("PM mass rounded to 1 decimal", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE_THAT(p.number("pm02"), Catch::Matchers::WithinAbs(12.4, 0.001)); @@ -450,7 +464,7 @@ TEST_CASE("PM particle counts emitted as integers", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); // Numeric value rounded to nearest integer. ParsedJson p(buf); @@ -475,7 +489,7 @@ TEST_CASE("dual-channel average then round (temp)", "[payload_serializer]") { const auto in = input_from_full(m); char buf[512]; size_t written = 0; - REQUIRE(serialize_measures_json(in, 0, buf, sizeof(buf), &written)); + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); ParsedJson p(buf); REQUIRE_THAT(p.number("atmp"), Catch::Matchers::WithinAbs(21.46, 0.001)); } diff --git a/products/go/docs/cloud_service.md b/products/go/docs/cloud_service.md index 1d66dc6..753887a 100644 --- a/products/go/docs/cloud_service.md +++ b/products/go/docs/cloud_service.md @@ -21,6 +21,7 @@ HTTP cadence, snapshot lifetime, and `AgClient` interactions. Active only in |---|---|---| | `AgClient` | `airgradient-client` (`services/ag_client.h`) | `http_post_measures()`, `http_fetch_config()` | | `WifiService` | product (`go_wifi.h`) | `rssi()` at post time | +| `go_uptime` | product (`go_uptime.h`) | Retained whole-minute uptime sampled at POST time | | `Event`, `EventType` | product (`go_events.h`) | Posts `PostMeasuresResult`, `FetchConfigResult` to the orchestrator queue | | `RTOS` | `airgradient-common` (`rtos.h`) | Task create/delete, mutex, semaphore, queue send, notify, time | | `GoBoard::ag_client()` | product (`go_board.h`) | Lazy accessor; runs `AgClient::begin(serial, Wifi)` on first call | @@ -117,6 +118,15 @@ not completion time. A 15 s POST leaves 45 s before the next one. `WIFI_RSSI_INVALID` (0) is translated to `-127` before posting so the dashboard never sees a misleading 0 dB. +### Uptime Metadata + +Every measurement POST includes `boot`, sampled from `go_uptime_minutes()` when +the POST begins. It is not stored in the `MeasuresAGo` snapshot, so it advances +without new sensor data and remains independent of measurement validity. The +value has the same retained whole-minute semantics as Local Server: deep-sleep +time counts, non-deep-sleep resets start at `0`, and the wire value saturates at +`UINT32_MAX`. + ### Shutdown `stop()` sets the shutdown latch, wakes the task, and waits on the @@ -240,8 +250,8 @@ Stationary Wi-Fi / provisioning flows. ## Edge Cases / Errors - **First POST before sensor data.** Default-constructed snapshot has - every field at invalid sentinels; the serializer omits them and only - the `wifi` signal byte reaches the dashboard. + every field at invalid sentinels; the serializer omits them while `wifi` and + `boot` still reach the dashboard. - **`start()` failure.** Self-cleans on partial allocation failure. The orchestrator logs the error; the next `on_wifi_connected()` retries. - **Reconnect after AP outage.** `disarm()` on disconnect, `arm(false)` diff --git a/products/go/docs/local_server.md b/products/go/docs/local_server.md index 3dd4ccb..68a5d9d 100644 --- a/products/go/docs/local_server.md +++ b/products/go/docs/local_server.md @@ -116,8 +116,8 @@ then `1` at 60,000 ms. Deep-sleep time counts because both the session start and ESP32-C5 retained clock continue across deep sleep. Power-on, software, OTA, panic, watchdog, brownout, and other non-deep-sleep resets start a new session. The value advances without measurements and saturates at `UINT32_MAX`. Local -Server is currently its only consumer; the uptime module is independent of the -transport and can be reused by BLE or cloud. +Server and cloud POSTs both consume the same transport-independent uptime +module; BLE can reuse it later. The optional sensor fields are `co2`, `pm01`, `pm25`, `pm10`, `pm003Count`, `temp`, `humidity`, `tvocIndex`, `tvocRaw`, `noxIndex`, and `noxRaw`. Each field diff --git a/products/go/main/go_cloud.cpp b/products/go/main/go_cloud.cpp index 9761deb..ec74f4d 100644 --- a/products/go/main/go_cloud.cpp +++ b/products/go/main/go_cloud.cpp @@ -14,6 +14,7 @@ #include "go_cloud.h" #include +#include #include #include #include @@ -26,6 +27,7 @@ #include "go_cloud_types.h" #include "go_config_types.h" #include "go_events.h" +#include "go_uptime.h" #include "go_wifi.h" #include "services/ag_client.h" #include "types/wifi_types.h" @@ -481,11 +483,13 @@ void CloudService::_do_post(uint32_t now_ms) { const int raw_rssi = _wifi.rssi(); const int rssi = (raw_rssi == WIFI_RSSI_INVALID) ? RSSI_UNAVAILABLE : raw_rssi; + const uint32_t boot = go_uptime_minutes(); log_heap(TAG, "cloud.post:pre-tls"); - const AgClientResult result = _client.http_post_measures(snap, rssi); + const AgClientResult result = _client.http_post_measures(snap, rssi, boot); log_heap(TAG, "cloud.post:post-tls"); - AG_LOGI(TAG, "post_measures result=%d rssi=%d", static_cast(result), rssi); + AG_LOGI(TAG, "post_measures result=%d rssi=%d boot=%" PRIu32, static_cast(result), rssi, + boot); Event evt{}; evt.type = EventType::PostMeasuresResult; diff --git a/products/go/tests/CMakeLists.txt b/products/go/tests/CMakeLists.txt index 0bdb85e..32cf9aa 100644 --- a/products/go/tests/CMakeLists.txt +++ b/products/go/tests/CMakeLists.txt @@ -665,6 +665,7 @@ catch_discover_tests(go_sensor_producer_tests) add_library(go_cloud_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_cloud.cpp" + "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/rtos.cpp" go_cloud_stubs.cpp ) diff --git a/products/go/tests/go_app_stubs.cpp b/products/go/tests/go_app_stubs.cpp index 057129e..68e4591 100644 --- a/products/go/tests/go_app_stubs.cpp +++ b/products/go/tests/go_app_stubs.cpp @@ -639,15 +639,18 @@ AgClientResult AgClient::http_fetch_config(char * /*config_out*/, size_t /*confi return AgClientResult::Ok; } -AgClientResult AgClient::http_post_measures(const Measures & /*measures*/, int /*signal*/) { +AgClientResult AgClient::http_post_measures(const Measures & /*measures*/, int /*signal*/, + uint32_t /*boot*/) { return AgClientResult::Ok; } -AgClientResult AgClient::http_post_measures(const MeasuresBasic & /*measures*/, int /*signal*/) { +AgClientResult AgClient::http_post_measures(const MeasuresBasic & /*measures*/, int /*signal*/, + uint32_t /*boot*/) { return AgClientResult::Ok; } -AgClientResult AgClient::http_post_measures(const MeasuresAGo & /*measures*/, int /*signal*/) { +AgClientResult AgClient::http_post_measures(const MeasuresAGo & /*measures*/, int /*signal*/, + uint32_t /*boot*/) { return AgClientResult::Ok; } diff --git a/products/go/tests/go_cloud.tests.cpp b/products/go/tests/go_cloud.tests.cpp index 3b95820..01a79eb 100644 --- a/products/go/tests/go_cloud.tests.cpp +++ b/products/go/tests/go_cloud.tests.cpp @@ -24,6 +24,7 @@ #include "go_cloud.h" #include "go_config_types.h" #include "go_events.h" +#include "go_uptime.h" #include "go_wifi.h" #include "rtos.h" #include "services/ag_client.h" @@ -37,6 +38,7 @@ extern uint32_t post_call_count; extern uint32_t fetch_call_count; extern MeasuresAGo last_post_snapshot; extern int last_post_signal; +extern uint32_t last_post_boot; extern char *last_fetch_buf; extern size_t last_fetch_buf_size; extern AgClientResult next_post_result; @@ -99,6 +101,8 @@ class MockRTOS : public trompeloeil::mock_interface { IMPLEMENT_MOCK1(delay_ms_impl); IMPLEMENT_MOCK0(get_time_ms_impl); + uint64_t get_retained_time_ms_impl() override { return retained_time_ms; } + // Capture the last timeout passed to task_notify_take(). uint32_t last_notify_take_ms = UINT32_MAX; uint32_t notify_take_calls = 0; @@ -120,6 +124,7 @@ class MockRTOS : public trompeloeil::mock_interface { Event last_event{}; uint32_t events_posted = 0; + uint64_t retained_time_ms = 0; }; // ============================================================================ @@ -152,6 +157,8 @@ struct CloudFixture { CloudService::Config{}) { cloud_spy::reset(); RTOS::set_instance(&mock_rtos); + go_uptime_reset_retained_state_for_test(); + go_uptime_init(); _exp_time = NAMED_ALLOW_CALL(mock_rtos, get_time_ms_impl()).RETURN(0); _exp_delay = NAMED_ALLOW_CALL(mock_rtos, delay_ms_impl(trompeloeil::_)); @@ -167,6 +174,7 @@ struct CloudFixture { // Detach the buffer so ~CloudService -> stop() does not free our // stack array. A::set_fetch_buf(cloud, nullptr); + go_uptime_reset_retained_state_for_test(); RTOS::set_instance(nullptr); } @@ -830,9 +838,7 @@ TEST_CASE("Past deadline returns 0 wake (no UINT32_MAX wrap)", "[CloudService][c } // ============================================================================ -// 13. First POST with empty snapshot — default sentinels mean no measure -// fields leak; only the wifi signal byte goes out (verified via the -// stub recording the snapshot as it was handed off). +// 13. POST metadata and empty snapshot // ============================================================================ TEST_CASE("First POST sees a default-constructed snapshot", "[CloudService][first_post]") { @@ -844,11 +850,12 @@ TEST_CASE("First POST sees a default-constructed snapshot", "[CloudService][firs A::run_once(f.cloud, /*now=*/0); REQUIRE(cloud_spy::post_call_count == 1); + REQUIRE(cloud_spy::last_post_boot == 0); // Every measure field on the snapshot fails its is_*_valid() check // because Prereq A made the default sentinels universal. This is // the contract the cloud task relies on for the cold-boot first - // POST: serializer omits all measure fields, only "wifi" goes out. + // POST: serializer omits all measure fields, while "wifi" and "boot" remain. const MeasuresAGo &s = cloud_spy::last_post_snapshot; REQUIRE_FALSE(s.co2.is_valid()); REQUIRE_FALSE(s.pm_a.is_valid()); @@ -858,6 +865,23 @@ TEST_CASE("First POST sees a default-constructed snapshot", "[CloudService][firs REQUIRE_FALSE(s.pressure.is_valid()); } +TEST_CASE("Cloud POST samples uptime without a new measurement", "[CloudService][boot]") { + CloudFixture f; + A::set_armed(f.cloud, true); + A::set_was_armed(f.cloud, true); + A::set_post_due(f.cloud, 0); + A::set_fetch_due(f.cloud, 999'999'999); + + A::run_once(f.cloud, /*now=*/0); + REQUIRE(cloud_spy::post_call_count == 1); + REQUIRE(cloud_spy::last_post_boot == 0); + + f.mock_rtos.retained_time_ms = 60'000; + A::run_once(f.cloud, /*now=*/60'000); + REQUIRE(cloud_spy::post_call_count == 2); + REQUIRE(cloud_spy::last_post_boot == 1); +} + // ============================================================================ // 14. Disarmed idle sleeps indefinitely // ============================================================================ diff --git a/products/go/tests/go_cloud_stubs.cpp b/products/go/tests/go_cloud_stubs.cpp index 6b6ba87..fb50460 100644 --- a/products/go/tests/go_cloud_stubs.cpp +++ b/products/go/tests/go_cloud_stubs.cpp @@ -31,6 +31,7 @@ uint32_t fetch_call_count = 0; // Last POST snapshot (and the RSSI value the cloud task forwarded) MeasuresAGo last_post_snapshot{}; int last_post_signal = 0; +uint32_t last_post_boot = 0; // Last FETCH parameters char *last_fetch_buf = nullptr; @@ -59,6 +60,7 @@ void reset() { fetch_call_count = 0; last_post_snapshot = MeasuresAGo{}; last_post_signal = 0; + last_post_boot = 0; last_fetch_buf = nullptr; last_fetch_buf_size = 0; next_post_result = AgClientResult::Ok; @@ -81,18 +83,21 @@ bool AgClient::begin(const char * /*serial_number*/, NetworkType /*network*/, return true; } -AgClientResult AgClient::http_post_measures(const Measures & /*m*/, int /*signal*/) { +AgClientResult AgClient::http_post_measures(const Measures & /*m*/, int /*signal*/, + uint32_t /*boot*/) { return AgClientResult::Ok; } -AgClientResult AgClient::http_post_measures(const MeasuresBasic & /*m*/, int /*signal*/) { +AgClientResult AgClient::http_post_measures(const MeasuresBasic & /*m*/, int /*signal*/, + uint32_t /*boot*/) { return AgClientResult::Ok; } -AgClientResult AgClient::http_post_measures(const MeasuresAGo &m, int signal) { +AgClientResult AgClient::http_post_measures(const MeasuresAGo &m, int signal, uint32_t boot) { cloud_spy::post_call_count += 1; cloud_spy::last_post_snapshot = m; cloud_spy::last_post_signal = signal; + cloud_spy::last_post_boot = boot; if (cloud_spy::on_post_hook != nullptr) { cloud_spy::on_post_hook(); } diff --git a/products/go/tests/go_orchestrator_stubs.cpp b/products/go/tests/go_orchestrator_stubs.cpp index bc58cad..bae1f28 100644 --- a/products/go/tests/go_orchestrator_stubs.cpp +++ b/products/go/tests/go_orchestrator_stubs.cpp @@ -1046,15 +1046,18 @@ AgClientResult AgClient::http_fetch_config(char * /*config_out*/, size_t /*confi return AgClientResult::Ok; } -AgClientResult AgClient::http_post_measures(const Measures & /*measures*/, int /*signal*/) { +AgClientResult AgClient::http_post_measures(const Measures & /*measures*/, int /*signal*/, + uint32_t /*boot*/) { return AgClientResult::Ok; } -AgClientResult AgClient::http_post_measures(const MeasuresBasic & /*measures*/, int /*signal*/) { +AgClientResult AgClient::http_post_measures(const MeasuresBasic & /*measures*/, int /*signal*/, + uint32_t /*boot*/) { return AgClientResult::Ok; } -AgClientResult AgClient::http_post_measures(const MeasuresAGo & /*measures*/, int /*signal*/) { +AgClientResult AgClient::http_post_measures(const MeasuresAGo & /*measures*/, int /*signal*/, + uint32_t /*boot*/) { return AgClientResult::Ok; } diff --git a/products/reference/main/test_airgradient_client.cpp b/products/reference/main/test_airgradient_client.cpp index cbd5601..faa19ce 100644 --- a/products/reference/main/test_airgradient_client.cpp +++ b/products/reference/main/test_airgradient_client.cpp @@ -31,6 +31,7 @@ static constexpr int WIFI_CONNECTED_BIT = BIT0; static constexpr int WIFI_FAILED_BIT = BIT1; static constexpr int WIFI_MAX_RETRIES = 5; static constexpr size_t CONFIG_BUFFER_SIZE = 2048; +static constexpr uint32_t TEST_BOOT_MINUTES = 0; namespace { @@ -217,7 +218,7 @@ bool run_case(const TestCase &tc, int signal, const Measures &measures) { ok &= fetch_match; // ---- post_measures ------------------------------------------------ - const AgClientResult post_result = client.http_post_measures(measures, signal); + const AgClientResult post_result = client.http_post_measures(measures, signal, TEST_BOOT_MINUTES); const bool post_match = (post_result == tc.expected_post); ESP_LOGI(TAG, " post_measures: got=%s expect=%s %s", result_to_str(post_result), result_to_str(tc.expected_post), post_match ? "[PASS]" : "[FAIL]"); From b6b2d9e795be785886abec621d29584b51379c88 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 24 Jul 2026 19:28:05 +0300 Subject: [PATCH 3/3] refactor(common): share retained uptime --- components/README.md | 2 +- components/airgradient-common/CMakeLists.txt | 5 +- .../include/retained_uptime.h | 28 +++++ .../airgradient-common/retained_uptime.cpp | 20 ++-- .../airgradient-common/tests/CMakeLists.txt | 1 + products/go/docs/cloud_service.md | 14 +-- products/go/docs/local_server.md | 9 +- products/go/docs/orchestrator.md | 9 +- products/go/docs/power_management.md | 11 +- products/go/main/CMakeLists.txt | 1 - products/go/main/go_app.cpp | 4 +- products/go/main/go_cloud.cpp | 4 +- products/go/main/go_local_api.cpp | 4 +- products/go/main/go_uptime.h | 26 ---- products/go/tests/CMakeLists.txt | 9 +- products/go/tests/go_cloud.tests.cpp | 8 +- products/go/tests/go_local_api.tests.cpp | 14 +-- products/go/tests/go_uptime.tests.cpp | 113 ------------------ 18 files changed, 88 insertions(+), 194 deletions(-) create mode 100644 components/airgradient-common/include/retained_uptime.h rename products/go/main/go_uptime.cpp => components/airgradient-common/retained_uptime.cpp (67%) delete mode 100644 products/go/main/go_uptime.h delete mode 100644 products/go/tests/go_uptime.tests.cpp diff --git a/components/README.md b/components/README.md index 6239a6a..d2dd376 100644 --- a/components/README.md +++ b/components/README.md @@ -18,7 +18,7 @@ third party can live here if it is part of the shared firmware foundation. | [`airgradient-ble`](airgradient-ble/README.md) | BLE peripheral HAL and NimBLE-backed GATT server, characteristic management, and advertising control | | [`airgradient-bms`](airgradient-bms/README.md) | Battery management HAL, public BMS types, and concrete charger / PMIC drivers (e.g. BQ25XX) | | [`airgradient-cellular`](airgradient-cellular/README.md) | Cellular modem HAL, shared cellular types, AT-command service, and modem drivers (scaffold) | -| [`airgradient-common`](airgradient-common/) | Shared data types, `Measures` types, and the RTOS abstraction (no README yet) | +| [`airgradient-common`](airgradient-common/) | Shared data types, `Measures` types, RTOS abstraction, and retained uptime (no README yet) | | [`airgradient-config`](airgradient-config/README.md) | Typed key-value persistence interface and reusable backends (NVS) | | [`airgradient-gpio`](airgradient-gpio/README.md) | GPIO HAL and ESP-IDF-backed driver for pin control and interrupt registration | | [`airgradient-nand-storage`](airgradient-nand-storage/README.md) | SPI NAND flash HAL providing FATFS mount/unmount lifecycle for application POSIX I/O | diff --git a/components/airgradient-common/CMakeLists.txt b/components/airgradient-common/CMakeLists.txt index 15f1b5f..4ea62fc 100644 --- a/components/airgradient-common/CMakeLists.txt +++ b/components/airgradient-common/CMakeLists.txt @@ -1,6 +1,7 @@ idf_component_register( - SRCS "rtos.cpp" "common.cpp" "ag_i2c.cpp" "aqi.cpp" "measurement_corrections.cpp" + SRCS "rtos.cpp" "retained_uptime.cpp" "common.cpp" "ag_i2c.cpp" "aqi.cpp" + "measurement_corrections.cpp" INCLUDE_DIRS "include" REQUIRES freertos esp_timer esp_system heap airgradient-gpio esp_app_format driver - PRIV_REQUIRES esp_hw_support + PRIV_REQUIRES esp_common esp_hw_support ) diff --git a/components/airgradient-common/include/retained_uptime.h b/components/airgradient-common/include/retained_uptime.h new file mode 100644 index 0000000..a3511d4 --- /dev/null +++ b/components/airgradient-common/include/retained_uptime.h @@ -0,0 +1,28 @@ +/** + * AirGradient + * https://airgradient.com + * + * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License + */ + +#ifndef AG_RETAINED_UPTIME_H +#define AG_RETAINED_UPTIME_H + +#include + +namespace retained_uptime { + +/** Initialize the retained uptime session start when it is not already valid. */ +void init(); + +/** Return completed uptime minutes, saturated to the uint32_t maximum. */ +uint32_t completed_minutes(); + +#ifdef TEST_HOST +/** Simulate reloading the RTC_DATA_ATTR initializer after a non-retained reset. */ +void reset_state_for_test(); +#endif + +} // namespace retained_uptime + +#endif // AG_RETAINED_UPTIME_H diff --git a/products/go/main/go_uptime.cpp b/components/airgradient-common/retained_uptime.cpp similarity index 67% rename from products/go/main/go_uptime.cpp rename to components/airgradient-common/retained_uptime.cpp index 5db60b0..a6e0076 100644 --- a/products/go/main/go_uptime.cpp +++ b/components/airgradient-common/retained_uptime.cpp @@ -1,13 +1,11 @@ /** - * AirGradient Go -- retained monotonic uptime - * * AirGradient * https://airgradient.com * * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License */ -#include "go_uptime.h" +#include "retained_uptime.h" #include @@ -28,27 +26,31 @@ RTC_DATA_ATTR uint64_t s_start_time_ms = INVALID_START_TIME_MS; } // namespace -void go_uptime_init() { +namespace retained_uptime { + +void init() { const uint64_t now_ms = RTOS::get_retained_time_ms(); if (s_start_time_ms == INVALID_START_TIME_MS || now_ms < s_start_time_ms) { s_start_time_ms = now_ms; } } -uint32_t go_uptime_minutes() { +uint32_t completed_minutes() { const uint64_t now_ms = RTOS::get_retained_time_ms(); if (s_start_time_ms == INVALID_START_TIME_MS || now_ms < s_start_time_ms) { s_start_time_ms = now_ms; return 0; } - const uint64_t completed_minutes = (now_ms - s_start_time_ms) / MILLISECONDS_PER_MINUTE; - if (completed_minutes > std::numeric_limits::max()) { + const uint64_t completed = (now_ms - s_start_time_ms) / MILLISECONDS_PER_MINUTE; + if (completed > std::numeric_limits::max()) { return std::numeric_limits::max(); } - return static_cast(completed_minutes); + return static_cast(completed); } #ifdef TEST_HOST -void go_uptime_reset_retained_state_for_test() { s_start_time_ms = INVALID_START_TIME_MS; } +void reset_state_for_test() { s_start_time_ms = INVALID_START_TIME_MS; } #endif + +} // namespace retained_uptime diff --git a/components/airgradient-common/tests/CMakeLists.txt b/components/airgradient-common/tests/CMakeLists.txt index cb49ea9..c31401b 100644 --- a/components/airgradient-common/tests/CMakeLists.txt +++ b/components/airgradient-common/tests/CMakeLists.txt @@ -46,6 +46,7 @@ catch_discover_tests(airgradient_common_correction_tests) add_library(airgradient_common_rtos_test_support "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/rtos.cpp" + "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/retained_uptime.cpp" ) target_include_directories(airgradient_common_rtos_test_support PUBLIC diff --git a/products/go/docs/cloud_service.md b/products/go/docs/cloud_service.md index 753887a..2cd4a21 100644 --- a/products/go/docs/cloud_service.md +++ b/products/go/docs/cloud_service.md @@ -21,7 +21,7 @@ HTTP cadence, snapshot lifetime, and `AgClient` interactions. Active only in |---|---|---| | `AgClient` | `airgradient-client` (`services/ag_client.h`) | `http_post_measures()`, `http_fetch_config()` | | `WifiService` | product (`go_wifi.h`) | `rssi()` at post time | -| `go_uptime` | product (`go_uptime.h`) | Retained whole-minute uptime sampled at POST time | +| `retained_uptime` | `airgradient-common` (`retained_uptime.h`) | Retained whole-minute uptime sampled at POST time | | `Event`, `EventType` | product (`go_events.h`) | Posts `PostMeasuresResult`, `FetchConfigResult` to the orchestrator queue | | `RTOS` | `airgradient-common` (`rtos.h`) | Task create/delete, mutex, semaphore, queue send, notify, time | | `GoBoard::ag_client()` | product (`go_board.h`) | Lazy accessor; runs `AgClient::begin(serial, Wifi)` on first call | @@ -120,12 +120,12 @@ dashboard never sees a misleading 0 dB. ### Uptime Metadata -Every measurement POST includes `boot`, sampled from `go_uptime_minutes()` when -the POST begins. It is not stored in the `MeasuresAGo` snapshot, so it advances -without new sensor data and remains independent of measurement validity. The -value has the same retained whole-minute semantics as Local Server: deep-sleep -time counts, non-deep-sleep resets start at `0`, and the wire value saturates at -`UINT32_MAX`. +Every measurement POST includes `boot`, sampled from +`retained_uptime::completed_minutes()` when the POST begins. It is not stored in +the `MeasuresAGo` snapshot, so it advances without new sensor data and remains +independent of measurement validity. The value has the same retained +whole-minute semantics as Local Server: deep-sleep time counts, non-deep-sleep +resets start at `0`, and the wire value saturates at `UINT32_MAX`. ### Shutdown diff --git a/products/go/docs/local_server.md b/products/go/docs/local_server.md index 68a5d9d..919d44d 100644 --- a/products/go/docs/local_server.md +++ b/products/go/docs/local_server.md @@ -13,7 +13,7 @@ incomplete. |---|---| | `products/go/main/go_local_api.h` | Product providers, snapshots, access state, and fixed request FIFO | | `products/go/main/go_local_api.cpp` | Go measures/config mapping, validation, admission, and queue signaling | -| `products/go/main/go_uptime.h`, `go_uptime.cpp` | RTC-retained monotonic uptime calculation | +| `components/airgradient-common/include/retained_uptime.h`, `retained_uptime.cpp` | Shared RTC-retained monotonic uptime calculation | | `products/go/main/go_orchestrator.h` | Local endpoint state, retry deadline, and orchestration declarations | | `products/go/main/go_orchestrator.cpp` | Request processing, persistence, OTA policy, and Stationary lifecycle | | `products/go/main/go_wifi.h` | Shared listener and local mDNS lifecycle API | @@ -30,7 +30,8 @@ incomplete. | `WifiManager` | `airgradient-wifi` (`services/wifi_manager.h`) | Stationary connectivity and `_airgradient._tcp` mDNS lifecycle | | `GoSettings`, `ConfigStore` | product (`go_settings.h`) | Authoritative configuration, validation, and persistence | | `SensorProducer` | product (`go_sensor_producer.h`) | Asynchronous CO2 calibration execution | -| `RTOS` | `airgradient-common` (`rtos.h`) | Snapshot mutex, event queue, lifecycle timers, and retained monotonic time | +| `retained_uptime` | `airgradient-common` (`retained_uptime.h`) | Completed-minute uptime for system information | +| `RTOS` | `airgradient-common` (`rtos.h`) | Snapshot mutex, event queue, lifecycle timers, and retained clock abstraction | ## Public API @@ -116,8 +117,8 @@ then `1` at 60,000 ms. Deep-sleep time counts because both the session start and ESP32-C5 retained clock continue across deep sleep. Power-on, software, OTA, panic, watchdog, brownout, and other non-deep-sleep resets start a new session. The value advances without measurements and saturates at `UINT32_MAX`. Local -Server and cloud POSTs both consume the same transport-independent uptime -module; BLE can reuse it later. +Server and cloud POSTs both consume the shared, transport-independent +`retained_uptime` utility; BLE can reuse it later. The optional sensor fields are `co2`, `pm01`, `pm25`, `pm10`, `pm003Count`, `temp`, `humidity`, `tvocIndex`, `tvocRaw`, `noxIndex`, and `noxRaw`. Each field diff --git a/products/go/docs/orchestrator.md b/products/go/docs/orchestrator.md index 09ac558..f9c1618 100644 --- a/products/go/docs/orchestrator.md +++ b/products/go/docs/orchestrator.md @@ -520,7 +520,8 @@ network-side contract. `GoApp` constructs `GoLocalApiService` as the `LocalServer` measures, config, and action provider. The service returns mutex-protected orchestrator snapshots -and computes retained uptime when system information is requested: +and reads the shared `airgradient-common` retained uptime utility when system +information is requested: - `init()` publishes the active settings, corrected measurement view, and an absent Wi-Fi RSSI. @@ -920,9 +921,9 @@ device is locked and the first measurement is complete: 7. power_service.reset_ext_watchdog() — maximize timeout window during sleep ``` -Uptime needs no `prepare_for_sleep()` checkpoint. Its RTC-retained start -timestamp is compared with a retained monotonic clock that continues while the -CPU is in deep sleep. +The shared retained uptime utility needs no `prepare_for_sleep()` checkpoint. +Its RTC-retained start timestamp is compared with a retained monotonic clock +that continues while the CPU is in deep sleep. `save_rtc_display_snapshot()` is called after `update(values, true)` so the snapshot reflects exactly what was last rendered. It is intentionally before diff --git a/products/go/docs/power_management.md b/products/go/docs/power_management.md index f93c797..d019f71 100644 --- a/products/go/docs/power_management.md +++ b/products/go/docs/power_management.md @@ -181,11 +181,12 @@ The caller must set `RtcAppState::sensors_warm` via ### Retained Uptime -Go stores one invalid-sentinel-initialized uptime start timestamp in RTC data. -`GoApp::run()` initializes it before boot-path selection. The ESP32-C5 retained -monotonic clock continues through intentional deep sleep, so the reported -completed-minute uptime includes both awake and deep-sleep time without a -per-sleep checkpoint or accumulation of requested sleep durations. +The shared `airgradient-common` retained uptime utility stores one +invalid-sentinel-initialized start timestamp in RTC data. `GoApp::run()` +initializes it before boot-path selection. The ESP32-C5 retained monotonic clock +continues through intentional deep sleep, so the reported completed-minute +uptime includes both awake and deep-sleep time without a per-sleep checkpoint or +accumulation of requested sleep durations. Deep sleep preserves the start timestamp. Power-on, software, OTA, panic, watchdog, brownout, and other non-deep-sleep resets reload its invalid diff --git a/products/go/main/CMakeLists.txt b/products/go/main/CMakeLists.txt index 8a35b4a..5d90613 100644 --- a/products/go/main/CMakeLists.txt +++ b/products/go/main/CMakeLists.txt @@ -19,7 +19,6 @@ idf_component_register( "go_storage.cpp" "go_ui.cpp" "go_ulp.cpp" - "go_uptime.cpp" "go_text_wrap.cpp" "go_wifi.cpp" "led/go_led.cpp" diff --git a/products/go/main/go_app.cpp b/products/go/main/go_app.cpp index 2a29823..ce41197 100644 --- a/products/go/main/go_app.cpp +++ b/products/go/main/go_app.cpp @@ -47,10 +47,10 @@ inline esp_reset_reason_t esp_reset_reason() { return ESP_RST_UNKNOWN; } #include "go_storage.h" #include "go_ui.h" #include "go_ulp.h" -#include "go_uptime.h" #include "go_wifi.h" #include "gps/gps_service.h" #include "measurement_corrections.h" +#include "retained_uptime.h" #include "rtos.h" #include "services/local_server.h" #include "services/sensor_manager.h" @@ -120,7 +120,7 @@ GoApp::GoApp(GoBoard &board) : _board(board) {} // =========================================================================== void GoApp::run() { - go_uptime_init(); + retained_uptime::init(); RTOS::delay_ms(100); log_heap(TAG, "boot:run-entry"); diff --git a/products/go/main/go_cloud.cpp b/products/go/main/go_cloud.cpp index ec74f4d..07f11b2 100644 --- a/products/go/main/go_cloud.cpp +++ b/products/go/main/go_cloud.cpp @@ -27,8 +27,8 @@ #include "go_cloud_types.h" #include "go_config_types.h" #include "go_events.h" -#include "go_uptime.h" #include "go_wifi.h" +#include "retained_uptime.h" #include "services/ag_client.h" #include "types/wifi_types.h" @@ -483,7 +483,7 @@ void CloudService::_do_post(uint32_t now_ms) { const int raw_rssi = _wifi.rssi(); const int rssi = (raw_rssi == WIFI_RSSI_INVALID) ? RSSI_UNAVAILABLE : raw_rssi; - const uint32_t boot = go_uptime_minutes(); + const uint32_t boot = retained_uptime::completed_minutes(); log_heap(TAG, "cloud.post:pre-tls"); const AgClientResult result = _client.http_post_measures(snap, rssi, boot); diff --git a/products/go/main/go_local_api.cpp b/products/go/main/go_local_api.cpp index 1010d77..5924a2a 100644 --- a/products/go/main/go_local_api.cpp +++ b/products/go/main/go_local_api.cpp @@ -14,8 +14,8 @@ #include #include "go_events.h" -#include "go_uptime.h" #include "measurement_corrections.h" +#include "retained_uptime.h" namespace { @@ -140,7 +140,7 @@ SystemInfo GoLocalApiService::get_system_info() { } SystemInfo system_info = _system_info; _mutex.unlock(); - system_info.boot = go_uptime_minutes(); + system_info.boot = retained_uptime::completed_minutes(); return system_info; } diff --git a/products/go/main/go_uptime.h b/products/go/main/go_uptime.h deleted file mode 100644 index 1b51bd6..0000000 --- a/products/go/main/go_uptime.h +++ /dev/null @@ -1,26 +0,0 @@ -/** - * AirGradient Go -- retained monotonic uptime - * - * AirGradient - * https://airgradient.com - * - * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License - */ - -#ifndef GO_UPTIME_H -#define GO_UPTIME_H - -#include - -/** Initialize the retained uptime session start when it is not already valid. */ -void go_uptime_init(); - -/** Return completed uptime minutes, saturated to the uint32_t wire range. */ -uint32_t go_uptime_minutes(); - -#ifdef TEST_HOST -/** Simulate reloading the RTC_DATA_ATTR initializer after a non-deep-sleep reset. */ -void go_uptime_reset_retained_state_for_test(); -#endif - -#endif // GO_UPTIME_H diff --git a/products/go/tests/CMakeLists.txt b/products/go/tests/CMakeLists.txt index 32cf9aa..80e8b52 100644 --- a/products/go/tests/CMakeLists.txt +++ b/products/go/tests/CMakeLists.txt @@ -271,7 +271,7 @@ catch_discover_tests(go_ui_tests) add_library(go_orchestrator_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_orchestrator.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_local_api.cpp" - "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_uptime.cpp" + "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/retained_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_ui.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_settings.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/measurement_corrections.cpp" @@ -555,7 +555,7 @@ catch_discover_tests(go_gps_types_tests) add_library(go_app_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_app.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_local_api.cpp" - "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_uptime.cpp" + "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/retained_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_settings.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/measurement_corrections.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/common.cpp" @@ -665,7 +665,7 @@ catch_discover_tests(go_sensor_producer_tests) add_library(go_cloud_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_cloud.cpp" - "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_uptime.cpp" + "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/retained_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/rtos.cpp" go_cloud_stubs.cpp ) @@ -702,7 +702,7 @@ catch_discover_tests(go_cloud_tests) add_library(go_local_api_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_local_api.cpp" - "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_uptime.cpp" + "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/retained_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/measurement_corrections.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/rtos.cpp" ) @@ -719,7 +719,6 @@ target_compile_definitions(go_local_api_test_support PUBLIC TEST_HOST) add_executable(go_local_api_tests go_local_api.tests.cpp - go_uptime.tests.cpp ) target_link_libraries(go_local_api_tests PRIVATE diff --git a/products/go/tests/go_cloud.tests.cpp b/products/go/tests/go_cloud.tests.cpp index 01a79eb..d537004 100644 --- a/products/go/tests/go_cloud.tests.cpp +++ b/products/go/tests/go_cloud.tests.cpp @@ -24,8 +24,8 @@ #include "go_cloud.h" #include "go_config_types.h" #include "go_events.h" -#include "go_uptime.h" #include "go_wifi.h" +#include "retained_uptime.h" #include "rtos.h" #include "services/ag_client.h" @@ -157,8 +157,8 @@ struct CloudFixture { CloudService::Config{}) { cloud_spy::reset(); RTOS::set_instance(&mock_rtos); - go_uptime_reset_retained_state_for_test(); - go_uptime_init(); + retained_uptime::reset_state_for_test(); + retained_uptime::init(); _exp_time = NAMED_ALLOW_CALL(mock_rtos, get_time_ms_impl()).RETURN(0); _exp_delay = NAMED_ALLOW_CALL(mock_rtos, delay_ms_impl(trompeloeil::_)); @@ -174,7 +174,7 @@ struct CloudFixture { // Detach the buffer so ~CloudService -> stop() does not free our // stack array. A::set_fetch_buf(cloud, nullptr); - go_uptime_reset_retained_state_for_test(); + retained_uptime::reset_state_for_test(); RTOS::set_instance(nullptr); } diff --git a/products/go/tests/go_local_api.tests.cpp b/products/go/tests/go_local_api.tests.cpp index 7728027..7a98a8d 100644 --- a/products/go/tests/go_local_api.tests.cpp +++ b/products/go/tests/go_local_api.tests.cpp @@ -14,7 +14,7 @@ #include "go_events.h" #include "go_local_api.h" -#include "go_uptime.h" +#include "retained_uptime.h" #include "rtos.h" class GoLocalApiServiceTestAccess { @@ -55,8 +55,8 @@ class TestRtos final : public RTOS { struct Fixture { Fixture() { RTOS::set_instance(&rtos); - go_uptime_reset_retained_state_for_test(); - go_uptime_init(); + retained_uptime::reset_state_for_test(); + retained_uptime::init(); event_queue = RTOS::queue_create(EVENT_QUEUE_DEPTH, sizeof(Event)); REQUIRE(event_queue != nullptr); @@ -69,7 +69,7 @@ struct Fixture { ~Fixture() { service.reset(); RTOS::queue_delete(event_queue); - go_uptime_reset_retained_state_for_test(); + retained_uptime::reset_state_for_test(); RTOS::set_instance(nullptr); } @@ -182,8 +182,8 @@ TEST_CASE("Go local API initializes safe snapshots") { TEST_CASE("Go local API truncates identity while preserving termination") { TestRtos rtos; RTOS::set_instance(&rtos); - go_uptime_reset_retained_state_for_test(); - go_uptime_init(); + retained_uptime::reset_state_for_test(); + retained_uptime::init(); RtosQueueHandle queue = RTOS::queue_create(EVENT_QUEUE_DEPTH, sizeof(Event)); REQUIRE(queue != nullptr); @@ -201,7 +201,7 @@ TEST_CASE("Go local API truncates identity while preserving termination") { CHECK(std::strlen(info.firmware) == sizeof(info.firmware) - 1); RTOS::queue_delete(queue); - go_uptime_reset_retained_state_for_test(); + retained_uptime::reset_state_for_test(); RTOS::set_instance(nullptr); } diff --git a/products/go/tests/go_uptime.tests.cpp b/products/go/tests/go_uptime.tests.cpp deleted file mode 100644 index 262d0b6..0000000 --- a/products/go/tests/go_uptime.tests.cpp +++ /dev/null @@ -1,113 +0,0 @@ -/** - * AirGradient Go -- retained monotonic uptime host tests - */ - -#include -#include - -#include - -#include "go_uptime.h" -#include "rtos.h" - -namespace { - -constexpr uint64_t MILLISECONDS_PER_MINUTE = 60'000ULL; - -class TestRtos final : public RTOS { -public: - void delay_ms_impl(uint32_t) override {} - uint64_t get_time_ms_impl() override { return 0; } - uint64_t get_retained_time_ms_impl() override { return retained_time_ms; } - - uint64_t retained_time_ms = 0; -}; - -class Fixture { -public: - Fixture() { - RTOS::set_instance(&rtos); - go_uptime_reset_retained_state_for_test(); - } - - ~Fixture() { - go_uptime_reset_retained_state_for_test(); - RTOS::set_instance(nullptr); - } - - TestRtos rtos; -}; - -} // namespace - -TEST_CASE("Go uptime floors completed minutes at boundaries", "[go][uptime]") { - Fixture fixture; - go_uptime_init(); - - fixture.rtos.retained_time_ms = MILLISECONDS_PER_MINUTE - 1; - CHECK(go_uptime_minutes() == 0); - - fixture.rtos.retained_time_ms = MILLISECONDS_PER_MINUTE; - CHECK(go_uptime_minutes() == 1); - - fixture.rtos.retained_time_ms = (2 * MILLISECONDS_PER_MINUTE) - 1; - CHECK(go_uptime_minutes() == 1); - - fixture.rtos.retained_time_ms = 2 * MILLISECONDS_PER_MINUTE; - CHECK(go_uptime_minutes() == 2); -} - -TEST_CASE("Go uptime advances across a simulated deep-sleep wake", "[go][uptime]") { - Fixture fixture; - fixture.rtos.retained_time_ms = 10'000; - go_uptime_init(); - - fixture.rtos.retained_time_ms = 70'000; - CHECK(go_uptime_minutes() == 1); - - // Deep sleep restarts the CPU, but both RTC data and the retained clock continue. - fixture.rtos.retained_time_ms = 100'000; - go_uptime_init(); - fixture.rtos.retained_time_ms = 130'000; - CHECK(go_uptime_minutes() == 2); -} - -TEST_CASE("Go uptime restarts after a simulated non-deep-sleep reset", "[go][uptime]") { - Fixture fixture; - go_uptime_init(); - fixture.rtos.retained_time_ms = 3 * MILLISECONDS_PER_MINUTE; - REQUIRE(go_uptime_minutes() == 3); - - go_uptime_reset_retained_state_for_test(); - fixture.rtos.retained_time_ms = 200'000; - go_uptime_init(); - CHECK(go_uptime_minutes() == 0); - - fixture.rtos.retained_time_ms = 200'000 + MILLISECONDS_PER_MINUTE; - CHECK(go_uptime_minutes() == 1); -} - -TEST_CASE("Go uptime recovers when the retained clock regresses", "[go][uptime]") { - Fixture fixture; - fixture.rtos.retained_time_ms = 100'000; - go_uptime_init(); - - fixture.rtos.retained_time_ms = 50'000; - CHECK(go_uptime_minutes() == 0); - - fixture.rtos.retained_time_ms = 110'000; - CHECK(go_uptime_minutes() == 1); -} - -TEST_CASE("Go uptime saturates the uint32 minute value", "[go][uptime]") { - Fixture fixture; - go_uptime_init(); - - const uint64_t overflow_minutes = - static_cast(std::numeric_limits::max()) + 1ULL; - fixture.rtos.retained_time_ms = overflow_minutes * MILLISECONDS_PER_MINUTE; - CHECK(go_uptime_minutes() == std::numeric_limits::max()); - - fixture.rtos.retained_time_ms = std::numeric_limits::max() - 1ULL; - CHECK(go_uptime_minutes() == std::numeric_limits::max()); -}