From 1598c0015597e43b3f6d3ff4810d562e7df38701 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Wed, 29 Jul 2026 02:00:33 +0300 Subject: [PATCH 1/6] feat(go): add USB serial commands --- products/go/README.md | 2 + products/go/docs/measurement_corrections.md | 1 + products/go/docs/serial_command_service.md | 106 +++++ products/go/main/CMakeLists.txt | 3 + products/go/main/go_app.cpp | 7 + products/go/main/go_events.h | 5 + products/go/main/go_orchestrator.cpp | 102 +++++ products/go/main/go_orchestrator.h | 3 + .../go/main/serial_command/serial_command.cpp | 370 ++++++++++++++++++ .../go/main/serial_command/serial_command.h | 114 ++++++ .../serial_command/serial_command_usb.cpp | 49 +++ products/go/tests/CMakeLists.txt | 13 + products/go/tests/go_orchestrator.tests.cpp | 31 +- products/go/tests/serial_command.tests.cpp | 217 ++++++++++ scripts/ago_serial_command.py | 152 +++++++ 15 files changed, 1167 insertions(+), 8 deletions(-) create mode 100644 products/go/docs/serial_command_service.md create mode 100644 products/go/main/serial_command/serial_command.cpp create mode 100644 products/go/main/serial_command/serial_command.h create mode 100644 products/go/main/serial_command/serial_command_usb.cpp create mode 100644 products/go/tests/serial_command.tests.cpp create mode 100644 scripts/ago_serial_command.py diff --git a/products/go/README.md b/products/go/README.md index b4a4cbe..c35dde1 100644 --- a/products/go/README.md +++ b/products/go/README.md @@ -165,6 +165,8 @@ partition table, and merged factory-flash binary. mDNS discovery, request queue, and OTA access policy - [`docs/measurement_corrections.md`](docs/measurement_corrections.md) — raw and corrected measurement views and their consumers +- [`docs/serial_command_service.md`](docs/serial_command_service.md) — + manufacturing-only USB Serial/JTAG command protocol - [`docs/fg_learning.md`](docs/fg_learning.md) — factory fuel-gauge learning boot path (`FgLearningRunner` / `FgLearningController` split, dashboard) - [`docs/hardware_test.md`](docs/hardware_test.md) — on-device Hardware Test diff --git a/products/go/docs/measurement_corrections.md b/products/go/docs/measurement_corrections.md index b95c0f3..d34b312 100644 --- a/products/go/docs/measurement_corrections.md +++ b/products/go/docs/measurement_corrections.md @@ -14,6 +14,7 @@ measurement transports retain raw sensor values. | [`go_config_types.h`](../main/go_config_types.h) | Shared `GoConfigUpdate`, field mask, source identity, and source-control policy | | [`go_cloud.cpp`](../main/go_cloud.cpp) | AirGradient cloud wire parsing into `GoConfigUpdate` | | [`go_local_api.cpp`](../main/go_local_api.cpp) | Local API mapping, semantic validation, and translation into `GoConfigUpdate` | +| [`serial_command.cpp`](../main/serial_command/serial_command.cpp) | Manufacturing USB command parser and typed custom-correction request bridge | | [`go_settings.cpp`](../main/go_settings.cpp) | Grouped correction persistence and boot-time loading | | [`go_orchestrator.cpp`](../main/go_orchestrator.cpp) | Persist-before-activate updates and raw/corrected consumer routing | | [`go_app.cpp`](../main/go_app.cpp) | Offline timer-wake fast-path correction | diff --git a/products/go/docs/serial_command_service.md b/products/go/docs/serial_command_service.md new file mode 100644 index 0000000..a83772d --- /dev/null +++ b/products/go/docs/serial_command_service.md @@ -0,0 +1,106 @@ +# Serial Command Service + +`SerialCommandService` provides the manufacturing-only `#AG` command protocol +over the native USB Serial/JTAG connection. It owns USB input, line parsing, +request admission, and response formatting; the orchestrator owns the typed +operations against Go settings and factory reset. + +## Files + +| File | Purpose | +|---|---| +| [`serial_command.h`](../main/serial_command/serial_command.h) | Queue-copyable request/result types, transport interface, and service declaration | +| [`serial_command.cpp`](../main/serial_command/serial_command.cpp) | Parser, command task, one-in-flight state, and event/result bridge | +| [`serial_command_usb.cpp`](../main/serial_command/serial_command_usb.cpp) | USB Serial/JTAG driver, VFS routing, RX, and atomic VFS response writes | +| [`go_orchestrator.cpp`](../main/go_orchestrator.cpp) | Settings, board serial, and factory-reset command completion | +| [`ago_serial_command.py`](../../../scripts/ago_serial_command.py) | Host CLI that sends one command and filters interleaved USB logs | + +## Dependencies + +| Dependency | Source | Usage | +|---|---|---| +| `RTOS` | `airgradient-common` (`rtos.h`) | Command task and fixed-size event/result queues | +| USB Serial/JTAG | ESP-IDF (`esp_driver_usb_serial_jtag`) | Native USB RX and the secondary-console VFS output path | +| `Orchestrator` | product (`go_orchestrator.cpp`) | Applies typed correction requests and factory reset | +| `GoSettings` | product (`go_settings.h`) | Existing validation, persistence, and correction activation path | + +## Public API + +| Method | Returns | Purpose | +|---|---|---| +| `SerialCommandService(event_queue, channel)` | — | Binds the central event queue and serial transport. | +| `start()` | `bool` | Initializes the transport, creates the one-item result queue, and starts the command task. | +| `complete(result)` | `void` | Delivers the orchestrator result for the accepted command. | + +See [`serial_command.h`](../main/serial_command/serial_command.h) for full +signatures and protocol payload types. + +## Behavior + +### Lifecycle + +The service is constructed during normal Go composition but remains inactive. +The orchestrator calls `start()` only when the boot-button manufacturing path +enters manufacturing mode. The mode and service remain active until reboot or +power-off, including after `FACTORY_RESET`; factory reset returns the device to +Portable/Home without rebooting. + +```mermaid +stateDiagram-v2 + [*] --> Inactive + Inactive --> Active: manufacturing mode entry + Active --> Active: FACTORY_RESET completes + Active --> Inactive: reboot or power off +``` + +On first activation, the USB channel installs the USB Serial/JTAG driver with +256-byte RX/TX rings, routes the existing VFS through that driver, and retains a +write-only `/dev/secondary` descriptor. Each response is emitted by one VFS +`write()` call, so normal mirrored logs cannot split its bytes. The channel is +not installed at normal boot, never uses UART0, and is not uninstalled. + +The task uses a 3072-byte stack at priority 3 and waits up to 50 ms per USB RX +read. This finite wait lets it poll the one-item result queue. A command is +marked in flight only after central-event admission succeeds; a second valid +command receives `#AG ERROR BUSY` until the prior result is emitted. + +### Protocol + +Messages are UTF-8 ASCII tokens terminated by LF. CRLF is accepted. Commands +and responses begin with `#AG` followed by one ASCII space; non-prefixed input is ignored. The receiver +buffers at most 128 bytes per line and discards an overlong line through its +next LF. Responses are bounded to 128 bytes. + +| Request | Successful Response | Other Error | +|---|---|---| +| `#AG HELP` | `#AG OK COMMANDS HELP GET_SERIAL SET_SLR GET_SLR FACTORY_RESET` | `INVALID_ARGUMENT` | +| `#AG GET_SERIAL` | `#AG OK SERIAL ` | `INVALID_ARGUMENT` | +| `#AG SET_SLR ` | `#AG OK SLR ` | `INVALID_ARGUMENT`, `OPERATION_FAILED` | +| `#AG GET_SLR ` | `#AG OK SLR ` | `INVALID_ARGUMENT`, `SLR_NOT_SET` | +| `#AG FACTORY_RESET` | `#AG OK RESET` | `INVALID_ARGUMENT`, `OPERATION_FAILED` | + +`target` is exactly `PM`, `TEMP`, or `HUM`. Numeric values must fully parse to +finite `float` values. SLR responses always render scale and intercept with six +decimal places. The board serial comes unchanged from the existing Go board +serial source. + +### Settings Operations + +The parser carries `Pm25Correction` or `LinearCorrection` in the typed request. +The orchestrator copies its complete settings, selects the requested custom +algorithm, validates the merged candidate, and uses +`activate_settings_candidate()` for persistence and runtime activation. No +serial-specific preferences or direct NVS writes exist. + +For PM, `SET_SLR` selects `CustomViaPm25Raw` and preserves `use_epa2021` when +the current PM correction is already custom; otherwise it initializes that flag +to `false`. Temperature and humidity select the linear `Custom` algorithm. + +## Edge Cases / Errors + +The only protocol errors are `EMPTY_COMMAND`, `INVALID_COMMAND`, +`INVALID_ARGUMENT`, `SLR_NOT_SET`, `OPERATION_FAILED`, and `BUSY`. Extra +arguments, unknown targets, invalid numbers, and arguments supplied to +argument-free commands are `INVALID_ARGUMENT`. A valid request that cannot be +queued, persisted, or completed is `OPERATION_FAILED`. A failed or short VFS +write is not retried because retrying could interleave with a log message. diff --git a/products/go/main/CMakeLists.txt b/products/go/main/CMakeLists.txt index 5d90613..a79964d 100644 --- a/products/go/main/CMakeLists.txt +++ b/products/go/main/CMakeLists.txt @@ -16,6 +16,8 @@ idf_component_register( "go_portable_provisioner.cpp" "go_power.cpp" "go_sensor_producer.cpp" + "serial_command/serial_command.cpp" + "serial_command/serial_command_usb.cpp" "go_storage.cpp" "go_ui.cpp" "go_ulp.cpp" @@ -54,6 +56,7 @@ idf_component_register( esp_driver_i2c esp_driver_ledc esp_driver_spi + esp_driver_usb_serial_jtag nvs_flash u8g2 fatfs diff --git a/products/go/main/go_app.cpp b/products/go/main/go_app.cpp index 7fb8a29..fc696c2 100644 --- a/products/go/main/go_app.cpp +++ b/products/go/main/go_app.cpp @@ -52,6 +52,7 @@ inline esp_reset_reason_t esp_reset_reason() { return ESP_RST_UNKNOWN; } #include "measurement_corrections.h" #include "retained_uptime.h" #include "rtos.h" +#include "serial_command/serial_command.h" #include "services/local_server.h" #include "services/sensor_manager.h" @@ -580,6 +581,8 @@ void GoApp::run_button_wake_path(const RtcAppState &state) { // Inert until start(); heap claimed only when Stationary + online. auto *cloud_service = new CloudService(event_queue, {_board.ag_client(), *wifi_service}, CloudService::Config{}); + auto *serial_command_channel = new UsbSerialCommandChannel(); + auto *serial_command_service = new SerialCommandService(event_queue, *serial_command_channel); // LED service — init and start before orchestrator. LedService &led = _board.led_service(); led.init(); @@ -611,6 +614,7 @@ void GoApp::run_button_wake_path(const RtcAppState &state) { .wifi = *wifi_service, .cloud = *cloud_service, .local_api = *local_api_service, + .serial_command = *serial_command_service, .portable_provisioner = *portable_provisioner, .board = _board, .ota = *ota_service, @@ -700,6 +704,8 @@ void GoApp::run_interactive(WakeCause cause, BootHandoff handoff) { // --- CloudService (inert until start()) --- auto *cloud_service = new CloudService(event_queue, {_board.ag_client(), *wifi_service}, CloudService::Config{}); + auto *serial_command_channel = new UsbSerialCommandChannel(); + auto *serial_command_service = new SerialCommandService(event_queue, *serial_command_channel); // --- Service construction --- auto *sensor_producer = new SensorProducer(sm, event_queue, @@ -803,6 +809,7 @@ void GoApp::run_interactive(WakeCause cause, BootHandoff handoff) { .wifi = *wifi_service, .cloud = *cloud_service, .local_api = *local_api_service, + .serial_command = *serial_command_service, .portable_provisioner = *portable_provisioner, .board = _board, .ota = *ota_service, diff --git a/products/go/main/go_events.h b/products/go/main/go_events.h index 52061f3..e7e9775 100644 --- a/products/go/main/go_events.h +++ b/products/go/main/go_events.h @@ -8,6 +8,7 @@ #include "gps/gps_types.h" #include "measures_types.h" #include "go_wifi_types.h" +#include "serial_command/serial_command.h" // --- Event type discriminator --- @@ -45,6 +46,9 @@ enum class EventType : uint8_t { // --- Local API events --- LocalApiRequestReady, // payload: uint32_t local_api_epoch + // --- USB serial command events --- + SerialCommandRequest, // payload: SerialCommandRequest + // --- Calibration events --- Co2CalibrationDone, // payload: uint8_t co2_cal_result (Co2CalibrationResult) Co2AbcPeriodDone, // payload: uint8_t co2_abc_result (Co2AbcPeriodResult) @@ -119,6 +123,7 @@ struct Event { CloudResultByte cloud_result; // PostMeasuresResult (AgClientResult byte) FetchConfigEventPayload fetch_config; // FetchConfigResult uint32_t local_api_epoch; // LocalApiRequestReady + SerialCommandRequest serial_command_request; // SerialCommandRequest }; }; diff --git a/products/go/main/go_orchestrator.cpp b/products/go/main/go_orchestrator.cpp index 52170cf..0e1bf5d 100644 --- a/products/go/main/go_orchestrator.cpp +++ b/products/go/main/go_orchestrator.cpp @@ -16,6 +16,7 @@ #include #include #include +#include #include #include @@ -759,6 +760,10 @@ void Orchestrator::dispatch(const Event &event) { case EventType::LocalApiRequestReady: on_local_api_request(event.local_api_epoch); break; + + case EventType::SerialCommandRequest: + handle_serial_command(event.serial_command_request); + break; } } @@ -802,6 +807,100 @@ void Orchestrator::on_local_api_request(uint32_t event_epoch) { } } +void Orchestrator::handle_serial_command(const SerialCommandRequest &request) { + SerialCommandResult result{}; + + if (!_manufacturing_mode) { + _svc.serial_command.complete(result); + return; + } + + switch (request.kind) { + case SerialCommandKind::GetSerial: + if (_serial == nullptr) { + break; + } + result.kind = SerialCommandResultKind::Serial; + std::strncpy(result.serial, _serial, sizeof(result.serial) - 1); + break; + + case SerialCommandKind::SetPmSlr: { + GoSettings candidate = _settings; + candidate.corrections.pm25 = request.pm25_correction; + candidate.corrections.pm25.use_epa2021 = + _settings.corrections.pm25.algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw + ? _settings.corrections.pm25.use_epa2021 + : false; + if (!is_go_settings_valid(candidate)) { + result.kind = SerialCommandResultKind::InvalidArgument; + } else if (activate_settings_candidate(candidate)) { + result.kind = SerialCommandResultKind::SlrPm; + result.pm25_correction = candidate.corrections.pm25; + } + break; + } + + case SerialCommandKind::SetTemperatureSlr: { + GoSettings candidate = _settings; + candidate.corrections.temperature = request.linear_correction; + if (!is_go_settings_valid(candidate)) { + result.kind = SerialCommandResultKind::InvalidArgument; + } else if (activate_settings_candidate(candidate)) { + result.kind = SerialCommandResultKind::SlrTemperature; + result.linear_correction = candidate.corrections.temperature; + } + break; + } + + case SerialCommandKind::SetHumiditySlr: { + GoSettings candidate = _settings; + candidate.corrections.humidity = request.linear_correction; + if (!is_go_settings_valid(candidate)) { + result.kind = SerialCommandResultKind::InvalidArgument; + } else if (activate_settings_candidate(candidate)) { + result.kind = SerialCommandResultKind::SlrHumidity; + result.linear_correction = candidate.corrections.humidity; + } + break; + } + + case SerialCommandKind::GetPmSlr: + if (_settings.corrections.pm25.algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw) { + result.kind = SerialCommandResultKind::SlrPm; + result.pm25_correction = _settings.corrections.pm25; + } else { + result.kind = SerialCommandResultKind::SlrNotSet; + } + break; + + case SerialCommandKind::GetTemperatureSlr: + if (_settings.corrections.temperature.algorithm == LinearCorrectionAlgorithm::Custom) { + result.kind = SerialCommandResultKind::SlrTemperature; + result.linear_correction = _settings.corrections.temperature; + } else { + result.kind = SerialCommandResultKind::SlrNotSet; + } + break; + + case SerialCommandKind::GetHumiditySlr: + if (_settings.corrections.humidity.algorithm == LinearCorrectionAlgorithm::Custom) { + result.kind = SerialCommandResultKind::SlrHumidity; + result.linear_correction = _settings.corrections.humidity; + } else { + result.kind = SerialCommandResultKind::SlrNotSet; + } + break; + + case SerialCommandKind::FactoryReset: + if (factory_reset()) { + result.kind = SerialCommandResultKind::Reset; + } + break; + } + + _svc.serial_command.complete(result); +} + void Orchestrator::apply_config_update(const GoConfigUpdate &update, GoConfigSource source) { if (!is_go_config_update_allowed(_settings.configuration_control, source, update)) { AG_LOGW(TAG, "config update discarded: source=%u no longer allowed", @@ -1345,6 +1444,9 @@ bool Orchestrator::mark_onboarding_done() { void Orchestrator::enter_manufacturing_mode() { AG_LOGI(TAG, "enter_manufacturing_mode: skip onboarding, Stationary (ephemeral)"); _manufacturing_mode = true; + if (!_svc.serial_command.start()) { + AG_LOGE(TAG, "failed to start serial command service"); + } change_mode(OperatingMode::Stationary, /*persist=*/false); } diff --git a/products/go/main/go_orchestrator.h b/products/go/main/go_orchestrator.h index 082e9d5..595acbf 100644 --- a/products/go/main/go_orchestrator.h +++ b/products/go/main/go_orchestrator.h @@ -34,6 +34,7 @@ #include "go_ulp.h" #include "gps/gps_service.h" #include "rtos.h" +#include "serial_command/serial_command.h" #include "go_wifi.h" #include "types/local_server_result.h" @@ -60,6 +61,7 @@ class Orchestrator { WifiService &wifi; CloudService &cloud; GoLocalApiService &local_api; + SerialCommandService &serial_command; PortableWifiProvisioner &portable_provisioner; // attached Portable Wi-Fi provisioning GoBoard &board; // borrowed for init_wifi_subsystem() in Stationary entry OtaService &ota; // per-mode OTA wiring (BLE push / WiFi pull) @@ -254,6 +256,7 @@ class Orchestrator { void dispatch(const Event &event); void handle_cloud_action_requests(const FetchConfigEventPayload &payload); void on_local_api_request(uint32_t event_epoch); + void handle_serial_command(const SerialCommandRequest &request); void apply_config_update(const GoConfigUpdate &update, GoConfigSource source); // --- Event handlers --- diff --git a/products/go/main/serial_command/serial_command.cpp b/products/go/main/serial_command/serial_command.cpp new file mode 100644 index 0000000..13be2e3 --- /dev/null +++ b/products/go/main/serial_command/serial_command.cpp @@ -0,0 +1,370 @@ +#include "serial_command/serial_command.h" + +#include +#include +#include +#include +#include + +#include "go_events.h" + +namespace { + +constexpr size_t SERIAL_COMMAND_READ_BUFFER_BYTES = 64; +constexpr size_t SERIAL_COMMAND_MAX_TOKENS = 5; + +constexpr char HELP_RESPONSE[] = + "#AG OK COMMANDS HELP GET_SERIAL SET_SLR " + "GET_SLR FACTORY_RESET\n"; + +static_assert(sizeof(HELP_RESPONSE) <= SERIAL_COMMAND_MAX_RESPONSE_BYTES); + +struct Token { + const char *data; + size_t size; +}; + +bool token_equals(const Token &token, const char *value) { + const size_t value_size = std::strlen(value); + return token.size == value_size && std::memcmp(token.data, value, value_size) == 0; +} + +size_t tokenize(const char *line, size_t line_size, Token *tokens, size_t max_tokens) { + size_t token_count = 0; + size_t position = 0; + + while (position < line_size) { + while (position < line_size && (line[position] == ' ' || line[position] == '\t')) { + ++position; + } + if (position == line_size) { + break; + } + + const size_t token_start = position; + while (position < line_size && line[position] != ' ' && line[position] != '\t') { + ++position; + } + if (token_count < max_tokens) { + tokens[token_count++] = {line + token_start, position - token_start}; + } else { + return max_tokens + 1; + } + } + + return token_count; +} + +bool parse_float(const Token &token, float &value) { + if (token.size == 0 || token.size > SERIAL_COMMAND_MAX_LINE_BYTES) { + return false; + } + + char number[SERIAL_COMMAND_MAX_LINE_BYTES + 1]; + std::memcpy(number, token.data, token.size); + number[token.size] = '\0'; + + char *end = nullptr; + value = std::strtof(number, &end); + return end == number + token.size && std::isfinite(value); +} + +const char *target_for_result(SerialCommandResultKind kind) { + switch (kind) { + case SerialCommandResultKind::SlrPm: + return "PM"; + case SerialCommandResultKind::SlrTemperature: + return "TEMP"; + case SerialCommandResultKind::SlrHumidity: + return "HUM"; + default: + return nullptr; + } +} + +} // namespace + +SerialCommandService::SerialCommandService(RtosQueueHandle event_queue, + SerialCommandChannel &channel) + : _event_queue(event_queue), _channel(channel) {} + +bool SerialCommandService::start() { + if (_started) { + return true; + } + if (!_channel.initialize()) { + return false; + } + + _result_queue = RTOS::queue_create(1, sizeof(SerialCommandResult)); + if (_result_queue == nullptr) { + return false; + } + +#ifdef TEST_HOST + _started = true; + return true; +#else + if (!RTOS::task_create(_task_entry, "serial_cmd", SERIAL_COMMAND_TASK_STACK_BYTES, this, + SERIAL_COMMAND_TASK_PRIORITY, &_task_handle)) { + RTOS::queue_delete(_result_queue); + _result_queue = nullptr; + return false; + } + _started = true; + return true; +#endif +} + +void SerialCommandService::complete(const SerialCommandResult &result) { + if (_result_queue == nullptr) { + return; + } + const bool delivered = RTOS::queue_send(_result_queue, &result, UINT32_MAX); + if (!delivered) { + return; + } +} + +void SerialCommandService::_task_entry(void *param) { + static_cast(param)->_command_task(); +} + +void SerialCommandService::_command_task() { + while (true) { + _poll_once(); + } +} + +void SerialCommandService::_poll_once() { + SerialCommandResult result{}; + if (_awaiting_result && RTOS::queue_receive(_result_queue, &result, 0)) { + _complete_result(result); + } + + char buffer[SERIAL_COMMAND_READ_BUFFER_BYTES]; + const int read_size = _channel.read_bytes(buffer, sizeof(buffer), SERIAL_COMMAND_RX_WAIT_MS); + if (read_size <= 0) { + return; + } + + const size_t received_size = static_cast(read_size); + const size_t process_size = received_size < sizeof(buffer) ? received_size : sizeof(buffer); + for (size_t i = 0; i < process_size; ++i) { + _process_byte(buffer[i]); + } +} + +void SerialCommandService::_process_byte(char byte) { + if (_discarding_line) { + if (byte == '\n') { + _discarding_line = false; + } + return; + } + + if (byte == '\n') { + size_t line_size = _line_size; + if (line_size > 0 && _line[line_size - 1] == '\r') { + --line_size; + } + _handle_line(_line, line_size); + _line_size = 0; + return; + } + + if (_line_size >= SERIAL_COMMAND_MAX_LINE_BYTES) { + _line_size = 0; + _discarding_line = true; + return; + } + _line[_line_size++] = byte; +} + +void SerialCommandService::_handle_line(const char *line, size_t line_size) { + constexpr char ENVELOPE[] = "#AG "; + if (line_size < sizeof(ENVELOPE) - 1 || std::memcmp(line, ENVELOPE, sizeof(ENVELOPE) - 1) != 0) { + return; + } + + Token tokens[SERIAL_COMMAND_MAX_TOKENS]{}; + const size_t token_count = + tokenize(line + sizeof(ENVELOPE) - 1, line_size - (sizeof(ENVELOPE) - 1), tokens, + SERIAL_COMMAND_MAX_TOKENS); + if (token_count == 0) { + _write_error("EMPTY_COMMAND"); + return; + } + + if (token_equals(tokens[0], "HELP")) { + if (token_count != 1) { + _write_error("INVALID_ARGUMENT"); + return; + } + if (_awaiting_result) { + _write_error("BUSY"); + return; + } + if (!_channel.write_response(HELP_RESPONSE, sizeof(HELP_RESPONSE) - 1)) { + return; + } + return; + } + + if (token_equals(tokens[0], "GET_SERIAL")) { + if (token_count != 1) { + _write_error("INVALID_ARGUMENT"); + return; + } + _submit_request({SerialCommandKind::GetSerial}); + return; + } + + if (token_equals(tokens[0], "FACTORY_RESET")) { + if (token_count != 1) { + _write_error("INVALID_ARGUMENT"); + return; + } + _submit_request({SerialCommandKind::FactoryReset}); + return; + } + + if (token_equals(tokens[0], "GET_SLR")) { + if (token_count != 2) { + _write_error("INVALID_ARGUMENT"); + return; + } + if (token_equals(tokens[1], "PM")) { + _submit_request({SerialCommandKind::GetPmSlr}); + } else if (token_equals(tokens[1], "TEMP")) { + _submit_request({SerialCommandKind::GetTemperatureSlr}); + } else if (token_equals(tokens[1], "HUM")) { + _submit_request({SerialCommandKind::GetHumiditySlr}); + } else { + _write_error("INVALID_ARGUMENT"); + } + return; + } + + if (token_equals(tokens[0], "SET_SLR")) { + if (token_count != 4) { + _write_error("INVALID_ARGUMENT"); + return; + } + + float scaling_factor = 0.0f; + float intercept = 0.0f; + if (!parse_float(tokens[2], scaling_factor) || !parse_float(tokens[3], intercept)) { + _write_error("INVALID_ARGUMENT"); + return; + } + + SerialCommandRequest request{}; + if (token_equals(tokens[1], "PM")) { + request.kind = SerialCommandKind::SetPmSlr; + request.pm25_correction.algorithm = Pm25CorrectionAlgorithm::CustomViaPm25Raw; + request.pm25_correction.scaling_factor = scaling_factor; + request.pm25_correction.intercept = intercept; + } else if (token_equals(tokens[1], "TEMP")) { + request.kind = SerialCommandKind::SetTemperatureSlr; + request.linear_correction.algorithm = LinearCorrectionAlgorithm::Custom; + request.linear_correction.scaling_factor = scaling_factor; + request.linear_correction.intercept = intercept; + } else if (token_equals(tokens[1], "HUM")) { + request.kind = SerialCommandKind::SetHumiditySlr; + request.linear_correction.algorithm = LinearCorrectionAlgorithm::Custom; + request.linear_correction.scaling_factor = scaling_factor; + request.linear_correction.intercept = intercept; + } else { + _write_error("INVALID_ARGUMENT"); + return; + } + _submit_request(request); + return; + } + + _write_error("INVALID_COMMAND"); +} + +void SerialCommandService::_submit_request(const SerialCommandRequest &request) { + if (_awaiting_result) { + _write_error("BUSY"); + return; + } + + Event event{}; + event.type = EventType::SerialCommandRequest; + event.serial_command_request = request; + if (!RTOS::queue_send(_event_queue, &event, 0)) { + _write_error("OPERATION_FAILED"); + return; + } + _awaiting_result = true; +} + +void SerialCommandService::_complete_result(const SerialCommandResult &result) { + _awaiting_result = false; + + switch (result.kind) { + case SerialCommandResultKind::Serial: { + char serial[SERIAL_COMMAND_MAX_SERIAL_BYTES]; + std::memcpy(serial, result.serial, sizeof(serial)); + serial[sizeof(serial) - 1] = '\0'; + _write_response("#AG OK SERIAL %s\n", serial); + return; + } + case SerialCommandResultKind::SlrPm: + case SerialCommandResultKind::SlrTemperature: + case SerialCommandResultKind::SlrHumidity: { + const char *target = target_for_result(result.kind); + const float scaling_factor = result.kind == SerialCommandResultKind::SlrPm + ? result.pm25_correction.scaling_factor + : result.linear_correction.scaling_factor; + const float intercept = result.kind == SerialCommandResultKind::SlrPm + ? result.pm25_correction.intercept + : result.linear_correction.intercept; + _write_response("#AG OK SLR %s %.6f %.6f\n", target, static_cast(scaling_factor), + static_cast(intercept)); + return; + } + case SerialCommandResultKind::Reset: + _write_response("#AG OK RESET\n"); + return; + case SerialCommandResultKind::SlrNotSet: + _write_error("SLR_NOT_SET"); + return; + case SerialCommandResultKind::InvalidArgument: + _write_error("INVALID_ARGUMENT"); + return; + case SerialCommandResultKind::OperationFailed: + _write_error("OPERATION_FAILED"); + return; + } +} + +void SerialCommandService::_write_error(const char *error_code) { + _write_response("#AG ERROR %s\n", error_code); +} + +void SerialCommandService::_write_response(const char *format, ...) { + char response[SERIAL_COMMAND_MAX_RESPONSE_BYTES]; + va_list args; + va_start(args, format); + const int response_size = std::vsnprintf(response, sizeof(response), format, args); + va_end(args); + + if (response_size <= 0 || static_cast(response_size) >= sizeof(response)) { + return; + } + if (!_channel.write_response(response, static_cast(response_size))) { + return; + } +} + +#ifdef TEST_HOST +bool UsbSerialCommandChannel::initialize() { return false; } + +int UsbSerialCommandChannel::read_bytes(char *, size_t, uint32_t) { return -1; } + +bool UsbSerialCommandChannel::write_response(const char *, size_t) { return false; } +#endif diff --git a/products/go/main/serial_command/serial_command.h b/products/go/main/serial_command/serial_command.h new file mode 100644 index 0000000..bce5ac1 --- /dev/null +++ b/products/go/main/serial_command/serial_command.h @@ -0,0 +1,114 @@ +#ifndef SERIAL_COMMAND_H +#define SERIAL_COMMAND_H + +#include +#include +#include + +#include "measurement_corrections.h" +#include "rtos.h" + +inline constexpr size_t SERIAL_COMMAND_MAX_LINE_BYTES = 128; +inline constexpr size_t SERIAL_COMMAND_MAX_RESPONSE_BYTES = 128; +inline constexpr size_t SERIAL_COMMAND_MAX_SERIAL_BYTES = 13; +inline constexpr uint32_t SERIAL_COMMAND_USB_TX_BUFFER_BYTES = 256; +inline constexpr uint32_t SERIAL_COMMAND_USB_RX_BUFFER_BYTES = 256; +inline constexpr uint32_t SERIAL_COMMAND_RX_WAIT_MS = 50; +inline constexpr uint32_t SERIAL_COMMAND_TASK_STACK_BYTES = 3072; +inline constexpr uint32_t SERIAL_COMMAND_TASK_PRIORITY = 3; + +enum class SerialCommandKind : uint8_t { + GetSerial, + SetPmSlr, + SetTemperatureSlr, + SetHumiditySlr, + GetPmSlr, + GetTemperatureSlr, + GetHumiditySlr, + FactoryReset, +}; + +struct SerialCommandRequest { + SerialCommandKind kind = SerialCommandKind::GetSerial; + Pm25Correction pm25_correction{}; + LinearCorrection linear_correction{}; +}; + +enum class SerialCommandResultKind : uint8_t { + Serial, + SlrPm, + SlrTemperature, + SlrHumidity, + Reset, + SlrNotSet, + InvalidArgument, + OperationFailed, +}; + +struct SerialCommandResult { + SerialCommandResultKind kind = SerialCommandResultKind::OperationFailed; + char serial[SERIAL_COMMAND_MAX_SERIAL_BYTES]{}; + Pm25Correction pm25_correction{}; + LinearCorrection linear_correction{}; +}; + +static_assert(std::is_trivially_copyable::value); +static_assert(std::is_trivially_copyable::value); + +class SerialCommandChannel { +public: + virtual ~SerialCommandChannel() = default; + + virtual bool initialize() = 0; + virtual int read_bytes(char *buffer, size_t buffer_size, uint32_t timeout_ms) = 0; + virtual bool write_response(const char *response, size_t response_size) = 0; +}; + +class SerialCommandService { +public: + SerialCommandService(RtosQueueHandle event_queue, SerialCommandChannel &channel); + + /// Initialize the transport and start the command task. Idempotent after success. + bool start(); + + /// Deliver the completed result for the single accepted command. + void complete(const SerialCommandResult &result); + +private: +#ifdef TEST_HOST + friend class SerialCommandServiceTestAccess; +#endif + + static void _task_entry(void *param); + void _command_task(); + void _poll_once(); + void _process_byte(char byte); + void _handle_line(const char *line, size_t line_size); + void _submit_request(const SerialCommandRequest &request); + void _complete_result(const SerialCommandResult &result); + void _write_error(const char *error_code); + void _write_response(const char *format, ...); + + RtosQueueHandle _event_queue; + SerialCommandChannel &_channel; + RtosQueueHandle _result_queue = nullptr; + RtosTaskHandle _task_handle = nullptr; + char _line[SERIAL_COMMAND_MAX_LINE_BYTES]{}; + size_t _line_size = 0; + bool _discarding_line = false; + bool _awaiting_result = false; + bool _started = false; +}; + +class UsbSerialCommandChannel final : public SerialCommandChannel { +public: + bool initialize() override; + int read_bytes(char *buffer, size_t buffer_size, uint32_t timeout_ms) override; + bool write_response(const char *response, size_t response_size) override; + +private: + int _tx_fd = -1; + bool _initialized = false; +}; + +#endif // SERIAL_COMMAND_H diff --git a/products/go/main/serial_command/serial_command_usb.cpp b/products/go/main/serial_command/serial_command_usb.cpp new file mode 100644 index 0000000..f527556 --- /dev/null +++ b/products/go/main/serial_command/serial_command_usb.cpp @@ -0,0 +1,49 @@ +#include "serial_command/serial_command.h" + +#include +#include + +#include "driver/usb_serial_jtag.h" +#include "driver/usb_serial_jtag_vfs.h" +#include "esp_err.h" +#include "freertos/FreeRTOS.h" + +bool UsbSerialCommandChannel::initialize() { + if (_initialized) { + return true; + } + + if (!usb_serial_jtag_is_driver_installed()) { + usb_serial_jtag_driver_config_t config = { + .tx_buffer_size = SERIAL_COMMAND_USB_TX_BUFFER_BYTES, + .rx_buffer_size = SERIAL_COMMAND_USB_RX_BUFFER_BYTES, + }; + if (usb_serial_jtag_driver_install(&config) != ESP_OK) { + return false; + } + } + + usb_serial_jtag_vfs_use_driver(); + _tx_fd = open("/dev/secondary", O_WRONLY); + if (_tx_fd < 0) { + return false; + } + + _initialized = true; + return true; +} + +int UsbSerialCommandChannel::read_bytes(char *buffer, size_t buffer_size, uint32_t timeout_ms) { + if (!_initialized || buffer == nullptr || buffer_size == 0) { + return -1; + } + return usb_serial_jtag_read_bytes(buffer, static_cast(buffer_size), + pdMS_TO_TICKS(timeout_ms)); +} + +bool UsbSerialCommandChannel::write_response(const char *response, size_t response_size) { + if (!_initialized || response == nullptr || response_size == 0) { + return false; + } + return write(_tx_fd, response, response_size) == static_cast(response_size); +} diff --git a/products/go/tests/CMakeLists.txt b/products/go/tests/CMakeLists.txt index 9f5d4ce..3507604 100644 --- a/products/go/tests/CMakeLists.txt +++ b/products/go/tests/CMakeLists.txt @@ -270,6 +270,7 @@ catch_discover_tests(go_ui_tests) add_library(go_orchestrator_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_orchestrator.cpp" + "${AIRGRADIENT_REPO_ROOT}/products/go/main/serial_command/serial_command.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_local_api.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/retained_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_ui.cpp" @@ -342,6 +343,17 @@ target_link_libraries(go_orchestrator_tests PRIVATE catch_discover_tests(go_orchestrator_tests) +add_executable(serial_command_tests + serial_command.tests.cpp +) + +target_link_libraries(serial_command_tests PRIVATE + go_orchestrator_test_support + Catch2::Catch2WithMain +) + +catch_discover_tests(serial_command_tests) + # --------------------------------------------------------------------------- # go_ble tests # --------------------------------------------------------------------------- @@ -554,6 +566,7 @@ catch_discover_tests(go_gps_types_tests) add_library(go_app_test_support "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_app.cpp" + "${AIRGRADIENT_REPO_ROOT}/products/go/main/serial_command/serial_command.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_local_api.cpp" "${AIRGRADIENT_REPO_ROOT}/components/airgradient-common/retained_uptime.cpp" "${AIRGRADIENT_REPO_ROOT}/products/go/main/go_settings.cpp" diff --git a/products/go/tests/go_orchestrator.tests.cpp b/products/go/tests/go_orchestrator.tests.cpp index 11aa75c..3ddc20f 100644 --- a/products/go/tests/go_orchestrator.tests.cpp +++ b/products/go/tests/go_orchestrator.tests.cpp @@ -279,6 +279,13 @@ class StubCapTouchSensor : public CapTouchSensor { bool read(TouchData &) override { return false; } }; +class StubSerialCommandChannel : public SerialCommandChannel { +public: + bool initialize() override { return true; } + int read_bytes(char *, size_t, uint32_t) override { return 0; } + bool write_response(const char *, size_t) override { return true; } +}; + class StubBmsDevice : public BmsDevice { public: bool init() override { return true; } @@ -608,6 +615,8 @@ struct TestFixture { AgClient ag_client; CloudService cloud_service; GoLocalApiService local_api; + StubSerialCommandChannel serial_command_channel; + SerialCommandService serial_command; StubGoBoard stub_board; PortableWifiProvisioner portable_provisioner; OtaService ota_service; @@ -640,15 +649,17 @@ struct TestFixture { ag_client(), cloud_service(nullptr, CloudService::Deps{ag_client, wifi_service}, CloudService::Config{}), local_api(event_queue, {.serial_number = "TEST00", .firmware_version = "test"}), + serial_command(event_queue, serial_command_channel), portable_provisioner(nullptr, {*reinterpret_cast(_stub_buf), *reinterpret_cast(_stub_buf), stub_board}, PortableWifiProvisioner::Config{}), ota_service(stub_ble_server, power_service, OtaService::Config{}), - services{sensor_producer, gps_service, input_service, display_service, - led_service_inert, buzzer_service_inert, storage_service, power_service, - ui_manager, ble_service, wifi_service, cloud_service, - local_api, portable_provisioner, stub_board, ota_service} { + services{sensor_producer, gps_service, input_service, display_service, + led_service_inert, buzzer_service_inert, storage_service, power_service, + ui_manager, ble_service, wifi_service, cloud_service, + local_api, serial_command, portable_provisioner, stub_board, + ota_service} { test_spy::reset(); RTOS::set_instance(&mock_rtos); _exp_time = NAMED_ALLOW_CALL(mock_rtos, get_time_ms_impl()).RETURN(0); @@ -4799,6 +4810,8 @@ struct PmSleepFixture { AgClient ag_client; CloudService cloud_service; GoLocalApiService local_api; + StubSerialCommandChannel serial_command_channel; + SerialCommandService serial_command; StubGoBoard stub_board; PortableWifiProvisioner portable_provisioner; OtaService ota_service; @@ -4841,15 +4854,17 @@ struct PmSleepFixture { ag_client(), cloud_service(nullptr, CloudService::Deps{ag_client, wifi_service}, CloudService::Config{}), local_api(nullptr, {.serial_number = "TEST00", .firmware_version = "test"}), + serial_command(nullptr, serial_command_channel), portable_provisioner(nullptr, {*reinterpret_cast(_stub_buf), *reinterpret_cast(_stub_buf), stub_board}, PortableWifiProvisioner::Config{}), ota_service(stub_ble_server, power_service, OtaService::Config{}), - services{sensor_producer, gps_service, input_service, display_service, - led_service_inert, buzzer_service_inert, storage_service, power_service, - ui_manager, ble_service, wifi_service, cloud_service, - local_api, portable_provisioner, stub_board, ota_service} { + services{sensor_producer, gps_service, input_service, display_service, + led_service_inert, buzzer_service_inert, storage_service, power_service, + ui_manager, ble_service, wifi_service, cloud_service, + local_api, serial_command, portable_provisioner, stub_board, + ota_service} { test_spy::reset(); RTOS::set_instance(&mock_rtos); settings.operating_mode = OperatingMode::Portable; diff --git a/products/go/tests/serial_command.tests.cpp b/products/go/tests/serial_command.tests.cpp new file mode 100644 index 0000000..f1f796e --- /dev/null +++ b/products/go/tests/serial_command.tests.cpp @@ -0,0 +1,217 @@ +#include +#include +#include +#include +#include +#include + +#include + +#include "go_events.h" +#include "serial_command/serial_command.h" + +class SerialCommandServiceTestAccess { +public: + static void poll(SerialCommandService &service) { service._poll_once(); } +}; + +class TestRTOS final : public RTOS { +public: + void delay_ms_impl(uint32_t) override {} + uint64_t get_time_ms_impl() override { return 0; } +}; + +class FakeSerialCommandChannel final : public SerialCommandChannel { +public: + bool initialize() override { + initialized = true; + return initialize_result; + } + + int read_bytes(char *buffer, size_t buffer_size, uint32_t) override { + const size_t bytes_to_read = std::min(buffer_size, input.size()); + std::copy_n(input.begin(), bytes_to_read, buffer); + input.erase(input.begin(), input.begin() + static_cast(bytes_to_read)); + return static_cast(bytes_to_read); + } + + bool write_response(const char *response, size_t response_size) override { + responses.emplace_back(response, response_size); + return write_result; + } + + void append_input(const std::string &value) { + input.insert(input.end(), value.begin(), value.end()); + } + + bool initialize_result = true; + bool write_result = true; + bool initialized = false; + std::vector input; + std::vector responses; +}; + +struct SerialCommandFixture { + TestRTOS rtos; + RtosQueueHandle event_queue = nullptr; + FakeSerialCommandChannel channel; + std::unique_ptr service; + + explicit SerialCommandFixture(uint32_t event_queue_depth = EVENT_QUEUE_DEPTH) { + RTOS::set_instance(&rtos); + event_queue = RTOS::queue_create(event_queue_depth, sizeof(Event)); + service = std::make_unique(event_queue, channel); + REQUIRE(service->start()); + } + + ~SerialCommandFixture() { + RTOS::queue_delete(event_queue); + RTOS::set_instance(nullptr); + } + + void poll() { SerialCommandServiceTestAccess::poll(*service); } + + void drain_input() { + while (!channel.input.empty()) { + poll(); + } + } + + Event receive_event() { + Event event{}; + REQUIRE(RTOS::queue_receive(event_queue, &event, 0)); + return event; + } + + void complete_operation_failed() { + service->complete({SerialCommandResultKind::OperationFailed}); + poll(); + } +}; + +TEST_CASE("serial command parses envelope and recovers overlong lines", "[serial_command]") { + SerialCommandFixture fixture; + + fixture.channel.append_input("ignored\n#AG \r\n#AG HELP\r\n"); + fixture.poll(); + + REQUIRE(fixture.channel.responses.size() == 2); + CHECK(fixture.channel.responses[0] == "#AG ERROR EMPTY_COMMAND\n"); + CHECK(fixture.channel.responses[1] == + "#AG OK COMMANDS HELP GET_SERIAL SET_SLR " + "GET_SLR FACTORY_RESET\n"); + + fixture.channel.append_input(std::string(SERIAL_COMMAND_MAX_LINE_BYTES + 1, 'x') + + "\n#AG GET_SERIAL\n"); + fixture.drain_input(); + + Event event = fixture.receive_event(); + CHECK(event.type == EventType::SerialCommandRequest); + CHECK(event.serial_command_request.kind == SerialCommandKind::GetSerial); +} + +TEST_CASE("serial command rejects malformed grammar and maps SLR requests", "[serial_command]") { + SerialCommandFixture fixture; + + fixture.channel.append_input("#AG UNKNOWN\n#AG GET_SERIAL extra\n#AG SET_SLR TEMP nan 1\n" + "#AG SET_SLR OTHER 1 2\n#AG SET_SLR TEMP 1.1 -0.3\n"); + fixture.drain_input(); + + REQUIRE(fixture.channel.responses.size() == 4); + CHECK(fixture.channel.responses[0] == "#AG ERROR INVALID_COMMAND\n"); + CHECK(fixture.channel.responses[1] == "#AG ERROR INVALID_ARGUMENT\n"); + CHECK(fixture.channel.responses[2] == "#AG ERROR INVALID_ARGUMENT\n"); + CHECK(fixture.channel.responses[3] == "#AG ERROR INVALID_ARGUMENT\n"); + + Event event = fixture.receive_event(); + CHECK(event.serial_command_request.kind == SerialCommandKind::SetTemperatureSlr); + CHECK(event.serial_command_request.linear_correction.algorithm == + LinearCorrectionAlgorithm::Custom); + CHECK(event.serial_command_request.linear_correction.scaling_factor == 1.1f); + CHECK(event.serial_command_request.linear_correction.intercept == -0.3f); +} + +TEST_CASE("serial command maps every correction target and factory reset", "[serial_command]") { + SerialCommandFixture fixture; + + fixture.channel.append_input("#AG SET_SLR PM 1.2 0.4\n"); + fixture.drain_input(); + Event event = fixture.receive_event(); + CHECK(event.serial_command_request.kind == SerialCommandKind::SetPmSlr); + CHECK(event.serial_command_request.pm25_correction.algorithm == + Pm25CorrectionAlgorithm::CustomViaPm25Raw); + CHECK(event.serial_command_request.pm25_correction.scaling_factor == 1.2f); + CHECK(event.serial_command_request.pm25_correction.intercept == 0.4f); + fixture.complete_operation_failed(); + + fixture.channel.append_input("#AG SET_SLR HUM 0.9 2\n"); + fixture.drain_input(); + event = fixture.receive_event(); + CHECK(event.serial_command_request.kind == SerialCommandKind::SetHumiditySlr); + CHECK(event.serial_command_request.linear_correction.algorithm == + LinearCorrectionAlgorithm::Custom); + fixture.complete_operation_failed(); + + fixture.channel.append_input("#AG GET_SLR PM\n"); + fixture.drain_input(); + event = fixture.receive_event(); + CHECK(event.serial_command_request.kind == SerialCommandKind::GetPmSlr); + fixture.complete_operation_failed(); + + fixture.channel.append_input("#AG GET_SLR TEMP\n"); + fixture.drain_input(); + event = fixture.receive_event(); + CHECK(event.serial_command_request.kind == SerialCommandKind::GetTemperatureSlr); + fixture.complete_operation_failed(); + + fixture.channel.append_input("#AG FACTORY_RESET\n"); + fixture.drain_input(); + event = fixture.receive_event(); + CHECK(event.serial_command_request.kind == SerialCommandKind::FactoryReset); +} + +TEST_CASE("serial command enforces one command in flight and formats results", "[serial_command]") { + SerialCommandFixture fixture; + + fixture.channel.append_input("#AG GET_SERIAL\n#AG HELP\n"); + fixture.poll(); + CHECK(fixture.channel.responses == std::vector{"#AG ERROR BUSY\n"}); + + Event event = fixture.receive_event(); + CHECK(event.serial_command_request.kind == SerialCommandKind::GetSerial); + + SerialCommandResult serial_result{}; + serial_result.kind = SerialCommandResultKind::Serial; + std::strncpy(serial_result.serial, "AABBCCDDEEFF", sizeof(serial_result.serial) - 1); + fixture.service->complete(serial_result); + fixture.poll(); + + REQUIRE(fixture.channel.responses.size() == 2); + CHECK(fixture.channel.responses[1] == "#AG OK SERIAL AABBCCDDEEFF\n"); + + fixture.channel.append_input("#AG GET_SLR HUM\n"); + fixture.poll(); + event = fixture.receive_event(); + CHECK(event.serial_command_request.kind == SerialCommandKind::GetHumiditySlr); + + SerialCommandResult slr_result{}; + slr_result.kind = SerialCommandResultKind::SlrHumidity; + slr_result.linear_correction.scaling_factor = 1.1f; + slr_result.linear_correction.intercept = -0.3f; + fixture.service->complete(slr_result); + fixture.poll(); + + CHECK(fixture.channel.responses[2] == "#AG OK SLR HUM 1.100000 -0.300000\n"); +} + +TEST_CASE("serial command reports failed event admission", "[serial_command]") { + SerialCommandFixture fixture(1); + Event occupied{}; + occupied.type = EventType::InactivityTimeout; + REQUIRE(RTOS::queue_send(fixture.event_queue, &occupied, 0)); + + fixture.channel.append_input("#AG FACTORY_RESET\n"); + fixture.poll(); + + CHECK(fixture.channel.responses == std::vector{"#AG ERROR OPERATION_FAILED\n"}); +} diff --git a/scripts/ago_serial_command.py b/scripts/ago_serial_command.py new file mode 100644 index 0000000..c154cc0 --- /dev/null +++ b/scripts/ago_serial_command.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Send one AirGradient Go manufacturing command over USB Serial/JTAG. + +The Go serial command protocol shares its transport with normal firmware logs. +This client ignores ordinary log lines and prints the first ``#AG`` response. +The device must already be in manufacturing mode. + +Requirements: + pip install pyserial + +Usage: + # Confirm the connection and read the board serial. + python scripts/ago_serial_command.py /dev/ttyACM0 GET_SERIAL + + # Read or set a correction. + python scripts/ago_serial_command.py /dev/ttyACM0 GET_SLR TEMP + python scripts/ago_serial_command.py /dev/ttyACM0 SET_SLR TEMP 1.1 -0.3 + + # Show interleaved logs while waiting for a response. + python scripts/ago_serial_command.py --show-logs /dev/ttyACM0 HELP +""" + +from __future__ import annotations + +import argparse +import sys +import time +from typing import Sequence + +PROTOCOL_PREFIX = "#AG " +LINE_FEED = b"\n" +DEFAULT_BAUD_RATE = 115200 +DEFAULT_TIMEOUT_SECONDS = 5.0 +READ_TIMEOUT_SECONDS = 0.1 +DEVICE_ERROR_EXIT_CODE = 2 + + +def _build_request(command_tokens: Sequence[str]) -> bytes: + """Validate CLI tokens and return one newline-delimited protocol request.""" + tokens = list(command_tokens) + if tokens and tokens[0] == "#AG": + tokens = tokens[1:] + if not tokens: + raise ValueError("a command is required") + if any(not token or any(char.isspace() for char in token) for token in tokens): + raise ValueError("each command field must be one non-whitespace argument") + + try: + return (PROTOCOL_PREFIX + " ".join(tokens)).encode("ascii") + LINE_FEED + except UnicodeEncodeError as exc: + raise ValueError("command fields must use ASCII protocol tokens") from exc + + +def _read_response(serial_port: object, timeout_seconds: float, show_logs: bool) -> str: + """Return the first complete protocol response, ignoring ordinary logs.""" + deadline = time.monotonic() + timeout_seconds + pending = bytearray() + + while time.monotonic() < deadline: + chunk = serial_port.read(256) + if not chunk: + continue + pending.extend(chunk) + + while True: + line_end = pending.find(LINE_FEED) + if line_end < 0: + break + raw_line = bytes(pending[:line_end]) + del pending[: line_end + 1] + line = raw_line.rstrip(b"\r").decode("utf-8", errors="replace") + if line.startswith(PROTOCOL_PREFIX): + return line + if show_logs: + print(line, file=sys.stderr) + + raise TimeoutError(f"no #AG response within {timeout_seconds:g} seconds") + + +def _run(args: argparse.Namespace) -> int: + try: + import serial + except ImportError as exc: + raise RuntimeError("pyserial is required; install it with: pip install pyserial") from exc + + request = _build_request(args.command) + try: + with serial.Serial( + port=args.port, + baudrate=args.baud_rate, + timeout=READ_TIMEOUT_SECONDS, + write_timeout=args.timeout, + ) as serial_port: + serial_port.reset_input_buffer() + serial_port.write(request) + serial_port.flush() + response = _read_response(serial_port, args.timeout, args.show_logs) + except serial.SerialException as exc: + raise RuntimeError(f"serial transport failed: {exc}") from exc + + print(response) + return DEVICE_ERROR_EXIT_CODE if response.startswith("#AG ERROR ") else 0 + + +def main() -> None: + parser = argparse.ArgumentParser( + description=( + "Send one #AG manufacturing command to an AirGradient Go USB " + "Serial/JTAG device and print its response." + ), + ) + parser.add_argument("port", help="USB Serial/JTAG device path, for example /dev/ttyACM0.") + parser.add_argument( + "command", + nargs=argparse.REMAINDER, + help="Protocol command and arguments, for example: SET_SLR TEMP 1.1 -0.3.", + ) + parser.add_argument( + "--baud-rate", + type=int, + default=DEFAULT_BAUD_RATE, + help="Host serial baud rate required by pyserial (default: 115200; USB ignores it).", + ) + parser.add_argument( + "--timeout", + type=float, + default=DEFAULT_TIMEOUT_SECONDS, + help="Seconds to wait for one #AG response (default: 5).", + ) + parser.add_argument( + "--show-logs", + action="store_true", + help="Write interleaved non-protocol USB logs to stderr while waiting.", + ) + args = parser.parse_args() + + if args.baud_rate <= 0: + parser.error("--baud-rate must be positive") + if args.timeout <= 0: + parser.error("--timeout must be positive") + + try: + exit_code = _run(args) + except (RuntimeError, TimeoutError, ValueError) as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(1) + + sys.exit(exit_code) + + +if __name__ == "__main__": + main() From d5ff7cf9d578132b704ae17732789ff6f79748be Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Wed, 29 Jul 2026 02:54:15 +0300 Subject: [PATCH 2/6] refactor(go): union serial command payloads --- products/go/main/serial_command/serial_command.h | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/products/go/main/serial_command/serial_command.h b/products/go/main/serial_command/serial_command.h index bce5ac1..4e0bb7c 100644 --- a/products/go/main/serial_command/serial_command.h +++ b/products/go/main/serial_command/serial_command.h @@ -30,8 +30,10 @@ enum class SerialCommandKind : uint8_t { struct SerialCommandRequest { SerialCommandKind kind = SerialCommandKind::GetSerial; - Pm25Correction pm25_correction{}; - LinearCorrection linear_correction{}; + union { + Pm25Correction pm25_correction; + LinearCorrection linear_correction; + }; }; enum class SerialCommandResultKind : uint8_t { @@ -47,9 +49,11 @@ enum class SerialCommandResultKind : uint8_t { struct SerialCommandResult { SerialCommandResultKind kind = SerialCommandResultKind::OperationFailed; - char serial[SERIAL_COMMAND_MAX_SERIAL_BYTES]{}; - Pm25Correction pm25_correction{}; - LinearCorrection linear_correction{}; + union { + char serial[SERIAL_COMMAND_MAX_SERIAL_BYTES]; + Pm25Correction pm25_correction; + LinearCorrection linear_correction; + }; }; static_assert(std::is_trivially_copyable::value); From 2c45119954bfa493ed380f518351d27091f691c8 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Wed, 29 Jul 2026 04:01:39 +0300 Subject: [PATCH 3/6] fix(ble): skip bond deletion when inactive --- components/airgradient-ble/README.md | 3 ++- .../airgradient-ble/drivers/nimble_ble_server.cpp | 11 ++++++++--- components/airgradient-ble/hal/ble_server.h | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/components/airgradient-ble/README.md b/components/airgradient-ble/README.md index 7fe7a32..978533a 100644 --- a/components/airgradient-ble/README.md +++ b/components/airgradient-ble/README.md @@ -162,7 +162,8 @@ sequenceDiagram Note over App: bond keys persisted if BOND set ``` -`delete_all_bonds()` erases all stored pairing keys (factory reset). +`delete_all_bonds()` erases all stored pairing keys (factory reset) while BLE +is active. It is a safe no-op after BLE teardown. Bond persistence requires `CONFIG_BT_NIMBLE_NVS_PERSIST=y` in the product `sdkconfig.defaults`. diff --git a/components/airgradient-ble/drivers/nimble_ble_server.cpp b/components/airgradient-ble/drivers/nimble_ble_server.cpp index b99bc3d..1dd1789 100644 --- a/components/airgradient-ble/drivers/nimble_ble_server.cpp +++ b/components/airgradient-ble/drivers/nimble_ble_server.cpp @@ -7,12 +7,12 @@ #include "nimble_ble_server.h" +#include + #include #include #include -#include - namespace { // Maps AgBleProperty flags to the corresponding NIMBLE_PROPERTY bitmask. @@ -189,7 +189,12 @@ bool NimbleBleServer::set_security(AgBleIoCapability io_cap, uint8_t auth_flags) return true; } -bool NimbleBleServer::delete_all_bonds() { return NimBLEDevice::deleteAllBonds(); } +bool NimbleBleServer::delete_all_bonds() { + if (!NimBLEDevice::isInitialized()) { + return true; + } + return NimBLEDevice::deleteAllBonds(); +} void NimbleBleServer::deinit() { if (_server == nullptr) { diff --git a/components/airgradient-ble/hal/ble_server.h b/components/airgradient-ble/hal/ble_server.h index 0741967..31d3dd3 100644 --- a/components/airgradient-ble/hal/ble_server.h +++ b/components/airgradient-ble/hal/ble_server.h @@ -103,7 +103,8 @@ class AgBleServer { // the server is not initialised. virtual bool set_security(AgBleIoCapability io_cap, uint8_t auth_flags) = 0; - // Deletes all stored bond information. Useful for factory reset or + // Deletes all stored bond information when the BLE stack is active. Safe to + // call after deinit(), where it is a no-op. Useful for factory reset or // development. Returns false on failure. virtual bool delete_all_bonds() = 0; From 5892fa8f7b7ac30cc12fb833f9e1b272d07f4d98 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Wed, 29 Jul 2026 04:02:46 +0300 Subject: [PATCH 4/6] feat(go): retain corrections in manufacturing --- products/go/ARCHITECTURE.md | 10 +++--- products/go/README.md | 10 +++--- products/go/docs/orchestrator.md | 24 +++++++------ products/go/docs/serial_command_service.md | 4 ++- products/go/main/go_orchestrator.cpp | 15 +++++--- products/go/main/go_orchestrator.h | 2 +- products/go/tests/go_orchestrator.tests.cpp | 38 +++++++++++++++++++-- 7 files changed, 76 insertions(+), 27 deletions(-) diff --git a/products/go/ARCHITECTURE.md b/products/go/ARCHITECTURE.md index ee1a116..2fa5c56 100644 --- a/products/go/ARCHITECTURE.md +++ b/products/go/ARCHITECTURE.md @@ -654,10 +654,12 @@ short press on Button 2 (`ButtonBoot`) calls `enter_manufacturing_mode()`, which skips the guide and enters Stationary ephemerally via `change_mode(Stationary, persist=false)` — neither `onboarding_done` nor `operating_mode` is written to NVS. The runtime `_manufacturing_mode` flag -forces a `factory_reset()` at `shutdown()`, so any settings, Wi-Fi -credentials, or BLE bonds touched during testing are wiped before -power-off and the unit ships at defaults. Because nothing is persisted, a -plain reboot also returns to fresh onboarding. Button 2 long press remains +forces a cleanup reset at `shutdown()`: Wi-Fi credentials, routes, and all Go +settings except active measurement corrections are wiped before power-off. This +preserves production-configured PM, temperature, and humidity corrections while +the unit otherwise ships at defaults. BLE bond deletion is a safe no-op after +Stationary mode has torn down the BLE host. Because nothing else is persisted, +a plain reboot also returns to fresh onboarding. Button 2 long press remains factory reset. **Fast path** avoids GPS task, input task, and the full orchestrator for a diff --git a/products/go/README.md b/products/go/README.md index c35dde1..307053e 100644 --- a/products/go/README.md +++ b/products/go/README.md @@ -90,10 +90,12 @@ press of Button 2 (`PIN_BUTTON_BOOT`) skips the Getting Started guide and enters Stationary operating mode **ephemerally** — nothing is written to NVS. This lets the production team exercise the full Stationary path (Wi-Fi, cloud) without latching `onboarding_done`. The device tracks an internal -manufacturing flag and runs a full factory reset at shutdown, so any -settings, Wi-Fi credentials, or BLE bonds changed during testing are wiped -before power-off. A plain reboot likewise returns to fresh onboarding. -Button 2 long press remains factory reset. +manufacturing flag and runs a cleanup reset at shutdown: saved Wi-Fi +credentials, routes, and all Go settings except active measurement corrections +are wiped before power-off. This retains production-configured PM, temperature, +and humidity corrections. BLE bond deletion is a safe no-op once Stationary +mode has torn down the BLE host. A plain reboot likewise returns to fresh +onboarding. Button 2 long press remains factory reset. ### Cell Safety diff --git a/products/go/docs/orchestrator.md b/products/go/docs/orchestrator.md index cdf0db8..8361829 100644 --- a/products/go/docs/orchestrator.md +++ b/products/go/docs/orchestrator.md @@ -318,13 +318,14 @@ Events are dispatched by type: path. This is the orchestrator's **only** learning touch point — no tick, resume, verify, ship hook, or dashboard. See [`fg_learning.md`](fg_learning.md) 4. **Short press ButtonBoot while `!onboarding_done`** — - `enter_manufacturing_mode()`: skip the Getting Started guide and enter - Stationary ephemerally (`change_mode(Stationary, persist=false)`), so - production can test a fresh unit without latching `onboarding_done`. - Sets `_manufacturing_mode`, which forces a `factory_reset()` at - `shutdown()` so any settings / Wi-Fi / bonds changed during testing are - wiped before power-off. Nothing is persisted, so a reboot also returns - to fresh onboarding + `enter_manufacturing_mode()`: skip the Getting Started guide and enter + Stationary ephemerally (`change_mode(Stationary, persist=false)`), so + production can test a fresh unit without latching `onboarding_done`. + Sets `_manufacturing_mode`, which preserves active measurement corrections + but clears all other Go settings, routes, and Wi-Fi credentials at + `shutdown()`. BLE bond deletion is a safe no-op after Stationary has torn + down the BLE host. Nothing else is persisted, so a reboot also returns to + fresh onboarding 5. **Short press ButtonPower while `_setup_session_active` or `_boot_splash_active`** — suppressed (no lock toggle); the setup instructions or cold-boot splash stay visible @@ -474,9 +475,12 @@ client is connected, shows a snackbar, and returns success/failure. Calls `clear_data()`, writes default `GoSettings` to NVS (which zeros `disable_cloud` and `static_ip`), calls `WifiService::clear_credentials()` to erase all saved networks and reset online latches, -deletes all stored BLE bonds, resets runtime state back to Portable + -Idle + Locked, updates the display, and returns success/failure. The -caller reboots the ESP on success. +deletes all stored BLE bonds, resets runtime state back to Portable + Idle + +Locked, updates the display, and returns success/failure. Explicit factory reset +uses the full default settings, including no measurement corrections. When +manufacturing mode is active, factory reset instead retains the active +correction set. Bond deletion is a safe no-op after Stationary has torn down +the Go BLE service. The caller reboots the ESP on success. ### shutdown(reason) diff --git a/products/go/docs/serial_command_service.md b/products/go/docs/serial_command_service.md index a83772d..e623afe 100644 --- a/products/go/docs/serial_command_service.md +++ b/products/go/docs/serial_command_service.md @@ -43,7 +43,9 @@ The service is constructed during normal Go composition but remains inactive. The orchestrator calls `start()` only when the boot-button manufacturing path enters manufacturing mode. The mode and service remain active until reboot or power-off, including after `FACTORY_RESET`; factory reset returns the device to -Portable/Home without rebooting. +Portable/Home without rebooting. Because serial commands run only in +manufacturing mode, `FACTORY_RESET` retains active measurement corrections +while clearing all other reset state. ```mermaid stateDiagram-v2 diff --git a/products/go/main/go_orchestrator.cpp b/products/go/main/go_orchestrator.cpp index 0e1bf5d..11820d5 100644 --- a/products/go/main/go_orchestrator.cpp +++ b/products/go/main/go_orchestrator.cpp @@ -1970,7 +1970,9 @@ bool Orchestrator::clear_data() { } bool Orchestrator::factory_reset() { - AG_LOGI(TAG, "factory_reset"); + AG_LOGI(TAG, "factory_reset: preserve_corrections=%d", _manufacturing_mode); + + const MeasurementCorrections corrections = _settings.corrections; // Erase temporary cache data and delete all persisted route files. const bool data_cleared = clear_data(); @@ -1988,7 +1990,10 @@ bool Orchestrator::factory_reset() { return false; } - const GoSettings defaults{}; + GoSettings defaults{}; + if (_manufacturing_mode) { + defaults.corrections = corrections; + } if (!activate_settings_candidate(defaults, /*persist=*/true, /*force_persist=*/true)) { _svc.ui_manager.show_snackbar("Factory reset failed"); update_display(); @@ -2020,10 +2025,10 @@ void Orchestrator::save_tag(uint8_t tag_index, const char *tag_label) { void Orchestrator::shutdown(ShipModeRequest reason) { AG_LOGI(TAG, "shutdown (reason=%d)", static_cast(reason)); - // Manufacturing units ship clean: wipe any settings / Wi-Fi / bonds the - // production team changed while testing. + // Manufacturing units retain corrections but clear all other settings and + // Wi-Fi state changed while testing. if (_manufacturing_mode) { - AG_LOGI(TAG, "shutdown: manufacturing mode — factory reset before power off"); + AG_LOGI(TAG, "shutdown: manufacturing mode — reset before power off, preserving corrections"); factory_reset(); } diff --git a/products/go/main/go_orchestrator.h b/products/go/main/go_orchestrator.h index 595acbf..e8371f9 100644 --- a/products/go/main/go_orchestrator.h +++ b/products/go/main/go_orchestrator.h @@ -173,7 +173,7 @@ class Orchestrator { /// True once the boot-button manufacturing shortcut entered ephemeral /// Stationary (onboarding skipped, nothing persisted). On shutdown this - /// forces a factory_reset() so test units ship clean. + /// clears test state while retaining active measurement corrections. bool _manufacturing_mode = false; // --- Peripheral (hardware) test flow --- diff --git a/products/go/tests/go_orchestrator.tests.cpp b/products/go/tests/go_orchestrator.tests.cpp index 3ddc20f..eaf84e6 100644 --- a/products/go/tests/go_orchestrator.tests.cpp +++ b/products/go/tests/go_orchestrator.tests.cpp @@ -1384,6 +1384,11 @@ TEST_CASE("factory_reset: resets settings to defaults without keeping tracking s A::settings(orch).gps_mode = GpsMode::AlwaysOff; A::settings(orch).device_name = "custom-name"; A::settings(orch).configuration_control = ConfigurationControl::Local; + A::settings(orch).corrections.temperature = { + LinearCorrectionAlgorithm::Custom, + 1.1f, + -0.3f, + }; A::set_mode(orch, OperatingMode::Offline); f.local_api.publish_config_snapshot(A::settings(orch)); f.local_api.publish_wifi_rssi(-61); @@ -1401,6 +1406,7 @@ TEST_CASE("factory_reset: resets settings to defaults without keeping tracking s CHECK(A::settings(orch).gps_mode == GpsMode::OnWhenTracking); CHECK(A::settings(orch).device_name == "airgradient-go"); CHECK(A::settings(orch).configuration_control == ConfigurationControl::Both); + CHECK(A::settings(orch).corrections.temperature.algorithm == LinearCorrectionAlgorithm::None); CHECK(test_spy::cloud_set_fetch_enabled_count == 1); CHECK(test_spy::cloud_last_config_fetch_enabled); CHECK(A::mode(orch) == OperatingMode::Portable); @@ -1646,7 +1652,7 @@ TEST_CASE("manufacturing: second boot short-press arms a fuel-gauge learning run CHECK(writes["fs_i"] == 0); } -TEST_CASE("manufacturing: shutdown wipes settings via factory reset", +TEST_CASE("manufacturing: shutdown resets settings while preserving corrections", "[Orchestrator][manufacturing][shutdown]") { TestFixture f; auto orch = f.make_orchestrator(); @@ -1657,12 +1663,40 @@ TEST_CASE("manufacturing: shutdown wipes settings via factory reset", ALLOW_CALL(f.mock_config, erase(trompeloeil::_)).RETURN(ConfigStoreResult::OK); ALLOW_CALL(f.mock_config, commit()).RETURN(ConfigStoreResult::OK); A::set_manufacturing_mode(orch, true); + A::settings(orch).device_name = "manufacturing-name"; + A::settings(orch).corrections.pm25 = { + Pm25CorrectionAlgorithm::CustomViaPm25Raw, + 1.2f, + 0.4f, + true, + }; + A::settings(orch).corrections.temperature = { + LinearCorrectionAlgorithm::Custom, + 1.1f, + -0.3f, + }; + A::settings(orch).corrections.humidity = { + LinearCorrectionAlgorithm::Custom, + 0.9f, + 2.0f, + }; A::shutdown(orch); CHECK(test_spy::routes_cleared); // factory_reset ran - CHECK(test_spy::ble_delete_all_bonds_called); // bonds wiped + CHECK(test_spy::ble_delete_all_bonds_called); // bond cleanup attempted CHECK(test_spy::shutdown_called); // power-off still happened + CHECK(A::settings(orch).device_name == "airgradient-go"); + CHECK(A::settings(orch).corrections.pm25.algorithm == Pm25CorrectionAlgorithm::CustomViaPm25Raw); + CHECK(A::settings(orch).corrections.pm25.scaling_factor == 1.2f); + CHECK(A::settings(orch).corrections.pm25.intercept == 0.4f); + CHECK(A::settings(orch).corrections.pm25.use_epa2021); + CHECK(A::settings(orch).corrections.temperature.algorithm == LinearCorrectionAlgorithm::Custom); + CHECK(A::settings(orch).corrections.temperature.scaling_factor == 1.1f); + CHECK(A::settings(orch).corrections.temperature.intercept == -0.3f); + CHECK(A::settings(orch).corrections.humidity.algorithm == LinearCorrectionAlgorithm::Custom); + CHECK(A::settings(orch).corrections.humidity.scaling_factor == 0.9f); + CHECK(A::settings(orch).corrections.humidity.intercept == 2.0f); } TEST_CASE("manufacturing: shutdown without flag skips factory reset", From 032aec45dd40906405eeb5b492543a6dadd17268 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Mon, 3 Aug 2026 12:38:34 +0700 Subject: [PATCH 5/6] fix(ble): pause advertising before bond reset --- components/airgradient-ble/README.md | 3 ++- .../airgradient-ble/drivers/nimble_ble_server.cpp | 13 ++++++++++++- components/airgradient-ble/hal/ble_server.h | 6 +++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/components/airgradient-ble/README.md b/components/airgradient-ble/README.md index 978533a..a48517a 100644 --- a/components/airgradient-ble/README.md +++ b/components/airgradient-ble/README.md @@ -163,7 +163,8 @@ sequenceDiagram ``` `delete_all_bonds()` erases all stored pairing keys (factory reset) while BLE -is active. It is a safe no-op after BLE teardown. +is active. Advertising is paused while NimBLE removes bonds, then restored if +it was active. The operation is a safe no-op after BLE teardown. Bond persistence requires `CONFIG_BT_NIMBLE_NVS_PERSIST=y` in the product `sdkconfig.defaults`. diff --git a/components/airgradient-ble/drivers/nimble_ble_server.cpp b/components/airgradient-ble/drivers/nimble_ble_server.cpp index 1dd1789..7c65b3d 100644 --- a/components/airgradient-ble/drivers/nimble_ble_server.cpp +++ b/components/airgradient-ble/drivers/nimble_ble_server.cpp @@ -193,7 +193,18 @@ bool NimbleBleServer::delete_all_bonds() { if (!NimBLEDevice::isInitialized()) { return true; } - return NimBLEDevice::deleteAllBonds(); + + NimBLEAdvertising *advertising = NimBLEDevice::getAdvertising(); + const bool was_advertising = advertising != nullptr && advertising->isAdvertising(); + if (was_advertising && !NimBLEDevice::stopAdvertising()) { + return false; + } + + const bool bonds_deleted = NimBLEDevice::deleteAllBonds(); + if (was_advertising) { + (void)NimBLEDevice::startAdvertising(); + } + return bonds_deleted; } void NimbleBleServer::deinit() { diff --git a/components/airgradient-ble/hal/ble_server.h b/components/airgradient-ble/hal/ble_server.h index 31d3dd3..59dd879 100644 --- a/components/airgradient-ble/hal/ble_server.h +++ b/components/airgradient-ble/hal/ble_server.h @@ -103,9 +103,9 @@ class AgBleServer { // the server is not initialised. virtual bool set_security(AgBleIoCapability io_cap, uint8_t auth_flags) = 0; - // Deletes all stored bond information when the BLE stack is active. Safe to - // call after deinit(), where it is a no-op. Useful for factory reset or - // development. Returns false on failure. + // Deletes all stored bond information when the BLE stack is active. Active + // advertising is paused and restored around deletion. Safe to call after + // deinit(), where it is a no-op. Returns false on failure. virtual bool delete_all_bonds() = 0; // Creates and returns a service. Returns nullptr on failure. The returned From 9d5c5cb2236d734d2339156407fd61c4a2eb1847 Mon Sep 17 00:00:00 2001 From: samuelbles07 Date: Fri, 7 Aug 2026 10:26:13 +0700 Subject: [PATCH 6/6] fix(go): frame serial responses at line start --- products/go/docs/serial_command_service.md | 9 ++++++--- .../go/main/serial_command/serial_command.cpp | 10 +++++----- products/go/tests/serial_command.tests.cpp | 20 +++++++++---------- 3 files changed, 21 insertions(+), 18 deletions(-) diff --git a/products/go/docs/serial_command_service.md b/products/go/docs/serial_command_service.md index e623afe..2ac0ce0 100644 --- a/products/go/docs/serial_command_service.md +++ b/products/go/docs/serial_command_service.md @@ -58,8 +58,9 @@ stateDiagram-v2 On first activation, the USB channel installs the USB Serial/JTAG driver with 256-byte RX/TX rings, routes the existing VFS through that driver, and retains a write-only `/dev/secondary` descriptor. Each response is emitted by one VFS -`write()` call, so normal mirrored logs cannot split its bytes. The channel is -not installed at normal boot, never uses UART0, and is not uninstalled. +`write()` call. It starts with LF and ends with LF, so it terminates a partial +normal mirrored log line before emitting its `#AG` response line. The channel +is not installed at normal boot, never uses UART0, and is not uninstalled. The task uses a 3072-byte stack at priority 3 and waits up to 50 ms per USB RX read. This finite wait lets it poll the one-item result queue. A command is @@ -69,7 +70,9 @@ command receives `#AG ERROR BUSY` until the prior result is emitted. ### Protocol Messages are UTF-8 ASCII tokens terminated by LF. CRLF is accepted. Commands -and responses begin with `#AG` followed by one ASCII space; non-prefixed input is ignored. The receiver +begin with `#AG` followed by one ASCII space; non-prefixed input is ignored. +Responses begin with LF followed by `#AG` and end with LF. The leading LF +terminates any partial mirrored log line before the response. The receiver buffers at most 128 bytes per line and discards an overlong line through its next LF. Responses are bounded to 128 bytes. diff --git a/products/go/main/serial_command/serial_command.cpp b/products/go/main/serial_command/serial_command.cpp index 13be2e3..119f72b 100644 --- a/products/go/main/serial_command/serial_command.cpp +++ b/products/go/main/serial_command/serial_command.cpp @@ -14,7 +14,7 @@ constexpr size_t SERIAL_COMMAND_READ_BUFFER_BYTES = 64; constexpr size_t SERIAL_COMMAND_MAX_TOKENS = 5; constexpr char HELP_RESPONSE[] = - "#AG OK COMMANDS HELP GET_SERIAL SET_SLR " + "\n#AG OK COMMANDS HELP GET_SERIAL SET_SLR " "GET_SLR FACTORY_RESET\n"; static_assert(sizeof(HELP_RESPONSE) <= SERIAL_COMMAND_MAX_RESPONSE_BYTES); @@ -310,7 +310,7 @@ void SerialCommandService::_complete_result(const SerialCommandResult &result) { char serial[SERIAL_COMMAND_MAX_SERIAL_BYTES]; std::memcpy(serial, result.serial, sizeof(serial)); serial[sizeof(serial) - 1] = '\0'; - _write_response("#AG OK SERIAL %s\n", serial); + _write_response("\n#AG OK SERIAL %s\n", serial); return; } case SerialCommandResultKind::SlrPm: @@ -323,12 +323,12 @@ void SerialCommandService::_complete_result(const SerialCommandResult &result) { const float intercept = result.kind == SerialCommandResultKind::SlrPm ? result.pm25_correction.intercept : result.linear_correction.intercept; - _write_response("#AG OK SLR %s %.6f %.6f\n", target, static_cast(scaling_factor), + _write_response("\n#AG OK SLR %s %.6f %.6f\n", target, static_cast(scaling_factor), static_cast(intercept)); return; } case SerialCommandResultKind::Reset: - _write_response("#AG OK RESET\n"); + _write_response("\n#AG OK RESET\n"); return; case SerialCommandResultKind::SlrNotSet: _write_error("SLR_NOT_SET"); @@ -343,7 +343,7 @@ void SerialCommandService::_complete_result(const SerialCommandResult &result) { } void SerialCommandService::_write_error(const char *error_code) { - _write_response("#AG ERROR %s\n", error_code); + _write_response("\n#AG ERROR %s\n", error_code); } void SerialCommandService::_write_response(const char *format, ...) { diff --git a/products/go/tests/serial_command.tests.cpp b/products/go/tests/serial_command.tests.cpp index f1f796e..5b08ded 100644 --- a/products/go/tests/serial_command.tests.cpp +++ b/products/go/tests/serial_command.tests.cpp @@ -96,9 +96,9 @@ TEST_CASE("serial command parses envelope and recovers overlong lines", "[serial fixture.poll(); REQUIRE(fixture.channel.responses.size() == 2); - CHECK(fixture.channel.responses[0] == "#AG ERROR EMPTY_COMMAND\n"); + CHECK(fixture.channel.responses[0] == "\n#AG ERROR EMPTY_COMMAND\n"); CHECK(fixture.channel.responses[1] == - "#AG OK COMMANDS HELP GET_SERIAL SET_SLR " + "\n#AG OK COMMANDS HELP GET_SERIAL SET_SLR " "GET_SLR FACTORY_RESET\n"); fixture.channel.append_input(std::string(SERIAL_COMMAND_MAX_LINE_BYTES + 1, 'x') + @@ -118,10 +118,10 @@ TEST_CASE("serial command rejects malformed grammar and maps SLR requests", "[se fixture.drain_input(); REQUIRE(fixture.channel.responses.size() == 4); - CHECK(fixture.channel.responses[0] == "#AG ERROR INVALID_COMMAND\n"); - CHECK(fixture.channel.responses[1] == "#AG ERROR INVALID_ARGUMENT\n"); - CHECK(fixture.channel.responses[2] == "#AG ERROR INVALID_ARGUMENT\n"); - CHECK(fixture.channel.responses[3] == "#AG ERROR INVALID_ARGUMENT\n"); + CHECK(fixture.channel.responses[0] == "\n#AG ERROR INVALID_COMMAND\n"); + CHECK(fixture.channel.responses[1] == "\n#AG ERROR INVALID_ARGUMENT\n"); + CHECK(fixture.channel.responses[2] == "\n#AG ERROR INVALID_ARGUMENT\n"); + CHECK(fixture.channel.responses[3] == "\n#AG ERROR INVALID_ARGUMENT\n"); Event event = fixture.receive_event(); CHECK(event.serial_command_request.kind == SerialCommandKind::SetTemperatureSlr); @@ -175,7 +175,7 @@ TEST_CASE("serial command enforces one command in flight and formats results", " fixture.channel.append_input("#AG GET_SERIAL\n#AG HELP\n"); fixture.poll(); - CHECK(fixture.channel.responses == std::vector{"#AG ERROR BUSY\n"}); + CHECK(fixture.channel.responses == std::vector{"\n#AG ERROR BUSY\n"}); Event event = fixture.receive_event(); CHECK(event.serial_command_request.kind == SerialCommandKind::GetSerial); @@ -187,7 +187,7 @@ TEST_CASE("serial command enforces one command in flight and formats results", " fixture.poll(); REQUIRE(fixture.channel.responses.size() == 2); - CHECK(fixture.channel.responses[1] == "#AG OK SERIAL AABBCCDDEEFF\n"); + CHECK(fixture.channel.responses[1] == "\n#AG OK SERIAL AABBCCDDEEFF\n"); fixture.channel.append_input("#AG GET_SLR HUM\n"); fixture.poll(); @@ -201,7 +201,7 @@ TEST_CASE("serial command enforces one command in flight and formats results", " fixture.service->complete(slr_result); fixture.poll(); - CHECK(fixture.channel.responses[2] == "#AG OK SLR HUM 1.100000 -0.300000\n"); + CHECK(fixture.channel.responses[2] == "\n#AG OK SLR HUM 1.100000 -0.300000\n"); } TEST_CASE("serial command reports failed event admission", "[serial_command]") { @@ -213,5 +213,5 @@ TEST_CASE("serial command reports failed event admission", "[serial_command]") { fixture.channel.append_input("#AG FACTORY_RESET\n"); fixture.poll(); - CHECK(fixture.channel.responses == std::vector{"#AG ERROR OPERATION_FAILED\n"}); + CHECK(fixture.channel.responses == std::vector{"\n#AG ERROR OPERATION_FAILED\n"}); }