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
16 changes: 12 additions & 4 deletions components/airgradient-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand All @@ -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

Expand Down
13 changes: 11 additions & 2 deletions components/airgradient-client/services/ag_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include <cstring>

#include "ag_log.h"
#include "device_model.h"

#include "ag_server_cert.h"
#include "payload_serializer.h"
Expand Down Expand Up @@ -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<size_t>(n) < size;
}

Expand Down
20 changes: 20 additions & 0 deletions components/airgradient-common/Kconfig
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions components/airgradient-common/include/device_model.h
Original file line number Diff line number Diff line change
@@ -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
17 changes: 13 additions & 4 deletions components/airgradient-ota/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -212,16 +213,23 @@ 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 |
| `CONFIG_AG_OTA_BLE_FW_MAX_LEN` | `32` | Max BLE `fw` string length |
| `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
Expand All @@ -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
Expand Down
36 changes: 15 additions & 21 deletions components/airgradient-ota/services/ota_url.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
#include <cstdio>
#include <cstring>

#include "device_model.h"

namespace ota_url {

namespace {
Expand All @@ -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.
Expand Down
8 changes: 4 additions & 4 deletions components/airgradient-ota/services/ota_url.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 8 additions & 29 deletions components/airgradient-ota/tests/ota_url.tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -15,79 +15,58 @@

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) ==
"http://hw.airgradient.com/sensors/airgradient:aabbccddeeff/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)));
}
}

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)));
Expand Down
6 changes: 0 additions & 6 deletions components/airgradient-ota/types/ota_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
5 changes: 5 additions & 0 deletions products/go/docs/cloud_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<serial>/go/config`. The
measurement POST route remains model-independent.

File-local constants in `go_cloud.cpp`:

| Constant | Value | Notes |
Expand Down
5 changes: 3 additions & 2 deletions products/go/docs/ota_service.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 1 addition & 2 deletions products/go/main/go_ota.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,7 @@ OtaStatus OtaService::run_wifi_check(const std::function<void()> &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
Expand Down
8 changes: 8 additions & 0 deletions products/go/sdkconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
#
Expand Down
3 changes: 3 additions & 0 deletions products/go/sdkconfig.defaults
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading