diff --git a/components/airgradient-local-server/README.md b/components/airgradient-local-server/README.md index 7ab89a6..aacab4c 100644 --- a/components/airgradient-local-server/README.md +++ b/components/airgradient-local-server/README.md @@ -152,9 +152,10 @@ Configurable through Kconfig under **AirGradient Local Server**: Host tests live in `components/airgradient-local-server/tests/` and run through the top-level [tests runner](../../tests/README.md). They cover serialization (omit-when-invalid / optional `wifiRssi`, nested `corrections`), strict parsing -(unknown key incl. dotted `corrections.*`, bad type / enum, non-object root, -trailing garbage), handler status mapping, and route lifecycle (idempotent / -transactional `begin`, scoped `end`, RAII teardown) using `fake_providers.h`. +(unknown key incl. dotted `corrections.*`, bad type / enum, non-integral integer +fields, non-object root, trailing garbage), handler status mapping, and route +lifecycle (idempotent / transactional `begin`, scoped `end`, RAII teardown) +using `fake_providers.h`. Handler coverage includes asynchronous `202`, retryable `503`, complete-body precedence, empty submissions, and config/action busy results. Correction tests cover each missing SLR coefficient independently. @@ -171,6 +172,6 @@ cover each missing SLR coefficient independently. - Local measurement precision matches the cloud payload: PM mass uses one decimal place, temperature and humidity use two, and particle counts are integers. -- Extended measurement groups (pressure / electrode / dual-channel) and - product-specific config fields are deferred; add them as flat optional fields - when a product exposes them. +- Extended measurement groups (pressure / electrode / dual-channel) remain + deferred. The config catalog includes shared and product-specific flat fields; + each product emits and accepts only its supported subset. diff --git a/components/airgradient-local-server/internal/config_json.cpp b/components/airgradient-local-server/internal/config_json.cpp index e62b172..e90c74c 100644 --- a/components/airgradient-local-server/internal/config_json.cpp +++ b/components/airgradient-local-server/internal/config_json.cpp @@ -7,6 +7,7 @@ #include "internal/config_json.h" +#include #include #include @@ -22,6 +23,7 @@ namespace { constexpr const char *PM_STANDARD_VALUES[] = {"ugm3", "us-aqi"}; constexpr const char *TEMP_UNIT_VALUES[] = {"c", "f"}; constexpr const char *CONFIG_CONTROL_VALUES[] = {"cloud", "local", "both"}; +constexpr const char *GPS_MODE_VALUES[] = {"off", "tracking", "always"}; constexpr const char *LED_MODE_VALUES[] = {"co2", "pm", "iaqs", "off"}; bool is_whitespace(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; } @@ -56,7 +58,8 @@ bool take_enum(const cJSON *item, const char *const (&options)[N], } bool take_int(const cJSON *item, std::optional &out) { - if (!cJSON_IsNumber(item)) { + if (!cJSON_IsNumber(item) || !std::isfinite(item->valuedouble) || + std::floor(item->valuedouble) != item->valuedouble) { return false; } out = item->valueint; @@ -233,6 +236,36 @@ ParseStatus apply_item(const cJSON *item, LocalServerConfig &out, ConfigFieldId ? ParseStatus::Ok : ParseStatus::InvalidValue; } + if (std::strcmp(key, fields::MEASUREMENT_INTERVAL) == 0) { + field = ConfigFieldId::MeasurementInterval; + return take_int(item, out.measurement_interval_seconds) ? ParseStatus::Ok + : ParseStatus::InvalidValue; + } + if (std::strcmp(key, fields::GPS_MODE) == 0) { + field = ConfigFieldId::GpsMode; + return take_enum(item, GPS_MODE_VALUES, out.gps_mode) ? ParseStatus::Ok + : ParseStatus::InvalidValue; + } + if (std::strcmp(key, fields::GPS_INTERVAL) == 0) { + field = ConfigFieldId::GpsInterval; + return take_int(item, out.gps_interval_seconds) ? ParseStatus::Ok : ParseStatus::InvalidValue; + } + if (std::strcmp(key, fields::FRONT_LED_BRIGHTNESS) == 0) { + field = ConfigFieldId::FrontLedBrightness; + return take_int(item, out.front_led_brightness) ? ParseStatus::Ok : ParseStatus::InvalidValue; + } + if (std::strcmp(key, fields::BACK_LED_BRIGHTNESS) == 0) { + field = ConfigFieldId::BackLedBrightness; + return take_int(item, out.back_led_brightness) ? ParseStatus::Ok : ParseStatus::InvalidValue; + } + if (std::strcmp(key, fields::TOUCH_LED_INTENSITY) == 0) { + field = ConfigFieldId::TouchLedIntensity; + return take_int(item, out.touch_led_intensity) ? ParseStatus::Ok : ParseStatus::InvalidValue; + } + if (std::strcmp(key, fields::BUZZER_ENABLED) == 0) { + field = ConfigFieldId::BuzzerEnabled; + return take_bool(item, out.buzzer_enabled) ? ParseStatus::Ok : ParseStatus::InvalidValue; + } if (std::strcmp(key, fields::CO2_ABC_DAYS) == 0) { field = ConfigFieldId::Co2AbcDays; return take_int(item, out.co2_abc_days) ? ParseStatus::Ok : ParseStatus::InvalidValue; @@ -406,6 +439,32 @@ size_t serialize(const LocalServerConfig &cfg, char *buf, size_t buf_len) { cJSON_AddStringToObject(root, fields::CONFIGURATION_CONTROL, cfg.configuration_control->c_str()); } + if (cfg.measurement_interval_seconds.has_value()) { + cJSON_AddNumberToObject(root, fields::MEASUREMENT_INTERVAL, + static_cast(*cfg.measurement_interval_seconds)); + } + if (cfg.gps_mode.has_value()) { + cJSON_AddStringToObject(root, fields::GPS_MODE, cfg.gps_mode->c_str()); + } + if (cfg.gps_interval_seconds.has_value()) { + cJSON_AddNumberToObject(root, fields::GPS_INTERVAL, + static_cast(*cfg.gps_interval_seconds)); + } + if (cfg.front_led_brightness.has_value()) { + cJSON_AddNumberToObject(root, fields::FRONT_LED_BRIGHTNESS, + static_cast(*cfg.front_led_brightness)); + } + if (cfg.back_led_brightness.has_value()) { + cJSON_AddNumberToObject(root, fields::BACK_LED_BRIGHTNESS, + static_cast(*cfg.back_led_brightness)); + } + if (cfg.touch_led_intensity.has_value()) { + cJSON_AddNumberToObject(root, fields::TOUCH_LED_INTENSITY, + static_cast(*cfg.touch_led_intensity)); + } + if (cfg.buzzer_enabled.has_value()) { + cJSON_AddBoolToObject(root, fields::BUZZER_ENABLED, *cfg.buzzer_enabled); + } if (cfg.co2_abc_days.has_value()) { cJSON_AddNumberToObject(root, fields::CO2_ABC_DAYS, static_cast(*cfg.co2_abc_days)); } @@ -469,6 +528,20 @@ const char *config_field_wire_key(ConfigFieldId id) { return fields::CLOUD_CONNECTION; case ConfigFieldId::ConfigurationControl: return fields::CONFIGURATION_CONTROL; + case ConfigFieldId::MeasurementInterval: + return fields::MEASUREMENT_INTERVAL; + case ConfigFieldId::GpsMode: + return fields::GPS_MODE; + case ConfigFieldId::GpsInterval: + return fields::GPS_INTERVAL; + case ConfigFieldId::FrontLedBrightness: + return fields::FRONT_LED_BRIGHTNESS; + case ConfigFieldId::BackLedBrightness: + return fields::BACK_LED_BRIGHTNESS; + case ConfigFieldId::TouchLedIntensity: + return fields::TOUCH_LED_INTENSITY; + case ConfigFieldId::BuzzerEnabled: + return fields::BUZZER_ENABLED; case ConfigFieldId::Co2AbcDays: return fields::CO2_ABC_DAYS; case ConfigFieldId::TvocLearningOffset: diff --git a/components/airgradient-local-server/internal/field_names.h b/components/airgradient-local-server/internal/field_names.h index aef0afb..70a979b 100644 --- a/components/airgradient-local-server/internal/field_names.h +++ b/components/airgradient-local-server/internal/field_names.h @@ -51,6 +51,13 @@ inline constexpr const char *TEMPERATURE_UNIT = "temperatureUnit"; inline constexpr const char *POST_DATA_TO_CLOUD = "postDataToCloud"; inline constexpr const char *CLOUD_CONNECTION = "cloudConnection"; inline constexpr const char *CONFIGURATION_CONTROL = "configurationControl"; +inline constexpr const char *MEASUREMENT_INTERVAL = "measurementInterval"; +inline constexpr const char *GPS_MODE = "gpsMode"; +inline constexpr const char *GPS_INTERVAL = "gpsInterval"; +inline constexpr const char *FRONT_LED_BRIGHTNESS = "frontLedBrightness"; +inline constexpr const char *BACK_LED_BRIGHTNESS = "backLedBrightness"; +inline constexpr const char *TOUCH_LED_INTENSITY = "touchLedIntensity"; +inline constexpr const char *BUZZER_ENABLED = "buzzerEnabled"; inline constexpr const char *CO2_ABC_DAYS = "co2AbcDays"; inline constexpr const char *TVOC_LEARNING_OFFSET = "tvocLearningOffset"; inline constexpr const char *NOX_LEARNING_OFFSET = "noxLearningOffset"; diff --git a/components/airgradient-local-server/tests/config_json.tests.cpp b/components/airgradient-local-server/tests/config_json.tests.cpp index bf6e98f..2ce1324 100644 --- a/components/airgradient-local-server/tests/config_json.tests.cpp +++ b/components/airgradient-local-server/tests/config_json.tests.cpp @@ -41,14 +41,31 @@ TEST_CASE("config parse: valid partial body sets only present keys", "[config][p TEST_CASE("config parse: all enum fields accept catalog values", "[config][parse]") { LocalServerConfig cfg; const auto res = parse( - R"({"pmStandard":"us-aqi","temperatureUnit":"c","configurationControl":"both","ledMode":"iaqs"})", + R"({"pmStandard":"us-aqi","temperatureUnit":"c","configurationControl":"both","gpsMode":"tracking","ledMode":"iaqs"})", cfg); REQUIRE(res.status == config_json::ParseStatus::Ok); REQUIRE(*cfg.pm_standard == "us-aqi"); REQUIRE(*cfg.configuration_control == "both"); + REQUIRE(*cfg.gps_mode == "tracking"); REQUIRE(*cfg.led_mode == "iaqs"); } +TEST_CASE("config parse: Go product fields parse with exact types", "[config][parse]") { + LocalServerConfig cfg; + const auto res = parse( + R"({"measurementInterval":30,"gpsMode":"always","gpsInterval":15,"frontLedBrightness":1,"backLedBrightness":2,"touchLedIntensity":2,"buzzerEnabled":true})", + cfg); + + REQUIRE(res.status == config_json::ParseStatus::Ok); + REQUIRE(cfg.measurement_interval_seconds == 30); + REQUIRE(cfg.gps_mode == "always"); + REQUIRE(cfg.gps_interval_seconds == 15); + REQUIRE(cfg.front_led_brightness == 1); + REQUIRE(cfg.back_led_brightness == 2); + REQUIRE(cfg.touch_led_intensity == 2); + REQUIRE(cfg.buzzer_enabled == true); +} + TEST_CASE("config parse: cloudConnection and url fields parse", "[config][parse]") { LocalServerConfig cfg; const auto res = @@ -74,6 +91,13 @@ TEST_CASE("config parse: wrong type rejected with field id", "[config][parse]") REQUIRE(res.field == ConfigFieldId::Co2AbcDays); } +TEST_CASE("config parse: integer fields reject fractions", "[config][parse]") { + LocalServerConfig cfg; + const auto res = parse(R"({"measurementInterval":10.5})", cfg); + REQUIRE(res.status == config_json::ParseStatus::InvalidValue); + REQUIRE(res.field == ConfigFieldId::MeasurementInterval); +} + TEST_CASE("config parse: bad enum rejected with field id", "[config][parse]") { LocalServerConfig cfg; const auto res = parse(R"({"temperatureUnit":"k"})", cfg); @@ -82,10 +106,19 @@ TEST_CASE("config parse: bad enum rejected with field id", "[config][parse]") { } TEST_CASE("config parse: non-bool for bool field rejected", "[config][parse]") { - LocalServerConfig cfg; - const auto res = parse(R"({"postDataToCloud":1})", cfg); - REQUIRE(res.status == config_json::ParseStatus::InvalidValue); - REQUIRE(res.field == ConfigFieldId::PostDataToCloud); + SECTION("shared field") { + LocalServerConfig cfg; + const auto res = parse(R"({"postDataToCloud":1})", cfg); + REQUIRE(res.status == config_json::ParseStatus::InvalidValue); + REQUIRE(res.field == ConfigFieldId::PostDataToCloud); + } + + SECTION("product field") { + LocalServerConfig cfg; + const auto res = parse(R"({"buzzerEnabled":1})", cfg); + REQUIRE(res.status == config_json::ParseStatus::InvalidValue); + REQUIRE(res.field == ConfigFieldId::BuzzerEnabled); + } } TEST_CASE("config parse: malformed JSON rejected", "[config][parse]") { @@ -237,6 +270,13 @@ TEST_CASE("config serialize: emits only present fields", "[config][serialize]") cfg.temperature_unit = "f"; cfg.led_bar_brightness = 80; cfg.post_data_to_cloud = false; + cfg.measurement_interval_seconds = 30; + cfg.gps_mode = "always"; + cfg.gps_interval_seconds = 15; + cfg.front_led_brightness = 1; + cfg.back_led_brightness = 2; + cfg.touch_led_intensity = 2; + cfg.buzzer_enabled = true; char buf[512] = {}; const size_t len = config_json::serialize(cfg, buf, sizeof(buf)); @@ -248,6 +288,13 @@ TEST_CASE("config serialize: emits only present fields", "[config][serialize]") REQUIRE(cJSON_GetObjectItem(root, "ledBarBrightness")->valueint == 80); REQUIRE(cJSON_IsBool(cJSON_GetObjectItem(root, "postDataToCloud"))); REQUIRE(cJSON_IsFalse(cJSON_GetObjectItem(root, "postDataToCloud"))); + REQUIRE(cJSON_GetObjectItem(root, "measurementInterval")->valueint == 30); + REQUIRE(std::strcmp(cJSON_GetObjectItem(root, "gpsMode")->valuestring, "always") == 0); + REQUIRE(cJSON_GetObjectItem(root, "gpsInterval")->valueint == 15); + REQUIRE(cJSON_GetObjectItem(root, "frontLedBrightness")->valueint == 1); + REQUIRE(cJSON_GetObjectItem(root, "backLedBrightness")->valueint == 2); + REQUIRE(cJSON_GetObjectItem(root, "touchLedIntensity")->valueint == 2); + REQUIRE(cJSON_IsTrue(cJSON_GetObjectItem(root, "buzzerEnabled"))); // Absent fields omitted. REQUIRE(cJSON_GetObjectItem(root, "country") == nullptr); REQUIRE(cJSON_GetObjectItem(root, "pmStandard") == nullptr); @@ -316,5 +363,18 @@ TEST_CASE("config field wire keys map correctly", "[config][parse]") { "displayBrightness") == 0); REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::CorrectionsPm25), "corrections.pm25") == 0); + REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::MeasurementInterval), + "measurementInterval") == 0); + REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::GpsMode), "gpsMode") == 0); + REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::GpsInterval), + "gpsInterval") == 0); + REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::FrontLedBrightness), + "frontLedBrightness") == 0); + REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::BackLedBrightness), + "backLedBrightness") == 0); + REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::TouchLedIntensity), + "touchLedIntensity") == 0); + REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::BuzzerEnabled), + "buzzerEnabled") == 0); REQUIRE(config_json::config_field_wire_key(ConfigFieldId::None) == nullptr); } diff --git a/components/airgradient-local-server/types/local_config.h b/components/airgradient-local-server/types/local_config.h index 42f3f61..bba201e 100644 --- a/components/airgradient-local-server/types/local_config.h +++ b/components/airgradient-local-server/types/local_config.h @@ -51,6 +51,13 @@ struct LocalServerConfig { std::optional post_data_to_cloud; // "postDataToCloud" std::optional cloud_connection; // "cloudConnection" std::optional configuration_control; // "configurationControl" + std::optional measurement_interval_seconds; // "measurementInterval" + std::optional gps_mode; // "gpsMode" + std::optional gps_interval_seconds; // "gpsInterval" + std::optional front_led_brightness; // "frontLedBrightness" + std::optional back_led_brightness; // "backLedBrightness" + std::optional touch_led_intensity; // "touchLedIntensity" + std::optional buzzer_enabled; // "buzzerEnabled" std::optional co2_abc_days; // "co2AbcDays" std::optional tvoc_learning_offset; // "tvocLearningOffset" std::optional nox_learning_offset; // "noxLearningOffset" @@ -60,8 +67,6 @@ struct LocalServerConfig { std::optional mqtt_broker_url; // "mqttBrokerUrl" std::optional http_domain; // "httpDomain" std::optional corrections; // "corrections" - // Product-specific fields (for example buzzer_enabled, gps_interval_s) are - // added here as flat optional fields when a product exposes them over HTTP. }; #endif // AG_LOCAL_SERVER_LOCAL_CONFIG_H diff --git a/components/airgradient-local-server/types/local_server_result.h b/components/airgradient-local-server/types/local_server_result.h index 9c057ea..6479e40 100644 --- a/components/airgradient-local-server/types/local_server_result.h +++ b/components/airgradient-local-server/types/local_server_result.h @@ -44,6 +44,13 @@ enum class ConfigFieldId : uint8_t { CorrectionsPm25, // "corrections.pm25" CorrectionsTemp, // "corrections.temp" CorrectionsHumidity, // "corrections.humidity" + MeasurementInterval, // "measurementInterval" + GpsMode, // "gpsMode" + GpsInterval, // "gpsInterval" + FrontLedBrightness, // "frontLedBrightness" + BackLedBrightness, // "backLedBrightness" + TouchLedIntensity, // "touchLedIntensity" + BuzzerEnabled, // "buzzerEnabled" }; enum class ConfigSubmitStatus : uint8_t { diff --git a/products/go/docs/ble_service.md b/products/go/docs/ble_service.md index 17cccca..2dfba6b 100644 --- a/products/go/docs/ble_service.md +++ b/products/go/docs/ble_service.md @@ -25,7 +25,7 @@ in the NimBLE task and post lightweight events to the orchestrator queue. | `MeasuresAGo` | `airgradient-common` (`measures_types.h`) | Sensor measurement data + field-level `is_*_valid()` methods | | `GpsData` | `airgradient-gps` (`types/gps_types.h`) | GPS position/fix data + `is_fix_valid()`, `is_latitude_valid()`, etc. | | `PowerSnapshot` | product (`go_power.h`) | Battery voltage, percentage, charging state | -| `GoSettings` | product (`go_settings.h`) | Device configuration struct (15 fields) | +| `GoSettings` | product (`go_settings.h`) | Device configuration struct | | `StorageService` | product (`go_storage.h`) | Route data read for history export, flash usage reporting, and command side effects | | `RTOS`, `RtosMutex` | `airgradient-common` (`rtos.h`) | `delay_ms()`, `queue_send()`, mutex for pending write buffers | @@ -168,7 +168,7 @@ All characteristic payloads use CBOR (RFC 8949) encoded with TinyCBOR's |---|---|---|---| | Measures | ~120B | ~135B | Yes | | Status | ~95B | ~115B | Yes | -| Config (read, 15 keys) | — | <512B | Yes (Read-Long) | +| Config (read, 19 keys) | — | <512B | Yes (Read-Long) | | Config (notify, one field + type) | — | <180B | Yes | | History control (CBOR) | ~40B | ~180B | Yes | | History data (binary, 4 pts) | 223B | 223B | Yes | @@ -376,17 +376,17 @@ config**, **set config values**, and **execute commands**. ### Read (phone reads characteristic) -Returns the full device configuration as a 15-key CBOR map. The BLE service +Returns the full device configuration as a 19-key CBOR map. The BLE service keeps this value updated whenever the orchestrator calls `update_config()`. -#### CBOR Payload (Map) — 15 Keys +#### CBOR Payload (Map) — 19 Keys | Key | CBOR Type | `GoSettings` field | Encoded with | |---|---|---|---| -| `"meas_int"` | uint | `measure_interval_seconds` | `cbor_encode_uint` | +| `"meas_int"` | uint | `measure_interval_seconds` | `cbor_encode_uint` (1–3600 seconds) | | `"temp_f"` | bool | `use_fahrenheit` | `cbor_encode_boolean` | | `"pm_aqi"` | bool | `pm_use_usaqi` | `cbor_encode_boolean` | -| `"gps_int"` | uint | `gps_interval_seconds` | `cbor_encode_uint` | +| `"gps_int"` | uint | `gps_interval_seconds` | `cbor_encode_uint` (1–60 seconds) | | `"gps_mode"` | text | `gps_mode` | See mapping below | | `"inact_to"` | uint | `inactivity_timeout_seconds` | `cbor_encode_uint` | | `"auto_lock"` | uint | `auto_lock_seconds` | `cbor_encode_uint` | @@ -395,6 +395,10 @@ keeps this value updated whenever the orchestrator calls `update_config()`. | `"fled"` | uint | `front_led_brightness` | `cbor_encode_uint` (0–3) | | `"bled"` | uint | `back_led_brightness` | `cbor_encode_uint` (0–3) | | `"tled"` | uint | `touch_led_intensity` | `cbor_encode_uint` (0–2) | +| `"buz"` | bool | `buzzer_enabled` | `cbor_encode_boolean` | +| `"abc"` | int | `co2_abc_days` | `cbor_encode_int` (`-1` or 1–200) | +| `"tlo"` | uint | `tvoc_learning_offset` | `cbor_encode_uint` (1–1000 hours) | +| `"nlo"` | uint | `nox_learning_offset` | `cbor_encode_uint` (1–1000 hours) | | `"pm25_corr"` | map | `corrections.pm25` | PM2.5 correction map below | | `"temp_corr"` | map | `corrections.temperature` | Temperature correction map below | | `"hum_corr"` | map | `corrections.humidity` | Humidity correction map below | @@ -469,9 +473,15 @@ Deprecated keys (`"pm_int"`, `"other_int"`, `"disp_int"`) are matched and skipped without modifying settings — backward compatible with older apps. If any unrecognized config key or nested correction key is present, the entire -write is rejected. Malformed correction values are rejected as well. No -settings are modified. The device sends a command-result error notification -with `unknown_config_key` or `invalid_config_value`, respectively. +write is rejected. Invalid interval, GPS mode, LED, buzzer, ABC, learning-offset, +and correction values are rejected as well. No settings are modified. The device +sends a command-result error notification with `unknown_config_key` or +`invalid_config_value`, respectively. + +The compact device keys require exact CBOR types and validate against the shared +configuration ranges. `"abc": -1` disables CO2 automatic background +calibration. Updating either learning offset persists the new settings and +applies the active TVOC and NOx offsets together. #### Execute Command (orchestrator decodes) @@ -601,7 +611,7 @@ Error strings are defined in `go_ble_protocol.h` and passed to | `"not_tracking"` | `stop_tracking` | No tracking session was active | | `"no_aiding_data"` | `set_aiding` | No valid position or time data in the payload | | `"unknown_command"` | (any) | Unrecognised `"cmd"` string | -| `"invalid_config_value"` | `set` | Correction map has an unsupported schema, array shape, algorithm, flag, type, or non-finite coefficient | +| `"invalid_config_value"` | `set` | A validated config field has the wrong type, is outside its allowed range, or contains an invalid correction map | | `"config_save_failed"` | `set` | Persisting the candidate settings failed | #### CO2 Calibration Request Semantics @@ -887,7 +897,7 @@ failed `setup_ble()` is non-fatal (advertise without OTA). See | `notify_tracking_status(power, gps, tracking, session_id)` | Refreshes the full 9-key snapshot via `update_status()` (Read stays full), then pushes a `{tracking, session}` transition delta via `notify(data, len)`. Used for urgent tracking transitions (start success, start failure, manual stop). Best-effort delivery — Read remains authoritative. | | `notify_charging_status(power, gps, tracking, session_id)` | Refreshes the full 9-key snapshot via `update_status()` (Read stays full), then pushes a `{charging, bat_pct, bat_v}` power delta via `notify(data, len)`. Used for charging transitions (plug in, unplug, charge complete). Disjoint keys from the tracking delta, no `"type"` discriminator — client merges by key. | | `notify_disconnect(reason)` | Pushes a NOTIFY-only `{disc}` delta via `notify(data, len)` (snapshot untouched) announcing an imminent link drop and why (`overheat`/`low_batt`/`user`/`op_stationary`/`op_offline`). Called from `change_mode()` (leaving Portable) and `shutdown()`; gated on `is_connected()`; the caller settles before teardown so it can drain. | -| `update_config(settings)` | Encode the full snapshot via `encode_config()` (15 keys, no `"type"`), `set_value()` only. Sole writer of the Config snapshot; buffer sized to the 512-byte ATT ceiling. | +| `update_config(settings)` | Encode the full snapshot via `encode_config()` (19 keys, no `"type"`), `set_value()` only. Sole writer of the Config snapshot; buffer sized to the 512-byte ATT ceiling. | | `notify_config(prev, cur)` | Refreshes the snapshot via `update_config(cur)`, then sends the changed-fields delta (`encode_config_delta()`: `"type":"config"` + changed keys) via `notify(data, len)`. | | `notify_command_progress(cmd)` | Inline CBOR encoding (2 keys: type + cmd), `notify(data, len)` (stored value untouched). Sent before long-running commands. | | `notify_command_result(cmd, success, error)` | Inline CBOR encoding (3-4 keys), `notify(data, len)` (stored value untouched). | @@ -1233,7 +1243,7 @@ cover: - **CBOR encoding**: `encode_measures()` (field omission, GPS inclusion), `encode_status()` (all 9 keys, battery clamping) and `encode_status_transition()` - (2-key delta), `encode_config()` (full 15-key snapshot, no `"type"`) and + (2-key delta), `encode_config()` (full 19-key snapshot, no `"type"`) and `encode_config_delta()` (`"type":"config"` + changed keys only), `notify_config(prev, cur)` (delta via `notify(data, len)`, Read stays full, snapshot refreshed first), `notify_command_result()` / diff --git a/products/go/docs/cloud_service.md b/products/go/docs/cloud_service.md index b496faa..abb4f87 100644 --- a/products/go/docs/cloud_service.md +++ b/products/go/docs/cloud_service.md @@ -156,12 +156,24 @@ while the socket is still alive. After a successful complete fetch, the cloud task parses the response body once and queues a value-only `FetchConfigEventPayload`. Supported fields map into -`GoConfigUpdate` independently: +`GoConfigUpdate` independently. Device behavior fields are: | Cloud Field | Accepted Values | Go Field | |---|---|---| | `pmStandard` | `"ugm3"`, `"us-aqi"` | `pm_use_usaqi` | | `temperatureUnit` | `"c"`, `"f"` | `use_fahrenheit` | +| `measurementInterval` | Integer 1 .. 3600 | `measure_interval_seconds` | +| `gpsMode` | `"off"`, `"tracking"`, `"always"` | `gps_mode` | +| `gpsInterval` | Integer 1 .. 60 | `gps_interval_seconds` | +| `frontLedBrightness` | Integer 0 .. 3 | `front_led_brightness` | +| `backLedBrightness` | Integer 0 .. 3 | `back_led_brightness` | +| `touchLedIntensity` | Integer 0 .. 2 | `touch_led_intensity` | +| `buzzerEnabled` | Boolean | `buzzer_enabled` | + +Sensor and correction fields are: + +| Cloud Field | Accepted Values | Go Field | +|---|---|---| | `abcDays` | Integer `-1` or 1 .. 200 | `co2_abc_days`; `-1` disables automatic background calibration | | `tvocLearningOffset` | Integer 1 .. 1000 | `tvoc_learning_offset` | | `noxLearningOffset` | Integer 1 .. 1000 | `nox_learning_offset` | diff --git a/products/go/docs/local_server.md b/products/go/docs/local_server.md index bc867b9..be8d6b5 100644 --- a/products/go/docs/local_server.md +++ b/products/go/docs/local_server.md @@ -43,7 +43,7 @@ incomplete. | `GoLocalApiService(queue, config)` | Constructor | Capture the event queue and process-lifetime identity | | `is_valid()` | `bool` | Report whether queue and synchronization dependencies are usable | | `get_measures()`, `get_system_info()` | Value snapshots | Supply thread-safe GET measures data | -| `get_config()` | `LocalServerConfig` | Supply the active five-key Go config snapshot | +| `get_config()` | `LocalServerConfig` | Supply the complete active 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 | @@ -138,12 +138,24 @@ storage, and BLE view. ### Configuration `GET` always emits Go's complete supported subset, and `PUT` accepts partial -objects from the same subset: +objects from the same subset. Device behavior fields are: | Field | Values | Go Behavior | |---|---|---| | `pmStandard` | `ugm3`, `us-aqi` | Select mass concentration or US AQI presentation | | `temperatureUnit` | `c`, `f` | Select product display temperature unit | +| `measurementInterval` | Integer 1 .. 3600 | Set the measurement interval in seconds | +| `gpsMode` | `off`, `tracking`, `always` | Disable GPS, run it only while tracking, or keep it active | +| `gpsInterval` | Integer 1 .. 60 | Set the GPS posting interval in seconds | +| `frontLedBrightness` | Integer 0 .. 3 | Set front LED brightness: off, dim, mid, or bright | +| `backLedBrightness` | Integer 0 .. 3 | Set AQI LED brightness: off, dim, mid, or bright | +| `touchLedIntensity` | Integer 0 .. 2 | Set touch LED intensity: off, dim, or bright | +| `buzzerEnabled` | Boolean | Enable or disable buzzer playback | + +Connectivity, sensor, and correction fields are: + +| Field | Values | Go Behavior | +|---|---|---| | `cloudConnection` | Boolean | Inverse of the product `disable_cloud` setting | | `configurationControl` | `cloud`, `local`, `both` | Arbitrate Local Server PUT and Cloud Fetch sources | | `co2AbcDays` | Integer `-1` or 1 .. 200 | Set the automatic background calibration period for the supported CO2 sensor. `-1` disables it; positive values are converted to hours. | @@ -175,24 +187,26 @@ without disabling the local endpoint. It does not cancel an in-flight cloud request; a Fetch result can still apply if source policy permits it when consumed. -PUT parsing is strict and completes before product policy checks. After -translation, config and action requests share one fixed four-entry FIFO. Each -entry posts one `LocalApiRequestReady` event carrying the FIFO epoch. A failed -central event-queue send rolls back the append. Queue saturation, event-queue -saturation, or an epoch change during admission returns `503 busy`. An empty -object is an accepted no-op and consumes no FIFO entry after the current access -and source gates pass. +PUT parsing is strict and completes before product policy checks. Integer fields +reject fractional JSON numbers rather than truncating them, and product +translation enforces the documented ranges. After translation, config and +action requests share one fixed four-entry FIFO. +Each entry posts one `LocalApiRequestReady` event carrying the FIFO epoch. A +failed central event-queue send rolls back the append. Queue saturation, +event-queue saturation, or an epoch change during admission returns `503 busy`. +An empty object is an accepted no-op and consumes no FIFO entry after the +current access and source gates pass. `202 Accepted` means only that the validated update was admitted. The orchestrator rechecks source policy, merges against the last active settings, -validates the complete candidate, persists and commits it, then asynchronously -requests a changed `co2AbcDays` setting from the sensor task and publishes new -snapshots. A later source-policy change, persistence failure, queue clear, or -superseding writer can prevent GET from converging. Sensor-application failure -does not roll back the persisted setting; the normal boot path retries it. -There is no request identifier, completion resource, correlation token, or -automatic retry. Clients that need confirmation poll `GET -/api/v1/config` against their own deadline. +validates the complete candidate, persists and commits it, applies changed +timing, GPS, LED, and buzzer settings, and publishes new snapshots. CO2 ABC and +gas learning changes are requested asynchronously from the sensor task. A later +source-policy change, persistence failure, queue clear, or superseding writer +can prevent GET from converging. Sensor-application failure does not roll back +the persisted setting; the normal boot path retries it. There is no request +identifier, completion resource, correlation token, or automatic retry. Clients +that need confirmation poll `GET /api/v1/config` against their own deadline. ### Actions diff --git a/products/go/go_ble_client.md b/products/go/go_ble_client.md index 1791fe0..f5c9272 100644 --- a/products/go/go_ble_client.md +++ b/products/go/go_ble_client.md @@ -212,15 +212,15 @@ Read-Long / Read Blob operations. Notifications are single ATT PDUs and are never fragmented by the application. The Config snapshot is therefore not a notification payload: it is a Read-Long -value and is typically about 219 bytes with correction schema version 1. Clients must -support Read-Long and must not assume that one Read Response contains the full -snapshot. +value and is typically about 239 bytes with correction schema version 1. Clients +must support Read-Long and must not assume that one Read Response contains the +full snapshot. | Characteristic | Typical Size | Max/Limit | Transport | |---|---:|---:|---| | Measures | ~120 B | ~135 B | One notification when MTU is at least 138 | | Status Read | ~95 B | ~115 B | Read; notifications carry small deltas | -| Config Read | ~219 B | <512 B | Read-Long / Read Blob | +| Config Read | ~239 B | <512 B | Read-Long / Read Blob | | Config Notify | — | <180 B | One notification when MTU is at least 185 | | History control | ~40 B | ~180 B | One notification per response | | History data | 227 B | 227 B | One notification when MTU is at least 230 | @@ -510,14 +510,14 @@ This characteristic supports three operations: Read the characteristic to receive the full device configuration. -#### Payload (15-key CBOR map) +#### Payload (19-key CBOR map) | Key | Type | Description | |---|---|---| | `"meas_int"` | uint | Measurement interval in seconds (1–3600). All sensors measured together at this cadence. | | `"temp_f"` | bool | `true` = Fahrenheit, `false` = Celsius | | `"pm_aqi"` | bool | `true` = US AQI for PM, `false` = raw ug/m3 | -| `"gps_int"` | uint | GPS update interval (seconds) | +| `"gps_int"` | uint | GPS update interval in seconds (1–60) | | `"gps_mode"` | text | GPS mode (see table below) | | `"inact_to"` | uint | Inactivity timeout (seconds) | | `"auto_lock"` | uint | Auto-lock timeout (seconds) | @@ -526,6 +526,10 @@ Read the characteristic to receive the full device configuration. | `"fled"` | uint | Front (display) LED brightness: 0=Off, 1=Dim, 2=Mid, 3=Bright | | `"bled"` | uint | Back (AQI) LED brightness: 0=Off, 1=Dim, 2=Mid, 3=Bright | | `"tled"` | uint | Touch LED intensity: 0=Off, 1=Dim, 2=Bright | +| `"buz"` | bool | Buzzer enabled | +| `"abc"` | int | CO2 ABC period: `-1` disables, otherwise 1–200 days | +| `"tlo"` | uint | TVOC learning-time offset: 1–1000 hours | +| `"nlo"` | uint | NOx learning-time offset: 1–1000 hours | | `"pm25_corr"` | map | PM2.5 correction (`s`, `v`) | | `"temp_corr"` | map | Temperature correction (`s`, `v`) | | `"hum_corr"` | map | Humidity correction (`s`, `v`) | @@ -573,6 +577,10 @@ them and persisted loading canonicalizes them. "fled": 3, "bled": 3, "tled": 2, + "buz": true, + "abc": 7, + "tlo": 12, + "nlo": 12, "pm25_corr": {"s": 1, "v": [2, 1.08, -0.2, 1]}, "temp_corr": {"s": 1, "v": [0, 1.0, 0.0]}, "hum_corr": {"s": 1, "v": [0, 1.0, 0.0]} @@ -609,7 +617,7 @@ silently ignored for backward compatibility. They do not modify any setting. | `"meas_int"` | uint | 1–3600 seconds | | `"temp_f"` | bool | | | `"pm_aqi"` | bool | | -| `"gps_int"` | uint | | +| `"gps_int"` | uint | 1–60 seconds | | `"gps_mode"` | text | `"off"`, `"tracking"`, or `"always"` | | `"inact_to"` | uint | | | `"auto_lock"` | uint | | @@ -618,6 +626,10 @@ silently ignored for backward compatibility. They do not modify any setting. | `"fled"` | uint | 0–3 (front LED brightness) | | `"bled"` | uint | 0–3 (back LED brightness) | | `"tled"` | uint | 0–2 (touch LED intensity) | +| `"buz"` | bool | Buzzer enabled | +| `"abc"` | int | `-1` disables ABC; otherwise 1–200 days | +| `"tlo"` | uint | 1–1000 hours | +| `"nlo"` | uint | 1–1000 hours | | `"pm25_corr"` | map | Complete PM2.5 correction group | | `"temp_corr"` | map | Complete temperature correction group | | `"hum_corr"` | map | Complete humidity correction group | @@ -642,8 +654,9 @@ After applying the config change, the device sends a **Config notification** The write is rejected before any value is applied if it contains an **unrecognized config key** (`unknown_config_key`, checked first), an invalid -correction value (`invalid_config_value`), or **more than one recognized config -key** (`single_field_only`). A settings persistence failure returns +interval, GPS mode, LED, buzzer, ABC, learning-offset, or correction value +(`invalid_config_value`), or **more than one recognized config key** +(`single_field_only`). A settings persistence failure returns `config_save_failed`. On rejection no settings are modified and the device sends an error notification instead: @@ -799,7 +812,7 @@ which normally changes one setting at a time) yields a 2-key map: Merge the changed key(s) into your local model. Production sends no Config notification for a no-op write. The full config is always available via **Read / Read-Long** (no `"type"` key) — re-read it on connect to establish the baseline. -The snapshot is typically about 219 bytes with schema version 1, so clients must +The snapshot is typically about 239 bytes with schema version 1, so clients must collect Read-Long fragments when the negotiated MTU cannot carry the complete value. The `"type"` key distinguishes this from command notifications (all arrive on the same characteristic; Read always returns the config snapshot @@ -1601,7 +1614,7 @@ negotiated interval; only its speed is affected. ### Required MTUs by operation -- **Config Read**: the full 15-key snapshot is typically about 219 bytes and is +- **Config Read**: the full 19-key snapshot is typically about 239 bytes and is bounded by a 512-byte characteristic buffer. Use Read-Long / Read Blob and collect all fragments. Config Read does not require MTU 512, but it does require a client API that supports long reads. diff --git a/products/go/main/go_ble.cpp b/products/go/main/go_ble.cpp index 0200553..4bc3ada 100644 --- a/products/go/main/go_ble.cpp +++ b/products/go/main/go_ble.cpp @@ -1843,6 +1843,18 @@ static void enc_bled(CborEncoder &m, const GoSettings &s) { static void enc_tled(CborEncoder &m, const GoSettings &s) { cbor_encode_uint(&m, static_cast(s.touch_led_intensity)); } +static void enc_buz(CborEncoder &m, const GoSettings &s) { + cbor_encode_boolean(&m, s.buzzer_enabled); +} +static void enc_abc(CborEncoder &m, const GoSettings &s) { + cbor_encode_int(&m, static_cast(s.co2_abc_days)); +} +static void enc_tlo(CborEncoder &m, const GoSettings &s) { + cbor_encode_uint(&m, static_cast(s.tvoc_learning_offset)); +} +static void enc_nlo(CborEncoder &m, const GoSettings &s) { + cbor_encode_uint(&m, static_cast(s.nox_learning_offset)); +} static void enc_pm25_correction(CborEncoder &m, const GoSettings &s) { encode_pm25_correction(m, s.corrections.pm25); } @@ -1890,6 +1902,18 @@ static bool dif_bled(const GoSettings &a, const GoSettings &b) { static bool dif_tled(const GoSettings &a, const GoSettings &b) { return a.touch_led_intensity != b.touch_led_intensity; } +static bool dif_buz(const GoSettings &a, const GoSettings &b) { + return a.buzzer_enabled != b.buzzer_enabled; +} +static bool dif_abc(const GoSettings &a, const GoSettings &b) { + return a.co2_abc_days != b.co2_abc_days; +} +static bool dif_tlo(const GoSettings &a, const GoSettings &b) { + return a.tvoc_learning_offset != b.tvoc_learning_offset; +} +static bool dif_nlo(const GoSettings &a, const GoSettings &b) { + return a.nox_learning_offset != b.nox_learning_offset; +} static bool dif_pm25_correction(const GoSettings &a, const GoSettings &b) { return a.corrections.pm25.algorithm != b.corrections.pm25.algorithm || a.corrections.pm25.scaling_factor != b.corrections.pm25.scaling_factor || @@ -1920,6 +1944,10 @@ static const ConfigField CONFIG_FIELDS[] = { {BLE_KEY_FRONT_LED, enc_fled, dif_fled}, {BLE_KEY_BACK_LED, enc_bled, dif_bled}, {BLE_KEY_TOUCH_LED, enc_tled, dif_tled}, + {BLE_KEY_BUZZER, enc_buz, dif_buz}, + {BLE_KEY_ABC_DAYS, enc_abc, dif_abc}, + {BLE_KEY_TVOC_LEARNING_OFFSET, enc_tlo, dif_tlo}, + {BLE_KEY_NOX_LEARNING_OFFSET, enc_nlo, dif_nlo}, {BLE_KEY_PM25_CORRECTION, enc_pm25_correction, dif_pm25_correction}, {BLE_KEY_TEMP_CORRECTION, enc_temp_correction, dif_temp_correction}, {BLE_KEY_HUM_CORRECTION, enc_hum_correction, dif_hum_correction}, @@ -2037,14 +2065,20 @@ const char *BleService::operating_mode_to_str(OperatingMode mode) { // --------------------------------------------------------------------------- /// Reverse mapping: text string -> GpsMode. -static GpsMode str_to_gps_mode(const char *s) { +static bool str_to_gps_mode(const char *s, GpsMode &mode) { if (strcmp(s, BLE_VAL_GPS_OFF) == 0) { - return GpsMode::AlwaysOff; + mode = GpsMode::AlwaysOff; + return true; + } + if (strcmp(s, BLE_VAL_GPS_TRACKING) == 0) { + mode = GpsMode::OnWhenTracking; + return true; } if (strcmp(s, BLE_VAL_GPS_ALWAYS) == 0) { - return GpsMode::AlwaysOn; + mode = GpsMode::AlwaysOn; + return true; } - return GpsMode::OnWhenTracking; // "tracking" or unrecognized + return false; } /// Reverse mapping: text string -> OperatingMode. @@ -2177,8 +2211,11 @@ BleConfigDecodeResult BleService::decode_config_write(const uint8_t *buf, size_t cbor_value_advance(&it); result.recognized_config_key_count++; uint64_t v = 0; - if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError) { - settings.measure_interval_seconds = static_cast(v); + if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError && + v >= MEASURE_INTERVAL_SECONDS_MIN && v <= MEASURE_INTERVAL_SECONDS_MAX) { + settings.measure_interval_seconds = static_cast(v); + } else { + result.has_invalid_config_values = true; } handled = true; } @@ -2190,8 +2227,11 @@ BleConfigDecodeResult BleService::decode_config_write(const uint8_t *buf, size_t cbor_value_advance(&it); result.recognized_config_key_count++; uint64_t v = 0; - if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError) { - settings.gps_interval_seconds = static_cast(v); + if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError && + v >= GPS_INTERVAL_SECONDS_MIN && v <= GPS_INTERVAL_SECONDS_MAX) { + settings.gps_interval_seconds = static_cast(v); + } else { + result.has_invalid_config_values = true; } handled = true; } else if (key_is(BLE_KEY_INACT_TO)) { @@ -2215,8 +2255,10 @@ BleConfigDecodeResult BleService::decode_config_write(const uint8_t *buf, size_t result.recognized_config_key_count++; uint64_t v = 0; if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError && - v <= 3) { + v <= static_cast(LedBrightness::Bright)) { settings.front_led_brightness = static_cast(v); + } else { + result.has_invalid_config_values = true; } handled = true; } else if (key_is(BLE_KEY_BACK_LED)) { @@ -2224,8 +2266,10 @@ BleConfigDecodeResult BleService::decode_config_write(const uint8_t *buf, size_t result.recognized_config_key_count++; uint64_t v = 0; if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError && - v <= 3) { + v <= static_cast(LedBrightness::Bright)) { settings.back_led_brightness = static_cast(v); + } else { + result.has_invalid_config_values = true; } handled = true; } else if (key_is(BLE_KEY_TOUCH_LED)) { @@ -2233,8 +2277,43 @@ BleConfigDecodeResult BleService::decode_config_write(const uint8_t *buf, size_t result.recognized_config_key_count++; uint64_t v = 0; if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError && - v <= 2) { + v <= static_cast(TouchLedIntensity::Bright)) { settings.touch_led_intensity = static_cast(v); + } else { + result.has_invalid_config_values = true; + } + handled = true; + } else if (key_is(BLE_KEY_ABC_DAYS)) { + cbor_value_advance(&it); + result.recognized_config_key_count++; + int64_t v = 0; + if (cbor_value_is_integer(&it) && cbor_value_get_int64(&it, &v) == CborNoError && + (v == CO2_ABC_DAYS_DISABLED || (v >= CO2_ABC_DAYS_MIN && v <= CO2_ABC_DAYS_MAX))) { + settings.co2_abc_days = static_cast(v); + } else { + result.has_invalid_config_values = true; + } + handled = true; + } else if (key_is(BLE_KEY_TVOC_LEARNING_OFFSET)) { + cbor_value_advance(&it); + result.recognized_config_key_count++; + uint64_t v = 0; + if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError && + v >= LEARNING_OFFSET_HOURS_MIN && v <= LEARNING_OFFSET_HOURS_MAX) { + settings.tvoc_learning_offset = static_cast(v); + } else { + result.has_invalid_config_values = true; + } + handled = true; + } else if (key_is(BLE_KEY_NOX_LEARNING_OFFSET)) { + cbor_value_advance(&it); + result.recognized_config_key_count++; + uint64_t v = 0; + if (cbor_value_is_unsigned_integer(&it) && cbor_value_get_uint64(&it, &v) == CborNoError && + v >= LEARNING_OFFSET_HOURS_MIN && v <= LEARNING_OFFSET_HOURS_MAX) { + settings.nox_learning_offset = static_cast(v); + } else { + result.has_invalid_config_values = true; } handled = true; } else if (key_is(BLE_KEY_PM25_CORRECTION)) { @@ -2293,6 +2372,16 @@ BleConfigDecodeResult BleService::decode_config_write(const uint8_t *buf, size_t settings.pm_use_usaqi = v; } handled = true; + } else if (key_is(BLE_KEY_BUZZER)) { + cbor_value_advance(&it); + result.recognized_config_key_count++; + bool v = false; + if (cbor_value_is_boolean(&it) && cbor_value_get_boolean(&it, &v) == CborNoError) { + settings.buzzer_enabled = v; + } else { + result.has_invalid_config_values = true; + } + handled = true; } // --- text config fields --- else if (key_is(BLE_KEY_GPS_MODE)) { @@ -2301,9 +2390,14 @@ BleConfigDecodeResult BleService::decode_config_write(const uint8_t *buf, size_t char text[16] = {}; if (cbor_value_is_text_string(&it)) { size_t slen = sizeof(text) - 1; - cbor_value_copy_text_string(&it, text, &slen, nullptr); - text[slen] = '\0'; - settings.gps_mode = str_to_gps_mode(text); + if (cbor_value_copy_text_string(&it, text, &slen, nullptr) == CborNoError) { + text[slen] = '\0'; + result.has_invalid_config_values |= !str_to_gps_mode(text, settings.gps_mode); + } else { + result.has_invalid_config_values = true; + } + } else { + result.has_invalid_config_values = true; } handled = true; } else if (key_is(BLE_KEY_OP_MODE)) { diff --git a/products/go/main/go_ble_protocol.h b/products/go/main/go_ble_protocol.h index 20e0ece..fcb2fdd 100644 --- a/products/go/main/go_ble_protocol.h +++ b/products/go/main/go_ble_protocol.h @@ -81,6 +81,10 @@ inline constexpr const char *BLE_KEY_OP_MODE = "op_mode"; inline constexpr const char *BLE_KEY_FRONT_LED = "fled"; inline constexpr const char *BLE_KEY_BACK_LED = "bled"; inline constexpr const char *BLE_KEY_TOUCH_LED = "tled"; +inline constexpr const char *BLE_KEY_BUZZER = "buz"; +inline constexpr const char *BLE_KEY_ABC_DAYS = "abc"; +inline constexpr const char *BLE_KEY_TVOC_LEARNING_OFFSET = "tlo"; +inline constexpr const char *BLE_KEY_NOX_LEARNING_OFFSET = "nlo"; inline constexpr const char *BLE_KEY_PM25_CORRECTION = "pm25_corr"; inline constexpr const char *BLE_KEY_TEMP_CORRECTION = "temp_corr"; inline constexpr const char *BLE_KEY_HUM_CORRECTION = "hum_corr"; diff --git a/products/go/main/go_cloud.cpp b/products/go/main/go_cloud.cpp index 94057a8..33da55d 100644 --- a/products/go/main/go_cloud.cpp +++ b/products/go/main/go_cloud.cpp @@ -50,6 +50,13 @@ namespace { constexpr const char *JSON_CORRECTIONS = "corrections"; constexpr const char *JSON_PM_STANDARD = "pmStandard"; constexpr const char *JSON_TEMPERATURE_UNIT = "temperatureUnit"; +constexpr const char *JSON_MEASUREMENT_INTERVAL = "measurementInterval"; +constexpr const char *JSON_GPS_MODE = "gpsMode"; +constexpr const char *JSON_GPS_INTERVAL = "gpsInterval"; +constexpr const char *JSON_FRONT_LED_BRIGHTNESS = "frontLedBrightness"; +constexpr const char *JSON_BACK_LED_BRIGHTNESS = "backLedBrightness"; +constexpr const char *JSON_TOUCH_LED_INTENSITY = "touchLedIntensity"; +constexpr const char *JSON_BUZZER_ENABLED = "buzzerEnabled"; constexpr const char *JSON_ABC_DAYS = "abcDays"; constexpr const char *JSON_TVOC_LEARNING_OFFSET = "tvocLearningOffset"; constexpr const char *JSON_NOX_LEARNING_OFFSET = "noxLearningOffset"; @@ -85,6 +92,53 @@ bool parse_float(const cJSON *item, float &out) { return true; } +bool parse_int_range(const cJSON *item, const char *field_name, int min_value, int max_value, + int &out) { + if (!cJSON_IsNumber(item) || !std::isfinite(item->valuedouble) || + std::floor(item->valuedouble) != item->valuedouble || item->valuedouble < min_value || + item->valuedouble > max_value) { + AG_LOGW(TAG, "config %s rejected: expected integer from %d to %d", field_name, min_value, + max_value); + return false; + } + + out = static_cast(item->valuedouble); + return true; +} + +bool parse_gps_mode(const cJSON *item, GpsMode &out) { + if (!cJSON_IsString(item) || item->valuestring == nullptr) { + AG_LOGW(TAG, "config %s rejected: value is not a string", JSON_GPS_MODE); + return false; + } + + if (std::strcmp(item->valuestring, "off") == 0) { + out = GpsMode::AlwaysOff; + return true; + } + if (std::strcmp(item->valuestring, "tracking") == 0) { + out = GpsMode::OnWhenTracking; + return true; + } + if (std::strcmp(item->valuestring, "always") == 0) { + out = GpsMode::AlwaysOn; + return true; + } + + AG_LOGW(TAG, "config %s rejected: unsupported value '%s'", JSON_GPS_MODE, item->valuestring); + return false; +} + +bool parse_bool(const cJSON *item, const char *field_name, bool &out) { + if (!cJSON_IsBool(item)) { + AG_LOGW(TAG, "config %s rejected: value is not a boolean", field_name); + return false; + } + + out = cJSON_IsTrue(item) != 0; + return true; +} + bool parse_co2_abc_days(const cJSON *item, int &out) { if (!cJSON_IsNumber(item) || !std::isfinite(item->valuedouble) || std::floor(item->valuedouble) != item->valuedouble || @@ -259,6 +313,63 @@ GoConfigUpdate parse_cloud_config(const char *buffer, size_t bytes) { update.update_mask |= static_cast(GoConfigField::TemperatureUnit); } + const cJSON *measurement_interval = + cJSON_GetObjectItemCaseSensitive(root, JSON_MEASUREMENT_INTERVAL); + if (measurement_interval != nullptr && + parse_int_range(measurement_interval, JSON_MEASUREMENT_INTERVAL, MEASURE_INTERVAL_SECONDS_MIN, + MEASURE_INTERVAL_SECONDS_MAX, update.measure_interval_seconds)) { + update.update_mask |= static_cast(GoConfigField::MeasurementInterval); + } + + const cJSON *gps_mode = cJSON_GetObjectItemCaseSensitive(root, JSON_GPS_MODE); + if (gps_mode != nullptr && parse_gps_mode(gps_mode, update.gps_mode)) { + update.update_mask |= static_cast(GoConfigField::GpsMode); + } + + const cJSON *gps_interval = cJSON_GetObjectItemCaseSensitive(root, JSON_GPS_INTERVAL); + if (gps_interval != nullptr && + parse_int_range(gps_interval, JSON_GPS_INTERVAL, GPS_INTERVAL_SECONDS_MIN, + GPS_INTERVAL_SECONDS_MAX, update.gps_interval_seconds)) { + update.update_mask |= static_cast(GoConfigField::GpsInterval); + } + + int led_value = 0; + const cJSON *front_led_brightness = + cJSON_GetObjectItemCaseSensitive(root, JSON_FRONT_LED_BRIGHTNESS); + if (front_led_brightness != nullptr && + parse_int_range(front_led_brightness, JSON_FRONT_LED_BRIGHTNESS, + static_cast(LedBrightness::Off), static_cast(LedBrightness::Bright), + led_value)) { + update.front_led_brightness = static_cast(led_value); + update.update_mask |= static_cast(GoConfigField::FrontLedBrightness); + } + + const cJSON *back_led_brightness = + cJSON_GetObjectItemCaseSensitive(root, JSON_BACK_LED_BRIGHTNESS); + if (back_led_brightness != nullptr && + parse_int_range(back_led_brightness, JSON_BACK_LED_BRIGHTNESS, + static_cast(LedBrightness::Off), static_cast(LedBrightness::Bright), + led_value)) { + update.back_led_brightness = static_cast(led_value); + update.update_mask |= static_cast(GoConfigField::BackLedBrightness); + } + + const cJSON *touch_led_intensity = + cJSON_GetObjectItemCaseSensitive(root, JSON_TOUCH_LED_INTENSITY); + if (touch_led_intensity != nullptr && + parse_int_range(touch_led_intensity, JSON_TOUCH_LED_INTENSITY, + static_cast(TouchLedIntensity::Off), + static_cast(TouchLedIntensity::Bright), led_value)) { + update.touch_led_intensity = static_cast(led_value); + update.update_mask |= static_cast(GoConfigField::TouchLedIntensity); + } + + const cJSON *buzzer_enabled = cJSON_GetObjectItemCaseSensitive(root, JSON_BUZZER_ENABLED); + if (buzzer_enabled != nullptr && + parse_bool(buzzer_enabled, JSON_BUZZER_ENABLED, update.buzzer_enabled)) { + update.update_mask |= static_cast(GoConfigField::BuzzerEnabled); + } + const cJSON *abc_days = cJSON_GetObjectItemCaseSensitive(root, JSON_ABC_DAYS); if (abc_days != nullptr && parse_co2_abc_days(abc_days, update.co2_abc_days)) { update.update_mask |= static_cast(GoConfigField::Co2AbcDays); diff --git a/products/go/main/go_config_types.h b/products/go/main/go_config_types.h index b65a6f9..5f17fbb 100644 --- a/products/go/main/go_config_types.h +++ b/products/go/main/go_config_types.h @@ -13,6 +13,8 @@ #include #include +#include "go_types.h" +#include "led/go_led_types.h" #include "measurement_corrections.h" enum class ConfigurationControl : uint8_t { @@ -32,8 +34,23 @@ enum class GoConfigField : uint32_t { Co2AbcDays = 1U << 7, TvocLearningOffset = 1U << 8, NoxLearningOffset = 1U << 9, + MeasurementInterval = 1U << 10, + GpsInterval = 1U << 11, + GpsMode = 1U << 12, + FrontLedBrightness = 1U << 13, + BackLedBrightness = 1U << 14, + TouchLedIntensity = 1U << 15, + BuzzerEnabled = 1U << 16, }; +constexpr int MEASURE_INTERVAL_SECONDS_MIN = 1; +constexpr int MEASURE_INTERVAL_SECONDS_MAX = 3600; +constexpr int MEASURE_INTERVAL_SECONDS_DEFAULT = 10; + +constexpr int GPS_INTERVAL_SECONDS_MIN = 1; +constexpr int GPS_INTERVAL_SECONDS_MAX = 60; +constexpr int GPS_INTERVAL_SECONDS_DEFAULT = 5; + constexpr int CO2_ABC_DAYS_DISABLED = -1; constexpr int CO2_ABC_DAYS_MIN = 1; constexpr int CO2_ABC_DAYS_MAX = 200; @@ -43,6 +60,29 @@ constexpr int LEARNING_OFFSET_HOURS_MIN = 1; constexpr int LEARNING_OFFSET_HOURS_MAX = 1000; constexpr int LEARNING_OFFSET_HOURS_DEFAULT = 12; +inline bool is_measure_interval_seconds_valid(int value) { + return value >= MEASURE_INTERVAL_SECONDS_MIN && value <= MEASURE_INTERVAL_SECONDS_MAX; +} + +inline bool is_gps_interval_seconds_valid(int value) { + return value >= GPS_INTERVAL_SECONDS_MIN && value <= GPS_INTERVAL_SECONDS_MAX; +} + +inline bool is_gps_mode_valid(int value) { + return value >= static_cast(GpsMode::AlwaysOff) && + value <= static_cast(GpsMode::AlwaysOn); +} + +inline bool is_led_brightness_valid(int value) { + return value >= static_cast(LedBrightness::Off) && + value <= static_cast(LedBrightness::Bright); +} + +inline bool is_touch_led_intensity_valid(int value) { + return value >= static_cast(TouchLedIntensity::Off) && + value <= static_cast(TouchLedIntensity::Bright); +} + inline bool is_co2_abc_days_valid(int value) { return value == CO2_ABC_DAYS_DISABLED || (value >= CO2_ABC_DAYS_MIN && value <= CO2_ABC_DAYS_MAX); } @@ -61,6 +101,13 @@ struct GoConfigUpdate { bool use_fahrenheit = false; bool disable_cloud = false; ConfigurationControl configuration_control = ConfigurationControl::Both; + int measure_interval_seconds = MEASURE_INTERVAL_SECONDS_DEFAULT; + int gps_interval_seconds = GPS_INTERVAL_SECONDS_DEFAULT; + GpsMode gps_mode = GpsMode::OnWhenTracking; + LedBrightness front_led_brightness = LedBrightness::Off; + LedBrightness back_led_brightness = LedBrightness::Off; + TouchLedIntensity touch_led_intensity = TouchLedIntensity::Off; + bool buzzer_enabled = false; int co2_abc_days = CO2_ABC_DAYS_DEFAULT; int tvoc_learning_offset = LEARNING_OFFSET_HOURS_DEFAULT; int nox_learning_offset = LEARNING_OFFSET_HOURS_DEFAULT; diff --git a/products/go/main/go_local_api.cpp b/products/go/main/go_local_api.cpp index 3274815..d31a213 100644 --- a/products/go/main/go_local_api.cpp +++ b/products/go/main/go_local_api.cpp @@ -26,6 +26,9 @@ constexpr const char *TEMPERATURE_UNIT_FAHRENHEIT = "f"; constexpr const char *CONFIG_CONTROL_CLOUD = "cloud"; constexpr const char *CONFIG_CONTROL_LOCAL = "local"; constexpr const char *CONFIG_CONTROL_BOTH = "both"; +constexpr const char *GPS_MODE_OFF = "off"; +constexpr const char *GPS_MODE_TRACKING = "tracking"; +constexpr const char *GPS_MODE_ALWAYS = "always"; constexpr const char *CORRECTION_NONE = "none"; constexpr const char *CORRECTION_EPA_2021 = "epa_2021"; @@ -388,6 +391,13 @@ GoLocalApiService::make_active_config(const GoSettings &settings) { active.use_fahrenheit = settings.use_fahrenheit; active.disable_cloud = settings.disable_cloud; active.configuration_control = settings.configuration_control; + active.measure_interval_seconds = settings.measure_interval_seconds; + active.gps_interval_seconds = settings.gps_interval_seconds; + active.gps_mode = settings.gps_mode; + active.front_led_brightness = settings.front_led_brightness; + active.back_led_brightness = settings.back_led_brightness; + active.touch_led_intensity = settings.touch_led_intensity; + active.buzzer_enabled = settings.buzzer_enabled; active.co2_abc_days = settings.co2_abc_days; active.tvoc_learning_offset = settings.tvoc_learning_offset; active.nox_learning_offset = settings.nox_learning_offset; @@ -401,6 +411,12 @@ LocalServerConfig GoLocalApiService::map_config(const ActiveConfigSnapshot &acti config.temperature_unit = active.use_fahrenheit ? TEMPERATURE_UNIT_FAHRENHEIT : TEMPERATURE_UNIT_CELSIUS; config.cloud_connection = !active.disable_cloud; + config.measurement_interval_seconds = active.measure_interval_seconds; + config.gps_interval_seconds = active.gps_interval_seconds; + config.front_led_brightness = static_cast(active.front_led_brightness); + config.back_led_brightness = static_cast(active.back_led_brightness); + config.touch_led_intensity = static_cast(active.touch_led_intensity); + config.buzzer_enabled = active.buzzer_enabled; config.co2_abc_days = active.co2_abc_days; config.tvoc_learning_offset = active.tvoc_learning_offset; config.nox_learning_offset = active.nox_learning_offset; @@ -417,6 +433,18 @@ LocalServerConfig GoLocalApiService::map_config(const ActiveConfigSnapshot &acti break; } + switch (active.gps_mode) { + case GpsMode::AlwaysOff: + config.gps_mode = GPS_MODE_OFF; + break; + case GpsMode::OnWhenTracking: + config.gps_mode = GPS_MODE_TRACKING; + break; + case GpsMode::AlwaysOn: + config.gps_mode = GPS_MODE_ALWAYS; + break; + } + Corrections corrections{}; corrections.pm25 = make_pm25_correction(active.corrections.pm25); corrections.temp = make_linear_correction(active.corrections.temperature); @@ -459,7 +487,11 @@ bool GoLocalApiService::is_exact_control_recovery(const LocalServerConfig &parti return !partial.country.has_value() && !partial.pm_standard.has_value() && !partial.temperature_unit.has_value() && !partial.post_data_to_cloud.has_value() && - !partial.cloud_connection.has_value() && !partial.co2_abc_days.has_value() && + !partial.cloud_connection.has_value() && + !partial.measurement_interval_seconds.has_value() && !partial.gps_mode.has_value() && + !partial.gps_interval_seconds.has_value() && !partial.front_led_brightness.has_value() && + !partial.back_led_brightness.has_value() && !partial.touch_led_intensity.has_value() && + !partial.buzzer_enabled.has_value() && !partial.co2_abc_days.has_value() && !partial.tvoc_learning_offset.has_value() && !partial.nox_learning_offset.has_value() && !partial.led_mode.has_value() && !partial.led_bar_brightness.has_value() && !partial.display_brightness.has_value() && !partial.mqtt_broker_url.has_value() && @@ -493,6 +525,64 @@ ConfigSubmitResult GoLocalApiService::translate_config(const LocalServerConfig & update.update_mask |= static_cast(GoConfigField::TemperatureUnit); } + if (partial.measurement_interval_seconds.has_value()) { + if (!is_measure_interval_seconds_valid(*partial.measurement_interval_seconds)) { + return {ConfigSubmitStatus::InvalidValue, ConfigFieldId::MeasurementInterval}; + } + update.measure_interval_seconds = *partial.measurement_interval_seconds; + update.update_mask |= static_cast(GoConfigField::MeasurementInterval); + } + + if (partial.gps_mode.has_value()) { + if (*partial.gps_mode == GPS_MODE_OFF) { + update.gps_mode = GpsMode::AlwaysOff; + } else if (*partial.gps_mode == GPS_MODE_TRACKING) { + update.gps_mode = GpsMode::OnWhenTracking; + } else if (*partial.gps_mode == GPS_MODE_ALWAYS) { + update.gps_mode = GpsMode::AlwaysOn; + } else { + return {ConfigSubmitStatus::InvalidValue, ConfigFieldId::GpsMode}; + } + update.update_mask |= static_cast(GoConfigField::GpsMode); + } + + if (partial.gps_interval_seconds.has_value()) { + if (!is_gps_interval_seconds_valid(*partial.gps_interval_seconds)) { + return {ConfigSubmitStatus::InvalidValue, ConfigFieldId::GpsInterval}; + } + update.gps_interval_seconds = *partial.gps_interval_seconds; + update.update_mask |= static_cast(GoConfigField::GpsInterval); + } + + if (partial.front_led_brightness.has_value()) { + if (!is_led_brightness_valid(*partial.front_led_brightness)) { + return {ConfigSubmitStatus::InvalidValue, ConfigFieldId::FrontLedBrightness}; + } + update.front_led_brightness = static_cast(*partial.front_led_brightness); + update.update_mask |= static_cast(GoConfigField::FrontLedBrightness); + } + + if (partial.back_led_brightness.has_value()) { + if (!is_led_brightness_valid(*partial.back_led_brightness)) { + return {ConfigSubmitStatus::InvalidValue, ConfigFieldId::BackLedBrightness}; + } + update.back_led_brightness = static_cast(*partial.back_led_brightness); + update.update_mask |= static_cast(GoConfigField::BackLedBrightness); + } + + if (partial.touch_led_intensity.has_value()) { + if (!is_touch_led_intensity_valid(*partial.touch_led_intensity)) { + return {ConfigSubmitStatus::InvalidValue, ConfigFieldId::TouchLedIntensity}; + } + update.touch_led_intensity = static_cast(*partial.touch_led_intensity); + update.update_mask |= static_cast(GoConfigField::TouchLedIntensity); + } + + if (partial.buzzer_enabled.has_value()) { + update.buzzer_enabled = *partial.buzzer_enabled; + update.update_mask |= static_cast(GoConfigField::BuzzerEnabled); + } + if (partial.cloud_connection.has_value()) { update.disable_cloud = !*partial.cloud_connection; update.update_mask |= static_cast(GoConfigField::CloudConnection); diff --git a/products/go/main/go_local_api.h b/products/go/main/go_local_api.h index f4382f3..7729921 100644 --- a/products/go/main/go_local_api.h +++ b/products/go/main/go_local_api.h @@ -83,6 +83,13 @@ class GoLocalApiService final : public MeasuresProvider, bool use_fahrenheit = false; bool disable_cloud = false; ConfigurationControl configuration_control = ConfigurationControl::Both; + int measure_interval_seconds = MEASURE_INTERVAL_SECONDS_DEFAULT; + int gps_interval_seconds = GPS_INTERVAL_SECONDS_DEFAULT; + GpsMode gps_mode = GpsMode::OnWhenTracking; + LedBrightness front_led_brightness = LedBrightness::Off; + LedBrightness back_led_brightness = LedBrightness::Off; + TouchLedIntensity touch_led_intensity = TouchLedIntensity::Off; + bool buzzer_enabled = false; int co2_abc_days = CO2_ABC_DAYS_DEFAULT; int tvoc_learning_offset = LEARNING_OFFSET_HOURS_DEFAULT; int nox_learning_offset = LEARNING_OFFSET_HOURS_DEFAULT; diff --git a/products/go/main/go_orchestrator.cpp b/products/go/main/go_orchestrator.cpp index 8625dff..1c09beb 100644 --- a/products/go/main/go_orchestrator.cpp +++ b/products/go/main/go_orchestrator.cpp @@ -108,6 +108,34 @@ static bool merge_config_update(const GoConfigUpdate &update, GoConfigSource sou candidate.use_fahrenheit = update.use_fahrenheit; has_update = true; } + if (has_go_config_field(update.update_mask, GoConfigField::MeasurementInterval)) { + candidate.measure_interval_seconds = update.measure_interval_seconds; + has_update = true; + } + if (has_go_config_field(update.update_mask, GoConfigField::GpsInterval)) { + candidate.gps_interval_seconds = update.gps_interval_seconds; + has_update = true; + } + if (has_go_config_field(update.update_mask, GoConfigField::GpsMode)) { + candidate.gps_mode = update.gps_mode; + has_update = true; + } + if (has_go_config_field(update.update_mask, GoConfigField::FrontLedBrightness)) { + candidate.front_led_brightness = update.front_led_brightness; + has_update = true; + } + if (has_go_config_field(update.update_mask, GoConfigField::BackLedBrightness)) { + candidate.back_led_brightness = update.back_led_brightness; + has_update = true; + } + if (has_go_config_field(update.update_mask, GoConfigField::TouchLedIntensity)) { + candidate.touch_led_intensity = update.touch_led_intensity; + has_update = true; + } + if (has_go_config_field(update.update_mask, GoConfigField::BuzzerEnabled)) { + candidate.buzzer_enabled = update.buzzer_enabled; + has_update = true; + } // Cloud Fetch intentionally does not own connectivity or source-control // policy. These fields are Local Server settings only. if (source != GoConfigSource::CloudFetch && diff --git a/products/go/main/go_settings.cpp b/products/go/main/go_settings.cpp index 7cc9995..757457a 100644 --- a/products/go/main/go_settings.cpp +++ b/products/go/main/go_settings.cpp @@ -60,14 +60,8 @@ bool is_fg_learning_stage_valid(int value) { bool is_byte_valid(int value) { return value >= 0 && value <= 255; } -bool is_measure_interval_valid(int value) { return value >= 1 && value <= 3600; } - bool is_inactivity_timeout_valid(int value) { return value >= 5 && value <= 600; } -bool is_gps_interval_valid(int value) { return value >= 1 && value <= 60; } - -bool is_gps_mode_valid(int value) { return value >= 0 && value <= 2; } - bool is_operating_mode_valid(int value) { return value >= 0 && value <= 2; } bool is_configuration_control_valid(int value) { @@ -81,10 +75,6 @@ bool is_auto_lock_valid(int value) { bool is_device_name_valid(const std::string &value) { return !value.empty() && value.size() <= 64; } -bool is_led_brightness_valid(int value) { return value >= 0 && value <= 3; } - -bool is_touch_led_intensity_valid(int value) { return value >= 0 && value <= 2; } - bool is_pm25_algorithm_valid(int value) { return value >= static_cast(Pm25CorrectionAlgorithm::None) && value <= static_cast(Pm25CorrectionAlgorithm::CustomViaPm25Raw); @@ -203,7 +193,7 @@ GoSettings load_go_settings(ConfigStore &store) { int measure_interval_seconds = 0; if (store.get_int(KEY_MEASURE_INTERVAL_SECONDS, measure_interval_seconds) == ConfigStoreResult::OK && - is_measure_interval_valid(measure_interval_seconds)) { + is_measure_interval_seconds_valid(measure_interval_seconds)) { settings.measure_interval_seconds = measure_interval_seconds; } @@ -216,7 +206,7 @@ GoSettings load_go_settings(ConfigStore &store) { int gps_interval_seconds = 0; if (store.get_int(KEY_GPS_INTERVAL_SECONDS, gps_interval_seconds) == ConfigStoreResult::OK && - is_gps_interval_valid(gps_interval_seconds)) { + is_gps_interval_seconds_valid(gps_interval_seconds)) { settings.gps_interval_seconds = gps_interval_seconds; } @@ -353,7 +343,7 @@ bool GoSettings::equals(const GoSettings &other) const { } bool is_go_settings_valid(const GoSettings &settings) { - if (!is_measure_interval_valid(settings.measure_interval_seconds)) { + if (!is_measure_interval_seconds_valid(settings.measure_interval_seconds)) { return false; } @@ -361,7 +351,7 @@ bool is_go_settings_valid(const GoSettings &settings) { return false; } - if (!is_gps_interval_valid(settings.gps_interval_seconds)) { + if (!is_gps_interval_seconds_valid(settings.gps_interval_seconds)) { return false; } diff --git a/products/go/main/go_settings.h b/products/go/main/go_settings.h index de130f0..ea9d5fc 100644 --- a/products/go/main/go_settings.h +++ b/products/go/main/go_settings.h @@ -12,14 +12,14 @@ struct GoSettings { // --- Measurement interval --- - int measure_interval_seconds = 10; // 1..3600 + int measure_interval_seconds = MEASURE_INTERVAL_SECONDS_DEFAULT; // --- Display --- bool use_fahrenheit = false; bool pm_use_usaqi = false; // --- GPS --- - int gps_interval_seconds = 5; + int gps_interval_seconds = GPS_INTERVAL_SECONDS_DEFAULT; GpsMode gps_mode = GpsMode::OnWhenTracking; // --- Device behavior --- diff --git a/products/go/tests/ble-integration/README.md b/products/go/tests/ble-integration/README.md index 79bafdc..f5e14cd 100644 --- a/products/go/tests/ble-integration/README.md +++ b/products/go/tests/ble-integration/README.md @@ -109,14 +109,15 @@ still returns the full 9-key snapshot: Covers read, write, delta-notify, and command operations: -- **Read** (6 tests, sync): reads Config once (module-scoped fixture), then - validates the 15 config keys present with correct types; versioned correction +- **Read** (sync): reads Config once (module-scoped fixture), then validates the + 19 config keys present with correct types; versioned correction arrays, `gps_mode`, and `op_mode` use valid fields and enum values - **Set config** (async): writes a single-field `{"op": "set", ...}`, verifies the device sends a Config **delta** notification — `"type": "config"` plus only the changed key -- **No-op set** (async): writing an unchanged value yields a delta of just - `{"type": "config"}` +- **Compact device fields** (async): round-trips buzzer, CO2 ABC, TVOC learning, + and NOx learning values, verifies exact deltas, then restores each value +- **No-op set** (async): writing an unchanged value emits no notification - **Correction set** (async): writes a complete temperature correction group, verifies the nested Config delta without a Measures notification, then restores the original group diff --git a/products/go/tests/ble-integration/ago_protocol.py b/products/go/tests/ble-integration/ago_protocol.py index ced1b5a..8526126 100644 --- a/products/go/tests/ble-integration/ago_protocol.py +++ b/products/go/tests/ble-integration/ago_protocol.py @@ -122,15 +122,21 @@ "inact_to", "auto_lock", "dev_name", "op_mode", "fled", "bled", "tled", + "buz", "abc", "tlo", "nlo", "pm25_corr", "temp_corr", "hum_corr", } # Config NOTIFY is a DELTA, not the full snapshot: "type":"config" plus only the # field(s) that changed. A single-field "set" yields {"type", }; a -# no-op "set" (nothing changed) yields just {"type"}. The full snapshot is only -# available via Read (CONFIG_READ_KEYS, no "type"). +# no-op "set" produces no notification. The full snapshot is only available via +# Read (CONFIG_READ_KEYS, no "type"). CONFIG_NOTIFY_TYPE = "config" -CONFIG_NOTIFY_MIN_KEYS = {"type"} # no-op set + +CO2_ABC_DAYS_DISABLED = -1 +CO2_ABC_DAYS_MIN = 1 +CO2_ABC_DAYS_MAX = 200 +LEARNING_OFFSET_HOURS_MIN = 1 +LEARNING_OFFSET_HOURS_MAX = 1000 CONFIG_FIELD_TYPES: dict[str, tuple[type, ...]] = { "meas_int": (int,), @@ -145,6 +151,10 @@ "fled": (int,), "bled": (int,), "tled": (int,), + "buz": (bool,), + "abc": (int,), + "tlo": (int,), + "nlo": (int,), "pm25_corr": (dict,), "temp_corr": (dict,), "hum_corr": (dict,), @@ -234,7 +244,7 @@ def decode_cbor_safe(data: bytes) -> tuple[bool, Any]: def encode_config_set(**overrides: Any) -> bytes: """Build a Config write payload for setting config values. - Example: encode_config_set(temp_f=True, pm_int=30) + Example: encode_config_set(buz=True) """ payload: dict[str, Any] = {"op": "set"} payload.update(overrides) diff --git a/products/go/tests/ble-integration/test_config.py b/products/go/tests/ble-integration/test_config.py index ade931d..2bf6990 100644 --- a/products/go/tests/ble-integration/test_config.py +++ b/products/go/tests/ble-integration/test_config.py @@ -9,6 +9,7 @@ from __future__ import annotations +import asyncio import math import pytest @@ -35,7 +36,7 @@ async def config_payload(ago_client: BleakClient) -> dict: # --------------------------------------------------------------------------- class TestConfigRead: - """Verify reading the Config characteristic returns a valid 15-key map.""" + """Verify reading the Config characteristic returns a valid 19-key map.""" def test_read_config(self, config_payload: dict): """Reading Config must return valid CBOR map.""" @@ -44,7 +45,7 @@ def test_read_config(self, config_payload: dict): ) def test_all_keys_present(self, config_payload: dict): - """Config read must contain exactly the 15 expected keys.""" + """Config read must contain exactly the 19 expected keys.""" missing = proto.CONFIG_READ_KEYS - set(config_payload.keys()) extra = set(config_payload.keys()) - proto.CONFIG_READ_KEYS assert not missing, f"Missing Config keys: {missing}" @@ -56,7 +57,7 @@ def test_field_types(self, config_payload: dict): assert key in config_payload, f"Config key '{key}' missing" value = config_payload[key] expected_types = proto.CONFIG_FIELD_TYPES[key] - assert isinstance(value, expected_types), ( + assert type(value) in expected_types, ( f"Config['{key}']: expected {expected_types}, " f"got {type(value).__name__} = {value!r}" ) @@ -75,6 +76,28 @@ def test_operating_mode_valid(self, config_payload: dict): f"Unknown op_mode: '{op_mode}'. Expected one of: {proto.OPERATING_MODES}" ) + def test_compact_device_fields_valid(self, config_payload: dict): + """Compact buzzer and sensor config fields must use valid ranges.""" + assert type(config_payload["buz"]) is bool + assert type(config_payload["abc"]) is int + assert config_payload["abc"] == proto.CO2_ABC_DAYS_DISABLED or ( + proto.CO2_ABC_DAYS_MIN + <= config_payload["abc"] + <= proto.CO2_ABC_DAYS_MAX + ) + assert type(config_payload["tlo"]) is int + assert ( + proto.LEARNING_OFFSET_HOURS_MIN + <= config_payload["tlo"] + <= proto.LEARNING_OFFSET_HOURS_MAX + ) + assert type(config_payload["nlo"]) is int + assert ( + proto.LEARNING_OFFSET_HOURS_MIN + <= config_payload["nlo"] + <= proto.LEARNING_OFFSET_HOURS_MAX + ) + def test_correction_maps_valid(self, config_payload: dict): """Correction maps must expose the versioned compact value arrays.""" for key in proto.CORRECTION_MAP_KEYS: @@ -206,6 +229,61 @@ async def test_set_config_roundtrip( ) await config_notifications.wait_for(timeout=ago_notify_timeout) + @pytest.mark.parametrize("key", ["buz", "abc", "tlo", "nlo"]) + async def test_set_compact_device_field_roundtrip( + self, + key: str, + ago_client: BleakClient, + config_notifications: NotificationCollector, + ago_notify_timeout: float, + ): + """Compact device config fields must persist and emit exact deltas.""" + original = proto.decode_cbor( + bytes(await ago_client.read_gatt_char(proto.CHAR_CONFIG_UUID)) + ) + original_value = original[key] + if key == "buz": + new_value = not original_value + elif key == "abc": + new_value = ( + proto.CO2_ABC_DAYS_MIN + if original_value == proto.CO2_ABC_DAYS_DISABLED + else proto.CO2_ABC_DAYS_DISABLED + ) + else: + new_value = ( + proto.LEARNING_OFFSET_HOURS_MIN + 1 + if original_value == proto.LEARNING_OFFSET_HOURS_MIN + else proto.LEARNING_OFFSET_HOURS_MIN + ) + + try: + await ago_client.write_gatt_char( + proto.CHAR_CONFIG_UUID, + proto.encode_config_set(**{key: new_value}), + response=True, + ) + payload = proto.decode_cbor( + await config_notifications.wait_for(timeout=ago_notify_timeout) + ) + assert payload == {"type": proto.CONFIG_NOTIFY_TYPE, key: new_value} + + updated = proto.decode_cbor( + bytes(await ago_client.read_gatt_char(proto.CHAR_CONFIG_UUID)) + ) + assert updated[key] == new_value + finally: + current = proto.decode_cbor( + bytes(await ago_client.read_gatt_char(proto.CHAR_CONFIG_UUID)) + ) + if current[key] != original_value: + await ago_client.write_gatt_char( + proto.CHAR_CONFIG_UUID, + proto.encode_config_set(**{key: original_value}), + response=True, + ) + await config_notifications.wait_for(timeout=ago_notify_timeout) + async def test_set_temperature_correction_updates_config_without_measures_notify( self, ago_client: BleakClient, @@ -316,7 +394,7 @@ async def test_config_notify_field_types( for key in payload: value = payload[key] expected_types = proto.CONFIG_FIELD_TYPES[key] - assert isinstance(value, expected_types), ( + assert type(value) in expected_types, ( f"Config delta['{key}']: expected {expected_types}, " f"got {type(value).__name__} = {value!r}" ) @@ -328,13 +406,13 @@ async def test_config_notify_field_types( ) await config_notifications.wait_for(timeout=ago_notify_timeout) - async def test_noop_set_emits_type_only( + async def test_noop_set_emits_no_notification( self, ago_client: BleakClient, config_notifications: NotificationCollector, ago_notify_timeout: float, ): - """A 'set' that changes nothing yields a delta of just {'type':'config'}.""" + """A 'set' that changes nothing emits no Config notification.""" raw = await ago_client.read_gatt_char(proto.CHAR_CONFIG_UUID) original = proto.decode_cbor(bytes(raw)) @@ -344,13 +422,8 @@ async def test_noop_set_emits_type_only( proto.CHAR_CONFIG_UUID, write_data, response=True, ) - notif_data = await config_notifications.wait_for(timeout=ago_notify_timeout) - payload = proto.decode_cbor(notif_data) - - assert set(payload.keys()) == proto.CONFIG_NOTIFY_MIN_KEYS, ( - f"No-op set should emit only {{'type'}}, got {set(payload.keys())}" - ) - assert payload["type"] == proto.CONFIG_NOTIFY_TYPE + with pytest.raises(asyncio.TimeoutError): + await config_notifications.wait_for(timeout=min(ago_notify_timeout, 1.0)) async def test_multi_field_set_rejected( self, diff --git a/products/go/tests/go_ble.tests.cpp b/products/go/tests/go_ble.tests.cpp index 7fd9559..f062446 100644 --- a/products/go/tests/go_ble.tests.cpp +++ b/products/go/tests/go_ble.tests.cpp @@ -410,6 +410,8 @@ static bool top_level_value_is_map(const uint8_t *data, size_t len, const char * // Conservative single-PDU budget (mirrors BLE_NOTIFY_MAX_BYTES in go_ble.cpp); // the 185-byte minimum MTU yields a 182-byte PDU, so 180 is the test bound. static constexpr size_t TEST_NOTIFY_BUDGET = 180; +static constexpr int TEST_MAX_INACTIVITY_TIMEOUT_SECONDS = 600; +static constexpr int TEST_MAX_AUTO_LOCK_SECONDS = 60; // =========================================================================== // Test fixture helpers @@ -791,7 +793,7 @@ TEST_CASE("BLE: encode_status clamps negative battery values to 0") { // CBOR encoding: Config // --------------------------------------------------------------------------- -TEST_CASE("BLE: encode_config produces 15 keys with meas_int and corrections") { +TEST_CASE("BLE: encode_config produces 19 keys with compact device config") { StorageService storage(*null_cache_ptr, *null_nand_ptr); BleService svc(nullptr, storage, default_ble_server); auto settings = make_default_settings(); @@ -801,7 +803,7 @@ TEST_CASE("BLE: encode_config produces 15 keys with meas_int and corrections") { REQUIRE(len > 0); auto entries = decode_cbor_map(buf, len); - CHECK(entries.size() == 15); + CHECK(entries.size() == 19); CHECK(find_entry(entries, "meas_int") != nullptr); CHECK(find_entry(entries, "pm_int") == nullptr); @@ -818,10 +820,14 @@ TEST_CASE("BLE: encode_config produces 15 keys with meas_int and corrections") { CHECK(find_entry(entries, "fled") != nullptr); CHECK(find_entry(entries, "bled") != nullptr); CHECK(find_entry(entries, "tled") != nullptr); + CHECK(find_entry(entries, "buz") != nullptr); + CHECK(find_entry(entries, "abc") != nullptr); + CHECK(find_entry(entries, "tlo") != nullptr); + CHECK(find_entry(entries, "nlo") != nullptr); CHECK(top_level_value_is_map(buf, len, "pm25_corr")); CHECK(top_level_value_is_map(buf, len, "temp_corr")); CHECK(top_level_value_is_map(buf, len, "hum_corr")); - CHECK(len < 220); + CHECK(len < 256); } TEST_CASE("BLE: encode_config values match settings") { @@ -833,6 +839,10 @@ TEST_CASE("BLE: encode_config values match settings") { s.gps_mode = GpsMode::AlwaysOn; s.device_name = "test-device"; s.operating_mode = OperatingMode::Stationary; + s.buzzer_enabled = true; + s.co2_abc_days = CO2_ABC_DAYS_DISABLED; + s.tvoc_learning_offset = LEARNING_OFFSET_HOURS_MIN; + s.nox_learning_offset = LEARNING_OFFSET_HOURS_MAX; uint8_t buf[512]; size_t len = BleServiceTestAccess::encode_config(svc, buf, sizeof(buf), s); @@ -847,6 +857,10 @@ TEST_CASE("BLE: encode_config values match settings") { CHECK(find_entry(entries, "gps_mode")->text_val == "always"); CHECK(find_entry(entries, "dev_name")->text_val == "test-device"); CHECK(find_entry(entries, "op_mode")->text_val == "stationary"); + CHECK(find_entry(entries, "buz")->bool_val == true); + CHECK(find_entry(entries, "abc")->int_val == CO2_ABC_DAYS_DISABLED); + CHECK(find_entry(entries, "tlo")->uint_val == LEARNING_OFFSET_HOURS_MIN); + CHECK(find_entry(entries, "nlo")->uint_val == LEARNING_OFFSET_HOURS_MAX); } // --------------------------------------------------------------------------- @@ -896,6 +910,30 @@ TEST_CASE("BLE: encode_config_delta correction change yields one nested field") CHECK(find_entry(entries, "hum_corr") == nullptr); } +TEST_CASE("BLE: encode_config_delta includes compact device config changes") { + StorageService storage(*null_cache_ptr, *null_nand_ptr); + BleService svc(nullptr, storage, default_ble_server); + + GoSettings prev = make_default_settings(); + GoSettings cur = prev; + cur.buzzer_enabled = true; + cur.co2_abc_days = CO2_ABC_DAYS_DISABLED; + cur.tvoc_learning_offset = LEARNING_OFFSET_HOURS_MIN; + cur.nox_learning_offset = LEARNING_OFFSET_HOURS_MAX; + + uint8_t buf[256]; + size_t len = BleServiceTestAccess::encode_config_delta(svc, buf, sizeof(buf), prev, cur); + REQUIRE(len > 0); + CHECK(len <= TEST_NOTIFY_BUDGET); + + auto entries = decode_cbor_map(buf, len); + CHECK(entries.size() == 5); + CHECK(find_entry(entries, "buz")->bool_val == true); + CHECK(find_entry(entries, "abc")->int_val == CO2_ABC_DAYS_DISABLED); + CHECK(find_entry(entries, "tlo")->uint_val == LEARNING_OFFSET_HOURS_MIN); + CHECK(find_entry(entries, "nlo")->uint_val == LEARNING_OFFSET_HOURS_MAX); +} + TEST_CASE("BLE: encode_config_delta no change yields only type") { StorageService storage(*null_cache_ptr, *null_nand_ptr); BleService svc(nullptr, storage, default_ble_server); @@ -933,7 +971,7 @@ TEST_CASE("BLE: notify_config sends delta and keeps READ as full snapshot") { REQUIRE(config_char.notify_count == 1); auto read_entries = decode_cbor_map(config_char.last_value.data(), config_char.last_value.size()); - CHECK(read_entries.size() == 15); // full snapshot, no "type" + CHECK(read_entries.size() == 19); // full snapshot, no "type" CHECK(find_entry(read_entries, "type") == nullptr); auto notify_entries = decode_cbor_map(config_char.last_notified_value.data(), @@ -974,12 +1012,16 @@ TEST_CASE("BLE: max-size config snapshot encodes within the 512-byte ceiling") { GoSettings s = make_default_settings(); s.measure_interval_seconds = 3600; - s.gps_interval_seconds = 3600; - s.inactivity_timeout_seconds = 86400; - s.auto_lock_seconds = 86400; + s.gps_interval_seconds = GPS_INTERVAL_SECONDS_MAX; + s.inactivity_timeout_seconds = TEST_MAX_INACTIVITY_TIMEOUT_SECONDS; + s.auto_lock_seconds = TEST_MAX_AUTO_LOCK_SECONDS; s.device_name = std::string(64, 'x'); s.gps_mode = GpsMode::OnWhenTracking; s.operating_mode = OperatingMode::Stationary; + s.buzzer_enabled = true; + s.co2_abc_days = CO2_ABC_DAYS_DISABLED; + s.tvoc_learning_offset = LEARNING_OFFSET_HOURS_MAX; + s.nox_learning_offset = LEARNING_OFFSET_HOURS_MAX; s.corrections.pm25.algorithm = Pm25CorrectionAlgorithm::CustomViaPm25Raw; s.corrections.pm25.scaling_factor = 1.08f; s.corrections.pm25.intercept = -0.2f; @@ -1260,6 +1302,45 @@ static size_t encode_set_uint(uint8_t *buf, size_t sz, const char *key, uint64_t return cbor_encoder_get_buffer_size(&enc, buf); } +static size_t encode_set_int(uint8_t *buf, size_t sz, const char *key, int64_t value) { + CborEncoder enc; + cbor_encoder_init(&enc, buf, sz, 0); + CborEncoder map; + cbor_encoder_create_map(&enc, &map, 2); + cbor_encode_text_stringz(&map, "op"); + cbor_encode_text_stringz(&map, "set"); + cbor_encode_text_stringz(&map, key); + cbor_encode_int(&map, value); + cbor_encoder_close_container(&enc, &map); + return cbor_encoder_get_buffer_size(&enc, buf); +} + +static size_t encode_set_bool(uint8_t *buf, size_t sz, const char *key, bool value) { + CborEncoder enc; + cbor_encoder_init(&enc, buf, sz, 0); + CborEncoder map; + cbor_encoder_create_map(&enc, &map, 2); + cbor_encode_text_stringz(&map, "op"); + cbor_encode_text_stringz(&map, "set"); + cbor_encode_text_stringz(&map, key); + cbor_encode_boolean(&map, value); + cbor_encoder_close_container(&enc, &map); + return cbor_encoder_get_buffer_size(&enc, buf); +} + +static size_t encode_set_text(uint8_t *buf, size_t sz, const char *key, const char *value) { + CborEncoder enc; + cbor_encoder_init(&enc, buf, sz, 0); + CborEncoder map; + cbor_encoder_create_map(&enc, &map, 2); + cbor_encode_text_stringz(&map, "op"); + cbor_encode_text_stringz(&map, "set"); + cbor_encode_text_stringz(&map, key); + cbor_encode_text_stringz(&map, value); + cbor_encoder_close_container(&enc, &map); + return cbor_encoder_get_buffer_size(&enc, buf); +} + static size_t encode_set_pm25_correction(uint8_t *buf, size_t sz, uint64_t algorithm, float scale, float intercept, bool use_epa, uint64_t schema = 1) { CborEncoder enc; @@ -1343,6 +1424,110 @@ TEST_CASE("BLE: decode_config_write with known key has no unknown keys") { CHECK(settings.measure_interval_seconds == 30); } +TEST_CASE("BLE: decode_config_write decodes compact device config fields") { + uint8_t buf[64]; + GoSettings settings; + + SECTION("buzzer") { + const size_t len = encode_set_bool(buf, sizeof(buf), "buz", true); + const auto result = BleService::decode_config_write(buf, len, settings); + CHECK(settings.buzzer_enabled); + CHECK(result.recognized_config_key_count == 1); + CHECK_FALSE(result.has_invalid_config_values); + } + + SECTION("ABC disabled") { + const size_t len = encode_set_int(buf, sizeof(buf), "abc", CO2_ABC_DAYS_DISABLED); + const auto result = BleService::decode_config_write(buf, len, settings); + CHECK(settings.co2_abc_days == CO2_ABC_DAYS_DISABLED); + CHECK(result.recognized_config_key_count == 1); + CHECK_FALSE(result.has_invalid_config_values); + } + + SECTION("ABC enabled") { + const size_t len = encode_set_int(buf, sizeof(buf), "abc", CO2_ABC_DAYS_MAX); + const auto result = BleService::decode_config_write(buf, len, settings); + CHECK(settings.co2_abc_days == CO2_ABC_DAYS_MAX); + CHECK(result.recognized_config_key_count == 1); + CHECK_FALSE(result.has_invalid_config_values); + } + + SECTION("TVOC learning") { + const size_t len = encode_set_uint(buf, sizeof(buf), "tlo", LEARNING_OFFSET_HOURS_MIN); + const auto result = BleService::decode_config_write(buf, len, settings); + CHECK(settings.tvoc_learning_offset == LEARNING_OFFSET_HOURS_MIN); + CHECK(result.recognized_config_key_count == 1); + CHECK_FALSE(result.has_invalid_config_values); + } + + SECTION("NOx learning") { + const size_t len = encode_set_uint(buf, sizeof(buf), "nlo", LEARNING_OFFSET_HOURS_MAX); + const auto result = BleService::decode_config_write(buf, len, settings); + CHECK(settings.nox_learning_offset == LEARNING_OFFSET_HOURS_MAX); + CHECK(result.recognized_config_key_count == 1); + CHECK_FALSE(result.has_invalid_config_values); + } + + SECTION("GPS mode") { + const size_t len = encode_set_text(buf, sizeof(buf), "gps_mode", "off"); + const auto result = BleService::decode_config_write(buf, len, settings); + CHECK(settings.gps_mode == GpsMode::AlwaysOff); + CHECK(result.recognized_config_key_count == 1); + CHECK_FALSE(result.has_invalid_config_values); + } +} + +TEST_CASE("BLE: decode_config_write rejects invalid requested config values") { + uint8_t buf[64]; + GoSettings settings; + const GoSettings original = settings; + size_t len = 0; + + SECTION("measurement interval") { + len = encode_set_uint(buf, sizeof(buf), "meas_int", MEASURE_INTERVAL_SECONDS_MIN - 1); + } + SECTION("GPS interval") { + len = encode_set_uint(buf, sizeof(buf), "gps_int", GPS_INTERVAL_SECONDS_MAX + 1); + } + SECTION("GPS mode") { len = encode_set_text(buf, sizeof(buf), "gps_mode", "sometimes"); } + SECTION("front LED") { + len = + encode_set_uint(buf, sizeof(buf), "fled", static_cast(LedBrightness::Bright) + 1); + } + SECTION("back LED") { + len = + encode_set_uint(buf, sizeof(buf), "bled", static_cast(LedBrightness::Bright) + 1); + } + SECTION("touch LED") { + len = encode_set_uint(buf, sizeof(buf), "tled", + static_cast(TouchLedIntensity::Bright) + 1); + } + SECTION("buzzer type") { len = encode_set_uint(buf, sizeof(buf), "buz", 1); } + SECTION("ABC days") { len = encode_set_int(buf, sizeof(buf), "abc", CO2_ABC_DAYS_MIN - 1); } + SECTION("TVOC learning") { + len = encode_set_uint(buf, sizeof(buf), "tlo", LEARNING_OFFSET_HOURS_MIN - 1); + } + SECTION("NOx learning") { + len = encode_set_uint(buf, sizeof(buf), "nlo", LEARNING_OFFSET_HOURS_MAX + 1); + } + + const auto result = BleService::decode_config_write(buf, len, settings); + CHECK(result.op == BleConfigOp::Set); + CHECK(result.recognized_config_key_count == 1); + CHECK_FALSE(result.has_unknown_keys); + CHECK(result.has_invalid_config_values); + CHECK(settings.measure_interval_seconds == original.measure_interval_seconds); + CHECK(settings.gps_interval_seconds == original.gps_interval_seconds); + CHECK(settings.gps_mode == original.gps_mode); + CHECK(settings.front_led_brightness == original.front_led_brightness); + CHECK(settings.back_led_brightness == original.back_led_brightness); + CHECK(settings.touch_led_intensity == original.touch_led_intensity); + CHECK(settings.buzzer_enabled == original.buzzer_enabled); + CHECK(settings.co2_abc_days == original.co2_abc_days); + CHECK(settings.tvoc_learning_offset == original.tvoc_learning_offset); + CHECK(settings.nox_learning_offset == original.nox_learning_offset); +} + TEST_CASE("BLE: decode_config_write decodes PM25 correction group") { uint8_t buf[192]; size_t len = encode_set_pm25_correction(buf, sizeof(buf), 2, 1.08f, -0.2f, true); diff --git a/products/go/tests/go_cloud.tests.cpp b/products/go/tests/go_cloud.tests.cpp index 138da9a..a935d66 100644 --- a/products/go/tests/go_cloud.tests.cpp +++ b/products/go/tests/go_cloud.tests.cpp @@ -454,7 +454,7 @@ TEST_CASE("FETCH parses supported root scalars and ignores cloud policy fields", "[CloudService][fetch][config]") { CloudFixture f; const char body[] = - R"({"pmStandard":"us-aqi","temperatureUnit":"f","disableCloudConnection":true,"configurationControl":"local","corrections":[]})"; + R"({"pmStandard":"us-aqi","temperatureUnit":"f","measurementInterval":3600,"gpsMode":"always","gpsInterval":60,"frontLedBrightness":0,"backLedBrightness":3,"touchLedIntensity":2,"buzzerEnabled":true,"disableCloudConnection":true,"configurationControl":"local","corrections":[]})"; cloud_spy::fetch_body_to_write = body; cloud_spy::fetch_bytes_to_write = std::strlen(body); @@ -467,15 +467,54 @@ TEST_CASE("FETCH parses supported root scalars and ignores cloud policy fields", const GoConfigUpdate &update = f.mock_rtos.last_event.fetch_config.update; REQUIRE(has_go_config_field(update.update_mask, GoConfigField::PmStandard)); REQUIRE(has_go_config_field(update.update_mask, GoConfigField::TemperatureUnit)); + REQUIRE(has_go_config_field(update.update_mask, GoConfigField::MeasurementInterval)); + REQUIRE(has_go_config_field(update.update_mask, GoConfigField::GpsMode)); + REQUIRE(has_go_config_field(update.update_mask, GoConfigField::GpsInterval)); + REQUIRE(has_go_config_field(update.update_mask, GoConfigField::FrontLedBrightness)); + REQUIRE(has_go_config_field(update.update_mask, GoConfigField::BackLedBrightness)); + REQUIRE(has_go_config_field(update.update_mask, GoConfigField::TouchLedIntensity)); + REQUIRE(has_go_config_field(update.update_mask, GoConfigField::BuzzerEnabled)); REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::CloudConnection)); REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::ConfigurationControl)); REQUIRE(update.pm_use_usaqi); REQUIRE(update.use_fahrenheit); + REQUIRE(update.measure_interval_seconds == MEASURE_INTERVAL_SECONDS_MAX); + REQUIRE(update.gps_mode == GpsMode::AlwaysOn); + REQUIRE(update.gps_interval_seconds == GPS_INTERVAL_SECONDS_MAX); + REQUIRE(update.front_led_brightness == LedBrightness::Off); + REQUIRE(update.back_led_brightness == LedBrightness::Bright); + REQUIRE(update.touch_led_intensity == TouchLedIntensity::Bright); + REQUIRE(update.buzzer_enabled); REQUIRE_FALSE(update.disable_cloud); REQUIRE(update.configuration_control == ConfigurationControl::Both); REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::Pm25Correction)); } +TEST_CASE("FETCH rejects malformed device settings independently", + "[CloudService][fetch][config]") { + CloudFixture f; + const char body[] = + R"({"temperatureUnit":"c","measurementInterval":0,"gpsMode":"ALWAYS","gpsInterval":1.5,"frontLedBrightness":4,"backLedBrightness":-1,"touchLedIntensity":3,"buzzerEnabled":"true"})"; + cloud_spy::fetch_body_to_write = body; + cloud_spy::fetch_bytes_to_write = std::strlen(body); + + A::set_armed(f.cloud, true); + A::set_was_armed(f.cloud, true); + A::set_post_due(f.cloud, 999'999'999); + A::set_fetch_due(f.cloud, 0); + A::run_once(f.cloud, 1000); + + const GoConfigUpdate &update = f.mock_rtos.last_event.fetch_config.update; + REQUIRE(has_go_config_field(update.update_mask, GoConfigField::TemperatureUnit)); + REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::MeasurementInterval)); + REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::GpsMode)); + REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::GpsInterval)); + REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::FrontLedBrightness)); + REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::BackLedBrightness)); + REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::TouchLedIntensity)); + REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::BuzzerEnabled)); +} + TEST_CASE("FETCH parses valid ABC days and rejects malformed values independently", "[CloudService][fetch][config]") { CloudFixture f; diff --git a/products/go/tests/go_local_api.tests.cpp b/products/go/tests/go_local_api.tests.cpp index 42f0823..337a8f3 100644 --- a/products/go/tests/go_local_api.tests.cpp +++ b/products/go/tests/go_local_api.tests.cpp @@ -169,6 +169,13 @@ TEST_CASE("Go local API initializes safe snapshots") { REQUIRE(config.temperature_unit.has_value()); REQUIRE(config.cloud_connection.has_value()); REQUIRE(config.configuration_control.has_value()); + REQUIRE(config.measurement_interval_seconds.has_value()); + REQUIRE(config.gps_mode.has_value()); + REQUIRE(config.gps_interval_seconds.has_value()); + REQUIRE(config.front_led_brightness.has_value()); + REQUIRE(config.back_led_brightness.has_value()); + REQUIRE(config.touch_led_intensity.has_value()); + REQUIRE(config.buzzer_enabled.has_value()); REQUIRE(config.co2_abc_days.has_value()); REQUIRE(config.tvoc_learning_offset.has_value()); REQUIRE(config.nox_learning_offset.has_value()); @@ -176,6 +183,13 @@ TEST_CASE("Go local API initializes safe snapshots") { CHECK(*config.temperature_unit == "c"); CHECK(*config.cloud_connection); CHECK(*config.configuration_control == "both"); + CHECK(*config.measurement_interval_seconds == MEASURE_INTERVAL_SECONDS_DEFAULT); + CHECK(*config.gps_mode == "tracking"); + CHECK(*config.gps_interval_seconds == GPS_INTERVAL_SECONDS_DEFAULT); + CHECK(*config.front_led_brightness == static_cast(LedBrightness::Off)); + CHECK(*config.back_led_brightness == static_cast(LedBrightness::Off)); + CHECK(*config.touch_led_intensity == static_cast(TouchLedIntensity::Off)); + CHECK_FALSE(*config.buzzer_enabled); CHECK(*config.co2_abc_days == CO2_ABC_DAYS_DEFAULT); CHECK(*config.tvoc_learning_offset == LEARNING_OFFSET_HOURS_DEFAULT); CHECK(*config.nox_learning_offset == LEARNING_OFFSET_HOURS_DEFAULT); @@ -335,6 +349,13 @@ TEST_CASE("Go local API maps the supported active config subset") { settings.use_fahrenheit = true; settings.disable_cloud = true; settings.configuration_control = ConfigurationControl::Local; + settings.measure_interval_seconds = 30; + settings.gps_mode = GpsMode::AlwaysOn; + settings.gps_interval_seconds = 15; + settings.front_led_brightness = LedBrightness::Dim; + settings.back_led_brightness = LedBrightness::Mid; + settings.touch_led_intensity = TouchLedIntensity::Bright; + settings.buzzer_enabled = true; settings.co2_abc_days = 200; settings.tvoc_learning_offset = 24; settings.nox_learning_offset = 48; @@ -348,6 +369,13 @@ TEST_CASE("Go local API maps the supported active config subset") { CHECK(*config.temperature_unit == "f"); CHECK_FALSE(*config.cloud_connection); CHECK(*config.configuration_control == "local"); + CHECK(config.measurement_interval_seconds == 30); + CHECK(config.gps_mode == "always"); + CHECK(config.gps_interval_seconds == 15); + CHECK(config.front_led_brightness == static_cast(LedBrightness::Dim)); + CHECK(config.back_led_brightness == static_cast(LedBrightness::Mid)); + CHECK(config.touch_led_intensity == static_cast(TouchLedIntensity::Bright)); + CHECK(config.buzzer_enabled == true); CHECK_FALSE(config.country.has_value()); CHECK_FALSE(config.post_data_to_cloud.has_value()); CHECK(config.co2_abc_days == 200); @@ -404,6 +432,13 @@ TEST_CASE("Go local API translates one atomic supported update") { partial.temperature_unit = "f"; partial.cloud_connection = false; partial.configuration_control = "local"; + partial.measurement_interval_seconds = 30; + partial.gps_mode = "off"; + partial.gps_interval_seconds = 15; + partial.front_led_brightness = 1; + partial.back_led_brightness = 2; + partial.touch_led_intensity = 2; + partial.buzzer_enabled = true; Corrections corrections{}; corrections.pm25 = custom_pm25(-1.0, 0.25, true); corrections.temp = custom_linear(2.0, 1.1); @@ -418,12 +453,23 @@ TEST_CASE("Go local API translates one atomic supported update") { field_mask(GoConfigField::PmStandard) | field_mask(GoConfigField::TemperatureUnit) | field_mask(GoConfigField::CloudConnection) | field_mask(GoConfigField::ConfigurationControl) | field_mask(GoConfigField::Pm25Correction) | field_mask(GoConfigField::TemperatureCorrection) | - field_mask(GoConfigField::HumidityCorrection); + field_mask(GoConfigField::HumidityCorrection) | + field_mask(GoConfigField::MeasurementInterval) | field_mask(GoConfigField::GpsMode) | + field_mask(GoConfigField::GpsInterval) | field_mask(GoConfigField::FrontLedBrightness) | + field_mask(GoConfigField::BackLedBrightness) | field_mask(GoConfigField::TouchLedIntensity) | + field_mask(GoConfigField::BuzzerEnabled); CHECK(request.config.update_mask == expected_mask); CHECK(request.config.pm_use_usaqi); CHECK(request.config.use_fahrenheit); CHECK(request.config.disable_cloud); CHECK(request.config.configuration_control == ConfigurationControl::Local); + CHECK(request.config.measure_interval_seconds == 30); + CHECK(request.config.gps_mode == GpsMode::AlwaysOff); + CHECK(request.config.gps_interval_seconds == 15); + CHECK(request.config.front_led_brightness == LedBrightness::Dim); + CHECK(request.config.back_led_brightness == LedBrightness::Mid); + CHECK(request.config.touch_led_intensity == TouchLedIntensity::Bright); + CHECK(request.config.buzzer_enabled); CHECK(request.config.corrections.pm25.algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw); CHECK(request.config.corrections.pm25.intercept == -1.0f); CHECK(request.config.corrections.pm25.scaling_factor == 0.25f); @@ -513,6 +559,9 @@ TEST_CASE("Go local API permits only exact control recovery from cloud control") recovery.pm_standard.reset(); recovery.corrections = Corrections{}; require_status(fixture.service->submit_config(recovery), ConfigSubmitStatus::Forbidden); + recovery.corrections.reset(); + recovery.measurement_interval_seconds = 30; + require_status(fixture.service->submit_config(recovery), ConfigSubmitStatus::Forbidden); LocalServerConfig empty{}; require_status(fixture.service->submit_config(empty), ConfigSubmitStatus::Forbidden); @@ -634,6 +683,47 @@ TEST_CASE("Go local API rejects invalid scalar values and cross-field candidates ConfigFieldId::ConfigurationControl); } +TEST_CASE("Go local API rejects invalid interval GPS and output settings") { + Fixture fixture; + fixture.service->set_access(ConfigAccess::ReadWrite); + + LocalServerConfig partial{}; + partial.measurement_interval_seconds = MEASURE_INTERVAL_SECONDS_MIN - 1; + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, + ConfigFieldId::MeasurementInterval); + partial.measurement_interval_seconds = MEASURE_INTERVAL_SECONDS_MAX + 1; + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, + ConfigFieldId::MeasurementInterval); + + partial = LocalServerConfig{}; + partial.gps_mode = "sometimes"; + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, + ConfigFieldId::GpsMode); + + partial = LocalServerConfig{}; + partial.gps_interval_seconds = GPS_INTERVAL_SECONDS_MIN - 1; + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, + ConfigFieldId::GpsInterval); + partial.gps_interval_seconds = GPS_INTERVAL_SECONDS_MAX + 1; + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, + ConfigFieldId::GpsInterval); + + partial = LocalServerConfig{}; + partial.front_led_brightness = -1; + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, + ConfigFieldId::FrontLedBrightness); + partial = LocalServerConfig{}; + partial.back_led_brightness = static_cast(LedBrightness::Bright) + 1; + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, + ConfigFieldId::BackLedBrightness); + partial = LocalServerConfig{}; + partial.touch_led_intensity = static_cast(TouchLedIntensity::Bright) + 1; + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, + ConfigFieldId::TouchLedIntensity); + + CHECK(GoLocalApiServiceTestAccess::request_count(*fixture.service) == 0); +} + TEST_CASE("Go local API maps CO2 ABC days into config updates") { Fixture fixture; fixture.service->set_access(ConfigAccess::ReadWrite); diff --git a/products/go/tests/go_orchestrator.tests.cpp b/products/go/tests/go_orchestrator.tests.cpp index 7dd7615..1d0ddc9 100644 --- a/products/go/tests/go_orchestrator.tests.cpp +++ b/products/go/tests/go_orchestrator.tests.cpp @@ -2833,8 +2833,8 @@ TEST_CASE("dispatch: routes SensorDataReady to on_sensor_data", "[Orchestrator][ REQUIRE(test_spy::cache_measurement_called); } -TEST_CASE("dispatch: cloud applies supported fields and ignores policy fields", - "[Orchestrator][dispatch][correction]") { +TEST_CASE("dispatch: cloud applies shared config fields and ignores policy fields", + "[Orchestrator][dispatch][config][correction]") { TestFixture f; auto orch = f.make_orchestrator(); @@ -2843,6 +2843,7 @@ TEST_CASE("dispatch: cloud applies supported fields and ignores policy fields", ALLOW_CALL(f.mock_config, set_string(trompeloeil::_, trompeloeil::_)) .RETURN(ConfigStoreResult::OK); ALLOW_CALL(f.mock_config, commit()).RETURN(ConfigStoreResult::OK); + ALLOW_CALL(f.mock_rtos, get_time_ms_impl()).RETURN(9000); MeasuresAGo raw{}; raw.pm_a.pm_25 = 10.0f; @@ -2862,11 +2863,25 @@ TEST_CASE("dispatch: cloud applies supported fields and ignores policy fields", static_cast(GoConfigField::ConfigurationControl) | static_cast(GoConfigField::Pm25Correction) | static_cast(GoConfigField::TemperatureCorrection) | - static_cast(GoConfigField::HumidityCorrection); + static_cast(GoConfigField::HumidityCorrection) | + static_cast(GoConfigField::MeasurementInterval) | + static_cast(GoConfigField::GpsInterval) | + static_cast(GoConfigField::GpsMode) | + static_cast(GoConfigField::FrontLedBrightness) | + static_cast(GoConfigField::BackLedBrightness) | + static_cast(GoConfigField::TouchLedIntensity) | + static_cast(GoConfigField::BuzzerEnabled); evt.fetch_config.update.pm_use_usaqi = true; evt.fetch_config.update.use_fahrenheit = true; evt.fetch_config.update.disable_cloud = true; evt.fetch_config.update.configuration_control = ConfigurationControl::Local; + evt.fetch_config.update.measure_interval_seconds = 30; + evt.fetch_config.update.gps_interval_seconds = 15; + evt.fetch_config.update.gps_mode = GpsMode::AlwaysOn; + evt.fetch_config.update.front_led_brightness = LedBrightness::Dim; + evt.fetch_config.update.back_led_brightness = LedBrightness::Mid; + evt.fetch_config.update.touch_led_intensity = TouchLedIntensity::Bright; + evt.fetch_config.update.buzzer_enabled = true; evt.fetch_config.update.corrections.pm25.algorithm = Pm25CorrectionAlgorithm::CustomViaPm25Raw; evt.fetch_config.update.corrections.pm25.scaling_factor = 2.0f; evt.fetch_config.update.corrections.pm25.intercept = 1.0f; @@ -2885,6 +2900,15 @@ TEST_CASE("dispatch: cloud applies supported fields and ignores policy fields", CHECK(A::settings(orch).use_fahrenheit); CHECK_FALSE(A::settings(orch).disable_cloud); CHECK(A::settings(orch).configuration_control == ConfigurationControl::Both); + CHECK(A::settings(orch).measure_interval_seconds == 30); + CHECK(A::settings(orch).gps_interval_seconds == 15); + CHECK(A::settings(orch).gps_mode == GpsMode::AlwaysOn); + CHECK(A::settings(orch).front_led_brightness == LedBrightness::Dim); + CHECK(A::settings(orch).back_led_brightness == LedBrightness::Mid); + CHECK(A::settings(orch).touch_led_intensity == TouchLedIntensity::Bright); + CHECK(A::settings(orch).buzzer_enabled); + CHECK(test_spy::gps_posting_interval_ms == 15000); + CHECK(test_spy::gps_started); REQUIRE(A::corrected_measures(orch).pm_a.pm_25 == 21.0f); REQUIRE(A::corrected_measures(orch).temp_hum_a.temperature == 29.0f); REQUIRE(A::corrected_measures(orch).temp_hum_a.humidity == 47.0f); diff --git a/products/go/tests/go_settings.tests.cpp b/products/go/tests/go_settings.tests.cpp index 542772b..71cde8f 100644 --- a/products/go/tests/go_settings.tests.cpp +++ b/products/go/tests/go_settings.tests.cpp @@ -251,6 +251,13 @@ TEST_CASE("shared Go config fields and update model", "[settings][config]") { REQUIRE(static_cast(GoConfigField::Co2AbcDays) == (1U << 7)); REQUIRE(static_cast(GoConfigField::TvocLearningOffset) == (1U << 8)); REQUIRE(static_cast(GoConfigField::NoxLearningOffset) == (1U << 9)); + REQUIRE(static_cast(GoConfigField::MeasurementInterval) == (1U << 10)); + REQUIRE(static_cast(GoConfigField::GpsInterval) == (1U << 11)); + REQUIRE(static_cast(GoConfigField::GpsMode) == (1U << 12)); + REQUIRE(static_cast(GoConfigField::FrontLedBrightness) == (1U << 13)); + REQUIRE(static_cast(GoConfigField::BackLedBrightness) == (1U << 14)); + REQUIRE(static_cast(GoConfigField::TouchLedIntensity) == (1U << 15)); + REQUIRE(static_cast(GoConfigField::BuzzerEnabled) == (1U << 16)); const uint32_t mask = static_cast(GoConfigField::CloudConnection) | static_cast(GoConfigField::HumidityCorrection); @@ -259,6 +266,34 @@ TEST_CASE("shared Go config fields and update model", "[settings][config]") { REQUIRE_FALSE(has_go_config_field(mask, GoConfigField::TemperatureUnit)); } +TEST_CASE("shared Go config validation covers interface-managed fields", "[settings][config]") { + REQUIRE(is_measure_interval_seconds_valid(MEASURE_INTERVAL_SECONDS_MIN)); + REQUIRE(is_measure_interval_seconds_valid(MEASURE_INTERVAL_SECONDS_MAX)); + REQUIRE_FALSE(is_measure_interval_seconds_valid(MEASURE_INTERVAL_SECONDS_MIN - 1)); + REQUIRE_FALSE(is_measure_interval_seconds_valid(MEASURE_INTERVAL_SECONDS_MAX + 1)); + + REQUIRE(is_gps_interval_seconds_valid(GPS_INTERVAL_SECONDS_MIN)); + REQUIRE(is_gps_interval_seconds_valid(GPS_INTERVAL_SECONDS_MAX)); + REQUIRE_FALSE(is_gps_interval_seconds_valid(GPS_INTERVAL_SECONDS_MIN - 1)); + REQUIRE_FALSE(is_gps_interval_seconds_valid(GPS_INTERVAL_SECONDS_MAX + 1)); + + REQUIRE(is_gps_mode_valid(static_cast(GpsMode::AlwaysOff))); + REQUIRE(is_gps_mode_valid(static_cast(GpsMode::OnWhenTracking))); + REQUIRE(is_gps_mode_valid(static_cast(GpsMode::AlwaysOn))); + REQUIRE_FALSE(is_gps_mode_valid(-1)); + REQUIRE_FALSE(is_gps_mode_valid(3)); + + REQUIRE(is_led_brightness_valid(static_cast(LedBrightness::Off))); + REQUIRE(is_led_brightness_valid(static_cast(LedBrightness::Bright))); + REQUIRE_FALSE(is_led_brightness_valid(-1)); + REQUIRE_FALSE(is_led_brightness_valid(4)); + + REQUIRE(is_touch_led_intensity_valid(static_cast(TouchLedIntensity::Off))); + REQUIRE(is_touch_led_intensity_valid(static_cast(TouchLedIntensity::Bright))); + REQUIRE_FALSE(is_touch_led_intensity_valid(-1)); + REQUIRE_FALSE(is_touch_led_intensity_valid(3)); +} + TEST_CASE("round-trip preserves each ConfigurationControl value", "[settings][config]") { FakeConfigStore store; GoSettings settings; diff --git a/products/go/tests/local-server-integration/README.md b/products/go/tests/local-server-integration/README.md index c629281..7c0c2a1 100644 --- a/products/go/tests/local-server-integration/README.md +++ b/products/go/tests/local-server-integration/README.md @@ -3,7 +3,7 @@ Hardware integration tests for the AirGradient Go Local Server API. The suite connects to a real device in Stationary mode and verifies mDNS discovery, HTTP routes, JSON schemas, configuration admission, structured errors, actions, and -OTA access policy. Pytest collects 24 tests across the suite. +OTA access policy. ## Prerequisites @@ -52,7 +52,7 @@ pytest products/go/tests/local-server-integration/test_measures.py -v \ | `--ago-discovery-timeout` | `10` | mDNS discovery timeout in seconds | | `--ago-http-timeout` | `5` | Per-request HTTP timeout in seconds | | `--ago-convergence-timeout` | `20` | Config convergence timeout in seconds | -| `--ago-allow-config-write` | off | Enable persisted toggle and restoration | +| `--ago-allow-config-write` | off | Enable persisted config round trips and restoration | | `--ago-allow-calibration` | off | Enable physical CO2 calibration | | `--ago-ota-active` | off | Confirm committed OTA is active | @@ -71,18 +71,28 @@ pytest products/go/tests/local-server-integration/ -v \ The default suite does not change durable configuration. It submits only an empty configuration update, malformed requests, and the unsupported LED action. -Persisted mutation tests require `--ago-allow-config-write`. They toggle only -`temperatureUnit`, poll the asynchronous GET snapshot for convergence, and -restore the original value during fixture teardown. They skip when -`configurationControl` is `cloud`. +Persisted mutation tests require `--ago-allow-config-write`. They round-trip the +temperature unit, measurement and GPS settings, three LED levels, buzzer, CO2 +ABC period, and TVOC/NOx learning offsets. Each parameterized case changes one +field, polls the asynchronous GET snapshot for convergence, and restores that +field during fixture teardown. They run only when `configurationControl` is +`local`, preventing cloud updates from racing with restoration. + +Extended invalid-value cases use the same opt-in and restoration fixture. If a +firmware regression accepts and persists an invalid value, teardown restores the +baseline by enqueuing the original value after the test request. + +These tests can briefly change measurement cadence, GPS operation, indicator +brightness, buzzer enablement, and sensor algorithm configuration. Run them only +on a dedicated device and do not use `pytest-xdist`. CO2 calibration is physical and fire-and-forget over HTTP. It runs only with `--ago-allow-calibration`; the HTTP response confirms queue admission, not dispatch to the sensor, calibration start, or completion. Do not run it without appropriate calibration conditions. -Do not run this suite with `pytest-xdist`. Configuration tests assume exclusive -access to one device. +The suite rejects parallel `pytest-xdist` execution because configuration tests +assume exclusive access to one device. ## Test Overview @@ -101,12 +111,14 @@ unknown-key rejection, and omission of invalid values rather than JSON `null`. ### `test_config.py` — Configuration Validates the complete Go configuration schema and safe empty-update admission. -The opt-in round-trip test toggles and restores `temperatureUnit`. +Opt-in parameterized cases round-trip and restore every remotely exposed timing, +GPS, LED, buzzer, CO2 ABC, and gas-learning setting. ### `test_errors.py` — Error Contract Exercises empty, malformed, non-object, and trailing request bodies; unknown and -invalid fields; nested dotted error paths; and known fields unsupported by Go. +invalid fields; extended config type/range rejection; nested dotted error paths; +and known fields unsupported by Go. ### `test_actions.py` — Actions @@ -164,8 +176,8 @@ products/go/tests/local-server-integration/ ## Coverage Limits -The suite currently has collection evidence only: all 24 tests collect, but no -physical AirGradient Go run has been recorded. The native host suite passed +The suite currently has collection evidence only; no physical AirGradient Go run +has been recorded. The native host suite passed `1240/1240` tests and the Go firmware build passed at revision `c17f2d3`; those checks do not replace hardware execution. diff --git a/products/go/tests/local-server-integration/ago_local_api.py b/products/go/tests/local-server-integration/ago_local_api.py index 5e35367..f42d5cd 100644 --- a/products/go/tests/local-server-integration/ago_local_api.py +++ b/products/go/tests/local-server-integration/ago_local_api.py @@ -48,12 +48,33 @@ "temperatureUnit", "cloudConnection", "configurationControl", + "measurementInterval", + "gpsMode", + "gpsInterval", + "frontLedBrightness", + "backLedBrightness", + "touchLedIntensity", + "buzzerEnabled", "co2AbcDays", + "tvocLearningOffset", + "noxLearningOffset", "corrections", } CORRECTION_KEYS = {"pm25", "temp", "humidity"} -SAFE_CONFIG_FIELDS = ("pmStandard", "temperatureUnit") +CONFIG_ROUND_TRIP_VALUES: dict[str, tuple[object, object]] = { + "temperatureUnit": ("c", "f"), + "measurementInterval": (1, 2), + "gpsMode": ("off", "tracking"), + "gpsInterval": (1, 2), + "frontLedBrightness": (0, 1), + "backLedBrightness": (0, 1), + "touchLedIntensity": (0, 1), + "buzzerEnabled": (False, True), + "co2AbcDays": (-1, 1), + "tvocLearningOffset": (1, 2), + "noxLearningOffset": (1, 2), +} _SERIAL_PATTERN = re.compile(r"^[0-9a-f]{12}$") _MISSING = object() @@ -209,8 +230,17 @@ def validate_config(payload: dict[str, Any]) -> None: assert payload["temperatureUnit"] in {"c", "f"} assert isinstance(payload["cloudConnection"], bool) assert payload["configurationControl"] in {"cloud", "local", "both"} + _assert_integer(payload["measurementInterval"], 1, 3600) + assert payload["gpsMode"] in {"off", "tracking", "always"} + _assert_integer(payload["gpsInterval"], 1, 60) + _assert_integer(payload["frontLedBrightness"], 0, 3) + _assert_integer(payload["backLedBrightness"], 0, 3) + _assert_integer(payload["touchLedIntensity"], 0, 2) + assert isinstance(payload["buzzerEnabled"], bool) _assert_integer(payload["co2AbcDays"], -1, 200) assert payload["co2AbcDays"] == -1 or payload["co2AbcDays"] >= 1 + _assert_integer(payload["tvocLearningOffset"], 1, 1000) + _assert_integer(payload["noxLearningOffset"], 1, 1000) corrections = payload["corrections"] assert isinstance(corrections, dict) @@ -241,7 +271,9 @@ def put_and_wait( stable_since: float | None = None while time.monotonic() < deadline: now = time.monotonic() - if get_config(client).get(field) == value: + payload = assert_json_response(client.get(CONFIG_PATH)) + if payload.get(field) == value: + validate_config(payload) if stable_since is None: stable_since = now if now - stable_since >= stable_duration: @@ -252,3 +284,9 @@ def put_and_wait( raise AssertionError( f"{field} did not converge to {value!r} within {convergence_timeout}s" ) + + +def alternate_config_value(field: str, current: object) -> object: + """Return a valid test value that differs from the current field value.""" + first, second = CONFIG_ROUND_TRIP_VALUES[field] + return second if current == first else first diff --git a/products/go/tests/local-server-integration/conftest.py b/products/go/tests/local-server-integration/conftest.py index ae009ff..9a35db7 100644 --- a/products/go/tests/local-server-integration/conftest.py +++ b/products/go/tests/local-server-integration/conftest.py @@ -99,7 +99,7 @@ def pytest_addoption(parser: pytest.Parser) -> None: group.addoption( "--ago-allow-config-write", action="store_true", - help="Enable persisted safe-field toggle and restoration tests.", + help="Enable persisted config round-trip and restoration tests.", ) group.addoption( "--ago-allow-calibration", @@ -113,6 +113,15 @@ def pytest_addoption(parser: pytest.Parser) -> None: ) +def pytest_configure(config: pytest.Config) -> None: + """Reject parallel execution because config restoration is stateful.""" + workers = config.getoption("numprocesses", default=None) + if workers not in (None, 0, "0"): + raise pytest.UsageError( + "Local Server integration tests require serial execution; remove pytest-xdist -n" + ) + + def _decode_properties(info: ServiceInfo) -> dict[str, str]: properties: dict[str, str] = {} for raw_key, raw_value in info.properties.items(): @@ -307,35 +316,47 @@ def ago_convergence_timeout(request: pytest.FixtureRequest) -> float: @pytest.fixture -def preserved_safe_config( +def preserved_config_setting( request: pytest.FixtureRequest, ago_http_client: httpx.Client, ago_convergence_timeout: float, -) -> Generator[dict[str, object], None, None]: +) -> Generator[tuple[str, object, object], None, None]: if not request.config.getoption("--ago-allow-config-write"): pytest.skip("requires --ago-allow-config-write") + parameter = request.param + requested_value: object | None = None + if isinstance(parameter, tuple) and len(parameter) == 2: + field, requested_value = parameter + else: + field = parameter + if not isinstance(field, str) or field not in api.CONFIG_ROUND_TRIP_VALUES: + raise ValueError(f"unsupported config restoration field: {field!r}") + baseline = api.get_config(ago_http_client) - if baseline["configurationControl"] == "cloud": - pytest.skip("local config writes are disabled by configurationControl=cloud") + if baseline["configurationControl"] != "local": + pytest.skip("config mutation tests require configurationControl=local") + + original = baseline[field] + owned_value = ( + api.alternate_config_value(field, original) + if requested_value is None + else requested_value + ) try: - yield baseline + yield field, original, owned_value finally: - failures: list[Exception] = [] - for field in api.SAFE_CONFIG_FIELDS: - try: - api.put_and_wait( - ago_http_client, - field, - baseline[field], - ago_convergence_timeout, - stable_duration=RESTORE_STABILITY_SECONDS, - ) - except Exception as error: - failures.append(error) - if failures: - raise failures[0] + # Always enqueue the baseline after the test request. If a regression + # accepted an invalid write asynchronously but GET has not reflected it + # yet, FIFO admission still leaves the baseline as the final update. + api.put_and_wait( + ago_http_client, + field, + original, + ago_convergence_timeout, + stable_duration=RESTORE_STABILITY_SECONDS, + ) @pytest.fixture diff --git a/products/go/tests/local-server-integration/test_config.py b/products/go/tests/local-server-integration/test_config.py index fd66ce9..e414c35 100644 --- a/products/go/tests/local-server-integration/test_config.py +++ b/products/go/tests/local-server-integration/test_config.py @@ -26,16 +26,20 @@ def test_empty_config_is_accepted( @pytest.mark.config_write -def test_temperature_unit_round_trip( +@pytest.mark.parametrize( + "preserved_config_setting", + tuple(api.CONFIG_ROUND_TRIP_VALUES), + indirect=True, +) +def test_config_field_round_trip( ago_http_client: httpx.Client, - preserved_safe_config: dict[str, object], + preserved_config_setting: tuple[str, object, object], ago_convergence_timeout: float, ) -> None: - original = preserved_safe_config["temperatureUnit"] - updated = "f" if original == "c" else "c" + field, _original, updated = preserved_config_setting api.put_and_wait( ago_http_client, - "temperatureUnit", + field, updated, ago_convergence_timeout, ) diff --git a/products/go/tests/local-server-integration/test_errors.py b/products/go/tests/local-server-integration/test_errors.py index d70b1a2..adf468f 100644 --- a/products/go/tests/local-server-integration/test_errors.py +++ b/products/go/tests/local-server-integration/test_errors.py @@ -38,6 +38,40 @@ def test_invalid_enum(ago_http_client: httpx.Client) -> None: ) +@pytest.mark.config_write +@pytest.mark.parametrize( + "preserved_config_setting", + [ + pytest.param(("measurementInterval", 0), id="measurementInterval-range"), + pytest.param(("measurementInterval", 1.5), id="measurementInterval-type"), + pytest.param(("gpsMode", "sometimes"), id="gpsMode"), + pytest.param(("gpsInterval", 0), id="gpsInterval"), + pytest.param(("frontLedBrightness", 4), id="frontLedBrightness"), + pytest.param(("backLedBrightness", 4), id="backLedBrightness"), + pytest.param(("touchLedIntensity", 3), id="touchLedIntensity"), + pytest.param(("buzzerEnabled", 1), id="buzzerEnabled-type"), + pytest.param(("co2AbcDays", 0), id="co2AbcDays"), + pytest.param(("tvocLearningOffset", 0), id="tvocLearningOffset"), + pytest.param(("noxLearningOffset", 0), id="noxLearningOffset"), + ], + indirect=True, +) +def test_invalid_extended_config_value( + ago_http_client: httpx.Client, + preserved_config_setting: tuple[str, object, object], +) -> None: + field, _original, value = preserved_config_setting + + response = ago_http_client.put(api.CONFIG_PATH, json={field: value}) + api.assert_error( + response, + 400, + "invalid_value", + "invalid value", + field=field, + ) + + def test_unknown_nested_correction_field(ago_http_client: httpx.Client) -> None: response = ago_http_client.put( api.CONFIG_PATH, diff --git a/products/reference/main/test_local_server.cpp b/products/reference/main/test_local_server.cpp index 95a1a96..632d7aa 100644 --- a/products/reference/main/test_local_server.cpp +++ b/products/reference/main/test_local_server.cpp @@ -220,6 +220,29 @@ class DemoConfigProvider : public ConfigProvider { LocalServerConfig get_config() override { return _cfg; } ConfigSubmitResult submit_config(const LocalServerConfig &p) override { + // Go-specific catalog fields are recognized but unsupported by this demo. + if (p.measurement_interval_seconds.has_value()) { + return {ConfigSubmitStatus::NotSupported, ConfigFieldId::MeasurementInterval}; + } + if (p.gps_mode.has_value()) { + return {ConfigSubmitStatus::NotSupported, ConfigFieldId::GpsMode}; + } + if (p.gps_interval_seconds.has_value()) { + return {ConfigSubmitStatus::NotSupported, ConfigFieldId::GpsInterval}; + } + if (p.front_led_brightness.has_value()) { + return {ConfigSubmitStatus::NotSupported, ConfigFieldId::FrontLedBrightness}; + } + if (p.back_led_brightness.has_value()) { + return {ConfigSubmitStatus::NotSupported, ConfigFieldId::BackLedBrightness}; + } + if (p.touch_led_intensity.has_value()) { + return {ConfigSubmitStatus::NotSupported, ConfigFieldId::TouchLedIntensity}; + } + if (p.buzzer_enabled.has_value()) { + return {ConfigSubmitStatus::NotSupported, ConfigFieldId::BuzzerEnabled}; + } + // 1) Validate ALL present fields first. if (p.country.has_value() && p.country->size() != COUNTRY_CODE_LEN) { return {ConfigSubmitStatus::InvalidValue, ConfigFieldId::CountryCode};