diff --git a/components/airgradient-client/README.md b/components/airgradient-client/README.md index 1e241ba..8f02304 100644 --- a/components/airgradient-client/README.md +++ b/components/airgradient-client/README.md @@ -75,7 +75,9 @@ caller -> AgClient -> HttpClient (interface) -> WifiHttpClient -> esp_http_clien `AgClient` builds URLs, serializes measures to JSON, and maps transport + status outcomes to `AgClientResult` (`Ok`, `BufferTooSmall`, `TransportError`, `ServerError`, `NotRegistered`). The protocol-specific -interfaces are the mock seam for host tests. +interfaces are the mock seam for host tests. The configured device model +selects the configuration-fetch route at build time; measurement posts retain +the shared `/measures` route. ## Usage @@ -132,10 +134,15 @@ whose native output uses different units are responsible for conversion. | Symbol | Default | Purpose | |---|---|---| +| `CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR` | `y` | Use the `/one/config` configuration route | +| `CONFIG_AG_DEVICE_MODEL_MAX` | `n` | Use the `/one/config` configuration route | +| `CONFIG_AG_DEVICE_MODEL_GO` | `n` | Use the `/go/config` configuration route | | `CONFIG_AG_CLIENT_CELLULAR_SUPPORT` | `n` | Reserved for future cellular work | -The Measures variant is picked per call site by the overload the caller -chooses — there is no compile-time variant selector for this component. +The device-model symbols are mutually exclusive members of the +shared `AG_DEVICE_MODEL` choice from `airgradient-common`. The Measures variant +is picked per call site by the overload the caller chooses; there is no +compile-time Measures variant selector for this component. ## Dependencies @@ -147,7 +154,8 @@ chooses — there is no compile-time variant selector for this component. Host tests live in `components/airgradient-client/tests/` and run through the top-level [tests runner](../../tests/README.md). They use a friend -class (`AgClientTestAccess`) to inject a hand-rolled `MockHttpClient`. +class (`AgClientTestAccess`) to inject a hand-rolled `MockHttpClient`. The host +target uses the default One / Open Air model. ## Validation diff --git a/components/airgradient-client/services/ag_client.cpp b/components/airgradient-client/services/ag_client.cpp index dba6b9c..11b06b3 100644 --- a/components/airgradient-client/services/ag_client.cpp +++ b/components/airgradient-client/services/ag_client.cpp @@ -12,6 +12,7 @@ #include #include "ag_log.h" +#include "device_model.h" #include "ag_server_cert.h" #include "payload_serializer.h" @@ -265,8 +266,16 @@ void AgClient::reset_coap_host() { _coap_host = DEFAULT_COAP_HOST; } // ----------------------------------------------------------------------------- bool AgClient::_build_fetch_config_url(char *buf, size_t size) const { - const int n = std::snprintf(buf, size, "https://%s/sensors/airgradient:%s/one/config", - _http_domain.c_str(), _serial_number); +#if defined(CONFIG_AG_DEVICE_MODEL_GO) + static constexpr const char *CONFIG_PATH = "go/config"; +#elif defined(CONFIG_AG_DEVICE_MODEL_MAX) + static constexpr const char *CONFIG_PATH = "one/config"; +#elif defined(CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR) + static constexpr const char *CONFIG_PATH = "one/config"; +#endif + + const int n = std::snprintf(buf, size, "https://%s/sensors/airgradient:%s/%s", + _http_domain.c_str(), _serial_number, CONFIG_PATH); return n > 0 && static_cast(n) < size; } diff --git a/components/airgradient-common/Kconfig b/components/airgradient-common/Kconfig new file mode 100644 index 0000000..cc62ebe --- /dev/null +++ b/components/airgradient-common/Kconfig @@ -0,0 +1,20 @@ +menu "AirGradient Common" + + choice AG_DEVICE_MODEL + prompt "Device model" + default AG_DEVICE_MODEL_ONE_OPEN_AIR + help + Selects the AirGradient device model for model-specific component + behavior and server endpoints. + + config AG_DEVICE_MODEL_ONE_OPEN_AIR + bool "One / Open Air" + + config AG_DEVICE_MODEL_MAX + bool "Max" + + config AG_DEVICE_MODEL_GO + bool "Go" + endchoice + +endmenu diff --git a/components/airgradient-common/include/device_model.h b/components/airgradient-common/include/device_model.h new file mode 100644 index 0000000..0da2778 --- /dev/null +++ b/components/airgradient-common/include/device_model.h @@ -0,0 +1,25 @@ +/** + * AirGradient + * https://airgradient.com + * + * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License + */ + +#ifndef AG_DEVICE_MODEL_H +#define AG_DEVICE_MODEL_H + +#if defined(__has_include) && __has_include("sdkconfig.h") +#include "sdkconfig.h" +#endif + +#if !defined(CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR) && !defined(CONFIG_AG_DEVICE_MODEL_MAX) && \ + !defined(CONFIG_AG_DEVICE_MODEL_GO) && defined(TEST_HOST) +#define CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR 1 +#endif + +#if (defined(CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR) + defined(CONFIG_AG_DEVICE_MODEL_MAX) + \ + defined(CONFIG_AG_DEVICE_MODEL_GO)) != 1 +#error "Exactly one AirGradient device model must be selected" +#endif + +#endif // AG_DEVICE_MODEL_H diff --git a/components/airgradient-ota/README.md b/components/airgradient-ota/README.md index 57d164b..af0cc3c 100644 --- a/components/airgradient-ota/README.md +++ b/components/airgradient-ota/README.md @@ -108,7 +108,8 @@ flowchart TB `OtaUpdater::run()` is a single blocking call: `open -> begin -> loop(read -> write, throttled progress) -> finish`. It aborts the writer on any read/write error and always closes the source. The URL builder centralises the AirGradient -URL conventions, mapping `OtaDeviceModel` to the path segment and serial format. +URL conventions, mapping the Kconfig-selected device model to the path segment +and serial format at build time. ### BLE push (`OtaBleService`) @@ -152,7 +153,7 @@ window (30–50 ms) on a non-success terminal, so the product does not touch ## Usage ```cpp -OtaRequest req{serial, current_fw, "hw.airgradient.com", OtaDeviceModel::OneOpenAir}; +OtaRequest req{serial, current_fw, "hw.airgradient.com"}; WifiHttpOtaSource source(req); EspOtaImageWriter writer; OtaUpdater updater(source, writer); @@ -212,9 +213,12 @@ The component exposes Kconfig knobs under **AirGradient OTA** in `menuconfig` | Symbol | Default | Purpose | |---|---|---| +| `CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR` | `y` | Use the One / Open Air firmware URL shape | +| `CONFIG_AG_DEVICE_MODEL_MAX` | `n` | Use the Max firmware URL shape | +| `CONFIG_AG_DEVICE_MODEL_GO` | `n` | Use the Go firmware URL shape | | `CONFIG_AG_OTA_HTTP_TIMEOUT_MS` | `15000` | HTTP connect/read timeout | | `CONFIG_AG_OTA_READ_BUFFER_SIZE` | `1024` | Per-read download/flash buffer | -| `CONFIG_AG_OTA_PROGRESS_INTERVAL_MS` | `250` | Minimum gap between progress callbacks | +| `CONFIG_AG_OTA_PROGRESS_INTERVAL_MS` | `1000` | Minimum gap between progress callbacks | | `CONFIG_AG_OTA_URL_BUFFER_SIZE` | `256` | Max built firmware URL length | | `CONFIG_AG_OTA_BLE_DATA_MAX_BYTES` | `512` | Max accepted single BLE Data write; larger writes are rejected | | `CONFIG_AG_OTA_BLE_CONTROL_MAX_BYTES` | `64` | Max accepted BLE Control write size | @@ -222,6 +226,10 @@ The component exposes Kconfig knobs under **AirGradient OTA** in `menuconfig` | `CONFIG_AG_OTA_BLE_STALL_TIMEOUT_MS` | `10000` | BLE silent-phone byte-progress watchdog window | | `CONFIG_AG_OTA_BLE_PROGRESS_INTERVAL_MS` | `5000` | BLE `run()` tick: progress log + progress NOTIFY cadence, stall granularity | +The device-model symbols are mutually exclusive members of the +shared `AG_DEVICE_MODEL` choice from `airgradient-common`. `OtaRequest` contains +only per-request values; callers cannot change the model at runtime. + The OTA connection-interval window is owned by `OtaBleService`: it requests the fast window (15–30 ms, latency 0, 2 s supervision) on `begin()` and restores the relaxed window (30–50 ms) on a non-success terminal. These are fixed constants @@ -241,7 +249,8 @@ product / BLE-stack concern (`CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU`). Host tests live in `components/airgradient-ota/tests/` and run through the top-level [tests runner](../../tests/README.md). They cover the `ota_url` -mapping and the `OtaUpdater` flow (ordering, skip/abort/truncation paths, +mapping for the default One / Open Air model and the `OtaUpdater` flow +(ordering, skip/abort/truncation paths, byte accounting, progress state sequence, and callback throttling) against a Trompeloeil mock source and a host fake writer, plus the `OtaBleService` protocol/state core (CBOR Control decode + bounds, wire constants, the diff --git a/components/airgradient-ota/services/ota_url.cpp b/components/airgradient-ota/services/ota_url.cpp index 6a77973..f66eb07 100644 --- a/components/airgradient-ota/services/ota_url.cpp +++ b/components/airgradient-ota/services/ota_url.cpp @@ -10,6 +10,8 @@ #include #include +#include "device_model.h" + namespace ota_url { namespace { @@ -29,27 +31,19 @@ bool build(const OtaRequest &req, char *out, size_t out_size) { return false; } - int written = -1; - switch (req.model) { - case OtaDeviceModel::OneOpenAir: - written = std::snprintf( - out, out_size, - "http://%s/sensors/airgradient:%s/generic/os/firmware.bin?current_firmware=%s", - req.http_domain, req.serial_number, req.current_firmware); - break; - case OtaDeviceModel::Max: - written = - std::snprintf(out, out_size, "http://%s/sensors/%s/max/firmware.bin?current_firmware=%s", - req.http_domain, req.serial_number, req.current_firmware); - break; - case OtaDeviceModel::Go: - written = std::snprintf(out, out_size, - "http://%s/sensors/airgradient:%s/go/firmware.bin?current_firmware=%s", - req.http_domain, req.serial_number, req.current_firmware); - break; - default: - return false; // unknown model - } +#if defined(CONFIG_AG_DEVICE_MODEL_MAX) + const int written = + std::snprintf(out, out_size, "http://%s/sensors/%s/max/firmware.bin?current_firmware=%s", + req.http_domain, req.serial_number, req.current_firmware); +#elif defined(CONFIG_AG_DEVICE_MODEL_GO) + const int written = std::snprintf( + out, out_size, "http://%s/sensors/airgradient:%s/go/firmware.bin?current_firmware=%s", + req.http_domain, req.serial_number, req.current_firmware); +#elif defined(CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR) + const int written = std::snprintf( + out, out_size, "http://%s/sensors/airgradient:%s/generic/os/firmware.bin?current_firmware=%s", + req.http_domain, req.serial_number, req.current_firmware); +#endif // snprintf returns the length it would have written; >= out_size means the // URL was truncated. diff --git a/components/airgradient-ota/services/ota_url.h b/components/airgradient-ota/services/ota_url.h index f4c30cb..8835f47 100644 --- a/components/airgradient-ota/services/ota_url.h +++ b/components/airgradient-ota/services/ota_url.h @@ -16,10 +16,10 @@ // conventions; shared by all pull transports (WiFi now, cellular later). namespace ota_url { -// Builds the base firmware URL from req. Maps req.model to the path/serial -// shape and appends ?current_firmware={fw}. Callers may append -// transport-specific params (e.g. cellular &offset=&length=&iccid=). -// Returns false on truncation, missing required fields, or unknown model. +// Builds the model-specific base firmware URL from req and appends +// ?current_firmware={fw}. Callers may append transport-specific params +// (e.g. cellular &offset=&length=&iccid=). Returns false on truncation or +// missing required fields. bool build(const OtaRequest &req, char *out, size_t out_size); } // namespace ota_url diff --git a/components/airgradient-ota/tests/ota_url.tests.cpp b/components/airgradient-ota/tests/ota_url.tests.cpp index d50afe9..7b82dd9 100644 --- a/components/airgradient-ota/tests/ota_url.tests.cpp +++ b/components/airgradient-ota/tests/ota_url.tests.cpp @@ -15,16 +15,14 @@ namespace { -OtaRequest make_request(OtaDeviceModel model) { - return OtaRequest{"aabbccddeeff", "3.1.21", "hw.airgradient.com", model}; -} +OtaRequest make_request() { return OtaRequest{"aabbccddeeff", "3.1.21", "hw.airgradient.com"}; } } // namespace TEST_CASE("ota_url builds OneOpenAir URL with airgradient: prefix and generic/os path", "[ota_url]") { char url[256] = {0}; - const OtaRequest req = make_request(OtaDeviceModel::OneOpenAir); + const OtaRequest req = make_request(); REQUIRE(ota_url::build(req, url, sizeof(url))); REQUIRE(std::string(url) == @@ -32,48 +30,29 @@ TEST_CASE("ota_url builds OneOpenAir URL with airgradient: prefix and generic/os "firmware.bin?current_firmware=3.1.21"); } -TEST_CASE("ota_url builds Max URL with bare serial and max path", "[ota_url]") { - char url[256] = {0}; - const OtaRequest req = make_request(OtaDeviceModel::Max); - - REQUIRE(ota_url::build(req, url, sizeof(url))); - REQUIRE( - std::string(url) == - "http://hw.airgradient.com/sensors/aabbccddeeff/max/firmware.bin?current_firmware=3.1.21"); -} - -TEST_CASE("ota_url builds Go URL with airgradient: prefix and go path", "[ota_url]") { - char url[256] = {0}; - const OtaRequest req = make_request(OtaDeviceModel::Go); - - REQUIRE(ota_url::build(req, url, sizeof(url))); - REQUIRE(std::string(url) == "http://hw.airgradient.com/sensors/airgradient:aabbccddeeff/go/" - "firmware.bin?current_firmware=3.1.21"); -} - TEST_CASE("ota_url rejects missing required fields", "[ota_url]") { char url[256] = {0}; SECTION("null serial") { - OtaRequest req = make_request(OtaDeviceModel::OneOpenAir); + OtaRequest req = make_request(); req.serial_number = nullptr; REQUIRE_FALSE(ota_url::build(req, url, sizeof(url))); } SECTION("empty serial") { - OtaRequest req = make_request(OtaDeviceModel::OneOpenAir); + OtaRequest req = make_request(); req.serial_number = ""; REQUIRE_FALSE(ota_url::build(req, url, sizeof(url))); } SECTION("null current_firmware") { - OtaRequest req = make_request(OtaDeviceModel::OneOpenAir); + OtaRequest req = make_request(); req.current_firmware = nullptr; REQUIRE_FALSE(ota_url::build(req, url, sizeof(url))); } SECTION("null http_domain") { - OtaRequest req = make_request(OtaDeviceModel::OneOpenAir); + OtaRequest req = make_request(); req.http_domain = nullptr; REQUIRE_FALSE(ota_url::build(req, url, sizeof(url))); } @@ -81,13 +60,13 @@ TEST_CASE("ota_url rejects missing required fields", "[ota_url]") { TEST_CASE("ota_url fails on truncation", "[ota_url]") { char url[32] = {0}; - const OtaRequest req = make_request(OtaDeviceModel::OneOpenAir); + const OtaRequest req = make_request(); REQUIRE_FALSE(ota_url::build(req, url, sizeof(url))); } TEST_CASE("ota_url rejects null/zero-size output buffer", "[ota_url]") { - const OtaRequest req = make_request(OtaDeviceModel::OneOpenAir); + const OtaRequest req = make_request(); char url[256] = {0}; REQUIRE_FALSE(ota_url::build(req, nullptr, sizeof(url))); diff --git a/components/airgradient-ota/types/ota_types.h b/components/airgradient-ota/types/ota_types.h index d8ea6ce..c4d47ac 100644 --- a/components/airgradient-ota/types/ota_types.h +++ b/components/airgradient-ota/types/ota_types.h @@ -50,11 +50,6 @@ enum class OtaState : uint8_t { Failed }; -// AirGradient device model. The caller selects a model; the OTA component -// translates it to the server URL shape (path segment + serial format). -// Extend this enum as new models are supported. -enum class OtaDeviceModel : uint8_t { OneOpenAir, Max, Go }; - struct OtaProgress { OtaState state; size_t bytes_written; @@ -72,7 +67,6 @@ struct OtaRequest { const char *serial_number; // e.g. "aabbccddeeff" const char *current_firmware; // e.g. "3.1.21" const char *http_domain; // e.g. "hw.airgradient.com" - OtaDeviceModel model; // device model; OTA maps it to the URL shape }; #endif // AG_OTA_TYPES_H diff --git a/products/go/docs/cloud_service.md b/products/go/docs/cloud_service.md index 6646484..e11e127 100644 --- a/products/go/docs/cloud_service.md +++ b/products/go/docs/cloud_service.md @@ -54,6 +54,11 @@ See [`go_cloud.h`](../main/go_cloud.h) for full signatures. | `disable_cloud` | `false` | Initial value; runtime changes via `set_disable_cloud()` | | `config_fetch_enabled` | `true` | Initial independent FETCH gate; runtime follows `configuration_control != Local` | +The Go product selects `CONFIG_AG_DEVICE_MODEL_GO`, so configuration FETCH +requests use +`https://hw.airgradient.com/sensors/airgradient:/go/config`. The +measurement POST route remains model-independent. + File-local constants in `go_cloud.cpp`: | Constant | Value | Notes | diff --git a/products/go/docs/ota_service.md b/products/go/docs/ota_service.md index a9967e6..2afbe56 100644 --- a/products/go/docs/ota_service.md +++ b/products/go/docs/ota_service.md @@ -138,8 +138,9 @@ so a flashed image boots directly with no mark-valid step and no automatic rever ## Configuration -The component's `CONFIG_AG_OTA_*` Kconfig knobs are reused as-is (timeouts, -buffer sizes, BLE Data/Control limits — see +The product selects `CONFIG_AG_DEVICE_MODEL_GO`, so WiFi pull uses the Go +firmware endpoint. The component's `CONFIG_AG_OTA_*` Kconfig knobs are reused +as-is (timeouts, buffer sizes, BLE Data/Control limits — see [`airgradient-ota`](../../../components/airgradient-ota/README.md)). The product sets `CONFIG_BT_NIMBLE_ATT_PREFERRED_MTU = 512` and provisions the NimBLE mbuf pool in `sdkconfig.defaults` for the larger MTU (at MTU 512 the BLE Data diff --git a/products/go/main/go_ota.cpp b/products/go/main/go_ota.cpp index 5d52dba..31ac775 100644 --- a/products/go/main/go_ota.cpp +++ b/products/go/main/go_ota.cpp @@ -39,8 +39,7 @@ OtaStatus OtaService::run_wifi_check(const std::function &on_download_st _wifi_download_painted = false; // re-arm the one-shot lazy paint _on_download_started = on_download_started; // store for the named handler - OtaRequest request{_config.serial_number, _config.firmware_version, _config.http_domain, - OtaDeviceModel::Go}; + OtaRequest request{_config.serial_number, _config.firmware_version, _config.http_domain}; WifiHttpOtaSource source(request); OtaUpdater updater(source, _writer); updater.set_on_progress([this](const OtaProgress &p) { _on_wifi_progress(p); }); // thin forwarder diff --git a/products/go/sdkconfig b/products/go/sdkconfig index 45cc658..1a15dd1 100644 --- a/products/go/sdkconfig +++ b/products/go/sdkconfig @@ -2537,6 +2537,14 @@ CONFIG_WL_SECTOR_SIZE=4096 # CONFIG_AG_CLIENT_CELLULAR_SUPPORT is not set # end of AirGradient Client +# +# AirGradient Common +# +# CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR is not set +# CONFIG_AG_DEVICE_MODEL_MAX is not set +CONFIG_AG_DEVICE_MODEL_GO=y +# end of AirGradient Common + # # AirGradient HTTP Server # diff --git a/products/go/sdkconfig.defaults b/products/go/sdkconfig.defaults index 567fc84..a92b399 100644 --- a/products/go/sdkconfig.defaults +++ b/products/go/sdkconfig.defaults @@ -1,5 +1,8 @@ # AirGradient Go product defaults. +# AirGradient device model. +CONFIG_AG_DEVICE_MODEL_GO=y + # PayloadCache: store MeasuresAGo (temp/hum, PM, CO2, TVOC/NOx, power) rather # than the full Measures struct. Go does not use the secondary sensor channels # (temp_hum_b, pm_b, electrode) in its chart cache. diff --git a/products/reference/main/test_ota.cpp b/products/reference/main/test_ota.cpp index d0b6f04..36b5427 100644 --- a/products/reference/main/test_ota.cpp +++ b/products/reference/main/test_ota.cpp @@ -226,8 +226,7 @@ static constexpr const char *SERIAL_NUMBER = "aabbccddeeff"; static constexpr const char *CURRENT_FIRMWARE = "3.6.0"; // Empty -> use compiled-in "hw.airgradient.com". static constexpr const char *HTTP_DOMAIN = "hw.airgradient.com"; -// Device model -> URL shape mapping (OneOpenAir or Max). -static constexpr OtaDeviceModel DEVICE_MODEL = OtaDeviceModel::OneOpenAir; +// URL shape is selected by CONFIG_AG_DEVICE_MODEL_*. // ---------------------------------------------------------------------- static constexpr int WIFI_CONNECTED_BIT = BIT0; @@ -323,7 +322,7 @@ bool run_case(const TestCase &tc) { ESP_LOGI(TAG, " expect=%s", status_to_str(tc.expected)); const char *domain = (tc.domain_override[0] != '\0') ? tc.domain_override : HTTP_DOMAIN; - OtaRequest req{tc.serial_number, tc.current_firmware, domain, DEVICE_MODEL}; + OtaRequest req{tc.serial_number, tc.current_firmware, domain}; WifiHttpOtaSource source(req); EspOtaImageWriter writer; diff --git a/products/reference/sdkconfig b/products/reference/sdkconfig index d624257..a1e21ea 100644 --- a/products/reference/sdkconfig +++ b/products/reference/sdkconfig @@ -2500,6 +2500,14 @@ CONFIG_WL_SECTOR_SIZE=4096 # CONFIG_AG_CLIENT_CELLULAR_SUPPORT is not set # end of AirGradient Client +# +# AirGradient Common +# +CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR=y +# CONFIG_AG_DEVICE_MODEL_MAX is not set +# CONFIG_AG_DEVICE_MODEL_GO is not set +# end of AirGradient Common + # # AirGradient HTTP Server # diff --git a/products/reference/sdkconfig.defaults b/products/reference/sdkconfig.defaults index 94a0ec8..3e18681 100644 --- a/products/reference/sdkconfig.defaults +++ b/products/reference/sdkconfig.defaults @@ -1,6 +1,9 @@ # Thin reference product defaults. # Add product-specific sdkconfig settings here as the first real product takes shape. +# AirGradient device model. +CONFIG_AG_DEVICE_MODEL_ONE_OPEN_AIR=y + # ESP32-C5: the GPIO light-sleep reset workaround calls gpio_sleep_sel_en() # for every GPIO via REGDMA, which stalls indefinitely on this chip during # early startup before the scheduler is running. Disable it until the