Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

The ROHI logo

ROHI (Robonomics Open Hardware Initiative) SDK is a project offering libraries and firmware to build IoT devices powered by Robonomics network - a Web3 cloud platform designed to manage IoT devices and robotics.

SDK Features

Where to start

The book explains architecture, contracts and workflows; exact Rust APIs are in the generated rustdoc (cargo doc --no-deps).

The following sections assume basic Rust knowledge. If you’re unsure about any language aspects, please refer to the Rust book.

Contributing

ROHI SDK is free and open source. You can find the source code on GitHub, where issues and feature requests can be posted on the issue tracker. The project relies on community contributions to fix bugs and add features - if you’d like to help, please consider opening a pull request.

License

The ROHI SDK source and documentation are released under the Apache v2.0.

What is the ROHI SDK?

ROHI (Robonomics Open Hardware Initiative) SDK is a set of Rust libraries, firmware and desktop tools for building IoT devices powered by the Robonomics network. It targets the ESP32-C3 and ESP32-C6 (RISC-V) and is built on:

Embassy            async executor, time, sync primitives
   ↓
esp-rtos           scheduler / time base required by the radio
   ↓
esp-hal / esp-radio  peripherals, Wi-Fi, BLE

ROHI does not introduce another runtime or RTOS abstraction. It adds its own SDK contracts on top: typed sensor measurements, a transactional configuration store, a Wi-Fi control plane, a portable IP data plane, a BLE control protocol, and the rules that make them safe to combine.

What is in the box

AreaWhat you getRead
SensorsTyped measurements, drivers (BMx280, SCD4x, SDS011, noise), runtime, MeasurementStoreSensor HAL, Sensors
ConfigurationCBOR, A/B-slot, power-fail-safe store of independent typed sections; users persist only their overrides of the firmware defaultsConfiguration store
NetworkingWi-Fi control plane, DNS/TCP data plane, ESP backendNetworking
ServicesConfig sections, live status (state, health, issues) and actions of every module, discovered by nameService pipeline, Services and status
ControlGeneric local BLE control protocol, rohi desktop clientControl plane, The rohi tool
LocationWhere the device is (configured now, GNSS later), sent in telemetryLocation
IdentityProvisioned Ed25519 device identity and signerDevice identity
FirmwareAltruist Urban, a complete product built from the aboveBoards

Design principles

  • Portable core, thin platform crates. Everything except *-esp crates, the firmware and examples builds and tests on the host. See Portability.
  • One owner per stateful service. Other tasks use bounded handles. See Runtime ownership.
  • Bounded and static. Application code does not allocate; every queue and buffer has a documented limit chosen by the product. See Memory model.
  • Cancellation is normal. Public async APIs document their cancellation semantics. See Async and cancellation.
  • Persistent data is an ABI. See Persistent ABI.

Reading path

  1. Installation, Workspace, Build and test.
  2. First device: run a sensor example on hardware.
  3. Architecture and Crate map: where things live.
  4. The concepts, then the SDK component pages you need, then the guides.
  5. Reference and Validation when you need exact numbers or want to prove a change.

Exact Rust APIs are in the generated API documentation (cargo doc --no-deps, published on docs.rs for released crates); this book explains architecture, contracts and workflows.

Installation

For blazing-fast dependency installation, use the Nix package manager. If you already have Nix, skip to the Nix Way section.

Clone the SDK repository first.

git clone https://github.com/akagi-dev/rohi-sdk && cd rohi-sdk

Prerequisites

Rust & Cargo

Use the rustup.rs script for a quick Rust compiler installation.

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

The required toolchain, components and both RISC-V targets (riscv32imc-unknown-none-elf for the ESP32-C3 and riscv32imac-unknown-none-elf for the ESP32-C6) are listed in rust-toolchain.toml. Run rustup inside the repository to install them (add rustup to your PATH if you’ve just installed it).

rustup toolchain install

Espflash

This tool is required for flashing ESP hardware. The SDK prepares the firmware and launches the flashing process, but the espflash binary must be available in your PATH. For setup details, see the official installation instructions.

Nix Way

The repository is a Nix flake. The development shell provides the pinned Rust toolchain, espflash, mdbook and the linker, nothing else has to be installed.

nix develop

Verify the installation

Build and test the SDK as described in Build and test: cargo test on the host, and cargo check-esp32c3 / cargo check-esp32c6 for the two chips. Documentation tooling (mdbook, link check) is covered in Documentation style and policy; the Nix shell provides it.

Workspace layout

rohi-sdk/
├── crates/
│   ├── rohi-hal, rohi-hal-esp                 sensors, measurements, runtime / ESP board backend
│   ├── rohi-config, -derive, -esp             configuration store / derive macro / ESP flash backend
│   ├── rohi-net, rohi-net-esp                 Wi-Fi control plane, IP data plane, BLE transport / ESP backend
│   ├── rohi-time, rohi-time-ntp               synchronized wall-clock time and its NTP source
│   ├── rohi-identity                          device identity: Ed25519 section and signer
│   └── rohi-control-proto, -control, -ble     control protocol: schema / dispatcher / BLE binding
├── firmwares/altruist-urban                   product firmware
├── examples                                   one hardware example per sensor, config self-test
├── tools/rohi                                 desktop client library and CLI
└── docs                                       this book (mdBook)

The crate responsibilities and the allowed dependency directions are in Crate map.

Default members and targets

cargo test and cargo clippy at the repository root cover the portable crates and desktop tools only (default-members). Firmware and examples are built per chip with the aliases from .cargo/config.toml; the repository has no default embedded target. The chip is always selected explicitly by the esp32c3 or esp32c6 feature, never globally. See Targets and features.

Toolchain

rust-toolchain.toml pins the toolchain, components and both RISC-V targets. The Nix flake provides the same toolchain plus espflash, mdbook, protoc and the other tools, see Installation.

Build and test

All commands run from the repository root (inside nix develop if you use Nix). The authoritative description of targets, chip features and why they are never combined is Targets and features.

On the host

cargo test                 # portable crates, framing, dispatcher, rohi client end to end
cargo fmt --all -- --check
cargo clippy --all-targets -- -D warnings

No board is needed. What the host tests cover and what they cannot is in Host testing.

For the ESP chips

cargo check-esp32c3        # ESP32-C3 (riscv32imc-unknown-none-elf)
cargo check-esp32c6        # ESP32-C6 (riscv32imac-unknown-none-elf)
cargo build-esp32c3        # release build
cargo build-esp32c6

Flash and run

Connect the board via USB:

cargo run --release -p altruist-urban --features esp32c3 --target riscv32imc-unknown-none-elf
# ESP32-C6
cargo run --release -p altruist-urban --features esp32c6 --target riscv32imac-unknown-none-elf

The runner flashes the chip’s partition table, which reserves the configuration region (Flash layout). Examples are built the same way with -p rohi-examples --features esp32c3 --target riscv32imc-unknown-none-elf --bin example-<name>.

Documentation

mdbook build docs          # the book
mdbook test docs           # plain Rust snippets of the book
cargo doc --no-deps        # API reference of the host crates

See Documentation style and policy for the checks that CI runs.

Next

First device runs a sensor example on hardware; Hardware testing lists what is validated on devices.

First device: Air Quality Terminal

This section assumes you are using the Nix package manager (nix develop). If you have a different environment, don’t worry—most commands will be the same, except you won’t need to enter the Nix shell first.

If you have successfully installed the SDK, let’s understand how the Altruist examples work step by step. The Altruist board has one example per supported sensor:

ExampleSensor
example-bmx280BMP280 / BME280 pressure, temperature, humidity
example-scd4xSCD40 / SCD41 CO₂, temperature, humidity
example-sds011SDS011 particulate matter (PM2.5 / PM10)
example-noiseI²S microphone noise level
example-all-sensorsAll of the above, concurrently

We’ll start from the sds011.rs example, it’s based on a particulate matter sensor. One SDS011 cycle takes about two minutes: the sensor wakes up, warms up, takes several samples, and the aggregated PM2.5 / PM10 is printed before the sensor goes back to sleep.

Explore the code

///////////////////////////////////////////////////////////////////////////////
///////////////////////////////////////////////////////////////////////////////
#![no_std]
#![no_main]

//! SDS011 example: prints PM2.5 and PM10 once per duty cycle (about 2 minutes).

use altruist_urban::tasks::sensors::sds011_task;
use esp_backtrace as _;
use rohi_examples::{print_events_task, run};

esp_bootloader_esp_idf::esp_app_desc!();

#[esp_hal::main]
fn main() -> ! {
    run(|spawner, board| {
        spawner.spawn(print_events_task().unwrap());
        spawner.spawn(sds011_task(board.sds011_uart).unwrap());
    })
}

no_std

The file starts with no_* declarations, which is typical for bare-metal software since there’s no operating system or standard library available by default.

Main task

main is a plain #[esp_hal::main] function. run() configures logging, the esp-rtos runtime that provides the time base of the Embassy Framework (with a small heap for its task stacks) and the board (I²C, UART and I²S pins are defined in crates/hal-esp/src/board/altruist_urban/), then starts the Embassy executor, which spawns the two tasks below. See the Embassy Docs for details.

Sensor task and events

sds011_task runs the SDS011 driver in a Runner that handles probing, polling and recovery. Drivers know nothing about networks or serialization: they publish typed SensorEvents (measurements, state changes, errors) into a bounded queue; a successful measurement alone marks the sensor Ready. print_events_task consumes them and prints to the terminal. The production firmware has a single task that applies the events to a MeasurementStore instead. See Sensor HAL (rohi-hal).

You’ll notice multiple await calls. This instruction puts the CPU to sleep until a waiting event occurs—whether it’s a time interval for a timer or reading measurements from a peripheral device. Rather than running in an infinite loop checking conditions, the CPU enters a low-power mode to conserve energy and resources. During await periods, other tasks can use the CPU time to execute their operations.

Flashing

Flashing firmware is quite simple—just launch it using cargo as you would run any Rust binary.

cargo run --release -p rohi-examples --features esp32c3 --target riscv32imc-unknown-none-elf --bin example-sds011

Behind the scenes, several processes occur:

  1. The Rust compiler creates a RISC-V binary;
  2. espflash detects the ESP board and flashes the firmware;
  3. espflash connects to the serial interface and displays logs.

Talk to the device

Once the firmware runs, the rohi tool finds it over Bluetooth LE and shows its status, sensors and Wi-Fi.

The rohi tool

rohi is the desktop command line for ROHI devices. It finds a device over Bluetooth LE, shows the status of its services, reads and changes its configuration, and runs its actions. It is the quickest way to check that a flashed device (see First device) is alive and talking.

rohi is a thin layer over the Device API of the rohi library, and the command tree knows no device module: status, config and action take the names of services, sections and actions that the device lists, and parse values with the field descriptions the device sends (type, unit, enum variants; the wire carries ids and numbers, the tool translates them with the schema). A new firmware module appears in rohi without changing the tool. The tool owns only the terminal side: arguments, device selection, prompts, output formats and configuration files.

The behaviour described here is covered by host tests against an in-memory device. It has not been exercised with a wide range of adapters and operating systems.

Install and run

rohi is a package of the workspace, tools/rohi. From the repository root (inside nix develop if you use Nix):

cargo install --path tools/rohi     # installs `rohi` into ~/.cargo/bin
cargo run -p rohi -- status         # or run it without installing

On Linux the build needs libdbus and pkg-config (provided by nix develop) and protoc; see Targets and features. Bluetooth must be enabled, and the device must be advertising and not connected to another client: a device accepts a single connection at a time.

rohi --help and rohi <COMMAND> --help describe every command with examples. Keep the tool and the firmware from the same revision: the control protocol is under development and a stale rohi can show an incomplete view of a newer device (for example fewer fields of a section).

First steps

rohi scan                  # find nearby ROHI devices
rohi status                # device health and every service
rohi status sensors        # one service in detail
rohi config                # the configuration sections of the device
rohi action                # what the services can do
$ rohi status
Device:  altruist-urban-0.4.0 (urban-esp32c3)
Uptime:  2h 14m
Health:  ok

SERVICE       STATE    HEALTH  READY  SINCE
network        running  ok      yes    2h 14m
connectivity  running  ok      yes    34s
...

What the columns mean and how status differs from configuration is explained in Services and status.

Choose a device

SituationWhat rohi does
One device founduses it
Several devices, interactive terminalshows a numbered list on stderr and asks
Several devices, no terminalfails and lists them; it never picks one for you
--device <DEVICE>uses the device that matches; fails if none or more than one does

--device (-d) accepts the exact platform ID, the exact advertised name, or a unique prefix or suffix of either (A12F for ALTRUIST-URBAN-A12F). Setting ROHI_DEVICE is the same as passing --device.

rohi -d A12F status
export ROHI_DEVICE=ALTRUIST-URBAN-A12F

The value may also be a URI, so the transport is explicit: ble://ALTRUIST-URBAN-A12F. Without a scheme the transport follows from the content: host:port is TCP, anything else is a Bluetooth name or ID. tcp://HOST:PORT is accepted by the parser but not implemented yet: rohi reports that the transport is not supported.

--scan-timeout <SECONDS> bounds the search for the device and --timeout <SECONDS> the wait for each response. A scan without --device keeps looking for a couple of seconds after the first device appears, so a second device can show up.

Scripting

Command results go to stdout. Prompts, progress, warnings and diagnostics go to stderr, so redirects and pipes stay clean. -o json (--output json) prints a JSON document with a stable, versioned schema instead of text:

rohi -d A12F -o json status network | jq -r '.values.ip'
rohi -d A12F -o json status sensors | jq '.values["scd4x.co2"]'
rohi -d A12F -o json status | jq -r '.services[] | select(.health != "ok") | .service'
rohi -d A12F -o json config wifi | jq '.overridden'   # {"section", "values", "overridden": [field names]}

Every JSON document has "schema_version": 1. The schemas are owned by the command line, not by the library types. A closed pipe (rohi action | head -1) ends the command quietly.

Exit codeMeaning
0success
1failure of the device, the link or the command (including a declined prompt)
2command line usage error
130interrupted with Ctrl-C

Failures print an error: line and, where there is something to try, hint: lines:

$ rohi status
error: no ROHI device found

hint: make sure Bluetooth is enabled and the device is advertising
hint: try `rohi scan`

Destructive commands (config reset, factory-reset, config apply) ask for confirmation on a terminal. Without a terminal they fail with confirmation required in non-interactive mode unless --yes is passed. Secrets are never accepted as command line arguments: a field or argument that is a secret is read with NAME=- from stdin, or from a hidden prompt on a terminal.

Status

rohi status                       # device health and one row per service: one compact request
rohi status network                  # one service: values with units, active issues
rohi status --watch               # reprint when something observable changed (Ctrl-C to stop)
rohi status network --watch --interval 5
rohi -o json status network

Services are discovered from the device, so rohi status <name> works for any service the summary lists. Without a service the output is the summary table; with -o json it is { "device": .., "uptime_ms": .., "health": .., "services": [..] }. See Services and status for state, health, readiness, issues and revisions.

Configuration

rohi config works on the sections the device lists. The configuration of a section is its effective configuration: the defaults of the firmware plus the overrides you set explicitly. Fields are typed and described by the device; values are parsed against that description (true/false, numbers, text, hex for bytes, a variant name for enums) and checked again by the device.

rohi config                                        # sections and their versions
rohi config connectivity                           # the effective values; secrets are shown as <redacted>
rohi config connectivity --help                    # fields: type, unit, allowed values (from the device)

rohi config connectivity host=ingress.example.org port=443 enabled=true
rohi config connectivity interval_s=120 --dry-run
rohi config wifi ssid=office password=-               # secret from stdin or a hidden prompt
rohi config wifi ssid=none                            # disable Wi-Fi (no ssid is the default)
rohi config wifi scan_timeout=null                    # remove the override: back to the firmware default
rohi config wifi password=none                        # explicit "no value": an open network
rohi config reset connectivity                     # remove every override of the section

A name that is also a command (reset, export, diff, apply) is not a valid section name. rohi config --help and rohi action --help need no device; rohi config SECTION --help connects and is generated from the schema.

$ rohi config wifi
ssid             office
password         <redacted>
scan_timeout     20000 ms
connect_timeout  30000 ms
backoff_initial  1000 ms
backoff_max      60000 ms
link_refresh     10000 ms

An optional field without a value shows as <unset> when it follows a default that has none, and as <none> when you set it to no value explicitly. After a change rohi reads the section again and prints what the device reports, so the output is always the effective result.

Format the whole configuration storage of the device with the dedicated rohi factory-reset command (not a config subcommand): every section, the device identity and anything else stored in the partition is erased, including section versions, and a damaged storage is repaired. The device is unprovisioned afterwards; the command then asks it to reboot (rohi reboot).

rohi factory-reset            # asks for confirmation
rohi factory-reset --yes
$ rohi config connectivity --help
Usage:
  rohi config connectivity [FIELD=VALUE...]

Fields:
  enabled     bool
  node_id     u64
  interval_s  u64 s
  host        string
  port        u64

Special values:
  FIELD=null  clear the override and use the field default
...
  • Effective = defaults + overrides. A field you never set keeps following the firmware default, also after a firmware update changes it; a field you set stays as you set it.
  • FIELD=VALUE is a patch of overrides: fields that are not mentioned are not touched. All mutations of one command are applied to a copy, the complete effective result is validated on the device and stored atomically: if anything is invalid, nothing is stored. --dry-run validates without storing.
  • The result of every change is validated as defaults + overrides; an unconfigured or disabled section (no ssid means Wi-Fi is disabled, no location, connectivity disabled) is a valid state. Fields that belong together are therefore set in one command: rohi config connectivity enabled=true alone fails until the host is set, rohi config connectivity enabled=true host=ingress.example.org works.
  • Three states for a nullable field. FIELD=value is an explicit value, FIELD=none is an explicit “no value” (rohi config wifi password=none configures an open network, rohi config time server2=none disables the second NTP server) and FIELD=null removes the override, so the field follows the current firmware default again (rohi config time server2=null restores the firmware’s second server). none is only valid for fields that --help marks nullable; rohi refuses it for any other field before contacting the device. FIELD=null works for any writable field, required or not.
  • Secrets (secret in --help) are never returned and never part of exports. Put NAME=- instead of a value; the tool refuses a secret on the command line. password=none and password=null are allowed: they carry no secret.
  • Reset removes every override of the section, so the firmware defaults apply again.
  • A change that needs a restart says so (A restart is required for the change to take effect).

Configuration files

A file describes your overrides, not the defaults:

rohi config export config.json              # or: rohi config export > config.json
rohi config diff config.json                # what applying would change
rohi config apply config.json --dry-run     # validate everything, change nothing
rohi config apply config.json

A file maps section names to their fields:

{
  "wifi": { "ssid": "office" },
  "connectivity": { "enabled": true, "interval_s": 120, "host": "ingress.example.org", "port": 443 },
  "location": { "latitude": 52.52, "longitude": 13.405 }
}
In the fileEffect
"field": valueset the override (even if the value equals the default)
"field": nullremove the override (follow the firmware default)
"field": {"none": true}explicit “no value” of a nullable field (distinct from null)
field missingleft unchanged
"section": nullreset the section (remove every override)
  • Export writes the explicit overrides only, never materialized defaults, so a later firmware default still reaches fields you did not override. A nullable field you set to no value is written as {"none": true}. Sections without overrides are omitted; secrets and read-only fields are left out, so applying an exported file never clears or overwrites a secret.
  • A file cannot contain secrets: config apply refuses them and points at config SECTION NAME=-.
  • diff compares the file with your overrides on the device and prints ~ changes, + new overrides, - removed overrides and reset section; nothing is changed. Fields that already match are not listed.
  • apply checks the whole file against the schemas first (unknown section, unknown or read-only field, wrong type fail with no change), then has the device validate every section with a dry run, shows the changes and asks for confirmation unless --yes, then applies the sections one by one. Each section is atomic; applying several is not atomic as a whole. If the device fails in between, the error lists the sections that were applied and the ones that were not. Every section is applied against the revision its diff was computed from, so a section that another client changed after the diff was shown fails with a conflict instead of being overwritten.
  • The backup of earlier versions (rohi backup) is replaced by export/apply; an old backup file is not understood.

Actions

rohi action                                        # all actions: service, name, privileged
rohi action network                                # the actions of one service with their arguments
rohi action network scan --help                    # arguments and results (generated from the device)
rohi action network scan                           # results as a table, one row per record
rohi action network reconnect
rohi action radio tune channel=11                  # arguments are NAME=VALUE, all required
rohi -o json action network scan

-o json with --help prints the schema. Privileged actions need a link that satisfies the authorization policy of the device. A refusal with a stable reason is reported as the device rejected the action: <reason> (for example already_provisioned).

Identity

The identity is an ordinary service: no dedicated command.

rohi status identity                                   # state, public key; never the secret
rohi action identity provision secret=-               # 32-byte secret as hex, from stdin or a hidden prompt
rohi action identity erase

The secret is derived on a trusted machine (rohi::derive_identity, see Device identity) and only the derived secret is sent. rohi factory-reset erases it with the rest of the configuration storage. A malformed secret is reported without echoing it.

Migrating from earlier versions

BeforeNow
rohi sensorsrohi status sensors
rohi timerohi status time
rohi wifi (status)rohi status wifi
rohi wifi scanrohi action wifi scan
rohi wifi reconnect, disconnectrohi action wifi reconnect / disconnect
rohi wifi configure, forgetrohi config wifi ssid=.. password=-, rohi config reset wifi, then rohi status wifi
rohi connectivity set --host ..rohi config connectivity host=..
rohi config list, config describe <section>rohi config, rohi config <section> --help
rohi config get <section>, config set <section> ..rohi config <section>, rohi config <section> FIELD=VALUE
rohi config unset <section> <field>rohi config <section> FIELD=null (removes the override)
rohi action list, describe, runrohi action, rohi action <service> <action> --help, rohi action <service> <action>
rohi backup export / importrohi config export / apply

Commands

CommandWhat it does
scandiscover nearby devices
status [SERVICE] [--watch]device summary or one service
config [SECTION] [FIELD=VALUE...], config reset SECTIONlist, show and change configuration sections
config export, diff, applyconfiguration files (your overrides)
factory-reset [--yes]format the configuration storage, identity included
rebootreboot the device
action [SERVICE] [ACTION] [ARG=VALUE...]list and run the actions of the services

Troubleshooting

SymptomCheck
no ROHI device foundBluetooth is on, the device is powered and advertising, no other client holds its connection; raise --scan-timeout
timed out waiting for the devicethe device is in range; raise --timeout
multiple ROHI devices foundpass --device or set ROHI_DEVICE; rohi scan lists them
the device has no section … / NotFoundthe name is not one the device lists: rohi config, rohi status, rohi action
a section shows fewer fields than expectedthe tool is older than the firmware; rebuild rohi from the same revision
the device refused the request: permission deniedthe change needs an encrypted or authenticated link, see Security

Next

How the services, sections and actions are built is in Service pipeline; the protocol behind the tool and the library API are in Control plane.

Services and status

A ROHI device is a set of services: Wi-Fi, telemetry, time, sensors, identity, location, and whatever a firmware adds. This page explains how to see what each one is doing and how that differs from configuring it, using the rohi tool. Nothing here is specific to a module: the device describes its services, sections and actions, and the tool shows them.

Four questions, four places

Capability    what can this firmware do?          (kept for runtime operations, e.g. firmware update)
Config        what is it set up to do?            persistent, desired state      rohi config ...
Status        what is it actually doing now?      live, observed state, read-only rohi status ...
Actions       what can I ask it to do?            explicit operations            rohi action ...
Diagnostics   what happened before?               logs, history                  (separate concern)

The same service appears in several places, never mixed:

$ rohi config connectivity          # desired: what it is set up to do (defaults + your overrides)
enabled     true
node_id     42
interval_s  60 s
host        ingress.example.org
port        443

$ rohi status connectivity          # observed: what is happening now
Service:  connectivity
State:    running
Health:   degraded
Ready:    yes
Since:    34s

frames_created           212
frames_delivered         209
send_retries             7
frames_rejected          0
queue_depth              3
frames_without_location  212

Issues:
  WARNING  no_location  (34s)

The device summary

rohi status is one compact request: the device health and one row per service. It stays small however many fields a service has.

$ rohi status
Device:  altruist-urban-0.4.0 (urban-esp32c3)
Uptime:  2h 14m
Health:  degraded

SERVICE       STATE     HEALTH    READY  SINCE
network        running   ok        yes    2h 14m
connectivity  running   degraded  yes    34s
time          running   ok        yes    2h 13m
sensors       running   ok        yes    2h 14m
identity      running   ok        yes    2h 14m
location      disabled  -         no     -

State, health and readiness are three separate things, not one big status:

AnswersValues
Statewhere is it in its lifecycle?disabled, stopped, starting, running, stopping, failed
Healthhow well does it work?ok, degraded, error, or - (unknown)
Readycan it do its job right now?yes / no

So the same word means different things in different columns, and combinations carry information:

StateHealthReadyMeaning
runningokyesworking
runningdegradedyesworks, but something is wrong (telemetry without a location)
runningdegradednothe task runs, the backend does not answer
starting-noWi-Fi is still connecting
disabled-noturned off by configuration or never set: not an error
failederrornocrashed and not recovering

SINCE is how long the service has been in its current state.

Device health

Health: in the header is decided by the firmware, not computed by the tool. The firmware knows which services are mandatory, optional or redundant: a cellular modem may be degraded while Ethernet is fine. With the default policy a failed essential service makes the device error, a degraded service or a running service that is not ready makes it degraded, and disabled services do not count.

One service in detail

rohi status <service> adds the service-specific values the device describes (with their units) and the active issues:

$ rohi status network
Service:  network
State:    running
Health:   ok
Ready:    yes
Since:    2h 14m

ssid     office
rssi     -61 dBm
ip       192.168.1.42
gateway  192.168.1.1
dns      192.168.1.1

An issue has a stable machine-readable code (connection_timeout, no_location), a severity, a message and an age. Scripts react to the code, people read the message. Only issues that are active now are listed; history is diagnostics. With -o json the values are keyed by field name and carry their units:

$ rohi -o json status network
{
  "schema_version": 1,
  "service": { "service": "network", "state": "running", "health": "ok", "ready": true, ... },
  "values": { "ssid": "office", "rssi": -61, "ip": "192.168.1.42" },
  "units": { "rssi": "dBm" },
  "issues": []
}

Watching

$ rohi status --watch                 # reprint when something observable changed
$ rohi status connectivity --watch --interval 5

Every service has a status revision that changes when its state, health, readiness or active issues change. A summary watch polls the compact summary and prints again only when a revision (or the device health) moved, so it stays cheap. Values that vary all the time (counters, signal strength) do not change the revision; rohi status <service> --watch re-reads the detail on every poll and prints when what is shown changed. Stop with Ctrl-C.

