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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions components/airgradient-local-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,10 @@ Configurable through Kconfig under **AirGradient Local Server**:
Host tests live in `components/airgradient-local-server/tests/` and run through
the top-level [tests runner](../../tests/README.md). They cover serialization
(omit-when-invalid / optional `wifiRssi`, nested `corrections`), strict parsing
(unknown key incl. dotted `corrections.*`, bad type / enum, non-object root,
trailing garbage), handler status mapping, and route lifecycle (idempotent /
transactional `begin`, scoped `end`, RAII teardown) using `fake_providers.h`.
(unknown key incl. dotted `corrections.*`, bad type / enum, non-integral integer
fields, non-object root, trailing garbage), handler status mapping, and route
lifecycle (idempotent / transactional `begin`, scoped `end`, RAII teardown)
using `fake_providers.h`.
Handler coverage includes asynchronous `202`, retryable `503`, complete-body
precedence, empty submissions, and config/action busy results. Correction tests
cover each missing SLR coefficient independently.
Expand All @@ -171,6 +172,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 (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) remain
deferred. The config catalog includes shared and product-specific flat fields;
each product emits and accepts only its supported subset.
75 changes: 74 additions & 1 deletion components/airgradient-local-server/internal/config_json.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#include "internal/config_json.h"

#include <cmath>
#include <cstdio>
#include <cstring>

