From 61ef2e300a531b57414ba2f013ce98b7fd5cc263 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 24 Jul 2026 21:09:28 +0300 Subject: [PATCH 1/3] fix(serialization): align measure precision --- components/airgradient-client/README.md | 4 +- .../services/payload_serializer.cpp | 36 +++++++----- components/airgradient-client/spec.md | 4 ++ .../tests/payload_serializer.tests.cpp | 43 ++++++++++++++ components/airgradient-local-server/README.md | 3 + .../internal/measures_json.cpp | 41 ++++++++++--- components/airgradient-local-server/spec.md | 3 + .../tests/measures_json.tests.cpp | 58 +++++++++++++++++++ 8 files changed, 166 insertions(+), 26 deletions(-) diff --git a/components/airgradient-client/README.md b/components/airgradient-client/README.md index e4deeaf..705c732 100644 --- a/components/airgradient-client/README.md +++ b/components/airgradient-client/README.md @@ -121,8 +121,8 @@ average. | PM standard mass | `pm01Standard`, `pm02Standard`, `pm10Standard` | 1 decimal | | PM particle counts | `pm003Count`, `pm005Count`, `pm01Count`, `pm02Count`, `pm50Count`, `pm10Count` | Integer | | TVOC / NOx | `tvocIndex`, `tvocRaw`, `noxIndex`, `noxRaw` | Integer | -| Power | `volt`, `light` | Unrounded float | -| O3 / NO2 electrodes | `measure0` through `measure4` | Unrounded float | +| Power | `volt`, `light` | 2 decimals | +| O3 / NO2 electrodes | `measure0` through `measure4` | 3 decimals | Particle-count units follow the shared `PMData` convention: counts are stored as particles per 0.1 L before they reach this serializer. Drivers diff --git a/components/airgradient-client/services/payload_serializer.cpp b/components/airgradient-client/services/payload_serializer.cpp index 16926d3..3ee1cde 100644 --- a/components/airgradient-client/services/payload_serializer.cpp +++ b/components/airgradient-client/services/payload_serializer.cpp @@ -48,16 +48,10 @@ constexpr const char *JSON_PROP_AFE_TEMP = "measure4"; constexpr int DECIMALS_INT = 0; constexpr int DECIMALS_PM_MASS = 1; constexpr int DECIMALS_TEMP_HUM = 2; +constexpr int DECIMALS_VOLT = 2; +constexpr int DECIMALS_ELECTRODE = 3; -inline void add_int(cJSON *obj, const char *name, int value) { - cJSON_AddNumberToObject(obj, name, static_cast(value)); -} - -inline void add_float(cJSON *obj, const char *name, float value) { - cJSON_AddNumberToObject(obj, name, static_cast(value)); -} - -// Round-half-away-from-zero to `decimals` places. decimals==0 yields an +// Round-half-away-from-zero to `decimals` places. decimals==0 yields an // integer-valued double, which cJSON prints without a decimal point. inline double round_to_decimals(double value, int decimals) { switch (decimals) { @@ -67,11 +61,21 @@ inline double round_to_decimals(double value, int decimals) { return std::round(value * 10.0) / 10.0; case 2: return std::round(value * 100.0) / 100.0; + case 3: + return std::round(value * 1000.0) / 1000.0; default: return value; } } +inline void add_int(cJSON *obj, const char *name, int value) { + cJSON_AddNumberToObject(obj, name, static_cast(value)); +} + +inline void add_float(cJSON *obj, const char *name, float value, int decimals) { + cJSON_AddNumberToObject(obj, name, round_to_decimals(static_cast(value), decimals)); +} + // Mean if both valid, single value if one, omit if neither. Rounded to // `decimals` places after the dual-channel reduction so the JSON output // matches the server-side numeric contract. @@ -198,10 +202,10 @@ void serialize_power(cJSON *obj, const MeasuresPower *p) { return; } if (p->is_battery_voltage_valid()) { - add_float(obj, JSON_PROP_VBATT, p->battery_voltage); + add_float(obj, JSON_PROP_VBATT, p->battery_voltage, DECIMALS_VOLT); } if (p->is_charging_voltage_valid()) { - add_float(obj, JSON_PROP_VPANEL, p->charging_voltage); + add_float(obj, JSON_PROP_VPANEL, p->charging_voltage, DECIMALS_VOLT); } } @@ -210,19 +214,19 @@ void serialize_electrode(cJSON *obj, const O3No2Data *e) { return; } if (e->is_o3_working_valid()) { - add_float(obj, JSON_PROP_O3_WE, e->o3_we); + add_float(obj, JSON_PROP_O3_WE, e->o3_we, DECIMALS_ELECTRODE); } if (e->is_o3_auxiliary_valid()) { - add_float(obj, JSON_PROP_O3_AE, e->o3_ae); + add_float(obj, JSON_PROP_O3_AE, e->o3_ae, DECIMALS_ELECTRODE); } if (e->is_no2_working_valid()) { - add_float(obj, JSON_PROP_NO2_WE, e->no2_we); + add_float(obj, JSON_PROP_NO2_WE, e->no2_we, DECIMALS_ELECTRODE); } if (e->is_no2_auxiliary_valid()) { - add_float(obj, JSON_PROP_NO2_AE, e->no2_ae); + add_float(obj, JSON_PROP_NO2_AE, e->no2_ae, DECIMALS_ELECTRODE); } if (e->is_afe_temp_valid()) { - add_float(obj, JSON_PROP_AFE_TEMP, e->afe_temp); + add_float(obj, JSON_PROP_AFE_TEMP, e->afe_temp, DECIMALS_ELECTRODE); } } diff --git a/components/airgradient-client/spec.md b/components/airgradient-client/spec.md index 12f8c67..69dd3cd 100644 --- a/components/airgradient-client/spec.md +++ b/components/airgradient-client/spec.md @@ -463,6 +463,10 @@ Only valid fields are included (using `is_*_valid()` methods from `measures_types.h`). If a `Measures` variant does not have a field (e.g., `MeasuresAGo` has no `electrode`), it is simply absent from the JSON. +Numeric values are rounded before serialization: temperature and humidity, +plus power voltages, use two decimal places; PM mass uses one; electrode values +use three; and CO2, particle counts, TVOC, and NOx use integers. + ### Dual-Channel Handling For products with two PM sensors and/or two temp/hum sensors (full diff --git a/components/airgradient-client/tests/payload_serializer.tests.cpp b/components/airgradient-client/tests/payload_serializer.tests.cpp index eb12ee4..4c1c744 100644 --- a/components/airgradient-client/tests/payload_serializer.tests.cpp +++ b/components/airgradient-client/tests/payload_serializer.tests.cpp @@ -5,6 +5,7 @@ * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License */ +#include #include #include #include @@ -228,6 +229,48 @@ TEST_CASE("electrode fields serialised when valid", "[payload_serializer]") { REQUIRE_FALSE(p.has("measure1")); } +TEST_CASE("power and electrode fields use their contract precision", "[payload_serializer]") { + auto m = make_invalid_measures(); + m.power.battery_voltage = 3.4567f; + m.power.charging_voltage = 4.3214f; + m.electrode.o3_we = 0.1236f; + m.electrode.o3_ae = 1.2346f; + m.electrode.no2_we = 2.3456f; + m.electrode.no2_ae = 3.4564f; + m.electrode.afe_temp = 4.5678f; + + const auto in = input_from_full(m); + char buf[512]; + size_t written = 0; + REQUIRE(serialize_measures_json(in, 0, 0, buf, sizeof(buf), &written)); + + ParsedJson p(buf); + REQUIRE_THAT(p.number("volt"), Catch::Matchers::WithinAbs(3.46, 0.001)); + REQUIRE_THAT(p.number("light"), Catch::Matchers::WithinAbs(4.32, 0.001)); + REQUIRE_THAT(p.number("measure0"), Catch::Matchers::WithinAbs(0.124, 0.001)); + REQUIRE_THAT(p.number("measure1"), Catch::Matchers::WithinAbs(1.235, 0.001)); + REQUIRE_THAT(p.number("measure2"), Catch::Matchers::WithinAbs(2.346, 0.001)); + REQUIRE_THAT(p.number("measure3"), Catch::Matchers::WithinAbs(3.456, 0.001)); + REQUIRE_THAT(p.number("measure4"), Catch::Matchers::WithinAbs(4.568, 0.001)); + + for (const char *key : {"volt", "light"}) { + const std::string raw = raw_number_str(buf, key); + REQUIRE_FALSE(raw.empty()); + const auto dot = raw.find('.'); + if (dot != std::string::npos) { + REQUIRE((raw.size() - dot - 1) <= 2); + } + } + for (const char *key : {"measure0", "measure1", "measure2", "measure3", "measure4"}) { + const std::string raw = raw_number_str(buf, key); + REQUIRE_FALSE(raw.empty()); + const auto dot = raw.find('.'); + if (dot != std::string::npos) { + REQUIRE((raw.size() - dot - 1) <= 3); + } + } +} + TEST_CASE("basic-variant view omits dual channel and electrode fields", "[payload_serializer]") { auto m = make_invalid_measures(); // Valid data here should still be omitted -- the Basic view skips them. diff --git a/components/airgradient-local-server/README.md b/components/airgradient-local-server/README.md index a6d576c..afd7a2a 100644 --- a/components/airgradient-local-server/README.md +++ b/components/airgradient-local-server/README.md @@ -170,6 +170,9 @@ cover each missing SLR coefficient independently. http-server's default `404` and are not wrapped in the structured envelope. - Successful actions return an empty `200` without a content type. Temporary action queue pressure returns structured `503 busy`. +- Local measurement precision matches the cloud payload: PM mass uses one + decimal place, temperature and humidity use two, and particle counts are + integers. - Extended measurement groups (battery / pressure / electrode / dual-channel) and product-specific config fields are deferred; add them as flat optional fields when a product exposes them. diff --git a/components/airgradient-local-server/internal/measures_json.cpp b/components/airgradient-local-server/internal/measures_json.cpp index 319617b..8441de7 100644 --- a/components/airgradient-local-server/internal/measures_json.cpp +++ b/components/airgradient-local-server/internal/measures_json.cpp @@ -7,12 +7,39 @@ #include "internal/measures_json.h" +#include #include #include #include "internal/field_names.h" +namespace { + +// Keep local API precision aligned with the cloud measurement payload. +constexpr int DECIMALS_INT = 0; +constexpr int DECIMALS_PM_MASS = 1; +constexpr int DECIMALS_TEMP_HUM = 2; + +double round_to_decimals(double value, int decimals) { + switch (decimals) { + case 0: + return std::round(value); + case 1: + return std::round(value * 10.0) / 10.0; + case 2: + return std::round(value * 100.0) / 100.0; + default: + return value; + } +} + +void add_float(cJSON *root, const char *name, float value, int decimals) { + cJSON_AddNumberToObject(root, name, round_to_decimals(static_cast(value), decimals)); +} + +} // namespace + namespace measures_json { size_t serialize(const Measures &measures, const SystemInfo &info, char *buf, size_t buf_len) { @@ -39,24 +66,22 @@ size_t serialize(const Measures &measures, const SystemInfo &info, char *buf, si cJSON_AddNumberToObject(root, fields::CO2, static_cast(measures.co2.co2)); } if (measures.pm_a.is_pm_01_valid()) { - cJSON_AddNumberToObject(root, fields::PM01, static_cast(measures.pm_a.pm_01)); + add_float(root, fields::PM01, measures.pm_a.pm_01, DECIMALS_PM_MASS); } if (measures.pm_a.is_pm_25_valid()) { - cJSON_AddNumberToObject(root, fields::PM25, static_cast(measures.pm_a.pm_25)); + add_float(root, fields::PM25, measures.pm_a.pm_25, DECIMALS_PM_MASS); } if (measures.pm_a.is_pm_10_valid()) { - cJSON_AddNumberToObject(root, fields::PM10, static_cast(measures.pm_a.pm_10)); + add_float(root, fields::PM10, measures.pm_a.pm_10, DECIMALS_PM_MASS); } if (measures.pm_a.is_pm_03_pc_valid()) { - cJSON_AddNumberToObject(root, fields::PM003_COUNT, static_cast(measures.pm_a.pm_03_pc)); + add_float(root, fields::PM003_COUNT, measures.pm_a.pm_03_pc, DECIMALS_INT); } if (measures.temp_hum_a.is_temp_valid()) { - cJSON_AddNumberToObject(root, fields::TEMP, - static_cast(measures.temp_hum_a.temperature)); + add_float(root, fields::TEMP, measures.temp_hum_a.temperature, DECIMALS_TEMP_HUM); } if (measures.temp_hum_a.is_hum_valid()) { - cJSON_AddNumberToObject(root, fields::HUMIDITY, - static_cast(measures.temp_hum_a.humidity)); + add_float(root, fields::HUMIDITY, measures.temp_hum_a.humidity, DECIMALS_TEMP_HUM); } if (measures.tvoc_nox.is_tvoc_index_valid()) { cJSON_AddNumberToObject(root, fields::TVOC_INDEX, diff --git a/components/airgradient-local-server/spec.md b/components/airgradient-local-server/spec.md index 2273756..39439f3 100644 --- a/components/airgradient-local-server/spec.md +++ b/components/airgradient-local-server/spec.md @@ -431,6 +431,9 @@ Wire field names are camelCase; the legacy vocabulary is kept where it was already clear and renamed only where it misled or was opaque (see `api-v1-naming-decision.md`). +Numeric precision matches the cloud measurement payload: `temp` and `humidity` +use two decimal places, PM mass uses one, and `pm003Count` is an integer. + ```json { "serialNumber": "aabbccddeeff", "model": "O-1PST", "firmware": "2.0.0", "wifiRssi": -57, "boot": 6, "co2": 612, "pm01": 5, "pm25": 8, "pm10": 9, diff --git a/components/airgradient-local-server/tests/measures_json.tests.cpp b/components/airgradient-local-server/tests/measures_json.tests.cpp index 410f439..6cc202c 100644 --- a/components/airgradient-local-server/tests/measures_json.tests.cpp +++ b/components/airgradient-local-server/tests/measures_json.tests.cpp @@ -5,8 +5,10 @@ * CC BY-SA 4.0 Attribution-ShareAlike 4.0 International License */ +#include #include #include +#include #include #include @@ -36,6 +38,21 @@ cJSON *serialize_and_parse(const Measures &m, const SystemInfo &info) { return root; } +std::string raw_number_str(const char *json, const char *key) { + const std::string needle = std::string("\"") + key + "\":"; + const char *number = std::strstr(json, needle.c_str()); + if (number == nullptr) { + return {}; + } + number += needle.size(); + const char *end = number; + while (*end && (std::isdigit(static_cast(*end)) || *end == '.' || *end == '-' || + *end == 'e' || *end == 'E' || *end == '+')) { + ++end; + } + return std::string(number, end); +} + } // namespace TEST_CASE("measures: identity always present, no measurement keys when invalid", "[measures]") { @@ -125,6 +142,47 @@ TEST_CASE("measures: pm003Count maps from pm_03_pc", "[measures]") { cJSON_Delete(root); } +TEST_CASE("measures: float fields use cloud payload precision", "[measures]") { + Measures m; + m.pm_a.pm_01 = 5.678f; + m.pm_a.pm_25 = 8.123f; + m.pm_a.pm_10 = 9.456f; + m.pm_a.pm_03_pc = 1234.7f; + m.temp_hum_a.temperature = 24.346f; + m.temp_hum_a.humidity = 47.126f; + const SystemInfo info = make_info(); + char buf[1024] = {}; + + REQUIRE(measures_json::serialize(m, info, buf, sizeof(buf)) > 0); + cJSON *root = cJSON_Parse(buf); + REQUIRE(root != nullptr); + REQUIRE(cJSON_GetObjectItem(root, "pm01")->valuedouble == 5.7); + REQUIRE(cJSON_GetObjectItem(root, "pm25")->valuedouble == 8.1); + REQUIRE(cJSON_GetObjectItem(root, "pm10")->valuedouble == 9.5); + REQUIRE(cJSON_GetObjectItem(root, "pm003Count")->valuedouble == 1235.0); + REQUIRE(cJSON_GetObjectItem(root, "temp")->valuedouble == 24.35); + REQUIRE(cJSON_GetObjectItem(root, "humidity")->valuedouble == 47.13); + cJSON_Delete(root); + + for (const char *key : {"pm01", "pm25", "pm10"}) { + const std::string raw = raw_number_str(buf, key); + REQUIRE_FALSE(raw.empty()); + const auto dot = raw.find('.'); + if (dot != std::string::npos) { + REQUIRE((raw.size() - dot - 1) <= 1); + } + } + for (const char *key : {"temp", "humidity"}) { + const std::string raw = raw_number_str(buf, key); + REQUIRE_FALSE(raw.empty()); + const auto dot = raw.find('.'); + if (dot != std::string::npos) { + REQUIRE((raw.size() - dot - 1) <= 2); + } + } + REQUIRE(raw_number_str(buf, "pm003Count").find('.') == std::string::npos); +} + TEST_CASE("measures: tiny buffer fails cleanly", "[measures]") { Measures m; SystemInfo info = make_info(); From 37f968bee5b82fdb8d449e7da81094c72a540566 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 24 Jul 2026 21:41:49 +0300 Subject: [PATCH 2/3] feat(local-api): add power and PM counts --- .../tests/measures_default_init.tests.cpp | 14 ++++++ .../include/measures_types.h | 10 ++++ components/airgradient-local-server/README.md | 6 +-- .../internal/field_names.h | 8 ++++ .../internal/measures_json.cpp | 30 +++++++++++- components/airgradient-local-server/spec.md | 20 ++++++-- .../tests/measures_json.tests.cpp | 48 +++++++++++++++++++ products/go/docs/local_server.md | 18 ++++--- products/go/main/go_local_api.cpp | 26 ++++++++++ products/go/main/go_orchestrator.cpp | 1 + products/go/tests/go_local_api.tests.cpp | 32 ++++++++++++- products/go/tests/go_orchestrator.tests.cpp | 5 ++ .../local-server-integration/ago_local_api.py | 25 +++++++++- 13 files changed, 226 insertions(+), 17 deletions(-) diff --git a/components/airgradient-client/tests/measures_default_init.tests.cpp b/components/airgradient-client/tests/measures_default_init.tests.cpp index 3c537f2..0dff7f7 100644 --- a/components/airgradient-client/tests/measures_default_init.tests.cpp +++ b/components/airgradient-client/tests/measures_default_init.tests.cpp @@ -69,9 +69,23 @@ TEST_CASE("Default-constructed MeasuresPower fails every validation", "[measures MeasuresPower d{}; REQUIRE_FALSE(d.is_battery_voltage_valid()); REQUIRE_FALSE(d.is_charging_voltage_valid()); + REQUIRE_FALSE(d.is_battery_percentage_valid()); REQUIRE_FALSE(d.is_valid()); } +TEST_CASE("MeasuresPower validates battery percentage independently", "[measures_default_init]") { + MeasuresPower d{}; + + d.battery_percentage = MeasuresRange::MIN_VALID_BATTERY_PERCENT; + REQUIRE(d.is_battery_percentage_valid()); + d.battery_percentage = MeasuresRange::MAX_VALID_BATTERY_PERCENT; + REQUIRE(d.is_battery_percentage_valid()); + d.battery_percentage = MeasuresRange::MIN_VALID_BATTERY_PERCENT - 0.1f; + REQUIRE_FALSE(d.is_battery_percentage_valid()); + d.battery_percentage = MeasuresRange::MAX_VALID_BATTERY_PERCENT + 0.1f; + REQUIRE_FALSE(d.is_battery_percentage_valid()); +} + TEST_CASE("Default-constructed PressureData fails every validation", "[measures_default_init]") { PressureData d{}; REQUIRE_FALSE(d.is_pressure_valid()); diff --git a/components/airgradient-common/include/measures_types.h b/components/airgradient-common/include/measures_types.h index 99a3a63..f14329b 100644 --- a/components/airgradient-common/include/measures_types.h +++ b/components/airgradient-common/include/measures_types.h @@ -24,6 +24,9 @@ constexpr int MIN_VALID_TVOC = 0; constexpr int MIN_VALID_NOX = 0; // Voltage constexpr float MIN_VALID_VOLT = 0.0f; +// Battery percentage +constexpr float MIN_VALID_BATTERY_PERCENT = 0.0f; +constexpr float MAX_VALID_BATTERY_PERCENT = 100.0f; // Pressure & Altitude constexpr float MIN_VALID_PRESSURE = 300.0f; constexpr float MAX_VALID_PRESSURE = 1100.0f; @@ -39,6 +42,7 @@ constexpr int CO2 = -1; constexpr int TVOC = -1; constexpr int NOX = -1; constexpr float VOLT = -1.0f; +constexpr float BATTERY_PERCENT = -1.0f; constexpr float PRESSURE = -1.0f; constexpr float ALTITUDE = -10000.0f; } // namespace MeasuresInvalid @@ -150,6 +154,7 @@ struct O3No2Data { struct MeasuresPower { float battery_voltage = MeasuresInvalid::VOLT; float charging_voltage = MeasuresInvalid::VOLT; + float battery_percentage = MeasuresInvalid::BATTERY_PERCENT; bool is_battery_voltage_valid() const { return battery_voltage >= MeasuresRange::MIN_VALID_VOLT; } @@ -157,6 +162,11 @@ struct MeasuresPower { return charging_voltage >= MeasuresRange::MIN_VALID_VOLT; } + bool is_battery_percentage_valid() const { + return battery_percentage >= MeasuresRange::MIN_VALID_BATTERY_PERCENT && + battery_percentage <= MeasuresRange::MAX_VALID_BATTERY_PERCENT; + } + bool is_valid() const { return is_battery_voltage_valid() && is_charging_voltage_valid(); } }; diff --git a/components/airgradient-local-server/README.md b/components/airgradient-local-server/README.md index afd7a2a..81e1d9d 100644 --- a/components/airgradient-local-server/README.md +++ b/components/airgradient-local-server/README.md @@ -173,6 +173,6 @@ cover each missing SLR coefficient independently. - Local measurement precision matches the cloud payload: PM mass uses one decimal place, temperature and humidity use two, and particle counts are integers. -- Extended measurement groups (battery / pressure / electrode / dual-channel) - and product-specific config fields are deferred; add them as flat optional - fields when a product exposes them. +- Extended measurement groups (pressure / electrode / dual-channel) and + product-specific config fields are deferred; add them as flat optional fields + when a product exposes them. diff --git a/components/airgradient-local-server/internal/field_names.h b/components/airgradient-local-server/internal/field_names.h index e26aa50..aef0afb 100644 --- a/components/airgradient-local-server/internal/field_names.h +++ b/components/airgradient-local-server/internal/field_names.h @@ -29,12 +29,20 @@ inline constexpr const char *PM01 = "pm01"; inline constexpr const char *PM25 = "pm25"; inline constexpr const char *PM10 = "pm10"; inline constexpr const char *PM003_COUNT = "pm003Count"; +inline constexpr const char *PM005_COUNT = "pm005Count"; +inline constexpr const char *PM01_COUNT = "pm01Count"; +inline constexpr const char *PM02_COUNT = "pm02Count"; +inline constexpr const char *PM50_COUNT = "pm50Count"; +inline constexpr const char *PM10_COUNT = "pm10Count"; inline constexpr const char *TEMP = "temp"; inline constexpr const char *HUMIDITY = "humidity"; inline constexpr const char *TVOC_INDEX = "tvocIndex"; inline constexpr const char *TVOC_RAW = "tvocRaw"; inline constexpr const char *NOX_INDEX = "noxIndex"; inline constexpr const char *NOX_RAW = "noxRaw"; +inline constexpr const char *BATT_PERCENT = "battPercent"; +inline constexpr const char *BATT_VOLT = "battVolt"; +inline constexpr const char *CHARGE_VOLT = "chargeVolt"; // --- Config catalog (GET / PUT /api/v1/config) --------------------------- inline constexpr const char *COUNTRY = "country"; diff --git a/components/airgradient-local-server/internal/measures_json.cpp b/components/airgradient-local-server/internal/measures_json.cpp index 8441de7..f6ca1b4 100644 --- a/components/airgradient-local-server/internal/measures_json.cpp +++ b/components/airgradient-local-server/internal/measures_json.cpp @@ -20,6 +20,7 @@ namespace { constexpr int DECIMALS_INT = 0; constexpr int DECIMALS_PM_MASS = 1; constexpr int DECIMALS_TEMP_HUM = 2; +constexpr int DECIMALS_VOLT = 2; double round_to_decimals(double value, int decimals) { switch (decimals) { @@ -38,6 +39,8 @@ void add_float(cJSON *root, const char *name, float value, int decimals) { cJSON_AddNumberToObject(root, name, round_to_decimals(static_cast(value), decimals)); } +bool is_finite(float value) { return std::isfinite(value); } + } // namespace namespace measures_json { @@ -74,9 +77,24 @@ size_t serialize(const Measures &measures, const SystemInfo &info, char *buf, si if (measures.pm_a.is_pm_10_valid()) { add_float(root, fields::PM10, measures.pm_a.pm_10, DECIMALS_PM_MASS); } - if (measures.pm_a.is_pm_03_pc_valid()) { + if (measures.pm_a.is_pm_03_pc_valid() && is_finite(measures.pm_a.pm_03_pc)) { add_float(root, fields::PM003_COUNT, measures.pm_a.pm_03_pc, DECIMALS_INT); } + if (measures.pm_a.is_pm_05_pc_valid() && is_finite(measures.pm_a.pm_05_pc)) { + add_float(root, fields::PM005_COUNT, measures.pm_a.pm_05_pc, DECIMALS_INT); + } + if (measures.pm_a.is_pm_01_pc_valid() && is_finite(measures.pm_a.pm_01_pc)) { + add_float(root, fields::PM01_COUNT, measures.pm_a.pm_01_pc, DECIMALS_INT); + } + if (measures.pm_a.is_pm_25_pc_valid() && is_finite(measures.pm_a.pm_25_pc)) { + add_float(root, fields::PM02_COUNT, measures.pm_a.pm_25_pc, DECIMALS_INT); + } + if (measures.pm_a.is_pm_5_pc_valid() && is_finite(measures.pm_a.pm_5_pc)) { + add_float(root, fields::PM50_COUNT, measures.pm_a.pm_5_pc, DECIMALS_INT); + } + if (measures.pm_a.is_pm_10_pc_valid() && is_finite(measures.pm_a.pm_10_pc)) { + add_float(root, fields::PM10_COUNT, measures.pm_a.pm_10_pc, DECIMALS_INT); + } if (measures.temp_hum_a.is_temp_valid()) { add_float(root, fields::TEMP, measures.temp_hum_a.temperature, DECIMALS_TEMP_HUM); } @@ -98,6 +116,16 @@ size_t serialize(const Measures &measures, const SystemInfo &info, char *buf, si if (measures.tvoc_nox.is_nox_raw_valid()) { cJSON_AddNumberToObject(root, fields::NOX_RAW, static_cast(measures.tvoc_nox.nox_raw)); } + if (measures.power.is_battery_percentage_valid() && + is_finite(measures.power.battery_percentage)) { + add_float(root, fields::BATT_PERCENT, measures.power.battery_percentage, DECIMALS_INT); + } + if (measures.power.is_battery_voltage_valid() && is_finite(measures.power.battery_voltage)) { + add_float(root, fields::BATT_VOLT, measures.power.battery_voltage, DECIMALS_VOLT); + } + if (measures.power.is_charging_voltage_valid() && is_finite(measures.power.charging_voltage)) { + add_float(root, fields::CHARGE_VOLT, measures.power.charging_voltage, DECIMALS_VOLT); + } const bool ok = cJSON_PrintPreallocated(root, buf, static_cast(buf_len), /*format=*/0); cJSON_Delete(root); diff --git a/components/airgradient-local-server/spec.md b/components/airgradient-local-server/spec.md index 39439f3..b10e56f 100644 --- a/components/airgradient-local-server/spec.md +++ b/components/airgradient-local-server/spec.md @@ -420,19 +420,29 @@ integration can map by model. | `pm25` | `PMData::pm_25` | `pm02` | | `pm10` | `PMData::pm_10` | `pm10` | | `pm003Count` | `PMData::pm_03_pc` | `pm003Count` | +| `pm005Count` | `PMData::pm_05_pc` | `pm005Count` | +| `pm01Count` | `PMData::pm_01_pc` | `pm01Count` | +| `pm02Count` | `PMData::pm_25_pc` | `pm02Count` | +| `pm50Count` | `PMData::pm_5_pc` | `pm50Count` | +| `pm10Count` | `PMData::pm_10_pc` | `pm10Count` | | `temp` | `TempHumData::temperature` | `atmp` | | `humidity` | `TempHumData::humidity` | `rhum` | | `tvocIndex` | `TVOCNOxData::tvoc_index` | `tvocIndex` | | `tvocRaw` | `TVOCNOxData::tvoc_raw` | `tvocRaw` | | `noxIndex` | `TVOCNOxData::nox_index` | `noxIndex` | | `noxRaw` | `TVOCNOxData::nox_raw` | `noxRaw` | +| `battPercent` | `MeasuresPower::battery_percentage` | — | +| `battVolt` | `MeasuresPower::battery_voltage` | `volt` | +| `chargeVolt` | `MeasuresPower::charging_voltage` | `light` | Wire field names are camelCase; the legacy vocabulary is kept where it was already clear and renamed only where it misled or was opaque (see `api-v1-naming-decision.md`). Numeric precision matches the cloud measurement payload: `temp` and `humidity` -use two decimal places, PM mass uses one, and `pm003Count` is an integer. +use two decimal places, PM mass uses one, particle counts and `battPercent` are +integers, and voltage values use two decimal places. `chargeVolt` is measured +input/VBUS voltage; it is not a charging-state indicator or a regulation setpoint. ```json { "serialNumber": "aabbccddeeff", "model": "O-1PST", "firmware": "2.0.0", @@ -447,10 +457,10 @@ retain uptime. The deprecated legacy `bootCount` duplicate is intentionally not emitted. **Deferred groups** (present in `Measures` but not exposed in v1; add as flat -optional fields when a product needs them): `power` (battery / charging -voltage), `pressure` / `altitude`, `electrode` (O3 / NO2), and the dual-channel -`temp_hum_b` / `pm_b`. Go is battery-powered and has pressure, so `battery` and -`pressure` are the most likely first additions. +optional fields when a product needs them): `pressure` / `altitude`, `electrode` +(O3 / NO2), and the dual-channel +`temp_hum_b` / `pm_b`. Go has pressure, so `pressure` is the most likely first +addition. ### Config Schema diff --git a/components/airgradient-local-server/tests/measures_json.tests.cpp b/components/airgradient-local-server/tests/measures_json.tests.cpp index 6cc202c..4b4a9b2 100644 --- a/components/airgradient-local-server/tests/measures_json.tests.cpp +++ b/components/airgradient-local-server/tests/measures_json.tests.cpp @@ -78,6 +78,10 @@ TEST_CASE("measures: identity always present, no measurement keys when invalid", REQUIRE(cJSON_GetObjectItem(root, "humidity") == nullptr); REQUIRE(cJSON_GetObjectItem(root, "tvocIndex") == nullptr); REQUIRE(cJSON_GetObjectItem(root, "noxIndex") == nullptr); + REQUIRE(cJSON_GetObjectItem(root, "pm005Count") == nullptr); + REQUIRE(cJSON_GetObjectItem(root, "battPercent") == nullptr); + REQUIRE(cJSON_GetObjectItem(root, "battVolt") == nullptr); + REQUIRE(cJSON_GetObjectItem(root, "chargeVolt") == nullptr); cJSON_Delete(root); } @@ -142,6 +146,50 @@ TEST_CASE("measures: pm003Count maps from pm_03_pc", "[measures]") { cJSON_Delete(root); } +TEST_CASE("measures: all PM counts and power fields are emitted independently", "[measures]") { + Measures m; + m.pm_a.pm_03_pc = 100.4f; + m.pm_a.pm_05_pc = 200.4f; + m.pm_a.pm_01_pc = 300.6f; + m.pm_a.pm_25_pc = 400.7f; + m.pm_a.pm_5_pc = 500.8f; + m.pm_a.pm_10_pc = 600.9f; + m.power.battery_percentage = 52.0f; + m.power.battery_voltage = 3.456f; + m.power.charging_voltage = 5.678f; + const SystemInfo info = make_info(); + + cJSON *root = serialize_and_parse(m, info); + REQUIRE(cJSON_GetObjectItem(root, "pm003Count")->valuedouble == 100.0); + REQUIRE(cJSON_GetObjectItem(root, "pm005Count")->valuedouble == 200.0); + REQUIRE(cJSON_GetObjectItem(root, "pm01Count")->valuedouble == 301.0); + REQUIRE(cJSON_GetObjectItem(root, "pm02Count")->valuedouble == 401.0); + REQUIRE(cJSON_GetObjectItem(root, "pm50Count")->valuedouble == 501.0); + REQUIRE(cJSON_GetObjectItem(root, "pm10Count")->valuedouble == 601.0); + REQUIRE(cJSON_GetObjectItem(root, "battPercent")->valuedouble == 52.0); + REQUIRE(cJSON_GetObjectItem(root, "battVolt")->valuedouble == 3.46); + REQUIRE(cJSON_GetObjectItem(root, "chargeVolt")->valuedouble == 5.68); + cJSON_Delete(root); +} + +TEST_CASE("measures: non-finite PM counts and voltages are omitted", "[measures]") { + Measures m; + m.pm_a.pm_03_pc = 123.0f; + m.pm_a.pm_05_pc = std::numeric_limits::infinity(); + m.power.battery_percentage = 42.0f; + m.power.battery_voltage = std::numeric_limits::infinity(); + m.power.charging_voltage = std::numeric_limits::quiet_NaN(); + const SystemInfo info = make_info(); + + cJSON *root = serialize_and_parse(m, info); + REQUIRE(cJSON_GetObjectItem(root, "pm003Count") != nullptr); + REQUIRE(cJSON_GetObjectItem(root, "pm005Count") == nullptr); + REQUIRE(cJSON_GetObjectItem(root, "battPercent") != nullptr); + REQUIRE(cJSON_GetObjectItem(root, "battVolt") == nullptr); + REQUIRE(cJSON_GetObjectItem(root, "chargeVolt") == nullptr); + cJSON_Delete(root); +} + TEST_CASE("measures: float fields use cloud payload precision", "[measures]") { Measures m; m.pm_a.pm_01 = 5.678f; diff --git a/products/go/docs/local_server.md b/products/go/docs/local_server.md index 919d44d..dbe0d1e 100644 --- a/products/go/docs/local_server.md +++ b/products/go/docs/local_server.md @@ -121,12 +121,18 @@ Server and cloud POSTs both consume the shared, transport-independent `retained_uptime` utility; BLE can reuse it later. The optional sensor fields are `co2`, `pm01`, `pm25`, `pm10`, `pm003Count`, -`temp`, `humidity`, `tvocIndex`, `tvocRaw`, `noxIndex`, and `noxRaw`. Each field -passes its field-specific validator before serialization; invalid fields are -omitted rather than emitted as JSON `null`. `temp` remains Celsius regardless -of the configured display temperature unit. Corrections are applied before the -Go common-measures snapshot is published, so local clients receive the -corrected PM2.5, temperature, and humidity view rather than the raw cloud, +`pm005Count`, `pm01Count`, `pm02Count`, `pm50Count`, `pm10Count`, `temp`, +`humidity`, `tvocIndex`, `tvocRaw`, `noxIndex`, `noxRaw`, `battPercent`, +`battVolt`, and `chargeVolt`. Particle counts and `battPercent` are integers; +the two voltage fields use two decimal places. `battPercent` uses the fuel gauge +when available, with the charger voltage-curve estimate as fallback. `chargeVolt` +is measured input/VBUS voltage, not a charging-state indicator. + +Each field passes its field-specific validator before serialization; invalid +fields are omitted rather than emitted as JSON `null`. `temp` remains Celsius +regardless of the configured display temperature unit. Corrections are applied +before the Go common-measures snapshot is published, so local clients receive +the corrected PM2.5, temperature, and humidity view rather than the raw cloud, storage, and BLE view. ### Configuration diff --git a/products/go/main/go_local_api.cpp b/products/go/main/go_local_api.cpp index 5924a2a..eafbfc0 100644 --- a/products/go/main/go_local_api.cpp +++ b/products/go/main/go_local_api.cpp @@ -333,6 +333,21 @@ Measures GoLocalApiService::map_measures(const MeasuresAGo &corrected) { if (corrected.pm_a.is_pm_03_pc_valid() && finite_float(corrected.pm_a.pm_03_pc)) { measures.pm_a.pm_03_pc = corrected.pm_a.pm_03_pc; } + if (corrected.pm_a.is_pm_05_pc_valid() && finite_float(corrected.pm_a.pm_05_pc)) { + measures.pm_a.pm_05_pc = corrected.pm_a.pm_05_pc; + } + if (corrected.pm_a.is_pm_01_pc_valid() && finite_float(corrected.pm_a.pm_01_pc)) { + measures.pm_a.pm_01_pc = corrected.pm_a.pm_01_pc; + } + if (corrected.pm_a.is_pm_25_pc_valid() && finite_float(corrected.pm_a.pm_25_pc)) { + measures.pm_a.pm_25_pc = corrected.pm_a.pm_25_pc; + } + if (corrected.pm_a.is_pm_5_pc_valid() && finite_float(corrected.pm_a.pm_5_pc)) { + measures.pm_a.pm_5_pc = corrected.pm_a.pm_5_pc; + } + if (corrected.pm_a.is_pm_10_pc_valid() && finite_float(corrected.pm_a.pm_10_pc)) { + measures.pm_a.pm_10_pc = corrected.pm_a.pm_10_pc; + } if (corrected.temp_hum_a.is_temp_valid() && finite_float(corrected.temp_hum_a.temperature)) { measures.temp_hum_a.temperature = corrected.temp_hum_a.temperature; } @@ -351,6 +366,17 @@ Measures GoLocalApiService::map_measures(const MeasuresAGo &corrected) { if (corrected.tvoc_nox.is_nox_raw_valid()) { measures.tvoc_nox.nox_raw = corrected.tvoc_nox.nox_raw; } + if (corrected.power.is_battery_percentage_valid() && + finite_float(corrected.power.battery_percentage)) { + measures.power.battery_percentage = corrected.power.battery_percentage; + } + if (corrected.power.is_battery_voltage_valid() && finite_float(corrected.power.battery_voltage)) { + measures.power.battery_voltage = corrected.power.battery_voltage; + } + if (corrected.power.is_charging_voltage_valid() && + finite_float(corrected.power.charging_voltage)) { + measures.power.charging_voltage = corrected.power.charging_voltage; + } return measures; } diff --git a/products/go/main/go_orchestrator.cpp b/products/go/main/go_orchestrator.cpp index 879a58a..3c46129 100644 --- a/products/go/main/go_orchestrator.cpp +++ b/products/go/main/go_orchestrator.cpp @@ -772,6 +772,7 @@ void Orchestrator::on_sensor_data(const MeasuresAGo &data) { _raw_measures.pressure = data.pressure; _raw_measures.power.battery_voltage = _latest_power.battery_voltage; _raw_measures.power.charging_voltage = _latest_power.charging_voltage; + _raw_measures.power.battery_percentage = _latest_power.battery_percentage; _corrected_measures = apply_measurement_corrections(_raw_measures, _settings.corrections); _svc.local_api.publish_measurement_snapshot(_corrected_measures); AG_LOGI(TAG, diff --git a/products/go/tests/go_local_api.tests.cpp b/products/go/tests/go_local_api.tests.cpp index 7a98a8d..c44e4d9 100644 --- a/products/go/tests/go_local_api.tests.cpp +++ b/products/go/tests/go_local_api.tests.cpp @@ -213,13 +213,20 @@ TEST_CASE("Go local API publishes corrected supported measures field by field") corrected.pm_a.pm_25 = 2.5f; corrected.pm_a.pm_10 = 10.2f; corrected.pm_a.pm_03_pc = 321.0f; + corrected.pm_a.pm_05_pc = 322.0f; + corrected.pm_a.pm_01_pc = 323.0f; + corrected.pm_a.pm_25_pc = 324.0f; + corrected.pm_a.pm_5_pc = 325.0f; + corrected.pm_a.pm_10_pc = 326.0f; corrected.temp_hum_a.temperature = 24.5f; corrected.temp_hum_a.humidity = 47.0f; corrected.tvoc_nox.tvoc_index = 100; corrected.tvoc_nox.tvoc_raw = 200; corrected.tvoc_nox.nox_index = 3; corrected.tvoc_nox.nox_raw = 4; + corrected.power.battery_percentage = 55.0f; corrected.power.battery_voltage = 4.1f; + corrected.power.charging_voltage = 5.0f; corrected.pressure.pressure = 1013.0f; fixture.service->publish_measurement_snapshot(corrected); @@ -229,25 +236,40 @@ TEST_CASE("Go local API publishes corrected supported measures field by field") CHECK(measures.pm_a.pm_25 == 2.5f); CHECK(measures.pm_a.pm_10 == 10.2f); CHECK(measures.pm_a.pm_03_pc == 321.0f); + CHECK(measures.pm_a.pm_05_pc == 322.0f); + CHECK(measures.pm_a.pm_01_pc == 323.0f); + CHECK(measures.pm_a.pm_25_pc == 324.0f); + CHECK(measures.pm_a.pm_5_pc == 325.0f); + CHECK(measures.pm_a.pm_10_pc == 326.0f); CHECK(measures.temp_hum_a.temperature == 24.5f); CHECK(measures.temp_hum_a.humidity == 47.0f); CHECK(measures.tvoc_nox.tvoc_index == 100); CHECK(measures.tvoc_nox.tvoc_raw == 200); CHECK(measures.tvoc_nox.nox_index == 3); CHECK(measures.tvoc_nox.nox_raw == 4); - CHECK_FALSE(measures.power.is_valid()); + CHECK(measures.power.battery_percentage == 55.0f); + CHECK(measures.power.battery_voltage == 4.1f); + CHECK(measures.power.charging_voltage == 5.0f); CHECK_FALSE(measures.pressure.is_valid()); CHECK_FALSE(measures.temp_hum_b.is_valid()); CHECK_FALSE(measures.pm_b.is_valid()); CHECK_FALSE(measures.electrode.is_valid()); corrected.pm_a.pm_25 = std::numeric_limits::infinity(); + corrected.pm_a.pm_05_pc = std::numeric_limits::infinity(); corrected.temp_hum_a.temperature = std::numeric_limits::quiet_NaN(); + corrected.power.battery_voltage = std::numeric_limits::infinity(); + corrected.power.charging_voltage = std::numeric_limits::quiet_NaN(); + corrected.power.battery_percentage = 101.0f; corrected.tvoc_nox.nox_raw = MeasuresInvalid::NOX; fixture.service->publish_measurement_snapshot(corrected); const Measures replaced = fixture.service->get_measures(); CHECK_FALSE(replaced.pm_a.is_pm_25_valid()); + CHECK_FALSE(replaced.pm_a.is_pm_05_pc_valid()); CHECK_FALSE(replaced.temp_hum_a.is_temp_valid()); + CHECK_FALSE(replaced.power.is_battery_percentage_valid()); + CHECK_FALSE(replaced.power.is_battery_voltage_valid()); + CHECK_FALSE(replaced.power.is_charging_voltage_valid()); CHECK_FALSE(replaced.tvoc_nox.is_nox_raw_valid()); CHECK(replaced.pm_a.pm_01 == 1.1f); @@ -258,12 +280,20 @@ TEST_CASE("Go local API publishes corrected supported measures field by field") CHECK_FALSE(invalid.pm_a.is_pm_25_valid()); CHECK_FALSE(invalid.pm_a.is_pm_10_valid()); CHECK_FALSE(invalid.pm_a.is_pm_03_pc_valid()); + CHECK_FALSE(invalid.pm_a.is_pm_05_pc_valid()); + CHECK_FALSE(invalid.pm_a.is_pm_01_pc_valid()); + CHECK_FALSE(invalid.pm_a.is_pm_25_pc_valid()); + CHECK_FALSE(invalid.pm_a.is_pm_5_pc_valid()); + CHECK_FALSE(invalid.pm_a.is_pm_10_pc_valid()); CHECK_FALSE(invalid.temp_hum_a.is_temp_valid()); CHECK_FALSE(invalid.temp_hum_a.is_hum_valid()); CHECK_FALSE(invalid.tvoc_nox.is_tvoc_index_valid()); CHECK_FALSE(invalid.tvoc_nox.is_tvoc_raw_valid()); CHECK_FALSE(invalid.tvoc_nox.is_nox_index_valid()); CHECK_FALSE(invalid.tvoc_nox.is_nox_raw_valid()); + CHECK_FALSE(invalid.power.is_battery_percentage_valid()); + CHECK_FALSE(invalid.power.is_battery_voltage_valid()); + CHECK_FALSE(invalid.power.is_charging_voltage_valid()); } TEST_CASE("Go local API uptime advances independently of measurements") { diff --git a/products/go/tests/go_orchestrator.tests.cpp b/products/go/tests/go_orchestrator.tests.cpp index 7fcac52..f60470c 100644 --- a/products/go/tests/go_orchestrator.tests.cpp +++ b/products/go/tests/go_orchestrator.tests.cpp @@ -1930,20 +1930,25 @@ TEST_CASE("on_sensor_data: measures power comes from latest PowerSnapshot", auto orch = f.make_orchestrator(); PowerSnapshot power{}; + power.battery_percentage = 54.0f; power.battery_voltage = 3.82f; power.charging_voltage = 5.01f; A::set_latest_power(orch, power); MeasuresAGo data{}; data.co2.co2 = 420; + data.power.battery_percentage = 12.0f; data.power.battery_voltage = 1.23f; data.power.charging_voltage = 9.87f; A::on_sensor_data(orch, data); + CHECK(A::cached_measures(orch).power.battery_percentage == 54.0f); CHECK(A::cached_measures(orch).power.battery_voltage == 3.82f); CHECK(A::cached_measures(orch).power.charging_voltage == 5.01f); + CHECK(test_spy::last_cached_measurement.power.battery_percentage == 54.0f); CHECK(test_spy::last_cached_measurement.power.battery_voltage == 3.82f); CHECK(test_spy::last_cached_measurement.power.charging_voltage == 5.01f); + CHECK(test_spy::cloud_last_snapshot.power.battery_percentage == 54.0f); CHECK(test_spy::cloud_last_snapshot.power.battery_voltage == 3.82f); CHECK(test_spy::cloud_last_snapshot.power.charging_voltage == 5.01f); } 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 e23993b..aeb71b3 100644 --- a/products/go/tests/local-server-integration/ago_local_api.py +++ b/products/go/tests/local-server-integration/ago_local_api.py @@ -26,12 +26,20 @@ "pm25", "pm10", "pm003Count", + "pm005Count", + "pm01Count", + "pm02Count", + "pm50Count", + "pm10Count", "temp", "humidity", "tvocIndex", "tvocRaw", "noxIndex", "noxRaw", + "battPercent", + "battVolt", + "chargeVolt", } MEASURES_KEYS = MEASURES_REQUIRED_KEYS | MEASURES_OPTIONAL_KEYS @@ -138,9 +146,19 @@ def validate_measures(payload: dict[str, Any]) -> None: _assert_integer(payload["wifiRssi"]) if "co2" in payload: _assert_integer(payload["co2"], 0, 10000) - for field in ("pm01", "pm25", "pm10", "pm003Count"): + for field in ("pm01", "pm25", "pm10"): if field in payload: _assert_number(payload[field], 0) + for field in ( + "pm003Count", + "pm005Count", + "pm01Count", + "pm02Count", + "pm50Count", + "pm10Count", + ): + if field in payload: + _assert_integer(payload[field], 0) if "temp" in payload: _assert_number(payload["temp"], -40, 125) if "humidity" in payload: @@ -148,6 +166,11 @@ def validate_measures(payload: dict[str, Any]) -> None: for field in ("tvocIndex", "tvocRaw", "noxIndex", "noxRaw"): if field in payload: _assert_integer(payload[field], 0) + if "battPercent" in payload: + _assert_integer(payload["battPercent"], 0, 100) + for field in ("battVolt", "chargeVolt"): + if field in payload: + _assert_number(payload[field], 0) def _validate_correction(entry: object, measure: str) -> None: From c5b4d055ce8669f783decd9f240265188c04c56e Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 24 Jul 2026 21:49:20 +0300 Subject: [PATCH 3/3] docs: retire completed component specs --- components/airgradient-client/README.md | 4 +- components/airgradient-client/spec.md | 838 --------------- components/airgradient-local-server/README.md | 8 +- components/airgradient-local-server/spec.md | 976 ------------------ components/airgradient-ota/README.md | 3 +- components/airgradient-ota/spec-ble.md | 27 +- components/airgradient-ota/spec.md | 725 ------------- 7 files changed, 18 insertions(+), 2563 deletions(-) delete mode 100644 components/airgradient-client/spec.md delete mode 100644 components/airgradient-local-server/spec.md delete mode 100644 components/airgradient-ota/spec.md diff --git a/components/airgradient-client/README.md b/components/airgradient-client/README.md index 705c732..1e241ba 100644 --- a/components/airgradient-client/README.md +++ b/components/airgradient-client/README.md @@ -168,9 +168,7 @@ reachable deterministically on hardware and rely on the host tests. ## Not Yet Implemented The following methods are present on `AgClient`'s public API so call -sites can be wired today, but they currently fail loudly. The full -design for each lives in [`spec.md`](spec.md), which will be deleted -once this work lands. +sites can be wired today, but they currently fail loudly. - `begin(sn, NetworkType::Cellular, modem)` — returns `false` and logs - `coap_fetch_config()` / `coap_post_measures()` — abort diff --git a/components/airgradient-client/spec.md b/components/airgradient-client/spec.md deleted file mode 100644 index 69dd3cd..0000000 --- a/components/airgradient-client/spec.md +++ /dev/null @@ -1,838 +0,0 @@ -# airgradient-client Spec - -> **This is a spec.** It describes how a feature **will be built**, not what -> currently exists. Once the feature ships, the corresponding component README -> becomes the source of truth and this file is typically deleted. - -Unified AirGradient server client component. Provides a single `AgClient` -class that handles all communication with AirGradient backend servers -(fetch config, post measures, OTA download) over HTTP, CoAP, and MQTT. -The caller selects a network at boot (WiFi or Cellular) and calls -protocol-specific methods without knowledge of network internals. - -## Scope - -This spec covers the **WiFi HTTP** implementation of `AgClient`. Cellular -network support (CoAP, MQTT, cellular HTTP for OTA) and WiFi MQTT are -intentionally out of scope and will be addressed in future specs. The -corresponding protocol client interfaces (`CoapClient`, `MqttClient`) and -backend placeholders are defined here to establish the full component -structure, but their implementations are not part of this work. - -Calling a method whose backend is not yet implemented results in an abort -with a clear error log. - -## Problem - -The existing `airgradient-client` library (in `tmp/airgradient-client/`) -has several issues: - -- **God-class inheritance** --- a base `AirgradientClient` with no-op virtual - methods for every protocol; WiFi and Cellular subclasses override different - subsets -- **Duplicate data model** --- owns `CommonPayload`/`ExtraPayload`/`PayloadBuffer` - types that duplicate the shared `Measures` types in `airgradient-common` -- **Dead Arduino compatibility** --- `#ifdef ARDUINO` / `#ifndef ESP8266` guards - and `HTTPClient.h` dependency no longer needed -- **Untestable on host** --- direct `esp_http_client`, `esp_random`, - `vTaskDelay` calls with no abstraction -- **Protocol logic mixed with application logic** --- URL paths, payload - formats, AT commands, CoAP Block1, and connection state all in the same - class - -## Goals - -- Single `AgClient` class --- one object for the caller regardless of network - or protocol -- Network chosen at boot (WiFi or Cellular), fixed for the runtime session -- Protocol-specific methods: `http_*`, `coap_*`, `mqtt_*` -- This spec implements WiFi HTTP only; cellular protocols and WiFi MQTT are - future work -- `http_*` methods are WiFi-only within `AgClient`; cellular uses CoAP for - config and measures, MQTT for publishing (future specs) -- Cellular HTTP exists only for OTA binary download (consumed by the future - `airgradient-ota` component, not by `AgClient` itself) -- Use shared `Measures` types from `airgradient-common` via Kconfig typedef - (same pattern as `airgradient-payload-cache`) -- Three protocol client interfaces (`HttpClient`, `MqttClient`, `CoapClient`) - in the `clients/` directory provide mockable seams for host testing -- All protocol client backend implementations live in this component, next to - the interfaces they implement -- Preserve existing AirGradient server contracts (endpoints, payload formats, - TLS certificate, response code interpretation) -- Host-testable: all application logic testable with mock protocol clients -- Optional cellular dependency gated by Kconfig - -## Non-Goals - -- No generic HTTP/MQTT/CoAP library --- this is AirGradient-server-specific -- No OTA implementation (separate component; this spec defines the protocol - client interface OTA will use) -- No WiFi MQTT implementation in this spec (interface defined, implementation - is future work) -- No cellular backend implementation in this spec (interfaces defined, - implementations are future work) -- No cellular implementation of `begin()` in this spec --- - `begin(sn, NetworkType::Cellular, modem)` returns `false` until a future - spec adds cellular backends -- No config fetch or measure post over cellular HTTP --- cellular products - use CoAP and MQTT for those operations -- No streaming HTTP GET (`get_stream()`) --- OTA binary download will be - addressed in the future `airgradient-ota` spec, which will extend - `HttpClient` when needed -- No config parsing --- the client returns raw config responses; parsing is the - caller's responsibility -- No connection management for WiFi (WiFi stack is assumed to be up when the - caller uses `AgClient`) - -## Design - -### Caller API - -```cpp -AgClient client; - -// At boot --- one or the other, fixed for this runtime -client.begin("aabbccddeeff", NetworkType::Wifi); -// or (future spec --- returns false until cellular backends are implemented) -client.begin("aabbccddeeff", NetworkType::Cellular, &modem); - -// HTTP (WiFi only --- aborts on Cellular) -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, boot); - -// CoAP (Cellular only --- aborts until cellular backends are implemented) -result = client.coap_fetch_config(config_buf, sizeof(config_buf), &written); -result = client.coap_post_measures(measures, signal, interval_seconds); -result = client.coap_post_measures(arr, count, signal, interval_seconds); - -// MQTT (Cellular only --- aborts until backends are implemented) -result = client.mqtt_connect(host, port, username, password); -result = client.mqtt_publish_measures(measures, signal, interval_seconds); -result = client.mqtt_disconnect(); -``` - -### Measures Type Selection - -Same Kconfig pattern as `airgradient-payload-cache`. Each product selects -which `Measures` variant the client serializes and posts at build time. No -mapping or conversion needed. - -```cpp -// types/client_types.h - -enum class AgClientResult { - Ok, // Operation succeeded (HTTP 200, 201, or 429) - BufferTooSmall, // Response did not fit in caller's buffer - TransportError, // Could not reach server (connection, DNS, timeout) - ServerError, // Non-success HTTP status (generic) - NotRegistered, // Server returned 400 --- device not registered -}; - -#if defined(CONFIG_AG_CLIENT_MEASURES_TYPE_BASIC) -typedef MeasuresBasic AgClientMeasuresType; -#elif defined(CONFIG_AG_CLIENT_MEASURES_TYPE_AGO) -typedef MeasuresAGo AgClientMeasuresType; -#else -typedef Measures AgClientMeasuresType; -#endif -``` - -### Measures Initialization Contract - -`AgClient` serializes only fields that pass the corresponding -`is_*_valid()` method on each `Measures` substruct. **Callers must pass -`Measures` values that have been initialized with invalid sentinels for -any unset fields.** Zero-initialization (`AgClientMeasuresType m{}`) is -**unsafe** because zero is a valid value for several fields: - -- `co2.co2 = 0` passes `CO2Data::is_valid()` (range 0..10000) -- `pm_a.pm_01 = 0.0f` passes `PMData::is_pm_01_valid()` (>= 0) -- `temp_hum_a.humidity = 0.0f` passes `TempHumData::is_hum_valid()` (0..100) -- `tvoc_nox.tvoc_index = 0` passes `TVOCNOxData::is_tvoc_index_valid()` (>= 0) - -Callers must set fields they did not measure to the invalid sentinels -defined in `MeasuresInvalid` (e.g., `co2.co2 = MeasuresInvalid::CO2`), -or use an initialization helper if one is introduced. - -`MeasuresPower` already defaults to invalid sentinels via member -initializers in `measures_types.h`; other substructs currently do not. -See Open Questions for a proposal to add invalid defaults to all -substructs. - -### AgClient Class - -```cpp -class AgClient { -public: - AgClient() = default; - - bool begin(const char *serial_number, NetworkType network, - CellularModem *modem = nullptr); - - // --- HTTP (WiFi only --- aborts on Cellular) --- - AgClientResult http_fetch_config(char *config_out, size_t config_size, - size_t *bytes_written); - AgClientResult http_post_measures(const AgClientMeasuresType &measures, - int signal, uint32_t boot); - - // --- CoAP (Cellular only --- aborts on WiFi) --- supports batch - AgClientResult coap_fetch_config(char *config_out, size_t config_size, - size_t *bytes_written); - AgClientResult coap_post_measures(const AgClientMeasuresType &measures, - int signal, int interval_seconds); - AgClientResult coap_post_measures(const AgClientMeasuresType *measures, - size_t count, int signal, - int interval_seconds); - - // --- MQTT (Cellular now, WiFi future --- aborts until implemented) --- - AgClientResult mqtt_connect(const char *host, int port, - const char *username = nullptr, - const char *password = nullptr); - AgClientResult mqtt_disconnect(); - AgClientResult mqtt_publish_measures(const AgClientMeasuresType &measures, - int signal, int interval_seconds); - - // --- Domain override (for staging/testing) --- - void set_http_domain(const char *domain); - void reset_http_domain(); - void set_coap_host(const char *host); - void reset_coap_host(); - -private: - NetworkType network_ = NetworkType::Wifi; - char serial_number_[13] = {}; // 12-char hex + null - - std::string http_domain_ = "hw.airgradient.com"; - std::string coap_host_ = "128.140.49.53"; - - HttpClient *http_ = nullptr; - MqttClient *mqtt_ = nullptr; - CoapClient *coap_ = nullptr; - - 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, uint32_t boot, char *buf, size_t size, - size_t *bytes_written) const; - -#ifdef TEST_HOST - friend class AgClientTestAccess; -#endif -}; -``` - -The `AgClientResult` enum replaces the previous `bool` return + separate -status query methods (`is_last_fetch_config_ok()`, -`is_last_post_measures_ok()`, `is_registered_on_server()`). Each call -returns the precise outcome --- `Ok`, `NotRegistered`, `BufferTooSmall`, -`TransportError`, or `ServerError` --- so the caller can act on it -immediately without inspecting separate state. - -`begin()` creates the appropriate protocol client backends internally -and assigns the raw pointers. Backends are created once at boot and -live for the process lifetime (never freed) --- this matches the -embedded pattern where `AgClient` is a static object that outlives the -program. Tests bypass `begin()` and inject mock clients via -`AgClientTestAccess`. - -`set_http_domain()` and `set_coap_host()` copy the input into the -internal `std::string`, so caller-supplied string lifetime does not -matter. `reset_http_domain()` and `reset_coap_host()` restore the -compile-time defaults. - -`interval_seconds` on CoAP and MQTT methods represents the device's -measurement cadence. The CoAP binary encoder converts `interval_seconds` -to minutes by integer division by 60 (matching the old library) and -stores the result as `uint8_t interval_minutes` in the payload header. -Fractional minutes are truncated. HTTP methods do not take -`interval_seconds` because the JSON payload format does not include it. - -### Backend Construction - -Concrete backend types (`WifiHttpClient`, etc.) include ESP-IDF headers -that are unavailable on host. To keep `services/ag_client.cpp` -host-testable, backend construction is guarded with `#ifndef TEST_HOST`: - -```cpp -// In ag_client.cpp - -#ifndef TEST_HOST -#include "backends/wifi_http_client.h" -static HttpClient *make_wifi_http_client() { - static WifiHttpClient instance; - return &instance; -} -#endif - -bool AgClient::begin(const char *sn, NetworkType network, ...) { - // ... -#ifndef TEST_HOST - if (network == NetworkType::Wifi) { - http_ = make_wifi_http_client(); - } -#endif - // ... -} -``` - -Host tests inject mock clients via `AgClientTestAccess` and never reach -the `#ifndef TEST_HOST` paths. Only the ESP-IDF firmware build compiles -the backend construction code. - -### Batch Behavior - -- **HTTP (WiFi only):** single measure only --- no batch overload exists. - The old library's compact CSV batch format for cellular HTTP is legacy - and is **not** carried forward. -- **CoAP (Cellular only):** supports batch --- serialized as binary via - `PayloadEncoder` (preserved from old library, future spec) -- **MQTT:** single measure only - -### HttpClient Interface - -```cpp -class HttpClient { -public: - virtual ~HttpClient() = default; - - // Returns true if the HTTP request completed (any status code). - // Returns false on transport failure (connection, DNS, timeout). - // When response exceeds body_size: writes what fits, NUL-terminates, - // sets *truncated = true. Transport still succeeded (returns true). - virtual bool get(const char *url, const char *cert_pem, - int &status_code, - char *response_body, size_t body_size, - size_t *bytes_written, - bool *truncated) = 0; - - virtual bool post(const char *url, const char *cert_pem, - const char *content_type, - const uint8_t *body, size_t body_len, - int &status_code) = 0; -}; -``` - -`HttpClient::get()` is an internal interface consumed by `AgClient`, not -by product code. The `truncated` parameter is always provided by -`AgClient` internally. `AgClient` maps the combination of `bool` return, -`status_code`, and `truncated` to the public `AgClientResult` enum. - -### HTTP Response Buffer Contract - -For `HttpClient::get()`: - -- The **caller** (i.e., `AgClient`) owns and supplies the response buffer - (`char *` of size `body_size`). -- **Transport success, response fits:** returns `true`, - `*truncated = false`, response NUL-terminated, `*bytes_written` is the - response length excluding the NUL terminator. -- **Transport success, buffer too small:** returns `true`, - `*truncated = true`, writes what fits, NUL-terminates, - `*bytes_written` is the bytes written (excluding NUL). -- **Transport failure:** returns `false`, `*bytes_written` is 0, buffer - contents undefined. -- **Invalid arguments** (`body_size == 0` or `response_body == nullptr`): - returns `false`, `*bytes_written` is 0. - -`AgClient` maps these to `AgClientResult`: - -| `HttpClient::get()` | `status_code` | `truncated` | `AgClientResult` | -|---|---|---|---| -| `false` | --- | --- | `TransportError` | -| `true` | --- | `true` | `BufferTooSmall` | -| `true` | 200 | `false` | `Ok` | -| `true` | 429 | `false` | `Ok` (rate-limited) | -| `true` | 400 | `false` | `NotRegistered` | -| `true` | other | `false` | `ServerError` | - -The exact status-code-to-result mapping varies by operation --- see -Response Code Interpretation below. - -For AirGradient config responses, 2048 bytes has been sufficient -historically. - -### MqttClient Interface - -```cpp -class MqttClient { -public: - virtual ~MqttClient() = default; - - virtual bool connect(const char *client_id, - const char *host, int port, - const char *username, - const char *password) = 0; - virtual bool disconnect() = 0; - virtual bool publish(const char *topic, - const uint8_t *payload, size_t len, - int qos) = 0; -}; -``` - -### CoapClient Interface - -```cpp -class CoapClient { -public: - virtual ~CoapClient() = default; - - // Fetch config from CoAP server. - // uri_path is the CoAP URI path (e.g., serial number). - virtual bool get(const char *host, int port, - const char *uri_path, - char *response_body, size_t body_size, - size_t *bytes_written) = 0; - - // Post binary payload to CoAP server. - // Handles Block1 chunking internally when payload exceeds block size. - virtual bool post(const char *host, int port, - const char *uri_path, - const uint8_t *body, size_t body_len, - int &response_code_class, - int &response_code_detail) = 0; -}; -``` - -The `CoapClient` encapsulates all CoAP protocol machinery --- packet -building/parsing, CON/ACK handling, Block1 chunking, retry logic, and DNS -fallback. `AgClient` calls `get()` or `post()` and never sees CoAP -internals or the `coap-packet` library. - -### WifiHttpClient - -Wraps ESP-IDF `esp_http_client`. The only backend implemented in this spec. - -```cpp -class WifiHttpClient : public HttpClient { -public: - bool get(const char *url, const char *cert_pem, - int &status_code, - char *response_body, size_t body_size, - size_t *bytes_written, - bool *truncated) override; - - bool post(const char *url, const char *cert_pem, - const char *content_type, - const uint8_t *body, size_t body_len, - int &status_code) override; -}; -``` - -`get()` and `post()` use `esp_http_client_perform()` (simple -request-response). - -### Payload Serialization - -HTTP uses JSON via `cJSON` (built into ESP-IDF, no extra dependency). HTTP -config fetch and measures post are WiFi-only operations within `AgClient`; -cellular products use CoAP for those (future spec). The old library's -compact CSV format for cellular HTTP is legacy and is **not** carried -forward. CoAP uses the binary `PayloadEncoder` format. - -The serializer maps `AgClientMeasuresType` fields to the AirGradient server -JSON property names. - -| Measures Field | JSON Property | Dual-Channel Handling | -|---|---|---| -| `temp_hum_a.temperature` / `temp_hum_b.temperature` | `atmp` | Average if both valid | -| `temp_hum_a.humidity` / `temp_hum_b.humidity` | `rhum` | Average if both valid | -| `co2.co2` | `rco2` | Single | -| `pm_a.pm_01` / `pm_b.pm_01` | `pm01` | Average if both valid | -| `pm_a.pm_25` / `pm_b.pm_25` | `pm02` | Average if both valid | -| `pm_a.pm_10` / `pm_b.pm_10` | `pm10` | Average if both valid | -| `pm_a.pm_03_pc` / `pm_b.pm_03_pc` | `pm003Count` | Average if both valid | -| `tvoc_nox.tvoc_index` | `tvocIndex` | Single | -| `tvoc_nox.tvoc_raw` | `tvocRaw` | Single | -| `tvoc_nox.nox_index` | `noxIndex` | Single | -| `tvoc_nox.nox_raw` | `noxRaw` | Single | -| `power.battery_voltage` | `volt` | Single | -| `power.charging_voltage` | `light` | Single | -| `electrode.o3_we` | `measure0` | Single (full `Measures` only) | -| `electrode.o3_ae` | `measure1` | Single (full `Measures` only) | -| `electrode.no2_we` | `measure2` | Single (full `Measures` only) | -| `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., -`MeasuresAGo` has no `electrode`), it is simply absent from the JSON. - -Numeric values are rounded before serialization: temperature and humidity, -plus power voltages, use two decimal places; PM mass uses one; electrode values -use three; and CO2, particle counts, TVOC, and NOx use integers. - -### Dual-Channel Handling - -For products with two PM sensors and/or two temp/hum sensors (full -`Measures` type), `pm_b` and `temp_hum_b` are merged with `pm_a` and -`temp_hum_a` as follows: - -- **Both channels valid:** arithmetic mean of the two values -- **One channel valid:** use the valid channel's value -- **Neither valid:** field is omitted from JSON - -This applies to all dual-channel fields listed in the table above (PM -atmospheric, PM particle count, temperature, humidity). For products -without a second channel (`MeasuresBasic`, `MeasuresAGo`), only the -`_a` fields are used. - -### AirGradient Server Endpoints - -```text -Fetch config: https://hw.airgradient.com/sensors/airgradient:{sn}/one/config -Post measures: https://hw.airgradient.com/sensors/airgradient:{sn}/measures -MQTT topic: airgradient/readings/{sn}/ce -CoAP host: 128.140.49.53:5683 (path: /{sn}) -``` - -### Response Code Interpretation - -Response codes are interpreted per-operation and mapped to -`AgClientResult` to match the existing server contract. - -#### http_fetch_config (WiFi) - -| Status | `AgClientResult` | -|---|---| -| 200 | `Ok` | -| 400 | `NotRegistered` | -| Other | `ServerError` | - -#### http_post_measures (WiFi) - -| Status | `AgClientResult` | -|---|---| -| 200 | `Ok` | -| 429 | `Ok` (rate-limited but accepted) | -| Other | `ServerError` | - -#### coap_fetch_config (Cellular, future spec) - -| Class | `AgClientResult` | -|---|---| -| 2.xx | `Ok` | -| 4.xx | `NotRegistered` | -| Other | `ServerError` | - -#### coap_post_measures (Cellular, future spec) - -| Class | `AgClientResult` | -|---|---| -| 2.xx | `Ok` | -| Other | `ServerError` | - -#### mqtt_publish_measures (Cellular, future spec) - -Broker acknowledgement returns `Ok`. Disconnect or publish error returns -`TransportError`. - -### TLS Certificate - -The AirGradient root CA is embedded as a static `constexpr` string inside -the component (same certificate as the old library). Passed to -`HttpClient` methods via the `cert_pem` parameter. - -### Unsupported Combination Handling - -Calling a method on an unsupported network, or a method whose backend is -not yet implemented, is a programming bug. The client logs an error and -aborts. - -- `http_fetch_config` / `http_post_measures` on Cellular --- **aborts** -- `coap_*` on WiFi --- **aborts** -- `coap_*` / `mqtt_*` on Cellular --- **aborts** (cellular backends are - not implemented in this spec; future work) -- `mqtt_*` on WiFi --- **aborts** (until `WifiMqttClient` is implemented) -- `begin(sn, NetworkType::Cellular, modem)` --- **returns `false`** and - logs that cellular is not supported in this implementation - -### Internal Flow - -```mermaid -sequenceDiagram - participant Caller - participant AgClient - participant Serializer as PayloadSerializer - participant Http as HttpClient - - Caller->>AgClient: http_post_measures(measures, signal, boot) - AgClient->>AgClient: build_post_measures_url() - 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 - AgClient->>AgClient: map to AgClientResult - AgClient-->>Caller: AgClientResult -``` - -### Component Structure - -```text -components/airgradient-client/ - clients/ - http_client.h -- HttpClient interface - mqtt_client.h -- MqttClient interface - coap_client.h -- CoapClient interface - types/ - client_types.h -- NetworkType, AgClientResult, AgClientMeasuresType - services/ - ag_client.h / .cpp -- AgClient class - payload_serializer.h / .cpp -- Measures to JSON / binary - backends/ - wifi_http_client.h / .cpp -- esp_http_client wrapper (implement now) - wifi_mqtt_client.h / .cpp -- esp_mqtt_client wrapper (future spec) - cellular_http_client.h / .cpp -- CellularModem HTTP adapter (future spec, OTA) - cellular_mqtt_client.h / .cpp -- CellularModem MQTT adapter (future spec) - cellular_coap_client.h / .cpp -- CellularModem UDP + CoAP (future spec) - lib/ - coap-packet/ -- Copied as-is from old library - payload-encoder/ -- Copied as-is from old library - tests/ - ag_client_test_access.h -- Friend class for AgClient test injection - ag_client.tests.cpp - payload_serializer.tests.cpp - CMakeLists.txt - Kconfig - README.md - spec.md -- This spec -``` - -### Dependencies - -```text -airgradient-client - airgradient-common (Measures types, logging) - airgradient-cellular (optional -- CellularModem HAL for CoAP, MQTT, and - OTA HTTP, gated by Kconfig) - esp_http_client (WiFi HTTP backend) - esp_mqtt (WiFi MQTT backend, future spec) - cJSON (JSON serialization, built into ESP-IDF) -``` - -### Kconfig - -```text -menu "AirGradient Client" - - choice AG_CLIENT_MEASURES_TYPE - prompt "Client measures type" - default AG_CLIENT_MEASURES_TYPE_FULL - help - Selects which Measures variant the client serializes and posts. - - config AG_CLIENT_MEASURES_TYPE_FULL - bool "Measures" - - config AG_CLIENT_MEASURES_TYPE_BASIC - bool "MeasuresBasic" - - config AG_CLIENT_MEASURES_TYPE_AGO - bool "MeasuresAGo" - endchoice - - config AG_CLIENT_CELLULAR_SUPPORT - bool "Enable cellular modem support" - default n - help - When enabled, AgClient can use a CellularModem for CoAP and - MQTT operations, and the CellularHttpClient backend is built - for OTA use. Adds a build dependency on airgradient-cellular. - -endmenu -``` - -## Implementation Plan - -Ordered by dependency. Each step is a focused commit. All non-WiFi-HTTP -methods (`coap_*`, `mqtt_*`, cellular `begin()`) are implemented as stubs -that log an error and abort. - -1. **Create component skeleton** --- directory structure, `CMakeLists.txt`, - `Kconfig`, empty `README.md` - -2. **Define types** --- `types/client_types.h` with `NetworkType` enum, - `AgClientResult` enum, `AgClientMeasuresType` typedef - -3. **Define protocol client interfaces** --- `clients/http_client.h`, - `clients/mqtt_client.h`, and `clients/coap_client.h` - -4. **Copy vendored libraries** --- `lib/coap-packet/` and - `lib/payload-encoder/` as-is from old library - -5. **Implement payload serializer** --- `services/payload_serializer.h/.cpp`, - `Measures` to JSON using `cJSON`. Host-testable (pure logic, no ESP-IDF - dependency except `cJSON` which builds natively) - -6. **Implement `AgClient` core** --- `services/ag_client.h/.cpp` with URL - building, response interpretation, status tracking, WiFi HTTP methods - delegating to `HttpClient`. All CoAP/MQTT/cellular methods are stubs - that abort with a clear error log. - -7. **Implement `WifiHttpClient`** --- `backends/wifi_http_client.h/.cpp` - wrapping `esp_http_client` for `get()` and `post()`. - -8. **Add host tests** --- `tests/ag_client_test_access.h` (friend class), - `tests/payload_serializer.tests.cpp` (pure logic), and - `tests/ag_client.tests.cpp` (mock `HttpClient` via friend class, verify - URL building, serialization, status tracking, response interpretation) - -9. **Wire into build system** --- update `tests/CMakeLists.txt` to include - component tests, verify ESP-IDF product build with the component - -10. **Write component README** --- following the component README template - -## Testing Strategy - -### Host Tests (Friend Class + Mock Protocol Clients) - -Tests use the `AgClientTestAccess` friend class to inject mock protocol -clients into `AgClient` internals without special public constructors. -This follows the same pattern as `GoAppTestAccess` and -`TestableSimcomA7672x` in the existing codebase. - -`AgClientTestAccess` lives in its own header -(`tests/ag_client_test_access.h`) and provides: - -- `inject_http_client()`, `inject_mqtt_client()`, `inject_coap_client()` - --- set the protocol client pointers directly -- `set_serial_number()`, `set_network()` --- configure internal state - without calling `begin()` - -```cpp -// tests/ag_client_test_access.h -class AgClientTestAccess { -public: - explicit AgClientTestAccess(AgClient &c) : c_(c) {} - void inject_http_client(HttpClient *h) { c_.http_ = h; } - void inject_mqtt_client(MqttClient *m) { c_.mqtt_ = m; } - void inject_coap_client(CoapClient *p) { c_.coap_ = p; } - void set_serial_number(const char *sn); - void set_network(NetworkType n) { c_.network_ = n; } -private: - AgClient &c_; -}; -``` - -Test example: - -```cpp -TEST_CASE("http_post_measures serializes correct JSON") { - MockHttpClient mock_http; - AgClient client; - AgClientTestAccess access(client); - access.set_serial_number("aabbccddeeff"); - access.set_network(NetworkType::Wifi); - access.inject_http_client(&mock_http); - - // Initialize all fields to invalid sentinels first - AgClientMeasuresType m{}; - m.co2.co2 = MeasuresInvalid::CO2; - m.tvoc_nox.tvoc_index = MeasuresInvalid::TVOC; - m.tvoc_nox.tvoc_raw = MeasuresInvalid::TVOC; - m.tvoc_nox.nox_index = MeasuresInvalid::NOX; - m.tvoc_nox.nox_raw = MeasuresInvalid::NOX; - m.temp_hum_a.temperature = MeasuresInvalid::TEMPERATURE; - m.temp_hum_a.humidity = MeasuresInvalid::HUMIDITY; - m.pm_a.pm_01 = MeasuresInvalid::PM; - m.pm_a.pm_25 = MeasuresInvalid::PM; - m.pm_a.pm_10 = MeasuresInvalid::PM; - // ... remaining fields set to invalid ... - - // Set only the fields we actually measured - m.temp_hum_a.temperature = 23.5f; - m.co2.co2 = 450; - - 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: {"wifi":-55,"boot":6,"rco2":450,"atmp":23.5} - // (no pm, no tvoc, no humidity --- those were set to invalid) -} - -TEST_CASE("http_fetch_config interprets 400 as not registered") { - MockHttpClient mock_http; - mock_http.next_get_status = 400; - - AgClient client; - AgClientTestAccess access(client); - access.set_serial_number("aabbccddeeff"); - access.set_network(NetworkType::Wifi); - access.inject_http_client(&mock_http); - - char buf[2048]; - size_t written; - auto result = client.http_fetch_config(buf, sizeof(buf), &written); - - REQUIRE(result == AgClientResult::NotRegistered); -} - -TEST_CASE("http_fetch_config reports truncation") { - MockHttpClient mock_http; - mock_http.next_get_status = 200; - mock_http.next_get_body = "{ ... long config ... }"; - - AgClient client; - AgClientTestAccess access(client); - access.set_serial_number("aabbccddeeff"); - access.set_network(NetworkType::Wifi); - access.inject_http_client(&mock_http); - - char small_buf[16]; - size_t written; - auto result = client.http_fetch_config(small_buf, sizeof(small_buf), - &written); - REQUIRE(result == AgClientResult::BufferTooSmall); - REQUIRE(written > 0); -} -``` - -When cellular CoAP is implemented in a future spec, tests will mock -`CoapClient` with a small surface (two methods: `get`, `post`). -`AgClient` never sees CoAP packet internals --- all CoAP protocol -machinery is encapsulated behind `CoapClient`, keeping tests focused on -AG server semantics. - -### Payload Serializer Tests (Pure Logic) - -- Valid fields produce correct JSON property names and values -- Invalid fields (set to `MeasuresInvalid` sentinels) are omitted from - 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,"boot":0}`) -- All `Measures` variants (`Measures`, `MeasuresBasic`, `MeasuresAGo`) - serialize without error - -### WiFi HTTP Client - -Not host-testable (wraps `esp_http_client`). Verified by ESP-IDF firmware -build and manual hardware test. - -## Open Questions - -- **`measures_types.h` default initializers** --- should all `Measures` - substructs (`CO2Data`, `TempHumData`, `PMData`, `TVOCNOxData`, - `O3No2Data`, `PressureData`) gain invalid-sentinel default member - initializers, matching what `MeasuresPower` already does? This would - make `AgClientMeasuresType m{}` safe by default and remove a class of - caller bugs. Cross-cutting change beyond this component's scope --- - deserves its own discussion. -- **CoAP endpoint path** --- the old library uses `/{sn}` as the CoAP URI - path. Confirm this is still the server contract. -- **MQTT QoS** --- the old library uses QoS 1 for MQTT publish. Confirm or - make configurable. -- **HTTP timeout** --- the old WiFi client uses 15s default. Should this be - a Kconfig constant or a runtime setter on `AgClient`? -- **`interval_seconds` overflow** --- the CoAP binary encoder stores the - interval as `uint8_t` minutes (max 255 minutes / ~4.25 hours). Should - values exceeding this ceiling clamp, abort, or be rejected? diff --git a/components/airgradient-local-server/README.md b/components/airgradient-local-server/README.md index 81e1d9d..7ab89a6 100644 --- a/components/airgradient-local-server/README.md +++ b/components/airgradient-local-server/README.md @@ -7,11 +7,9 @@ supply live data and config semantics through small abstract providers. ## Status -`Experimental`. The component and its host tests are implemented, and the Go -source integration is documented in the -[Go Local Server service doc](../../products/go/docs/local_server.md). The API -surface (`/api/v1`) still requires physical-device validation, and discovery -and external client integration require follow-up work. +`Stable`. The component and its host tests are implemented, and the Go source +integration is documented in the +[Go Local Server service doc](../../products/go/docs/local_server.md). ## Scope diff --git a/components/airgradient-local-server/spec.md b/components/airgradient-local-server/spec.md deleted file mode 100644 index b10e56f..0000000 --- a/components/airgradient-local-server/spec.md +++ /dev/null @@ -1,976 +0,0 @@ -# airgradient-local-server Spec - -> **This is a spec.** It describes how a feature **will be built**, not what -> currently exists. Once the feature ships, the component README becomes the -> source of truth and this file is typically deleted. See `docs/STYLE.md` → -> "Doc Lifecycle". - -A generic, product-agnostic local HTTP API for AirGradient ESP-IDF devices, -layered on `airgradient-http-server`. It exposes a small **versioned** API -(`/api/v1/...`) for measurements, configuration, and commands, consumed -primarily by Home Assistant. This is a **light redesign** — not a port of the -legacy Arduino local server — driven by two new device models arriving on this -codebase. It keeps the surface small: separate configuration (durable settings) -from commands (actions), let the device model define what a unit supports -(no runtime capability discovery), and use a mostly-flat config schema (one -nested exception, `corrections`). The -primary engineering win is the component itself: generic, host-testable, and -reusable across products, with the wire schema and JSON owned by the component -and live data plus config semantics supplied by the product through small -abstract providers. - -## Problem - -The local HTTP server only exists today inside the Arduino monolith -(`examples/OneOpenAir/LocalServer.{h,cpp}`), and its design has aged badly: - -- **Arduino-bound transport** — it runs a dedicated `webserver` task looping on - `server.handleClient()` because Arduino's `WebServer` is poll-based. This repo - uses `esp_http_server` (via `airgradient-http-server`), which runs its own - internal task, so a separate task is unnecessary. -- **No product seam** — payload building and config parsing are wired to global - `Measurements` and `Configuration` objects, so the logic cannot be shared by - products with different settings structs (`GoSettings`, etc.). -- **Commands disguised as settings** — actions such as CO2 calibration and LED - test are modeled as boolean config fields (`co2CalibrationRequested`), mixing - fire-and-forget commands into durable configuration. -- **No versioning** — the API evolves in place, so any change risks breaking - consumers. -- **Untestable on host** — handler logic is welded to globals and the Arduino - server. - -Two of AirGradient's four device models are new and arrive on this codebase, so -nothing in the field expects a legacy schema from them. That makes a small, -versioned redesign low-risk and a sensible moment to fix the command/settings -mix and the lack of versioning — without overbuilding. - -## Goals - -- A generic component reusable by every product, owning versioned routing, the - wire schema, JSON, and the structured error model. -- A small **versioned** API under `/api/v1/`: `measures`, `config`, `actions/*`. -- Small abstract provider seams (`MeasuresProvider`, `ConfigProvider`, - `ActionHandler`) so products supply live data and config semantics without the - component seeing product types. -- A clean **settings-vs-command split**: durable settings under `config` - (GET / partial PUT), commands under `actions/*` (POST). -- A **mostly-flat configuration schema**: one object, every field optional, named - by function, with a single nested exception (`corrections`); each device emits - and accepts only the subset its model supports. -- Consistent wire conventions: **camelCase** JSON fields, **kebab-case** URL path - segments, keeping the legacy vocabulary where it was already clear and renaming - only misleading or opaque names (see `api-v1-naming-decision.md`). -- Host-testable: providers are faked under `TEST_HOST`; ESP-IDF and - `esp_http_server` stay confined to `airgradient-http-server`. -- Discovery alignment: devices are found by Home Assistant over mDNS and - distinguished by an `api` TXT record (advertised outside this component). - -## Non-Goals - -- **No runtime capability discovery** — there is no `capabilities` endpoint. With - four models and AirGradient owning the Home Assistant integration, the model - string maps to supported fields, actions, and value ranges on the integration - side. Device identity (`model`, `serialNumber`, `firmware`) rides in the - measures payload so the integration can do that mapping. -- **No server lifecycle ownership** — the product owns `HttpServer::start()` and - `stop()`. `LocalServer` only registers / unregisters its routes (lazily). -- **No own task** — handlers run in the `esp_http_server` httpd task. -- **No mDNS ownership** — the component documents the discovery contract; mDNS - registration lives in `airgradient-wifi` or product wiring. -- **No TLS/HTTPS, authentication, authorization, or CORS** — those belong to - `airgradient-http-server` or a future component. -- **No product-specific config fields yet** — the catalog ships with the common - fields (including `mqttBrokerUrl`, `httpDomain`, and `corrections`), but no - product-niche fields. Product-specific HTTP fields (for example Go's GPS or - buzzer settings) are added later as flat optional fields when a product - actually exposes them; Go remains BLE-centric for its niche settings for now. -- **No extended measurement groups yet** — only the common monitor fields ship - in v1; battery / pressure / electrode / dual-channel groups are deferred (see - Measures Schema). - -## Dependencies - -- `airgradient-http-server` — the underlying server, request/response, and route - registration. It provides `202 Accepted`, `503 Service Unavailable`, - status-only responses, and complete-body reporting. -- `airgradient-common` — the shared `Measures` types in `measures_types.h`. -- `cJSON` (ESP-IDF) — serialization, isolated to `internal/`. - -## Design - -### Resource Model - -```text -GET /api/v1/measures sensor readings + identity + wifiRssi -GET /api/v1/config current settings (supported fields only) -PUT /api/v1/config partial settings submission -> 202 Accepted -POST /api/v1/actions/calibrate-co2 trigger CO2 calibration (fire-and-forget) -> 200 -POST /api/v1/actions/test-leds trigger LED test (fire-and-forget) -> 200 -``` - -Wire conventions: JSON field names are **camelCase**; URL path segments are -**kebab-case** (so action ids appear as `calibrate-co2`, `test-leds`). - -Durable settings and fire-and-forget commands are separated by nature. The API -version lives in the path (`/api/v1`): it is the in-band version signal and -yields a clean `404` if a client targets the wrong version. Device identity is -carried in the measures payload because the Home Assistant integration reads the -model from there to drive its model-based mapping. - -`202 Accepted` confirms validation and admission, not persistence or runtime -application. There is no completion resource in v1. A client that needs -confirmation polls `GET /api/v1/config` until the desired values appear or its -own deadline expires. `503 busy` is temporary and carries no `Retry-After`. - -Because `airgradient-http-server` matches URIs **exactly** (no wildcards), each -action is a **concrete** route (`/api/v1/actions/calibrate-co2`, -`/api/v1/actions/test-leds`), not one dynamic `/actions/` route. When an -`ActionHandler` is present, the component registers a route for every catalog -action, so every known action path is handled and returns a structured response. -A request to a **non-catalog** path (for example `/api/v1/actions/foo`) falls -through to the http-server's default `404` and is therefore **not** wrapped in -the structured error envelope; the structured-error guarantee applies to requests -routed to a local-server handler. - -Product-specific resources (for example Go's saved recordings) are registered by -the product directly on the shared `HttpServer`; the component neither owns nor -knows about them. - -### Component Layout - -```text -components/airgradient-local-server/ - services/ - local_server.h # facade: begin / end (route registration) - hal/ - measures_provider.h # required provider - config_provider.h # optional provider - action_handler.h # optional provider - types/ - local_config.h # LocalServerConfig (mostly-flat optional fields + nested Corrections) - system_info.h # SystemInfo (serial_number, model, firmware, wifi_rssi, boot) - local_server_result.h # ConfigSubmitResult, ActionResult, ConfigAccess, ActionId - api_error.h # structured error code enum - internal/ - measures_json.{h,cpp} # serialize measures (cJSON) - config_json.{h,cpp} # serialize / parse config (cJSON) - tests/ - fake_providers.h - measures_json.tests.cpp - config_json.tests.cpp - handler.tests.cpp - CMakeLists.txt - Kconfig - README.md -``` - -### Provider Seams - -The dependency arrow always points product → component; the component never -includes product types. Providers are injected by reference and must outlive the -`LocalServer`. - -```cpp -// hal/measures_provider.h -// -// Ownership : product owns the implementation. -// Lifetime : must outlive the LocalServer. -// Thread-safe: yes — called from the httpd task; return cached snapshots. -// Blocking : should not block. -class MeasuresProvider { - public: - virtual ~MeasuresProvider() = default; - - // Snapshot of current readings for GET /api/v1/measures. - virtual Measures get_measures() = 0; - - // Identity + link info embedded in the measures payload. - virtual SystemInfo get_system_info() = 0; -}; -``` - -```cpp -// types/system_info.h -// -// wifi_rssi is optional: std::nullopt when the link quality is unavailable, in -// which case the "wifiRssi" key is omitted from the measures payload. (C++ -// members are snake_case; the camelCase wire key is noted per field.) -struct SystemInfo { - char serial_number[24] = {}; // "serialNumber" - char model[32] = {}; // "model" - char firmware[16] = {}; // "firmware" - std::optional wifi_rssi; // "wifiRssi" (dBm; omitted when unavailable) - uint32_t boot = 0; // "boot": saturated uptime in completed minutes -}; -``` - -```cpp -// hal/config_provider.h -class ConfigProvider { - public: - virtual ~ConfigProvider() = default; - - // Current settings mapped into the flat schema for GET /api/v1/config. - // Unsupported fields are std::nullopt and omitted from the JSON. - virtual LocalServerConfig get_config() = 0; - - // Validate and atomically admit a partial config without blocking. Accepted - // means the product assumed responsibility for later processing; it does not - // guarantee persistence or runtime application. - virtual ConfigSubmitResult submit_config(const LocalServerConfig &partial) = 0; -}; -``` - -```cpp -// hal/action_handler.h -// -// Actions are fire-and-forget commands. trigger() must not block: it dispatches -// the work (for example queues a CO2 calibration on the product's worker) and -// returns immediately. No progress is reported; a consumer observes the effect -// indirectly (for example the CO2 reading settling after calibration). -class ActionHandler { - public: - virtual ~ActionHandler() = default; - - // Dispatch a named action. The component maps the result to a status: - // Dispatched -> 200, Rejected -> 403, NotSupported -> 404, Busy -> 503. - virtual ActionResult trigger(ActionId action) = 0; -}; -``` - -```cpp -// types/local_server_result.h -// -// Results are pointer-free: providers return only enums. The component owns -// and serializes all error strings (canonical field name + a standardized -// message per status). This removes any borrowed-string lifetime hazard — a -// provider can never accidentally return a stack/local pointer that dangles -// during error serialization. -enum class ConfigAccess : uint8_t { Disabled, ReadOnly, ReadWrite }; - -// Mirrors the config catalog; the component maps each id to its canonical -// camelCase wire key (e.g. CountryCode -> "country", TemperatureUnit -> -// "temperatureUnit") when building an error body. The nested corrections entries -// map to dotted keys (e.g. CorrectionsPm25 -> "corrections.pm25"). None is used -// when no specific field applies. -enum class ConfigFieldId : uint8_t { - None, - CountryCode, // "country" - PmStandard, // "pmStandard" - TemperatureUnit, // "temperatureUnit" - PostDataToCloud, // "postDataToCloud" - CloudConnection, // "cloudConnection" - ConfigurationControl, // "configurationControl" - Co2AbcDays, // "co2AbcDays" - TvocLearningOffset, // "tvocLearningOffset" - NoxLearningOffset, // "noxLearningOffset" - LedMode, // "ledMode" - LedBarBrightness, // "ledBarBrightness" - DisplayBrightness, // "displayBrightness" - MqttBrokerUrl, // "mqttBrokerUrl" - HttpDomain, // "httpDomain" - Corrections, // "corrections" (whole object) - CorrectionsPm25, // "corrections.pm25" - CorrectionsTemp, // "corrections.temp" - CorrectionsHumidity, // "corrections.humidity" -}; - -enum class ConfigSubmitStatus : uint8_t { - Accepted, // validated and admitted -> 202 - InvalidValue, // semantic validation failure -> 400 - Forbidden, // source or endpoint policy -> 403 - NotSupported, // field not supported on this model -> 404 - Busy, // temporary admission pressure -> 503 - Internal, // unexpected provider failure -> 500 -}; - -struct ConfigSubmitResult { - ConfigSubmitStatus status = ConfigSubmitStatus::Internal; - // The offending field for InvalidValue / NotSupported; None otherwise. The - // component maps it to the canonical wire key in the error body. - ConfigFieldId field = ConfigFieldId::None; -}; - -enum class ActionId : uint8_t { CalibrateCo2, TestLeds }; -enum class ActionStatus : uint8_t { - Dispatched, // accepted and queued (fire-and-forget) -> 200 - Rejected, // policy / state gate -> 403 - NotSupported, // action not available on this model -> 404 - Busy, // temporary admission pressure -> 503 -}; - -struct ActionResult { - ActionStatus status = ActionStatus::NotSupported; -}; -``` - -```cpp -// services/local_server.h -// -// Ownership : holds references only; the HttpServer and all providers MUST -// outlive this object. -// Copy/move : non-copyable, non-movable (handlers capture `this`). -// Thread-safe: no (begin / end are setup-time calls from one task). -// Blocking : no. -class LocalServer { - public: - struct Providers { - MeasuresProvider &measures; // required - ConfigProvider *config = nullptr; // optional - ConfigAccess config_access = ConfigAccess::Disabled; // GET and/or PUT - ActionHandler *actions = nullptr; // optional - }; - - LocalServer(HttpServer &server, const Providers &providers); - - // RAII: unregisters any routes still registered, so no captured-`this` - // handler can outlive the object on the server. - ~LocalServer(); - - LocalServer(const LocalServer &) = delete; - LocalServer &operator=(const LocalServer &) = delete; - LocalServer(LocalServer &&) = delete; - LocalServer &operator=(LocalServer &&) = delete; - - // Register the versioned routes for the providers that are present: - // measures -> GET /api/v1/measures - // config ReadOnly -> GET /api/v1/config - // config ReadWrite -> GET + PUT /api/v1/config - // actions present -> POST /api/v1/actions/ for EVERY ActionId in - // the catalog, regardless of model support - // (unsupported -> structured 404 at request time) - // - // Idempotent: a no-op returning true if already begun. Transactional: if any - // route fails to register, the routes registered so far in this call are - // rolled back (unregistered) and begin() returns false. May be called lazily - // (for example when the device joins a network). - bool begin(); - - // Unregister ONLY the routes this LocalServer registered (tracked in - // _routes). Never calls HttpServer::unregister_all(), so product- or - // provisioning-owned routes on the same server are untouched. Safe to call - // when not begun. - void end(); - - private: - struct OwnedRoute { - HttpMethod method; - const char *path; // static-lifetime literal - }; - - void _handle_get_measures(const HttpRequest &, HttpResponse &); - void _handle_get_config(const HttpRequest &, HttpResponse &); - void _handle_put_config(const HttpRequest &, HttpResponse &); - void _handle_action(ActionId action, const HttpRequest &, HttpResponse &); - - HttpServer &_server; - MeasuresProvider &_measures; - ConfigProvider *_config; - ConfigAccess _config_access; - ActionHandler *_actions; - - static constexpr size_t MAX_OWNED_ROUTES = 5; // measures + config x2 + 2 actions - OwnedRoute _routes[MAX_OWNED_ROUTES] = {}; - size_t _route_count = 0; - bool _begun = false; -}; -``` - -### Lifecycle and Route Ownership - -`LocalServer` shares the `HttpServer` with other route owners (the provisioning -captive portal, product-specific routes). It must therefore never touch routes -it did not register. - -- **Owns only its routes.** Every successful `register_route` is recorded in - `_routes` with its method and static-lifetime path. `end()` unregisters - exactly those (via `HttpServer::unregister_route`) and clears the list. It - **never** calls `HttpServer::unregister_all()`. -- **Transactional `begin()`.** Routes are registered in order; if any fails, the - ones already registered in that call are rolled back and `begin()` returns - `false`, leaving the server as it was. `begin()` is idempotent — a no-op - returning `true` when `_begun` is already set. -- **RAII teardown.** `~LocalServer()` calls `end()`, so a destroyed - `LocalServer` can never leave a captured-`this` handler registered on the - server. Because the destructor touches `_server`, the `HttpServer` must - outlive the `LocalServer`; the providers must too. -- **Non-copyable, non-movable.** Handlers capture `this`; copying or moving would - invalidate those captures. Both are `= delete`d. - -### Measures Schema - -Single corrected value per field. A field is **omitted** whenever it is -unsupported by the model **or** currently invalid (per the `Measures` -`is_*_valid()` methods) — there is no `null` form. The distinction is -unnecessary: the Home Assistant integration already treats a missing key and a -`null` identically (it defers creating the entity until a real value appears), -and which sensors a device has is known from the model. Identity (`serialNumber`, -`model`, `firmware`) and `wifiRssi` (when available) are included so the -integration can map by model. - -| v1 wire | Source (`measures_types.h` / `SystemInfo`) | Legacy wire | -|---|---|---| -| `serialNumber` | `SystemInfo::serial_number` | `serialno` | -| `model` | `SystemInfo::model` | `model` | -| `firmware` | `SystemInfo::firmware` | `firmware` | -| `wifiRssi` | `SystemInfo::wifi_rssi` (optional) | `wifi` | -| `boot` | `SystemInfo::boot` | `boot` | -| `co2` | `CO2Data::co2` | `rco2` | -| `pm01` | `PMData::pm_01` | `pm01` | -| `pm25` | `PMData::pm_25` | `pm02` | -| `pm10` | `PMData::pm_10` | `pm10` | -| `pm003Count` | `PMData::pm_03_pc` | `pm003Count` | -| `pm005Count` | `PMData::pm_05_pc` | `pm005Count` | -| `pm01Count` | `PMData::pm_01_pc` | `pm01Count` | -| `pm02Count` | `PMData::pm_25_pc` | `pm02Count` | -| `pm50Count` | `PMData::pm_5_pc` | `pm50Count` | -| `pm10Count` | `PMData::pm_10_pc` | `pm10Count` | -| `temp` | `TempHumData::temperature` | `atmp` | -| `humidity` | `TempHumData::humidity` | `rhum` | -| `tvocIndex` | `TVOCNOxData::tvoc_index` | `tvocIndex` | -| `tvocRaw` | `TVOCNOxData::tvoc_raw` | `tvocRaw` | -| `noxIndex` | `TVOCNOxData::nox_index` | `noxIndex` | -| `noxRaw` | `TVOCNOxData::nox_raw` | `noxRaw` | -| `battPercent` | `MeasuresPower::battery_percentage` | — | -| `battVolt` | `MeasuresPower::battery_voltage` | `volt` | -| `chargeVolt` | `MeasuresPower::charging_voltage` | `light` | - -Wire field names are camelCase; the legacy vocabulary is kept where it was -already clear and renamed only where it misled or was opaque (see -`api-v1-naming-decision.md`). - -Numeric precision matches the cloud measurement payload: `temp` and `humidity` -use two decimal places, PM mass uses one, particle counts and `battPercent` are -integers, and voltage values use two decimal places. `chargeVolt` is measured -input/VBUS voltage; it is not a charging-state indicator or a regulation setpoint. - -```json -{ "serialNumber": "aabbccddeeff", "model": "O-1PST", "firmware": "2.0.0", - "wifiRssi": -57, "boot": 6, "co2": 612, "pm01": 5, "pm25": 8, "pm10": 9, - "temp": 24.3, "humidity": 47.1, "tvocIndex": 101, "noxIndex": 1 } -``` - -`boot` is device uptime floored to completed minutes and saturated to the -`uint32_t` wire range. It starts at `0`, advances independently of measurement -delivery, and is **not** a timestamp. Products define which reset boundaries -retain uptime. The deprecated legacy `bootCount` duplicate is intentionally not -emitted. - -**Deferred groups** (present in `Measures` but not exposed in v1; add as flat -optional fields when a product needs them): `pressure` / `altitude`, `electrode` -(O3 / NO2), and the dual-channel -`temp_hum_b` / `pm_b`. Go has pressure, so `pressure` is the most likely first -addition. - -### Config Schema - -One **flat** object; every field optional in the wire and in `LocalServerConfig` -(`std::optional`). Fields are named by function, not by product. The -component owns the catalog as a **union** of known fields; a device emits only -the fields its model supports (GET) and applies only the present supported -fields (PUT). Enum string values are kept identical to the existing Home -Assistant integration to minimize its churn. Adding a future field (including a -product-specific one) is a non-breaking addition of one optional field. - -The catalog ships with the **common** fields only: - -| v1 wire | Type | Values / range | Legacy | Notes | -|---|---|---|---|---| -| `country` | string(2) | ISO-3166 alpha-2 | `country` | locale for AQI | -| `pmStandard` | enum | `ugm3` / `us-aqi` | `pmStandard` | | -| `temperatureUnit` | enum | `c` / `f` | `temperatureUnit` | | -| `postDataToCloud` | bool | — | `postDataToAirGradient` | post measurement data to the cloud | -| `cloudConnection` | bool | — | `disableCloudConnection` (inverted) | master cloud switch; writable here | -| `configurationControl` | enum | `cloud` / `local` / `both` | `configurationControl` | product enforces the gate | -| `co2AbcDays` | int | 0–200 (default 8) | `abcDays` | days | -| `tvocLearningOffset` | int | 0–720 (default 12) | `tvocLearningOffset` | days | -| `noxLearningOffset` | int | 0–720 (default 12) | `noxLearningOffset` | days | -| `ledMode` | enum | `co2` / `pm` / `iaqs` / `off` | `ledBarMode` | LED-bar models | -| `ledBarBrightness` | int | 0–100 | `ledBarBrightness` | LED-bar models | -| `displayBrightness` | int | 0–100 | `displayBrightness` | display models | -| `mqttBrokerUrl` | string | broker URL (empty to clear) | `mqttBrokerUrl` | MQTT-capable models | -| `httpDomain` | string | custom HTTP domain (empty to clear) | `httpDomain` | | -| `corrections` | object | nested; see Corrections Schema | `corrections` | inner keys use v1 measure names | - -`corrections` is the **one nested exception** to the flat schema (see Corrections -Schema below). Wire field names are camelCase; URL path segments are kebab-case. - -C++ members stay `snake_case` (firmware style); the wire key (camelCase) is the -trailing comment. The serialize / parse layer is the only place the two -vocabularies meet. - -```cpp -// types/local_config.h (excerpt) - -// Parsing preserves coefficient presence for product semantic validation. GET -// serialization requires both coefficients for every non-null SLR. -struct SlrParams { - std::optional intercept; // "intercept" - std::optional scaling_factor; // "scalingFactor" - std::optional use_epa2021; // "useEpa2021" (pm25 only) -}; - -struct CorrectionEntry { - std::string algorithm; // "correctionAlgorithm" ("none" disables) - std::optional slr; // "slr" (null -> nullopt) -}; - -// Nested object; the single exception to the flat schema. Inner keys use v1 -// measure vocabulary (pm25 / temp / humidity), not legacy (pm02 / atmp / rhum). -struct Corrections { - std::optional pm25; // "pm25" - std::optional temp; // "temp" - std::optional humidity; // "humidity" -}; - -struct LocalServerConfig { - std::optional country; // "country" - std::optional pm_standard; // "pmStandard" - std::optional temperature_unit; // "temperatureUnit" - std::optional post_data_to_cloud; // "postDataToCloud" - std::optional cloud_connection; // "cloudConnection" - std::optional configuration_control; // "configurationControl" - std::optional co2_abc_days; // "co2AbcDays" - std::optional tvoc_learning_offset; // "tvocLearningOffset" - std::optional nox_learning_offset; // "noxLearningOffset" - std::optional led_mode; // "ledMode" - std::optional led_bar_brightness; // "ledBarBrightness" - std::optional display_brightness; // "displayBrightness" - std::optional mqtt_broker_url; // "mqttBrokerUrl" - std::optional http_domain; // "httpDomain" - std::optional corrections; // "corrections" - // Product-specific fields (for example buzzer_enabled, gps_interval_s) are - // added here as flat optional fields when a product exposes them over HTTP. -}; -``` - -`configurationControl` stays a normal catalog field: the component serializes -and parses it like any other, and the product's apply path enforces the -cloud-vs-local gate. The component holds no config policy. - -The two cloud fields are distinct: - -- `postDataToCloud` — whether the device posts measurement **data** to the - AirGradient cloud (the legacy `postDataToAirGradient`, same polarity). -- `cloudConnection` — the **master** switch for any cloud contact (data post, - cloud config fetch, automatic OTA). It maps to the legacy - `disableCloudConnection` with **inverted polarity**: `cloudConnection: true` - means connected, whereas legacy `disableCloudConnection: true` meant disabled. - Legacy exposed it read-only (Wi-Fi setup only); v1 makes it **writable**. - -Precedence is product-enforced: when `cloudConnection` is `false`, all cloud -activity is off regardless of `postDataToCloud` or `configurationControl`. On Go -`cloudConnection` maps to the existing `disable_cloud` setting (inverted). - -### Corrections Schema - -`corrections` is the single **nested** config field — a deliberate exception to -the otherwise flat schema. Its structure **mirrors the legacy cloud `corrections` -object verbatim**; only the inner per-measure keys are remapped to v1 measure -vocabulary (`pm02` → `pm25`, `atmp` → `temp`, `rhum` → `humidity`). The algorithm -sub-keys (`correctionAlgorithm`, `slr`, `intercept`, `scalingFactor`, -`useEpa2021`) are unchanged from legacy. - -Each present measure carries a `correctionAlgorithm` string (`"none"` disables -correction) and an `slr` object, or `slr: null` when no SLR parameters apply. -`useEpa2021` appears **only** in the `pm25` entry; `temp` and `humidity` carry -just `intercept` and `scalingFactor`. - -```json -{ - "corrections": { - "pm25": { "correctionAlgorithm": "slr_PMS5003_20231030", - "slr": { "intercept": 0, "scalingFactor": 0.02838, "useEpa2021": true } }, - "temp": { "correctionAlgorithm": "none", "slr": null }, - "humidity": { "correctionAlgorithm": "none", "slr": null } - } -} -``` - -The component validates structure (object shape, known inner keys, sub-key -types, `slr` object-or-null) while preserving presence for `intercept` and -`scalingFactor`. The product validates algorithm support, required coefficient -presence, and ranges in `submit_config`. Structural errors inside the object -report a **dotted** `field` (for example `corrections.pm25`); an unknown inner -key is rejected like any other unknown field (`400 unknown_field`). GET -serialization fails with `500 internal` if a provider supplies a non-null SLR -without both coefficients. - -### Actions - -Commands, not settings — the legacy modeled these as boolean config fields. Each -is a concrete `POST /api/v1/actions/` route with an empty request body -and an empty success body. Action path segments are **kebab-case** (the REST path -convention), distinct from the camelCase JSON fields. Actions are -**fire-and-forget**: the handler dispatches the work and returns `200 OK` -immediately. No progress is tracked or reported — a consumer observes the effect -indirectly (for example the CO2 reading settling after calibration). A status or -progress resource can be added later if a consumer ever needs it; it would live -on the action, never in the measures payload. - -| Action id (`ActionId`) | Route | Legacy field | Supported by | -|---|---|---|---| -| `CalibrateCo2` | `/api/v1/actions/calibrate-co2` | `co2CalibrationRequested` | devices with a CO2 sensor | -| `TestLeds` | `/api/v1/actions/test-leds` | `ledBarTestRequested` | devices with controllable LEDs | - -When an `ActionHandler` is registered, the component registers a route for -**every** catalog action (every `ActionId`), not just the supported ones. -Support is decided at request time: `trigger()` returns `NotSupported` (mapped to -a structured `404 not_found`) for an action the model lacks or `Busy` (mapped to -structured `503 busy`) for temporary admission pressure. Consequently every -catalog action path returns a component-owned response; a -**bare** `404` occurs only for non-catalog paths (for example -`/api/v1/actions/foo`), which a model-aware client never requests. The -integration's model map decides whether to surface a button at all, so it never -needs to disambiguate a bare `404` from a structured one. - -### Validation Split - -- **Component** — transport completeness, JSON well-formedness (strict full-body - parse), **unknown-key rejection**, per-known-field type checks, and known-enum - membership. On failure it returns a structured error (`invalid_body`, - `unknown_field`, or `invalid_value`) before provider policy runs. -- **Product (`submit_config` / `trigger`)** — semantic validation and support, - source policy, and non-blocking admission. `submit_config` validates the - complete partial update before atomically admitting it. Persistence, runtime - apply, and action effects run later under product ownership. - -The component owns every error string. Providers return only enums -(`ConfigSubmitResult` / `ActionResult`); the component maps `ConfigFieldId` to its -canonical wire key and the status to a standardized message when building the -error body. No provider-borrowed strings are serialized, so there is no -dangling-pointer hazard. - -Unknown keys on `PUT /config` are **rejected** with `400 unknown_field` (not -silently ignored), so typos such as `temperatureUnits` fail loudly (this applies -to inner `corrections.*` keys too). Consequence: -because clients are model- and version-aware through the integration, this is -safe; but a within-version catalog addition must be coordinated so an older -firmware does not reject a newer client's field. New fields should therefore be -introduced alongside a client that only sends them to firmware known to accept -them. - -### Request Body Parsing - -`config_json::parse` performs **strict full-body** parsing — no "first valid -object wins": - -- Check `HttpRequest::body_complete()` before parsing. Oversized bodies, socket - short reads, and receive failures return `400 invalid_body` without exposing - a valid-looking prefix to the parser. -- Parse with the explicit body length via `cJSON_ParseWithLengthOpts(body, len, - &end, ...)` (the body buffer is not assumed null-terminated). -- **Reject malformed JSON** (null parse result) → `400 invalid_body`. -- **Reject a non-object root** (array, string, number, etc.) → `400 invalid_body`. -- **Reject trailing non-whitespace** after the root object — verify `end` - reached the end of the buffer modulo trailing whitespace → `400 invalid_body`. - -This is stricter than `airgradient-provisioning`'s lenient `cJSON_Parse`; the -length/opts variant is required so trailing garbage and truncated bodies are -caught rather than silently accepted. - -Parsing precedes provider policy. A malformed request therefore remains `400` -even when writes are disabled. An empty object is structurally valid and still -reaches `submit_config()` so the active write gate can return `202` or `403`. - -### Error Model - -Structured JSON for every request routed to a local-server handler. The -component composes the body entirely from its own strings: `code` from the error -case, `field` (when applicable) from the canonical wire key mapped from -`ConfigFieldId`, and `message` a standardized phrase for the status. For nested -`corrections` errors the `field` is **dotted** (for example `corrections.pm25`). - -```json -{ "error": { "code": "invalid_value", "field": "temperatureUnit", - "message": "invalid value" } } -``` - -| Case | Status | `error.code` | -|---|---|---| -| GET success | 200 | — | -| PUT config accepted | 202 | — | -| Action dispatched (fire-and-forget) | 200 | — | -| Malformed body | 400 | `invalid_body` | -| Unknown config key | 400 | `unknown_field` | -| Bad type / enum / out of range | 400 | `invalid_value` | -| Config rejected by policy / lock | 403 | `forbidden` | -| Catalog action not supported on model / config field not supported | 404 | `not_found` | -| Config or action admission temporarily busy | 503 | `busy` | -| Provider / serialize failure | 500 | `internal` | - -Successful `202` and action `200` responses have empty bodies and no content -type. A `503 busy` body uses message `busy` and does not include `Retry-After`. - -Unregistered paths (including unknown `/api/v1/actions/*`) are answered by the -http-server's default `404` and are not wrapped in this envelope. - -### PUT /api/v1/config Flow - -```mermaid -sequenceDiagram - participant C as Client - participant LS as handler - participant J as config_json - participant P as ConfigProvider - C->>LS: PUT /api/v1/config (partial JSON) - LS->>LS: require complete body - LS->>J: strict parse(body, len) - alt malformed, non-object root, trailing garbage, unknown key, bad type or enum - J-->>LS: parse error (field_id, code) - LS-->>C: 400 structured error - else parsed ok - J-->>LS: LocalServerConfig (present known keys only) - LS->>P: submit_config(partial) - Note over P: validate and atomically admit without blocking - alt InvalidValue - P-->>LS: InvalidValue (field_id) - LS-->>C: 400 invalid_value - else NotSupported - P-->>LS: NotSupported (field_id) - LS-->>C: 404 not_found - else Forbidden - P-->>LS: Forbidden - LS-->>C: 403 forbidden - else Busy - P-->>LS: Busy - LS-->>C: 503 busy - else Accepted - P-->>LS: Accepted - LS-->>C: 202 Accepted - end - end -``` - -### Memory and Threading - -- **No dedicated task** — handlers run in the `esp_http_server` httpd task, which - serializes requests. -- **Zero-copy GET responses** — handlers serialize into a single static scratch - buffer of `CONFIG_AG_LOCAL_SERVER_JSON_BUF` bytes with `cJSON_PrintPreallocated`, - then respond with `HttpResponse::body_static(HttpStatus::Ok, buf, len, - "application/json")`. `body_static` borrows (no copy); `json()` would copy into - an owned `std::string` and defeat the buffer's purpose, so it is **not** used - for these payloads. Borrowing is safe because requests are serialized and the - driver sends the body synchronously in `_trampoline` before the next request - can reuse the buffer. An optional `HttpResponse::json_static()` convenience may - be added upstream to set `application/json` and borrow in one call. -- **Transient cJSON heap** of roughly 4–6 KB at peak per request (the cJSON tree), - freed before the handler returns. -- **`GET /config` payload size** now includes the nested `corrections` object and - the two URL string fields (`mqttBrokerUrl`, `httpDomain`); the 3 KB scratch - buffer still has headroom, but confirm against the firmware build and bump - `CONFIG_AG_LOCAL_SERVER_JSON_BUF` if a fully-populated config approaches the cap. -- **Request body** is read and capped by `airgradient-http-server`; incomplete - bodies never reach JSON or provider policy. -- **Provider thread-safety** is the product's responsibility: provider methods - run on the httpd task and return cached snapshots or admission results without - blocking. Config persistence and runtime effects must not run in - `submit_config()`. - -### Configuration - -| Symbol | Default | Purpose | -|---|---|---| -| `CONFIG_AG_LOCAL_SERVER_JSON_BUF` | `3072` | Static scratch buffer for serialized GET payloads (measures and config, incl. `corrections`), in bytes | - -The httpd task stack size is owned by `airgradient-http-server` -(`HTTPD_DEFAULT_CONFIG()` default of 4096 bytes); promote it to Kconfig there if -the firmware build shows cJSON stack pressure. - -## Integration - -This API is one side of a contract spanning three repositories. The firmware -component owns only the HTTP surface; the other two pieces must be updated in -lockstep for end-to-end discovery and control. Each is out of scope for this -component but in scope for the feature. - -### airgradient-wifi (and product wiring) - -The discovery contract lives outside this component. `airgradient-wifi` (or the -product) must advertise the device over mDNS so Home Assistant finds it and can -route to the correct API version. - -- **Service:** `_airgradient._tcp` on the HTTP port (default 80). -- **Hostname:** `airgradient_.local`. -- **TXT records:** `vendor=AirGradient`, `model`, `serialno`, `fw_ver`, and the - new **`api=1`** key. `api` is the routing signal: its presence marks a v1-API - device; its absence marks a legacy device. -- **Stability:** `serialno` must stay stable across a legacy → new firmware - upgrade so the device keeps its Home Assistant identity; the integration - re-reads `api` on reconnect and switches paths. - -Work item: add an mDNS responder hook in `airgradient-wifi` (or product wiring) -that registers the service and TXT records after the device gets an IP, sourcing -`model` / `serialno` / `fw_ver` from the same identity used by `SystemInfo`. - -### python-airgradient (client library) - -`airgradienthq/python-airgradient` must become **version-aware** while keeping -the legacy path working. The realized design uses a backend abstraction — -`LegacyBackend` and `V1Backend` selected per device — plus internal v1 parser -models (`_V1Measures` / `_V1Config`) that normalize into the existing public -`Measures` / `Config` dataclasses via `to_public()`. Because this adapter layer -absorbs naming entirely (each field is one `mashumaro` alias plus a -`_SETTING_WIRE_KEYS` row), the v1 camelCase schema costs the integration no more -than legacy names would — and since v1 **keeps the legacy vocabulary wherever it -was already clear**, many v1 keys are byte-identical to legacy, so only the -handful of renamed fields need a distinct alias. - -- `V1Backend` targets `/api/v1/measures`, `/api/v1/config` (partial `PUT` → - `202`), and `POST /api/v1/actions/` (→ `200`, for example - `actions/calibrate-co2`) replacing the legacy action-via-config-PUT calls. - It treats `503 busy` as retryable and polls GET when config convergence must - be confirmed. -- The public `Config` model is made **all-optional** (the legacy model was - all-required and raised `MissingField`). -- `_SETTING_WIRE_KEYS` maps each normalized setting to its `(legacy_key, v1_key)` - pair, including the two cloud fields: - - `postDataToCloud` (v1) ↔ `postDataToAirGradient` (legacy) — same polarity. - - `cloudConnection` (v1) ↔ `disableCloudConnection` (legacy) — **inverted** - polarity; the backend negates when translating. -- Other renamed keys to map: `co2` ↔ `rco2`, `pm25` ↔ `pm02`, `temp` ↔ `atmp`, - `humidity` ↔ `rhum`, `serialNumber` ↔ `serialno`, `wifiRssi` ↔ `wifi` - (measures); `ledMode` ↔ `ledBarMode`, `co2AbcDays` ↔ `abcDays` (config). The - newly included `mqttBrokerUrl`, `httpDomain`, and `corrections` keep their - legacy keys; `corrections` inner keys are remapped (`pm02`→`pm25`, - `atmp`→`temp`, `rhum`→`humidity`). -- Enum value strings are unchanged (`ugm3`, `us-aqi`, `c`, `f`, `co2`, `pm`, - `iaqs`, `off`, `cloud`/`local`/`both`). - -### Home Assistant core (`homeassistant/components/airgradient`) - -The HA integration must learn the v1 API while continuing to serve legacy -devices in the same install: - -- **Discovery branch:** the zeroconf matcher already matches - `_airgradient._tcp.local.`; add handling that reads the `api` TXT property and - selects the new client path when present (falling back to a - `GET /api/v1/measures` probe if needed). -- **Model-based mapping:** keep deciding which config / LED / display entities, - actions, and value ranges apply from the device model string (read from the - measures payload). With four models this stays a small, integration-owned map - — no device capability endpoint needed. -- **Per-device coordinators:** each device keeps its own config entry and - coordinator keyed by `serial`, so a legacy device and a v1-API device coexist - with distinct unique IDs (`{serial}-{key}`) and no collisions. -- **Actions:** map the `button` / command entities to - `POST /api/v1/actions/` (`calibrate-co2`, `test-leds`). - -```text -firmware (this component) --advertises api=1--> HA discovery -python-airgradient client <--polls /api/v1--> HA coordinator -legacy device (no api TXT) --legacy /measures/current--> HA legacy path -``` - -## Implementation Plan - -Each step is sized to land as a focused commit. - -1. **airgradient-http-server prerequisites:** provide required status codes, - status-only responses, and complete request-body reporting. Update that - component's tests and README. -2. Add value types: `types/local_config.h` (mostly-flat optional fields plus the - nested `Corrections` / `CorrectionEntry` / `SlrParams` types), - `types/system_info.h` (optional `wifi_rssi`, `boot`), - `types/local_server_result.h`, `types/api_error.h`. Wire keys are camelCase; - C++ members stay snake_case. -3. Add `internal/measures_json.{h,cpp}` (serialize `Measures` + `SystemInfo` to - the camelCase schema) with host tests for omit-when-invalid-or-unsupported and - optional `wifiRssi`. -4. Add `internal/config_json.{h,cpp}` (serialize + **strict full-body** parse via - `cJSON_ParseWithLengthOpts`, non-object-root / trailing-garbage / unknown-key - rejection incl. inner `corrections.*`, `ConfigFieldId`-based errors with dotted - `corrections.` fields) with host tests for partial bodies, unknown-key - `400`, type / enum errors, non-object root, trailing garbage, and `corrections` - (`slr: null` and a populated `pm25` entry). -5. Add `hal/` provider interfaces and `services/local_server.{h,cpp}` (facade + four - handlers; non-copyable/non-movable; RAII `~LocalServer` calls `end()`; - idempotent + transactional `begin()`; tracked-route `end()` using - `unregister_route` only; `ConfigAccess` route selection; concrete action - routes; result → status mapping), plus `tests/fake_providers.h` and handler - tests. -6. Add `CMakeLists.txt` and `Kconfig` (`CONFIG_AG_LOCAL_SERVER_JSON_BUF`); wire - into the build and host-test runner. -7. Add the component `README.md` per `docs/templates/component_readme.md`. -8. Add a concrete provider set and wiring to the reference product, then build - the reference firmware. -9. Integration follow-ups (separate repos / components): mDNS TXT in - `airgradient-wifi`; `/api/v1` path in `python-airgradient`; discovery + model - mapping in HA core. - -### Pending alignment with the committed component - -The initial component was committed against the earlier **snake_case** draft of -this spec. Adopting the agreed camelCase contract (see -`api-v1-naming-decision.md`) makes this a **full re-key**, not a few deltas: - -- **Wire keys → camelCase.** Re-key every measures and config wire string in - `measures_json` and `config_json` (serialize + parse) to the camelCase catalog - (`serialNumber`, `wifiRssi`, `tvocIndex`, `pmStandard`, `temperatureUnit`, - `co2AbcDays`, `tvocLearningOffset`, `noxLearningOffset`, `ledMode`, …). C++ - members stay snake_case. -- **Cloud fields.** Rename `cloud_enabled` → member `post_data_to_cloud` (wire - `postDataToCloud`); add the `cloudConnection` master switch (member - `cloud_connection`) to `LocalServerConfig`, `config_json`, and `ConfigFieldId`. -- **`boot`.** Add to `SystemInfo` and emit it (wire `boot`) in `measures_json`. -- **New config fields.** Add `mqttBrokerUrl`, `httpDomain`, and the nested - `corrections` object (with `SlrParams` / `CorrectionEntry` / `Corrections` - types, dotted-`field` errors, and `useEpa2021` on `pm25` only). -- **`ConfigFieldId`.** Replace the enum with the camelCase-mapped set above, - including the `Corrections*` dotted entries. -- **Action routes → kebab-case.** Change the registered paths to - `/api/v1/actions/calibrate-co2` and `/api/v1/actions/test-leds` (and the test - constants). -- **Validation.** Widen `co2AbcDays` to `0–200`; accept `iaqs` in `ledMode`; - extend strict parse + unknown-key rejection into the nested `corrections` - object. -- **Tests.** Update `config_json.tests.cpp`, `measures_json.tests.cpp`, - `handler.tests.cpp`, and `fake_providers.h` to the new wire keys, kebab routes, - and `corrections` cases. -- **python-airgradient.** Add the renamed-key aliases, the cloud mappings - (`postDataToCloud` same-polarity, `cloudConnection` inverted), the `boot` - mapping, and the `corrections` inner-key remap. - -## Testing Strategy - -- **Serialize tests** — partially populated `Measures` emits only valid+supported - keys (no nulls) under the camelCase wire names, with identity and `wifiRssi` - only when present; a `LocalServerConfig` subset emits only present keys, - including a `corrections` object with `slr: null` and a populated `pm25` entry - (with `useEpa2021`). Incomplete non-null SLR values fail serialization. -- **Parse tests** — valid partial body applies; unknown key (top-level **and** - inner `corrections.*`) → `400 unknown_field`; wrong type / bad enum → - `400 invalid_value`; malformed JSON, **non-object root**, and **trailing garbage - after the root** → `400 invalid_body`; a nested `corrections` error reports a - dotted `field`; missing SLR coefficients remain distinguishable for product - semantic validation. -- **Handler tests** — drive every handler with fakes and assert status / body for: - GET success; accepted `PUT` → `202`; `ConfigSubmitStatus` mapped to `400` / - `403` / `404` / `503` / `500` with the component-composed `field` (from - `ConfigFieldId`) and message; action → `200`; unsupported action → `404`; busy - action → `503`. Verify complete-body and parse-before-policy precedence, empty - object policy, `ConfigAccess` route presence, and absent action providers. -- **Lifecycle tests** — `begin()` is idempotent (second call is a no-op `true`); - a forced mid-registration failure rolls back so no partial routes remain; - `end()` unregisters only this server's routes and leaves a co-registered - foreign route intact; destruction unregisters remaining routes. -- **Host build and run** — `cmake --build tests/build` and - `ctest --test-dir tests/build --output-on-failure`. -- **Firmware build** — the reference product builds with real providers after - exporting ESP-IDF; confirms the `body_static` zero-copy path against the real - driver. -- **Manual / integration** — discover a v1-API device in Home Assistant alongside - a legacy device; confirm model-mapped entities and action buttons. - -## Open Questions - -- Does Go expose `test-leds` (its LEDs differ in kind from the monitor LED bar)? -- Which `correctionAlgorithm` string values each model accepts, and the per-field - SLR parameter ranges the product validates in `submit_config`. -- When to surface the deferred measurement groups (battery / pressure first for - Go) and their flat field names. -- Which product-specific config fields (if any) Go will eventually expose over - HTTP, and their flat names. -- The `api` mDNS TXT value form: integer (`api=1`) vs semantic (`api=v1`) — pick - what the HA zeroconf matcher will key on. diff --git a/components/airgradient-ota/README.md b/components/airgradient-ota/README.md index 7570587..57d164b 100644 --- a/components/airgradient-ota/README.md +++ b/components/airgradient-ota/README.md @@ -260,5 +260,4 @@ and the live silent-phone stall watchdog. BLE push path relies on the product-configured authenticated pairing (`WRITE_AUTHEN` + `BOND | MITM | SC`). HTTPS and signed images are explicit future improvements. -- The cellular pull source is defined as a seam in `spec.md` but not - implemented here. +- The cellular pull source remains a seam but is not implemented here. diff --git a/components/airgradient-ota/spec-ble.md b/components/airgradient-ota/spec-ble.md index cdf4c06..4842337 100644 --- a/components/airgradient-ota/spec-ble.md +++ b/components/airgradient-ota/spec-ble.md @@ -5,12 +5,11 @@ > source of truth and this file is typically deleted. See `docs/STYLE.md` → > "Doc Lifecycle". -This spec implements the **BLE push** OTA path that the original -[`spec.md`](spec.md) defined as a future seam. It is derived from that spec and -reuses its universal flash-write core (`OtaImageWriter`) **unchanged**. Where -the WiFi/cellular paths are device-initiated **pull** (driven by `OtaUpdater` -over an `OtaImageSource`), BLE inverts the control flow: the phone drives and -the device receives. There is therefore **no `OtaUpdater` and no +This spec implements the **BLE push** OTA path alongside the existing WiFi pull +path. It reuses the universal flash-write core (`OtaImageWriter`) **unchanged**. +Where the WiFi/cellular paths are device-initiated **pull** (driven by +`OtaUpdater` over an `OtaImageSource`), BLE inverts the control flow: the phone +drives and the device receives. There is therefore **no `OtaUpdater` and no `OtaImageSource`** here. A new service — `OtaBleService` — owns the complete GATT flow and feeds the writer directly from each data-write callback. @@ -30,9 +29,9 @@ GATT flow and feeds the writer directly from each data-write callback. ## Problem -`spec.md` shipped the WiFi pull path and the universal core, and sketched the -BLE push service as "future reference" (interface + flow diagram only). The -sketch left the hard parts undefined; v2 answers them as follows: +The existing WiFi pull path and universal core originally sketched the BLE push +service as "future reference" (interface + flow diagram only). The sketch left +the hard parts undefined; v2 answers them as follows: - **Where flash writes run** — `esp_ota_write` programs/erases flash. v2 runs it **in the Data write callback on the NimBLE host task**; the call is fast @@ -57,7 +56,7 @@ sketch left the hard parts undefined; v2 answers them as follows: ## Goals -- Reuse the universal `OtaImageWriter` core from `spec.md` with no changes. +- Reuse the universal `OtaImageWriter` core with no changes. - A reusable `OtaBleService` that owns the OTA GATT service (Control / Data / Status) on a **borrowed**, already-initialised `AgBleServer`, mirroring how provisioning's `BleTransport::setup_on_server()` @@ -89,7 +88,7 @@ sketch left the hard parts undefined; v2 answers them as follows: ## Non-Goals - **No reboot** — the product decides whether and when to reboot from the - terminal `OtaStatus` that `run()` returns (same contract as `spec.md`). + terminal `OtaStatus` that `run()` returns (same contract as the WiFi pull path). - **No resume across reconnect** — a mid-stream disconnect aborts the writer and discards progress; the phone restarts from `START`. No offset/resume protocol. - **No server ownership** — `OtaBleService` never calls `init()`, @@ -97,7 +96,7 @@ sketch left the hard parts undefined; v2 answers them as follows: `AgBleServer`. The product owns the stack lifecycle, forwards disconnect events, and brackets the connection-parameter window. Only the **attached** model is implemented. -- **No image signing / Secure Boot (known limitation)** — as in `spec.md`, +- **No image signing / Secure Boot (known limitation)** — as in the WiFi pull path, `esp_ota` checks image _integrity_ (SHA-256 against the image header), not _authenticity_. The BLE link's authenticated pairing (`WRITE_AUTHEN` + product-configured bonding/MITM) is the practical defense against a rogue @@ -141,9 +140,9 @@ flowchart TB PT -.->|run returns terminal status| PR[Product decides reboot] ``` -How the BLE push path compares to the pull paths from `spec.md`: +How the BLE push path compares to the pull paths: -| Concern | WiFi/Cellular (pull, `spec.md`) | BLE (push, this spec) | +| Concern | WiFi/Cellular (pull) | BLE (push, this spec) | |---|---|---| | Drive model | Pull (device-initiated) | Push (phone-initiated) | | Orchestrator | `OtaUpdater` | `OtaBleService` + product `run()` | diff --git a/components/airgradient-ota/spec.md b/components/airgradient-ota/spec.md deleted file mode 100644 index cdf3d1f..0000000 --- a/components/airgradient-ota/spec.md +++ /dev/null @@ -1,725 +0,0 @@ -# airgradient-ota Spec - -> **This is a spec.** It describes how a feature **will be built**, not what -> currently exists. Once the feature ships, the component README becomes the -> source of truth and this file is typically deleted. See `docs/STYLE.md` → -> "Doc Lifecycle". - -A transport-agnostic Over-The-Air (OTA) firmware update component for -AirGradient ESP-IDF devices. It separates the universal flash-write core -(`OtaImageWriter`, wrapping `esp_ota_ops`) from the transport that delivers -the image bytes. Two delivery models are designed up front: a **pull** model -for HTTP-style transports (WiFi now, cellular later) and a **push** model for -BLE (later). This spec implements the **WiFi pull** path and the universal -core, and defines — but does not implement — the cellular and BLE seams so the -architecture does not have to change when they land. - -## Problem - -The legacy library in `tmp/airgradient-ota/` works but does not fit this -repository's conventions and is hard to extend: - -- **God-class inheritance** — a base `AirgradientOTA` holds the flash logic and - every transport (`AirgradientOTAWifi`, `AirgradientOTACellular`) subclasses - it, fusing transport, URL building, and flash writing into one hierarchy. - This is the same anti-pattern `airgradient-client` was built to remove. -- **Dead Arduino cruft** — `#ifdef ARDUINO` / `#ifndef ESP8266` guards and - `Arduino.h` includes that this ESP-IDF-only codebase does not need. -- **Stringy callbacks** — progress is reported through a raw function pointer - with the percentage passed as a stringified integer (`"100"`). -- **Magic numbers** — hard-coded URL buffer sizes (`200`), chunk sizes - (`64000`), and an assumed image size (`1400000`). -- **Untestable on host** — direct `esp_http_client` / `esp_ota_ops` / - `MILLIS()` calls with no abstraction or mock seam. -- **Mixed drive models** — the single `updateIfAvailable()` API only fits - device-initiated pull. BLE is phone-initiated push and does not fit it, - so bolting BLE onto the same hierarchy later would distort the design. - -## Goals - -- A universal, transport-agnostic flash-write core (`OtaImageWriter`) that - every transport — pull or push — terminates at. -- A pull transport seam (`OtaImageSource`) shared by all device-initiated - HTTP transports (WiFi, cellular). -- A blocking pull orchestrator (`OtaUpdater`) that owns the read→write loop, - progress throttling, and abort-on-error; it touches only the two HAL - interfaces and knows nothing about the transport. -- Implement the **WiFi pull** path: stream one HTTP GET through - `esp_http_client` directly into the writer. -- Typed results (`OtaStatus`) and a struct-based progress callback — no - stringly-typed messages. -- Host-testable: the orchestrator and URL builder run under `TEST_HOST` - against a mock source and fake writer; ESP-IDF headers are isolated behind - `#ifndef TEST_HOST` in the driver `.cpp` files. -- Caller supplies all request inputs explicitly (`OtaRequest`); no globals. -- Define the cellular pull source and the BLE push service seams (interfaces + - flow diagrams) for future reference, without implementing them. - -## Non-Goals - -- **No cellular implementation** — the `OtaImageSource` seam is defined; the - `CellularHttpOtaSource` driver is future work. -- **No BLE implementation** — the `OtaBleService` GATT flow is designed; the - service is future work. -- **No reboot** — the component reports a final `OtaStatus`; the product - decides whether and when to call `reboot()`. -- **No transport security (known limitation)** — downloads use plain HTTP, - matching the legacy behavior. This gives **no transport authentication**: - `esp_ota` checks image _integrity_ (SHA-256 against the image's own header), - which is not the same as _authenticity_. With no Secure Boot v2 / signed app - configured on these products, a man-in-the-middle could serve a malicious but - internally-consistent image. Accepted for now; **HTTPS and signed images are - explicit future improvements**, not part of this work. -- **No rollback / health-check policy** — marking the new image valid - (`esp_ota_mark_app_valid_cancel_rollback`) and anti-rollback are product - responsibilities, out of scope here. -- **No background task ownership** — `OtaUpdater::run()` is blocking and runs - on a task the product provides; the component creates no tasks of its own. - -## Design - -### Layering - -The `OtaImageWriter` is the single universal piece every transport terminates -at. Pull transports share the `OtaImageSource` seam and are driven by -`OtaUpdater`; the push transport (BLE) owns its own GATT flow and feeds the -writer directly. "Is an update available?" semantics live only in the pull -path. Reboot is never performed here. - -```mermaid -flowchart TB - subgraph PULL["PULL drive model (device-initiated)"] - direction TB - U[OtaUpdater
owns read/write loop] - WS[WifiHttpOtaSource
1 stream GET - THIS SPEC] - CS[CellularHttpOtaSource
N ranged GETs - future] - U -->|OtaImageSource| WS - U -->|OtaImageSource| CS - end - - subgraph PUSH["PUSH drive model (phone-initiated)"] - direction TB - BS[OtaBleService
GATT control/data/status - future] - end - - W[("OtaImageWriter
universal flash core
wraps esp_ota_ops")] - - U -->|write bytes| W - BS -->|write bytes| W - - W -.->|status only| PR[Product decides reboot] -``` - -| Concern | WiFi (this spec) | Cellular (future) | BLE (future) | -|---|---|---|---| -| Drive model | Pull | Pull | Push | -| Orchestrator | `OtaUpdater` | `OtaUpdater` | `OtaBleService` | -| Transport seam | `OtaImageSource` | `OtaImageSource` | GATT characteristics | -| Fetch | 1 stream GET | N ranged GETs | Phone writes | -| Availability check | HTTP 304/200 | HTTP 304/200 | Phone decides | -| Flash core | `OtaImageWriter` | `OtaImageWriter` | `OtaImageWriter` | -| Reboot | Product | Product | Product | - -### Types - -```cpp -// types/ota_types.h -#include -#include -#include - -enum class OtaStatus : uint8_t { - Ok, // image downloaded, written, and boot partition set - UpToDate, // server returned 304 — current firmware is newest - Declined, // server declined to serve an image (e.g. 400/404) - TransportError, // connection / DNS / read failure, or truncated download - ServerError, // unexpected HTTP status or empty body - FlashError, // esp_ota_begin/write/end/set_boot_partition failure - InvalidImage, // image failed validation at finish() - InvalidArgument, // null/empty request field or null dependency -}; - -enum class OtaState : uint8_t { Idle, Checking, Downloading, Applying, Done, Skipped, Failed }; - -// Progress state emission points (see OtaUpdater::run()). A terminal state -// (Done / Skipped / Failed) is ALWAYS emitted: -// Checking - emitted once before source.open() -// Downloading - emitted once immediately after writer.begin(), then again -// during the read/write loop (throttled to -// CONFIG_AG_OTA_PROGRESS_INTERVAL_MS) -// Applying - emitted once immediately before writer.finish() -// Done - terminal: image written and boot partition set (Ok) -// Skipped - terminal: no update applied (UpToDate / Declined) -// Failed - terminal: any error outcome -// percent = total_size > 0 ? min(100, bytes_written * 100 / total_size) : 0 - -// 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; - size_t total_size; // 0 when unknown (e.g. cellular chunked) - uint8_t percent; // 0..100; 0 when total unknown -}; - -// std::function may heap-allocate; this is intentional and consistent with -// provisioning's ProvisioningEventCallback. Set once before run(); the -// callback fires synchronously on the run() task. -using OtaProgressCallback = std::function; - -// Caller-supplied, per-update inputs. The string fields need only be valid -// during construction of the source — the source copies the bounded fields it -// needs into internal fixed buffers (see WifiHttpOtaSource). -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 -}; -``` - -### Installer core (universal) - -```cpp -// hal/ota_image_writer.h -class OtaImageWriter { -public: - virtual ~OtaImageWriter() = default; - - // Select the next OTA partition and open it for writing. - // total_size == 0 means unknown (OTA_SIZE_UNKNOWN). - virtual OtaStatus begin(size_t total_size) = 0; - - // Append a chunk to the open partition. Must be called between begin() - // and finish(). len == 0 returns InvalidArgument. - virtual OtaStatus write(const uint8_t *data, size_t len) = 0; - - // Validate the image and set it as the next boot partition. - // Does NOT reboot. - virtual OtaStatus finish() = 0; - - // Free the open handle without activating the image. Idempotent. - virtual void abort() = 0; - - // Total bytes accepted by write() since begin(). - virtual size_t bytes_written() const = 0; -}; -``` - -The concrete `EspOtaImageWriter` (`backends/esp/`) wraps `esp_ota_ops` -(`esp_ota_get_next_update_partition` → `esp_ota_begin` → `esp_ota_write` → -`esp_ota_end` → `esp_ota_set_boot_partition`), with all ESP-IDF includes -behind `#ifndef TEST_HOST`. This is the legacy base-class flash logic, -cleaned up and freed of transport knowledge. - -ESP-IDF error → `OtaStatus` mapping: - -| Call / result | `OtaStatus` | -|---|---| -| `esp_ota_get_next_update_partition` returns null | `FlashError` | -| `esp_ota_begin` fails | `FlashError` | -| `esp_ota_write` fails | `FlashError` | -| `esp_ota_end` → `ESP_ERR_OTA_VALIDATE_FAILED` | `InvalidImage` | -| `esp_ota_end` → other error | `FlashError` | -| `esp_ota_set_boot_partition` fails | `FlashError` | -| rollback / pending-verify state | out of scope (product decides) | - -### Pull transport seam - -```cpp -// hal/ota_image_source.h -class OtaImageSource { -public: - virtual ~OtaImageSource() = default; - - // Resolve availability and open the byte stream. - // Ok -> an update is available; read() will yield bytes - // UpToDate -> server returned 304 - // Declined -> server declined to serve an image (e.g. 400/404) - // errors -> TransportError / ServerError - // out_total_size must be non-null; set to the image size when known, 0 - // otherwise. Passing nullptr returns InvalidArgument. - virtual OtaStatus open(size_t *out_total_size) = 0; - - // Read the next chunk into buf. - // > 0 -> bytes read - // 0 -> end of image (EOF) - // < 0 -> error (including invalid args: buf == nullptr || buf_size == 0) - virtual int read(uint8_t *buf, size_t buf_size) = 0; - - // Release transport resources. Idempotent. The orchestrator calls this - // after EVERY open() — including UpToDate and error returns — so the - // implementation must tolerate close() whether or not a stream was opened. - virtual void close() = 0; -}; -``` - -### Pull orchestrator - -`OtaUpdater::run()` is a single blocking call. The product wires the pieces, -calls `run()` once, and decides reboot from the returned status. **The loop -lives inside `run()`, never on the product side.** - -```cpp -// services/ota_updater.h -class OtaUpdater { -public: - OtaUpdater(OtaImageSource &source, OtaImageWriter &writer); - - void set_on_progress(OtaProgressCallback cb); - - // open -> begin -> loop(read -> write, throttled progress) -> finish. - // Aborts the writer on any read/write error. Always closes the source. - // - // Blocking, non-reentrant, not thread-safe: run one update per instance at - // a time. Concurrent or reentrant calls are undefined. - OtaStatus run(); - -private: - OtaImageSource &_source; - OtaImageWriter &_writer; - OtaProgressCallback _on_progress; -}; -``` - -Reference loop body: - -```cpp -OtaStatus OtaUpdater::run() { - emit_progress(OtaState::Checking, 0); - - size_t total = 0; - OtaStatus st = _source.open(&total); - if (st != OtaStatus::Ok) { // UpToDate / Declined / error - _source.close(); // always close after open() - // UpToDate and Declined are non-update outcomes, not failures. - bool skipped = (st == OtaStatus::UpToDate || st == OtaStatus::Declined); - emit_progress(skipped ? OtaState::Skipped : OtaState::Failed, total); - return st; - } - - st = _writer.begin(total); - if (st != OtaStatus::Ok) { _source.close(); emit_progress(OtaState::Failed, total); return st; } - - // Emit one immediate Downloading so small images still report progress. - emit_progress(OtaState::Downloading, total); - - uint8_t buf[CONFIG_AG_OTA_READ_BUFFER_SIZE]; - uint64_t last_cb = RTOS::get_time_ms(); - while (true) { - int n = _source.read(buf, sizeof(buf)); - if (n == 0) break; // EOF - if (n < 0) { _writer.abort(); _source.close(); - emit_progress(OtaState::Failed, total); return OtaStatus::TransportError; } - - st = _writer.write(buf, static_cast(n)); - if (st != OtaStatus::Ok) { _writer.abort(); _source.close(); - emit_progress(OtaState::Failed, total); return st; } - - const uint64_t now = RTOS::get_time_ms(); - if (now - last_cb >= CONFIG_AG_OTA_PROGRESS_INTERVAL_MS) { - emit_progress(OtaState::Downloading, total); // percent derived from bytes_written/total - last_cb = now; - } - } - - _source.close(); - - // Guard against a truncated download when the total size is known. - if (total > 0 && _writer.bytes_written() != total) { - _writer.abort(); - emit_progress(OtaState::Failed, total); - return OtaStatus::TransportError; - } - - emit_progress(OtaState::Applying, total); - st = _writer.finish(); - emit_progress(st == OtaStatus::Ok ? OtaState::Done : OtaState::Failed, total); - return st; -} -``` - -### AG-server URL builder (shared) - -Pulled out of the legacy `buildUrl()` so WiFi and cellular share it. The -builder is the single place that knows the AirGradient URL conventions; it -translates `OtaDeviceModel` into the path segment and serial format so callers never -deal with URL strings. - -```cpp -// services/ota_url.h -namespace ota_url { -// Builds the base firmware URL from req. Maps req.model to the path/serial -// shape (see table below) 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. -bool build(const OtaRequest &req, char *out, size_t out_size); -} -``` - -`OtaDeviceModel` → URL translation (mirrors the legacy library): - -| `OtaDeviceModel` | URL shape | -|---|---| -| `OneOpenAir` | `http://{domain}/sensors/airgradient:{sn}/generic/os/firmware.bin?current_firmware={fw}` | -| `Max` | `http://{domain}/sensors/{sn}/max/firmware.bin?current_firmware={fw}` | -| `Go` | `http://{domain}/sensors/airgradient:{sn}/go/firmware.bin?current_firmware={fw}` | - -> Note the two models differ in **both** the path segment (`generic/os` vs -> `max`) **and** the serial format (`airgradient:` prefix vs bare serial). -> Centralizing this mapping in `ota_url` — keyed off the `OtaDeviceModel` enum — is -> exactly why the caller passes a model rather than a raw path. - -### WiFi pull source (this spec) - -`WifiHttpOtaSource` implements `OtaImageSource` over `esp_http_client`, -streaming a single GET — no whole-image buffer, no assumption that the server -honors ranged requests on the WiFi endpoint. - -```cpp -// backends/wifi/wifi_http_ota_source.h -class WifiHttpOtaSource : public OtaImageSource { -public: - // Copies the bounded request fields into internal buffers and builds the URL - // at construction; the OtaRequest (and its strings) need not outlive this - // call. If the URL build fails (missing field / truncation / unknown model), - // construction stores the failure and open() reports it. - explicit WifiHttpOtaSource(const OtaRequest &request); - OtaStatus open(size_t *out_total_size) override; // returns _init_status on failure - int read(uint8_t *buf, size_t buf_size) override; // esp_http_client_read - void close() override; // close + cleanup (idempotent) - -private: - char _url[CONFIG_AG_OTA_URL_BUFFER_SIZE]; // built once at construction - OtaStatus _init_status; // Ok, or InvalidArgument if build failed - void *_client = nullptr; // opaque esp_http_client_handle_t (cast in .cpp) -}; -``` - -> **Header isolation:** backend headers must not expose ESP-IDF types and must -> compile under `TEST_HOST`. The `esp_http_client` handle is held as an opaque -> `void *` and cast inside the `.cpp`; all `esp_http_client` includes sit behind -> `#ifndef TEST_HOST` in the `.cpp` only. - -`open()` first returns `_init_status` if construction failed (`InvalidArgument`). -Otherwise it opens the connection, reads the response headers, and maps the -status code. Availability is determined by the response itself: WiFi reads the -status from the header; cellular uses an empty-body probe request -(`offset=0&length=0`). Mapping: - -| HTTP status | `OtaStatus` | Notes | -|---|---|---| -| `200`, `content_length > 0` | `Ok` | `content_length` → `*out_total_size` | -| `200`, unknown / chunked length | `Ok` | `*out_total_size = 0` (size known only at EOF) | -| `200`, `content_length == 0` | `ServerError` | nothing to download | -| `304` | `UpToDate` | current firmware is newest | -| `400` / `404` | `Declined` | server declined to serve an image | -| any other | `ServerError` | unexpected | - -#### WiFi flow - -```mermaid -sequenceDiagram - autonumber - participant P as Product - participant U as OtaUpdater - participant S as WifiHttpOtaSource - participant W as OtaImageWriter (core) - - P->>U: run() - Note over U: loop lives entirely inside run() - - U->>S: open(&total) - S->>S: build URL + open HTTP conn + read headers - alt 304 Not Modified - S-->>U: UpToDate - U-->>P: UpToDate - else 200 OK - S-->>U: Ok (total = content-length) - U->>W: begin(total) - W->>W: esp_ota_begin - - loop until EOF or error - U->>S: read(buf, BUF) - S->>S: esp_http_client_read - S-->>U: n bytes (0 = EOF, <0 = error) - U->>W: write(buf, n) - W->>W: esp_ota_write - U-->>P: on_progress(state, %, bytes) [throttled] - end - - U->>W: finish() - W->>W: esp_ota_end + set_boot_partition - W-->>U: Ok / FlashError - U-->>P: OtaStatus - end - Note over P: product decides reboot() on Ok -``` - -### Future reference — cellular pull source - -`CellularHttpOtaSource` would implement the same `OtaImageSource` over -`CellularModem::http_get`, issuing one ranged GET per chunk -(`...&offset=K&length=CHUNK&iccid=...`) into a fixed chunk buffer because the -I²C↔serial bridge cannot hold a whole image. It reads until a `204` or a -short final chunk. It drops straight into `OtaUpdater` — the orchestrator and -writer are unchanged. `total_size` is reported as `0` (unknown), so progress -percent stays `0` and completion is detected by EOF. - -> **Classification is per-transport — do not share a classifier.** The -> response-status / body-length → `OtaStatus` mapping lives inside each -> source's `open()`, because the same response means different things per -> transport. Most notably, a `200` with body length `0`: -> -> - **WiFi** treats it as `ServerError` (an empty body = nothing to download). -> - **Cellular** treats it as the **update-available** indicator: the probe -> request asks for `offset=0&length=0`, so a `200` with an empty body is the -> expected "an image exists" signal, after which the ranged GETs begin. -> -> Because `(200, 0)` must map to opposite `OtaStatus` values, a shared -> `classify(status, length)` helper is impossible by construction. Keep WiFi's -> mapping in `WifiHttpOtaSource` and give `CellularHttpOtaSource` its own. - -```mermaid -sequenceDiagram - autonumber - participant P as Product - participant U as OtaUpdater - participant S as CellularHttpOtaSource - participant M as CellularModem - participant W as OtaImageWriter (core) - - P->>U: run() - - U->>S: open(&total) - S->>M: http_get(...&offset=0&length=0&iccid=) - M-->>S: status 304 / 200 - alt up to date - S-->>U: UpToDate - U-->>P: UpToDate - else available - S-->>U: Ok (total unknown / 0) - U->>W: begin(0) - - loop until 204 / short chunk - U->>S: read(buf, CHUNK) - S->>M: http_get(...&offset=K&length=CHUNK) - M-->>S: chunk bytes - S->>S: offset += CHUNK - S-->>U: n bytes (0 = EOF, <0 = error) - U->>W: write(buf, n) - U-->>P: on_progress(...) [throttled] - end - - U->>W: finish() - W-->>U: Ok / FlashError - U-->>P: OtaStatus - end - Note over P: product decides reboot() on Ok -``` - -### Future reference — BLE push service - -BLE inverts the control flow: the phone drives, the device receives. There is -**no `OtaUpdater` and no `OtaImageSource`**. `OtaBleService` owns the complete, -reusable GATT flow built on the `AgBleServer` HAL and feeds `OtaImageWriter` -directly from each data-write callback. The product only supplies the -`AgBleServer` and wires connect/disconnect and the completion callback — -mirroring how `ProvisioningManager` borrows transports. - -GATT layout (owned entirely by `OtaBleService`, reusable across products): - -```text - OTA Service (UUID) - ├─ Control char [WRITE] START{total, fw_version} | END | ABORT - ├─ Data char [WRITE / WNR] raw image bytes, MTU-sized - └─ Status char [READ | NOTIFY] state + bytes + percent + result -``` - -```cpp -// services/ota_ble_service.h (future) -class OtaBleService { -public: - OtaBleService(AgBleServer &server, OtaImageWriter &writer); - bool setup(); // add_service + characteristics + write callbacks - void teardown(); - void set_on_complete(std::function cb); -}; -``` - -```mermaid -sequenceDiagram - autonumber - participant Ph as Phone (central) - participant B as OtaBleService - participant W as OtaImageWriter (core) - participant P as Product - - Ph->>B: connect + pair - B-->>P: on_connect - - Ph->>B: WRITE Control: START{total_size, fw_version} - B->>W: begin(total) - W->>W: esp_ota_begin - B-->>Ph: NOTIFY Status: Ready - - loop image chunks - Ph->>B: WRITE Data: - B->>W: write(buf, n) - W->>W: esp_ota_write - B-->>Ph: NOTIFY Status: Progress{bytes, %} - end - - Ph->>B: WRITE Control: END - B->>W: finish() - W->>W: esp_ota_end + set_boot_partition - - alt success - B-->>Ph: NOTIFY Status: Success - B-->>P: on_complete(Ok) - else failure / disconnect mid-stream - B->>W: abort() - W->>W: esp_ota_abort - B-->>Ph: NOTIFY Status: Failed - B-->>P: on_complete(Failed) - end - Note over P: product decides reboot() on Success -``` - -### Product usage (WiFi) - -```cpp -OtaRequest req{ serial, current_fw, "hw.airgradient.com", OtaDeviceModel::OneOpenAir }; -WifiHttpOtaSource source(req); -EspOtaImageWriter writer; -OtaUpdater updater(source, writer); -updater.set_on_progress([](const OtaProgress &p) { - AG_LOGI("App", "ota %u%% (%u bytes)", p.percent, (unsigned)p.bytes_written); -}); - -OtaStatus st = updater.run(); // single blocking call -if (st == OtaStatus::Ok) reboot(); // product decides -``` - -### Component structure - -```text -components/airgradient-ota/ - hal/ - ota_image_writer.h # universal flash-write interface - ota_image_source.h # pull transport seam - types/ - ota_types.h # OtaStatus, OtaState, OtaProgress, OtaRequest - backends/ - esp/ - esp_ota_image_writer.h - esp_ota_image_writer.cpp # esp_ota_ops, #ifndef TEST_HOST - wifi/ - wifi_http_ota_source.h - wifi_http_ota_source.cpp # esp_http_client streaming, #ifndef TEST_HOST - services/ - ota_updater.h - ota_updater.cpp # pull orchestrator (host-testable) - ota_url.h - ota_url.cpp # AG-server URL builder (host-testable) - tests/ - CMakeLists.txt - ota_updater.tests.cpp - ota_url.tests.cpp - mock_ota_image_source.h - fake_ota_image_writer.h - CMakeLists.txt - Kconfig - README.md - spec.md # this file (deleted once shipped) -``` - -### CMake and dependencies - -```cmake -idf_component_register( - SRCS "services/ota_updater.cpp" - "services/ota_url.cpp" - "backends/esp/esp_ota_image_writer.cpp" - "backends/wifi/wifi_http_ota_source.cpp" - INCLUDE_DIRS "." - REQUIRES airgradient-common app_update esp_http_client -) -``` - -- `airgradient-common` — `RTOS` timing, `AG_LOG` macros. -- `app_update` — `esp_ota_ops` flash API. -- `esp_http_client` — WiFi streaming download. -- Future: `airgradient-cellular` (cellular source), `airgradient-ble` - (BLE service). - -### Kconfig (menu "AirGradient OTA") - -Symbols use the repo-wide `AG_` prefix (matching `AG_CLIENT_*`, `AG_WIFI_*`, -`AG_HTTP_*`) to avoid collisions with ESP-IDF and other components. - -| Symbol | Default | Purpose | -|---|---|---| -| `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_URL_BUFFER_SIZE` | `256` | Max built firmware URL length | - -## Implementation Plan - -1. Scaffold the component: directories, `CMakeLists.txt`, `Kconfig`, - `README.md`; register `tests/` in the top-level `tests/CMakeLists.txt`. -2. Add `types/ota_types.h` (enums, `OtaProgress`, `OtaRequest`, callback). -3. Add `hal/ota_image_writer.h` and `hal/ota_image_source.h`. -4. Add `services/ota_url.{h,cpp}` with host tests. -5. Add `services/ota_updater.{h,cpp}` with host tests (mock source, fake - writer): happy path, up-to-date, transport error, write/flash failure → - abort, progress throttling. -6. Add `backends/esp/esp_ota_image_writer.{h,cpp}` (`esp_ota_ops`, - `#ifndef TEST_HOST`). -7. Add `backends/wifi/wifi_http_ota_source.{h,cpp}` (`esp_http_client` - streaming, `#ifndef TEST_HOST`). -8. Write `README.md` from the component template; wire a WiFi example in the - reference product; HIL-verify a real update. -9. (Future) Add `backends/cellular/cellular_http_ota_source` and - `services/ota_ble_service` against the seams defined here. - -## Testing Strategy - -- **Host tests** (`TEST_HOST`): - - `ota_url` — per-`OtaDeviceModel` path/serial mapping, query formatting, - truncation, missing fields, unknown model. - - `OtaUpdater` — against `mock_ota_image_source.h` (Trompeloeil) and - `fake_ota_image_writer.h`: verifies open→begin→read/write→finish ordering, - `UpToDate`/`Declined` short-circuit emit terminal `Skipped` (and - `close()` is still called), abort on read error, abort on write/flash error, - truncated-download guard (`bytes_written != total` → `TransportError`), - `bytes_written` accounting, progress state sequence - (`Checking`→`Downloading`→`Applying`→`Done`), an immediate `Downloading` - even for a single-chunk (small) image, and progress-callback throttling. - Throttling is driven by `RTOS::get_time_ms()`, mocked on host with - `trompeloeil::mock_interface` + `RTOS::set_instance()` (same pattern as - `airgradient-cellular/tests/simcom_a7672x.tests.cpp`). -- **Not host-tested:** `EspOtaImageWriter` and `WifiHttpOtaSource` wrap - ESP-IDF APIs and are excluded from host builds via `#ifndef TEST_HOST`; - kept thin and verified by HIL. -- **HIL:** real WiFi update against `hw.airgradient.com` — fresh image - applies and boots; `304` path reports `UpToDate`; mid-download disconnect - aborts cleanly without bricking (old partition still bootable). - -## Open Questions - -- **Model coverage** — `OtaDeviceModel` currently supports `OneOpenAir`, `Max`, - and `Go`; confirm the full set of models that need OTA and their exact URL - shapes as they are added to the `ota_url` translation table. -- **`current_firmware` query param** — confirm the WiFi endpoint still keys - "is an update available?" off `?current_firmware=` returning `304`, matching - the legacy behavior. -- **Progress when total is unknown** — for cellular (no content length), - `OtaUpdater` emits byte-count-only progress with `percent = 0`; confirm this - is acceptable for product UX or whether the source should estimate a total. -- **Read buffer size** — is `1024` (legacy WiFi value) the right default, or - should it be larger to reduce `esp_ota_write` calls?