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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion components/airgradient-ble/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
22 changes: 19 additions & 3 deletions components/airgradient-ble/drivers/nimble_ble_server.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@

#include "nimble_ble_server.h"

#include <cstdio>

#include <esp_random.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>

#include <cstdio>

namespace {

// Maps AgBleProperty flags to the corresponding NIMBLE_PROPERTY bitmask.
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 3 additions & 2 deletions components/airgradient-ble/hal/ble_server.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 6 additions & 4 deletions products/go/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions products/go/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions products/go/docs/measurement_corrections.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
24 changes: 14 additions & 10 deletions products/go/docs/orchestrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down
111 changes: 111 additions & 0 deletions products/go/docs/serial_command_service.md
Original file line number Diff line number Diff line change
@@ -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 <PM\|TEMP\|HUM> <scale> <intercept> GET_SLR <PM\|TEMP\|HUM> FACTORY_RESET` | `INVALID_ARGUMENT` |
| `#AG GET_SERIAL` | `#AG OK SERIAL <serial>` | `INVALID_ARGUMENT` |
| `#AG SET_SLR <target> <scale> <intercept>` | `#AG OK SLR <target> <scale> <intercept>` | `INVALID_ARGUMENT`, `OPERATION_FAILED` |
| `#AG GET_SLR <target>` | `#AG OK SLR <target> <scale> <intercept>` | `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.
3 changes: 3 additions & 0 deletions products/go/main/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions products/go/main/go_app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions products/go/main/go_events.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 ---

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
};
};

Expand Down
Loading
Loading