Expand All @@ -22,6 +23,7 @@ namespace {
constexpr const char *PM_STANDARD_VALUES[] = {"ugm3", "us-aqi"};
constexpr const char *TEMP_UNIT_VALUES[] = {"c", "f"};
constexpr const char *CONFIG_CONTROL_VALUES[] = {"cloud", "local", "both"};
constexpr const char *GPS_MODE_VALUES[] = {"off", "tracking", "always"};
constexpr const char *LED_MODE_VALUES[] = {"co2", "pm", "iaqs", "off"};

bool is_whitespace(char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; }
Expand Down Expand Up @@ -56,7 +58,8 @@ bool take_enum(const cJSON *item, const char *const (&options)[N],
}

bool take_int(const cJSON *item, std::optional<int> &out) {
if (!cJSON_IsNumber(item)) {
if (!cJSON_IsNumber(item) || !std::isfinite(item->valuedouble) ||
std::floor(item->valuedouble) != item->valuedouble) {
return false;
}
out = item->valueint;
Expand Down Expand Up @@ -233,6 +236,36 @@ ParseStatus apply_item(const cJSON *item, LocalServerConfig &out, ConfigFieldId
? ParseStatus::Ok
: ParseStatus::InvalidValue;
}
if (std::strcmp(key, fields::MEASUREMENT_INTERVAL) == 0) {
field = ConfigFieldId::MeasurementInterval;
return take_int(item, out.measurement_interval_seconds) ? ParseStatus::Ok
: ParseStatus::InvalidValue;
}
if (std::strcmp(key, fields::GPS_MODE) == 0) {
field = ConfigFieldId::GpsMode;
return take_enum(item, GPS_MODE_VALUES, out.gps_mode) ? ParseStatus::Ok
: ParseStatus::InvalidValue;
}
if (std::strcmp(key, fields::GPS_INTERVAL) == 0) {
field = ConfigFieldId::GpsInterval;
return take_int(item, out.gps_interval_seconds) ? ParseStatus::Ok : ParseStatus::InvalidValue;
}
if (std::strcmp(key, fields::FRONT_LED_BRIGHTNESS) == 0) {
field = ConfigFieldId::FrontLedBrightness;
return take_int(item, out.front_led_brightness) ? ParseStatus::Ok : ParseStatus::InvalidValue;
}
if (std::strcmp(key, fields::BACK_LED_BRIGHTNESS) == 0) {
field = ConfigFieldId::BackLedBrightness;
return take_int(item, out.back_led_brightness) ? ParseStatus::Ok : ParseStatus::InvalidValue;
}
if (std::strcmp(key, fields::TOUCH_LED_INTENSITY) == 0) {
field = ConfigFieldId::TouchLedIntensity;
return take_int(item, out.touch_led_intensity) ? ParseStatus::Ok : ParseStatus::InvalidValue;
}
if (std::strcmp(key, fields::BUZZER_ENABLED) == 0) {
field = ConfigFieldId::BuzzerEnabled;
return take_bool(item, out.buzzer_enabled) ? ParseStatus::Ok : ParseStatus::InvalidValue;
}
if (std::strcmp(key, fields::CO2_ABC_DAYS) == 0) {
field = ConfigFieldId::Co2AbcDays;
return take_int(item, out.co2_abc_days) ? ParseStatus::Ok : ParseStatus::InvalidValue;
Expand Down Expand Up @@ -406,6 +439,32 @@ size_t serialize(const LocalServerConfig &cfg, char *buf, size_t buf_len) {
cJSON_AddStringToObject(root, fields::CONFIGURATION_CONTROL,
cfg.configuration_control->c_str());
}
if (cfg.measurement_interval_seconds.has_value()) {
cJSON_AddNumberToObject(root, fields::MEASUREMENT_INTERVAL,
static_cast<double>(*cfg.measurement_interval_seconds));
}
if (cfg.gps_mode.has_value()) {
cJSON_AddStringToObject(root, fields::GPS_MODE, cfg.gps_mode->c_str());
}
if (cfg.gps_interval_seconds.has_value()) {
cJSON_AddNumberToObject(root, fields::GPS_INTERVAL,
static_cast<double>(*cfg.gps_interval_seconds));
}
if (cfg.front_led_brightness.has_value()) {
cJSON_AddNumberToObject(root, fields::FRONT_LED_BRIGHTNESS,
static_cast<double>(*cfg.front_led_brightness));
}
if (cfg.back_led_brightness.has_value()) {
cJSON_AddNumberToObject(root, fields::BACK_LED_BRIGHTNESS,
static_cast<double>(*cfg.back_led_brightness));
}
if (cfg.touch_led_intensity.has_value()) {
cJSON_AddNumberToObject(root, fields::TOUCH_LED_INTENSITY,
static_cast<double>(*cfg.touch_led_intensity));
}
if (cfg.buzzer_enabled.has_value()) {
cJSON_AddBoolToObject(root, fields::BUZZER_ENABLED, *cfg.buzzer_enabled);
}
if (cfg.co2_abc_days.has_value()) {
cJSON_AddNumberToObject(root, fields::CO2_ABC_DAYS, static_cast<double>(*cfg.co2_abc_days));
}
Expand Down Expand Up @@ -469,6 +528,20 @@ const char *config_field_wire_key(ConfigFieldId id) {
return fields::CLOUD_CONNECTION;
case ConfigFieldId::ConfigurationControl:
return fields::CONFIGURATION_CONTROL;
case ConfigFieldId::MeasurementInterval:
return fields::MEASUREMENT_INTERVAL;
case ConfigFieldId::GpsMode:
return fields::GPS_MODE;
case ConfigFieldId::GpsInterval:
return fields::GPS_INTERVAL;
case ConfigFieldId::FrontLedBrightness:
return fields::FRONT_LED_BRIGHTNESS;
case ConfigFieldId::BackLedBrightness:
return fields::BACK_LED_BRIGHTNESS;
case ConfigFieldId::TouchLedIntensity:
return fields::TOUCH_LED_INTENSITY;
case ConfigFieldId::BuzzerEnabled:
return fields::BUZZER_ENABLED;
case ConfigFieldId::Co2AbcDays:
return fields::CO2_ABC_DAYS;
case ConfigFieldId::TvocLearningOffset:
Expand Down
7 changes: 7 additions & 0 deletions components/airgradient-local-server/internal/field_names.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ inline constexpr const char *TEMPERATURE_UNIT = "temperatureUnit";
inline constexpr const char *POST_DATA_TO_CLOUD = "postDataToCloud";
inline constexpr const char *CLOUD_CONNECTION = "cloudConnection";
inline constexpr const char *CONFIGURATION_CONTROL = "configurationControl";
inline constexpr const char *MEASUREMENT_INTERVAL = "measurementInterval";
inline constexpr const char *GPS_MODE = "gpsMode";
inline constexpr const char *GPS_INTERVAL = "gpsInterval";
inline constexpr const char *FRONT_LED_BRIGHTNESS = "frontLedBrightness";
inline constexpr const char *BACK_LED_BRIGHTNESS = "backLedBrightness";
inline constexpr const char *TOUCH_LED_INTENSITY = "touchLedIntensity";
inline constexpr const char *BUZZER_ENABLED = "buzzerEnabled";
inline constexpr const char *CO2_ABC_DAYS = "co2AbcDays";
inline constexpr const char *TVOC_LEARNING_OFFSET = "tvocLearningOffset";
inline constexpr const char *NOX_LEARNING_OFFSET = "noxLearningOffset";
Expand Down
70 changes: 65 additions & 5 deletions components/airgradient-local-server/tests/config_json.tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,31 @@ TEST_CASE("config parse: valid partial body sets only present keys", "[config][p
TEST_CASE("config parse: all enum fields accept catalog values", "[config][parse]") {
LocalServerConfig cfg;
const auto res = parse(
R"({"pmStandard":"us-aqi","temperatureUnit":"c","configurationControl":"both","ledMode":"iaqs"})",
R"({"pmStandard":"us-aqi","temperatureUnit":"c","configurationControl":"both","gpsMode":"tracking","ledMode":"iaqs"})",
cfg);
REQUIRE(res.status == config_json::ParseStatus::Ok);
REQUIRE(*cfg.pm_standard == "us-aqi");
REQUIRE(*cfg.configuration_control == "both");
REQUIRE(*cfg.gps_mode == "tracking");
REQUIRE(*cfg.led_mode == "iaqs");
}

TEST_CASE("config parse: Go product fields parse with exact types", "[config][parse]") {
LocalServerConfig cfg;
const auto res = parse(
R"({"measurementInterval":30,"gpsMode":"always","gpsInterval":15,"frontLedBrightness":1,"backLedBrightness":2,"touchLedIntensity":2,"buzzerEnabled":true})",
cfg);

REQUIRE(res.status == config_json::ParseStatus::Ok);
REQUIRE(cfg.measurement_interval_seconds == 30);
REQUIRE(cfg.gps_mode == "always");
REQUIRE(cfg.gps_interval_seconds == 15);
REQUIRE(cfg.front_led_brightness == 1);
REQUIRE(cfg.back_led_brightness == 2);
REQUIRE(cfg.touch_led_intensity == 2);
REQUIRE(cfg.buzzer_enabled == true);
}

TEST_CASE("config parse: cloudConnection and url fields parse", "[config][parse]") {
LocalServerConfig cfg;
const auto res =
Expand All @@ -74,6 +91,13 @@ TEST_CASE("config parse: wrong type rejected with field id", "[config][parse]")
REQUIRE(res.field == ConfigFieldId::Co2AbcDays);
}

TEST_CASE("config parse: integer fields reject fractions", "[config][parse]") {
LocalServerConfig cfg;
const auto res = parse(R"({"measurementInterval":10.5})", cfg);
REQUIRE(res.status == config_json::ParseStatus::InvalidValue);
REQUIRE(res.field == ConfigFieldId::MeasurementInterval);
}

TEST_CASE("config parse: bad enum rejected with field id", "[config][parse]") {
LocalServerConfig cfg;
const auto res = parse(R"({"temperatureUnit":"k"})", cfg);
Expand All @@ -82,10 +106,19 @@ TEST_CASE("config parse: bad enum rejected with field id", "[config][parse]") {
}

TEST_CASE("config parse: non-bool for bool field rejected", "[config][parse]") {
LocalServerConfig cfg;
const auto res = parse(R"({"postDataToCloud":1})", cfg);
REQUIRE(res.status == config_json::ParseStatus::InvalidValue);
REQUIRE(res.field == ConfigFieldId::PostDataToCloud);
SECTION("shared field") {
LocalServerConfig cfg;
const auto res = parse(R"({"postDataToCloud":1})", cfg);
REQUIRE(res.status == config_json::ParseStatus::InvalidValue);
REQUIRE(res.field == ConfigFieldId::PostDataToCloud);
}

SECTION("product field") {
LocalServerConfig cfg;
const auto res = parse(R"({"buzzerEnabled":1})", cfg);
REQUIRE(res.status == config_json::ParseStatus::InvalidValue);
REQUIRE(res.field == ConfigFieldId::BuzzerEnabled);
}
}

TEST_CASE("config parse: malformed JSON rejected", "[config][parse]") {
Expand Down Expand Up @@ -237,6 +270,13 @@ TEST_CASE("config serialize: emits only present fields", "[config][serialize]")
cfg.temperature_unit = "f";
cfg.led_bar_brightness = 80;
cfg.post_data_to_cloud = false;
cfg.measurement_interval_seconds = 30;
cfg.gps_mode = "always";
cfg.gps_interval_seconds = 15;
cfg.front_led_brightness = 1;
cfg.back_led_brightness = 2;
cfg.touch_led_intensity = 2;
cfg.buzzer_enabled = true;

char buf[512] = {};
const size_t len = config_json::serialize(cfg, buf, sizeof(buf));
Expand All @@ -248,6 +288,13 @@ TEST_CASE("config serialize: emits only present fields", "[config][serialize]")
REQUIRE(cJSON_GetObjectItem(root, "ledBarBrightness")->valueint == 80);
REQUIRE(cJSON_IsBool(cJSON_GetObjectItem(root, "postDataToCloud")));
REQUIRE(cJSON_IsFalse(cJSON_GetObjectItem(root, "postDataToCloud")));
REQUIRE(cJSON_GetObjectItem(root, "measurementInterval")->valueint == 30);
REQUIRE(std::strcmp(cJSON_GetObjectItem(root, "gpsMode")->valuestring, "always") == 0);
REQUIRE(cJSON_GetObjectItem(root, "gpsInterval")->valueint == 15);
REQUIRE(cJSON_GetObjectItem(root, "frontLedBrightness")->valueint == 1);
REQUIRE(cJSON_GetObjectItem(root, "backLedBrightness")->valueint == 2);
REQUIRE(cJSON_GetObjectItem(root, "touchLedIntensity")->valueint == 2);
REQUIRE(cJSON_IsTrue(cJSON_GetObjectItem(root, "buzzerEnabled")));
// Absent fields omitted.
REQUIRE(cJSON_GetObjectItem(root, "country") == nullptr);
REQUIRE(cJSON_GetObjectItem(root, "pmStandard") == nullptr);
Expand Down Expand Up @@ -316,5 +363,18 @@ TEST_CASE("config field wire keys map correctly", "[config][parse]") {
"displayBrightness") == 0);
REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::CorrectionsPm25),
"corrections.pm25") == 0);
REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::MeasurementInterval),
"measurementInterval") == 0);
REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::GpsMode), "gpsMode") == 0);
REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::GpsInterval),
"gpsInterval") == 0);
REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::FrontLedBrightness),
"frontLedBrightness") == 0);
REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::BackLedBrightness),
"backLedBrightness") == 0);
REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::TouchLedIntensity),
"touchLedIntensity") == 0);
REQUIRE(std::strcmp(config_json::config_field_wire_key(ConfigFieldId::BuzzerEnabled),
"buzzerEnabled") == 0);
REQUIRE(config_json::config_field_wire_key(ConfigFieldId::None) == nullptr);
}
9 changes: 7 additions & 2 deletions components/airgradient-local-server/types/local_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,13 @@ struct LocalServerConfig {
std::optional<bool> post_data_to_cloud; // "postDataToCloud"
std::optional<bool> cloud_connection; // "cloudConnection"
std::optional<std::string> configuration_control; // "configurationControl"
std::optional<int> measurement_interval_seconds; // "measurementInterval"
std::optional<std::string> gps_mode; // "gpsMode"
std::optional<int> gps_interval_seconds; // "gpsInterval"
std::optional<int> front_led_brightness; // "frontLedBrightness"
std::optional<int> back_led_brightness; // "backLedBrightness"
std::optional<int> touch_led_intensity; // "touchLedIntensity"
std::optional<bool> buzzer_enabled; // "buzzerEnabled"
std::optional<int> co2_abc_days; // "co2AbcDays"
std::optional<int> tvoc_learning_offset; // "tvocLearningOffset"
std::optional<int> nox_learning_offset; // "noxLearningOffset"
Expand All @@ -60,8 +67,6 @@ struct LocalServerConfig {
std::optional<std::string> mqtt_broker_url; // "mqttBrokerUrl"
std::optional<std::string> http_domain; // "httpDomain"
std::optional<Corrections> 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.
};

#endif // AG_LOCAL_SERVER_LOCAL_CONFIG_H
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@ enum class ConfigFieldId : uint8_t {
CorrectionsPm25, // "corrections.pm25"
CorrectionsTemp, // "corrections.temp"
CorrectionsHumidity, // "corrections.humidity"
MeasurementInterval, // "measurementInterval"
GpsMode, // "gpsMode"
GpsInterval, // "gpsInterval"
FrontLedBrightness, // "frontLedBrightness"
BackLedBrightness, // "backLedBrightness"
TouchLedIntensity, // "touchLedIntensity"
BuzzerEnabled, // "buzzerEnabled"
};

enum class ConfigSubmitStatus : uint8_t {
Expand Down
Loading
Loading