Doing things: actions

Status never changes anything. Operations are actions, listed and described by the device:

$ rohi action
SERVICE   ACTION      PRIVILEGED
network   scan        no
network   reconnect   yes
network   disconnect  yes
identity  provision   yes
identity  erase       yes

$ rohi action network                  # the actions of one service, with their arguments
$ rohi action network scan --help      # arguments and results, from the device schema
$ rohi action network scan
SSID    RSSI     CHANNEL  SECURITY
office  -50 dBm  6        wpa2
guest   -70 dBm  11       open

Privileged actions need a link that satisfies the device’s authorization policy. Arguments are NAME=VALUE pairs; use NAME=- to read a value from stdin so it stays out of the shell history. Secret arguments, like the one of identity provisioning, are always read from stdin or a hidden prompt: rohi action identity provision secret=-.

Configuration

rohi config lists the sections of the device, shows the effective configuration of a section (the firmware defaults with your explicit overrides applied) and changes overrides with patches that are applied atomically per section. Secrets are write-only. It is covered on The rohi tool; the rules are in Service pipeline.

What the services are

ServiceWhat it reportsTypical issue codes
wificonnection state, network, signal, channel, radio heapauthentication_failed, network_not_found, association_failed
networkIPv4 address, gateway, DNS, address mode, socket usagedhcp_failed
bleperipheral state, advertised name, link securitydriver_error
connectivitytelemetry queue and countersconnection_timeout, unauthorized, no_identity, no_utc_time, no_location
timewall-clock time, source, age, failuresnetwork_unavailable, timeout, implausible
sensors<sensor>.<measurement> values in canonical unitsbmx280_error, sds011_degraded, …
identityprovisioned or not, public keynot_provisioned
locationwhere the device is and where that comes from (Location)not_set

A firmware may offer more or fewer; rohi status shows what the connected device really has. Adding a service needs no change to the tool: Add a service.

Architecture

The SDK is layered: portable crates define typed models, contracts and logic; thin *-esp platform crates implement those contracts on the ESP32; the firmware composes them and owns the product decisions (resource sizes, which features run). This page shows the complete dependency model; Crate map lists every crate.

                    firmwares/altruist-urban   (product: composition, tasks, resources.rs)
 ┌──────────────────────────────┼───────────────────────────────────────────┐
 │                              │                                           │
 sensors + runtime        configuration                  networking / control
 ─────────────────        ─────────────                  ────────────────────
 rohi-hal                 rohi-config                    rohi-control-ble ─► rohi-control ─► rohi-control-proto
   drivers, Runner          ConfigStore                    (BLE binding)      (dispatcher)      (protobuf schema)
   MeasurementStore         ConfigSection, rohi-identity         │                  │
   MeasurementHandle        (derive: rohi-config-derive)         ▼                  ▼
       │                         │                          rohi-net ◄────── rohi-hal (measurement types)
       ▼                         ▼                            WifiHandle / WifiManager     (Wi-Fi control plane)
 rohi-hal-esp              rohi-config-esp                    NetworkHandle / IpBackend    (IP data plane)
   board pins, I²C,          flash region,                    BleTransport                 (BLE transport)
   UART, I²S, clock          esp-storage                            │
       │                         │                          rohi-net-esp  (EspWireless, EspWifi, EspIp, EspBle)
       └────────────┬────────────┴────────────────────────────────┘
                    ▼
        Embassy  ─  esp-rtos  ─  esp-hal / esp-radio / embassy-net / TrouBLE

Rules that keep this picture true:

  1. Dependencies point from platform and product towards portable crates. rohi-net never depends on rohi-net-esp or on rohi-control*; rohi-hal never depends on rohi-net; portable crates never depend on esp-* crates. See Portability.
  2. Feature crates own their own config sections. rohi-config never learns the set of application sections: rohi-net owns WifiConfig, NetworkConfig and BleSettings (each the effective configuration, persisted as generated overrides), rohi-identity owns the Identity section. A section type lives next to the code that interprets it, and the firmware only wires the section to its runtime. See Add a ConfigSection.
  3. Stateful services have one owner task and are reached through bounded handles: see Runtime ownership.
  4. Platform types never cross a portable API. Vendor types and errors are converted into ROHI types inside the platform crate.
  5. The product, not the platform crate, decides how much RAM the wireless stack takes: see Memory model.

Data flows

sensor tasks ─► SensorEvent ─► EVENTS (bounded) ─► store task ─► MeasurementStore
                                                       ▲                │
                                       MeasurementHandle (snapshot)     ▼
                                                          rohi-control ─► BLE ─► rohi client

BLE write ─► rohi-net-esp (EspBle, bounded RX) ─► rohi-control-ble (reassemble) ─► Controller
          ◄─ bounded TX ◄─ fragment ◄─ response ◄─────────────────────────────────┘
                                           │ ControlBackend (registries of rohi-service)
                  config sections (ConfigHandle) · services (MeasurementHandle, WifiHandle, ...)

ConfigHandle::set(WifiConfig) ─► config owner task ─ commit ─► publish desired Wi-Fi config ─► WifiManager
                                                                                                 │
                                          application ◄── NetworkHandle (DNS, TCP) ◄── EspIp ◄───┘ (link + DHCP)

Where does new code go?

See the table at the end of Crate map.

Crate map

Every production workspace crate, what it is for, whether it is portable and the dependency direction it must respect. “Portable” crates are no_std, never allocate and build and test on the host. For the dependency picture see Architecture.

CratePurposePortable?std / no_stdMajor public abstractionsMay depend onDoes not own
rohi-halTyped units and measurements, sensor drivers, sensor runtime, MeasurementStoreportableno_stdMeasurementStore, MeasurementsSnapshot, SensorDriver, Runner, EventBus, MeasurementHandleembedded-hal(-async), Embassy sync, rohi-serviceMCU peripherals, board pins, networking, serialization
rohi-hal-espESP32 backend of the HAL: boards, I²C/UART/I²S setup, clockESPno_stdboard::altruist_urban::{Hardware, AltruistUrban}rohi-hal, esp-halSensor logic, application tasks
rohi-configTransactional CBOR configuration storeportableno_stdConfigSection, ConfigStore, ConfigStorage, MemoryStorage, Override, ConfigOverrides, HasOverrides, materializeminicborAny concrete section, flash access
rohi-config-derive#[derive(ConfigSection)], #[derive(ConfigOverrides)]portable (proc-macro)std (build time)derive macrosyn, quoteRuntime behaviour
rohi-serviceThe generic service pipeline: Value, ManagedConfig sections with atomic patch, ManagedService status with state/health/issues/revisions, actions, tuple registriesportableno_stdManagedConfig, ManagedSection, ConfigRegistry, ManagedService, Tracked, ServiceRegistry, ActionDescriptor, Valuerohi-config (the ConfigOverrides bound), heapless, Embassy syncAny concrete section or service, the protocol
rohi-config-espFlash backend of the storeESPno_stdEspConfigStorage, FlashRegion, layoutrohi-config, esp-storageCBOR, sections, any feature’s configuration
rohi-identityDevice identity: Ed25519 Identity config section, Secret, Signer, IdentitySignerportableno_stdIdentity, Secret, Signer, IdentitySigner, PublicKey, Signaturerohi-config, rohi-service, ed25519-zebraProvisioning transport, key derivation (done on the host by rohi), protocol semantics of signed messages
rohi-netWi-Fi control plane, IP data plane (DNS, TCP, UDP), BLE transport traits, WifiConfig, NetworkConfig and BleSettings sections and the wifi, network and ble servicesportableno_stdWifiManager, WifiHandle, WifiBackend, NetworkHandle, IpBackend, UdpSocket, NetError, BleTransportrohi-config, rohi-service, Embassy sync, embedded-nal-async, embedded-io-asyncRadio drivers, the IP stack, control protocol
rohi-timeSynchronized wall-clock time: TimeService, TimeSource, validation, background Synchronizerportableno_stdTimeService, TimeSource, TimeSample, UnixTime, TimeStatus, Synchronizerrohi-hal (monotonic Clock, Backoff), rohi-service, Embassy syncNetwork or clock hardware, any concrete source
rohi-time-ntpNTP/SNTP TimeSource over the IP data planeportableno_stdNtpTimeSource, NtpConfig, NtpServersConfig (the time config section)rohi-time, rohi-net, rohi-config, rohi-service, sntpcThe global concept of time, the UDP stack
rohi-locationThe location of the device (user-configured now, GNSS later): LocationConfig section, LocationShared, LocationServiceportableno_stdLocation, LocationConfig, LocationShared, LocationSource, LocationServicerohi-config, rohi-serviceGNSS hardware, telemetry
rohi-connectivity-protoBounded bindings of airalab/connectivity-protocol (vendored, pinned revision)portableno_stdcore_v1, crypto_v1, device_v1, sensor_v1, limits, signing_preimagemicropbSigning, mapping, transports
rohi-connectivityTelemetry: snapshot (+ location) → signed envelope frame, bounded outbox, retry, HTTP transport, ConnectivityConfig section, connectivity serviceportableno_stdUrbanEncoder, EnvelopeBuilder, EnvelopeFrame, EnvelopeTransport, HttpTransport, ConnectivityService, UtcClock, NonceSource, FrameSignerrohi-connectivity-proto, rohi-hal, rohi-identity, rohi-location, rohi-net, rohi-service, rohi-timePrivate keys, Wi-Fi lifecycle, sensors, backend logic
rohi-net-espESP backend of rohi-netESPno_stdEspWireless, EspWifi, EspIp, EspBle, NetResources, BleResources, wireless_heap!rohi-net, esp-radio, embassy-net, TrouBLEProduct resource sizes, control protocol
rohi-control-protoBounded protobuf schema of the control protocolportableno_stdgenerated messages, PROTOCOL_VERSION, limitsmicropbTransport, behaviour
rohi-controlTransport-agnostic control dispatcher and authorization; serves the registries of rohi-serviceportableno_stdController, ControlBackend, Device, AuthorizationPolicyrohi-control-proto, rohi-serviceBLE, stores, the radio, any knowledge of a module (Wi-Fi, sensors, …)
rohi-control-bleGATT binding: UUIDs, framing, fragmentation, control loopportableno_stdControlBle, framerohi-control, rohi-netThe BLE stack
altruist-urban (firmware)Product composition, tasks, resource budgetproductno_stdconfig, net, control, time, connectivity, resources, taskseverything aboveReusable library code
rohi-examples (examples/)One hardware example per sensor and a config self-testproductno_stdbinariesrohi-hal(-esp), rohi-config(-esp)
rohi (tools/rohi)Desktop client library and CLI over BLEdesktopstdDevice, Session, BleLinkrohi-control-ble::frame, rohi-control-proto, rohi-net (BLE payload size), btleplugAnything that runs on the device

Allowed dependency direction

altruist-urban ──► rohi-*-esp ──► rohi-* (portable) ──► embedded-* / embassy-sync
                                                      └─► never esp-*
rohi-control-ble ──► rohi-control ──► rohi-control-proto
rohi-control ──► rohi-control-proto, rohi-service   (nothing else: it knows no module)
rohi-service ──► rohi-config                (generic model: Value, ManagedConfig, ManagedService, registries)
rohi-net, rohi-connectivity, rohi-time, rohi-hal, rohi-identity, rohi-location ──► rohi-service   (each implements the traits for its own types)
rohi-location ──► rohi-config, rohi-service   (owns the LocationConfig section and its overrides)
rohi-net ──► rohi-config                   (owns the WifiConfig, NetworkConfig, BleSettings sections and their overrides)
rohi-identity ──► rohi-config              (owns the Identity section)
rohi-time-ntp ──► rohi-time, rohi-net, rohi-config, rohi-service   (a source: DNS + UDP samples, owns the `time` section; rohi-time never depends on it)
rohi-connectivity ──► rohi-connectivity-proto, rohi-hal, rohi-identity, rohi-location, rohi-net, rohi-time   (snapshots, location, signing, NetworkHandle, UtcClock)

Forbidden: a portable crate depending on an *-esp crate; rohi-net* depending on rohi-control*; rohi-hal depending on rohi-net; a platform crate depending on the firmware.

Where does new code go?

You are adding…Put it inNotes
A new sensor driverrohi-hal (sensors/)Add a sensor; a new board only needs wiring in rohi-hal-esp
Pins or bus setup for a boardrohi-hal-esp (board/)Add a board
A new persistent configuration sectionThe crate of the feature that interprets itAdd a ConfigSection; never in rohi-config
Flash or storage backendrohi-config-esp (or a new *-esp crate)Implement ConfigStorage
An absolute-time source (RTC, external time)A new portable crate implementing rohi_time::TimeSourceConsumers keep using TimeService; see Time service
DNS/TCP/UDP behaviour, TLS/HTTP/uplink protocolsA new portable crate over NetworkHandle / the embedded-nal-async traitsNever use embassy-net directly
Another IP stack or radioA platform crate implementing IpBackend / WifiBackendVendor types stay inside
A service (status, config, actions) of a runtimeThe crate that owns the runtime; register it in the firmware tuplesAdd a service; no protocol or CLI change
A control operation that is none of config, status or actionrohi-control-proto (schema), rohi-control (dispatch, ControlBackend)Add a control operation or an action
Product resource sizes and task wiringThe firmware (resources.rs, tasks/)The product owns the RAM budget
Anything that needs esp-hal / esp-radioA *-esp platform crateKeep the portable API free of vendor types

Runtime ownership

Rule. Stateful drivers and services have one mutable runtime owner; other tasks interact with them through bounded handles, snapshots or commands.

There are no global mutable stores and no shared locks around drivers. The owner is an Embassy task (or, for the radio, one runtime object); everything else holds a small copyable handle into a bounded, statically allocated channel.

ServiceOwnerHandleReached through
Measurementsstore task owns MeasurementStoreMeasurementHandlebounded snapshot request/response
Configurationconfiguration task owns ConfigStoreConfigHandlebounded, numbered request/response
Wi-FiWifiManager task owns the Wi-Fi backendWifiHandledesired-state cell, bounded commands, published status
IP stackthe platform stack (EspIp) behind the stack runner taskNetworkHandleDNS and TCP through standard traits
ESP wireless resourcesEspWireless (radio peripherals, wireless heap)the drivers it hands out oncewifi(), ble()
BLE peripheralBleRunner task owns the controller and host stackEspBle (BleTransport)bounded RX/TX queues
MeasurementStore  ─ one owner task ─►  MeasurementHandle
ConfigStore       ─ one owner task ─►  ConfigHandle
WifiManager       ─ one owner      ─►  WifiHandle
ESP wireless resources ─ one owner / runtime ─► Wi-Fi + BLE

Why: a single owner makes ordering explicit (requests are served one at a time), keeps flash and radio access out of timing-critical tasks, makes cancellation analysable (see Async and cancellation) and lets the owner be tested on the host with a mock backend.

Consequences

  • Nothing outside altruist_urban::config touches the configuration flash; nothing outside the Wi-Fi manager task calls the Wi-Fi driver; sensor tasks never touch the MeasurementStore.
  • The owner never blocks on a caller that gave up: responses to abandoned requests are discarded.
  • Handles are cheap (Copy) and are the only thing passed to other crates, which keeps portable crates independent of the owner’s concrete types.
  • Failure of one owner (for example Wi-Fi) is contained: the others keep running.

State versus command

Two kinds of interaction exist; mixing them in one queue causes subtle bugs:

desired persistent state  ──►  latest-state publication / reconciliation
transient requested action ──►  bounded command

Guideline. Prefer state publication over queued commands for persistent desired state. Use commands for transient actions.

InteractionKindMechanism
Desired WifiConfig and NetworkConfigstateWifiHandle::set_wifi_config / set_network_config (latest value wins, synchronous)
Latest system / Wi-Fi statusstatepublished WifiStatus (Watch)
Scan, reconnect, disconnectcommandbounded queue, Busy when full
One-shot request (snapshot, config read)commandbounded request/response

Wi-Fi configuration is the canonical example. The persistent configuration is state: the manager continuously reconciles the actual radio state towards the latest desired value. Rapid updates A, B, C converge on C, an identical update does nothing, and a new value invalidates the retry state of the old one. Had it been a command in the same FIFO as Scan, a full queue would have forced the publisher to wait (a cancellable gap after the durable commit), updates could not be coalesced and ordering against transient commands would matter. The reconciliation rules are in Networking.

Async and cancellation

ROHI code is async on Embassy. Cancellation — dropping a future before it completes — is normal behaviour in async Rust: select, timeouts and task shutdown all do it. SDK code must therefore never leave a service in an undocumented, partially mutated state when a future is dropped.

Execution model

  • One Embassy executor (thread mode, esp-rtos time base). Tasks are cooperative: nothing runs concurrently with another task between two .await points of the same executor. Radio callbacks never run application logic; they only copy into bounded queues.
  • Every stateful service has one owner task (Runtime ownership); callers talk to it through bounded channels, so a caller’s future being dropped must not stall or corrupt the owner.
  • A future is dropped between two .await points. Anything that must be atomic must not contain an .await.

Primitives and their cancellation behaviour

PrimitiveUseWhen the waiting future is dropped
Bounded Channel (send/receive)commands, request/response, queuesreceive takes an item only when it returns it; send queues the item entirely or not at all
Signal / Watch (latest value)status, completion, desired statethe value stays available; waiting again returns it
Mutex serialising callersone request in flightthe guard is released on drop
Sequence-numbered responsesrequest/response with one ownera stale response is discarded; it never reaches a later caller
Timer / selecttimeouts, preemptionthe other branch is dropped: it must be cancel-safe

Rules for SDK code

  1. Document it. Every public async operation that changes durable state, changes desired runtime state, owns a peripheral transaction or uses a request/response channel documents its non-trivial cancellation semantics, using one of:

    • cancellation-safe;
    • cancel before commit leaves state unchanged;
    • cancel after commit leaves the operation logically completed;
    • cancel may abort the transient operation and the caller must retry.

    Operations that are clearly cancellation-safe by construction (a plain bounded receive) need only a short note.

  2. No cancellable gap after a durable commit. For operations that commit durable state: there must not be a cancellable gap between the commit and the publication of the corresponding desired runtime state. Do the publication synchronously, in the same step as the commit, in the task that owns the store.

  3. Peripheral transactions are not cancel-safe. A driver future that owns a bus or radio transaction may be cut short by a drop; document it, and run such futures to completion in their own task. Do not put them in a select unless the driver documents that this is safe (WifiBackend::connect and wait_link_lost are, because the manager drops them on purpose).

  4. State changes after the last .await. Do not take or clear a flag before an .await that can be dropped; clear it after the await completes. (The sensor runner’s warm-up flag was fixed for exactly this reason.)

  5. Prefer state publication over queued commands for persistent desired state; see State versus command.

  6. Release resources in Drop. Slots, locks and pool entries are owned by values that release them on drop (a TCP connection slot, a mutex guard), never by code after an .await.

Durable mutation

The Wi-Fi configuration is the first concrete application of the rule:

validate ─► ConfigHandle::set(WifiConfig)         ┐ request is queued: from here the owner task completes it
                                                  │
           config owner task:  commit to flash ─► publish desired Wi-Fi config (synchronous) ─► respond
                                  no .await between commit and publish
  • Cancelled before the request was queued: nothing changed.
  • Cancelled after: the owner still commits and publishes, so the durable state and the runtime agree; the caller only misses the result.

The same logic applies to the device identity (the owner task updates the signing identity in the commit step) and at boot (stored configuration is published once after opening the store).

What each public async API guarantees

APICancellation semantics
WifiHandle::set_wifi_config / set_network_configsynchronous; nothing to cancel
WifiHandle::scandropping abandons the request. A scan already handed to the manager still runs; other callers get Busy until it finishes; its result is discarded and never delivered to a later caller. Capacity is not lost
WifiHandle::reconnect / disconnectsynchronous commands; Busy when the bounded queue is full
WifiBackend::connect / wait_link_lostmust be cancel-safe: the manager drops them when a command or configuration change preempts them
NetworkHandle DNS, connect, read, writecancel-safe; dropping connect frees the slot; a dropped write sends nothing that was not reported
MeasurementHandle::snapshot / try_snapshotcancellation-safe: reads only; a stale answer is discarded; request slot and call lock are released
MeasurementService::accept (owner side)cancel-safe; usable in a select next to the event queue
ConfigHandle::set / removebefore the request is queued: unchanged; after: logically completed (the owner commits and publishes)
IdentityHandle::store / eraseas ConfigHandle::set / remove: the owner task updates the signer in the commit step, so the stored identity and the signer cannot diverge
ConfigHandle::get / statusread only; abandoned requests are answered and discarded (numbered responses)
BleTransport::recv / sendcancel-safe: a chunk is taken or queued entirely or not at all
ControlBle::stepdropping abandons the request being handled; no partial state; committed operations stay committed
EventBus::receivecancel-safe
sensor Runner::stepmay cut a sensor transaction short; the next step repeats the attempt; the warm-up wait is kept

Testing cancellation

Host tests cover the meaningful hazards (see Host testing): drop a Wi-Fi scan while waiting, drop a measurement snapshot, cancel a TCP connect/read/write/DNS query, cancel a control request, cancel before and after a state publication, drop a sensor step during its warm-up wait. After every cancellation the tests verify that the service still responds, channel capacity is not permanently lost, stale requests get no later response, nothing stays locked, and the next operation succeeds.

Portability

The SDK separates portable code from platform code so that most of it can be tested on a developer machine and reused on another MCU.

CategoryCratesProperties
Portablerohi-hal, rohi-config, rohi-config-derive, rohi-net, rohi-control-proto, rohi-control, rohi-control-bleno_std, no allocation, no MCU HAL, builds and tests on the host
ESP-specificrohi-hal-esp, rohi-config-esp, rohi-net-espno_std, depend on esp-hal / esp-radio / esp-storage / embassy-net / TrouBLE; empty without a chip feature, so they stay buildable on the host
Productaltruist-urban, exampleschoose the chip, size the resources, spawn the tasks
Desktoptools/rohistd; talks to a device over BLE

How portability is achieved

  • Traits at the seams. SensorDriver + embedded-hal(-async) for sensors; ConfigStorage for flash; WifiBackend, IpBackend and BleTransport for the radio and IP stack; ControlBackend and the rohi-service registries for the control plane. Portable code is generic over them and ships a fake or in-memory implementation for tests (MemoryStorage, rohi_net::testing::FakeIp, mocks in the test modules).
  • ROHI types in public APIs. Vendor types and errors are converted into ROHI types inside the platform crate; a portable signature never mentions esp-* or embassy-net. NetworkHandle is generic over IpBackend, and higher layers should depend on the standard embedded-nal-async / embedded-io-async traits.
  • Time and clocks are injected. Portable code takes a Clock / DelayNs; nothing reads a hardware timer or wall clock.
  • One chip feature at the edge. The chip (esp32c3 or esp32c6) is selected only by the firmware’s features and forwarded to the platform crates; it never leaks into portable crates.

What can and cannot be tested on the host

Portable crates are fully testable on the host, including timing (mock delays), reconnect logic (mock backends), cancellation (dropped futures) and storage faults (MemoryStorage fault injection). Platform crates are only compile-checked for both chips; their behaviour needs hardware, see Host testing and Hardware testing.

Porting to another platform

Implement the seam traits in a new platform crate (for example rohi-hal-<mcu>, rohi-net-<mcu>), keep vendor types inside, and add a firmware or board module that composes them. The portable crates should need no change; if one does, the seam was leaking a platform detail and should be fixed there.

Memory model

Application and runtime code never allocates. Every queue, buffer and table has a compile-time bound. The only heap is a fixed region used by the vendor radio drivers.

Static bounds

  • Bounded types. Collections are heapless types or fixed arrays; strings and scan results have maximum lengths (MAX_SSID_LEN, MAX_WIFI_SCAN_RESULTS, …). Section payloads are limited by MAX_SECTION_PAYLOAD.
  • Bounded queues and channels. Every channel and request slot has a fixed depth; “full” has a defined behaviour (Busy, oldest event dropped, backpressure) documented with the limit. Nothing grows.
  • Static state. Shared state (WifiShared, MeasurementService, EVENTS, NetResources, BleResources) is placed in static items, so its size is visible in .bss and checked at link time. Task futures are statically allocated by Embassy.
  • No heap in protocol code. The control protocol uses bounded generated types (micropb, no heap) and fixed framing buffers.

Where heap exists

The Espressif Wi-Fi and BLE controllers allocate dynamically. The ESP backend gives them one fixed-size wireless heap (esp-alloc, no growth) shared by Wi-Fi and BLE:

  • It is created by the firmware with wireless_heap!(SIZE) and handed to EspWireless::new, which is its only owner. The allocator implementation stays in rohi-net-esp; the size is a product decision.
  • Only the radio drivers allocate from it. Heap-backed vendor results (for example the scan Vec) are converted into bounded ROHI types inside the backend and dropped there.
  • wireless_heap() reports free bytes and, with the heap-stats feature, the minimum free bytes; verify it on hardware with both radios active before changing the size.

The product owns the budget

Platform crates provide mechanisms with const generics; the firmware states the numbers in one file — firmwares/altruist-urban/src/resources.rs — which is the authoritative RAM budget of the product:

ResourceType / constantConsumed by
Wireless heapWIRELESS_HEAP_SIZE → wireless_heap!Wi-Fi and BLE controllers
IP stack sockets, TCP connections and buffersNETWORK_SOCKETS, TCP_CONNECTIONS, TCP_RX_BUFFER, TCP_TX_BUFFER → NetResourcesDHCP, DNS, TCP
BLE queues, connections, L2CAP channels, attributes, controller slotsBLE_* → BleResourcesBLE peripheral

Each constant documents what consumes it, why it has its value, what happens when it is exhausted and what has been measured; the values are summarised in Resource limits. Invariants that would otherwise abort at runtime are const assertions (for example the IP stack needs SOCKETS ≥ TCP connections + DHCP + DNS).

Adding a new static resource: put its type in the platform crate with const generics, its size in resources.rs with the four-point documentation, and a line in Resource limits.

Exhaustion behaviour

Exhaustion never panics the firmware and never allocates: it is a typed error or backpressure (NetError::NoSocketAvailable, WifiError::Busy, a delayed ATT response, a dropped oldest sensor event counted by EVENTS.dropped()). The one exception is an attribute table too small for the GATT service, which panics once at BLE start and is therefore caught on the first boot.

Measured sizes

Flash and RAM measurements are recorded in Resource budget.

Versioning and ABI

Several things in the SDK are contracts between independently updated parties — a firmware and the data an older firmware wrote, a device and its desktop client. They have their own version rules; ordinary Rust API changes follow semver of the crates.

