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
2 changes: 1 addition & 1 deletion components/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ third party can live here if it is part of the shared firmware foundation.
| [`airgradient-ble`](airgradient-ble/README.md) | BLE peripheral HAL and NimBLE-backed GATT server, characteristic management, and advertising control |
| [`airgradient-bms`](airgradient-bms/README.md) | Battery management HAL, public BMS types, and concrete charger / PMIC drivers (e.g. BQ25XX) |
| [`airgradient-cellular`](airgradient-cellular/README.md) | Cellular modem HAL, shared cellular types, AT-command service, and modem drivers (scaffold) |
| [`airgradient-common`](airgradient-common/) | Shared data types, `Measures` types, and the RTOS abstraction (no README yet) |
| [`airgradient-common`](airgradient-common/) | Shared data types, `Measures` types, RTOS abstraction, and retained uptime (no README yet) |
| [`airgradient-config`](airgradient-config/README.md) | Typed key-value persistence interface and reusable backends (NVS) |
| [`airgradient-gpio`](airgradient-gpio/README.md) | GPIO HAL and ESP-IDF-backed driver for pin control and interrupt registration |
| [`airgradient-nand-storage`](airgradient-nand-storage/README.md) | SPI NAND flash HAL providing FATFS mount/unmount lifecycle for application POSIX I/O |
Expand Down
7 changes: 5 additions & 2 deletions components/airgradient-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,15 +91,17 @@ if (!client.begin("aabbccddeeff", NetworkType::Wifi)) {
// omitted by the serializer.
MeasuresBasic m{};
m.temp_hum_a.temperature = 23.5f;
const uint32_t boot_minutes = 6; // Sample product uptime at POST time.

if (client.http_post_measures(m, -55) == AgClientResult::Ok) {
if (client.http_post_measures(m, -55, boot_minutes) == AgClientResult::Ok) {
// shipped
}
```

The same call works with `Measures` (full) and `MeasuresAGo` via
overloads — the appropriate overload is selected at the call site by
type.
type. The caller supplies `boot` as a `uint32_t` device uptime value for every
HTTP measurement POST.

## JSON Payload Contract

Expand All @@ -112,6 +114,7 @@ average.
| Field family | JSON properties | Precision |
|---|---|---|
| Wi-Fi signal | `wifi` | Integer |
| Device uptime | `boot` | Unsigned 32-bit integer |
| CO2 | `rco2` | Integer |
| Temperature / humidity | `atmp`, `rhum` | 2 decimals |
| PM atmospheric mass | `pm01`, `pm02`, `pm10` | 1 decimal |
Expand Down
19 changes: 11 additions & 8 deletions components/airgradient-client/services/ag_client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -140,19 +140,22 @@ AgClientResult AgClient::http_fetch_config(char *config_out, size_t config_size,
return result;
}

AgClientResult AgClient::http_post_measures(const Measures &measures, int signal) {
return _do_http_post_measures(_make_input(measures), signal);
AgClientResult AgClient::http_post_measures(const Measures &measures, int signal, uint32_t boot) {
return _do_http_post_measures(_make_input(measures), signal, boot);
}

AgClientResult AgClient::http_post_measures(const MeasuresBasic &measures, int signal) {
return _do_http_post_measures(_make_input(measures), signal);
AgClientResult AgClient::http_post_measures(const MeasuresBasic &measures, int signal,
uint32_t boot) {
return _do_http_post_measures(_make_input(measures), signal, boot);
}

AgClientResult AgClient::http_post_measures(const MeasuresAGo &measures, int signal) {
return _do_http_post_measures(_make_input(measures), signal);
AgClientResult AgClient::http_post_measures(const MeasuresAGo &measures, int signal,
uint32_t boot) {
return _do_http_post_measures(_make_input(measures), signal, boot);
}

AgClientResult AgClient::_do_http_post_measures(const MeasuresInput &input, int signal) {
AgClientResult AgClient::_do_http_post_measures(const MeasuresInput &input, int signal,
uint32_t boot) {
if (_network != NetworkType::Wifi) {
abort_unsupported("http_post_measures", "called on non-WiFi network");
}
Expand All @@ -169,7 +172,7 @@ AgClientResult AgClient::_do_http_post_measures(const MeasuresInput &input, int

char body[POST_BODY_BUFFER_SIZE];
size_t body_len = 0;
if (!serialize_measures_json(input, signal, body, sizeof(body), &body_len)) {
if (!serialize_measures_json(input, signal, boot, body, sizeof(body), &body_len)) {
AG_LOGE(TAG, "http_post_measures: JSON serialisation failed");
return AgClientResult::TransportError;
}
Expand Down
11 changes: 6 additions & 5 deletions components/airgradient-client/services/ag_client.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#define AG_CLIENT_H

#include <cstddef>
#include <cstdint>
#include <string>

#include "../clients/coap_client.h"
Expand All @@ -31,9 +32,9 @@ class AgClient {
// --- HTTP (WiFi) ---
AgClientResult http_fetch_config(char *config_out, size_t config_size, size_t *bytes_written);

AgClientResult http_post_measures(const Measures &measures, int signal);
AgClientResult http_post_measures(const MeasuresBasic &measures, int signal);
AgClientResult http_post_measures(const MeasuresAGo &measures, int signal);
AgClientResult http_post_measures(const Measures &measures, int signal, uint32_t boot);
AgClientResult http_post_measures(const MeasuresBasic &measures, int signal, uint32_t boot);
AgClientResult http_post_measures(const MeasuresAGo &measures, int signal, uint32_t boot);

// --- CoAP (stubs, abort) ---
AgClientResult coap_fetch_config(char *config_out, size_t config_size, size_t *bytes_written);
Expand Down Expand Up @@ -71,14 +72,14 @@ class AgClient {
static constexpr const char *DEFAULT_HTTP_DOMAIN = "hw.airgradient.com";
static constexpr const char *DEFAULT_COAP_HOST = "128.140.49.53";
static constexpr int DEFAULT_COAP_PORT = 5683;
static constexpr size_t POST_BODY_BUFFER_SIZE = 768;
static constexpr size_t POST_BODY_BUFFER_SIZE = 1024;
static constexpr size_t URL_BUFFER_SIZE = 128;

static MeasuresInput _make_input(const Measures &m);
static MeasuresInput _make_input(const MeasuresBasic &m);
static MeasuresInput _make_input(const MeasuresAGo &m);

AgClientResult _do_http_post_measures(const MeasuresInput &input, int signal);
AgClientResult _do_http_post_measures(const MeasuresInput &input, int signal, uint32_t boot);

bool _build_fetch_config_url(char *buf, size_t size) const;
bool _build_post_measures_url(char *buf, size_t size) const;
Expand Down
14 changes: 11 additions & 3 deletions components/airgradient-client/services/payload_serializer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
namespace {

constexpr const char *JSON_PROP_SIGNAL = "wifi";
constexpr const char *JSON_PROP_BOOT = "boot";
constexpr const char *JSON_PROP_CO2 = "rco2";
constexpr const char *JSON_PROP_TEMP = "atmp";
constexpr const char *JSON_PROP_RHUM = "rhum";
Expand Down Expand Up @@ -227,8 +228,8 @@ void serialize_electrode(cJSON *obj, const O3No2Data *e) {

} // namespace

bool serialize_measures_json(const MeasuresInput &input, int signal, char *out, size_t out_size,
size_t *bytes_written) {
bool serialize_measures_json(const MeasuresInput &input, int signal, uint32_t boot, char *out,
size_t out_size, size_t *bytes_written) {
if (bytes_written != nullptr) {
*bytes_written = 0;
}
Expand All @@ -241,7 +242,14 @@ bool serialize_measures_json(const MeasuresInput &input, int signal, char *out,
return false;
}

add_int(doc, JSON_PROP_SIGNAL, signal); // always included
const bool metadata_added =
cJSON_AddNumberToObject(doc, JSON_PROP_SIGNAL, static_cast<double>(signal)) != nullptr &&
cJSON_AddNumberToObject(doc, JSON_PROP_BOOT, static_cast<double>(boot)) != nullptr;
if (!metadata_added) {
cJSON_Delete(doc);
out[0] = '\0';
return false;
}

serialize_co2(doc, input.co2);
serialize_temp_hum(doc, input.temp_hum_a, input.temp_hum_b);
Expand Down
11 changes: 6 additions & 5 deletions components/airgradient-client/services/payload_serializer.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,16 @@
#define AG_PAYLOAD_SERIALIZER_H

#include <cstddef>
#include <cstdint>

#include "../types/client_types.h"

// Serialize MeasuresInput to AirGradient HTTP JSON. Only fields passing
// is_*_valid() are emitted; dual-channel fields are averaged when both
// channels are valid, otherwise the single valid channel is used.
// Serialize MeasuresInput and request metadata to AirGradient HTTP JSON. Only
// fields passing is_*_valid() are emitted; dual-channel fields are averaged
// when both channels are valid, otherwise the single valid channel is used.
// Writes NUL-terminated JSON; returns false on alloc failure or if `out`
// is too small (*bytes_written = 0).
bool serialize_measures_json(const MeasuresInput &input, int signal, char *out, size_t out_size,
size_t *bytes_written);
bool serialize_measures_json(const MeasuresInput &input, int signal, uint32_t boot, char *out,
size_t out_size, size_t *bytes_written);

#endif // AG_PAYLOAD_SERIALIZER_H
18 changes: 10 additions & 8 deletions components/airgradient-client/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ auto result = client.http_fetch_config(config_buf, sizeof(config_buf), &written)
if (result == AgClientResult::NotRegistered) { /* device not on server */ }
if (result == AgClientResult::BufferTooSmall) { /* config too large */ }

result = client.http_post_measures(measures, signal);
result = client.http_post_measures(measures, signal, boot);

// CoAP (Cellular only --- aborts until cellular backends are implemented)
result = client.coap_fetch_config(config_buf, sizeof(config_buf), &written);
Expand Down Expand Up @@ -177,7 +177,7 @@ public:
AgClientResult http_fetch_config(char *config_out, size_t config_size,
size_t *bytes_written);
AgClientResult http_post_measures(const AgClientMeasuresType &measures,
int signal);
int signal, uint32_t boot);

// --- CoAP (Cellular only --- aborts on WiFi) --- supports batch
AgClientResult coap_fetch_config(char *config_out, size_t config_size,
Expand Down Expand Up @@ -216,7 +216,7 @@ private:
bool build_fetch_config_url(char *buf, size_t size) const;
bool build_post_measures_url(char *buf, size_t size) const;
bool serialize_json(const AgClientMeasuresType &measures,
int signal, char *buf, size_t size,
int signal, uint32_t boot, char *buf, size_t size,
size_t *bytes_written) const;

#ifdef TEST_HOST
Expand Down Expand Up @@ -457,6 +457,7 @@ JSON property names.
| `electrode.no2_ae` | `measure3` | Single (full `Measures` only) |
| `electrode.afe_temp` | `measure4` | Single (full `Measures` only) |
| signal (parameter) | `wifi` | Always included |
| boot (parameter) | `boot` | Always included |

Only valid fields are included (using `is_*_valid()` methods from
`measures_types.h`). If a `Measures` variant does not have a field (e.g.,
Expand Down Expand Up @@ -556,9 +557,9 @@ sequenceDiagram
participant Serializer as PayloadSerializer
participant Http as HttpClient

Caller->>AgClient: http_post_measures(measures, signal)
Caller->>AgClient: http_post_measures(measures, signal, boot)
AgClient->>AgClient: build_post_measures_url()
AgClient->>Serializer: serialize_json(measures, signal)
AgClient->>Serializer: serialize_json(measures, signal, boot)
Serializer-->>AgClient: JSON buffer
AgClient->>Http: post(url, cert, "application/json", body, len, status)
Http-->>AgClient: bool + status_code
Expand Down Expand Up @@ -743,13 +744,13 @@ TEST_CASE("http_post_measures serializes correct JSON") {
m.temp_hum_a.temperature = 23.5f;
m.co2.co2 = 450;

auto result = client.http_post_measures(m, -55);
auto result = client.http_post_measures(m, -55, 6);
REQUIRE(result == AgClientResult::Ok);

// Assert mock_http received:
// URL: https://hw.airgradient.com/sensors/airgradient:aabbccddeeff/measures
// Content-Type: application/json
// Body: {"atmp":23.5,"rco2":450,"wifi":-55}
// Body: {"wifi":-55,"boot":6,"rco2":450,"atmp":23.5}
// (no pm, no tvoc, no humidity --- those were set to invalid)
}

Expand Down Expand Up @@ -803,7 +804,8 @@ AG server semantics.
JSON
- Dual-channel averaging (two valid PM2.5 values produce arithmetic mean;
one valid produces that value; neither valid omits field)
- Measures with no valid fields produce minimal JSON (`{"wifi":-55}`)
- Measures with no valid fields produce minimal JSON
(`{"wifi":-55,"boot":0}`)
- All `Measures` variants (`Measures`, `MeasuresBasic`, `MeasuresAGo`)
serialize without error

Expand Down
18 changes: 11 additions & 7 deletions components/airgradient-client/tests/ag_client.tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ TEST_CASE("http_post_measures builds correct URL and content type", "[ag_client]
f.mock_http.next_transport_ok = true;
f.mock_http.next_status = 200;

const auto result = f.client.http_post_measures(m, -55);
const auto result = f.client.http_post_measures(m, -55, 7);
REQUIRE(result == AgClientResult::Ok);
REQUIRE(f.mock_http.post_call_count == 1);
REQUIRE(f.mock_http.last_url ==
Expand All @@ -75,28 +75,30 @@ TEST_CASE("http_post_measures builds correct URL and content type", "[ag_client]
cJSON *doc = cJSON_Parse(body.c_str());
REQUIRE(doc != nullptr);
REQUIRE(cJSON_GetObjectItem(doc, "wifi") != nullptr);
REQUIRE(cJSON_GetObjectItem(doc, "boot") != nullptr);
REQUIRE(cJSON_GetObjectItem(doc, "boot")->valuedouble == 7.0);
cJSON_Delete(doc);
}

TEST_CASE("http_post_measures maps 429 to Ok", "[ag_client]") {
ClientFixture f;
const auto m = make_invalid_basic();
f.mock_http.next_status = 429;
REQUIRE(f.client.http_post_measures(m, 0) == AgClientResult::Ok);
REQUIRE(f.client.http_post_measures(m, 0, 0) == AgClientResult::Ok);
}

TEST_CASE("http_post_measures returns ServerError on 500", "[ag_client]") {
ClientFixture f;
const auto m = make_invalid_basic();
f.mock_http.next_status = 500;
REQUIRE(f.client.http_post_measures(m, 0) == AgClientResult::ServerError);
REQUIRE(f.client.http_post_measures(m, 0, 0) == AgClientResult::ServerError);
}

TEST_CASE("http_post_measures returns TransportError when HTTP fails", "[ag_client]") {
ClientFixture f;
const auto m = make_invalid_basic();
f.mock_http.next_transport_ok = false;
REQUIRE(f.client.http_post_measures(m, 0) == AgClientResult::TransportError);
REQUIRE(f.client.http_post_measures(m, 0, 0) == AgClientResult::TransportError);
}

TEST_CASE("http_post_measures accepts MeasuresAGo overload", "[ag_client]") {
Expand All @@ -116,12 +118,13 @@ TEST_CASE("http_post_measures accepts MeasuresAGo overload", "[ag_client]") {
m.power.battery_voltage = 4.0f;

f.mock_http.next_status = 200;
REQUIRE(f.client.http_post_measures(m, -50) == AgClientResult::Ok);
REQUIRE(f.client.http_post_measures(m, -50, 8) == AgClientResult::Ok);

std::string body(f.mock_http.last_post_body.begin(), f.mock_http.last_post_body.end());
cJSON *doc = cJSON_Parse(body.c_str());
REQUIRE(doc != nullptr);
REQUIRE(cJSON_GetObjectItem(doc, "volt") != nullptr);
REQUIRE(cJSON_GetObjectItem(doc, "boot")->valuedouble == 8.0);
cJSON_Delete(doc);
}

Expand All @@ -134,14 +137,15 @@ TEST_CASE("http_post_measures accepts full Measures overload", "[ag_client]") {
// Rest zero-initialised -- transport-only test.

f.mock_http.next_status = 200;
REQUIRE(f.client.http_post_measures(m, -40) == AgClientResult::Ok);
REQUIRE(f.client.http_post_measures(m, -40, 9) == AgClientResult::Ok);

std::string body(f.mock_http.last_post_body.begin(), f.mock_http.last_post_body.end());
cJSON *doc = cJSON_Parse(body.c_str());
REQUIRE(doc != nullptr);
cJSON *atmp = cJSON_GetObjectItem(doc, "atmp");
REQUIRE(atmp != nullptr);
REQUIRE(atmp->valuedouble == 21.0); // dual-channel average
REQUIRE(cJSON_GetObjectItem(doc, "boot")->valuedouble == 9.0);
cJSON_Delete(doc);
}

Expand Down Expand Up @@ -231,7 +235,7 @@ TEST_CASE("http_post_measures maps 201 to Ok", "[ag_client]") {
ClientFixture f;
const auto m = make_invalid_basic();
f.mock_http.next_status = 201;
REQUIRE(f.client.http_post_measures(m, 0) == AgClientResult::Ok);
REQUIRE(f.client.http_post_measures(m, 0, 0) == AgClientResult::Ok);
}

TEST_CASE("http_fetch_config: truncation beats 400 status", "[ag_client]") {
Expand Down
Loading
Loading