Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions components/airgradient-local-server/services/local_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion components/airgradient-local-server/tests/fake_providers.h
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}
};

Expand Down
15 changes: 14 additions & 1 deletion components/airgradient-local-server/tests/handler.tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<const char *>(resp.body_data()), resp.body_size());
Expand Down Expand Up @@ -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};
Expand All @@ -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);
Expand Down Expand Up @@ -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]") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/local_http_api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions products/go/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -906,8 +907,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:

Expand Down
15 changes: 7 additions & 8 deletions products/go/docs/ble_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|
Expand Down
26 changes: 15 additions & 11 deletions products/go/docs/cloud_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -185,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
Expand All @@ -199,23 +200,26 @@ 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
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

Expand Down
7 changes: 7 additions & 0 deletions products/go/docs/hardware_test.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,13 @@ 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`, 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.

### Accelerometer Test
Expand Down
10 changes: 9 additions & 1 deletion products/go/docs/local_server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down Expand Up @@ -164,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.
Expand Down Expand Up @@ -227,6 +229,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
Expand Down
23 changes: 11 additions & 12 deletions products/go/docs/measurement_corrections.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand All @@ -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`
Expand All @@ -126,16 +123,18 @@ 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.

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),
Expand Down
Loading
Loading