ContractCompatibility ruleCanonical page
Persistent storage (container, section IDs, CBOR payloads)New firmware reads old data; old firmware preserves unknown sections. Renaming a type, renumbering a field or changing a representation is an ABI breakPersistent ABI
Control protocol (protobuf messages, PROTOCOL_VERSION)Field numbers and enum values are stable and only ever added; clients discover sections, services and actions with the List... requests instead of inferring features from the firmware version; the protocol is under development and PROTOCOL_VERSION is not bumped yetControl plane
BLE framing (frame header, GATT UUIDs)Frame version byte; incompatible changes bump itControl plane
Rust APIs of the cratesSemver; ControlBackend, the rohi-service traits and the backend traits are extension points and change deliberately (see changelogs in commit history)rustdoc
Partition table / flash layoutMirrored by constants in rohi_config_esp::layout; a host test keeps both in syncConfiguration store

Rules of thumb

  • Never reuse or renumber a persistent field or protobuf field number; only append.
  • Pin every persistent encoding with a golden test; a failing golden test is a decision, not an inconvenience.
  • A schema change that old data must survive needs a section version bump and a migration hook (decode_versioned).
  • Version policy is explicit: the default decoder accepts only stored version == T::VERSION; CBOR’s tolerance of unknown fields does not replace it.
  • Secrets are write-only on every interface and never printed (Wi-Fi password).
  • Update the SDK book in the same change when you touch any of these (Documentation policy).

Sensor HAL (rohi-hal)

The Altruist Urban firmware turns physical sensors into immutable MeasurementsSnapshot values:

physical sensor -> rohi-hal driver -> Runner (one Embassy task per sensor) -> SensorEvent
  -> bounded event queue -> store task -> MeasurementStore -> MeasurementsSnapshot

Dependency direction: altruist-urban → rohi-hal-esp → rohi-hal. rohi-hal holds the typed measurements, the store, the runtime and the drivers, and is platform independent. Drivers know nothing about Wi-Fi, BLE, serialization or any other transport, and nothing is averaged or fused across sensors: bmx280.temperature and scd4x.temperature are separate observations. Per-sensor details are in Sensors, pins in Boards.

Runtime

The firmware runs Embassy on top of esp-rtos (thread-mode executor and the 1 MHz embassy-time driver), which the Wi-Fi radio driver requires. The only heap is the fixed radio heap owned by the ESP wireless runtime (Wireless runtime); sensor and application code is heapless. All waits use monotonic async timers; nothing depends on wall-clock time.

Components

ComponentLocationRole
Driverscrates/hal/src/sensors/BMx280, SCD4x, SDS011, noise. Hardware independent (embedded-hal-async, embedded-io-async), unit tested on a host
Runtimecrates/hal/src/runtime/Runner lifecycle, Reporter, EventBus, backoff, bounded I²C
Types, storecrates/hal/src/ (measurements, sensor, store, …)Typed units, SensorEvent, MeasurementStore, MeasurementsSnapshot
Boardcrates/hal-esp/src/board/altruist_urban/All pin assignments, bus configuration (features esp32c3 / esp32c6)
Tasksfirmwares/altruist-urban/src/tasks/One Embassy task per sensor, single MeasurementStore owner

Every sensor runs in its own task, so a failing or slow sensor never delays another one. Lightweight I²C sensors share one bus; every transaction is bounded to 200 ms (TimeoutI2c), so a stuck device cannot block the bus forever.

Sensor lifecycle

Each driver is wrapped by a Runner which maps driver results into SensorState:

NotDetected -> Initializing -> WarmingUp -> Ready
Ready -> Degraded -> Error -> (re-init) -> Initializing -> Ready
  • Absence is not an error: a sensor that never answered is NotDetected and probed every 10 s.
  • A sensor that was detected before and stops answering is Error and recovered with exponential backoff, so a reconnected sensor returns to Ready without a reboot.
  • The first failure gives Degraded; 3 consecutive failures give Error and driver re-initialization.
  • Errors never erase the last valid observation; they are published as SensorEvent::Error.
  • After a successful init a sensor is WarmingUp (SCD4x, SDS011) or still Initializing (BMx280, noise) until its first measurement arrives.

Timing semantics

Four delays exist and are never added up:

DelayOwnerMeaning
Initial delaydriver (initial_delay_ms, default 0)Wait before the first sample after first detection. No current driver needs one
Poll intervaldriver (poll_interval_ms)Wait after a successful sample before the next one
Recovery backoffrunner (RunnerConfig)Wait after a failed sample or init: 1 s, 2 s, 4 s … capped at 30 s; reset by the next successful measurement
Warm-updriver, inside sampleHardware specific: SDS011 fan spin-up, SCD4x first reading
success:  sample -> publish -> poll interval -> sample
failure:  sample -> error   -> backoff       -> sample   (no poll interval on top)

Retries therefore never wait “backoff + a full poll interval” (for the SDS011 that would be over 90 s), and the first sample after boot is taken immediately.

Events

Sensors publish three kinds of SensorEvent:

  • Measurements — a successful observation;
  • StateChanged — an actual lifecycle transition (published only when the state changes);
  • Error — a failed operation.

Success is one event. The MeasurementStore derives Ready from a Measurements event: new values, their timestamp and Ready are applied in one step, also when recovering from Degraded or Error. There is no separate StateChanged(Ready), so a consumer can never observe Ready together with an old value, and one successful cycle costs one queue slot.

EventBus is a bounded queue of 16 events (EVENT_QUEUE_CAPACITY). When it is full, the oldest event is dropped and counted (EVENTS.dropped(), logged with every snapshot): the newest state is more useful than a backlog and producers never block. The store task is the only component mutating the MeasurementStore.

Other tasks never see the store. They call tasks::store::handle().snapshot().await (a MeasurementHandle, from rohi_hal::runtime::service): a bounded request/response to the store task, which generates the snapshot at request time with the current monotonic clock, so Valid/Stale are correct when asked. try_snapshot() returns Busy instead of waiting for another request in flight.

Freshness

A valid measurement becomes Stale when it is older than the sensor’s FreshnessPolicy (defaults in rohi-hal, tunable with MeasurementStore::set_freshness_policy). A host test in rohi-hal asserts that every default exceeds the worst healthy cadence of its sensor by at least 1.5×:

SensorWorst healthy cadenceStale after
BMx28010 s60 s
SCD4x15 s30 s
SDS011150 s (warm-up + sampling window + sleep)300 s
Noise5 s30 s

Errors do not make a value stale; they mark it Error while the last valid value is preserved.

SDS011 and noise coordination

The legacy firmware coordinated SDS011 activity with noise measurement (the fan is a noise source). This is not implemented. The runtime exposes STATES (SensorStates), a read-only view of every sensor state, so a future noise policy can react to the SDS011 state (e.g. flag windows while it is in WarmingUp) without coupling the drivers.

Testing and resources

The runner timing and recovery, event consistency, the store, SDS011 sampling and aggregation, BMx280 address probing and the freshness thresholds are covered by host tests (Host testing). Hardware examples, the combined sensor checklist and the stability run are in Hardware testing; flash and RAM measurements are in Resource budget.

Cancellation

The sensor runner is meant to run to completion: Runner::step may be dropped (for example at shutdown) but a sensor transaction in flight is then cut short, and the next step repeats the interrupted attempt. The warm-up wait is not lost when a step is dropped. EventBus::receive and the owner side MeasurementService::accept are cancel-safe; MeasurementHandle::snapshot can be dropped at any point. Details: Async and cancellation.

Sensors

Supported by the Altruist Urban firmware (ESP32-C3 and ESP32-C6). Each driver is hardware independent and publishes typed rohi-hal observations; see Sensor HAL (rohi-hal) for lifecycle, timing and events.

SensorBusCadenceNotes
BMP280 / BME280I²C 0x76 / 0x7710 sTemperature, pressure, humidity (BME280 only)
SCD40 / SCD41I²C 0x625 sCO₂, temperature, humidity
SDS011UART, 9600 baud125–150 s duty cyclePM2.5, PM10, fan off between cycles
I²S microphoneI²S5 s windowNoise level, dB SPL

BMP280 / BME280

Forced mode: temperature ×2, pressure ×4, humidity ×1, IIR filter off, one conversion (≈19 ms) per sample; the sensor sleeps in between.

Address. The driver probes 0x76 first, then 0x77, and uses the first address whose chip ID register reads a BMP280 (0x56..=0x58) or a BME280 (0x60); devices with another ID are skipped. Either strapping works without a rebuild. If valid sensors answer at both addresses, 0x76 wins (two BMx280 are not supported). Probing is repeated on every re-init, so a sensor moved to the other address is found again. If nothing is found, the failure is a bus error, an unsupported chip ID or no device, in that order of precedence; the last two keep the sensor NotDetected.

SCD40 / SCD41

Probing stops any running measurement, reads and validates the serial number, then starts periodic measurement (a new value every 5 s). Each sample waits on the data-ready flag (poll every 500 ms, at most 20 polls) and validates every word with CRC-8. The first valid value arrives about 5 s after start, until then the sensor is WarmingUp.

SDS011

The command flow and wire protocol (sensors/sds011/protocol.rs) are derived from the community sds011-rs driver (MIT OR Apache-2.0). The sensor is kept in query mode and asleep between cycles, so the fan and laser run only while measuring:

wake -> warm-up (30 s) -> sample 1..N -> filter / aggregate -> publish PM2.5 / PM10 -> sleep (90 s)

Constants (sensors/sds011/mod.rs), also available as sds011::Config:

ConstantDefaultMeaning
WARMUP_MS30 sFan spin-up before sampling
SAMPLE_COUNT5Valid samples required per cycle
SAMPLE_INTERVAL_MS1.5 sPause between two queries
CYCLE_TIMEOUT_MS30 sSampling window
TRIM_MIN_MAXonDiscard the lowest and highest sample
SLEEP_MS90 sPause between cycles (the poll interval)

These values are a starting point and are to be confirmed on hardware against the legacy firmware behaviour.

  • Valid samples only. Malformed, truncated and checksum-invalid frames and values above 999.9 µg/m³ are dropped. Sampling stops when SAMPLE_COUNT valid samples exist or the window is over.
  • Aggregation (sds011/aggregate.rs, fixed arrays, no heap): rounded integer mean per channel; with at least 5 samples the lowest and highest are discarded first, independently for PM2.5 and PM10. Example, PM2.5 10 12 11 80 9: drop 9 and 80, result 11.
  • Failure. Too few valid samples give Error::InsufficientSamples and nothing is published. A silent sensor fails the cycle at once (NoResponse). Either way the sensor is told to sleep and the runner retries after its recovery backoff.
  • Robustness. Every UART read is bounded to 5 s, stale bytes (e.g. frames of a sensor still in active mode) are discarded, unexpected frames are skipped and the wake-up is retried up to 3 times.
  • The first measurement starts right after boot; the cycle’s own warm-up is the only wait.

Noise

Metric: sound pressure level in dB SPL (re 20 µPa), unweighted. It is not dBA and no A-weighting is applied.

PropertyValue
MicrophoneICS-43434 assumed: −26 dBFS at 94 dB SPL (1 kHz), so 24-bit full scale is 120 dB SPL
Sample rate16 kHz mono, 24-bit samples
Filtering≈10 Hz integer DC blocker only, otherwise flat
Sub-window125 ms mean square gives one level
Measurement window5 s (40 sub-windows), continuous; average is the energy average (Leq), maximum the loudest sub-window
Calibrationfactory sensitivity only (CALIBRATION_OFFSET_DB), no per-device calibration

Levels include the microphone’s frequency response and the device’s own noise (e.g. the SDS011 fan). They compare between devices with the same microphone model, not with a calibrated, A-weighted sound level meter. This is the v1 metric the telemetry schema can rely on; an A-weighted level would be added as a separate, tested metric under its own name.

Raw audio is never stored: a 256-sample stack chunk plus the 8 KiB DMA ring buffer (≈128 ms) are the only buffers.

Status and the control plane

The firmware keeps the strongly typed rohi-hal model. Only at the control boundary does it become the generic sensors service (SensorsService over a MeasurementHandle): every measurement of a present sensor is a status field named <sensor>.<measurement> in the canonical unit of its type, and is absent until the sensor has delivered a value. Observations of one quantity from different sensors are never merged. Every request takes a fresh snapshot, so values and states are correct at request time.

$ rohi status sensors
Service:  sensors
State:    running
Health:   ok
Ready:    yes
Since:    2h 14m

bmx280.temperature  21.5 C
bmx280.humidity     45 %
bmx280.pressure     100800 Pa
scd4x.co2           640 ppm
sds011.pm2_5        6.5 ug/m3
FieldUnitFieldUnit
bmx280.temperature°Czmod4510.o3, zmod4510.no2ppb
bmx280.humidity% RHzmod4510.aqi_fast, zmod4510.aqi_epaindex
bmx280.pressurePaags3871.coppm
scd4x.co2ppmradsens.radiationµSv/h
scd4x.temperature, scd4x.humidity°C, % RHradsens.pulsespulses
sds011.pm2_5, sds011.pm10µg/m³microphone.average, microphone.maximumdB

Service state: disabled when no sensor is detected (not an error), starting while every present sensor is initializing or warming up, otherwise running with health ok, degraded when some sensor is degraded or failed and error when all present sensors failed. A sensor in error or degraded state is an active issue with the code <sensor>_error or <sensor>_degraded (at most four are listed). The service is not essential for the device. The status schema has at most 20 fields.

Configuration store

rohi-config is a hardware independent, transactional configuration store. Configuration is a set of independent typed sections; there is no central DeviceConfig. A feature crate defines its own section and the store persists it without knowing anything about it.

The store only persists bytes. How users see and change a section (rohi config, atomic patches of overrides, secret fields, revisions) is a separate, generic layer: a feature crate also implements ManagedConfig for its typed section, see Service pipeline.

feature crate
    ↓
typed section ── overrides (`#[derive(ConfigOverrides)]`) or `#[derive(ConfigSection)]` ── section ID / version
    ↓
minicbor payload
    ↓
CBOR SectionRecord
    ↓
CBOR ConfigPayload
    ↓
CBOR SlotEnvelope (+ CRC-32) ── commit word
    ↓
A/B flash transaction ── ConfigStorage
                          ├─ MemoryStorage      (host tests)
                          └─ EspConfigStorage   (ESP32 flash)

Crates:

CrateResponsibility
rohi-configConfigSection, Override, ConfigOverrides, HasOverrides, materialize, ConfigStorage, ConfigStore, CBOR slot format, MemoryStorage
rohi-config-derive#[derive(ConfigSection)], #[derive(ConfigOverrides)], section IDs from struct names
rohi-config-espESP32-C3 / ESP32-C6 flash backend, bounded flash region, partition lookup

Each feature crate owns its own schema (for example rohi-net owns NetworkConfig and NetworkOverrides); rohi-config never learns the set of application sections.

Defining a section

A hand written section derives the codec and ConfigSection (this is how Identity is defined):

use minicbor::{CborLen, Decode, Encode};
use rohi_config::ConfigSection;

#[derive(Encode, Decode, CborLen, ConfigSection)]
#[cbor(array)]
#[config(version = 1)]
pub struct Identity {
    #[n(0)]
    pub secret: Secret,
}

store.set(&identity)?;
let identity = store.get::<Identity>()?; // Option<Identity>

A section that users configure is split in two. The typed struct (WifiConfig) is the effective configuration and is not persisted; its Default is the portable baseline. What the store holds is the user’s explicit overrides, generated from the struct itself by #[derive(ConfigOverrides)]:

#[derive(Clone, ConfigOverrides)]
#[config(overrides = WifiOverrides, version = 1)]
pub struct WifiConfig {
    #[config(id = 1)]
    pub ssid: Option<WifiSsid>,
    #[config(id = 2)]
    pub password: Option<WifiPassword>,
    #[config(id = 3)]
    pub scan_timeout_ms: u32,
}

store.set(&overrides)?;
let overrides = store.get::<WifiOverrides>()?;          // Option<WifiOverrides>
let wifi: WifiConfig = materialize(&firmware, &user);   // default + firmware overrides + user overrides

The derive generates, next to the struct and with its visibility, WifiOverrides with an Override<T> per field, whose type is taken from the struct (Override::Default: the user did not override it, Override::Value(v): explicit; nullable fields are Override<Option<T>>, so Value(None) differs from Default). It derives the CBOR codec as a map keyed by the stable field ids and implements ConfigSection (the section ID is the hash of the name of the config type, WifiConfig, never of WifiOverrides), ConfigOverrides<WifiConfig> (apply_to, is_overridden, capture, clear, is_empty(), FIELD_IDS) and HasOverrides for WifiConfig, which links the config to its overrides type. The generated code only overlays the explicit overrides on a base value; it never decides the base. rohi_config::materialize is that rule: C::default(), then the firmware overrides, then the user overrides. A section without overrides encodes as the empty map. Every field needs #[config(id = N)]; generic types, tuple structs and duplicate ids are compile errors.

store.remove::<T>() deletes one section, store.clear() all of them. The caller never handles section IDs, record framing, CRCs, slots or generations.

Section payloads are CBOR. Field numbers, the explicit #[cbor(array)] / #[cbor(map)] representation, strict decode, secret handling, section IDs, versioning, the container format and unknown-section preservation are persistent ABI and documented once in Persistent ABI. The step by step recipe is Add a ConfigSection.

The #[config(version = N)] attribute is required for a derived section (#[config(overrides = Name, version = N)] on a config); id is optional (and decode_versioned for ConfigSection), see Section versioning.

Transactions

Every update streams the complete new document into the inactive slot; the active slot is never modified:

plan (exact CBOR sizes, nothing written; StorageFull / SectionTooLarge / TooManySections)
 -> clear the inactive slot's commit word (program it to 0), then erase the slot
 -> stream envelope, payload and records: the replaced section is encoded, all others copied byte-for-byte
 -> write CRC
 -> read back and verify the whole slot
 -> write commit word LAST

After interruption at any point during an update, the previous valid configuration or the completely committed new configuration remains loadable.

The host tests prove it by failing the storage after every possible number of programmed bytes (torn writes, including a torn commit word) and erased bytes, then reopening the store; the erase faults include documents that reach into the sector holding the commit word.

Generation is a u32 inside ConfigPayload. ConfigStore::open picks the committed, valid slot with the newest generation using wrap-safe serial arithmetic, so u32::MAX -> 0 is handled.

Open states: empty versus corrupt

ConfigStore::open distinguishes a fresh device from damaged configuration:

FlashResult
Neither slot has a commit word (erased, or first write interrupted before the commit)ConfigOpenState::Empty
Every committed slot validatesConfigOpenState::Valid (newest generation is used)
One committed slot is invalid, the other validatesConfigOpenState::Recovered (the valid slot is used)
Committed slot(s) exist but none validatesErr(ConfigError::CorruptStorage(reason))

Recovered does not say which slot was newer: the generation of the damaged slot cannot be read, so the active configuration may be older than an update that was lost, or the damage may have hit the older, inactive copy. The next successful update overwrites the damaged slot. open_state() keeps reporting how the flash looked at open; state() is the current health and turns Valid after that update (and after the first update of an empty store).

Corruption is never reported as an empty store, so a corrupted Identity cannot look like “not provisioned”. The Identity section is described in Device identity. Only genuinely erased storage reads as absent. The store also exposes generation(), active_slot() and section_count() for diagnostics.

Recovering corrupt storage

ConfigStore::open drops the storage on failure. ConfigStore::try_open returns an OpenFailure { storage, error } instead, so the caller can decide what to do with the flash. ConfigStore::format(storage) is the explicit way out of CorruptStorage: it clears both commit words, erases both slots and returns an empty store, discarding every section (known, unknown or corrupted). An interrupted format never leaves a committed, half-erased slot: each slot is either untouched or uncommitted, and running format again completes it.

Errors

ErrorMeaning
SectionError::Decode(DecodeFailure)Payload is not a valid CBOR encoding of the type
SectionError::TrailingBytesValid value followed by extra bytes
SectionError::UnsupportedSectionVersionStored version unknown to this firmware
ConfigError::SectionTooLargeEncoded section above MAX_SECTION_PAYLOAD
ConfigError::StorageFullResulting document does not fit a slot (nothing was written)
ConfigError::TooManySectionsMore than MAX_SECTIONS sections
ConfigError::CorruptStorage(Corruption)Committed configuration unreadable (Envelope, Crc, Payload, Record, DuplicateSection, TooManySections, UnsupportedFormatVersion)
ConfigError::Storage(E)The storage backend failed

Storage backends

pub trait ConfigStorage {
    type Error;
    const ERASE_SIZE: usize;
    const WRITE_SIZE: usize;
    fn capacity(&self) -> usize;
    fn read(&mut self, offset: usize, output: &mut [u8]) -> Result<(), Self::Error>;
    fn write(&mut self, offset: usize, data: &[u8]) -> Result<(), Self::Error>;
    fn erase(&mut self, offset: usize, len: usize) -> Result<(), Self::Error>;
}

write follows NOR programming: it may only clear bits. The store writes erased bytes, except that it programs an already written commit word to zero before erasing its slot, so a backend must allow re-programming a word with a value that only clears bits.

  • MemoryStorage<N, ERASE> emulates flash (erase to 0xFF, writes may only clear bits, MemoryError::NotErased otherwise) and offers deterministic fault injection: fail_write_after(bytes) tears the write at that byte, fail_erase_after(bytes) leaves a partially erased sector.
  • EspConfigStorage<F: MultiwriteNorFlash> restricts an embedded-storage NOR flash to a FlashRegion { offset, length }. MultiwriteNorFlash is the embedded-storage marker for flash that can program a word more than once (esp-storage’s FlashStorage implements it; encrypted flash would not qualify). Every offset is region-relative and checked; writes and erases must be word/sector aligned (EspStorageError::InvalidAlignment), unaligned reads are served through a small bounce buffer. Errors are the typed OutOfBounds, InvalidAlignment, InvalidRegion, ReadFailed, WriteFailed, EraseFailed. With a chip feature (esp32c3 or esp32c6), rohi_config_esp::esp::config_storage builds it on the esp-storage driver and the rohi-cfg partition. It knows nothing about CBOR, sections or any feature’s configuration.

Runtime ownership (Altruist Urban)

The firmware opens the store once at boot and hands it to a configuration task, the single logical owner, as described in Runtime ownership. Other code (the control layer) uses a cloneable ConfigHandle:

ConfigStore ← config task ← bounded request channel ← ConfigHandle::{get, set, remove, status}

Nothing outside altruist_urban::config touches the configuration flash and there is no global mutable store. If the partition is missing or committed configuration is corrupt the firmware keeps running, and every request fails with ServiceError::Unavailable (status() tells why) instead of behaving as if the configuration were empty. status() reports the store’s current state(), so Recovered becomes Valid once an update repaired the slot.

The owner task also publishes a committed WifiConfig or NetworkConfig to the Wi-Fi runtime as the desired configuration, in the same step as the commit and with no .await in between (and once at boot from the stored value). Cancelling a ConfigHandle::set or remove therefore either happens before the request was queued (nothing changed) or the operation is logically completed: see Async and cancellation.

ConfigHandle is section-oriented only: there is deliberately no “erase everything” operation, so ordinary maintenance such as removing WifiConfig never touches unrelated or future protected sections (for example an identity). The way out of corruption without reflashing is the separate config::maintenance() handle: StorageMaintenance::format_storage() formats an unavailable store (corrupt or unreadable, partition present) with ConfigStore::format and the service becomes ready with an empty store. It refuses a ready store (ServiceError::StorageHealthy) and cannot help a missing partition. The factory reset is its own explicit, privileged operation: StorageMaintenance::factory_reset() formats the region whatever its state, so it also removes the identity, unknown sections and the generation counter, and it repairs corrupt storage. The control protocol exposes it as FactoryReset (rohi factory-reset).

Flash erase and programming are synchronous, and esp-storage runs them in a critical section with the flash cache disabled. An update therefore stalls the whole CPU, not just the configuration task (each 4 KiB sector erase takes tens of milliseconds), and moving the task to another executor would not change that. Configuration updates are rare and must not be issued from timing-critical paths.

Flash layout (Altruist Urban, ESP32-C3 4 MB / ESP32-C6 8 MB)

The single source of truth is the per-chip firmwares/altruist-urban/partitions-esp32c3.csv (4 MB flash) and partitions-esp32c6.csv (8 MB flash), mirrored by the constants in rohi_config_esp::layout (a host test keeps both in sync). Both tables differ only in the size of the factory partition (0x3E0000 on ESP32-C3, 0x7E0000 on ESP32-C6):

0x09000  nvs        24 KiB
0x0f000  phy_init    4 KiB
0x10000  rohi-cfg   16 KiB   <- configuration region
           ├─ slot A  8 KiB (2 sectors)
           └─ slot B  8 KiB (2 sectors)
0x20000  factory    app (3.9 MB on ESP32-C3, 7.9 MB on ESP32-C6)

cargo run passes the table of the selected target to espflash (see .cargo/config.toml); run it from the repository root. If the partition is missing the firmware logs an error and keeps running the sensors without persistent configuration.

Resource baseline

Flash region, slot, payload and stack figures of the store are listed with all other limits in Resource limits. Commit streams CBOR straight into flash and copies unrelated records in 64 byte chunks, so there is never a second copy of the configuration in RAM, and no heap or dynamic CBOR tree is used.

Hardware self-test

examples/src/bin/config.rs verifies persistence on a real ESP32-C3 or ESP32-C6. Flash it and reset or power-cycle the board repeatedly:

cargo run --release -p rohi-examples --features esp32c3 --target riscv32imc-unknown-none-elf --bin example-config

Every boot checks what the previous boot stored (typed write + reboot + read, several sections, replacement preserving unrelated sections, repeated write cycles) and logs SELFTEST PASS or SELFTEST FAIL. On the third boot it corrupts the newest slot on purpose and checks that the store reports Recovered and falls back to the previous generation.

Not in this layer

Device identity is a section of this store and is described in Device identity; BLE/Wi-Fi control, OTA, flash encryption and JSON configuration are out of scope. The format is CBOR only; there is no migration from the earlier development-only binary header format.

Networking (rohi-net)

rohi-net is the portable networking layer of the SDK. It is organised in three layers; each has one job and one entry point:

 application / control plane / future uplink (TLS, HTTP)
        │                              │
   WifiHandle                    NetworkHandle
   Wi-Fi control plane           IP data plane
   configure · scan · status     DNS · TCP · UDP
   reconnect · disconnect        embedded-nal-async + embedded-io-async
        │                              │
   WifiManager  ── WifiBackend    IpBackend
        │                              │
        └──────────── rohi-net-esp ────┘      ESP backend: EspWifi, EspIp
                           │
              esp-radio · embassy-net · esp-rtos · radio heap
LayerQuestion it answersOwner
Wi-Fi control plane (WifiHandle)Which network should the radio use, and is it connected?WifiManager (one task)
IP data plane (NetworkHandle)Can I resolve a name, open a TCP connection or send a UDP datagram?the platform IP stack (EspIp)
ESP backend (rohi-net-esp)How are the radio and the IP stack driven on ESP32-C3 / C6?EspWireless

Wi-Fi connected is not the same as a TCP connection can be opened. The control plane reports link state; the data plane has its own readiness (link up and DHCP complete) and its own typed errors. Higher-level crates must use NetworkHandle (or the standard traits it implements) and never touch embassy-net or any vendor type directly: no esp-radio, esp-hal or embassy-net type appears in a public signature of rohi-net; vendor types and errors are converted inside rohi-net-esp. rohi-net has no ESP dependency and builds and tests on the host.

The wifi, network and ble sections and services

The management plane sees the radio side of the device as three config sections and three services, named wifi, network and ble (rohi config wifi, rohi status network). Each section holds only the properties of its own target; the Wi-Fi station and the IP layer are configured and observed separately.

wifinetworkble
Configssid, password (secret), scan_timeout, connect_timeout, backoff_initial, backoff_max, link_refresh (ms)address_mode (dhcp or static), ip, prefix_len, gateway, dns, hostname (DHCP option 12), dhcp_timeout (ms). Static mode needs ip, prefix_len and gateway on the same linkname (1 to 29 bytes, optional; without an override the firmware default <PREFIX>-<MAC> applies). Read when the peripheral starts, so a change applies after a reboot
Statusssid, rssi, channel, mac, reconnects, link_uptime, heap_size, heap_free, heap_min_free (with heap-stats)ip, gateway, dns, tcp_in_use, tcp_capacity, udp_in_use, udp_capacitystate (disabled, advertising, connected, error), name, encrypted, authenticated, max_payload
Actionsscan, reconnect, disconnectnonenone

Fields that do not exist now are not reported. A change of the wifi or network section reconnects. A section is the effective configuration (firmware defaults plus your overrides); ssid is nullable and None is the default: without an ssid Wi-Fi is disabled and the radio stays idle, an ssid enables it. ssid=none disables Wi-Fi explicitly even when the firmware default names a network, ssid=null follows the firmware default again. Timings default to the former constants. The wifi and network services share the link: network is running when the link is up and reports dhcp_failed as its issue; every other link error is an issue of wifi.

The heap, clock and socket numbers come from a WifiProbe and a NetworkProbe the firmware passes to WifiService and NetworkService, and the BLE state from a BleProbe passed to BleService, so rohi-net stays free of platform types. Socket counts and buffer sizes stay compile-time (they size static memory), see Resource limits.

Wi-Fi control plane

WifiHandle is a cheap, copyable handle to the WifiManager, the single owner of the Wi-Fi backend. It does not carry DNS, sockets, TLS or HTTP.

TypeRole
WifiConfigPersistent CBOR section: ssid, optional password (open network = None), scan and connect timeouts, reconnect backoff and link refresh
NetworkConfigPersistent CBOR section: DHCP or static IPv4, host name, DHCP timeout
LinkConfigThe two together, as the manager and the backend see them
WifiState / WifiStatusNotConfigured, Idle, Scanning, Connecting, Connected, Disconnected, Error (+ last WifiError)
NetworkInfoSSID, RSSI, IPv4, gateway, DNS. Never contains credentials
WifiNetwork / WifiScanResultsBounded scan result (heapless::Vec), strongest first
WifiErrorStable typed errors (AuthenticationFailed, DhcpFailed, Busy, …)
WifiShared / WifiHandlestatic shared state and the handle other tasks use

Connected means network ready: association and DHCP have completed.

Desired state versus commands

The persistent configuration is state, the rest are transient actions (see State versus command):

persistent WifiConfig + NetworkConfig ──► desired link config ──► WifiManager ──► actual Wi-Fi state
                                     (latest value wins)      reconciles
Scan · Reconnect · Disconnect ──► bounded command queue ──► WifiManager
  • WifiHandle::set_wifi_config(WifiConfig) (the whole effective section: without an SSID no network is configured, yet wifi_settings() keeps the timing, so the scan timeout stays effective) and WifiHandle::set_network_config(NetworkConfig) publish the desired configuration. They are synchronous: no .await, no queue, never Busy. The latest value wins; updates published before the manager ran are coalesced, so rapid updates A, B, C converge directly on C instead of causing a connect/disconnect cycle for A and B.
  • scan(), reconnect() and disconnect() are commands in a bounded queue (WIFI_COMMAND_QUEUE_DEPTH). They return Busy when the queue is full, and only one scan can be in flight.
  • Who publishes: the firmware’s configuration owner task publishes the committed value right after the commit and once at boot, so a cancelled caller cannot leave a stored configuration unknown to the runtime (Async and cancellation).

Reconciliation behaviour

Desired updateBehaviour
Nonedisconnect if necessary, NotConfigured, no automatic reconnect
new Some(config), or a changed NetworkConfigabort the running attempt or backoff, disconnect, connect with the new config and a fresh backoff
same config againnothing: attempt, backoff timer, idle state or link continue (use reconnect to force a new attempt)
invalid WifiConfig or NetworkConfigdisconnect, NotConfigured with WifiError::InvalidConfig

The same rules hold in every phase (connecting, backing off, connected, idle). An update published while a scan runs is applied after the scan. A new configuration invalidates retry state of the previous one: if AP A had a wrong password and a 60 s backoff, AP B does not inherit the remaining wait.

Other behaviour:

  • No configuration: state NotConfigured, no retry loop.
  • Failure or link loss: bounded exponential backoff (1 s doubling up to 60 s, see Resource limits). The backoff is reset only after the link stayed up for one info refresh interval (10 s), so a flapping link keeps backing off. Failures never panic or reboot; sensors keep running.
  • Scans never abort or delay reconnection: a scan requested while connecting is served after the attempt, and a scan during the backoff wait or while connected does not restart the backoff or refresh timer.

Wi-Fi in the service pipeline

rohi-net exposes Wi-Fi to clients through the generic service pipeline, with no protocol of its own:

PlaneWhatrohi
config section wifissid (text, 1 to 32 bytes) and password (write-only secret, 8 to 64 bytes, optional: no override means an open network)rohi config wifi ssid=office password=-
service wifistate, ssid, rssi (dBm), channel, macrohi status wifi
action scanone record per network, strongest first: ssid, rssi, channel, securityrohi action wifi scan
actions reconnect, disconnect (privileged)drop the link and connect again / stay idlerohi action wifi reconnect

The service maps WifiState to lifecycle and health: NotConfigured is disabled (not an error), Idle is stopped, Scanning and Connecting are starting, Connected is running/ok/ready, Disconnected is running/degraded (not ready) and Error is failed/error. The last error is the active issue (authentication_failed, network_not_found, dhcp_failed, association_failed, disconnected, driver_error, …). A scan is bounded by the configured scan_timeout of WifiService (20 s by default) and lists at most six networks; networks whose name is not UTF-8 are not listed. Storing a configuration means it is stored and handed to the manager, not that the access point was reached: watch rohi status wifi. SSIDs that are not UTF-8 keep working but are not shown by rohi config wifi.

IP data plane

NetworkHandle<'a, B: IpBackend> is a copyable handle to the IP stack. It implements the standard embedded Rust traits, so TLS, HTTP and uplink layers stay independent of embassy-net:

TraitProvided by NetworkHandle
embedded_nal_async::Dnsget_host_by_name (IPv4); reverse lookup is not supported
embedded_nal_async::TcpConnectconnect → a connection that implements embedded_io_async::{Read, Write}
/// Sends `request` to `host:port` and reads the first chunk of the answer.
///
/// Generic over the standard traits: it works with `NetworkHandle`, with any
/// other `embedded-nal-async` stack, and it is what a TLS or HTTP layer would
/// build on.
async fn exchange<N>(
    net: &N,
    host: &str,
    port: u16,
    request: &[u8],
    answer: &mut [u8],
) -> Result<usize, NetError>
where
    N: Dns<Error = NetError> + TcpConnect<Error = NetError>,
{
    let address = net.get_host_by_name(host, AddrType::IPv4).await?;
    let mut connection = net.connect(SocketAddr::new(address, port)).await?;
    connection.write_all(request).await?;
    connection.flush().await?;
    connection.read(answer).await
}

This function is compiled and run against the host fake backend by cargo test -p rohi-net (tests/network_example.rs). On the device the same function runs against the firmware’s handle; see Use IP networking.

Readiness and errors

Every operation first checks readiness (link up and DHCP complete) and fails with NetError::NetworkUnavailable while Wi-Fi is not configured or connecting, DHCP is incomplete or the link was lost, also when readiness is lost during the operation. Higher layers can therefore tell an unusable network from a DNS or connect failure while the network is up.

NetErrorMeaning
NetworkUnavailableWi-Fi not configured/connecting, DHCP incomplete, link lost
DnsFailedResolution failed while the network was up
ConnectFailedConnection refused or unreachable while the network was up
NoSocketAvailableAll statically allocated TCP connections (or UDP sockets) are in use
ConnectionResetAn established connection was reset or closed
TimeoutA timeout expired
BackendUnexpected stack failure

NetError implements embedded_io_async::Error; raw embassy-net errors never reach higher layers.

Socket resources

TCP connections and UDP sockets are statically bounded; nothing allocates. Opening more simultaneous connections or sockets than the product configured fails immediately with NetError::NoSocketAvailable: connect and udp_socket never wait for a free slot. A slot is released when the connection or socket is dropped. The capacity is a product decision, see Resource limits.

UDP

NetworkHandle::udp_socket() opens a UdpSocket bound to an ephemeral port (it fails with NetworkUnavailable while the network is not ready). send_to and recv_from take &self; a datagram larger than the receive buffer is discarded and the wait continues. There is no built-in receive timeout: callers bound the wait themselves (the NTP source does, see Time service).

Cancellation

All NetworkHandle operations are cancel-safe: dropping get_host_by_name allocates nothing; dropping a pending connect aborts the attempt and frees its slot; dropping a UDP socket frees its slot, and a datagram arriving after a cancelled recv_from is delivered to the next call; dropping a read or write future transfers nothing that was not reported. See Async and cancellation.

Testing without hardware

rohi_net::testing::FakeIp (feature testing) is an in-memory IpBackend: scripted DNS answers, refused connections, readiness changes, slot exhaustion, held operations for cancellation tests and the peer side of every connection and UDP socket (udp_peer_send, udp_peer_received). See Host testing.

ESP backend (rohi-net-esp)

The only platform-specific entry point is EspWireless (see Wireless runtime), called once by the board firmware. It owns the radio peripherals and the single radio heap shared with BLE and creates:

  • EspWifi (implements WifiBackend) and its NetRunner (the background task of the IP stack),
  • EspIp (implements IpBackend): DNS, TCP and UDP on the same embassy-net stack.

The firmware, not the backend, decides how much RAM this takes: it passes a NetResources value (stack sockets, TCP connections, UDP sockets, buffer sizes) and the heap size. See Memory model.

The embassy-net features are limited to what a current component uses: DHCPv4, DNS, TCP, UDP (for the NTP time source) and the Ethernet medium used by esp-radio. Policy: platform and network protocol features are enabled only when a current SDK component uses them; later work adds features together with the component that needs them (Targets and features).

Memory policy

Application and runtime code is heapless. The Espressif wireless stack needs dynamic allocation, so the ESP backend uses a fixed-size wireless heap (esp-alloc, no growth) that Wi-Fi and BLE share. Heap-backed vendor results (for example the scan Vec) are converted into bounded ROHI types inside the backend and dropped there. See Memory model.

Security

The Wi-Fi password is secret: WifiPassword has a redacting Debug, nothing logs configuration contents, and NetworkInfo has no credential field. Only the backend reads the secret (expose_secret). The control API (see Control plane) never returns the password and accepts it write-only. The TCP layer has no TLS: do not send secrets over NetworkHandle without a TLS layer on top.

Out of scope

SoftAP, TLS/HTTP(S), NTP, Connectivity Protocol uplink, multiple saved networks, static IP, WPA enterprise. WifiConfig authentication currently selects WPA/WPA2 personal; WPA3-only networks are not yet supported by the esp-radio station configuration used here.

Hardware validation status of Wi-Fi, IP and coexistence is tracked in Hardware testing.

Service pipeline (rohi-service)

rohi-service is the one place where a firmware module describes itself to the outside world. A module offers up to three things, all through generic types, so the control protocol, the rohi tool and any other client never know a module by name:

PlaneQuestionOwner typeWire operations
Configwhat is the device set up to do? (desired, persistent)ManagedConfigListConfigSections, GetConfigSchema, GetConfigSection, PatchConfigSection, ResetConfigSection
Statuswhat is happening now? (observed, read-only)ManagedServiceGetServicesStatus, GetServiceSchema, GetServiceStatus
Actionswhat can I ask it to do? (explicit operations)ManagedService::ACTIONSListActions, GetActionSchema, InvokeAction

Capabilities, config and status are separate on purpose: a service can exist without a config section, a section can exist without a service, and an action is never smuggled into status. See the user-facing view in Services and status.

 rohi-control (dispatcher)  ──►  ControlBackend { config(), services() }
                                       │                    │
                          ConfigRegistry (tuple)     ServiceRegistry (tuple)
                                 │                          │
                   ManagedSection<C, Store>          Tracked<S: ManagedService>
                                 │                          │
                     typed section C: ManagedConfig   live runtime state of S

rohi-service is no_std, allocation free and portable. It does not depend on the protocol, on rohi-config or on any feature crate; each feature crate implements the traits for its own types (rohi-net, rohi-connectivity, rohi-time, rohi-hal, rohi-identity, rohi-location).

Values and fields

Everything crossing the boundary is a Value: Bool, I64, U64, F32, String, Bytes or Enum. Floats are single precision; a counter, a timestamp or an id is an integer. An Enum value is a number (u32). A FieldDescriptor { id, name, kind, unit, enum_id } describes one field once, as a const:

FieldDescriptor::new(2, "rssi", ValueKind::I64).unit(Unit::Dbm)

The Unit is a code, not text, and there is no description: documentation belongs to these pages, not to every response of the firmware. The named variants of an enum field are listed once per section, service or action as EnumDescriptor::new(id, &[EnumVariant::new(value, "name"), ..]) (ManagedConfig::ENUMS, ManagedService::ENUMS, ActionDescriptor::enums) and the field refers to the table with .with_enum(id); checked values must be one of its variants.

Field ids are stable and only ever added; names use dotted paths for nested fields (bmx280.temperature). Config fields extend the descriptor (ConfigField) with writable, secret, optional (the field may have no value: its default is none) and typed Bounds (Bounds::U64(min, max), I64, F32): the bounds are checked by the device with the integers staying integers and are not sent to clients; the typed validate() stays authoritative. Value’s Debug shows only its kind, so a secret never reaches a log by accident.

Config sections

A module implements ManagedConfig for its typed section, the effective configuration, and keeps all knowledge about it. The persisted part is an overrides struct generated from the config by #[derive(ConfigOverrides)] (see Configuration store) that holds an Override<T> per field: Override::Default (the user did not override it) or Override::Value(v) (explicit; nullable fields use Override<Option<T>>: Value(Some(v)) is an explicit value, Value(None) an explicit “no value” and Default follows the layer below). The effective configuration is C::default() (the portable baseline owned by the module), then the firmware overrides compiled into the product, then the user overrides: rohi_config::materialize. Firmware and user overrides are two values of the same generated type. The firmware supplies its part through SectionStore::firmware_overrides(); it is never persisted.

// ConnectivityConfig: Clone + Default + #[derive(ConfigOverrides)]. The id of the section (the address of
// requests) and its version are those of `ConnectivityOverrides` as a `ConfigSection`.
impl ManagedConfig for ConnectivityConfig {
    const NAME: &'static str = "connectivity";
    const FIELDS: &'static [ConfigField] = &[
        ConfigField::new(3, "interval_s", ValueKind::U64).unit(Unit::S).bounds(Bounds::U64(10, 86_400)),
        // ...
    ];
    fn read_field(&self, id: u32) -> Result<Option<Value>, FieldError> { /* typed -> Value, None = no value */ }
    fn field_is_set(&self, id: u32) -> Result<bool, FieldError> { /* without reading a secret */ }
    fn set_field(&mut self, id: u32, value: Value) -> Result<(), FieldError> { /* Value -> typed */ }
    fn set_none(&mut self, id: u32) -> Result<(), FieldError> { /* optional fields: no value; default: NotOptional */ }
    fn validate(&self) -> Result<(), FieldError> { /* the whole effective section, across fields; an unconfigured or disabled section is valid */ }
}

The schema only expresses simple constraints (kind, enum variants, numeric range); everything else stays in typed validate. A ManagedSection<C, S, M> adds the generic behaviour on top of a SectionStore<C> (firmware_overrides, load, store, remove of the user overrides, restart_required), which the firmware implements over its configuration owner task. Reads, validation and runtime publication all use materialize(firmware_overrides(), user), and store / remove receive that same effective value to publish.

  1. Patch semantics. A patch is a list of set / none / clear mutations by field id. Load the stored overrides (or an empty set) and apply all mutations to a temporary copy: set checks kind and range, sets the value on the effective copy and captures it as an explicit override; none sets an optional field to no value and captures that as an explicit override (a required field answers NotOptional); clear removes the override of any writable field, required or not, so the field follows the firmware override, or else the default, again. Then validate the complete effective config (defaults + firmware overrides + candidate user overrides). An unconfigured or disabled section is a valid state (wifi without an SSID is disabled, a location without coordinates, connectivity disabled), so every patch is validated. Then store atomically (a result without any override removes the persisted section), publish the same effective config, increment the revision. If anything is invalid, nothing is stored. A field that is not mentioned keeps its override or its default. Consequence: fields that belong together are set in one patch (enabled=true alone on connectivity fails until host is set).
  2. Secrets (secret) are write-only: GetConfigSection reports them as redacted, never their value.
  3. Dry run validates exactly like a real patch and stores nothing; the revision does not change.
  4. Revision (u32, RAM only, 0 at boot) increments on every successful write or reset; expected_revision makes a patch or a reset fail with CONFLICT if the section changed meanwhile.
  5. Service actions that change the section use ConfigUpdate::update of the same ManagedSection: the same lock, validation, publication and revision as a patch, never a separate load and store.
  6. Reset removes every user override and publishes the firmware policy over the defaults; compiled firmware overrides are retained.
  7. restart_required tells the client that a change needs a restart.

GetConfigSection returns the effective values (SectionValues::entries; a field without a value, such as an optional field whose default is none, is absent) and overridden, the ids of the explicitly overridden fields. A changed firmware override or default reaches every field the user did not override; an explicit override survives it. The three states of a nullable field stay distinct: FIELD=value is an explicit value, FIELD=none (MutationOp::None) an explicit “no value” and FIELD=null (MutationOp::Clear) removes only the user override and follows the current firmware override or default (server2 of the time section defaults to time.cloudflare.com: server2=none disables the second server, server2=null restores that default).

The store must commit and publish to the runtime in one step, so a cancelled caller can never leave a stored value unknown to the runtime (Durable mutation). The firmware owner publishes the effective config (defaults, firmware overrides and user overrides, also after a removal).

A ConfigRegistry is a tuple of sections; it dispatches by name:

type ConfigSections = (
    ManagedSection<WifiConfig, Stored<WifiOverrides>, RawMutex>,
    ManagedSection<ConnectivityConfig, Stored<ConnectivityOverrides>, RawMutex>,
    ManagedSection<LocationConfig, Stored<LocationOverrides>, RawMutex>,
);

Adding a section means adding it to the tuple (up to 8). Nothing else changes: not the protocol, not the client, not the CLI.

Status

A module implements ManagedService on top of its live state. There is no stored status struct to duplicate:

impl ManagedService for WifiService<'_, M, D> {
    const ID: u32 = 1;                              // stable numeric id, the address of requests
    const NAME: &'static str = "wifi";
    const VERSION: u16 = 1;
    const STATUS_SCHEMA: &'static [FieldDescriptor] = &[ /* ssid, rssi, ip, ... */ ];
    async fn summary(&self) -> ServiceSummary { /* state, health, ready */ }
    async fn issues(&self, out: &mut Vec<Issue, MAX_ISSUES>) { /* active issues */ }
    async fn status(&self, out: &mut impl StatusWriter) -> Result<(), WriteError> { /* fields */ }
}

State, health and readiness are separate values, never one big enum:

Values
ServiceState (lifecycle)disabled, stopped, starting, running, stopping, failed
ServiceHealth (quality)unknown, ok, degraded, error
readywhether the service can currently do its job

Examples: a connecting Wi-Fi is starting / unknown / not ready; a telemetry service that cannot reach its backend is running / degraded / not ready; an optional GPS turned off is disabled / unknown / not ready, which is not an error.

Issues are the currently active problems, each with a stable numeric code, a severity (info, warning, error, critical) and an age. The names of the codes are listed once in ManagedService::ISSUES and sent with the schema, never with every status; there is no message text. Codes below 256 are reserved for the SDK (Startup reports ISSUE_STARTUP_FAILED), services start at FIRST_SERVICE_ISSUE. History does not belong here.

Tracked<S, M> wraps a service and adds what every service needs without its cooperation: a status revision that changes when state, health, readiness or the active issues change (values that vary all the time, such as counters or signal strength, deliberately do not bump it), and the time in the current state. Revisions and ages are derived from what the service reports when a client asks.

Device health is decided by the firmware, never by the client. ControlBackend::device_health defaults to default_device_health: disabled services do not count; a failed or error service is an error for the device when it is ESSENTIAL (the default) and a degradation otherwise; a degraded service, or a running one that is not ready, degrades the device. Firmware with other redundancy rules overrides it.

Actions

Status is read-only. Everything that does something is an action declared by the service:

const ACTIONS: &'static [ActionDescriptor] = &[
    ActionDescriptor::new(1, "scan")
        .results(&[ /* ssid, rssi, channel, security */ ]),
    ActionDescriptor::new(2, "reconnect").privileged(),
];

async fn invoke(&self, action: u32, args: &[(u32, Value)], out: &mut impl ActionOutput)
    -> Result<(), ActionError> { /* ... */ }

An action takes typed arguments (all required, checked against the descriptor before invoke runs) and returns zero or more records of typed fields (a scan returns one record per network, a reconnect none). privileged() requires the write privilege of the link policy; secret() marks arguments that are secrets (identity provisioning): they imply privileged, are the Provision privilege, and are overwritten after the call. An action can fail with InvalidArgument, Busy, Unavailable, Internal or Rejected("code"), a refusal with a stable machine-readable reason such as already_provisioned.

Registries

ServiceRegistry is a tuple of Tracked services, like the config registry. Both traits are implemented for () (a device with no sections or services) and for tuples of up to 8. Requests address sections, services and actions by their numeric ids, which must be unique within a registry: Device::new panics in every build, including release, when two sections or two services share an id. Names are for discovery.

Schemas are validated when the section or service is built, as a compile-time error: section, service, action, field and issue names are not empty and at most 24 bytes (variant names 16), so nothing is ever truncated on the wire; field ids and names are unique within a schema, action ids and names within a service, issue codes and names within a service (codes from FIRST_SERVICE_ISSUE), enum ids are non-zero and unique, and variant values and names are unique within their enum. An enum field must refer to an enum of its table, no other field may. A section, service or action has at most one enum (MAX_ENUMS), a constraint of the compact protocol.

What lives where

CrateConfig sectionServiceActions
rohi-netwifi (ssid, password secret, timeouts, backoff), network (address mode, static IPv4, host name, DHCP timeout), ble (name)wifi (link, radio heap), network (address, sockets), ble (state, name, link security)wifi: scan, reconnect, disconnect
rohi-connectivityconnectivityconnectivity (queue, counters, endpoint)
rohi-locationlocationlocation
rohi-time / rohi-time-ntptime (NTP servers, rohi-time-ntp)time (unix time, source, age, failures)
rohi-halsensors (<sensor>.<measurement> values)
rohi-identityidentity (provisioned, public key)provision, erase

The firmware (altruist-urban/src/control.rs) builds the two tuples over its runtime handles and gives them to the dispatcher. Add a service shows the steps.

Limits

Bounds keep every message small enough for a microcontroller stack; they are defined once in rohi-control-proto/build.rs and listed in Control plane. In memory: MAX_FIELDS (status fields per service) 20, MAX_CONFIG_FIELDS 16, MAX_MUTATIONS 8, MAX_ISSUES 4, MAX_ACTION_ARGS 3, MAX_RECORDS 6, MAX_RECORD_FIELDS 4, string and bytes values 64 bytes.

Control plane (BLE)

The control plane lets a desktop (or any BLE central) inspect and configure a ROHI device locally. It is generic: the protocol, the dispatcher and the client know no module by name. They offer four planes, all driven by schemas the device publishes: device info, persistent config, runtime status and explicit actions. What those planes contain (Wi-Fi, telemetry, sensors, identity, location, …) is decided by the firmware’s registries, see Service pipeline. It is local control only: uplink telemetry, HTTP and SoftAP are out of scope.

 BLE radio
     │
 rohi-net-esp::ble         ESP32 backend (esp-radio HCI + TrouBLE GATT)       platform
     │  BleTransport
 rohi-control-ble          Control Service, framing, fragmentation            portable
     │  bytes
 rohi-control              dispatcher + authorization policy                  portable
     │  rohi-control-proto messages (bounded protobuf)
 ControlBackend            device info, uptime, config registry, service registry
     ├── ConfigRegistry    tuple of ManagedSection<..>     (rohi-service)
     └── ServiceRegistry   tuple of Tracked<ManagedService> (rohi-service)
              │
        feature crates implement ManagedConfig / ManagedService over their runtime handles

 rohi (library + CLI)      desktop client, btleplug

Dependency direction: rohi-control-proto ← rohi-control ← rohi-control-ble → rohi-net ← rohi-net-esp ← altruist-urban (see Crate map). rohi-control depends only on the protocol and rohi-service: it has no dependency on Wi-Fi, telemetry, sensors, time or identity, and no ESP specific control crate exists: the ESP part only exposes a generic byte pipe. rohi-net* never depends on rohi-control*.

Crates

CrateRole
rohi-net (ble)BleTransport, BleState, BleConnectionInfo, BleConfig, the ble section (BleSettings) and service (BleService): portable BLE API, no vendor types
rohi-net-espEspWireless (shared radio runtime), EspWifi, EspIp, EspBle
rohi-control-protocontrol.proto, bounded generated types (micropb), PROTOCOL_VERSION, limits
rohi-servicethe generic model: Value, ManagedConfig, ManagedService, registries; see Service pipeline
rohi-controlController, ControlBackend, Device (a backend built from registries), AuthorizationPolicy
rohi-control-bleService/characteristic UUIDs, frame (fragmenter, reassembler), ControlBle control loop
rohi (tools/rohi)Desktop library (Device, Session, BleLink, model) and the rohi command line tool

