From 32343d9dc41fdd075265d0b47e3d89abd38d192f Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 7 Aug 2026 01:05:52 +0700 Subject: [PATCH 1/3] feat(go): add cloud GPS test trigger --- products/go/ARCHITECTURE.md | 5 +- products/go/docs/cloud_service.md | 24 +++++---- products/go/docs/hardware_test.md | 6 +++ products/go/main/go_cloud.cpp | 6 +++ products/go/main/go_cloud_types.h | 1 + products/go/main/go_orchestrator.cpp | 9 ++++ products/go/tests/go_cloud.tests.cpp | 11 ++-- products/go/tests/go_orchestrator.tests.cpp | 59 ++++++++++++++++++++- 8 files changed, 104 insertions(+), 17 deletions(-) diff --git a/products/go/ARCHITECTURE.md b/products/go/ARCHITECTURE.md index ee1a116..cd39ec5 100644 --- a/products/go/ARCHITECTURE.md +++ b/products/go/ARCHITECTURE.md @@ -906,8 +906,9 @@ snapshot ownership, queue semantics, and lifecycle details. - Cloud Fetch can update PM standard, temperature unit, `abcDays`, and PM2.5, temperature, and humidity corrections. It does not own `cloudConnection` or `configurationControl`, even if those fields appear in a response -- True `co2CalibrationRequested` and `ledTestRequested` Fetch fields are carried - outside persistent settings and dispatch calibration or the LED diagnostic +- True `co2CalibrationRequested`, `ledTestRequested`, and `gpsTestRequested` + Fetch fields are carried outside persistent settings and dispatch calibration, + the LED diagnostic, or direct navigation to the live GPS Test screen `configurationControl` governs the two competing remote config sources: diff --git a/products/go/docs/cloud_service.md b/products/go/docs/cloud_service.md index 357944a..f41cdd6 100644 --- a/products/go/docs/cloud_service.md +++ b/products/go/docs/cloud_service.md @@ -170,6 +170,7 @@ and queues a value-only `FetchConfigEventPayload`. Supported fields map into | `buzzerEnabled` | Boolean | `buzzer_enabled` | | `co2CalibrationRequested` | Boolean | One-shot CO2 calibration request; not persisted | | `ledTestRequested` | Boolean | One-shot LED diagnostic request; not persisted | +| `gpsTestRequested` | Boolean | One-shot GPS test screen request; not persisted | Sensor and correction fields are: @@ -199,12 +200,15 @@ malformed roots, and trailing non-whitespace data produce an empty update mask. `co2CalibrationRequested: true` queues background CO2 calibration through the sensor producer. `ledTestRequested: true` then runs the three-second LED -diagnostic. If both are true, calibration is queued before the blocking LED -test. `false`, a missing field, or a non-boolean value does nothing. The parser -stores both values directly in `FetchConfigEventPayload`; they never enter -`GoConfigUpdate`, settings, or NVS. The firmware performs no edge detection -because the backend returns `true` once per request and then returns `false` -until another request is made. +diagnostic. `gpsTestRequested: true` finally opens the live GPS Test screen and +starts its receiver, fast-posting, and TTFF behavior. This ordering lets all +three actions run when one response requests them. A GPS trigger is ignored if +the Peripheral or Accelerometer test is active, and is a no-op if the GPS Test +screen is already open. `false`, a missing field, or a non-boolean value does +nothing. The parser stores the action values directly in +`FetchConfigEventPayload`; they never enter `GoConfigUpdate`, settings, or NVS. +The firmware performs no edge detection because the backend returns `true` once +per request and then returns `false` until another request is made. Cloud result delivery is best-effort. A full central queue drops the zero-wait event, so no update is applied; the next periodic FETCH is the next retry @@ -212,10 +216,10 @@ opportunity. The orchestrator rechecks `configuration_control` when it consumes the result. If control changes to `Local` while HTTP is in flight, the successful event is -discarded without persistence, runtime changes, calibration, or an LED -diagnostic. Re-enabling Cloud/Both calls `set_config_fetch_enabled(true)`, making -the next FETCH immediately due when the task is armed and cloud transport is -enabled. +discarded without persistence, runtime changes, calibration, an LED diagnostic, +or GPS Test navigation. Re-enabling Cloud/Both calls +`set_config_fetch_enabled(true)`, making the next FETCH immediately due when the +task is armed and cloud transport is enabled. ### OTA Interaction diff --git a/products/go/docs/hardware_test.md b/products/go/docs/hardware_test.md index 27515a8..56b5e79 100644 --- a/products/go/docs/hardware_test.md +++ b/products/go/docs/hardware_test.md @@ -111,6 +111,12 @@ LED marks the fix. The screen shows TTFF (`mm:ss`), fix type, satellites, HDOP, latitude/longitude, and UTC. On exit the posting cadence and back LED are restored, and the receiver is stopped only if the test ungated it. +A cloud Fetch response can set `gpsTestRequested: true` to open this screen +without manual Settings navigation. The orchestrator handles it after the CO2 +calibration and LED-test action flags from the same response. It ignores the +request while the Peripheral or Accelerometer test is active and treats it as a +no-op when the GPS Test screen is already open. + See [`gps_service.md`](gps_service.md) for the receiver lifecycle. ### Accelerometer Test diff --git a/products/go/main/go_cloud.cpp b/products/go/main/go_cloud.cpp index 75f1ac1..b9af958 100644 --- a/products/go/main/go_cloud.cpp +++ b/products/go/main/go_cloud.cpp @@ -58,6 +58,7 @@ constexpr const char *JSON_TOUCH_LED_INTENSITY = "touchLedIntensity"; constexpr const char *JSON_BUZZER_ENABLED = "buzzerEnabled"; constexpr const char *JSON_CO2_CALIBRATION_REQUESTED = "co2CalibrationRequested"; constexpr const char *JSON_LED_TEST_REQUESTED = "ledTestRequested"; +constexpr const char *JSON_GPS_TEST_REQUESTED = "gpsTestRequested"; constexpr const char *JSON_ABC_DAYS = "abcDays"; constexpr const char *JSON_TVOC_LEARNING_OFFSET = "tvocLearningOffset"; constexpr const char *JSON_NOX_LEARNING_OFFSET = "noxLearningOffset"; @@ -377,6 +378,11 @@ FetchConfigEventPayload parse_cloud_config(const char *buffer, size_t bytes) { (void)parse_bool(led_test_requested, JSON_LED_TEST_REQUESTED, payload.led_test_requested); } + const cJSON *gps_test_requested = cJSON_GetObjectItemCaseSensitive(root, JSON_GPS_TEST_REQUESTED); + if (gps_test_requested != nullptr) { + (void)parse_bool(gps_test_requested, JSON_GPS_TEST_REQUESTED, payload.gps_test_requested); + } + 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_cloud_types.h b/products/go/main/go_cloud_types.h index 3bdc961..9a4f29e 100644 --- a/products/go/main/go_cloud_types.h +++ b/products/go/main/go_cloud_types.h @@ -26,6 +26,7 @@ struct FetchConfigEventPayload { GoConfigUpdate update{}; bool co2_calibration_requested = false; bool led_test_requested = false; + bool gps_test_requested = false; }; static_assert(std::is_trivially_copyable::value, diff --git a/products/go/main/go_orchestrator.cpp b/products/go/main/go_orchestrator.cpp index a78ba5c..0f2ac28 100644 --- a/products/go/main/go_orchestrator.cpp +++ b/products/go/main/go_orchestrator.cpp @@ -770,6 +770,15 @@ void Orchestrator::handle_cloud_action_requests(const FetchConfigEventPayload &p if (payload.led_test_requested) { run_led_test(); } + if (payload.gps_test_requested) { + const Screen current_screen = _svc.ui_manager.current_screen(); + if (current_screen == Screen::PeripheralTest || current_screen == Screen::AccelTest) { + AG_LOGW(TAG, "GPS test trigger ignored: another hardware test is active"); + } else if (current_screen != Screen::GpsTest) { + _svc.ui_manager.set_screen(Screen::GpsTest); + start_gps_test(); + } + } } void Orchestrator::on_local_api_request(uint32_t event_epoch) { diff --git a/products/go/tests/go_cloud.tests.cpp b/products/go/tests/go_cloud.tests.cpp index cfd76ab..54ee818 100644 --- a/products/go/tests/go_cloud.tests.cpp +++ b/products/go/tests/go_cloud.tests.cpp @@ -374,6 +374,7 @@ TEST_CASE("FETCH forwards AgClientResult into FetchConfigResult event", "[CloudS REQUIRE(f.mock_rtos.last_event.fetch_config.update.update_mask == 0); REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.co2_calibration_requested); REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.led_test_requested); + REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.gps_test_requested); REQUIRE(wake == 0); } @@ -456,7 +457,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","measurementInterval":3600,"gpsMode":"always","frontLedBrightness":0,"backLedBrightness":3,"touchLedIntensity":2,"buzzerEnabled":true,"co2CalibrationRequested":true,"ledTestRequested":true,"disableCloudConnection":true,"configurationControl":"local","corrections":[]})"; + R"({"pmStandard":"us-aqi","temperatureUnit":"f","measurementInterval":3600,"gpsMode":"always","frontLedBrightness":0,"backLedBrightness":3,"touchLedIntensity":2,"buzzerEnabled":true,"co2CalibrationRequested":true,"ledTestRequested":true,"gpsTestRequested":true,"disableCloudConnection":true,"configurationControl":"local","corrections":[]})"; cloud_spy::fetch_body_to_write = body; cloud_spy::fetch_bytes_to_write = std::strlen(body); @@ -487,24 +488,27 @@ TEST_CASE("FETCH parses supported root scalars and ignores cloud policy fields", REQUIRE(update.buzzer_enabled); REQUIRE(f.mock_rtos.last_event.fetch_config.co2_calibration_requested); REQUIRE(f.mock_rtos.last_event.fetch_config.led_test_requested); + REQUIRE(f.mock_rtos.last_event.fetch_config.gps_test_requested); REQUIRE_FALSE(update.disable_cloud); REQUIRE(update.configuration_control == ConfigurationControl::Both); REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::Pm25Correction)); - const char cleared_body[] = R"({"co2CalibrationRequested":false,"ledTestRequested":false})"; + const char cleared_body[] = + R"({"co2CalibrationRequested":false,"ledTestRequested":false,"gpsTestRequested":false})"; cloud_spy::fetch_body_to_write = cleared_body; cloud_spy::fetch_bytes_to_write = std::strlen(cleared_body); A::set_fetch_due(f.cloud, 0); A::run_once(f.cloud, 1500); REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.co2_calibration_requested); REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.led_test_requested); + REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.gps_test_requested); } TEST_CASE("FETCH rejects malformed device settings independently", "[CloudService][fetch][config]") { CloudFixture f; const char body[] = - R"({"temperatureUnit":"c","measurementInterval":0,"gpsMode":"ALWAYS","frontLedBrightness":4,"backLedBrightness":-1,"touchLedIntensity":3,"buzzerEnabled":"true","co2CalibrationRequested":"true","ledTestRequested":1})"; + R"({"temperatureUnit":"c","measurementInterval":0,"gpsMode":"ALWAYS","frontLedBrightness":4,"backLedBrightness":-1,"touchLedIntensity":3,"buzzerEnabled":"true","co2CalibrationRequested":"true","ledTestRequested":1,"gpsTestRequested":"true"})"; cloud_spy::fetch_body_to_write = body; cloud_spy::fetch_bytes_to_write = std::strlen(body); @@ -524,6 +528,7 @@ TEST_CASE("FETCH rejects malformed device settings independently", REQUIRE_FALSE(has_go_config_field(update.update_mask, GoConfigField::BuzzerEnabled)); REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.co2_calibration_requested); REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.led_test_requested); + REQUIRE_FALSE(f.mock_rtos.last_event.fetch_config.gps_test_requested); } TEST_CASE("FETCH parses valid ABC days and rejects malformed values independently", diff --git a/products/go/tests/go_orchestrator.tests.cpp b/products/go/tests/go_orchestrator.tests.cpp index 4e9332b..b8b3596 100644 --- a/products/go/tests/go_orchestrator.tests.cpp +++ b/products/go/tests/go_orchestrator.tests.cpp @@ -2935,7 +2935,7 @@ TEST_CASE("dispatch: cloud applies shared config fields and ignores policy field CHECK(test_spy::cloud_set_fetch_enabled_count == 0); } -TEST_CASE("dispatch: Cloud Fetch actions queue calibration before the LED test", +TEST_CASE("dispatch: Cloud Fetch actions run calibration and LED test before opening GPS test", "[Orchestrator][dispatch][cloud][action]") { TestFixture f; auto orch = f.make_orchestrator(); @@ -2945,16 +2945,24 @@ TEST_CASE("dispatch: Cloud Fetch actions queue calibration before the LED test", evt.fetch_config.result = static_cast(AgClientResult::Ok); evt.fetch_config.co2_calibration_requested = true; evt.fetch_config.led_test_requested = true; + evt.fetch_config.gps_test_requested = true; bool calibration_queued_before_led_test = false; + bool gps_closed_during_led_test = false; REQUIRE_CALL(f.mock_rtos, delay_ms_impl(3000)) - .LR_SIDE_EFFECT(calibration_queued_before_led_test = test_spy::co2_calibration_requested); + .LR_SIDE_EFFECT(calibration_queued_before_led_test = test_spy::co2_calibration_requested; + gps_closed_during_led_test = + f.ui_manager.current_screen() != Screen::GpsTest;); A::dispatch(orch, evt); CHECK(test_spy::co2_calibration_requested); CHECK(calibration_queued_before_led_test); + CHECK(gps_closed_during_led_test); CHECK(test_spy::led_back_play_count == 1); CHECK(test_spy::led_touch_all_on_seen); + CHECK(f.ui_manager.current_screen() == Screen::GpsTest); + CHECK(test_spy::gps_started); + CHECK(test_spy::gps_posting_interval_ms == 1000); } TEST_CASE("dispatch: Cloud Fetch actions respect local-only control", @@ -2968,11 +2976,58 @@ TEST_CASE("dispatch: Cloud Fetch actions respect local-only control", evt.fetch_config.result = static_cast(AgClientResult::Ok); evt.fetch_config.co2_calibration_requested = true; evt.fetch_config.led_test_requested = true; + evt.fetch_config.gps_test_requested = true; A::dispatch(orch, evt); CHECK_FALSE(test_spy::co2_calibration_requested); CHECK(test_spy::led_back_play_count == 0); CHECK_FALSE(test_spy::led_touch_all_on_seen); + CHECK(f.ui_manager.current_screen() == Screen::Home); + CHECK_FALSE(test_spy::gps_started); +} + +TEST_CASE("dispatch: Cloud GPS test trigger does not interrupt another hardware test", + "[Orchestrator][dispatch][cloud][action][gps-test]") { + TestFixture f; + auto orch = f.make_orchestrator(); + + Event evt{}; + evt.type = EventType::FetchConfigResult; + evt.fetch_config.result = static_cast(AgClientResult::Ok); + evt.fetch_config.gps_test_requested = true; + + SECTION("peripheral test") { + f.ui_manager.set_screen(Screen::PeripheralTest); + A::dispatch(orch, evt); + + CHECK(f.ui_manager.current_screen() == Screen::PeripheralTest); + CHECK_FALSE(test_spy::gps_started); + } + + SECTION("accelerometer test") { + f.ui_manager.set_screen(Screen::AccelTest); + A::dispatch(orch, evt); + + CHECK(f.ui_manager.current_screen() == Screen::AccelTest); + CHECK_FALSE(test_spy::gps_started); + } +} + +TEST_CASE("dispatch: Cloud GPS test trigger is idempotent while GPS test is open", + "[Orchestrator][dispatch][cloud][action][gps-test]") { + TestFixture f; + auto orch = f.make_orchestrator(); + f.ui_manager.set_screen(Screen::GpsTest); + + Event evt{}; + evt.type = EventType::FetchConfigResult; + evt.fetch_config.result = static_cast(AgClientResult::Ok); + evt.fetch_config.gps_test_requested = true; + A::dispatch(orch, evt); + + CHECK(f.ui_manager.current_screen() == Screen::GpsTest); + CHECK_FALSE(test_spy::gps_started); + CHECK(test_spy::gps_posting_interval_ms == 0); } TEST_CASE("dispatch: cloud ABC days persists before requesting sensor application", From c46ec843bb4ab7f93d8d684f5340265a2f315ba6 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 7 Aug 2026 01:32:44 +0700 Subject: [PATCH 2/3] feat(go): add local GPS test action --- .../services/local_server.cpp | 7 ++++++ .../services/local_server.h | 2 +- .../tests/fake_providers.h | 11 +++++++- .../tests/handler.tests.cpp | 15 ++++++++++- .../types/local_server_result.h | 2 +- docs/local_http_api.md | 4 +++ products/go/ARCHITECTURE.md | 5 ++-- products/go/docs/hardware_test.md | 11 ++++---- products/go/docs/local_server.md | 7 ++++++ products/go/feature_overview.md | 7 +++--- products/go/main/go_local_api.cpp | 3 ++- products/go/main/go_orchestrator.cpp | 25 +++++++++++++------ products/go/main/go_orchestrator.h | 3 +++ products/go/tests/go_local_api.tests.cpp | 10 +++++++- products/go/tests/go_orchestrator.tests.cpp | 14 +++++++++++ products/go/tests/go_wifi.tests.cpp | 20 ++++++++------- .../tests/local-server-integration/README.md | 9 ++++--- .../local-server-integration/ago_local_api.py | 1 + .../local-server-integration/test_actions.py | 4 +++ .../local-server-integration/test_ota.py | 13 ++++++++++ products/reference/main/test_local_server.cpp | 3 +++ products/reference/main/test_local_server.h | 1 + 22 files changed, 142 insertions(+), 35 deletions(-) diff --git a/components/airgradient-local-server/services/local_server.cpp b/components/airgradient-local-server/services/local_server.cpp index fe868e3..4e33698 100644 --- a/components/airgradient-local-server/services/local_server.cpp +++ b/components/airgradient-local-server/services/local_server.cpp @@ -24,6 +24,7 @@ constexpr const char *PATH_MEASURES = "/api/v1/measures"; constexpr const char *PATH_CONFIG = "/api/v1/config"; constexpr const char *PATH_ACTION_CALIBRATE_CO2 = "/api/v1/actions/calibrate-co2"; constexpr const char *PATH_ACTION_TEST_LEDS = "/api/v1/actions/test-leds"; +constexpr const char *PATH_ACTION_TEST_GPS = "/api/v1/actions/test-gps"; // Includes headroom for a fully escaped MAX_UNKNOWN_KEY field. constexpr size_t ERROR_BUF_SIZE = 512; @@ -167,6 +168,12 @@ bool LocalServer::begin() { _handle_action(ActionId::TestLeds, q, r); }); } + if (ok) { + ok = _register(HttpMethod::Post, PATH_ACTION_TEST_GPS, + [this](const HttpRequest &q, HttpResponse &r) { + _handle_action(ActionId::TestGps, q, r); + }); + } } if (!ok) { diff --git a/components/airgradient-local-server/services/local_server.h b/components/airgradient-local-server/services/local_server.h index 4c99b57..86ed3e0 100644 --- a/components/airgradient-local-server/services/local_server.h +++ b/components/airgradient-local-server/services/local_server.h @@ -100,7 +100,7 @@ class LocalServer { ConfigAccess _config_access; ActionHandler *_actions; - static constexpr size_t MAX_OWNED_ROUTES = 5; // measures + config x2 + 2 actions + static constexpr size_t MAX_OWNED_ROUTES = 6; // measures + config x2 + 3 actions OwnedRoute _routes[MAX_OWNED_ROUTES] = {}; size_t _route_count = 0; bool _begun = false; diff --git a/components/airgradient-local-server/tests/fake_providers.h b/components/airgradient-local-server/tests/fake_providers.h index ee89498..aa851ca 100644 --- a/components/airgradient-local-server/tests/fake_providers.h +++ b/components/airgradient-local-server/tests/fake_providers.h @@ -113,13 +113,22 @@ class FakeActionHandler : public ActionHandler { public: ActionResult result_calibrate{ActionStatus::Dispatched}; ActionResult result_test_leds{ActionStatus::Dispatched}; + ActionResult result_test_gps{ActionStatus::Dispatched}; ActionId last_action = ActionId::CalibrateCo2; bool triggered = false; ActionResult trigger(ActionId action) override { triggered = true; last_action = action; - return action == ActionId::CalibrateCo2 ? result_calibrate : result_test_leds; + switch (action) { + case ActionId::CalibrateCo2: + return result_calibrate; + case ActionId::TestLeds: + return result_test_leds; + case ActionId::TestGps: + return result_test_gps; + } + return {ActionStatus::NotSupported}; } }; diff --git a/components/airgradient-local-server/tests/handler.tests.cpp b/components/airgradient-local-server/tests/handler.tests.cpp index 6201d05..63da950 100644 --- a/components/airgradient-local-server/tests/handler.tests.cpp +++ b/components/airgradient-local-server/tests/handler.tests.cpp @@ -21,6 +21,7 @@ constexpr const char *MEASURES = "/api/v1/measures"; constexpr const char *CONFIG = "/api/v1/config"; constexpr const char *CALIBRATE_CO2 = "/api/v1/actions/calibrate-co2"; constexpr const char *TEST_LEDS = "/api/v1/actions/test-leds"; +constexpr const char *TEST_GPS = "/api/v1/actions/test-gps"; std::string body_string(const HttpResponse &resp) { return std::string(static_cast(resp.body_data()), resp.body_size()); @@ -354,9 +355,10 @@ TEST_CASE("actions register all catalog routes and map results", "[handler][acti LocalServer ls(server, {measures, nullptr, ConfigAccess::Disabled, &actions}); REQUIRE(ls.begin()); - // Both catalog actions get a route, regardless of model support. + // Every catalog action gets a route, regardless of model support. REQUIRE(server.has_route(HttpMethod::Post, CALIBRATE_CO2)); REQUIRE(server.has_route(HttpMethod::Post, TEST_LEDS)); + REQUIRE(server.has_route(HttpMethod::Post, TEST_GPS)); SECTION("Dispatched -> 200 empty body") { actions.result_calibrate = {ActionStatus::Dispatched}; @@ -379,6 +381,16 @@ TEST_CASE("actions register all catalog routes and map results", "[handler][acti REQUIRE(error_code(resp) == "forbidden"); } + SECTION("GPS test dispatches the catalog action") { + actions.result_test_gps = {ActionStatus::Dispatched}; + TestHttpRequest req(HttpMethod::Post, TEST_GPS); + HttpResponse resp; + REQUIRE(server.invoke(HttpMethod::Post, TEST_GPS, req, resp)); + REQUIRE(resp.status == HttpStatus::Ok); + REQUIRE(resp.body_size() == 0); + REQUIRE(actions.last_action == ActionId::TestGps); + } + SECTION("NotSupported -> 404 not_found") { actions.result_test_leds = {ActionStatus::NotSupported}; TestHttpRequest req(HttpMethod::Post, TEST_LEDS); @@ -407,6 +419,7 @@ TEST_CASE("no action handler leaves action routes unregistered", "[handler][acti REQUIRE(ls.begin()); REQUIRE_FALSE(server.has_route(HttpMethod::Post, CALIBRATE_CO2)); REQUIRE_FALSE(server.has_route(HttpMethod::Post, TEST_LEDS)); + REQUIRE_FALSE(server.has_route(HttpMethod::Post, TEST_GPS)); } TEST_CASE("begin is idempotent", "[lifecycle]") { diff --git a/components/airgradient-local-server/types/local_server_result.h b/components/airgradient-local-server/types/local_server_result.h index 3fcde55..5bac496 100644 --- a/components/airgradient-local-server/types/local_server_result.h +++ b/components/airgradient-local-server/types/local_server_result.h @@ -68,7 +68,7 @@ struct ConfigSubmitResult { ConfigFieldId field = ConfigFieldId::None; }; -enum class ActionId : uint8_t { CalibrateCo2, TestLeds }; +enum class ActionId : uint8_t { CalibrateCo2, TestLeds, TestGps }; enum class ActionStatus : uint8_t { Dispatched, // accepted and queued (fire-and-forget) -> 200 diff --git a/docs/local_http_api.md b/docs/local_http_api.md index c6cef85..f785af0 100644 --- a/docs/local_http_api.md +++ b/docs/local_http_api.md @@ -50,6 +50,7 @@ curl "$AG_URL/api/v1/config" | `PUT` | `/api/v1/config` | `202` | Submit a partial configuration update. | | `POST` | `/api/v1/actions/calibrate-co2` | `200` | Request CO2 calibration when supported. | | `POST` | `/api/v1/actions/test-leds` | `200` | Request an LED diagnostic when supported. | +| `POST` | `/api/v1/actions/test-gps` | `200` | Open the live GPS test when supported. | An endpoint can be absent when the product does not expose that capability. In that case, the HTTP server returns its normal `404` response. A registered @@ -226,6 +227,7 @@ The v1 action catalog contains these actions: |---|---|---|---| | CO2 calibration | `POST /api/v1/actions/calibrate-co2` | None | Request CO2 calibration. | | LED test | `POST /api/v1/actions/test-leds` | None | Request the device LED diagnostic. | +| GPS test | `POST /api/v1/actions/test-gps` | None | Open the device live GPS test. | An action may be unavailable for a product, current device state, or device policy. Unsupported actions return `404 not_found`; rejected actions return @@ -284,6 +286,7 @@ activated; its mDNS advertisement follows Wi-Fi address availability. | `PUT` | `/api/v1/config` | When local configuration writes are allowed | Submit supported Go configuration changes. | | `POST` | `/api/v1/actions/calibrate-co2` | When actions are allowed | Request CO2 calibration. | | `POST` | `/api/v1/actions/test-leds` | When actions are allowed | Request the LED diagnostic. | +| `POST` | `/api/v1/actions/test-gps` | When actions are allowed | Open the live GPS test. | ### Measures Fields @@ -320,6 +323,7 @@ Go returns these fields from `GET /api/v1/config` and accepts them in partial |---|---|---| | CO2 calibration | `POST /api/v1/actions/calibrate-co2` | Available when actions are allowed. | | LED test | `POST /api/v1/actions/test-leds` | Available when actions are allowed. | +| GPS test | `POST /api/v1/actions/test-gps` | Available when actions are allowed. | `configurationControl` determines whether local configuration writes, cloud configuration Fetch, or both are permitted. `cloudConnection` controls cloud diff --git a/products/go/ARCHITECTURE.md b/products/go/ARCHITECTURE.md index cd39ec5..8539ddf 100644 --- a/products/go/ARCHITECTURE.md +++ b/products/go/ARCHITECTURE.md @@ -878,8 +878,9 @@ Settings fields: - Returns a busy response if either the local FIFO or central event queue cannot admit a request; clearing the FIFO advances its epoch so stale events cannot consume requests from a later endpoint generation -- Dispatches `calibrate-co2` and `test-leds` as fire-and-forget actions. The - HTTP success response confirms queue admission, not action completion +- Dispatches `calibrate-co2`, `test-leds`, and `test-gps` as fire-and-forget + actions. The HTTP success response confirms queue admission, not action + completion - Uses plain HTTP without API authentication or TLS. The security boundary is a trusted local network, not exposure through an untrusted or public network - Retains routes and the listener across transient STA reconnects, restarts mDNS diff --git a/products/go/docs/hardware_test.md b/products/go/docs/hardware_test.md index 56b5e79..7b59ecb 100644 --- a/products/go/docs/hardware_test.md +++ b/products/go/docs/hardware_test.md @@ -111,11 +111,12 @@ LED marks the fix. The screen shows TTFF (`mm:ss`), fix type, satellites, HDOP, latitude/longitude, and UTC. On exit the posting cadence and back LED are restored, and the receiver is stopped only if the test ungated it. -A cloud Fetch response can set `gpsTestRequested: true` to open this screen -without manual Settings navigation. The orchestrator handles it after the CO2 -calibration and LED-test action flags from the same response. It ignores the -request while the Peripheral or Accelerometer test is active and treats it as a -no-op when the GPS Test screen is already open. +A cloud Fetch response can set `gpsTestRequested: true`, or a Local API client +can call `POST /api/v1/actions/test-gps`, to open this screen without manual +Settings navigation. The orchestrator handles the cloud trigger after the CO2 +calibration and LED-test action flags from the same response. Both trigger paths +ignore the request while the Peripheral or Accelerometer test is active and +treat it as a no-op when the GPS Test screen is already open. See [`gps_service.md`](gps_service.md) for the receiver lifecycle. diff --git a/products/go/docs/local_server.md b/products/go/docs/local_server.md index 95c4a46..b3d6c8e 100644 --- a/products/go/docs/local_server.md +++ b/products/go/docs/local_server.md @@ -86,6 +86,7 @@ The Go integration registers exactly these routes: | `PUT` | `/api/v1/config` | Empty `202` | `400`, `403`, `404`, `503`, `500` | | `POST` | `/api/v1/actions/calibrate-co2` | Empty `200` | `403`, `503` | | `POST` | `/api/v1/actions/test-leds` | Empty `200` | `403`, `503` | +| `POST` | `/api/v1/actions/test-gps` | Empty `200` | `403`, `503` | Errors produced by these handlers use an `application/json` envelope with `error.code`, optional `error.field`, and `error.message`: @@ -227,6 +228,12 @@ the configured levels and current AQI state. The success response confirms only queue admission. If an interactive hardware-test screen owns the LEDs when the request is consumed, the diagnostic is ignored. +`POST /api/v1/actions/test-gps` also returns empty `200` once queued. The +orchestrator opens the live GPS Test screen and starts its existing receiver, +fast-posting, and TTFF behavior. The request is ignored while the Peripheral or +Accelerometer test is active and is a no-op when the GPS Test screen is already +open. + ### Endpoint Lifecycle ```mermaid diff --git a/products/go/feature_overview.md b/products/go/feature_overview.md index 08bc5cd..624334f 100644 --- a/products/go/feature_overview.md +++ b/products/go/feature_overview.md @@ -176,9 +176,10 @@ Configuration writes are asynchronous. For an update carrying at least one actual setting field, an accepted response means the request entered the device queue. Effect-free partials such as `{}` or `{"corrections":{}}` are immediate no-ops. A client reads the config endpoint to confirm that a requested value was -persisted and became active. A local CO2 calibration request is also -fire-and-forget: its response confirms queue admission, not dispatch to the -sensor, calibration start, or completion. +persisted and became active. Local actions can request CO2 calibration, the LED +diagnostic, or direct navigation to the live GPS Test screen. They are +fire-and-forget: a response confirms queue admission, not action dispatch or +completion. The local API uses plain HTTP without API authentication or TLS. It is designed for a trusted private LAN and should not be exposed directly to an untrusted or diff --git a/products/go/main/go_local_api.cpp b/products/go/main/go_local_api.cpp index 04ca4d6..96bef29 100644 --- a/products/go/main/go_local_api.cpp +++ b/products/go/main/go_local_api.cpp @@ -200,7 +200,8 @@ ActionResult GoLocalApiService::trigger(ActionId action) { return {ActionStatus::Rejected}; } - if (action != ActionId::CalibrateCo2 && action != ActionId::TestLeds) { + if (action != ActionId::CalibrateCo2 && action != ActionId::TestLeds && + action != ActionId::TestGps) { _mutex.unlock(); return {ActionStatus::NotSupported}; } diff --git a/products/go/main/go_orchestrator.cpp b/products/go/main/go_orchestrator.cpp index 0f2ac28..fa879d7 100644 --- a/products/go/main/go_orchestrator.cpp +++ b/products/go/main/go_orchestrator.cpp @@ -771,13 +771,7 @@ void Orchestrator::handle_cloud_action_requests(const FetchConfigEventPayload &p run_led_test(); } if (payload.gps_test_requested) { - const Screen current_screen = _svc.ui_manager.current_screen(); - if (current_screen == Screen::PeripheralTest || current_screen == Screen::AccelTest) { - AG_LOGW(TAG, "GPS test trigger ignored: another hardware test is active"); - } else if (current_screen != Screen::GpsTest) { - _svc.ui_manager.set_screen(Screen::GpsTest); - start_gps_test(); - } + trigger_gps_test(); } } @@ -799,6 +793,9 @@ void Orchestrator::on_local_api_request(uint32_t event_epoch) { case ActionId::TestLeds: run_led_test(); break; + case ActionId::TestGps: + trigger_gps_test(); + break; default: AG_LOGW(TAG, "unsupported queued local action=%u", static_cast(request.action)); break; @@ -1572,6 +1569,20 @@ void Orchestrator::start_gps_test() { update_display(); } +void Orchestrator::trigger_gps_test() { + const Screen current_screen = _svc.ui_manager.current_screen(); + if (current_screen == Screen::PeripheralTest || current_screen == Screen::AccelTest) { + AG_LOGW(TAG, "GPS test trigger ignored: another hardware test is active"); + return; + } + if (current_screen == Screen::GpsTest) { + return; + } + + _svc.ui_manager.set_screen(Screen::GpsTest); + start_gps_test(); +} + void Orchestrator::finish_gps_test() { AG_LOGI(TAG, "gps test: finish"); _svc.gps_service.set_posting_interval_ms(GPS_POSTING_INTERVAL_MS_DEFAULT); diff --git a/products/go/main/go_orchestrator.h b/products/go/main/go_orchestrator.h index 082e9d5..b2b6522 100644 --- a/products/go/main/go_orchestrator.h +++ b/products/go/main/go_orchestrator.h @@ -412,6 +412,9 @@ class Orchestrator { /// Enter the live GPS test: reset the TTFF timer, ungate the receiver if /// settings leave GPS inactive, speed up posting for a ~1 Hz refresh, render. void start_gps_test(); + /// Open and start the GPS test unless it is already open or another live + /// hardware test owns the UI. + void trigger_gps_test(); /// Leave the GPS test: restore the settings posting cadence and reconcile the /// receiver against settings (stop it if the test ungated it). void finish_gps_test(); diff --git a/products/go/tests/go_local_api.tests.cpp b/products/go/tests/go_local_api.tests.cpp index 90afdaa..dcf945e 100644 --- a/products/go/tests/go_local_api.tests.cpp +++ b/products/go/tests/go_local_api.tests.cpp @@ -1063,14 +1063,17 @@ TEST_CASE("Go local API access changes do not implicitly clear admitted work") { TEST_CASE("Go local API action access precedes admission") { Fixture fixture; CHECK(fixture.service->trigger(ActionId::TestLeds).status == ActionStatus::Rejected); + CHECK(fixture.service->trigger(ActionId::TestGps).status == ActionStatus::Rejected); CHECK(fixture.service->trigger(ActionId::CalibrateCo2).status == ActionStatus::Rejected); fixture.service->set_access(ConfigAccess::ReadOnly); CHECK(fixture.service->trigger(ActionId::TestLeds).status == ActionStatus::Rejected); + CHECK(fixture.service->trigger(ActionId::TestGps).status == ActionStatus::Rejected); CHECK(fixture.service->trigger(ActionId::CalibrateCo2).status == ActionStatus::Rejected); fixture.service->set_access(ConfigAccess::ReadWrite); CHECK(fixture.service->trigger(ActionId::TestLeds).status == ActionStatus::Dispatched); + CHECK(fixture.service->trigger(ActionId::TestGps).status == ActionStatus::Dispatched); CHECK(fixture.service->trigger(ActionId::CalibrateCo2).status == ActionStatus::Dispatched); } @@ -1080,7 +1083,8 @@ TEST_CASE("Go local API queues supported actions independently") { CHECK(fixture.service->trigger(ActionId::CalibrateCo2).status == ActionStatus::Dispatched); CHECK(fixture.service->trigger(ActionId::TestLeds).status == ActionStatus::Dispatched); - CHECK(GoLocalApiServiceTestAccess::request_count(*fixture.service) == 2); + CHECK(fixture.service->trigger(ActionId::TestGps).status == ActionStatus::Dispatched); + CHECK(GoLocalApiServiceTestAccess::request_count(*fixture.service) == 3); const LocalApiRequest calibration = fixture.receive_request(); CHECK(calibration.kind == LocalApiRequestKind::Action); @@ -1089,6 +1093,10 @@ TEST_CASE("Go local API queues supported actions independently") { const LocalApiRequest led_test = fixture.receive_request(); CHECK(led_test.kind == LocalApiRequestKind::Action); CHECK(led_test.action == ActionId::TestLeds); + + const LocalApiRequest gps_test = fixture.receive_request(); + CHECK(gps_test.kind == LocalApiRequestKind::Action); + CHECK(gps_test.action == ActionId::TestGps); } TEST_CASE("Go local API rolls back action after admission failure") { diff --git a/products/go/tests/go_orchestrator.tests.cpp b/products/go/tests/go_orchestrator.tests.cpp index b8b3596..f83b8cf 100644 --- a/products/go/tests/go_orchestrator.tests.cpp +++ b/products/go/tests/go_orchestrator.tests.cpp @@ -7247,6 +7247,20 @@ TEST_CASE("local LED test action is queued and dispatched fire-and-forget", CHECK(test_spy::led_touch_all_on_seen); } +TEST_CASE("local GPS test action opens the live test screen", + "[Orchestrator][local-api][action][gps-test]") { + TestFixture f; + auto orch = f.make_orchestrator(); + f.local_api.set_access(ConfigAccess::ReadWrite); + REQUIRE(f.local_api.trigger(ActionId::TestGps).status == ActionStatus::Dispatched); + + dispatch_next_local_request(f, orch); + + CHECK(f.ui_manager.current_screen() == Screen::GpsTest); + CHECK(test_spy::gps_started); + CHECK(test_spy::gps_posting_interval_ms == 1000); +} + TEST_CASE("local settings remain unchanged until persistence commits", "[Orchestrator][local-api][config][ordering]") { TestFixture f; diff --git a/products/go/tests/go_wifi.tests.cpp b/products/go/tests/go_wifi.tests.cpp index 5ee011a..75f3d8a 100644 --- a/products/go/tests/go_wifi.tests.cpp +++ b/products/go/tests/go_wifi.tests.cpp @@ -32,6 +32,8 @@ namespace { +constexpr int LOCAL_API_ROUTE_COUNT = 6; + // --------------------------------------------------------------------------- // FakeWifiHal — minimal in-memory HAL for WifiManager // --------------------------------------------------------------------------- @@ -929,12 +931,12 @@ TEST_CASE("local endpoint starts routes before listener and advertises exact ide f.hal.got_ip_cb(0x0100A8C0); REQUIRE(f.svc.ensure_local_http()); - CHECK(f.http.register_calls == 5); + CHECK(f.http.register_calls == LOCAL_API_ROUTE_COUNT); CHECK(f.http.start_calls == 1); CHECK(f.http.last_port == 8080); CHECK(WifiServiceTestAccess::local_http_active(f.svc)); CHECK(f.svc.ensure_local_http()); - CHECK(f.http.register_calls == 5); + CHECK(f.http.register_calls == LOCAL_API_ROUTE_COUNT); CHECK(f.http.start_calls == 1); REQUIRE(f.svc.ensure_local_mdns()); @@ -972,8 +974,8 @@ TEST_CASE("listener start failure rolls back local routes", "[go_wifi][local_end CHECK_FALSE(f.svc.ensure_local_http()); CHECK_FALSE(WifiServiceTestAccess::local_http_active(f.svc)); - CHECK(f.http.register_calls == 5); - CHECK(f.http.unregister_calls == 5); + CHECK(f.http.register_calls == LOCAL_API_ROUTE_COUNT); + CHECK(f.http.unregister_calls == LOCAL_API_ROUTE_COUNT); CHECK(f.hal.start_mdns_calls == 0); } @@ -1050,7 +1052,7 @@ TEST_CASE("provisioning success handoff retains listener for local routes", CHECK(f.http.active_routes == 0); CHECK(f.svc.ensure_local_http()); CHECK(f.http.start_calls == 2); - CHECK(f.http.active_routes == 5); + CHECK(f.http.active_routes == LOCAL_API_ROUTE_COUNT); CHECK(WifiServiceTestAccess::local_http_active(f.svc)); } @@ -1070,7 +1072,7 @@ TEST_CASE("local endpoint survives STA reconnect without route or listener churn f.hal.got_ip_cb(0x0100A8C0); REQUIRE(f.svc.ensure_local_http()); REQUIRE(f.svc.ensure_local_mdns()); - REQUIRE(f.http.active_routes == 5); + REQUIRE(f.http.active_routes == LOCAL_API_ROUTE_COUNT); REQUIRE(f.hal.start_mdns_calls == 1); REQUIRE(f.hal.sta_disconnected_cb); @@ -1078,7 +1080,7 @@ TEST_CASE("local endpoint survives STA reconnect without route or listener churn CHECK(f.hal.stop_mdns_calls == 1); CHECK(f.http.stop_calls == 0); CHECK(f.http.unregister_calls == 0); - CHECK(f.http.active_routes == 5); + CHECK(f.http.active_routes == LOCAL_API_ROUTE_COUNT); f.hal.sta_connected_cb(); f.hal.got_ip_cb(0x0200A8C0); @@ -1086,8 +1088,8 @@ TEST_CASE("local endpoint survives STA reconnect without route or listener churn CHECK(f.svc.ensure_local_http()); CHECK(f.svc.ensure_local_mdns()); CHECK(f.http.start_calls == 1); - CHECK(f.http.register_calls == 5); - CHECK(f.http.active_routes == 5); + CHECK(f.http.register_calls == LOCAL_API_ROUTE_COUNT); + CHECK(f.http.active_routes == LOCAL_API_ROUTE_COUNT); } TEST_CASE("shutdown stops a listener retained by Wi-Fi to BLE provisioning switch", diff --git a/products/go/tests/local-server-integration/README.md b/products/go/tests/local-server-integration/README.md index 46bdaf9..f698b8f 100644 --- a/products/go/tests/local-server-integration/README.md +++ b/products/go/tests/local-server-integration/README.md @@ -69,7 +69,9 @@ pytest products/go/tests/local-server-integration/ -v \ ## Safety The default suite does not change durable configuration. It submits only an -empty configuration update, malformed requests, and the unsupported LED action. +empty configuration update, malformed requests, and the LED and GPS test +actions. The GPS action leaves the live GPS Test screen open for the operator to +exit. Persisted mutation tests require `--ago-allow-config-write`. They round-trip the temperature unit, measurement and GPS settings, three LED levels, buzzer, CO2 @@ -122,8 +124,9 @@ and known fields unsupported by Go. ### `test_actions.py` — Actions -Verifies that `test-leds` returns an empty `200` fire-and-forget response. The -opt-in calibration test verifies the same response contract for `calibrate-co2`. +Verifies that `test-leds` and `test-gps` return empty `200` fire-and-forget +responses. The opt-in calibration test verifies the same response contract for +`calibrate-co2`. ### `test_ota.py` — OTA Policy 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 37c2b79..2977c0f 100644 --- a/products/go/tests/local-server-integration/ago_local_api.py +++ b/products/go/tests/local-server-integration/ago_local_api.py @@ -14,6 +14,7 @@ CONFIG_PATH = "/api/v1/config" CALIBRATE_CO2_PATH = "/api/v1/actions/calibrate-co2" TEST_LEDS_PATH = "/api/v1/actions/test-leds" +TEST_GPS_PATH = "/api/v1/actions/test-gps" MODEL = "P-1PSG" SERVICE_TYPE = "_airgradient._tcp.local." diff --git a/products/go/tests/local-server-integration/test_actions.py b/products/go/tests/local-server-integration/test_actions.py index 0470331..6716559 100644 --- a/products/go/tests/local-server-integration/test_actions.py +++ b/products/go/tests/local-server-integration/test_actions.py @@ -12,6 +12,10 @@ def test_led_action_is_dispatched(ago_http_client: httpx.Client) -> None: api.assert_empty_response(ago_http_client.post(api.TEST_LEDS_PATH), 200) +def test_gps_action_is_dispatched(ago_http_client: httpx.Client) -> None: + api.assert_empty_response(ago_http_client.post(api.TEST_GPS_PATH), 200) + + @pytest.mark.interactive def test_calibrate_co2_is_dispatched( ago_http_client: httpx.Client, diff --git a/products/go/tests/local-server-integration/test_ota.py b/products/go/tests/local-server-integration/test_ota.py index f8a98ed..341f7ba 100644 --- a/products/go/tests/local-server-integration/test_ota.py +++ b/products/go/tests/local-server-integration/test_ota.py @@ -60,6 +60,19 @@ def test_led_action_is_forbidden( ) +def test_gps_action_is_forbidden( + ago_http_client: httpx.Client, + require_ota_active: None, +) -> None: + del require_ota_active + api.assert_error( + ago_http_client.post(api.TEST_GPS_PATH), + 403, + "forbidden", + "forbidden", + ) + + @pytest.mark.interactive def test_calibration_action_is_forbidden( ago_http_client: httpx.Client, diff --git a/products/reference/main/test_local_server.cpp b/products/reference/main/test_local_server.cpp index e7f90a0..72a8dbe 100644 --- a/products/reference/main/test_local_server.cpp +++ b/products/reference/main/test_local_server.cpp @@ -355,6 +355,9 @@ class DemoActionHandler : public ActionHandler { case ActionId::TestLeds: ESP_LOGI(TAG, "action: test-leds dispatched"); break; + case ActionId::TestGps: + ESP_LOGI(TAG, "action: test-gps dispatched"); + break; } return {ActionStatus::Dispatched}; } diff --git a/products/reference/main/test_local_server.h b/products/reference/main/test_local_server.h index e1e0ce6..6aff5ff 100644 --- a/products/reference/main/test_local_server.h +++ b/products/reference/main/test_local_server.h @@ -18,6 +18,7 @@ // PUT http:///api/v1/config (partial JSON -> 202) // POST http:///api/v1/actions/calibrate-co2 (-> 200) // POST http:///api/v1/actions/test-leds (-> 200) +// POST http:///api/v1/actions/test-gps (-> 200) // // A 202 confirms admission, not completion. Poll GET /api/v1/config for // convergence; retry a structured 503 busy response using client-owned timing. From b1df7f754d5945f8b3ed23dfe63fb4c9d1dbe32a Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 7 Aug 2026 12:44:20 +0700 Subject: [PATCH 3/3] fix(go): ignore custom PM EPA flag Go custom PM corrections no longer emit or apply useEpa2021. Cloud, Local API, and legacy BLE values are ignored. --- products/go/docs/ble_service.md | 15 +++-- products/go/docs/cloud_service.md | 2 +- products/go/docs/local_server.md | 3 +- products/go/docs/measurement_corrections.md | 23 ++++--- products/go/docs/settings.md | 5 +- products/go/go_ble_client.md | 6 +- products/go/main/go_ble.cpp | 11 +--- products/go/main/go_ble_protocol.h | 3 +- products/go/main/go_cloud.cpp | 5 +- products/go/main/go_local_api.cpp | 5 +- products/go/main/go_settings.cpp | 11 +--- .../go/tests/ble-integration/test_config.py | 10 +--- products/go/tests/go_ble.tests.cpp | 60 ++++++++++++++++++- products/go/tests/go_cloud.tests.cpp | 23 ++++++- products/go/tests/go_local_api.tests.cpp | 33 +++++----- products/go/tests/go_settings.tests.cpp | 10 +++- .../local-server-integration/ago_local_api.py | 4 -- 17 files changed, 141 insertions(+), 88 deletions(-) diff --git a/products/go/docs/ble_service.md b/products/go/docs/ble_service.md index 0195354..b1fc0e7 100644 --- a/products/go/docs/ble_service.md +++ b/products/go/docs/ble_service.md @@ -405,14 +405,13 @@ keeps this value updated whenever the orchestrator calls `update_config()`. Each correction map contains schema version `"s"` and a positional `"v"` array. Schema version 1 uses `[algorithm, scale, intercept]` for temperature and humidity, and `[algorithm, scale, intercept, flags]` for PM2.5. Coefficients are -finite float32 values. The PM2.5 flags value uses bit 0 for `use_epa`; the flag -must be clear unless the algorithm is `custom_via_pm25_raw`. The canonical -`none` representation uses identity coefficients, but the encoder emits active -coefficients verbatim. The decoder requires finite coefficients but does not -enforce identity values for `none`; nonidentity values are ignored by correction -math, remain visible until persisted settings are reloaded, and are canonicalized -on reload. The full snapshot includes all array values so clients can render and -round-trip the current state. +finite float32 values. Go reserves PM2.5 flag bit 0 for wire compatibility: the +encoder always clears it and the decoder accepts but ignores it for +`custom_via_pm25_raw`. The canonical `none` representation uses identity +coefficients, but the encoder emits active coefficients verbatim. The decoder +requires finite coefficients but does not enforce identity values for `none`; +nonidentity values are ignored by correction math, remain visible until +persisted settings are reloaded, and are canonicalized on reload. | Algorithm | PM2.5 | Temperature / Humidity | |---|---|---| diff --git a/products/go/docs/cloud_service.md b/products/go/docs/cloud_service.md index f41cdd6..6646484 100644 --- a/products/go/docs/cloud_service.md +++ b/products/go/docs/cloud_service.md @@ -186,7 +186,7 @@ Sensor and correction fields are: Each valid scalar or correction sets its own update-mask bit. A malformed field does not prevent valid siblings from being delivered. Missing fields retain the active setting, and custom coefficients must be finite JSON numbers with exact -property names. +property names. Go ignores `useEpa2021` in custom PM2.5 corrections. Cloud FETCH does not own connectivity or writer authority. The parser ignores `cloudConnection`/`disableCloudConnection` and `configurationControl`, and the diff --git a/products/go/docs/local_server.md b/products/go/docs/local_server.md index b3d6c8e..3283b54 100644 --- a/products/go/docs/local_server.md +++ b/products/go/docs/local_server.md @@ -165,7 +165,8 @@ Connectivity, sensor, and correction fields are: Go accepts `none`, `epa_2021`, and `custom_via_pm25_raw` for PM2.5. Temperature and humidity accept `none` and `custom`. Custom entries require finite -`intercept` and `scalingFactor` values; PM2.5 also requires `useEpa2021`. +`intercept` and `scalingFactor` values. Go omits the shared `useEpa2021` field +from GET responses and ignores it when supplied in a PM2.5 PUT. Partial correction objects preserve omitted active siblings. Other known v1 catalog fields are omitted from GET and return `404 not_found` on PUT when endpoint and source policy otherwise permit the request. diff --git a/products/go/docs/measurement_corrections.md b/products/go/docs/measurement_corrections.md index b95c0f3..b0c9cdf 100644 --- a/products/go/docs/measurement_corrections.md +++ b/products/go/docs/measurement_corrections.md @@ -59,17 +59,15 @@ Algorithm and property names are case-sensitive. |---|---|---| | PM2.5 | `none` | Preserve valid raw PM2.5 | | PM2.5 | `epa_2021` | Apply the EPA 2021 piecewise correction using raw averaged PM2.5 and raw averaged humidity | -| PM2.5 | `custom_via_pm25_raw` | Apply a linear scale and intercept to raw PM2.5, then optionally apply EPA 2021 using raw humidity | +| PM2.5 | `custom_via_pm25_raw` | Apply a linear scale and intercept to raw PM2.5 | | Temperature | `none` | Preserve valid raw temperature | | Temperature | `custom` | Apply `scaling factor * raw + intercept` in Celsius | | Humidity | `none` | Preserve valid raw relative humidity | | Humidity | `custom` | Apply `scaling factor * raw + intercept` | The PM custom transform preserves an exact raw zero instead of adding the -intercept and clamps negative finite results to zero. Its optional EPA stage -runs after the linear stage but still uses raw, not humidity-corrected, -humidity. Temperature conversion to Fahrenheit and all presentation rounding -happen after correction. +intercept and clamps negative finite results to zero. Temperature conversion to +Fahrenheit and all presentation rounding happen after correction. For the EPA transform, let `p` be PM2.5 and `h` be raw relative humidity clamped to its valid range. The implemented piecewise equations are: @@ -97,9 +95,8 @@ p >= 260: The final finite result is floored at zero. The source of truth remains [`measurement_corrections.cpp`](../../../components/airgradient-common/measurement_corrections.cpp). -Tests exercise the boundary inputs and cover PM custom zero, ordering, and -invalid-humidity fallback; exact expected EPA values are not asserted at every -boundary. +Tests exercise the boundary inputs and PM custom zero behavior; exact expected +EPA values are not asserted at every boundary. ### Wire Names and Shapes @@ -113,7 +110,7 @@ use different measure and PM scaling-factor names. | Humidity entry | `corrections.humidity` | `corrections.rhum` | | PM custom scaling factor | `slr.scalingFactor` | `slr.scalingFactorViaPm25` | | Linear custom scaling factor | `slr.scalingFactor` | `slr.scalingFactor` | -| Shared fields | `correctionAlgorithm`, `slr.intercept`, `slr.useEpa2021` | `correctionAlgorithm`, `slr.intercept`, `slr.useEpa2021` | +| Shared fields | `correctionAlgorithm`, `slr.intercept` | `correctionAlgorithm`, `slr.intercept` | Local Config parsing is strict. Correction objects accept only `pm25`, `temperature`, and `humidity`; each entry accepts only `correctionAlgorithm` @@ -126,7 +123,8 @@ Local API algorithm shapes are: as null. - PM2.5 `epa_2021` has the same no-SLR shape. - PM2.5 `custom_via_pm25_raw` requires finite, float-representable - `intercept` and `scalingFactor` numbers plus Boolean `useEpa2021` in `slr`. + `intercept` and `scalingFactor` numbers. The shared Local API schema accepts + `useEpa2021`, but Go ignores it and omits it from GET responses. - Temperature and humidity `custom` require finite, float-representable `intercept` and `scalingFactor` numbers in `slr`; `useEpa2021` is rejected. @@ -134,8 +132,9 @@ The cloud parser tolerates unrelated root, correction-entry, and SLR fields, but the supported values retain strict types and required names. Cloud PM2.5 custom input requires `scalingFactorViaPm25`; `scalingFactor` is not an alias. Cloud custom inputs require all parameters, while `none` and `epa_2021` ignore -`slr`. A malformed cloud measure leaves only that measure's update bit clear, -so valid siblings remain applicable. +`slr`. Go ignores `useEpa2021` when it is present. A malformed cloud measure +leaves only that measure's update bit clear, so valid siblings remain +applicable. See the local component [`config_json.tests.cpp`](../../../components/airgradient-local-server/tests/config_json.tests.cpp), diff --git a/products/go/docs/settings.md b/products/go/docs/settings.md index 7399960..79975b9 100644 --- a/products/go/docs/settings.md +++ b/products/go/docs/settings.md @@ -67,7 +67,7 @@ IEEE-754 blob accessors. | Measure | Supported algorithms | Required custom values | |---|---|---| -| PM2.5 | `none`, `epa_2021`, `custom_via_pm25_raw` | `intercept`, `scalingFactorViaPm25`, `useEpa2021` | +| PM2.5 | `none`, `epa_2021`, `custom_via_pm25_raw` | `intercept`, `scalingFactorViaPm25` | | Temperature | `none`, `custom` | `intercept`, `scalingFactor` | | Humidity | `none`, `custom` | `intercept`, `scalingFactor` | @@ -75,7 +75,8 @@ Missing or invalid algorithms fall back to `none`. A custom correction is active only when every required coefficient is present and finite. PM2.5 `none` and `epa_2021` do not require persisted coefficients and load with identity parameters. Factory reset writes the default all-`none` correction -set. +set. Go ignores the retired `useEpa2021` custom-PM option and does not read or +write its former `mc_pe` NVS key. Wi-Fi SSID and password are owned by `WifiManager`'s saved-networks store (its own `wifi_creds` NVS namespace, injected at construction). Only the diff --git a/products/go/go_ble_client.md b/products/go/go_ble_client.md index 860a471..6837179 100644 --- a/products/go/go_ble_client.md +++ b/products/go/go_ble_client.md @@ -538,8 +538,8 @@ Schema version 1 uses `[algorithm, scale, intercept]` for temperature and humidity, and `[algorithm, scale, intercept, flags]` for PM2.5. All coefficients are finite float32 values. Algorithm enums are `0 = none`, `1 = custom` for temperature/humidity, and `0 = none`, `1 = epa_2021`, `2 = custom_via_pm25_raw` -for PM2.5. PM2.5 flag bit 0 is `use_epa`. -The flag must be clear unless the PM2.5 algorithm is `custom_via_pm25_raw`. +for PM2.5. PM2.5 flag bit 0 is reserved for compatibility. Go always emits it +clear and ignores it on `custom_via_pm25_raw` writes. Canonical `none` uses scale `1.0`, intercept `0.0`, and clear flags. The current decoder accepts other finite coefficients for `none`; correction math ignores them and persisted loading canonicalizes them. @@ -579,7 +579,7 @@ them and persisted loading canonicalizes them. "abc": 7, "tlo": 12, "nlo": 12, - "pm25_corr": {"s": 1, "v": [2, 1.08, -0.2, 1]}, + "pm25_corr": {"s": 1, "v": [2, 1.08, -0.2, 0]}, "temp_corr": {"s": 1, "v": [0, 1.0, 0.0]}, "hum_corr": {"s": 1, "v": [0, 1.0, 0.0]} } diff --git a/products/go/main/go_ble.cpp b/products/go/main/go_ble.cpp index 86a7e02..fb6060c 100644 --- a/products/go/main/go_ble.cpp +++ b/products/go/main/go_ble.cpp @@ -190,9 +190,7 @@ void encode_pm25_correction(CborEncoder &map, const Pm25Correction &correction) cbor_encode_uint(&values, pm25_correction_algorithm_to_wire(correction.algorithm)); cbor_encode_float(&values, correction.scaling_factor); cbor_encode_float(&values, correction.intercept); - const bool use_epa = - correction.algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw && correction.use_epa2021; - cbor_encode_uint(&values, use_epa ? BLE_PM25_CORRECTION_FLAG_USE_EPA : 0); + cbor_encode_uint(&values, 0); cbor_encoder_close_container(&value, &values); cbor_encoder_close_container(&map, &value); @@ -260,11 +258,9 @@ bool decode_pm25_values(CborValue &value, Pm25Correction &out, bool &invalid) { uint64_t flags = 0; if (cbor_value_at_end(&values) || !read_uint(values, flags) || - (flags & ~BLE_PM25_CORRECTION_FLAG_USE_EPA) != 0 || + (flags & ~BLE_PM25_CORRECTION_ALLOWED_RESERVED_FLAGS) != 0 || (out.algorithm != Pm25CorrectionAlgorithm::CustomViaPm25Raw && flags != 0)) { invalid = true; - } else { - out.use_epa2021 = (flags & BLE_PM25_CORRECTION_FLAG_USE_EPA) != 0; } if (!cbor_value_at_end(&values)) { cbor_value_advance(&values); @@ -1911,8 +1907,7 @@ static bool dif_nlo(const GoSettings &a, const GoSettings &b) { 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 || - a.corrections.pm25.intercept != b.corrections.pm25.intercept || - a.corrections.pm25.use_epa2021 != b.corrections.pm25.use_epa2021; + a.corrections.pm25.intercept != b.corrections.pm25.intercept; } static bool dif_temp_correction(const GoSettings &a, const GoSettings &b) { return a.corrections.temperature.algorithm != b.corrections.temperature.algorithm || diff --git a/products/go/main/go_ble_protocol.h b/products/go/main/go_ble_protocol.h index 614321b..22a93d3 100644 --- a/products/go/main/go_ble_protocol.h +++ b/products/go/main/go_ble_protocol.h @@ -91,7 +91,8 @@ inline constexpr const char *BLE_KEY_CORRECTION_SCHEMA = "s"; inline constexpr const char *BLE_KEY_CORRECTION_VALUES = "v"; inline constexpr uint64_t BLE_CORRECTION_SCHEMA_VERSION = 1; -inline constexpr uint64_t BLE_PM25_CORRECTION_FLAG_USE_EPA = 1U << 0; +// Schema-v1 compatibility: bit 0 is accepted on writes but ignored by Go. +inline constexpr uint64_t BLE_PM25_CORRECTION_ALLOWED_RESERVED_FLAGS = 1U << 0; // --------------------------------------------------------------------------- // Aiding command keys (payload fields for "set_aiding" command) diff --git a/products/go/main/go_cloud.cpp b/products/go/main/go_cloud.cpp index b9af958..cc637ba 100644 --- a/products/go/main/go_cloud.cpp +++ b/products/go/main/go_cloud.cpp @@ -70,7 +70,6 @@ constexpr const char *JSON_SLR = "slr"; constexpr const char *JSON_INTERCEPT = "intercept"; constexpr const char *JSON_SCALING_FACTOR = "scalingFactor"; constexpr const char *JSON_SCALING_FACTOR_VIA_PM25 = "scalingFactorViaPm25"; -constexpr const char *JSON_USE_EPA2021 = "useEpa2021"; uint32_t deadline_wait_ms(uint32_t now, uint32_t deadline) { return static_cast(now - deadline) >= 0 ? 0 : deadline - now; @@ -268,13 +267,11 @@ bool parse_pm25_correction(const cJSON *entry, Pm25Correction &out) { parsed.algorithm = Pm25CorrectionAlgorithm::CustomViaPm25Raw; const cJSON *intercept = cJSON_GetObjectItemCaseSensitive(slr, JSON_INTERCEPT); const cJSON *scaling_factor = cJSON_GetObjectItemCaseSensitive(slr, JSON_SCALING_FACTOR_VIA_PM25); - const cJSON *use_epa2021 = cJSON_GetObjectItemCaseSensitive(slr, JSON_USE_EPA2021); if (!parse_float(intercept, parsed.intercept) || - !parse_float(scaling_factor, parsed.scaling_factor) || !cJSON_IsBool(use_epa2021)) { + !parse_float(scaling_factor, parsed.scaling_factor)) { AG_LOGW(TAG, "correction %s rejected: custom parameters are invalid", JSON_PM25); return false; } - parsed.use_epa2021 = cJSON_IsTrue(use_epa2021) != 0; out = parsed; return true; diff --git a/products/go/main/go_local_api.cpp b/products/go/main/go_local_api.cpp index 96bef29..679aac9 100644 --- a/products/go/main/go_local_api.cpp +++ b/products/go/main/go_local_api.cpp @@ -74,7 +74,6 @@ CorrectionEntry make_pm25_correction(const Pm25Correction &correction) { SlrParams slr{}; slr.intercept = correction.intercept; slr.scaling_factor = correction.scaling_factor; - slr.use_epa2021 = correction.use_epa2021; entry.slr = slr; break; } @@ -689,8 +688,7 @@ bool GoLocalApiService::translate_pm25_correction(const CorrectionEntry &entry, return true; } - if (entry.algorithm != CORRECTION_CUSTOM_PM25 || !entry.slr.has_value() || - !entry.slr->use_epa2021.has_value()) { + if (entry.algorithm != CORRECTION_CUSTOM_PM25 || !entry.slr.has_value()) { return false; } @@ -700,7 +698,6 @@ bool GoLocalApiService::translate_pm25_correction(const CorrectionEntry &entry, !to_finite_float(entry.slr->scaling_factor, parsed.scaling_factor)) { return false; } - parsed.use_epa2021 = *entry.slr->use_epa2021; correction = parsed; return true; } diff --git a/products/go/main/go_settings.cpp b/products/go/main/go_settings.cpp index c6dbdbf..7ef7eed 100644 --- a/products/go/main/go_settings.cpp +++ b/products/go/main/go_settings.cpp @@ -38,7 +38,6 @@ constexpr const char *KEY_ONBOARDING_DONE = "obd"; constexpr const char *KEY_PM25_CORRECTION_ALGORITHM = "mc_pa"; constexpr const char *KEY_PM25_CORRECTION_SCALING_FACTOR = "mc_ps"; constexpr const char *KEY_PM25_CORRECTION_INTERCEPT = "mc_pi"; -constexpr const char *KEY_PM25_CORRECTION_USE_EPA2021 = "mc_pe"; constexpr const char *KEY_TEMP_CORRECTION_ALGORITHM = "mc_ta"; constexpr const char *KEY_TEMP_CORRECTION_SCALING_FACTOR = "mc_ts"; constexpr const char *KEY_TEMP_CORRECTION_INTERCEPT = "mc_ti"; @@ -100,14 +99,11 @@ MeasurementCorrections load_measurement_corrections(ConfigStore &store) { } else if (pm_algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw) { float scaling_factor = 0.0f; float intercept = 0.0f; - bool use_epa2021 = false; if (load_finite_float(store, KEY_PM25_CORRECTION_SCALING_FACTOR, scaling_factor) && - load_finite_float(store, KEY_PM25_CORRECTION_INTERCEPT, intercept) && - store.get_bool(KEY_PM25_CORRECTION_USE_EPA2021, use_epa2021) == ConfigStoreResult::OK) { + load_finite_float(store, KEY_PM25_CORRECTION_INTERCEPT, intercept)) { corrections.pm25.algorithm = pm_algorithm; corrections.pm25.scaling_factor = scaling_factor; corrections.pm25.intercept = intercept; - corrections.pm25.use_epa2021 = use_epa2021; } } } @@ -157,8 +153,6 @@ bool save_measurement_corrections(ConfigStore &store, const MeasurementCorrectio store.set_float(KEY_PM25_CORRECTION_SCALING_FACTOR, corrections.pm25.scaling_factor) != ConfigStoreResult::OK || store.set_float(KEY_PM25_CORRECTION_INTERCEPT, corrections.pm25.intercept) != - ConfigStoreResult::OK || - store.set_bool(KEY_PM25_CORRECTION_USE_EPA2021, corrections.pm25.use_epa2021) != ConfigStoreResult::OK) { return false; } @@ -595,13 +589,12 @@ void print_settings(const GoSettings &settings) { settings.tvoc_learning_offset, settings.nox_learning_offset, settings.static_ip.ip != 0 ? "set" : "dhcp", settings.onboarding_done ? "true" : "false"); AG_LOGI(TAG, - "** corrections | pm_alg=%d pm_scale=%.6f pm_intercept=%.6f pm_epa=%s " + "** corrections | pm_alg=%d pm_scale=%.6f pm_intercept=%.6f " "temp_alg=%d temp_scale=%.6f temp_intercept=%.6f " "hum_alg=%d hum_scale=%.6f hum_intercept=%.6f **", static_cast(settings.corrections.pm25.algorithm), static_cast(settings.corrections.pm25.scaling_factor), static_cast(settings.corrections.pm25.intercept), - settings.corrections.pm25.use_epa2021 ? "true" : "false", static_cast(settings.corrections.temperature.algorithm), static_cast(settings.corrections.temperature.scaling_factor), static_cast(settings.corrections.temperature.intercept), diff --git a/products/go/tests/ble-integration/test_config.py b/products/go/tests/ble-integration/test_config.py index dbba45c..697b6e0 100644 --- a/products/go/tests/ble-integration/test_config.py +++ b/products/go/tests/ble-integration/test_config.py @@ -117,7 +117,7 @@ def test_correction_maps_valid(self, config_payload: dict): if key == "pm25_corr": assert algorithm in proto.PM25_CORRECTION_ALGORITHMS.values() assert type(values[3]) is int - assert values[3] & ~proto.PM25_CORRECTION_FLAG_USE_EPA == 0 + assert values[3] == 0, "Config['pm25_corr'] reserved flags must be clear" else: assert algorithm in proto.LINEAR_CORRECTION_ALGORITHMS.values() assert isinstance(values[1], float) @@ -126,14 +126,6 @@ def test_correction_maps_valid(self, config_payload: dict): assert math.isfinite(values[1]) assert math.isfinite(values[2]) - if key == "pm25_corr" and values[0] not in { - proto.PM25_CORRECTION_ALGORITHMS["custom_via_pm25_raw"], - }: - assert values[3] == 0, ( - f"Config['{key}'] EPA flags must be clear for algorithm {values[0]}" - ) - - # --------------------------------------------------------------------------- # Write tests — async, interactive BLE I/O per test # --------------------------------------------------------------------------- diff --git a/products/go/tests/go_ble.tests.cpp b/products/go/tests/go_ble.tests.cpp index 0c3a555..774be12 100644 --- a/products/go/tests/go_ble.tests.cpp +++ b/products/go/tests/go_ble.tests.cpp @@ -407,6 +407,49 @@ static bool top_level_value_is_map(const uint8_t *data, size_t len, const char * return false; } +static uint64_t pm25_correction_flags(const uint8_t *data, size_t len) { + CborParser parser; + CborValue root; + REQUIRE(cbor_parser_init(data, len, 0, &parser, &root) == CborNoError); + + CborValue map; + REQUIRE(cbor_value_enter_container(&root, &map) == CborNoError); + while (!cbor_value_at_end(&map)) { + size_t key_len = 0; + REQUIRE(cbor_value_get_string_length(&map, &key_len) == CborNoError); + std::string key(key_len, '\0'); + REQUIRE(cbor_value_copy_text_string(&map, key.data(), &key_len, &map) == CborNoError); + if (key != "pm25_corr") { + REQUIRE(cbor_value_advance(&map) == CborNoError); + continue; + } + + CborValue correction; + REQUIRE(cbor_value_enter_container(&map, &correction) == CborNoError); + while (!cbor_value_at_end(&correction)) { + REQUIRE(cbor_value_get_string_length(&correction, &key_len) == CborNoError); + key.assign(key_len, '\0'); + REQUIRE(cbor_value_copy_text_string(&correction, key.data(), &key_len, &correction) == + CborNoError); + if (key != "v") { + REQUIRE(cbor_value_advance(&correction) == CborNoError); + continue; + } + + CborValue values; + REQUIRE(cbor_value_enter_container(&correction, &values) == CborNoError); + for (int i = 0; i < 3; ++i) { + REQUIRE(cbor_value_advance(&values) == CborNoError); + } + uint64_t flags = 0; + REQUIRE(cbor_value_get_uint64(&values, &flags) == CborNoError); + return flags; + } + } + FAIL("pm25_corr.v missing"); + return UINT64_MAX; +} + // 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; @@ -862,6 +905,19 @@ TEST_CASE("BLE: encode_config values match settings") { CHECK(find_entry(entries, "nlo")->uint_val == LEARNING_OFFSET_HOURS_MAX); } +TEST_CASE("BLE: encode_config clears the retired PM25 EPA flag") { + StorageService storage(*null_cache_ptr, *null_nand_ptr); + BleService svc(nullptr, storage, default_ble_server); + GoSettings settings{}; + settings.corrections.pm25.algorithm = Pm25CorrectionAlgorithm::CustomViaPm25Raw; + settings.corrections.pm25.use_epa2021 = true; + + uint8_t buf[512]; + const size_t len = BleServiceTestAccess::encode_config(svc, buf, sizeof(buf), settings); + REQUIRE(len > 0); + CHECK(pm25_correction_flags(buf, len) == 0); +} + // --------------------------------------------------------------------------- // encode_config_delta (NOTIFY form: "type":"config" + changed fields only) // --------------------------------------------------------------------------- @@ -1522,7 +1578,7 @@ TEST_CASE("BLE: decode_config_write rejects invalid requested config values") { CHECK(settings.nox_learning_offset == original.nox_learning_offset); } -TEST_CASE("BLE: decode_config_write decodes PM25 correction group") { +TEST_CASE("BLE: decode_config_write ignores the retired PM25 EPA flag") { uint8_t buf[192]; size_t len = encode_set_pm25_correction(buf, sizeof(buf), 2, 1.08f, -0.2f, true); @@ -1536,7 +1592,7 @@ TEST_CASE("BLE: decode_config_write decodes PM25 correction group") { CHECK(settings.corrections.pm25.algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw); CHECK(settings.corrections.pm25.scaling_factor == Catch::Approx(1.08f)); CHECK(settings.corrections.pm25.intercept == Catch::Approx(-0.2f)); - CHECK(settings.corrections.pm25.use_epa2021); + CHECK_FALSE(settings.corrections.pm25.use_epa2021); } TEST_CASE("BLE: decode_config_write decodes linear correction group") { diff --git a/products/go/tests/go_cloud.tests.cpp b/products/go/tests/go_cloud.tests.cpp index 54ee818..d0d1d87 100644 --- a/products/go/tests/go_cloud.tests.cpp +++ b/products/go/tests/go_cloud.tests.cpp @@ -381,7 +381,7 @@ TEST_CASE("FETCH forwards AgClientResult into FetchConfigResult event", "[CloudS TEST_CASE("FETCH parses supported corrections independently", "[CloudService][fetch][correction]") { CloudFixture f; const char body[] = - R"({"country":"DE","corrections":{"pm02":{"correctionAlgorithm":"custom_via_pm25_raw","slr":{"intercept":0,"scalingFactorViaPm25":1.08,"useEpa2021":true}},"atmp":{"correctionAlgorithm":"custom","slr":{"intercept":-0.4,"scalingFactor":1}},"rhum":{"correctionAlgorithm":"none","slr":null}}})"; + R"({"country":"DE","corrections":{"pm02":{"correctionAlgorithm":"custom_via_pm25_raw","slr":{"intercept":0,"scalingFactorViaPm25":1.08}},"atmp":{"correctionAlgorithm":"custom","slr":{"intercept":-0.4,"scalingFactor":1}},"rhum":{"correctionAlgorithm":"none","slr":null}}})"; cloud_spy::fetch_body_to_write = body; cloud_spy::fetch_bytes_to_write = std::strlen(body); @@ -398,11 +398,30 @@ TEST_CASE("FETCH parses supported corrections independently", "[CloudService][fe REQUIRE(has_go_config_field(payload.update.update_mask, GoConfigField::HumidityCorrection)); REQUIRE(payload.update.corrections.pm25.algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw); REQUIRE(payload.update.corrections.pm25.scaling_factor == 1.08f); - REQUIRE(payload.update.corrections.pm25.use_epa2021); + REQUIRE_FALSE(payload.update.corrections.pm25.use_epa2021); REQUIRE(payload.update.corrections.temperature.intercept == -0.4f); REQUIRE(payload.update.corrections.humidity.algorithm == LinearCorrectionAlgorithm::None); } +TEST_CASE("FETCH ignores the retired Go custom PM EPA flag", "[CloudService][fetch][correction]") { + CloudFixture f; + const char body[] = + R"({"corrections":{"pm02":{"correctionAlgorithm":"custom_via_pm25_raw","slr":{"intercept":0,"scalingFactorViaPm25":1.08,"useEpa2021":"ignored"}}}})"; + 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 FetchConfigEventPayload &payload = f.mock_rtos.last_event.fetch_config; + REQUIRE(has_go_config_field(payload.update.update_mask, GoConfigField::Pm25Correction)); + CHECK(payload.update.corrections.pm25.algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw); + CHECK_FALSE(payload.update.corrections.pm25.use_epa2021); +} + TEST_CASE("FETCH rejects one malformed correction but keeps valid siblings", "[CloudService][fetch][correction]") { CloudFixture f; diff --git a/products/go/tests/go_local_api.tests.cpp b/products/go/tests/go_local_api.tests.cpp index dcf945e..a1a6702 100644 --- a/products/go/tests/go_local_api.tests.cpp +++ b/products/go/tests/go_local_api.tests.cpp @@ -100,13 +100,12 @@ LocalServerConfig pm_standard_config(const char *value) { return config; } -CorrectionEntry custom_pm25(double intercept, double scaling_factor, bool use_epa2021) { +CorrectionEntry custom_pm25(double intercept, double scaling_factor) { CorrectionEntry entry{}; entry.algorithm = "custom_via_pm25_raw"; SlrParams slr{}; slr.intercept = intercept; slr.scaling_factor = scaling_factor; - slr.use_epa2021 = use_epa2021; entry.slr = slr; return entry; } @@ -389,7 +388,7 @@ TEST_CASE("Go local API maps the supported active config subset") { CHECK(config.corrections->pm25->algorithm == "custom_via_pm25_raw"); CHECK(*config.corrections->pm25->slr->intercept == -1.5); CHECK(*config.corrections->pm25->slr->scaling_factor == 0.5); - CHECK(*config.corrections->pm25->slr->use_epa2021); + CHECK_FALSE(config.corrections->pm25->slr->use_epa2021.has_value()); REQUIRE(config.corrections->temperature.has_value()); REQUIRE(config.corrections->temperature->slr.has_value()); CHECK(config.corrections->temperature->algorithm == "custom"); @@ -435,7 +434,7 @@ TEST_CASE("Go local API translates one atomic supported update") { partial.touch_led_intensity = 2; partial.buzzer_enabled = true; Corrections corrections{}; - corrections.pm25 = custom_pm25(-1.0, 0.25, true); + corrections.pm25 = custom_pm25(-1.0, 0.25); corrections.temperature = custom_linear(2.0, 1.1); corrections.humidity = custom_linear(-3.0, 0.9); partial.corrections = corrections; @@ -466,7 +465,7 @@ TEST_CASE("Go local API translates one atomic supported update") { 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); - CHECK(request.config.corrections.pm25.use_epa2021); + CHECK_FALSE(request.config.corrections.pm25.use_epa2021); CHECK(request.config.corrections.temperature.algorithm == LinearCorrectionAlgorithm::Custom); CHECK(request.config.corrections.temperature.intercept == 2.0f); CHECK(request.config.corrections.temperature.scaling_factor == Catch::Approx(1.1f)); @@ -484,7 +483,7 @@ TEST_CASE("Go local API preserves absent active correction siblings") { fixture.service->set_access(ConfigAccess::ReadWrite); const LocalServerConfig partial = - correction_config(custom_pm25(1.0, 0.5, false), std::nullopt, std::nullopt); + correction_config(custom_pm25(1.0, 0.5), std::nullopt, std::nullopt); require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::Accepted); const LocalApiRequest request = fixture.receive_request(); CHECK(request.config.update_mask == field_mask(GoConfigField::Pm25Correction)); @@ -765,7 +764,7 @@ TEST_CASE("Go local API validates strict correction shapes") { Fixture fixture; fixture.service->set_access(ConfigAccess::ReadWrite); - SECTION("PM custom requires all coefficients and EPA flag") { + SECTION("PM custom requires both coefficients") { CorrectionEntry entry{}; entry.algorithm = "custom_via_pm25_raw"; entry.slr = SlrParams{}; @@ -774,23 +773,28 @@ TEST_CASE("Go local API validates strict correction shapes") { ConfigFieldId::CorrectionsPm25); entry.slr->scaling_factor = 1.0; - entry.slr->use_epa2021 = false; partial = correction_config(entry, std::nullopt, std::nullopt); require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, ConfigFieldId::CorrectionsPm25); entry.slr = SlrParams{}; entry.slr->intercept = 0.0; - entry.slr->use_epa2021 = false; partial = correction_config(entry, std::nullopt, std::nullopt); require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, ConfigFieldId::CorrectionsPm25); entry.slr->scaling_factor = 1.0; - entry.slr->use_epa2021.reset(); partial = correction_config(entry, std::nullopt, std::nullopt); - require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, - ConfigFieldId::CorrectionsPm25); + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::Accepted); + CHECK_FALSE(fixture.receive_request().config.corrections.pm25.use_epa2021); + } + + SECTION("PM custom ignores the shared EPA flag") { + CorrectionEntry entry = custom_pm25(0.0, 1.0); + entry.slr->use_epa2021 = true; + const LocalServerConfig partial = correction_config(entry, std::nullopt, std::nullopt); + require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::Accepted); + CHECK_FALSE(fixture.receive_request().config.corrections.pm25.use_epa2021); } SECTION("linear custom requires each coefficient") { @@ -866,9 +870,8 @@ TEST_CASE("Go local API validates strict correction shapes") { } SECTION("non-finite and float-overflow coefficients are rejected") { - LocalServerConfig partial = - correction_config(custom_pm25(std::numeric_limits::infinity(), 1.0, false), - std::nullopt, std::nullopt); + LocalServerConfig partial = correction_config( + custom_pm25(std::numeric_limits::infinity(), 1.0), std::nullopt, std::nullopt); require_status(fixture.service->submit_config(partial), ConfigSubmitStatus::InvalidValue, ConfigFieldId::CorrectionsPm25); partial = correction_config( diff --git a/products/go/tests/go_settings.tests.cpp b/products/go/tests/go_settings.tests.cpp index 477a9e3..a472acc 100644 --- a/products/go/tests/go_settings.tests.cpp +++ b/products/go/tests/go_settings.tests.cpp @@ -128,7 +128,7 @@ class FakeConfigStore : public ConfigStore { std::size_t _write_attempt_count = 0; }; -static constexpr std::size_t GO_SETTINGS_WRITE_COUNT = 33; +static constexpr std::size_t GO_SETTINGS_WRITE_COUNT = 32; // ============================================================================ // Defaults — load from empty store returns struct defaults @@ -415,7 +415,8 @@ TEST_CASE("cloud control permits only an exact local recovery update", "[setting } } -TEST_CASE("measurement corrections round-trip as grouped settings", "[settings][correction]") { +TEST_CASE("measurement corrections round-trip and ignore the retired PM EPA flag", + "[settings][correction]") { FakeConfigStore store; GoSettings original; original.corrections.pm25.algorithm = Pm25CorrectionAlgorithm::CustomViaPm25Raw; @@ -430,12 +431,15 @@ TEST_CASE("measurement corrections round-trip as grouped settings", "[settings][ original.corrections.humidity.intercept = 1.5f; REQUIRE(save_go_settings(store, original)); + bool retired_use_epa2021 = false; + REQUIRE(store.get_bool("mc_pe", retired_use_epa2021) == ConfigStoreResult::NOT_FOUND); + REQUIRE(store.set_bool("mc_pe", true) == ConfigStoreResult::OK); const GoSettings loaded = load_go_settings(store); REQUIRE(loaded.corrections.pm25.algorithm == original.corrections.pm25.algorithm); REQUIRE(loaded.corrections.pm25.scaling_factor == original.corrections.pm25.scaling_factor); REQUIRE(loaded.corrections.pm25.intercept == original.corrections.pm25.intercept); - REQUIRE(loaded.corrections.pm25.use_epa2021 == original.corrections.pm25.use_epa2021); + REQUIRE_FALSE(loaded.corrections.pm25.use_epa2021); REQUIRE(loaded.corrections.temperature.scaling_factor == original.corrections.temperature.scaling_factor); REQUIRE(loaded.corrections.temperature.intercept == original.corrections.temperature.intercept); 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 2977c0f..ca2c4fe 100644 --- a/products/go/tests/local-server-integration/ago_local_api.py +++ b/products/go/tests/local-server-integration/ago_local_api.py @@ -213,13 +213,9 @@ def _validate_correction(entry: object, measure: str) -> None: assert isinstance(slr, dict) expected = {"intercept", "scalingFactor"} - if measure == "pm25": - expected.add("useEpa2021") assert set(slr) == expected assert _is_number(slr["intercept"]) assert _is_number(slr["scalingFactor"]) - if measure == "pm25": - assert isinstance(slr["useEpa2021"], bool) def validate_config(payload: dict[str, Any]) -> None: