diff --git a/components/airgradient-ble/README.md b/components/airgradient-ble/README.md index 7fe7a32..a48517a 100644 --- a/components/airgradient-ble/README.md +++ b/components/airgradient-ble/README.md @@ -162,7 +162,9 @@ 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. 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 b99bc3d..7c65b3d 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,23 @@ 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; + } + + 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() { if (_server == nullptr) { diff --git a/components/airgradient-ble/hal/ble_server.h b/components/airgradient-ble/hal/ble_server.h index 0741967..59dd879 100644 --- a/components/airgradient-ble/hal/ble_server.h +++ b/components/airgradient-ble/hal/ble_server.h @@ -103,8 +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. 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 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 b4a4cbe..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 @@ -165,6 +167,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/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 new file mode 100644 index 0000000..2ac0ce0 --- /dev/null +++ b/products/go/docs/serial_command_service.md @@ -0,0 +1,111 @@ +# 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. Because serial commands run only in +manufacturing mode, `FACTORY_RESET` retains active measurement corrections +while clearing all other reset state. + +```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. 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 +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 +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. + +| 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..11820d5 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); } @@ -1868,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(); @@ -1886,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(); @@ -1918,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 082e9d5..e8371f9 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) @@ -171,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 --- @@ -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..119f72b --- /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[] = + "\n#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("\n#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("\n#AG OK SLR %s %.6f %.6f\n", target, static_cast(scaling_factor), + static_cast(intercept)); + return; + } + case SerialCommandResultKind::Reset: + _write_response("\n#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("\n#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..4e0bb7c --- /dev/null +++ b/products/go/main/serial_command/serial_command.h @@ -0,0 +1,118 @@ +#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; + union { + 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; + union { + 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..eaf84e6 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); @@ -1373,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); @@ -1390,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); @@ -1635,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(); @@ -1646,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", @@ -4799,6 +4844,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 +4888,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..5b08ded --- /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] == "\n#AG ERROR EMPTY_COMMAND\n"); + CHECK(fixture.channel.responses[1] == + "\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') + + "\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] == "\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); + 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{"\n#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] == "\n#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] == "\n#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{"\n#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()