Everything except rohi-net-esp, the firmware and tools/rohi builds and is tested on the host.

Protocol

One protobuf request, one protobuf response, correlated by request_id. The schema is crates/control-proto/proto/rohi/control/v1/control.proto. protocol_version is 1 in every message; the protocol is still under development and changes without a version bump, so keep the tool and the firmware on the same revision. Unknown requests, a wrong version or an unknown name return a typed error; the link is never dropped because of a bad request.

PlaneRequestNeeds a secure link policy
infoGetInfono
configListConfigSections, GetConfigSchema, GetConfigSectionno
configPatchConfigSection, ResetConfigSectionyes (Privilege::Mutate)
configFactoryResetyes (Privilege::Provision, it erases the identity)
deviceRebootyes (Privilege::Mutate)
statusGetServicesStatus, GetServiceSchema, GetServiceStatusno
actionsListActions, GetActionSchemano
actionsInvokeActiondepends on the action: open, privileged (Mutate) or secret arguments (Provision)

Sections, services and actions have a stable name and a stable numeric id. The id of a section is the u64 id of its persisted section (derived from the name of the section type, see persistent ABI); the ids of services and actions are u32 declared by the module that owns them. Names are for discovery and for people; every request after discovery addresses by id (section_id, service_id, action_id), which keeps requests small. There is no capability list of modules: whether something exists is answered by ListConfigSections, GetServicesStatus (which carries id, name and version of every service) and ListActions. Ids are unique within their registry and only ever added. Errors:

ErrorMeaning
INVALID_ARGUMENTa value, field or argument is missing, unknown, of the wrong kind, out of range or fails the typed validation; nothing was changed
NOT_FOUNDno such section, service or action
NOT_SUPPORTEDunknown request or wrong protocol version
BUSY, UNAVAILABLEtry again later; the backing service or storage is not available
PERMISSION_DENIEDthe link does not satisfy the authorization policy
CONFLICTexpected_revision no longer matches the section
REJECTEDan action ran and was refused for the stable reason in Error.reason (for example already_provisioned)
INTERNALunexpected device failure

A response with request_id = 0 means the device could not parse the request at all.

Values and schemas

Config values, status values, action arguments and results all use the same value cases (bool, i64, u64, f32, string, bytes, enum), repeated flat in FieldValue, ActionArg, ConfigEntry and ConfigMutation instead of a nested generic message. Floats are single precision: counters, timestamps, ids and exact configuration values are integers. An enum value is a number; its name is schema metadata.

One FieldDescriptor { id, name, kind, unit, enum_id } describes a field. The unit is a code (Unit, for example UNIT_DBM), there is no description text on the wire (documentation belongs to the SDK pages), and the variants of enums are not part of the field: a schema response carries a small table of EnumDescriptor { id, variants[] } that fields refer to with enum_id, so fields that are no enums pay nothing for it. A service schema also names its issue codes (IssueDescriptor { code, name }). The client fetches a schema on demand, caches it for the connection and parses and renders values by it. Nothing is paged: every schema, status and action list fits one response, and the limits below are hard compile-time bounds. If a device ever exceeds them, pagination can come back with explicit snapshot semantics.

Config

A section has a name, a schema version and a revision. GetConfigSection returns the effective values (effective: the firmware defaults with the user’s overrides applied; a field without a value is absent, secrets are redacted, never their value) and overridden, the ids of the fields the user has explicitly overridden (the override value of such a field is its entry in effective). PatchConfigSection carries mutations by field id (a value, none or clear), an optional expected_revision and dry_run. A value sets an explicit override; none overrides an optional field with no value (refused for a required field); clear removes the override of any writable field so the field follows the firmware default again. They are three distinct operations on the wire (none = 9, clear = 10). The patch is atomic per section and the complete effective result (firmware defaults + overrides) is validated, see Service pipeline. ResetConfigSection removes every override of the section; like a patch it takes an optional expected_revision. The response carries the new revision and restart_required. FactoryReset formats the configuration storage: every section, sections the firmware does not know and the identity are removed and the runtime returns to the defaults. The response carries restart_required. Reboot asks the device to reboot: it answers first and reboots shortly after (the Altruist Urban about 1.5 s later), so the link is lost. A device without a reboot hook answers NOT_SUPPORTED. Success means the configuration is stored and handed to its runtime, not that the network was reached: observe the service status. There is no request that touches several sections; a client that needs that applies them one by one.

Status

GetServicesStatus is the compact request: the device uptime, the device health decided by the firmware and one summary per service (service_id, name, version, state, health, ready, revision, state_age_ms): discovery and status in one message. It never carries service-specific values. GetServiceStatus returns the service’s values, its revision and the currently active issues (numeric code, severity, age; the name of the code is in the service schema), but not the summary again. There is no human-readable reason in the protocol: the active issues say why. Status is read-only: there is no start, stop or reset in it. Pollers compare revisions and fetch the detail only when one changed. See Services and status.

Actions

ListActions returns (service_id, id, name, privileged, secret_args); GetActionSchema returns the argument and result fields. InvokeAction takes all arguments (typed, all required) and returns up to MAX_RECORDS records of typed fields. Arguments that are secrets are overwritten after the call; neither they nor the status of a service ever contain a secret.

BLE binding

One GATT service with two characteristics:

UUIDDirection
ROHI Control Service726f6869-0001-4d00-8000-636f6e74726f
RX726f6869-0002-4d00-8000-636f6e74726fcentral writes a chunk (write / write without response)
TX726f6869-0003-4d00-8000-636f6e74726fperipheral notifies a chunk

The device advertises the service UUID and the name ALTRUIST-URBAN-XXXX (scan response), where XXXX are the last two bytes of the device MAC in hex.

A message is split into frames of at most one ATT payload (MTU − 3, between 20 and 244 bytes). The framing does not know protobuf:

| version u8 | msg id u8 | index u8 | count u8 | total len u16 LE | payload chunk |

Frames arrive strictly in order; a new first fragment (index 0) starts a new message. count and total len must not change within a message. Bad frames (bad version, invalid or duplicate index, a skipped fragment, a changed header, an oversized message, a length mismatch) are rejected: the partial message is dropped, the rest of that message is skipped, and the device answers once with INVALID_ARGUMENT (request_id 0). Disconnecting drops all partial state; a response is never sent to a different connection than the one that asked.

One connection, one request in flight, one response. Nothing runs in a radio callback: GATT writes are copied into a bounded queue (the ATT response waits for room, which is the backpressure), the control task reassembles and dispatches, and responses leave through a bounded TX queue.

Limits

ConstantValue
MAX_REQUEST_LEN768 B (a patch of 8 full values is the worst case)
MAX_RESPONSE_LEN2048 B
ATT payload20 – 244 B, header 6 B, at most 255 fragments
MAX_NAME_LEN (section, service, field, action, issue)24 B
MAX_VALUE_LEN (string and bytes of config values and action arguments)64 B
MAX_FIELD_VALUE_LEN (string and bytes of status values and action results; a longer value is an error, never cut)32 B
MAX_ENUMS / MAX_ENUM_VARIANTS / MAX_VARIANT_NAME_LEN1 / 9 / 16 B per schema response
MAX_SCHEMA_FIELDS / MAX_STATUS_FIELDS20 / 20 per response
MAX_ACTIONS12 per device
MAX_CONFIG_FIELDS16 fields per section
MAX_MUTATIONS8 per patch
MAX_CONFIG_SECTIONS / MAX_SERVICES12 / 12
MAX_ISSUES / MAX_ISSUE_DESCRIPTORS4 active / 12 described
MAX_REASON_LEN (reason of a rejected action)32 B
MAX_ACTION_ARGS / MAX_RECORDS / MAX_RECORD_FIELDS3 / 6 / 4

MAX_ENUMS = 1 is a deliberate constraint of the compact protocol: a schema describes the variants of one enum once, so a section, service or action has at most one enum; more would need a flat variant table, a protocol change.

The limits are defined once (rohi-control-proto/build.rs), applied to the generated types and checked by tests: requests must fit MAX_REQUEST_LEN in the worst case, full schema, status and action responses must encode within MAX_RESPONSE_LEN, and a memory budget test keeps the size of the generated types (Request, Response, FieldDescriptor, FieldValue, GetServicesStatusResponse, GetServiceStatusResponse) under explicit budgets; the measured values are in Resource budget. Growth fails CI instead of showing up as a stack overflow on the device. That matters on the device: responses are built and moved on the stack of the control task, and the main stack of the ESP32-C3 is only about 42 KB. The BLE queue depths belong to the product and are in Resource limits.

Security

rohi-net reports encrypted / authenticated for the current link. rohi-control turns that into decisions with an AuthorizationPolicy (Privilege::Read, Mutate, Provision): OpenPolicy, RequireEncryption, RequireAuthenticated. Config writes and privileged actions need Mutate; actions with secret arguments (identity provisioning) need Provision, so a stricter policy can protect them through the same hook. The policy runs before the action does. The Altruist Urban firmware currently runs OpenPolicy, and the BLE backend does not pair yet (TrouBLE’s security feature is not enabled), so every link reports unencrypted. Anyone in radio range can use the control plane until a policy is configured, including identity provisioning and erase. Secrets such as the Wi-Fi password still cross the air in clear text until pairing is enabled.

rohi desktop client

tools/rohi is one package: the library rohi carries the device protocol, the binary rohi is a thin command line layer.

rohi library
  Device         generic async API: info,
                 config_sections, config_schema, config_get, config_patch, config_reset,
                 services, services_status, service_schema, service_status,
                 actions, action_schema, invoke        (schemas fetched on demand and cached)
  Session        framing, request ids, timeouts, typed error mapping   (reuses rohi-control-ble::frame)
  ClientTransport  chunk link trait; BleLink (btleplug) implements it, tests use an in-memory one
  model          plain Rust types (no protobuf), optional serde
  format         human readable rendering driven by the schemas

No method is named after a module. The command line has one authoritative page, The rohi tool: commands, device selection (--device, ROHI_DEVICE, ble:// and tcp:// URIs), the output contract (results on stdout, prompts and diagnostics on stderr, -o json, exit codes), configuration files and actions. It is a layer over Device: parsing, device choice, prompts and output live in tools/rohi/src/cli, protocol behaviour stays in Device and below, and the command dispatch (rohi::cli::execute) is generic over ClientTransport, so it runs unchanged against the in-memory device of the tests, which is built from the real rohi-control, rohi-service and feature crates.

Secrets are never printed, exported or accepted on a command line. On Linux the build needs libdbus and pkg-config (provided by nix develop); the protobuf compiler protoc is needed to build rohi-control-proto.

Build, test and measurements

Commands are listed once in Build and test and Targets and features. The control plane is covered by host tests (protocol, dispatcher, framing, rohi client end to end); flash, RAM and heap measurements are in Resource budget and the open hardware checks in Hardware testing. The shared radio runtime that serves BLE and Wi-Fi is described in Wireless runtime.

Device identity

Every provisioned ROHI device has exactly one Ed25519 identity. It is provisioned from the host, never generated by the device, and becomes the cryptographic identity used to sign telemetry.

master SURI ──► rohi ──► hard derivation //<number> ──► derived Ed25519 secret
                                                              │ control protocol (ProvisionIdentity)
                                                              ▼
                              ConfigStore ◄── Identity section ◄── device
                                                              │
                                                       IdentitySigner ──► sign(exact bytes)

The master seed and the provisioning number exist only on the provisioning host. The device stores only its own derived secret; the public key is derived from it and never persisted.

Crate rohi-identity

no_std, no transport dependency (no BLE, Wi-Fi or Meshtastic). Built on ed25519-zebra.

ItemRole
Secret<T>Zeroized on drop; Debug prints Secret(<redacted>); no Display.
IdentityThe Identity ConfigSection; holds the 32-byte secret only. public_key(), signer().
PublicKey, Signature32 / 64 raw bytes. PublicKey displays as 0x + 64 hex digits and verifies signatures.
Signerpublic_key() and sign(&[u8]); there is no way to get the private key out of it.
IdentitySignerSigner backed by an Identity; IdentitySigner::provisioned(None) is IdentityError::NotProvisioned.
MockSignerTest signer (feature mock).

The signer signs exactly the given bytes. Protocol semantics (domain separation, SignedEnvelope, timestamp and nonce for replay protection) belong to the Connectivity Protocol layer; the intended pipeline is MeasurementsSnapshot → protocol message → serialize once → IdentitySigner::sign(bytes) → SignedEnvelope, with the payload bytes never decoded or re-encoded after signing.

Storage

The identity lives in the normal configuration store, so it inherits A/B transactional writes, generation tracking, CRC validation and power-loss recovery. There is no separate identity storage or partition.

  • Section type Identity, ID 0x94d63573d312fe9b (FNV-1a of the struct name), version 1.
  • CBOR: [bytes(32)] (#[cbor(array)]), pinned by a golden byte test. This is persistent ABI.
  • The identity is not a config section of the generic configuration API: identity is a service whose secret is a write-only action argument, so no config request can read, patch or reset it.
  • Ordinary configuration changes, removing another section and OTA never touch it. Only the erase action removes it.
  • A corrupted store is reported as unavailable, never as “not provisioned”.

Boot behaviour

On boot the firmware reads the Identity section. If it is absent the device is not provisioned: sensors, BLE control and diagnostics keep working, and only operations that need the identity fail with IdentityNotProvisioned. There is no automatic generation fallback. The log shows only the public key, never the secret.

The firmware keeps the result as runtime state (not provisioned, provisioned with its public key, or unknown when the storage could not be read) next to the signer. provision and erase update it in the same step as the commit, and the service reads only that state for its summary, issues and status: observing the identity does not read the persistent store. Only an unknown state reads the store again, so a storage that was unavailable at boot recovers.

Derivation (provisioning ABI)

The derived identity is the Substrate hard derivation of the master with the junction //<number>:

number = 1    →  //1
number = 42   →  //42
number = 1000 →  //1000
  • The path is exactly //<number>: no namespace (//rohi//42, //sensor//42 are wrong). It must stay stable.
  • The master is a Substrate secret URI (mnemonic phrase or 0x seed, optional path and ///password) read from a file. rohi applies the master’s own path/password first and then the hard junction //<number>.
  • Derivation uses sp-core (ed25519), the same algorithm as Pair::from_string("<suri>//<number>").
  • The same master and number give the same identity on every machine and in every future version, so an identity can be restored after flash replacement without backing up each derived key.
  • The number must be unique within one master hierarchy. Using it for two active devices creates duplicate identities; the firmware cannot detect that because the number is never sent to it.

Golden vectors for the public development phrase (bottom drive obey lake curtain smoke basket hold race lonely fit walk, never a production master) live in tools/rohi/src/provision/tests.rs:

NumberPublic key
10xbf3a763d817cee09bf785b9cc6118f58dab5c03f3ace6d524899bcb28ac74f27
20x276b9ce3c58b9f009f4f1f2ae5c7e9a599796246165df52f2d8b857fcecb1a40
420x8743ca72162320542b15ef0e66995fac97cd295626627046c41f64f796bcf2f3

Control plane

The identity is the identity service of rohi-identity (IdentityService over an IdentityStore that the firmware implements on its configuration handle):

RequestLink privilegeEffect
GetServiceStatus identityreadprovisioned and the 32-byte public_key (absent when not provisioned)
InvokeAction identity.provision { secret }provision (secret argument)stores the identity, returns its public key
InvokeAction identity.erasemutateremoves the identity section; no-op when absent

rohi status identity shows the state; rohi action identity provision secret=- runs the action.

  • A status of stopped with the informational issue not_provisioned is the normal state of a fresh device.
  • provision succeeds only when not provisioned, otherwise it is rejected with the reason already_provisioned. An existing identity is never silently replaced; erase it explicitly first.
  • A secret that is not exactly 32 bytes is INVALID_ARGUMENT. After storing, the device reads the identity back and compares the public key before answering.
  • No response or status ever carries the secret. The invoke request type has no Debug output and its arguments are overwritten after the call, so the secret cannot reach a log.
  • Errors: REJECTED (already_provisioned), INVALID_ARGUMENT, UNAVAILABLE, INTERNAL, PERMISSION_DENIED.
  • The master seed, the number and the derivation path are never sent to the device.

Security status

Provisioning moves a private key over BLE, so it must only be accepted on a protected link. BLE pairing is not implemented yet (see Control plane): the Altruist Urban firmware runs OpenPolicy, so today anyone in radio range can provision or erase an identity. Provision and erase use a dedicated Privilege::Provision, so RequireEncryption / RequireAuthenticated already reject them on an unprotected link with PERMISSION_DENIED (covered by host tests); enabling pairing and switching the firmware policy is the remaining step. Provision devices in a trusted place until then.

rohi command line

There is no identity command: the generic status and action commands are used.

rohi status identity                          # state and public key; never the secret
rohi action identity provision secret=-   # 32-byte secret as hex, read from stdin or a hidden prompt
rohi action identity erase

The secret is derived on the host with rohi::derive_identity(suri, number) (the library of the rohi crate) and only the derived secret is sent. The master seed, the number and the derivation path are never sent, stored or logged, and a malformed secret is not echoed in the error. Compare the public key reported by status identity with DerivedIdentity::public_key() after provisioning. rohi factory-reset formats the configuration storage and so erases the identity too.

Firmware

altruist_urban::identity owns access to the identity: it loads it at boot, serves the control requests through ConfigHandle and keeps an IdentitySigner for future signed telemetry (IdentityHandle::sign, which returns NotProvisioned when erased).

The signer is updated by the configuration owner task in the same step as the commit or removal of the Identity section, with no .await in between. Provision and erase therefore follow the durable-mutation rule: cancelled before the request was queued, nothing changed; cancelled afterwards, the operation is logically completed and the signer agrees with the stored identity (Async and cancellation).

The secret passes through fixed-size buffers of the configuration channel and the BLE request path, which are not scrubbed after use.

Validation checklist (hardware)

  1. Fresh Altruist Urban: rohi status identity reports not provisioned; sensors and BLE diagnostics work.
  2. rohi action identity provision secret=- with the secret derived from <test-master>: host and device public keys match.
  3. Reboot: rohi status identity returns the same public key. Change unrelated configuration, reboot: unchanged.
  4. rohi action identity erase: state becomes not provisioned, signing returns IdentityNotProvisioned, sensors keep running.
  5. Provision again with the same master and number: the original public key is restored.

Out of scope

Automatic device-side generation, per-sensor keypairs, certificates, multiple identities, key rotation, secure element, storing the number on the device, remote master storage, and Connectivity Protocol telemetry/uplink.

Time service (rohi-time, rohi-time-ntp)

rohi-time gives the firmware one source of wall-clock time and synchronization state. Components that need a real-world timestamp (persistent logs, datalog, diagnostics, measurement timestamps) ask the TimeService. They never contact an NTP server, manage a clock chip, keep clock offsets or decide whether a timestamp is trustworthy.

   NtpTimeSource            future RtcTimeSource        external time
   (rohi-time-ntp)          (hardware RTC)
        │  DNS + UDP              │  I²C / SPI
        └────────────┬────────────┴─────────────┘
                     ▼   TimeSample { unix_time, uncertainty }
              Synchronizer        background task, backoff, resync
                     ▼
              ┌─────────────┐   validation (plausibility window)
              │ TimeService │   anchor = absolute time + monotonic instant
              └──────┬──────┘   state, failure counters
                     │  now() = anchor + monotonic elapsed
        ┌────────────┼────────────┐
        ▼            ▼            ▼
     logging       datalog     control plane (`time` service)

Monotonic time versus wall-clock time

These are different concepts with different types, and there is no ambiguous timestamp().

Monotonic timeWall-clock time
Typerohi_hal::MonotonicTimestamp (from embassy-time)rohi_time::UnixTime (Unix milliseconds, UTC)
Availablealways, from bootonly after a valid sample; otherwise None
Can jumpneveryes, forward or backward, when a sample corrects it
Use fordeadlines, timeouts, intervals, retries, scheduling, elapsed time, ordering events in a bootabsolute timestamps only

Wall-clock time may step. A newly accepted sample replaces the anchor at once. The clock is not slewed and not made monotonic. Never use wall-clock time to measure durations or order events; use the monotonic clock. A future work item may add smoothing.

Guarantees

  • TimeService::now() is None until the first valid sample. Uptime is never returned as Unix time and no placeholder date (such as 1970-01-01) is ever invented.
  • now() is cheap: it adds the monotonic time elapsed since the accepted sample to that sample. No network, no hardware access, no allocation.
  • An invalid, implausible or failed sample never replaces a valid anchor.
  • Firmware startup never waits for time. Everything else runs unsynchronized and failures are only recorded.
  • Synchronization is not authentication. Plain NTP is not cryptographically protected, so the time must not be an input to security-critical decisions. TimeStatus reports synchronization quality, not authenticity. Authenticated network time (NTS) is out of scope.

Consumer API

let time = altruist_urban::time::service();

match time.now() {
    Some(unix) => attach_wall_timestamp(unix),   // real, synchronized time
    None => monotonic_only(),                     // never a fabricated date
}

let status = time.status();      // state, source, last_sync_age, uncertainty, consecutive_failures, last_failure
let snapshot = time.snapshot();  // monotonic + wall time of the same instant, plus state
TimeSyncStateMeaning
Unsynchronizedno valid sample yet; now() is None
Synchronizeda valid sample was accepted within the stale threshold
Stalethe anchor is older than the threshold (TimeConfig::stale_after, default 6 h); now() still works from the last anchor and may have drifted

TimeSource/TimeSourceKind (Ntp, Rtc, External) appear in diagnostics only; consumers do not normally branch on them. The latest valid sample of any source wins; the policy is deliberately not generalized yet, but the public API carries no NTP assumption.

Sources: the TimeSource trait

trait TimeSource {
    type Error: Into<SyncFailure>;
    fn kind(&self) -> TimeSourceKind;
    fn is_available(&self) -> bool { true }          // e.g. network ready
    async fn sample(&mut self) -> Result<TimeSample, Self::Error>;
}

A source only produces samples. A hardware RTC source would implement the same trait (HardwareRtcSource<R>), so a board with an RTC can establish the initial time before the network exists and let NTP refine it later; consumers do not change. A source must bound its own waits and be cancel-safe.

NTP source (rohi-time-ntp)

NtpTimeSource resolves a server with NetworkHandle DNS, sends one SNTPv4 datagram over a UDP socket of the same handle and returns a sample: the server’s transmit time plus half the round trip, with the round trip and the server’s root delay/dispersion as uncertainty. It uses the sntpc client for the protocol (mode, version, stratum, origin timestamp, leap indicator, kiss-o’-death, root distance and reply address checks); the result is mapped to coarse SyncFailure categories. It rotates through at most MAX_SERVERS (4) configured servers, moving on after a failure, and holds one UDP socket only for the duration of one exchange. DNS and the exchange are bounded by NtpConfig::timeout_ms (5 s). It never owns the global concept of time.

NTP era. NTP timestamps wrap every 2³² s (the next wrap is in 2036), so a wire value alone is ambiguous. The source takes a pivot, the best known current time, and picks the era closest to it. In firmware the pivot is TimeService::pivot(): the synchronized time when there is one, otherwise the firmware build time plus uptime (not a timestamp, never recorded). A pivot more than about 68 years off picks the wrong era; such a result is far outside the plausibility window and is rejected.

Validation

TimeConfig (named, documented values) decides whether a sample may become the anchor:

RuleDefault
not_before (plausibility floor)firmware build time (BUILD_TIMESTAMP, set by the firmware build.rs)
not_afterfloor + DEFAULT_HORIZON (40 years)
max_uncertaintyDEFAULT_MAX_UNCERTAINTY (5 s)
stale_afterDEFAULT_STALE_AFTER (6 h)

A rejected sample is recorded as SyncFailure::Implausible and the existing anchor is kept.

Background synchronization

Synchronizer runs in one task (altruist_urban::time::ntp_task):

  • Network down (is_available() == false): polls every 2 s, without counting a failure; the first sample is taken as soon as the network returns. Network appearing and disappearing at runtime is expected.
  • Failure (DNS, timeout, unreachable, invalid response, kiss-o’-death, implausible time): recorded (consecutive_failures, last_failure), retried with bounded exponential backoff, 15 s doubling up to 10 minutes. The anchor is untouched; there is no panic.
  • Success: backoff is reset, the failure counters are cleared and the next sample is taken after 1 hour. Oscillator drift is thereby corrected, while polling stays well within NTP etiquette (RFC 4330: never more often than every 15 s).

The intervals are SyncSchedule fields; SyncSchedule::DEFAULT holds the values above.

Control plane

The time service (TimeStatusService over TimeService, read-only, no secure link needed) reports its state and, when synchronized, unix_time (ms; absent while unsynchronized), source, sync_age, uncertainty and consecutive_failures; the time servers are in the time config section, not repeated in the status. An unsynchronized device that has not failed yet is starting; after failures it is running and degraded with the active issue of the last failure category (network_unavailable, resolve_failed, timeout, invalid_response, rejected, implausible, source_failure). A stale time is usable but degraded. The service is not essential for the device. See Services and status:

$ rohi status time
Service:  time
State:    running
Health:   ok
Ready:    yes
Since:    2h 13m

unix_time                1780000000250 ms
source                   ntp
sync_age                 90000 ms
uncertainty              40 ms
consecutive_failures     0

The servers are the time config section (NtpServersConfig, fields server1 to server4: host names or IPv4 addresses of letters, digits, . and -, up to 64 bytes). The defaults are pool.ntp.org and time.cloudflare.com (server3 and server4 have no value). FIELD=null removes an override and restores the default, so server2=null brings back time.cloudflare.com; a field whose default is a server cannot be forced to “none”, and rohi config reset time removes every override. A change is applied at once: the NTP task restarts with the new servers and samples immediately.

$ rohi config time server1=ntp.example.org server2=time.example.net

Manual resynchronization is not offered: periodic automatic synchronization is authoritative.

Resources

All storage is static and bounded: the anchor and counters are a few words behind a critical-section mutex; NtpTimeSource keeps no state besides a server index; one UDP socket (UDP_SOCKETS = 1, UDP_BUFFER = 128 B per direction) is allocated from the NetResources pool for each exchange. See Resource limits.

Testing

Everything runs on the host without Internet access: fake clock, fake sources, and rohi_net::testing::FakeIp with a scripted NTP server (valid and malformed answers, kiss-o’-death, timeouts, DNS failure, unreachable server, era rollover, implausible time, network loss and recovery). See Host testing.

Not in scope

A hardware RTC driver, persisted last-known time, clock slewing, time zones and daylight saving, authenticated NTP (NTS), GPS/PTP, an NTP server. Persistent logging will consume TimeService::now() and attach a wall timestamp only when it is Some; it must keep working before the first synchronization.

Telemetry (rohi-connectivity)

rohi-connectivity turns an immutable MeasurementsSnapshot into a signed airalab/connectivity-protocol frame and delivers it through a pluggable transport. HTTP is implemented first, on top of rohi_net::NetworkHandle.

The wire format is pinned to airalab/connectivity-protocol@33b4e7ec6eaa19a930b91bfb59bd3337c1ec88ac. The .proto files are vendored in crates/connectivity-proto/proto/ (verbatim copy, license included) and the revision is recorded in crates/connectivity-proto/UPSTREAM_REV; no build downloads anything. The reference implementation is airalab/robins.

sensor tasks ─► MeasurementStore ─snapshot─► UrbanEncoder ─► core.v1.Message   (encoded once)
                                                                │
        UtcClock ─ Meta.timestamp ──────────────────────────────┤
        NonceSource ─ 16-byte nonce ────────────────────────────┤
        FrameSigner ─ sign(sensor_id ‖ nonce ‖ message) ────────┤
                                                                ▼
                                          EnvelopeBuilder ─► EnvelopeFrame     (encoded once, immutable)
                                                                │
                                              Outbox (bounded) ◄┘
                                                │
                                     EnvelopeTransport ─► HttpTransport ─► NetworkHandle

Wire contract

crypto.v1.SignedEnvelope has exactly sensor_id, nonce, message and signature at the pinned revision; there is no envelope timestamp. The signature covers sensor_id ‖ nonce ‖ message, where message is the exact serialized core.v1.Message. The timestamp is core.v1.Meta.timestamp, Unix UTC milliseconds.

  • The message is serialized once, signed as those bytes, wrapped in the envelope, and the envelope is serialized once into an EnvelopeFrame.
  • A retry resends the same frame byte for byte: nonce, timestamp and signature are never regenerated and nothing is re-encoded from decoded fields.
  • The signing preimage is documented and tested in rohi_connectivity_proto::signing_preimage; golden vectors generated with robins pin the whole chain (see below).

Measurement mapping

Only MeasurementState::Valid measurements are emitted. A cached value of a WarmingUp, Stale or Error measurement is never sent. Every Urban sensor message carries one measurement, so each measurement is one UrbanSensor entry.

Rohi observationProtocol fieldConversion
BMx280 temperature, only when the humidity channel exists (BME280)BME280.temperature°C → 0.01 °C (sint32)
BME280 humidityBME280.humidity% → 0.01 % (clamped to 0..100 %)
BME280 pressureBME280.pressurePa → 0.1 Pa
SDS011 PM2.5 / PM10SDS011.pm25 / pm10µg/m³ → 0.1 µg/m³
Microphone average / maximumICS43434.noise_avg / noise_maxdB → whole dB
BMP280 (no humidity channel), SCD4x, ZMOD4510, AGS3871, RadSens—omitted
Location of the device (rohi-location)GPS (lat, lon, height_m)WGS84 degrees; first entry; height 0 when unknown; not counted as a measurement

The location is context, not a measurement: it is added as the first entry when the device knows where it is, a frame with a location but no valid measurement is still not sent, and a position that is not a finite number is left out and counted as out of range. Without a location the frame is still sent without the entry (Diagnostics::frames_without_location) and the service is reported as degraded, see Status.

A sensor without an unambiguous representation is omitted, never relabeled; omissions are counted (Diagnostics::measurements_unsupported). Values that cannot be represented (NaN, infinite, negative where the field is unsigned) are omitted and counted too. Supporting SCD4x or a generic BMx280 needs a protocol change and is out of scope.

UTC and entropy

Meta.timestamp is UTC, from a UtcClock; monotonic time (embassy_time::Instant, MonotonicTimestamp) is never used as UTC. The timestamp is the UTC time of the measurement set: UTC now minus the age of the snapshot (monotonic now − snapshot.generated_at). TimeService implements UtcClock; while it is not synchronized (or the age exceeds the clock value) no frame is created and sensing continues.

NonceSource supplies the 16-byte nonce, requested once per frame before the frame exists. The firmware uses the hardware RNG (true random while the radio runs; nonces only need to be unique for replay protection). Without entropy no frame is created.

Identity

The service sees a FrameSigner (sensor_id() and sign_payload()): only the public key and signatures cross the boundary, never key bytes. Every rohi_identity::Signer is a FrameSigner. The firmware’s StaticSigner follows the runtime identity (provisioned or erased at any time). Without identity no frame is created.

Transport

pub trait EnvelopeTransport {
    async fn send(&mut self, frame: &EnvelopeFrame) -> Result<TransportAck, TransportError>;
}

Transports operate on the immutable frame; they cannot change what was signed. HttpTransport sends

POST /v1/telemetry HTTP/1.1
Host: <host>[:<port>]
Content-Type: application/protobuf
Content-Length: <frame length>
Connection: close

<exact SignedEnvelope bytes>

over plain TCP (DNS or an IPv4 literal, then NetworkHandle::connect), one request per connection, with a total timeout per attempt (20 s). TLS can later wrap the connection without touching encoding or signing.

Error classification

Centralized in TransportError::disposition / classify_status:

ResultDisposition
202 Accepteddelivered, frame removed
network unavailable, DNS, connect, timeout, I/O error, malformed responseretry the same frame with backoff
408, 429, 5xxretry with backoff
1xx, 3xx, 2xx other than 202 (captive portal, proxy)retry (not an acceptance)
401, 403unauthorized: frame dropped, pause for the longest backoff (no hot loop)
400, 413, any other 4xxpermanent: frame dropped

Outbox, retry, runtime

ConnectivityService::run is one task, separate from sensing: every interval it creates a frame (identity? UTC? snapshot, encode, sign), queues it in the bounded in-memory Outbox (no allocation), and delivers the oldest frame.

  • Retryable failures keep the frame at the front and back off exponentially (2 s, doubling, capped at 5 min); a success resets the backoff. There is never a zero-length wait.
  • When the outbox is full, no new frame is created (nothing is evicted) and skipped_outbox_full is incremented.
  • The service only reads snapshots. Wi-Fi, DNS, TCP, server, time or authorization failures cannot stop or delay the sensors or the measurement store.
  • The queue is in memory: frames are lost on reboot (a flash spool is out of scope).
  • ConnectivityShared::status() publishes the state (Idle, WaitingForUtc, WaitingForIdentity, Backoff(error), Unauthorized(error)) and the Diagnostics counters.

Configuration

ConnectivityConfig is the effective section, persisted as the overrides ConnectivityOverrides (owned by rohi-connectivity; see Add a ConfigSection): enabled, node_id, interval_s (10 s … 24 h), host (letters, digits, ., -; written into the Host header, so no spaces or line breaks) and port. The CBOR map of overrides (version 1) is pinned by a golden test.

The configuration is the connectivity config section, edited with the generic rohi config commands, for example rohi config connectivity host=ingress.example.org node_id=42 enabled=true and read with rohi config connectivity. A change is applied live: the firmware’s configuration owner task publishes every commit or removal of the section to the telemetry task, which stops the running service and starts a new one with the new values (or stays stopped when the configuration is disabled or invalid). The outbox of the replaced service is dropped and the published status restarts from Starting.

In altruist-urban the build-time firmware overrides sit between ConnectivityConfig::default() (disabled) and the user overrides: the environment variables ROHI_CONNECTIVITY_HOST (which also enables telemetry), ROHI_CONNECTIVITY_PORT, ROHI_CONNECTIVITY_NODE_ID and ROHI_CONNECTIVITY_INTERVAL_S at compile time. Without a valid host, telemetry is disabled and the other variables are ignored. A user override replaces only its own field; FIELD=null and rohi config reset connectivity remove user overrides and return to the build-time values.

Status

The connectivity service reports the state of ConnectivityShared and the counters without any cooperation from the sending task:

State of the senderService state / health / readyIssue code
telemetry disabled by the effective configurationdisabled / unknown / no
Startingstarting / unknown / no
Idlerunning / ok / yes, degraded while no location is setno_location
WaitingForIdentityrunning / degraded / nono_identity
WaitingForUtcrunning / degraded / nono_utc_time
Backoff(error)running / degraded / nonetwork_unavailable, dns_failed, connect_failed, connection_timeout, io_error, bad_response, delivery_rejected
Unauthorizedrunning / error / nounauthorized

Status fields: frames_created, frames_delivered, send_retries, frames_rejected, queue_depth and frames_without_location; the ingress host and port are configuration, see rohi config connectivity. Without a location an otherwise healthy service is degraded with the warning no_location (and so is the device health), because the frames are sent but incomplete; rohi config location latitude=.. longitude=.. fixes it, see Location. The service is not essential for the device.

Limits

ConstantValue
MAX_SENSORS_PER_MESSAGE12
MAX_MESSAGE_LEN400 B (worst case of the schema: 399 B)
MAX_ENVELOPE_LEN544 B
NONCE_LEN16 B
CONNECTIVITY_OUTBOX_DEPTH (firmware)4 frames

A full Altruist Urban frame (7 measurements) is about 200 B. Private (encrypted) sections and envelope batches are not generated: decoding skips them as unknown fields.

Testing and interoperability

  • Unit tests cover valid-only emission, per-field mapping, omitted and unsupported sensors, timestamp placement, the signing preimage, nonce use, byte-identical retries, status classification, backoff, outbox-full behaviour and the HTTP exchange against the in-memory IP backend.

  • src/tests/golden.rs holds interoperability vectors (message, preimage, Ed25519 signature, envelope) generated with the robins edge CLI (a6c1d9c, whose protocol submodule is exactly the pinned revision). Note: the CLI help says pressure is in hPa, but it takes pascal and encodes 101325 as deci_pascal = 1013250, as the schema documents.

  • tests/robins_e2e.rs is an opt-in end-to-end test: a snapshot goes through the service and HttpTransport into a real edge gateway, which must answer 202:

    ROHI_ROBINS_EDGE=/path/to/robins/target/debug/edge \
        cargo test -p rohi-connectivity --test robins_e2e -- --ignored
    

Compatibility assumptions: the ingress accepts POST /v1/telemetry with application/protobuf and answers 202; the sensor id must be accepted by the ingress authorization (robins auth.mode = "none" or whitelisting the device public key).

Location (rohi-location)

rohi-location answers one question for the rest of the firmware: where is the device? Today the answer is what the user stored; later a GNSS receiver will measure it. Telemetry puts the answer into every frame.

  user ── config section `location` ──┐
                                      ├─► LocationShared ──► LocationSource ──► telemetry frames (GPS entry)
  GNSS receiver (future) ─────────────┘          │
                                                 └─► `location` service (rohi status location)

A location is optional. A device that was never told where it is has none; nothing invents one, and nothing substitutes 0, 0.

Set the location

The location is an ordinary config section called location:

$ rohi config location latitude=52.52 longitude=13.405 height_m=34
latitude   52.52 deg
longitude  13.405 deg
height_m   34 m

$ rohi config location                    # the effective configuration; nothing set shows <unset>
latitude   52.52 deg
longitude  13.405 deg
height_m   34 m

$ rohi status location
Service:  location
State:    running
Health:   ok
Ready:    yes
Since:    3s

latitude   52.52 deg
longitude  13.405 deg
height_m   34 m
source     configured
FieldTypeRangeRequired
latitudef32 on the control plane, degrees (WGS84)-90 … 90yes
longitudef32 on the control plane, degrees (WGS84)-180 … 180yes
height_mf32 on the control plane, metres above mean sea level-1000 … 10000no (rohi config location height_m=null removes the override)

Single precision resolves a coordinate to about one to two metres; the overrides are stored as f64 (see persistent ABI) and the control plane converts.

Latitude and longitude only exist as a pair: the default (no override) is not a position (NaN, shown as <unset>) and is a valid, unconfigured section, so a first height_m=10 is refused instead of storing 0, 0. NaN and infinite values are refused too. The device validates the complete effective result before anything is stored (see Config sections); a section without coordinates is valid. rohi config reset location removes every override: the device does not know where it is again.

The change takes effect at once, without a restart: the firmware’s configuration owner task hands every commit or removal to the shared location in the commit step, like the other runtime sections (Durable mutation).

Sources and priority

LocationShared holds two optional values and current() returns the better one:

SourcePublished byWhen it wins
gpsa GNSS runtime with publish_fix(Some(..))while it publishes a fix
configuredthe configuration owner with publish_configuredwhen there is no fix

There is no expiry in rohi-location: the GNSS runtime knows what a stale fix is and must publish None as soon as the fix is lost, which makes the device fall back to the configured position. The GNSS runtime does not exist yet; publish_fix and the rule are covered by unit tests and are the only seam a future receiver needs. Consumers see a Location { latitude, longitude, height_m, source } through the LocationSource trait and never depend on the provider.

Telemetry

rohi-connectivity reads the location through LocationSource when it creates a frame and adds the sensor.v1.GPS entry as the first entry of Urban.public: WGS84 degrees, height 0 when the height is not known (as the schema defines). Consequences:

  • The location is context, not a measurement. It is not counted as emitted, and a snapshot without a valid measurement is still not sent, even if a location is known.
  • The location is public, unencrypted telemetry. Anyone who can read the frames sees where the device is. Remove the overrides (rohi config reset location) to stop sending it.
  • Without a location the frame is still sent, without the entry, and counted in Diagnostics::frames_without_location (frames_without_location in rohi status connectivity).
  • A position that is not a finite number is left out and counted as out of range.

Connectivity is degraded without a location

Telemetry without a position is incomplete, so while telemetry is running and the device has no location, the connectivity service reports

$ rohi status connectivity
Service:  connectivity
State:    running
Health:   degraded
Ready:    yes
...
Issues:
  WARNING  no_location  (12s)
           No location is set; telemetry is sent without it

ready stays yes because frames are delivered. The device health follows the default policy and becomes degraded; rohi status shows it. Disabled telemetry is not degraded by a missing location. The fix is one command: rohi config location latitude=.. longitude=...

Service

LocationService is the location service of the status plane: disabled (with the informational issue not_set) until a location is set, then running, ok, ready. Status fields: latitude, longitude, height_m (absent when unknown) and source (configured or gps). It is not essential for the device.

Persistent ABI

LocationConfig is the effective section and LocationOverrides (generated by #[derive(ConfigOverrides)]) its persisted overrides, both owned by this crate: section type name LocationConfig, section ID 0xce098b4a3e381f40, version 1, CBOR map {1: latitude, 2: longitude, 3: height_m} of 64-bit floats (a key is absent when the field is not overridden; the default has NaN coordinates, which read as “no value”). A golden test pins the bytes and the ID; see Persistent ABI.

Firmware wiring

altruist-urban owns one static LocationShared (location.rs). The configuration owner publishes the stored section at boot and after every commit (config.rs), the connectivity task reads it for every frame and the control registries list the section and the service (control.rs). Adding a GNSS source means publishing its fix to the same LocationShared.

Add a sensor

A sensor is a driver in rohi-hal managed by a generic Runner; the board crate supplies the bus, the firmware spawns one task. Drivers know nothing about networks or serialization. Background: Sensor HAL, Sensors.

  1. Model the observation (portable, rohi-hal). Add the sensor to SensorKind and give it a typed update in MeasurementUpdate (units from units.rs: Celsius, Ppm, …). Measurements are grouped by the physical sensor; never merge readings of different sensors. Give the sensor a freshness policy (FreshnessPolicy::default_for) that outlasts its worst healthy cadence: a test in sensors/mod.rs enforces it.
  2. Write the driver in crates/hal/src/sensors/<name>.rs and implement SensorDriver: init (probe and configure; called again to recover), sample (wait for and read the next observation, Ok(None) = no data yet), poll_interval_ms, optionally initial_delay_ms, plus KIND and WARMS_UP. Map the bus error into DriverError (code, and is_absent for “does not answer”, which makes the state NotDetected instead of Error).
  3. Test on the host. In the driver module, drive the driver with MockI2c / a fake UART from the crate-internal testutil (crates/hal/src/testutil.rs), and the runner with a recording delay. Cover detection, a failing bus, recovery, warm-up and the exact decoding of datasheet examples. See Host testing.
  4. Wire the bus (platform). If the sensor needs pins or a new peripheral, add them to the board module in rohi-hal-esp (see Add a board); drivers never name pins.
  5. Spawn a task in firmwares/altruist-urban/src/tasks/sensors.rs: build the driver, wrap it in Runner::new(driver, reporter(SensorKind::X), Delay, RunnerConfig::DEFAULT) and runner.run().await; start it in main.rs with the other sensor tasks. I²C sensors share one bus through i2c_handle.
  6. Expose it to the control plane if it should be visible: add its measurements as status fields (stable ids, names <sensor>.<measurement>, canonical units) and its issue codes in rohi-hal/src/sensors_service.rs; the table in Sensors lists them. The protocol and the rohi tool need no change (see Add a service).
  7. Add a hardware example in examples/src/bin/ if useful, and record the validation in Hardware testing.
  8. Update the docs (policy): the sensor’s page section in Sensors and any new limit in Resource limits.

Rules to respect: the runner owns the driver (one task per sensor); do not call step in a select unless you accept that a bus transaction may be cut short (cancellation); do not allocate.

Add a ConfigSection

A persistent configuration section is owned by the feature crate that interprets it (for example rohi-net owns NetworkConfig), never by rohi-config. The stored bytes are a persistent ABI; read it before choosing names and field numbers.

A section has two types. The typed section (UplinkConfig) is the effective configuration the runtime uses: every field has a value. What is persisted is a generated overrides struct (UplinkOverrides) that holds only what the user explicitly set. The effective configuration is C::default(), plus the firmware overrides (a value of the same generated type, compiled into the product, never persisted), plus the user overrides.

  1. Define the typed section in the feature crate with bounded fields (heapless, fixed arrays), implement Default as a portable baseline, and derive its persisted overrides with #[derive(ConfigOverrides)]. The field types need Clone and the minicbor traits (the overrides hold exactly these types); the struct itself is never encoded:

    use rohi_config::ConfigOverrides;
    
    #[derive(Clone, PartialEq, Eq, ConfigOverrides)]
    #[config(overrides = UplinkOverrides, version = 1)]
    pub struct UplinkConfig {
        #[config(id = 1)]
        pub endpoint: Endpoint,
        #[config(id = 2)]
        pub interval_s: u32,
    }
    
    impl Default for UplinkConfig {
        fn default() -> Self { /* the portable baseline */ }
    }
    • Secrets: do not derive Debug on the field types; implement it by hand with redaction (see WifiPassword). The generated overrides print their values only through the Debug of the field types.
    • A field that may have no value is Option<T>. Its override has three states: Value(Some(v)), an explicit Value(None) and Default (follow the default).
    • Every field needs a stable id (the CBOR map key). Ids are only appended, never reused or renumbered. Duplicate or missing ids, generic types and structs without named fields are compile errors.
    • The derive generates UplinkOverrides (with the visibility of the struct and an Override<T> per field), its CBOR map codec, ConfigSection, ConfigOverrides<UplinkConfig> and HasOverrides for UplinkConfig. The section ID is the FNV-1a hash of the config type name (UplinkConfig, not UplinkOverrides): renaming the config type orphans the stored data; keep the ID with #[config(id = 0x...)] if it must be renamed.
    • The effective configuration is rohi_config::materialize(&firmware, &user): UplinkConfig::default(), then the firmware overrides, then the user overrides.
  2. Pin the encoding with a golden test (assert_eq! on the exact bytes of an overrides value, ID and VERSION); see the_cbor_encoding_is_stable for WifiOverrides. Also test the empty map a0, the round trip and the maximum-size value against MAX_SECTION_PAYLOAD.

  3. Validate in the typed section (validate() of the complete effective value), at the boundary that accepts new values and again in the consumer. It runs on default + firmware overrides + user overrides for every patch, so make “unconfigured” or “disabled” a valid state (see LocationConfig, valid without a position, and ConnectivityConfig, valid while disabled).

  4. Use it from the firmware through ConfigHandle::{get, set, remove} of the overrides type (get::<UplinkOverrides>()); the configuration task is the only owner of the store. If the runtime must react to a change (as Wi-Fi does), publish the effective config (materialize(&firmware, &user), also after a removal) as desired state from the owner task right after the commit, with no .await in between (Durable mutation). Describe the section once as a firmware Binding (bindings.rs: the firmware overrides and the consumer to publish to) and list it in firmware_configs! (config.rs); that one list drives the control-plane registration, boot, commit, removal and factory-reset publication, and a restart-required section (RESTART_REQUIRED) is published at boot only.

  5. Expose it to clients (optional, but this is how users set it): implement ManagedConfig for the typed section in the same crate (NAME, FIELDS, read_field, field_is_set, set_field, set_none for the optional fields, validate; see Service pipeline), implement SectionStore<UplinkConfig> (with firmware_overrides()) over the configuration handle in the firmware (Stored<YourBinding>, generated into the config registry by firmware_configs!). The protocol, the client and the rohi CLI need no change: `rohi config

    ` and `rohi config
    FIELD=VALUE` work at once. `FIELD=none` sets an optional field to no value explicitly and `FIELD=null` removes the override of any writable field so that it follows the firmware override or default again. `read_field` returns `None` for a field without a value (an optional field that is none, or a default that is not a value, such as the `NaN` coordinates of `LocationConfig`). Mark secrets `secret()`: the registry never calls `read_field` for them, it only asks `field_is_set`, so a secret is never copied into a generic value to be discarded; answer `field_is_set` from the typed field and never log the secret.
  6. Versioning: changing the stored layout later means bumping the version of the #[config(overrides = .., version = ..)] attribute and, if old data must stay readable, a migration through decode_versioned (rohi_config::decode_exact decodes older layouts strictly).

  7. Docs: add the section to the persistent ABI examples if it introduces a new rule, and note it in Configuration store.

A destructive “erase all” is deliberately not part of ConfigHandle; per-section removal must never touch sections of other features (a future identity).

Add or support a board

A board is a place where pins and buses are named. rohi-hal-esp is the single place for that; drivers, firmware tasks and the portable crates never contain pin numbers. Existing example: rohi_hal_esp::board::altruist_urban (Boards).

A new chip variant of an existing board (for example another ESP32 for the Altruist Urban):

  1. Add esp32xx.rs next to esp32c3.rs / esp32c6.rs in board/altruist_urban/ with the pin map and the peripheral types (Hardware), and select it with a Cargo feature forwarded by the firmware and by rohi-hal-esp, rohi-config-esp, rohi-net-esp and the other platform crates.
  2. Add the matching rohi-net-esp / esp-radio feature, a partition table (firmwares/altruist-urban/partitions-<chip>.csv) mirrored by rohi_config_esp::layout (a host test keeps them in sync), and the Cargo alias plus runner entry in .cargo/config.toml (check-*, build-*).
  3. Add the chip to the CI matrix, and to Targets and features. Chips are never validated together with --all-features.

A new board revision with a different pin map gets its own module instead of an edit of the existing tables.

A new board (new product):

  1. Create board/<name>/ in rohi-hal-esp exposing a chip independent API: Hardware::split(peripherals) and <Board>::new(hardware) returning configured buses (I²C, UART, I²S), ready for the sensor drivers.
  2. Create firmwares/<name>/ composing the portable crates. Put its static resource sizes in its own src/resources.rs with the documentation of Memory model; the product owns its budget.
  3. Document the pin map and its verification status in Boards, marking anything that has not been verified on hardware.

Keep vendor types inside the platform crate and the portable APIs unchanged; if a portable crate needs to change for the new board, the seam was leaking (Portability).

Use IP networking

Open DNS and TCP connections through NetworkHandle. Never use embassy-net or any vendor type directly: code written against the standard traits is portable, testable on the host and independent of the stack. Background: Networking.

Write portable code

Make your layer generic over embedded_nal_async::{Dns, TcpConnect} and embedded_io_async::{Read, Write}:

/// Sends `request` to `host:port` and reads the first chunk of the answer.
///
/// Generic over the standard traits: it works with `NetworkHandle`, with any
/// other `embedded-nal-async` stack, and it is what a TLS or HTTP layer would
/// build on.
async fn exchange<N>(
    net: &N,
    host: &str,
    port: u16,
    request: &[u8],
    answer: &mut [u8],
) -> Result<usize, NetError>
where
    N: Dns<Error = NetError> + TcpConnect<Error = NetError>,
{
    let address = net.get_host_by_name(host, AddrType::IPv4).await?;
    let mut connection = net.connect(SocketAddr::new(address, port)).await?;
    connection.write_all(request).await?;
    connection.flush().await?;
    connection.read(answer).await
}

Handle the typed NetError:

  • NetworkUnavailable: Wi-Fi is not connected (or DHCP incomplete). Wait for WifiStatus Connected (see WifiHandle::status_receiver) and retry; this is not a server problem.
  • DnsFailed / ConnectFailed: the network is up but the name or endpoint failed; apply your own retry policy.
  • NoSocketAvailable: all TCP slots of the product are in use; the call does not wait. Close a connection first.
  • ConnectionReset / Timeout: the connection is gone; reconnect.

Always drop connections you no longer need: the slot is released on drop. Bound waits with your own timeout (embassy_time::with_timeout); dropping a pending connect is safe and frees its slot (cancellation).

Test on the host

let ip = rohi_net::testing::FakeIp::<2, 128>::new();   // feature `testing`
ip.add_host("uplink.example", server);
ip.set_ready(true);
let net = NetworkHandle::new(&ip);

FakeIp scripts DNS answers, refused connections, readiness changes, slot exhaustion and held operations and lets you play the peer. See crates/net/src/ip/tests.rs and tests/network_example.rs.

On the device

The firmware creates the handle when it starts Wi-Fi (altruist_urban::net::start returns (EspWifi, NetRunner, NetworkHandle<'static, Ip>)) and hands it to the component that needs it. Capacity (TCP_CONNECTIONS, buffer sizes) is a product decision in firmwares/altruist-urban/src/resources.rs (Resource limits). Remember there is no TLS yet: do not send secrets in clear text.

Add a service

A service makes a runtime part of the device visible and controllable: its state (rohi status <name>), optionally its configuration (rohi config <name> ...) and its actions (rohi action <name> ...). It needs no change to the protocol, the dispatcher, the client or the CLI: you implement rohi-service traits in the crate that owns the runtime and add the result to the firmware’s tuples. Location is a small complete example (config section, service, shared state, telemetry); Service pipeline explains the model.

1. Status

Implement ManagedService in the feature crate, over a handle to the live runtime state (no duplicated status struct):

pub struct LocationService<'a, M: RawMutex>(pub &'a LocationShared<M>);

impl<M: RawMutex> ManagedService for LocationService<'_, M> {
    const ID: u32 = 7;                              // stable numeric id, the address of requests
    const NAME: &'static str = "location";          // stable public name
    const VERSION: u16 = 1;                         // version of the status schema
    const STATUS_SCHEMA: &'static [FieldDescriptor] = &[
        FieldDescriptor::new(1, "latitude", ValueKind::F32).unit(Unit::Degrees),
        // ...ids are stable: only ever add, never reuse or renumber
    ];
    const ESSENTIAL: bool = false;                  // does the device depend on it?

    async fn summary(&self) -> ServiceSummary { /* state, health, ready */ }
    async fn issues(&self, out: &mut Vec<Issue, MAX_ISSUES>) { /* active problems */ }
    async fn status(&self, out: &mut impl StatusWriter) -> Result<(), WriteError> { /* values that exist now */ }
}

Rules that make the status useful:

  • State, health and ready are separate. disabled is not an error (turned off, or never set); starting is unknown/not ready; running + degraded means it works with reduced capability. Do not invent one status enum.
  • Issues are active problems with a stable numeric code (named once in ISSUES, snake_case, never renumbered or reworded) and a severity; there is no message text. Only what is wrong now; history is diagnostics. At most 4 active and 12 described.
  • Fields have stable ids and units. A value that does not exist now is simply not written. At most 20 fields per service; names are dotted paths for nesting (bmx280.temperature); strings and bytes are at most 32 bytes, a longer value is a write error; floats are f32.
  • ESSENTIAL = false limits how much a failing service hurts the device health, which the firmware decides (the default policy: rohi-service::default_device_health).
  • Status is a snapshot of live state, cheap and non-blocking; do not wait on slow hardware inside it.

The registry wraps the service in Tracked, which adds the status revision (it changes when state, health, readiness or issues change) and the time in the current state. You do nothing for that.

2. Config (optional)

If users configure it, add a typed section with #[derive(ConfigOverrides)] and implement ManagedConfig as in Add a ConfigSection: the schema, typed conversion, whole-section validation of the effective configuration. Name the section like the service when they belong together (location, connectivity, wifi).

3. Actions (optional)

Operations the service offers go into ACTIONS and invoke, see Add a control operation or an action.

4. Register in the firmware

In firmwares/altruist-urban/src/control.rs add the service to the Services tuple (up to 12 entries; the Embassy control task needs the concrete type) and construct it in device(). A config section is not added here but as a Binding in firmware_configs! (config.rs), which generates the ConfigSections tuple (also up to 12 entries):

pub type Services = (
    Tracked<NetworkService<'static, RawMutex, net::Probe>, RawMutex>,
    // ...
    Tracked<LocationService<'static, RawMutex>, RawMutex>,
);
// device(): (..., Tracked::new(LocationService(location::shared())), ...)

If the runtime must react to a stored section, the configuration owner publishes its effective config in the commit step (a Binding listed in firmware_configs! in config.rs; it also runs once at boot and after a factory reset).

5. Tests

  • The feature crate: the status of every state (summary, issues, values), the schema (ids unique, names fit 24 bytes, at most 20 fields), and for a section the typed validation and a golden test of the stored overrides.
  • End to end: tools/rohi/tests/common/mod.rs builds the fake device from the real sections and services; add yours there and test rohi config <name> FIELD=VALUE, rohi status <name> against it.

6. Docs

A page for the service in docs/sdk/ (what it reports, issue codes, fields), the table in Service pipeline, the services list in Services and status, and the crate map.

Add a control operation or an action

The control plane is generic: you almost never touch the protocol. Decide what you are adding, then use the matching recipe. The wire contract is in Control plane, the model in Service pipeline.

You want a client to…It is a…Recipe
set how a module behaves, persistentlyconfig sectionAdd a ConfigSection, step 6
see what a module is doing nowservice statusAdd a service
make a module do something (scan, reconnect, provision)actionbelow, and Add a service
something that fits none of thesea new protocol operationA new request

An action

Actions are declared by a service, so a new operation is one entry in its ACTIONS and one arm in invoke:

  1. Describe it in ManagedService::ACTIONS with ActionDescriptor::new(id, "name") (a stable numeric id, never reused), typed args (all required, at most 3) and results (the fields of each returned record, at most 4 fields, 6 records).
  2. Privilege: .privileged() if it changes anything (the link policy must grant write access), .secret() if an argument is a secret (implies privileged, requires the strongest privilege, and the argument is overwritten after the call). A read-only action such as a scan needs neither.
  3. Implement it in invoke(action, args, out), where action is the numeric id. The registry has already checked that the arguments exist and match the declared kinds. Write each result record with out.next_record() and the typed writers (out.text(id, ..), out.i64(id, ..)); a field that does not match its descriptor is refused. Return ActionError::Rejected("code") for a refusal a client may react to; the code is stable and machine readable (already_provisioned).
  4. Mutation rule: if the action commits durable state that a runtime must react to, the commit and the publication happen in the owner task (Durable mutation). Long-running actions bound their own wait (see the Wi-Fi scan timeout) and are cancellation safe.
  5. Tests: the action against the registry (ServiceRegistry::invoke: arguments checked, records, refusals), and through the dispatcher if the privilege matters (rohi-control tests show the policy gate).
  6. Docs: nothing in the protocol; add the action to the service’s page. rohi action <service> lists it.

No protocol, dispatcher, client or CLI change is needed.

A new request

Only when the operation fits none of the planes (config, status, actions, info):

  1. Schema (crates/control-proto/proto/rohi/control/v1/control.proto): add the request and response messages and a oneof entry with a new field number. Add bounds for every repeated, string and bytes field in build.rs so the generated types stay heapless, and keep the memory budget test green (messages_stay_small_in_memory): a response is built on the stack of a microcontroller, so prefer paging to a larger response. Do not bump PROTOCOL_VERSION; the protocol is under development.
  2. Dispatch (rohi-control/src/controller.rs): handle the call in execute, decide its Privilege in dispatch, and map types in mapping.rs; protobuf types do not leak into other crates. Failures map to the typed errors (INVALID_ARGUMENT, NOT_FOUND, BUSY, UNAVAILABLE, PERMISSION_DENIED, CONFLICT, REJECTED, INTERNAL); secrets are never returned or logged.
  3. Backend: if the dispatcher needs something from the firmware, add it to ControlBackend (the dispatcher never sees stores, flash or radio drivers) and update the implementations: Device, the fakes in rohi-control-ble and tools/rohi tests.
  4. Client: add the generic method to rohi::Device and, if useful, a CLI subcommand.
  5. Tests: protocol golden bytes and bounds, dispatcher (authorization, validation, “nothing stored on invalid input”), loopback client end to end, and a cancellation test if the operation is long-running.
  6. Docs: the request table and limits in Control plane, resource limits if any, and the glossary for new terms.

ESP32 platform (C3 and C6)

The SDK supports two chips, selected per build by exactly one Cargo feature. This page lists what is common, what differs and how chip selection works; the commands are in Targets and features.

ESP32-C3ESP32-C6
Featureesp32c3esp32c6
Rust targetriscv32imc-unknown-none-elfriscv32imac-unknown-none-elf
Flash (Altruist Urban)4 MB, factory partition 0x3E00008 MB, factory partition 0x7E0000
Partition tablepartitions-esp32c3.csvpartitions-esp32c6.csv
Pin map (Altruist Urban)verified against the pinoutfrom the schematic, not yet verified on hardware (Boards)
Hardware validationpartial, see Hardware testingcompile-checked and built in CI; behaviour largely unvalidated

The rest of the stack is identical: the same portable crates, the same firmware source, the same configuration region (rohi-cfg, 16 KiB, two 8 KiB slots) and the same resource constants. Differences live in three places only: the pin module of the board, the partition table and the chip features of the platform crates.

Chip selection

  • The firmware package has the mutually exclusive features esp32c3 and esp32c6; each forwards to esp-hal, esp-println, esp-backtrace, esp-bootloader-esp-idf, esp-rtos and the rohi-*-esp crates.
  • Platform crates (rohi-hal-esp, rohi-config-esp, rohi-net-esp) are empty without a chip feature, so they stay buildable on the host; rohi-net-esp rejects both features together with a compile_error!.
  • The workspace dependencies never select a chip globally: esp-hal and friends are pinned in the root Cargo.toml, chips come from the firmware features. This is why --all-features cannot validate both chips: it would enable both features at once.

Runtime on ESP32

Embassy runs on esp-rtos (thread-mode executor, 1 MHz embassy-time driver), which esp-radio requires. Both chips are single-core RISC-V; Wi-Fi and BLE share one radio (Wireless runtime). Flash erase and programming stall the whole CPU (cache disabled), see Configuration store.

Wireless features

esp-radio is built with wifi, ble, coex, esp-alloc and unstable. WPA3-only networks are not supported by the station configuration currently used (Networking).

Boards

Altruist Urban

rohi_hal_esp::board::altruist_urban is the single place that names GPIO pins and configures the buses. The API is chip independent; the chip is selected by the Cargo feature esp32c3 or esp32c6 (exactly one, forwarded by the firmware), which picks the backend module board/altruist_urban/esp32c3.rs or esp32c6.rs. Drivers and firmware tasks never contain pin numbers: AltruistUrban::new(Hardware::split(peripherals)) returns the configured I²C bus, the SDS011 UART and the I²S microphone, ready to be handed to the sensor drivers.

FunctionESP32-C3ESP32-C6
I²C0 SDA / SCLGPIO3 / GPIO0GPIO3 / GPIO2
SDS011 UART1 TX / RXGPIO10 / GPIO1GPIO5 / GPIO4
I²S BCLK / WS / DINGPIO7 / GPIO6 / GPIO8GPIO10 / GPIO1 / GPIO11

The ESP32-C3 map is verified against the Altruist Urban pinout. The ESP32-C6 map comes from the Altruist Urban main board schematic (ESP32-C6-WROOM-1-N8, “GPIO Map” sheet) in the hardware repository; it is not yet verified on hardware. The schematic names the SDS011 nets PM_TX (GPIO5) and PM_RX (GPIO4) without a viewpoint; they are taken from the ESP32-C6 side, so swap them if the sensor stays silent. GPIO4 and GPIO5 are strapping pins, but they only select the JTAG source when the JTAG_SEL_ENABLE eFuse is burned, so the SDS011 does not affect booting. The C6 board also wires a button (GPIO7), a data-logging LED (GPIO0) and the CN4 expansion connector that the firmware does not drive. The firmware drives no sensor power or control pins. A board revision with a different pin map must get its own module instead of an edit of these tables. Hardware production documentation: airalab/hardware, device page: https://robonomics.network/devices/altruist/.

Wireless runtime (Wi-Fi and BLE)

Wi-Fi and BLE on the ESP32 share one radio, one PHY, the coexistence arbiter and one allocator. They are therefore brought up by one owner, rohi_net_esp::EspWireless, and not by two independent drivers. This page describes that runtime and how Wi-Fi and BLE interact; the portable APIs are in Networking and Control plane.

EspWireless::new(WIFI, BT, heap)     one owner of radio peripherals and the wireless heap
  ├── wifi(&mut NetResources)  ->  EspWifi + NetRunner + EspIp   (Wi-Fi control plane + IP data plane)
  └── ble(config, &mut BleResources) ->  EspBle + BleRunner      (BLE peripheral transport)

Bring-up

let heap = rohi_net_esp::wireless_heap!(WIRELESS_HEAP_SIZE);    // adds the single wireless heap
let mut wireless = EspWireless::new(peripherals.WIFI, peripherals.BT, heap);
esp_rtos::start(...);
let (wifi, net_runner, ip) = wireless.wifi(NET_RESOURCES.init(NetResources::new()))?;
let (ble, ble_runner) = wireless.ble(BLE_CONFIG, BLE_RESOURCES.init(Default::default()))?;

Rules:

  • wireless_heap! and EspWireless::new are called once, before esp_rtos::start and before any radio use. The peripherals and the heap region are moved in, so a second call cannot compile.
  • wifi() and ble() each hand out their driver once (WirelessError::AlreadyTaken / WifiError::DriverError otherwise) and need the esp-rtos scheduler to be running.
  • Neither driver initializes radio state or an allocator on its own. Nothing else in the firmware may add heap regions for the radio.
  • A Wi-Fi or BLE failure must never stop the sensors: the firmware logs the error and carries on without that radio.

The sizes — the heap, the IP stack sockets, TCP buffers and the BLE queues — are chosen by the firmware in one place, for Altruist Urban firmwares/altruist-urban/src/resources.rs, see Resource limits.

How Wi-Fi and BLE interact

  • Shared heap. Both controllers allocate from the same fixed wireless heap; see Memory model. wireless_heap() reports the free bytes and, with the heap-stats feature, the minimum free bytes, which must be checked with both radios busy.
  • Coexistence. esp-radio runs with the coex feature so the radio arbitrates between the two protocols. Bringing the radio up, and the first Wi-Fi association in particular, keeps the core busy long enough to overrun the microphone DMA ring, so the firmware starts the sensor tasks after the first Wi-Fi attempt (bounded wait).
  • Independent control. The BLE control plane configures Wi-Fi through the generic wifi config section and service of rohi-service; the Wi-Fi manager never depends on BLE and BLE never touches the radio driver of Wi-Fi.
  • Independent failure. A BLE host error restarts advertising; a Wi-Fi failure backs off and retries. Neither stops the other or the sensors.

Hardware status

Coexistence behaviour (BLE connected while Wi-Fi scans or reconnects) and the minimum free heap with both radios busy are tracked in Hardware testing; they are not yet fully measured.

Resource limits

The canonical list of limits and static resource sizes. Other pages link here instead of repeating the numbers. Values marked product are chosen by the firmware in firmwares/altruist-urban/src/resources.rs, which documents, for each constant, what consumes it, why it has its value, what happens when it is exhausted and what was measured. Measured sizes are in Resource budget; the approach is in Memory model.

Product resource budget (Altruist Urban)

ConstantValueConsumed byWhen exhausted
WIRELESS_HEAP_SIZE128 KiBWi-Fi and BLE controllers (one shared fixed heap)radio driver allocation fails; the driver reports an error, the sensors keep running
TCP_CONNECTIONS2NetworkHandle::connectNetError::NoSocketAvailable immediately (no waiting)
TCP_RX_BUFFER / TCP_TX_BUFFER1024 B / 1024 B per connectionTCP connectionsTCP flow control; nothing is lost
UDP_SOCKETS1NetworkHandle::udp_socket (NTP time source, one exchange at a time)NetError::NoSocketAvailable immediately; the time source records a failed attempt and retries with backoff
UDP_BUFFER128 B per direction, UDP_PACKETS = 2 datagramseach UDP socketan oversized datagram is dropped by the stack
NETWORK_SOCKETSTCP_CONNECTIONS + UDP_SOCKETS + 2 (DHCP, DNS) = 5embassy-net socket tablecannot happen: checked at compile time
BLE_RX_DEPTH / BLE_TX_DEPTH4 / 4 chunks of ≤ 244 BBLE RX / TX queuesRX: the ATT response is delayed (backpressure); TX: send waits
BLE_CONNECTIONS1BLE host stackfurther centrals cannot connect
BLE_L2CAP_CHANNELS2BLE host stackconnection setup fails, advertising restarts
BLE_ATTRIBUTES24GATT attribute tablepanics once when the BLE task starts
BLE_CONTROLLER_SLOTS4HCI command slotscommands wait for a slot
CONNECTIVITY_OUTBOX_DEPTH4 frames of ≤ 544 Btelemetry outbox (rohi-connectivity)no new frame is created (skipped_outbox_full); queued frames are never evicted. See Telemetry

Heap history: 72 KiB (Wi-Fi only) → 96 KiB left 11 KB minimum free with Wi-Fi + BLE → 128 KiB. The high-water mark with the final value is not yet measured (Hardware testing).

Wi-Fi

ConstantValue
MAX_SSID_LEN32
MAX_HOST_LEN (telemetry host, control protocol)64
MAX_WIFI_PASSWORD_LEN / MIN_WIFI_PASSWORD_LEN64 / 8 (open networks have no password)
MAX_WIFI_SCAN_RESULTS16 (strongest first; the backend converts at most 48 raw entries per scan)
WIFI_COMMAND_QUEUE_DEPTH4 (scan, reconnect, disconnect only; Busy when full)
STATUS_RECEIVERS3 tasks waiting for status changes
Backoff (wifi section: backoff_initial, backoff_max, link_refresh)default 1 s doubling to 60 s; reset after the link stayed up for 10 s
Timeouts (wifi section: scan, connect; network section: DHCP)scan 20 s, connect 30 s, DHCP 20 s by default

The desired-configuration cell holds one value (latest wins); it is not a queue.

IP data plane

Capacity is the product’s TCP_CONNECTIONS above. rohi-net itself imposes no socket limit; the backend’s pool is bounded (at most 32 connections and 32 UDP sockets) and NetResources checks SOCKETS ≥ TCP + UDP + 2 at compile time. DNS returns IPv4 only.

BLE

ConstantValue
MAX_BLE_PAYLOAD244 B (MTU 247 − 3)
DEFAULT_BLE_PAYLOAD20 B (MTU 23)
Control framing6 B header, at most 255 fragments, see the control protocol limits below

Queue depths, connections and attribute count are product constants (table above).

Control protocol

Protocol limits (MAX_REQUEST_LEN 768 B, MAX_RESPONSE_LEN 2048 B, MAX_SENSORS 8, MAX_MEASUREMENTS_PER_SENSOR 4, text/SSID/password lengths, ATT payload) are defined once in rohi-control-proto/build.rs and tabulated in Control plane › Limits.

Configuration store

ItemSize
Configuration flash region16 KiB (rohi-cfg)
Slot size8 KiB (commit word included); at most MAX_SLOT_SIZE = 64 KiB
Envelope / payload overheadabout 10 B + 8 B plus 2–4 B per record
Maximum section payload (MAX_SECTION_PAYLOAD)1 024 B
Maximum sections (MAX_SECTIONS)16
ConfigStore RAMa few words of state
get stack workspace1 KiB (one MAX_SECTION_PAYLOAD buffer, only during the call)
Commit stack workspace< 0.5 KiB (64 B write buffer, 64 B copy chunk, 128 B ID set)
Request path (firmware ConfigHandle)one request slot, one response slot, callers serialised

Sensor runtime

ItemValue
Event queue (EVENT_QUEUE_CAPACITY)16 events; the oldest is dropped when full and counted (EVENTS.dropped())
Snapshot request path (MeasurementService)one request slot, one response slot, callers serialised
Noise buffers256 samples (1 KiB) on the stack + 4 KiB I²S DMA ring (I2S_DMA_BUFFER_BYTES, 64 ms of audio: the noise task must drain it in time)
Runner defaults (RunnerConfig::DEFAULT)reprobe every 10 s, Error after 3 failures, re-init after 3, retry delay 1 s up to 30 s
I²C transaction timeout (firmware)200 ms

Task stack usage is not measured yet.

Changing a limit

Change the constant where it is defined, update this page and the documentation next to the constant, and re-measure what is affected. A limit that is part of a wire or storage format (protocol sizes, MAX_SECTION_PAYLOAD) is an ABI decision: see Versioning and ABI.

Targets and features

The single reference for what the SDK builds for, and how.

Targets

TargetChipCargo featureRust targetCommands
Hostdeveloper machine–the host triplecargo test, cargo clippy, cargo doc
ESP32-C3ESP32-C3esp32c3riscv32imc-unknown-none-elfcargo check-esp32c3, cargo build-esp32c3
ESP32-C6ESP32-C6esp32c6riscv32imac-unknown-none-elfcargo check-esp32c6, cargo build-esp32c6
cargo test                # host: portable crates and desktop tools (default-members)
cargo check-esp32c3
cargo check-esp32c6
cargo build-esp32c3
cargo build-esp32c6

The check-* / build-* aliases are defined in .cargo/config.toml and expand to cargo [check|build --release] -p altruist-urban --no-default-features --features <chip> --target <triple>. Examples: cargo check -p rohi-examples --no-default-features --features <chip> --target <triple>. CI additionally runs cargo fmt --all -- --check, cargo clippy --all-targets -- -D warnings on the host and clippy for the ESP packages per chip.

One chip at a time

Never validate with --all-features. esp32c3 and esp32c6 are mutually exclusive: the firmware features forward to chip-specific esp-hal features, and the build scripts / compile_error!s reject both (or none) at once. Check each chip in its own invocation. The repository has no default embedded target: a plain cargo command builds for the host and the chip is always explicit.

Host default members

cargo test and cargo clippy at the root run on default-members: crates/* and tools/*. The platform crates compile on the host without a chip feature (they are empty), so they are covered; firmware and examples are built per chip only.

Feature flags of the SDK crates

CrateFeatureMeaning
altruist-urbanesp32c3 / esp32c6chip selection, forwarded to every *-esp crate and esp-* dependency
rohi-hal-esp, rohi-config-esp, rohi-net-espesp32c3 / esp32c6chip selection; no feature = empty crate
rohi-net-espheap-statstrack the minimum free wireless heap (small runtime overhead); enabled by the firmware
rohi-nettestingin-memory IpBackend (rohi_net::testing) for host tests; not for firmware

Network stack features

embassy-net is built with tcp, udp, dns, dhcpv4, medium-ethernet (the Ethernet medium is what esp-radio uses). udp was added with the NTP time source (rohi-time-ntp), the first component that needs it.

Policy. Platform and network protocol features are enabled only when a current SDK component uses them. A work item that needs another feature (IPv6, mDNS, …) enables it together with the component and documents why.

Toolchain

rust-toolchain.toml pins the Rust toolchain, rust-src, rustfmt, clippy and both RISC-V targets; CI uses the same toolchain. protoc is needed to build rohi-control-proto, libdbus and pkg-config to build tools/rohi on Linux; the Nix development shell provides all of them.

Errors

Public errors are small typed enums in ROHI terms. Platform crates map every vendor error into them; raw vendor errors never reach higher layers. Messages never contain secrets.

DomainTypeWhereVariants / meaning
Wi-FiWifiErrorrohi-netNotConfigured, Busy (command queue full / scan in flight), Timeout, ScanFailed, NetworkNotFound, AuthenticationFailed, AssociationFailed, DhcpFailed, Disconnected, InvalidConfig, DriverError
IP data planeNetErrorrohi-netNetworkUnavailable, DnsFailed, ConnectFailed, NoSocketAvailable, ConnectionReset, Timeout, Backend; implements embedded_io_async::Error
BLEBleErrorrohi-netDisconnected (returned once per connection end), BufferTooSmall, Busy, DriverError
Wireless runtimeWirelessErrorrohi-net-espAlreadyTaken, BleInit
Configuration storeConfigError<E>, SectionErrorrohi-configsee Configuration store › Errors
IdentityIdentityErrorrohi-identityNotProvisioned and key/signature errors, see Device identity
Configuration serviceServiceErrorfirmware configUnavailable, StorageHealthy, Store, Section, Encode
MeasurementsMeasurementServiceErrorrohi-halBusy (only try_snapshot)
SensorsSensorErrorCode, DriverErrorrohi-haldomain error codes reported in SensorEvent::Error
Control servicesServiceErrorrohi-controlInvalidArgument, NotFound, NotSupported, Busy, Unavailable, Internal, IdentityNotProvisioned, IdentityAlreadyProvisioned
Control protocolErrorCoderohi-control-protoINVALID_ARGUMENT, NOT_FOUND, NOT_SUPPORTED, BUSY, UNAVAILABLE, PERMISSION_DENIED, INTERNAL, IDENTITY_NOT_PROVISIONED, IDENTITY_ALREADY_PROVISIONED
FramingFrameErrorrohi-control-blebad version, invalid/duplicate/missing fragment, changed header, oversize, length mismatch

Distinguishing “network unavailable” from other failures

NetError::NetworkUnavailable means the network cannot carry traffic (Wi-Fi not configured or connecting, DHCP incomplete, link lost); DnsFailed and ConnectFailed are only reported while the network is up. Use the Wi-Fi status (Networking) to wait for readiness before retrying.

Mapping at the control boundary

SourceControl ServiceErrorProtocol ErrorCode
WifiError::NotConfiguredNotFoundNOT_FOUND
WifiError::BusyBusyBUSY
WifiError::InvalidConfigInvalidArgumentINVALID_ARGUMENT
WifiError::Timeout, ScanFailedUnavailableUNAVAILABLE
other WifiErrorInternalINTERNAL
configuration store unavailableUnavailableUNAVAILABLE
other configuration failuresInternalINTERNAL
missing capability–NOT_SUPPORTED
link policy denies a mutation–PERMISSION_DENIED

Persistent ABI

Everything the SDK writes to non-volatile storage is a persistent ABI: a firmware update must keep reading what an older firmware stored, and an older firmware must not destroy what a newer one stored. This page is the single reference for those rules. How to use the configuration store is described in Configuration store; step by step instructions for a new section are in Add a ConfigSection.

What is covered:

TopicSection
Field numbers, explicit CBOR representation, strict decode, secretsSection payload
Section versioning and migrationSection versioning
Section ID derivation and rename implicationsSection ID ABI
Container, slots, CRC and commit wordContainer format
Unknown-section preservationUnknown sections
Golden testsGolden tests

Section payload

Codec. Section payloads are CBOR encoded with minicbor. Types derive Encode, Decode and CborLen. Field numbers are explicit (#[n(0)], #[n(1)]…) and are the persistent schema: never reuse or renumber a field, only append new ones. There is no per-section size attribute: the encoded size is calculated from the actual value (minicbor::len) and checked against MAX_SECTION_PAYLOAD (1024 bytes) and the slot capacity before anything is written. Prefer bounded types (fixed arrays, bounded byte strings) over unbounded ones; the store itself never allocates.

Explicit representation. Every production ConfigSection must declare its CBOR representation with #[cbor(array)] or #[cbor(map)] instead of relying on the minicbor derive default, and pin the encoded bytes with a golden test (see Identity in rohi-identity). The encoding is persistent ABI.

User-configurable sections store overrides. The typed struct of a configurable section (WifiConfig) is the effective configuration and is never persisted. What is persisted is its generated overrides struct (WifiOverrides, generated by #[derive(ConfigOverrides)] on WifiConfig): a CBOR map keyed by the stable field ids, where an absent key is Override::Default (the field follows the firmware default) and a present key is an explicit Override::Value. Nullable fields are Override<Option<T>>: null is the explicit value none, an absent key is the default. A section without overrides is the empty map a0 (the section is removed from the store instead). The effective configuration is C::default(), plus the firmware overrides (compiled into the product, never persisted), plus these user overrides, so a changed firmware override or default reaches every field the user did not override, and an explicit override survives it. The three states of a nullable field are Default (absent key), Value(None) (null) and Value(Some(v)) (the value).

Strict decode. get requires the decoder to consume the whole payload. A valid value followed by extra bytes fails with SectionError::TrailingBytes, never with an unrelated error.

Secrets. Do not derive Debug for secret-bearing sections (Wi-Fi passwords, private keys); implement it by hand with redaction so secrets never reach logs or panic output.

Section versioning

The overrides sections WifiConfig, NetworkConfig, BleSettings, ConnectivityConfig, LocationConfig and NtpServersConfig keep version 1 and their section IDs, although the stored layout changed from the complete section as a CBOR array to a CBOR map of overrides. Data written in the old layout is not migrated and does not decode; reset the section (rohi config reset SECTION) to clear it.

#[config(...)] attributes

#[derive(ConfigOverrides)] takes the version from #[config(overrides = WifiOverrides, version = 1)] on the config and accepts id as below; decode_versioned and the attributes below are for hand written sections such as Identity.

AttributeMeaning
version = NRequired. Schema version of this section (u16).
id = 0xAABB…Explicit 64-bit ID, replacing the one derived from the struct name.
decode_versioned = "path::fn"Migration hook fn(version: u16, payload: &[u8]) -> Result<Self, SectionError>.

There is no name attribute. There are no global migrations: each section versions itself, and the default decoder accepts only stored version == T::VERSION (SectionError::UnsupportedSectionVersion otherwise). CBOR’s tolerance of unknown fields never replaces this explicit version policy. A migration hook can use rohi_config::decode_exact to decode older payload layouts strictly.

Section ID ABI

SectionId(u64) is the FNV-1a 64-bit hash (offset basis 0xcbf29ce484222325, prime 0x100000001b3) of the UTF-8 bytes of the Rust struct name; for an overrides struct that is the name of the section type (WifiConfig for WifiOverrides). The module path and crate name are not hashed, so moving a type between modules does not change its identity. The algorithm is part of the persistent storage ABI and must never change. Golden values:

Struct nameSectionId
Identity0x94d63573d312fe9b
WifiConfig0x335246ad20248b26
NetworkConfig0x48f89649eb74bec7
BleSettings0xcba3fd7b3d5ad5ef
ConnectivityConfig0x9c57015a40662d0c
LocationConfig0xce098b4a3e381f40
NtpServersConfig0x2feb7d230f6ed989

Renaming a persisted section type is a storage ABI change. NetworkConfig renamed to WirelessConfig gets a different ID and the stored data is no longer found. The same holds for the section type behind an overrides struct.

NetworkConfig holds IPv4 addressing and DHCP settings (version 1, overrides); Wi-Fi credentials and timing live in WifiConfig.

A raw identifier (r#Type) hashes as Type. Types with type or const parameters are rejected unless they give an explicit id: the name-derived ID would be shared by every instantiation (Foo<A>, Foo<B>).

The explicit id is the escape hatch for the cases where the ID must stay fixed or be chosen:

#[derive(Encode, Decode, CborLen, ConfigSection)]
#[config(id = 0x48f89649eb74bec7, version = 1)] // formerly named `NetworkConfig`
pub struct WirelessConfig { /* ... */ }

Use it only to keep an existing ID across a rename, to resolve a (very unlikely) hash collision or to import a legacy ID. #[derive(ConfigOverrides)] computes the ID of the overrides struct from the name of the config type (WifiConfig, never WifiOverrides); #[config(overrides = .., version = .., id = 0x...)] pins it.

Overrides golden example

WifiOverrides with ssid = "ab" and scan_timeout_ms = 5000 and nothing else overridden:

a2                 map(2)
01 42 61 62        1: ssid = bstr "ab"
03 19 13 88        3: scan_timeout_ms = 5000

An explicit “no password” is a1 02 f6 (key 2, null); a section without overrides is a0. The map keys are the field ids declared with #[config(id = N)] on the fields of WifiConfig (WifiOverrides: 1 ssid, 2 password, 3 scan_timeout_ms … 7 link_refresh_ms); they are only ever added.

Container format

Everything inside a slot is CBOR. The region is split into two equal slots, A and B.

slot  = SlotEnvelope | 0xFF padding | commit word (last 4 bytes of the slot)

SlotEnvelope  = { 0: bstr(ConfigPayload), 1: crc32 }
ConfigPayload = { 0: format_version, 1: generation, 2: [ bstr(SectionRecord)* ] }
SectionRecord = [ section_id (u64), section_version (u16), bstr(typed section CBOR) ]

ConfigPayload is stored as an opaque byte string inside the envelope, so the CRC-32 (IEEE) covers the exact encoded payload bytes and validation never needs to decode and re-encode anything. Likewise every SectionRecord is an opaque byte string, which lets the store copy records it does not understand verbatim. Fields are written in the order shown, with minimal-length CBOR heads, so the encoder is deterministic. CRC-32 detects accidental corruption; it is not authentication.

Golden example (Golden { value: 42, flag: true } with explicit ID 0x1122334455667788, generation 1):

typed section   82 18 2A F5                                   [42, true]
SectionRecord   83 1B 1122334455667788 01 44 82182AF5
ConfigPayload   A3 00 01 01 01 02 81 50 <record, 16 B>
SlotEnvelope    A2 00 58 18 <payload, 24 B> 01 1A E47B3143

Commit word

The only non-CBOR bytes are a single 4-byte word ("COMT") in the last word of each slot. It is flash transaction metadata, not schema: it holds no fields, lives outside the CBOR document and is written last, which is what makes NOR flash programming (one-way, word by word) atomic from the store’s point of view. A slot without the commit word is ignored.

Before a committed slot is erased its commit word is programmed to 0x00000000. NOR programming only clears bits, so this needs no erase, and the slot is uncommitted before any cell of it is erased. Flash erases are not atomic and make no promise about the order in which cells are cleared, so erasing alone could leave a commit word in front of a half-erased document.

Limits

MAX_SECTIONS = 16, MAX_SECTION_PAYLOAD = 1024 bytes (typed payload), slot size at most MAX_SLOT_SIZE = 64 KiB (half of the region, rounded down to the erase size). All length arithmetic is checked. A slot is valid only if the commit word, the envelope, the CRC, the payload structure and all records check out, the format version is supported, there are at most MAX_SECTIONS records and no duplicate section ID (a duplicate is an error, never first-wins or last-wins).

Unknown sections

Sections whose ID the running firmware does not know are never decoded; their SectionRecord bytes are copied unchanged on every update, so older firmware preserves a newer firmware’s sections. Updating one known section never alters the encoded bytes of unrelated records. They can be listed (for_each_section), read raw (read_raw) and explicitly removed (remove_raw).

Golden tests

Persistent encodings are pinned by golden tests, so an accidental change of a field number, representation or ID fails the test suite instead of silently orphaning stored data:

  • rohi-config tests pin the container (envelope, payload, record bytes, CRC) and the section ID hash values listed above.
  • Every production section pins its encoded bytes; WifiOverrides in rohi-net (a map of overrides) and Identity in rohi-identity ([bytes(32)]) are the reference examples. The section ID golden values above include both.
  • A new section must add its own golden test before it is persisted by a release, see Add a ConfigSection.

Changing a golden value is an ABI break and needs an explicit version bump and, where old data must stay readable, a migration hook.

Glossary

TermMeaning
ABI (persistent)The stored byte format other firmware versions rely on: container, section IDs, CBOR payloads. See Persistent ABI
BackendA platform implementation of a portable trait: WifiBackend, IpBackend, BleTransport, ConfigStorage
BleTransportPortable BLE peripheral API: one connected central as a bounded byte pipe
ActionAn explicit operation a service declares (scan, reconnect, provision) with typed arguments and result records; listed and run through the control plane. Status never changes anything. See Service pipeline
Config sectionA named group of typed, described fields of persistent desired state (wifi, connectivity, location), patched atomically; owned by a feature crate via ManagedConfig. What is persisted are its overrides
CommandA transient, bounded request to an owner task (scan, reconnect). Contrast desired state
ConfigHandle / ConfigStore / ConfigSectionHandle to the configuration owner task / the transactional store / one persisted section (the overrides struct for a user-configurable section) owned by a feature crate
Control plane (BLE)The local protobuf protocol served over BLE by rohi-control + rohi-control-ble: info, config, status and actions, driven by schemas the device publishes
Control plane (Wi-Fi)WifiHandle / WifiManager: configuration, scan, status, reconnect, disconnect. Not DNS or TCP
Desired stateThe latest value a runtime should converge to (for example the desired WifiConfig); published, not queued. See Runtime ownership
Effective configurationWhat a runtime uses and rohi config SECTION shows: C::default(), the firmware overrides compiled into the product and the user’s overrides, in that order (materialize). A changed firmware override or default reaches every field that is not overridden. See Service pipeline
EspWirelessThe single owner of the ESP radio peripherals and the wireless heap; creates the Wi-Fi and BLE drivers
Golden testA test pinning exact encoded bytes or IDs so accidental ABI changes fail
Device healthThe overall ok / degraded / error of the device, decided by the firmware from its services, never by a client
HandleA small copyable value giving other tasks bounded access to a service owned by one task
IdentityThe device’s single Ed25519 key, provisioned from the host (never generated on the device) and stored as the Identity config section. See Device identity
IP data planeNetworkHandle: DNS and TCP through embedded-nal-async / embedded-io-async, and bounded UDP sockets
IssueA currently active problem of a service with a stable machine-readable code, a severity and an age; history is not an issue
LocationWhere the device is (WGS84): set by the user now, GNSS later; sent in telemetry. See Location
MeasurementStore / MeasurementHandle / snapshotOwner-side store of typed observations / handle for on-demand requests / immutable MeasurementsSnapshot generated at request time
OwnerThe one task (or runtime object) that holds the mutable state of a service
OverrideAn explicit user value of one config field (Override::Value), as opposed to Override::Default (the field follows the firmware default). Only overrides are persisted, as a CBOR map keyed by field id; FIELD=null removes one, FIELD=none sets a nullable field to an explicit no value (Value(None)). See Persistent ABI
ProductA firmware (Altruist Urban) that composes the SDK and owns its resource budget
ReconciliationThe Wi-Fi manager continuously moving the actual radio state towards the latest desired configuration
Section IDFNV-1a 64-bit hash of the Rust struct name identifying a ConfigSection (for an overrides struct: the name of the section type)
SensorDriver / RunnerDriver trait for one sensor / the generic task loop handling probing, polling, backoff and recovery
Service (status)A runtime part exposed through ManagedService: state, health, readiness, active issues, values and actions, by name; see Services and status
Status revisionA counter that changes when a service’s state, health, readiness or issues change; lets a poller fetch details only when needed
Slot (A/B)One of the two flash slots of the configuration region; updates go to the inactive one and commit last
Time anchorThe accepted absolute time together with the monotonic instant it was accepted at; wall-clock time is anchor + monotonic elapsed. See Time service
TimeService / TimeSourceThe single source of optional wall-clock time / an abstract producer of absolute-time samples (NTP, future RTC)
State / health / readyThe three independent parts of a service status: lifecycle, operational quality, and whether it can do its job now
Wall-clock timeUnixTime: absolute time, available only after a valid sample and allowed to step; never used for durations (those use monotonic time)
Wireless heapThe fixed heap shared by the Wi-Fi and BLE controllers; the only heap in the firmware

Host testing

Most of the SDK is testable without hardware. This page defines the test pyramid, what should normally be proven on the host and the cancellation test pattern.

host unit tests
        ↓
host service / integration tests
        ↓
ESP compile checks
        ↓
hardware functional validation        ← Hardware testing
        ↓
long-running stability tests          ← Hardware testing
LevelCommandWhat it provesHardware needed
Host unit testscargo testcodecs, state machines, bounds, error mapping, golden encodingsno
Host service / integrationcargo testowner + handle + mock backend: reconnect logic, reconciliation, dispatcher, framing, rohi client end to end against the real dispatcher, cancellationno
ESP compile checkscargo check-esp32c3, cargo check-esp32c6, cargo build-esp32c3/6, ESP clippythe platform crates and firmware type-check and link for both chipsno
Hardware functional validationflash + examples/firmwareradios, flash, buses, pins, heap high-water markyes (Hardware testing)
Long-running stability24–72 h soakleaks, deadlocks, recovery under churnyes

What should be testable without hardware

Everything in the portable crates, behind its seam trait:

  • sensor drivers and the runner (mock I²C/UART, recording delay);
  • the configuration store, including power-fail safety (MemoryStorage fault injection: every torn write and erase);
  • the Wi-Fi manager and reconciliation (mock WifiBackend with scripted outcomes, held connects/scans/delays);
  • the IP data plane (rohi_net::testing::FakeIp: readiness, DNS, refused connections, slot exhaustion, read/write, UDP);
  • the time service (rohi-time: fake clock and fake sources) and the NTP source (rohi-time-ntp: a scripted NTP server over FakeIp, no Internet);
  • the control protocol, dispatcher, framing and the rohi client (in-memory transport);
  • cancellation of all of the above.

What the host cannot prove: radio and coexistence behaviour, flash timing, DMA, pin maps, real heap use. If a bug is found in one of those, add a host test for the logic part and record the hardware check.

Fakes and mocks

SeamHost implementation
ConfigStoragerohi_config::MemoryStorage
WifiBackendmock in rohi-net/src/wifi/manager/tests.rs
IpBackendrohi_net::testing::FakeIp (feature testing)
BleTransportscripted fake in rohi-control-ble tests; in-memory transport in tools/rohi
ControlBackend / registriesDevice built from fake services in rohi-control and rohi-control-ble tests; the real sections and services in tools/rohi tests
sensor buses and delaysMockI2c, RecordingDelay, FixedClock in rohi-hal

Cancellation tests

For every public async operation with non-trivial cancellation semantics (Async and cancellation) test the hazard with embassy_futures::poll_once and a pinned future that is dropped while pending:

start the operation ── poll once (pending) ── drop it ── then verify:
  - the service still responds
  - channel / slot / pool capacity is not permanently lost
  - a stale answer is not delivered to the next caller
  - nothing stays locked
  - the next operation succeeds

Existing examples: dropped Wi-Fi scan (dropped_scan_does_not_leak_into_the_next_one), configuration change during connecting/backoff/scanning, dropped measurement snapshot, cancelled connect / read / write / DNS on FakeIp, cancelled control request (cancelled_step_abandons_the_request_and_the_next_one_works), dropped sensor step during its warm-up wait.

Book examples

Rust code shown in the book is either compiled by cargo test and included with {{#include file:anchor}} (for example crates/net/tests/network_example.rs) or marked ignore/text when it cannot run on the host (hardware snippets). mdbook test docs runs plain snippets. See Documentation style and policy.

Hardware testing

What the host tests cannot prove — real radios, flash, buses and long-term stability — is validated on devices. The test pyramid and what must be covered without hardware is in Host testing. This page lists the hardware checks, their procedure and their current status. Anything not listed as measured has not been validated on hardware yet.

Procedure

Build and flash with the per-chip commands in Targets and features; the runner flashes the chip’s partition table. Examples (one per sensor and a configuration self-test) live in examples/ and print typed results; the firmware logs a snapshot summary every 30 s.

EXAMPLE="cargo run --release -p rohi-examples --features esp32c3 --target riscv32imc-unknown-none-elf"
$EXAMPLE --bin example-bmx280
$EXAMPLE --bin example-scd4x
$EXAMPLE --bin example-sds011
$EXAMPLE --bin example-noise
$EXAMPLE --bin example-all-sensors
$EXAMPLE --bin example-config        # configuration persistence self-test

(ESP32-C6: --features esp32c6 --target riscv32imac-unknown-none-elf.)

Sensors

Combined test checklist: BMx280 keeps updating; SCD4x keeps updating; SDS011 completes its duty cycle and sleeps; noise windows keep arriving; no sensor starves another. Hot-reconnect check: unplug and replug an I²C sensor and confirm Error → Initializing → Ready without a reboot; repeat for the SDS011 UART. The ESP32-C6 pin map is not yet verified on hardware (see Boards).

Configuration store

example-config verifies persistence: every boot checks what the previous boot stored (typed write + reboot + read, several sections, replacement preserving unrelated sections, repeated write cycles) and logs SELFTEST PASS or SELFTEST FAIL. On the third boot it corrupts the newest slot on purpose and checks that the store reports Recovered and falls back to the previous generation.

Wi-Fi and IP

Not yet measured on hardware: scan, connect/DHCP, reconnect after reboot, wrong password, access point loss, configuration change while connecting/backing off/connected, DNS and TCP through NetworkHandle (no component uses it yet), BLE + Wi-Fi coexistence, minimum free wireless heap (idle, scanning, connected), 24 h stability. Record results here together with the final WIRELESS_HEAP_SIZE.

Device identity

The hardware checklist (provision, reboot, erase, re-provision) is in Device identity; its results are not yet recorded.

BLE control

First hardware data point (96 KiB heap, Wi-Fi + BLE running, chip not recorded): 14 656 B free, 11 120 B minimum free, i.e. about 87 KB peak use, too little margin, hence 128 KiB. Re-measure the minimum free with the larger heap during BLE connected + Wi-Fi scan/reconnect.

Not yet measured: BLE advertising with Wi-Fi disconnected and connected, BLE connected with Wi-Fi connected / scanning / reconnecting, minimum free wireless heap for each, MTU negotiation, and a 24 h (preferably 72 h) stability run exercising BLE connect/disconnect, sensor reads, config reads and updates, Wi-Fi status, scan and reconnect. Expected outcome: no panic, no allocator exhaustion, no deadlock, no permanent BLE or Wi-Fi failure.

Control plane stack

The stack high-water mark of the control path (rohi status with Wi-Fi and identity configured and all services active, config and action requests, factory reset) is not yet measured. The procedure, the scenarios and the table for the results are in Resource budget.

Sensor stability test

Run altruist-urban without networking for at least 24 h (preferably 72 h) with all sensors attached and record the following. The table is filled in after each hardware run:

ItemResult
Duration
Unexpected reboots / panics
Sensor errors per sensor
Successful recoveries
dropped events (log)
Task stalls (snapshots stop updating)
Stale measurements
Memory stability

Resource budget (measurements)

Measured sizes of the firmware and the libraries, per hardware or build run. The limits themselves (queue depths, buffer sizes, heap size) are defined and explained in Resource limits; this page only records what was measured with them. Numbers here are history, not contracts: they change with every dependency update and are re-measured when the limits change.

Altruist Urban firmware (Wi-Fi + BLE control)

Measured with size on the release build (cargo build-esp32c3 / cargo build-esp32c6), baseline = Wi-Fi only, before the BLE control plane was added:

ESP32-C3 beforeESP32-C3 afterESP32-C6 beforeESP32-C6 after
.text (flash)970 0921 149 9521 012 3221 335 452
.data10 02415 64012 87618 396
.bss106 984181 248111 288185 056
wireless heap (in .bss)73 728131 07273 728131 072
.bss without heap33 25650 17637 56053 984

Statically allocated control buffers: reassembly 256 B + response 2 048 B + chunk 244 B (control task), BLE RX/TX queues 4 × ~250 B each, TrouBLE host resources, and the task futures. The application code stays heapless; only the radio drivers use the wireless heap.

Sensor runtime (rohi-hal)

cargo build-esp32c3, ESP32-C3 (before Wi-Fi/BLE/TLS):

ItemSize
.text (flash)114 370 B
.rodata (flash)24 096 B
.rwtext + .data (RAM)1 700 B + 2 132 B
.bss (RAM, includes task futures)9 160 B
Heapfixed radio heap only (see Memory model); application code does not allocate
Event queue16 events
Noise buffers256 samples (1 KiB) stack + 8 KiB DMA ring

Task stack usage is not measured yet.

Stack and the control plane

All embassy tasks are polled on the one main stack, which on the ESP32-C3 build of altruist-urban is the linker .stack section: about 42 KB (_stack_end to _stack_start of the release ELF) and it sits directly above .bss, so an overflow corrupts task state (observed as a Load access fault in the embassy timer queue with the ASCII of a service name in the registers). The control task builds and moves protobuf responses on that stack, so response sizes are bounded by the compact protocol layout and by explicit budgets, see Control protocol types. The static future of control_task is about 11.6 KB of .bss (11 856 B, release build, ESP32-C3, measured with nm -S on altruist_urban::control::control_task::POOL). Stack depth under load is not measured yet: it needs a device, see the plan to measure it. The stack is what the linker leaves after .bss, so every static byte and every deep frame on the config or control path costs stack: a decoder that builds a large section from temporaries (the wifi section, about 200 B) needed ~900 B of frame and overflowed the stack on a rohi config request; WifiConfig is decoded in place for that reason. Check _stack_end and the frames of the config and control path when a section or a static grows.

Control protocol types

The generated types of the control protocol keep every bounded field inline, so their size is the size of their largest possible content. generated_types_stay_within_their_memory_budgets (rohi-control-proto, runs in CI) asserts a budget for each; the sizes are size_of on the host (64-bit), before and after the compact layout of the protocol (numeric ids and enum values, f32, compact FieldDescriptor, enums and issue names in the schema, no descriptions, no summary in the detailed status, no pagination):

TypeBeforeAfterBudget
Request760736768
Response2 6161 8241 900
FieldDescriptor3524856
FieldValue885664
GetServicesStatusResponse792792 (12 services; 536 for 8)832
GetServiceStatusResponse1 272 (8 values)1 192 (20 values)1 280
GetConfigSectionResponsen/a1 5041 536
PatchConfigSectionRequestn/a728768

The Response is the largest of its variants (the service schema). The before column paged status values by eight, schemas by four; the after column holds a whole service status (20 values), a whole schema (20 fields, an enum table, 12 issue names) and 12 actions in one response. GetConfigSectionResponse carries the effective values plus the compact list of overridden field ids (overridden), and PatchConfigSectionRequest its set / clear mutations; both are new rows, measured after the overrides model. Raising a budget is a deliberate decision: measure the control task again and update this table.

Measuring the control task

The stack high-water mark of the control path has to be measured on a device, so that a future change has a concrete baseline to be compared with. Not measured yet: the table below is the record to fill in. What is known without a measurement: rohi status completes with Wi-Fi configured, an identity provisioned and all services active (reported on the pull request that introduced the generic services); the previous stack overflow is not reproducible. How much headroom remains is not known.

Procedure. Build the release profile of altruist-urban for the target (cargo build --release -p altruist-urban --no-default-features --features esp32c3 --target riscv32imc-unknown-none-elf, esp32c6 with riscv32imac-unknown-none-elf) and read the stack size from the ELF (_stack_start - _stack_end, e.g. with riscv32-esp-elf-nm or rust-nm). Paint the stack with a known pattern at boot (fill the region between _stack_end and the current stack pointer, keeping a guard word at the bottom), run one scenario at a time from the table, then read the stack with a debugger or dump it from a debug-only command and find the lowest address whose pattern changed. Use the same firmware build for every row and reboot between scenarios. Maximum usage is _stack_start - lowest touched address; headroom is the stack size minus that.

Scenarios, each with the device in the stated state (all services active, i.e. the Wi-Fi and BLE services running):

  1. unconfigured device (no Wi-Fi, no identity)
  2. Wi-Fi configured
  3. identity provisioned
  4. Wi-Fi and identity both configured: rohi status (the regression scenario) and rohi status <service> for every service
  5. rohi config <section> and rohi config <section> FIELD=VALUE of every section
  6. Wi-Fi scan and wifi reconnect action invocation
  7. identity provision and erase action invocation
  8. rohi factory-reset
  9. a reboot request

Record per target:

FieldESP32-C3ESP32-C6
Hardware (board, chip revision)
Firmware build (commit, profile)
Stack size (bytes)
Static control_task future (bytes, nm -S)
ScenarioESP32-C3 max usage / headroomESP32-C6 max usage / headroom
1. unconfigured
2. Wi-Fi configured
3. identity provisioned
4. rohi status, Wi-Fi + identity
4. rohi status <service>, worst service
5. config <section> read / FIELD=VALUE patch, worst section
6. Wi-Fi scan / connect
7. identity provision / erase
8. factory reset
9. reboot request

Safety margin (proposed). Until measured, no margin is claimed. When the table is filled in, state the lowest headroom of any row and keep at least 25 % of the stack (or 4 KB, whichever is larger) free in the worst row; a change that cuts into it needs another measurement. The generated-type budgets in Control protocol types stay in CI independently of this measurement.

Configuration store

The store uses no heap; see Resource limits for its bounds. Its RAM cost is a few words of state plus the stack workspace during a call.

IP stack, TCP and UDP pools

Not measured: no SDK component opens TCP connections yet, and the UDP pool (one socket, 2 x 128 B plus packet metadata) used by the NTP time source has not been measured on hardware either; the ntp_task stack and the time service state are small and also not measured. Measure the .bss contribution of NetResources and the minimum free wireless heap with an open connection when the first uplink component lands, and record it here.

How to measure

cargo build-esp32c3 && size target/riscv32imc-unknown-none-elf/release/altruist-urban
cargo build-esp32c6 && size target/riscv32imac-unknown-none-elf/release/altruist-urban

The minimum free wireless heap comes from rohi_net_esp::wireless_heap() (the firmware logs it with its snapshot; the heap-stats feature adds the minimum).

Documentation style and policy

Where things are documented

PlaceContent
README.mdproject landing page and the shortest quickstart, with links to this book and the API docs
This book (mdBook, docs/)architecture, concepts, SDK component overviews, guides, platform notes, reference, validation
Rustdoc (cargo doc --no-deps)exact Rust API documentation; do not copy API listings into the book
Crate and module commentslocal implementation contracts: invariants, cancellation semantics, safety

One authoritative location per concept

A fact lives on exactly one page; other pages link to it. Canonical pages:

ConceptPage
Resource limits and constantsResource limits
Persistent ABI rulesPersistent ABI
Targets, features and commandsTargets and features
Control protocol and its limitsControl plane
Cancellation rulesAsync and cancellation
Measurements and sizesResource budget

Style

  • Describe the current architecture with stable source names (WifiHandle, resources.rs), small diagrams, concrete examples and explicit ownership and dependency boundaries. Link to source and rustdoc instead of pasting them.
  • Do not leave obsolete work-package terminology or history in normative pages; history belongs to the commit log and the measured-data tables of the validation pages.
  • Mark claims that are not validated on hardware explicitly (“not yet measured”, “not verified on hardware”).
  • State security limitations plainly (no BLE pairing, no TLS yet).
  • Prefer tables for limits and error maps, text diagrams for structure, and short paragraphs.
  • Examples are compiled where possible: put host-compilable code in a test file, show it with {{#include path:anchor}} and mark the block rust,ignore; use text or rust,ignore for hardware-only snippets.

Update policy

A change must update the SDK book in the same pull request when it changes:

  • public architecture or crate responsibility,
  • public API usage,
  • persistent ABI or the wire protocol,
  • target support,
  • resource defaults,
  • the developer workflow.

Pure internal refactors with unchanged public behaviour do not need documentation churn.

Checks

mdbook build docs          # the book builds
mdbook test docs           # plain Rust snippets compile
cargo doc --no-deps        # rustdoc of the host crates builds without warnings
lychee --offline docs/book # internal links and anchors resolve (CI)

CI runs these in the docs job. A broken internal link is a defect: fix the link or the page, do not exclude it.