Introduction
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
- Type- and memory-safe using Rust Language
- Fully async code powered by Embassy Framework
- Secure & decentralized infrastructure on Robonomics Network
Where to start
- New to ROHI: What is the ROHI SDK?, then Installation and First device.
- Understand the design: Architecture and Crate map.
- Extend it: the guides.
- Look something up: Reference and Validation.
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
| Area | What you get | Read |
|---|---|---|
| Sensors | Typed measurements, drivers (BMx280, SCD4x, SDS011, noise), runtime, MeasurementStore | Sensor HAL, Sensors |
| Configuration | CBOR, A/B-slot, power-fail-safe store of independent typed sections; users persist only their overrides of the firmware defaults | Configuration store |
| Networking | Wi-Fi control plane, DNS/TCP data plane, ESP backend | Networking |
| Services | Config sections, live status (state, health, issues) and actions of every module, discovered by name | Service pipeline, Services and status |
| Control | Generic local BLE control protocol, rohi desktop client | Control plane, The rohi tool |
| Location | Where the device is (configured now, GNSS later), sent in telemetry | Location |
| Identity | Provisioned Ed25519 device identity and signer | Device identity |
| Firmware | Altruist Urban, a complete product built from the above | Boards |
Design principles
- Portable core, thin platform crates. Everything except
*-espcrates, 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
- Installation, Workspace, Build and test.
- First device: run a sensor example on hardware.
- Architecture and Crate map: where things live.
- The concepts, then the SDK component pages you need, then the guides.
- 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:
| Example | Sensor |
|---|---|
example-bmx280 | BMP280 / BME280 pressure, temperature, humidity |
example-scd4x | SCD40 / SCD41 CO₂, temperature, humidity |
example-sds011 | SDS011 particulate matter (PM2.5 / PM10) |
example-noise | I²S microphone noise level |
example-all-sensors | All 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:
- The Rust compiler creates a RISC-V binary;
espflashdetects the ESP board and flashes the firmware;espflashconnects 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
| Situation | What rohi does |
|---|---|
| One device found | uses it |
| Several devices, interactive terminal | shows a numbered list on stderr and asks |
| Several devices, no terminal | fails 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 code | Meaning |
|---|---|
0 | success |
1 | failure of the device, the link or the command (including a declined prompt) |
2 | command line usage error |
130 | interrupted 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=VALUEis 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-runvalidates without storing.- The result of every change is validated as
defaults + overrides; an unconfigured or disabled section (nossidmeans 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=truealone fails until thehostis set,rohi config connectivity enabled=true host=ingress.example.orgworks. - Three states for a nullable field.
FIELD=valueis an explicit value,FIELD=noneis an explicit “no value” (rohi config wifi password=noneconfigures an open network,rohi config time server2=nonedisables the second NTP server) andFIELD=nullremoves the override, so the field follows the current firmware default again (rohi config time server2=nullrestores the firmware’s second server).noneis only valid for fields that--helpmarksnullable;rohirefuses it for any other field before contacting the device.FIELD=nullworks for any writable field, required or not. - Secrets (
secretin--help) are never returned and never part of exports. PutNAME=-instead of a value; the tool refuses a secret on the command line.password=noneandpassword=nullare 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 file | Effect |
|---|---|
"field": value | set the override (even if the value equals the default) |
"field": null | remove the override (follow the firmware default) |
"field": {"none": true} | explicit “no value” of a nullable field (distinct from null) |
| field missing | left unchanged |
"section": null | reset 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 applyrefuses them and points atconfig SECTION NAME=-. diffcompares the file with your overrides on the device and prints~changes,+new overrides,-removed overrides andreset section; nothing is changed. Fields that already match are not listed.applychecks 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 byexport/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
| Before | Now |
|---|---|
rohi sensors | rohi status sensors |
rohi time | rohi status time |
rohi wifi (status) | rohi status wifi |
rohi wifi scan | rohi action wifi scan |
rohi wifi reconnect, disconnect | rohi action wifi reconnect / disconnect |
rohi wifi configure, forget | rohi 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, run | rohi action, rohi action <service> <action> --help, rohi action <service> <action> |
rohi backup export / import | rohi config export / apply |
Commands
| Command | What it does |
|---|---|
scan | discover nearby devices |
status [SERVICE] [--watch] | device summary or one service |
config [SECTION] [FIELD=VALUE...], config reset SECTION | list, show and change configuration sections |
config export, diff, apply | configuration files (your overrides) |
factory-reset [--yes] | format the configuration storage, identity included |
reboot | reboot the device |
action [SERVICE] [ACTION] [ARG=VALUE...] | list and run the actions of the services |
Troubleshooting
| Symptom | Check |
|---|---|
no ROHI device found | Bluetooth is on, the device is powered and advertising, no other client holds its connection; raise --scan-timeout |
timed out waiting for the device | the device is in range; raise --timeout |
multiple ROHI devices found | pass --device or set ROHI_DEVICE; rohi scan lists them |
the device has no section … / NotFound | the name is not one the device lists: rohi config, rohi status, rohi action |
| a section shows fewer fields than expected | the tool is older than the firmware; rebuild rohi from the same revision |
the device refused the request: permission denied | the 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:
| Answers | Values | |
|---|---|---|
| State | where is it in its lifecycle? | disabled, stopped, starting, running, stopping, failed |
| Health | how well does it work? | ok, degraded, error, or - (unknown) |
| Ready | can it do its job right now? | yes / no |
So the same word means different things in different columns, and combinations carry information:
| State | Health | Ready | Meaning |
|---|---|---|---|
running | ok | yes | working |
running | degraded | yes | works, but something is wrong (telemetry without a location) |
running | degraded | no | the task runs, the backend does not answer |
starting | - | no | Wi-Fi is still connecting |
disabled | - | no | turned off by configuration or never set: not an error |
failed | error | no | crashed 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
| Service | What it reports | Typical issue codes |
|---|---|---|
wifi | connection state, network, signal, channel, radio heap | authentication_failed, network_not_found, association_failed |
network | IPv4 address, gateway, DNS, address mode, socket usage | dhcp_failed |
ble | peripheral state, advertised name, link security | driver_error |
connectivity | telemetry queue and counters | connection_timeout, unauthorized, no_identity, no_utc_time, no_location |
time | wall-clock time, source, age, failures | network_unavailable, timeout, implausible |
sensors | <sensor>.<measurement> values in canonical units | bmx280_error, sds011_degraded, … |
identity | provisioned or not, public key | not_provisioned |
location | where 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:
- Dependencies point from platform and product towards portable crates.
rohi-netnever depends onrohi-net-espor onrohi-control*;rohi-halnever depends onrohi-net; portable crates never depend onesp-*crates. See Portability. - Feature crates own their own config sections.
rohi-confignever learns the set of application sections:rohi-netownsWifiConfig,NetworkConfigandBleSettings(each the effective configuration, persisted as generated overrides),rohi-identityowns theIdentitysection. 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. - Stateful services have one owner task and are reached through bounded handles: see Runtime ownership.
- Platform types never cross a portable API. Vendor types and errors are converted into ROHI types inside the platform crate.
- 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.
| Crate | Purpose | Portable? | std / no_std | Major public abstractions | May depend on | Does not own |
|---|---|---|---|---|---|---|
rohi-hal | Typed units and measurements, sensor drivers, sensor runtime, MeasurementStore | portable | no_std | MeasurementStore, MeasurementsSnapshot, SensorDriver, Runner, EventBus, MeasurementHandle | embedded-hal(-async), Embassy sync, rohi-service | MCU peripherals, board pins, networking, serialization |
rohi-hal-esp | ESP32 backend of the HAL: boards, I²C/UART/I²S setup, clock | ESP | no_std | board::altruist_urban::{Hardware, AltruistUrban} | rohi-hal, esp-hal | Sensor logic, application tasks |
rohi-config | Transactional CBOR configuration store | portable | no_std | ConfigSection, ConfigStore, ConfigStorage, MemoryStorage, Override, ConfigOverrides, HasOverrides, materialize | minicbor | Any concrete section, flash access |
rohi-config-derive | #[derive(ConfigSection)], #[derive(ConfigOverrides)] | portable (proc-macro) | std (build time) | derive macro | syn, quote | Runtime behaviour |
rohi-service | The generic service pipeline: Value, ManagedConfig sections with atomic patch, ManagedService status with state/health/issues/revisions, actions, tuple registries | portable | no_std | ManagedConfig, ManagedSection, ConfigRegistry, ManagedService, Tracked, ServiceRegistry, ActionDescriptor, Value | rohi-config (the ConfigOverrides bound), heapless, Embassy sync | Any concrete section or service, the protocol |
rohi-config-esp | Flash backend of the store | ESP | no_std | EspConfigStorage, FlashRegion, layout | rohi-config, esp-storage | CBOR, sections, any feature’s configuration |
rohi-identity | Device identity: Ed25519 Identity config section, Secret, Signer, IdentitySigner | portable | no_std | Identity, Secret, Signer, IdentitySigner, PublicKey, Signature | rohi-config, rohi-service, ed25519-zebra | Provisioning transport, key derivation (done on the host by rohi), protocol semantics of signed messages |
rohi-net | Wi-Fi control plane, IP data plane (DNS, TCP, UDP), BLE transport traits, WifiConfig, NetworkConfig and BleSettings sections and the wifi, network and ble services | portable | no_std | WifiManager, WifiHandle, WifiBackend, NetworkHandle, IpBackend, UdpSocket, NetError, BleTransport | rohi-config, rohi-service, Embassy sync, embedded-nal-async, embedded-io-async | Radio drivers, the IP stack, control protocol |
rohi-time | Synchronized wall-clock time: TimeService, TimeSource, validation, background Synchronizer | portable | no_std | TimeService, TimeSource, TimeSample, UnixTime, TimeStatus, Synchronizer | rohi-hal (monotonic Clock, Backoff), rohi-service, Embassy sync | Network or clock hardware, any concrete source |
rohi-time-ntp | NTP/SNTP TimeSource over the IP data plane | portable | no_std | NtpTimeSource, NtpConfig, NtpServersConfig (the time config section) | rohi-time, rohi-net, rohi-config, rohi-service, sntpc | The global concept of time, the UDP stack |
rohi-location | The location of the device (user-configured now, GNSS later): LocationConfig section, LocationShared, LocationService | portable | no_std | Location, LocationConfig, LocationShared, LocationSource, LocationService | rohi-config, rohi-service | GNSS hardware, telemetry |
rohi-connectivity-proto | Bounded bindings of airalab/connectivity-protocol (vendored, pinned revision) | portable | no_std | core_v1, crypto_v1, device_v1, sensor_v1, limits, signing_preimage | micropb | Signing, mapping, transports |
rohi-connectivity | Telemetry: snapshot (+ location) → signed envelope frame, bounded outbox, retry, HTTP transport, ConnectivityConfig section, connectivity service | portable | no_std | UrbanEncoder, EnvelopeBuilder, EnvelopeFrame, EnvelopeTransport, HttpTransport, ConnectivityService, UtcClock, NonceSource, FrameSigner | rohi-connectivity-proto, rohi-hal, rohi-identity, rohi-location, rohi-net, rohi-service, rohi-time | Private keys, Wi-Fi lifecycle, sensors, backend logic |
rohi-net-esp | ESP backend of rohi-net | ESP | no_std | EspWireless, EspWifi, EspIp, EspBle, NetResources, BleResources, wireless_heap! | rohi-net, esp-radio, embassy-net, TrouBLE | Product resource sizes, control protocol |
rohi-control-proto | Bounded protobuf schema of the control protocol | portable | no_std | generated messages, PROTOCOL_VERSION, limits | micropb | Transport, behaviour |
rohi-control | Transport-agnostic control dispatcher and authorization; serves the registries of rohi-service | portable | no_std | Controller, ControlBackend, Device, AuthorizationPolicy | rohi-control-proto, rohi-service | BLE, stores, the radio, any knowledge of a module (Wi-Fi, sensors, …) |
rohi-control-ble | GATT binding: UUIDs, framing, fragmentation, control loop | portable | no_std | ControlBle, frame | rohi-control, rohi-net | The BLE stack |
altruist-urban (firmware) | Product composition, tasks, resource budget | product | no_std | config, net, control, time, connectivity, resources, tasks | everything above | Reusable library code |
rohi-examples (examples/) | One hardware example per sensor and a config self-test | product | no_std | binaries | rohi-hal(-esp), rohi-config(-esp) | |
rohi (tools/rohi) | Desktop client library and CLI over BLE | desktop | std | Device, Session, BleLink | rohi-control-ble::frame, rohi-control-proto, rohi-net (BLE payload size), btleplug | Anything 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 in | Notes |
|---|---|---|
| A new sensor driver | rohi-hal (sensors/) | Add a sensor; a new board only needs wiring in rohi-hal-esp |
| Pins or bus setup for a board | rohi-hal-esp (board/) | Add a board |
| A new persistent configuration section | The crate of the feature that interprets it | Add a ConfigSection; never in rohi-config |
| Flash or storage backend | rohi-config-esp (or a new *-esp crate) | Implement ConfigStorage |
| An absolute-time source (RTC, external time) | A new portable crate implementing rohi_time::TimeSource | Consumers keep using TimeService; see Time service |
| DNS/TCP/UDP behaviour, TLS/HTTP/uplink protocols | A new portable crate over NetworkHandle / the embedded-nal-async traits | Never use embassy-net directly |
| Another IP stack or radio | A platform crate implementing IpBackend / WifiBackend | Vendor types stay inside |
| A service (status, config, actions) of a runtime | The crate that owns the runtime; register it in the firmware tuples | Add a service; no protocol or CLI change |
| A control operation that is none of config, status or action | rohi-control-proto (schema), rohi-control (dispatch, ControlBackend) | Add a control operation or an action |
| Product resource sizes and task wiring | The firmware (resources.rs, tasks/) | The product owns the RAM budget |
Anything that needs esp-hal / esp-radio | A *-esp platform crate | Keep 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.
| Service | Owner | Handle | Reached through |
|---|---|---|---|
| Measurements | store task owns MeasurementStore | MeasurementHandle | bounded snapshot request/response |
| Configuration | configuration task owns ConfigStore | ConfigHandle | bounded, numbered request/response |
| Wi-Fi | WifiManager task owns the Wi-Fi backend | WifiHandle | desired-state cell, bounded commands, published status |
| IP stack | the platform stack (EspIp) behind the stack runner task | NetworkHandle | DNS and TCP through standard traits |
| ESP wireless resources | EspWireless (radio peripherals, wireless heap) | the drivers it hands out once | wifi(), ble() |
| BLE peripheral | BleRunner task owns the controller and host stack | EspBle (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::configtouches the configuration flash; nothing outside the Wi-Fi manager task calls the Wi-Fi driver; sensor tasks never touch theMeasurementStore. - 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.
| Interaction | Kind | Mechanism |
|---|---|---|
Desired WifiConfig and NetworkConfig | state | WifiHandle::set_wifi_config / set_network_config (latest value wins, synchronous) |
| Latest system / Wi-Fi status | state | published WifiStatus (Watch) |
| Scan, reconnect, disconnect | command | bounded queue, Busy when full |
| One-shot request (snapshot, config read) | command | bounded 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-rtostime base). Tasks are cooperative: nothing runs concurrently with another task between two.awaitpoints 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
.awaitpoints. Anything that must be atomic must not contain an.await.
Primitives and their cancellation behaviour
| Primitive | Use | When the waiting future is dropped |
|---|---|---|
Bounded Channel (send/receive) | commands, request/response, queues | receive takes an item only when it returns it; send queues the item entirely or not at all |
Signal / Watch (latest value) | status, completion, desired state | the value stays available; waiting again returns it |
Mutex serialising callers | one request in flight | the guard is released on drop |
| Sequence-numbered responses | request/response with one owner | a stale response is discarded; it never reaches a later caller |
Timer / select | timeouts, preemption | the other branch is dropped: it must be cancel-safe |
Rules for SDK code
-
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. -
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.
-
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
selectunless the driver documents that this is safe (WifiBackend::connectandwait_link_lostare, because the manager drops them on purpose). -
State changes after the last
.await. Do not take or clear a flag before an.awaitthat can be dropped; clear it after the await completes. (The sensor runner’s warm-up flag was fixed for exactly this reason.) -
Prefer state publication over queued commands for persistent desired state; see State versus command.
-
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
| API | Cancellation semantics |
|---|---|
WifiHandle::set_wifi_config / set_network_config | synchronous; nothing to cancel |
WifiHandle::scan | dropping 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 / disconnect | synchronous commands; Busy when the bounded queue is full |
WifiBackend::connect / wait_link_lost | must be cancel-safe: the manager drops them when a command or configuration change preempts them |
NetworkHandle DNS, connect, read, write | cancel-safe; dropping connect frees the slot; a dropped write sends nothing that was not reported |
MeasurementHandle::snapshot / try_snapshot | cancellation-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 / remove | before the request is queued: unchanged; after: logically completed (the owner commits and publishes) |
IdentityHandle::store / erase | as ConfigHandle::set / remove: the owner task updates the signer in the commit step, so the stored identity and the signer cannot diverge |
ConfigHandle::get / status | read only; abandoned requests are answered and discarded (numbered responses) |
BleTransport::recv / send | cancel-safe: a chunk is taken or queued entirely or not at all |
ControlBle::step | dropping abandons the request being handled; no partial state; committed operations stay committed |
EventBus::receive | cancel-safe |
sensor Runner::step | may 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.
| Category | Crates | Properties |
|---|---|---|
| Portable | rohi-hal, rohi-config, rohi-config-derive, rohi-net, rohi-control-proto, rohi-control, rohi-control-ble | no_std, no allocation, no MCU HAL, builds and tests on the host |
| ESP-specific | rohi-hal-esp, rohi-config-esp, rohi-net-esp | no_std, depend on esp-hal / esp-radio / esp-storage / embassy-net / TrouBLE; empty without a chip feature, so they stay buildable on the host |
| Product | altruist-urban, examples | choose the chip, size the resources, spawn the tasks |
| Desktop | tools/rohi | std; talks to a device over BLE |
How portability is achieved
- Traits at the seams.
SensorDriver+embedded-hal(-async)for sensors;ConfigStoragefor flash;WifiBackend,IpBackendandBleTransportfor the radio and IP stack;ControlBackendand therohi-serviceregistries 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-*orembassy-net.NetworkHandleis generic overIpBackend, and higher layers should depend on the standardembedded-nal-async/embedded-io-asynctraits. - 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 (
esp32c3oresp32c6) 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
heaplesstypes or fixed arrays; strings and scan results have maximum lengths (MAX_SSID_LEN,MAX_WIFI_SCAN_RESULTS, …). Section payloads are limited byMAX_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 instaticitems, so its size is visible in.bssand 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 toEspWireless::new, which is its only owner. The allocator implementation stays inrohi-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 theheap-statsfeature, 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:
| Resource | Type / constant | Consumed by |
|---|---|---|
| Wireless heap | WIRELESS_HEAP_SIZE → wireless_heap! | Wi-Fi and BLE controllers |
| IP stack sockets, TCP connections and buffers | NETWORK_SOCKETS, TCP_CONNECTIONS, TCP_RX_BUFFER, TCP_TX_BUFFER → NetResources | DHCP, DNS, TCP |
| BLE queues, connections, L2CAP channels, attributes, controller slots | BLE_* → BleResources | BLE 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.
| Contract | Compatibility rule | Canonical 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 break | Persistent 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 yet | Control plane |
| BLE framing (frame header, GATT UUIDs) | Frame version byte; incompatible changes bump it | Control plane |
| Rust APIs of the crates | Semver; ControlBackend, the rohi-service traits and the backend traits are extension points and change deliberately (see changelogs in commit history) | rustdoc |
| Partition table / flash layout | Mirrored by constants in rohi_config_esp::layout; a host test keeps both in sync | Configuration 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
| Component | Location | Role |
|---|---|---|
| Drivers | crates/hal/src/sensors/ | BMx280, SCD4x, SDS011, noise. Hardware independent (embedded-hal-async, embedded-io-async), unit tested on a host |
| Runtime | crates/hal/src/runtime/ | Runner lifecycle, Reporter, EventBus, backoff, bounded I²C |
| Types, store | crates/hal/src/ (measurements, sensor, store, …) | Typed units, SensorEvent, MeasurementStore, MeasurementsSnapshot |
| Board | crates/hal-esp/src/board/altruist_urban/ | All pin assignments, bus configuration (features esp32c3 / esp32c6) |
| Tasks | firmwares/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
NotDetectedand probed every 10 s. - A sensor that was detected before and stops answering is
Errorand recovered with exponential backoff, so a reconnected sensor returns toReadywithout a reboot. - The first failure gives
Degraded; 3 consecutive failures giveErrorand 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 stillInitializing(BMx280, noise) until its first measurement arrives.
Timing semantics
Four delays exist and are never added up:
| Delay | Owner | Meaning |
|---|---|---|
| Initial delay | driver (initial_delay_ms, default 0) | Wait before the first sample after first detection. No current driver needs one |
| Poll interval | driver (poll_interval_ms) | Wait after a successful sample before the next one |
| Recovery backoff | runner (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-up | driver, inside sample | Hardware 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×:
| Sensor | Worst healthy cadence | Stale after |
|---|---|---|
| BMx280 | 10 s | 60 s |
| SCD4x | 15 s | 30 s |
| SDS011 | 150 s (warm-up + sampling window + sleep) | 300 s |
| Noise | 5 s | 30 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.
| Sensor | Bus | Cadence | Notes |
|---|---|---|---|
| BMP280 / BME280 | I²C 0x76 / 0x77 | 10 s | Temperature, pressure, humidity (BME280 only) |
| SCD40 / SCD41 | I²C 0x62 | 5 s | CO₂, temperature, humidity |
| SDS011 | UART, 9600 baud | 125–150 s duty cycle | PM2.5, PM10, fan off between cycles |
| I²S microphone | I²S | 5 s window | Noise 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:
| Constant | Default | Meaning |
|---|---|---|
WARMUP_MS | 30 s | Fan spin-up before sampling |
SAMPLE_COUNT | 5 | Valid samples required per cycle |
SAMPLE_INTERVAL_MS | 1.5 s | Pause between two queries |
CYCLE_TIMEOUT_MS | 30 s | Sampling window |
TRIM_MIN_MAX | on | Discard the lowest and highest sample |
SLEEP_MS | 90 s | Pause 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_COUNTvalid 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.510 12 11 80 9: drop9and80, result11. - Failure. Too few valid samples give
Error::InsufficientSamplesand 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.
| Property | Value |
|---|---|
| Microphone | ICS-43434 assumed: −26 dBFS at 94 dB SPL (1 kHz), so 24-bit full scale is 120 dB SPL |
| Sample rate | 16 kHz mono, 24-bit samples |
| Filtering | ≈10 Hz integer DC blocker only, otherwise flat |
| Sub-window | 125 ms mean square gives one level |
| Measurement window | 5 s (40 sub-windows), continuous; average is the energy average (Leq), maximum the loudest sub-window |
| Calibration | factory 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
| Field | Unit | Field | Unit |
|---|---|---|---|
bmx280.temperature | °C | zmod4510.o3, zmod4510.no2 | ppb |
bmx280.humidity | % RH | zmod4510.aqi_fast, zmod4510.aqi_epa | index |
bmx280.pressure | Pa | ags3871.co | ppm |
scd4x.co2 | ppm | radsens.radiation | µSv/h |
scd4x.temperature, scd4x.humidity | °C, % RH | radsens.pulses | pulses |
sds011.pm2_5, sds011.pm10 | µg/m³ | microphone.average, microphone.maximum | dB |
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:
| Crate | Responsibility |
|---|---|
rohi-config | ConfigSection, Override, ConfigOverrides, HasOverrides, materialize, ConfigStorage, ConfigStore, CBOR slot format, MemoryStorage |
rohi-config-derive | #[derive(ConfigSection)], #[derive(ConfigOverrides)], section IDs from struct names |
rohi-config-esp | ESP32-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:
| Flash | Result |
|---|---|
| Neither slot has a commit word (erased, or first write interrupted before the commit) | ConfigOpenState::Empty |
| Every committed slot validates | ConfigOpenState::Valid (newest generation is used) |
| One committed slot is invalid, the other validates | ConfigOpenState::Recovered (the valid slot is used) |
| Committed slot(s) exist but none validates | Err(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
| Error | Meaning |
|---|---|
SectionError::Decode(DecodeFailure) | Payload is not a valid CBOR encoding of the type |
SectionError::TrailingBytes | Valid value followed by extra bytes |
SectionError::UnsupportedSectionVersion | Stored version unknown to this firmware |
ConfigError::SectionTooLarge | Encoded section above MAX_SECTION_PAYLOAD |
ConfigError::StorageFull | Resulting document does not fit a slot (nothing was written) |
ConfigError::TooManySections | More 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 to0xFF, writes may only clear bits,MemoryError::NotErasedotherwise) 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 anembedded-storageNOR flash to aFlashRegion { offset, length }.MultiwriteNorFlashis theembedded-storagemarker for flash that can program a word more than once (esp-storage’sFlashStorageimplements 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 typedOutOfBounds,InvalidAlignment,InvalidRegion,ReadFailed,WriteFailed,EraseFailed. With a chip feature (esp32c3oresp32c6),rohi_config_esp::esp::config_storagebuilds it on theesp-storagedriver and therohi-cfgpartition. 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
| Layer | Question it answers | Owner |
|---|---|---|
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.
wifi | network | ble | |
|---|---|---|---|
| Config | ssid, 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 link | name (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 |
| Status | ssid, 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_capacity | state (disabled, advertising, connected, error), name, encrypted, authenticated, max_payload |
| Actions | scan, reconnect, disconnect | none | none |
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.
| Type | Role |
|---|---|
WifiConfig | Persistent CBOR section: ssid, optional password (open network = None), scan and connect timeouts, reconnect backoff and link refresh |
NetworkConfig | Persistent CBOR section: DHCP or static IPv4, host name, DHCP timeout |
LinkConfig | The two together, as the manager and the backend see them |
WifiState / WifiStatus | NotConfigured, Idle, Scanning, Connecting, Connected, Disconnected, Error (+ last WifiError) |
NetworkInfo | SSID, RSSI, IPv4, gateway, DNS. Never contains credentials |
WifiNetwork / WifiScanResults | Bounded scan result (heapless::Vec), strongest first |
WifiError | Stable typed errors (AuthenticationFailed, DhcpFailed, Busy, …) |
WifiShared / WifiHandle | static 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, yetwifi_settings()keeps the timing, so the scan timeout stays effective) andWifiHandle::set_network_config(NetworkConfig)publish the desired configuration. They are synchronous: no.await, no queue, neverBusy. 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()anddisconnect()are commands in a bounded queue (WIFI_COMMAND_QUEUE_DEPTH). They returnBusywhen 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 update | Behaviour |
|---|---|
None | disconnect if necessary, NotConfigured, no automatic reconnect |
new Some(config), or a changed NetworkConfig | abort the running attempt or backoff, disconnect, connect with the new config and a fresh backoff |
| same config again | nothing: attempt, backoff timer, idle state or link continue (use reconnect to force a new attempt) |
invalid WifiConfig or NetworkConfig | disconnect, 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:
| Plane | What | rohi |
|---|---|---|
config section wifi | ssid (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 wifi | state, ssid, rssi (dBm), channel, mac | rohi status wifi |
action scan | one record per network, strongest first: ssid, rssi, channel, security | rohi action wifi scan |
actions reconnect, disconnect (privileged) | drop the link and connect again / stay idle | rohi 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:
| Trait | Provided by NetworkHandle |
|---|---|
embedded_nal_async::Dns | get_host_by_name (IPv4); reverse lookup is not supported |
embedded_nal_async::TcpConnect | connect → 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.
NetError | Meaning |
|---|---|
NetworkUnavailable | Wi-Fi not configured/connecting, DHCP incomplete, link lost |
DnsFailed | Resolution failed while the network was up |
ConnectFailed | Connection refused or unreachable while the network was up |
NoSocketAvailable | All statically allocated TCP connections (or UDP sockets) are in use |
ConnectionReset | An established connection was reset or closed |
Timeout | A timeout expired |
Backend | Unexpected 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(implementsWifiBackend) and itsNetRunner(the background task of the IP stack),EspIp(implementsIpBackend): DNS, TCP and UDP on the sameembassy-netstack.
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:
| Plane | Question | Owner type | Wire operations |
|---|---|---|---|
| Config | what is the device set up to do? (desired, persistent) | ManagedConfig | ListConfigSections, GetConfigSchema, GetConfigSection, PatchConfigSection, ResetConfigSection |
| Status | what is happening now? (observed, read-only) | ManagedService | GetServicesStatus, GetServiceSchema, GetServiceStatus |
| Actions | what can I ask it to do? (explicit operations) | ManagedService::ACTIONS | ListActions, 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.
- Patch semantics. A patch is a list of
set/none/clearmutations by field id. Load the stored overrides (or an empty set) and apply all mutations to a temporary copy:setchecks kind and range, sets the value on the effective copy and captures it as an explicit override;nonesets an optional field to no value and captures that as an explicit override (a required field answersNotOptional);clearremoves 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=truealone on connectivity fails untilhostis set). - Secrets (
secret) are write-only:GetConfigSectionreports them asredacted, never their value. - Dry run validates exactly like a real patch and stores nothing; the revision does not change.
- Revision (
u32, RAM only, 0 at boot) increments on every successful write or reset;expected_revisionmakes a patch or a reset fail withCONFLICTif the section changed meanwhile. - Service actions that change the section use
ConfigUpdate::updateof the sameManagedSection: the same lock, validation, publication and revision as a patch, never a separate load and store. - Reset removes every user override and publishes the firmware policy over the defaults; compiled firmware overrides are retained.
restart_requiredtells 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 |
ready | whether 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
| Crate | Config section | Service | Actions |
|---|---|---|---|
rohi-net | wifi (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-connectivity | connectivity | connectivity (queue, counters, endpoint) | |
rohi-location | location | location | |
rohi-time / rohi-time-ntp | time (NTP servers, rohi-time-ntp) | time (unix time, source, age, failures) | |
rohi-hal | sensors (<sensor>.<measurement> values) | ||
rohi-identity | identity (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
| Crate | Role |
|---|---|
rohi-net (ble) | BleTransport, BleState, BleConnectionInfo, BleConfig, the ble section (BleSettings) and service (BleService): portable BLE API, no vendor types |
rohi-net-esp | EspWireless (shared radio runtime), EspWifi, EspIp, EspBle |
rohi-control-proto | control.proto, bounded generated types (micropb), PROTOCOL_VERSION, limits |
rohi-service | the generic model: Value, ManagedConfig, ManagedService, registries; see Service pipeline |
rohi-control | Controller, ControlBackend, Device (a backend built from registries), AuthorizationPolicy |
rohi-control-ble | Service/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.
| Plane | Request | Needs a secure link policy |
|---|---|---|
| info | GetInfo | no |
| config | ListConfigSections, GetConfigSchema, GetConfigSection | no |
| config | PatchConfigSection, ResetConfigSection | yes (Privilege::Mutate) |
| config | FactoryReset | yes (Privilege::Provision, it erases the identity) |
| device | Reboot | yes (Privilege::Mutate) |
| status | GetServicesStatus, GetServiceSchema, GetServiceStatus | no |
| actions | ListActions, GetActionSchema | no |
| actions | InvokeAction | depends 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:
| Error | Meaning |
|---|---|
INVALID_ARGUMENT | a value, field or argument is missing, unknown, of the wrong kind, out of range or fails the typed validation; nothing was changed |
NOT_FOUND | no such section, service or action |
NOT_SUPPORTED | unknown request or wrong protocol version |
BUSY, UNAVAILABLE | try again later; the backing service or storage is not available |
PERMISSION_DENIED | the link does not satisfy the authorization policy |
CONFLICT | expected_revision no longer matches the section |
REJECTED | an action ran and was refused for the stable reason in Error.reason (for example already_provisioned) |
INTERNAL | unexpected 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:
| UUID | Direction | |
|---|---|---|
| ROHI Control Service | 726f6869-0001-4d00-8000-636f6e74726f | |
| RX | 726f6869-0002-4d00-8000-636f6e74726f | central writes a chunk (write / write without response) |
| TX | 726f6869-0003-4d00-8000-636f6e74726f | peripheral 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
| Constant | Value |
|---|---|
MAX_REQUEST_LEN | 768 B (a patch of 8 full values is the worst case) |
MAX_RESPONSE_LEN | 2048 B |
| ATT payload | 20 – 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_LEN | 1 / 9 / 16 B per schema response |
MAX_SCHEMA_FIELDS / MAX_STATUS_FIELDS | 20 / 20 per response |
MAX_ACTIONS | 12 per device |
MAX_CONFIG_FIELDS | 16 fields per section |
MAX_MUTATIONS | 8 per patch |
MAX_CONFIG_SECTIONS / MAX_SERVICES | 12 / 12 |
MAX_ISSUES / MAX_ISSUE_DESCRIPTORS | 4 active / 12 described |
MAX_REASON_LEN (reason of a rejected action) | 32 B |
MAX_ACTION_ARGS / MAX_RECORDS / MAX_RECORD_FIELDS | 3 / 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.
| Item | Role |
|---|---|
Secret<T> | Zeroized on drop; Debug prints Secret(<redacted>); no Display. |
Identity | The Identity ConfigSection; holds the 32-byte secret only. public_key(), signer(). |
PublicKey, Signature | 32 / 64 raw bytes. PublicKey displays as 0x + 64 hex digits and verifies signatures. |
Signer | public_key() and sign(&[u8]); there is no way to get the private key out of it. |
IdentitySigner | Signer backed by an Identity; IdentitySigner::provisioned(None) is IdentityError::NotProvisioned. |
MockSigner | Test 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, ID0x94d63573d312fe9b(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:
identityis 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
eraseaction 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//42are wrong). It must stay stable. - The master is a Substrate secret URI (mnemonic phrase or
0xseed, optional path and///password) read from a file.rohiapplies the master’s own path/password first and then the hard junction//<number>. - Derivation uses
sp-core(ed25519), the same algorithm asPair::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:
| Number | Public key |
|---|---|
| 1 | 0xbf3a763d817cee09bf785b9cc6118f58dab5c03f3ace6d524899bcb28ac74f27 |
| 2 | 0x276b9ce3c58b9f009f4f1f2ae5c7e9a599796246165df52f2d8b857fcecb1a40 |
| 42 | 0x8743ca72162320542b15ef0e66995fac97cd295626627046c41f64f796bcf2f3 |
Control plane
The identity is the identity service of rohi-identity (IdentityService over an IdentityStore that
the firmware implements on its configuration handle):
| Request | Link privilege | Effect |
|---|---|---|
GetServiceStatus identity | read | provisioned 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.erase | mutate | removes the identity section; no-op when absent |
rohi status identity shows the state; rohi action identity provision secret=- runs the action.
- A status of
stoppedwith the informational issuenot_provisionedis the normal state of a fresh device. provisionsucceeds only when not provisioned, otherwise it is rejected with the reasonalready_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
Debugoutput 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)
- Fresh Altruist Urban:
rohi status identityreports not provisioned; sensors and BLE diagnostics work. rohi action identity provision secret=-with the secret derived from<test-master>: host and device public keys match.- Reboot:
rohi status identityreturns the same public key. Change unrelated configuration, reboot: unchanged. rohi action identity erase: state becomes not provisioned, signing returnsIdentityNotProvisioned, sensors keep running.- 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 time | Wall-clock time | |
|---|---|---|
| Type | rohi_hal::MonotonicTimestamp (from embassy-time) | rohi_time::UnixTime (Unix milliseconds, UTC) |
| Available | always, from boot | only after a valid sample; otherwise None |
| Can jump | never | yes, forward or backward, when a sample corrects it |
| Use for | deadlines, timeouts, intervals, retries, scheduling, elapsed time, ordering events in a boot | absolute 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()isNoneuntil 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.
TimeStatusreports 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
TimeSyncState | Meaning |
|---|---|
Unsynchronized | no valid sample yet; now() is None |
Synchronized | a valid sample was accepted within the stale threshold |
Stale | the 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:
| Rule | Default |
|---|---|
not_before (plausibility floor) | firmware build time (BUILD_TIMESTAMP, set by the firmware build.rs) |
not_after | floor + DEFAULT_HORIZON (40 years) |
max_uncertainty | DEFAULT_MAX_UNCERTAINTY (5 s) |
stale_after | DEFAULT_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 withrobinspin 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 observation | Protocol field | Conversion |
|---|---|---|
| BMx280 temperature, only when the humidity channel exists (BME280) | BME280.temperature | °C → 0.01 °C (sint32) |
| BME280 humidity | BME280.humidity | % → 0.01 % (clamped to 0..100 %) |
| BME280 pressure | BME280.pressure | Pa → 0.1 Pa |
| SDS011 PM2.5 / PM10 | SDS011.pm25 / pm10 | µg/m³ → 0.1 µg/m³ |
| Microphone average / maximum | ICS43434.noise_avg / noise_max | dB → 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:
| Result | Disposition |
|---|---|
202 Accepted | delivered, frame removed |
| network unavailable, DNS, connect, timeout, I/O error, malformed response | retry the same frame with backoff |
408, 429, 5xx | retry with backoff |
1xx, 3xx, 2xx other than 202 (captive portal, proxy) | retry (not an acceptance) |
401, 403 | unauthorized: frame dropped, pause for the longest backoff (no hot loop) |
400, 413, any other 4xx | permanent: 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_fullis 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 theDiagnosticscounters.
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 sender | Service state / health / ready | Issue code |
|---|---|---|
| telemetry disabled by the effective configuration | disabled / unknown / no | |
Starting | starting / unknown / no | |
Idle | running / ok / yes, degraded while no location is set | no_location |
WaitingForIdentity | running / degraded / no | no_identity |
WaitingForUtc | running / degraded / no | no_utc_time |
Backoff(error) | running / degraded / no | network_unavailable, dns_failed, connect_failed, connection_timeout, io_error, bad_response, delivery_rejected |
Unauthorized | running / error / no | unauthorized |
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
| Constant | Value |
|---|---|
MAX_SENSORS_PER_MESSAGE | 12 |
MAX_MESSAGE_LEN | 400 B (worst case of the schema: 399 B) |
MAX_ENVELOPE_LEN | 544 B |
NONCE_LEN | 16 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.rsholds interoperability vectors (message, preimage, Ed25519 signature, envelope) generated with therobinsedgeCLI (a6c1d9c, whose protocol submodule is exactly the pinned revision). Note: the CLI help says pressure is in hPa, but it takes pascal and encodes101325asdeci_pascal = 1013250, as the schema documents. -
tests/robins_e2e.rsis an opt-in end-to-end test: a snapshot goes through the service andHttpTransportinto a realedge gateway, which must answer202: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
| Field | Type | Range | Required |
|---|---|---|---|
latitude | f32 on the control plane, degrees (WGS84) | -90 … 90 | yes |
longitude | f32 on the control plane, degrees (WGS84) | -180 … 180 | yes |
height_m | f32 on the control plane, metres above mean sea level | -1000 … 10000 | no (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:
| Source | Published by | When it wins |
|---|---|---|
gps | a GNSS runtime with publish_fix(Some(..)) | while it publishes a fix |
configured | the configuration owner with publish_configured | when 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_locationinrohi 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.
- Model the observation (portable,
rohi-hal). Add the sensor toSensorKindand give it a typed update inMeasurementUpdate(units fromunits.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 insensors/mod.rsenforces it. - Write the driver in
crates/hal/src/sensors/<name>.rsand implementSensorDriver:init(probe and configure; called again to recover),sample(wait for and read the next observation,Ok(None)= no data yet),poll_interval_ms, optionallyinitial_delay_ms, plusKINDandWARMS_UP. Map the bus error intoDriverError(code, andis_absentfor “does not answer”, which makes the stateNotDetectedinstead ofError). - Test on the host. In the driver module, drive the driver with
MockI2c/ a fake UART from the crate-internaltestutil(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. - 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. - Spawn a task in
firmwares/altruist-urban/src/tasks/sensors.rs: build the driver, wrap it inRunner::new(driver, reporter(SensorKind::X), Delay, RunnerConfig::DEFAULT)andrunner.run().await; start it inmain.rswith the other sensor tasks. I²C sensors share one bus throughi2c_handle. - 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 inrohi-hal/src/sensors_service.rs; the table in Sensors lists them. The protocol and therohitool need no change (see Add a service). - Add a hardware example in
examples/src/bin/if useful, and record the validation in Hardware testing. - 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.
-
Define the typed section in the feature crate with bounded fields (
heapless, fixed arrays), implementDefaultas a portable baseline, and derive its persisted overrides with#[derive(ConfigOverrides)]. The field types needCloneand theminicbortraits (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
Debugon the field types; implement it by hand with redaction (seeWifiPassword). The generated overrides print their values only through theDebugof the field types. - A field that may have no value is
Option<T>. Its override has three states:Value(Some(v)), an explicitValue(None)andDefault(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 anOverride<T>per field), its CBOR map codec,ConfigSection,ConfigOverrides<UplinkConfig>andHasOverrides for UplinkConfig. The section ID is the FNV-1a hash of the config type name (UplinkConfig, notUplinkOverrides): 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.
- Secrets: do not derive
-
Pin the encoding with a golden test (
assert_eq!on the exact bytes of an overrides value,IDandVERSION); seethe_cbor_encoding_is_stableforWifiOverrides. Also test the empty mapa0, the round trip and the maximum-size value againstMAX_SECTION_PAYLOAD. -
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 ondefault + firmware overrides + user overridesfor every patch, so make “unconfigured” or “disabled” a valid state (seeLocationConfig, valid without a position, andConnectivityConfig, valid while disabled). -
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.awaitin between (Durable mutation). Describe the section once as a firmwareBinding(bindings.rs: the firmware overrides and the consumer to publish to) and list it infirmware_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. -
Expose it to clients (optional, but this is how users set it): implement
ManagedConfigfor the typed section in the same crate (NAME,FIELDS,read_field,field_is_set,set_field,set_nonefor the optional fields,validate; see Service pipeline), implementSectionStore<UplinkConfig>(withfirmware_overrides()) over the configuration handle in the firmware (Stored<YourBinding>, generated into the config registry byfirmware_configs!). The protocol, the client and therohiCLI 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. -
Versioning: changing the stored layout later means bumping the
versionof the#[config(overrides = .., version = ..)]attribute and, if old data must stay readable, a migration throughdecode_versioned(rohi_config::decode_exactdecodes older layouts strictly). -
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):
- Add
esp32xx.rsnext toesp32c3.rs/esp32c6.rsinboard/altruist_urban/with the pin map and the peripheral types (Hardware), and select it with a Cargo feature forwarded by the firmware and byrohi-hal-esp,rohi-config-esp,rohi-net-espand the other platform crates. - Add the matching
rohi-net-esp/esp-radiofeature, a partition table (firmwares/altruist-urban/partitions-<chip>.csv) mirrored byrohi_config_esp::layout(a host test keeps them in sync), and the Cargo alias plus runner entry in.cargo/config.toml(check-*,build-*). - 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):
- Create
board/<name>/inrohi-hal-espexposing a chip independent API:Hardware::split(peripherals)and<Board>::new(hardware)returning configured buses (I²C, UART, I²S), ready for the sensor drivers. - Create
firmwares/<name>/composing the portable crates. Put its static resource sizes in its ownsrc/resources.rswith the documentation of Memory model; the product owns its budget. - 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 forWifiStatusConnected(seeWifiHandle::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.
disabledis not an error (turned off, or never set);startingisunknown/not ready;running+degradedmeans it works with reduced capability. Do not invent one status enum. - Issues are active problems with a stable numeric
code(named once inISSUES,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 aref32. ESSENTIAL = falselimits 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.rsbuilds the fake device from the real sections and services; add yours there and testrohi 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, persistently | config section | Add a ConfigSection, step 6 |
| see what a module is doing now | service status | Add a service |
| make a module do something (scan, reconnect, provision) | action | below, and Add a service |
| something that fits none of these | a new protocol operation | A 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:
- Describe it in
ManagedService::ACTIONSwithActionDescriptor::new(id, "name")(a stable numeric id, never reused), typedargs(all required, at most 3) andresults(the fields of each returned record, at most 4 fields, 6 records). - 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. - Implement it in
invoke(action, args, out), whereactionis the numeric id. The registry has already checked that the arguments exist and match the declared kinds. Write each result record without.next_record()and the typed writers (out.text(id, ..),out.i64(id, ..)); a field that does not match its descriptor is refused. ReturnActionError::Rejected("code")for a refusal a client may react to; the code is stable and machine readable (already_provisioned). - 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.
- Tests: the action against the registry (
ServiceRegistry::invoke: arguments checked, records, refusals), and through the dispatcher if the privilege matters (rohi-controltests show the policy gate). - 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):
- Schema (
crates/control-proto/proto/rohi/control/v1/control.proto): add the request and response messages and aoneofentry with a new field number. Add bounds for every repeated, string and bytes field inbuild.rsso 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 bumpPROTOCOL_VERSION; the protocol is under development. - Dispatch (
rohi-control/src/controller.rs): handle the call inexecute, decide itsPrivilegeindispatch, and map types inmapping.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. - 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 inrohi-control-bleandtools/rohitests. - Client: add the generic method to
rohi::Deviceand, if useful, a CLI subcommand. - 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.
- 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-C3 | ESP32-C6 | |
|---|---|---|
| Feature | esp32c3 | esp32c6 |
| Rust target | riscv32imc-unknown-none-elf | riscv32imac-unknown-none-elf |
| Flash (Altruist Urban) | 4 MB, factory partition 0x3E0000 | 8 MB, factory partition 0x7E0000 |
| Partition table | partitions-esp32c3.csv | partitions-esp32c6.csv |
| Pin map (Altruist Urban) | verified against the pinout | from the schematic, not yet verified on hardware (Boards) |
| Hardware validation | partial, see Hardware testing | compile-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
esp32c3andesp32c6; each forwards toesp-hal,esp-println,esp-backtrace,esp-bootloader-esp-idf,esp-rtosand therohi-*-espcrates. - 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-esprejects both features together with acompile_error!. - The workspace dependencies never select a chip globally:
esp-haland friends are pinned in the rootCargo.toml, chips come from the firmware features. This is why--all-featurescannot 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.
| Function | ESP32-C3 | ESP32-C6 |
|---|---|---|
| I²C0 SDA / SCL | GPIO3 / GPIO0 | GPIO3 / GPIO2 |
| SDS011 UART1 TX / RX | GPIO10 / GPIO1 | GPIO5 / GPIO4 |
| I²S BCLK / WS / DIN | GPIO7 / GPIO6 / GPIO8 | GPIO10 / 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!andEspWireless::neware called once, beforeesp_rtos::startand before any radio use. The peripherals and the heap region are moved in, so a second call cannot compile.wifi()andble()each hand out their driver once (WirelessError::AlreadyTaken/WifiError::DriverErrorotherwise) and need theesp-rtosscheduler 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 theheap-statsfeature, the minimum free bytes, which must be checked with both radios busy. - Coexistence.
esp-radioruns with thecoexfeature 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
wificonfig section and service ofrohi-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)
| Constant | Value | Consumed by | When exhausted |
|---|---|---|---|
WIRELESS_HEAP_SIZE | 128 KiB | Wi-Fi and BLE controllers (one shared fixed heap) | radio driver allocation fails; the driver reports an error, the sensors keep running |
TCP_CONNECTIONS | 2 | NetworkHandle::connect | NetError::NoSocketAvailable immediately (no waiting) |
TCP_RX_BUFFER / TCP_TX_BUFFER | 1024 B / 1024 B per connection | TCP connections | TCP flow control; nothing is lost |
UDP_SOCKETS | 1 | NetworkHandle::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_BUFFER | 128 B per direction, UDP_PACKETS = 2 datagrams | each UDP socket | an oversized datagram is dropped by the stack |
NETWORK_SOCKETS | TCP_CONNECTIONS + UDP_SOCKETS + 2 (DHCP, DNS) = 5 | embassy-net socket table | cannot happen: checked at compile time |
BLE_RX_DEPTH / BLE_TX_DEPTH | 4 / 4 chunks of ≤ 244 B | BLE RX / TX queues | RX: the ATT response is delayed (backpressure); TX: send waits |
BLE_CONNECTIONS | 1 | BLE host stack | further centrals cannot connect |
BLE_L2CAP_CHANNELS | 2 | BLE host stack | connection setup fails, advertising restarts |
BLE_ATTRIBUTES | 24 | GATT attribute table | panics once when the BLE task starts |
BLE_CONTROLLER_SLOTS | 4 | HCI command slots | commands wait for a slot |
CONNECTIVITY_OUTBOX_DEPTH | 4 frames of ≤ 544 B | telemetry 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
| Constant | Value |
|---|---|
MAX_SSID_LEN | 32 |
MAX_HOST_LEN (telemetry host, control protocol) | 64 |
MAX_WIFI_PASSWORD_LEN / MIN_WIFI_PASSWORD_LEN | 64 / 8 (open networks have no password) |
MAX_WIFI_SCAN_RESULTS | 16 (strongest first; the backend converts at most 48 raw entries per scan) |
WIFI_COMMAND_QUEUE_DEPTH | 4 (scan, reconnect, disconnect only; Busy when full) |
STATUS_RECEIVERS | 3 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
| Constant | Value |
|---|---|
MAX_BLE_PAYLOAD | 244 B (MTU 247 − 3) |
DEFAULT_BLE_PAYLOAD | 20 B (MTU 23) |
| Control framing | 6 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
| Item | Size |
|---|---|
| Configuration flash region | 16 KiB (rohi-cfg) |
| Slot size | 8 KiB (commit word included); at most MAX_SLOT_SIZE = 64 KiB |
| Envelope / payload overhead | about 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 RAM | a few words of state |
get stack workspace | 1 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
| Item | Value |
|---|---|
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 buffers | 256 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
| Target | Chip | Cargo feature | Rust target | Commands |
|---|---|---|---|---|
| Host | developer machine | – | the host triple | cargo test, cargo clippy, cargo doc |
| ESP32-C3 | ESP32-C3 | esp32c3 | riscv32imc-unknown-none-elf | cargo check-esp32c3, cargo build-esp32c3 |
| ESP32-C6 | ESP32-C6 | esp32c6 | riscv32imac-unknown-none-elf | cargo 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
| Crate | Feature | Meaning |
|---|---|---|
altruist-urban | esp32c3 / esp32c6 | chip selection, forwarded to every *-esp crate and esp-* dependency |
rohi-hal-esp, rohi-config-esp, rohi-net-esp | esp32c3 / esp32c6 | chip selection; no feature = empty crate |
rohi-net-esp | heap-stats | track the minimum free wireless heap (small runtime overhead); enabled by the firmware |
rohi-net | testing | in-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.
| Domain | Type | Where | Variants / meaning |
|---|---|---|---|
| Wi-Fi | WifiError | rohi-net | NotConfigured, Busy (command queue full / scan in flight), Timeout, ScanFailed, NetworkNotFound, AuthenticationFailed, AssociationFailed, DhcpFailed, Disconnected, InvalidConfig, DriverError |
| IP data plane | NetError | rohi-net | NetworkUnavailable, DnsFailed, ConnectFailed, NoSocketAvailable, ConnectionReset, Timeout, Backend; implements embedded_io_async::Error |
| BLE | BleError | rohi-net | Disconnected (returned once per connection end), BufferTooSmall, Busy, DriverError |
| Wireless runtime | WirelessError | rohi-net-esp | AlreadyTaken, BleInit |
| Configuration store | ConfigError<E>, SectionError | rohi-config | see Configuration store › Errors |
| Identity | IdentityError | rohi-identity | NotProvisioned and key/signature errors, see Device identity |
| Configuration service | ServiceError | firmware config | Unavailable, StorageHealthy, Store, Section, Encode |
| Measurements | MeasurementServiceError | rohi-hal | Busy (only try_snapshot) |
| Sensors | SensorErrorCode, DriverError | rohi-hal | domain error codes reported in SensorEvent::Error |
| Control services | ServiceError | rohi-control | InvalidArgument, NotFound, NotSupported, Busy, Unavailable, Internal, IdentityNotProvisioned, IdentityAlreadyProvisioned |
| Control protocol | ErrorCode | rohi-control-proto | INVALID_ARGUMENT, NOT_FOUND, NOT_SUPPORTED, BUSY, UNAVAILABLE, PERMISSION_DENIED, INTERNAL, IDENTITY_NOT_PROVISIONED, IDENTITY_ALREADY_PROVISIONED |
| Framing | FrameError | rohi-control-ble | bad 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
| Source | Control ServiceError | Protocol ErrorCode |
|---|---|---|
WifiError::NotConfigured | NotFound | NOT_FOUND |
WifiError::Busy | Busy | BUSY |
WifiError::InvalidConfig | InvalidArgument | INVALID_ARGUMENT |
WifiError::Timeout, ScanFailed | Unavailable | UNAVAILABLE |
other WifiError | Internal | INTERNAL |
| configuration store unavailable | Unavailable | UNAVAILABLE |
| other configuration failures | Internal | INTERNAL |
| 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:
| Topic | Section |
|---|---|
| Field numbers, explicit CBOR representation, strict decode, secrets | Section payload |
| Section versioning and migration | Section versioning |
| Section ID derivation and rename implications | Section ID ABI |
| Container, slots, CRC and commit word | Container format |
| Unknown-section preservation | Unknown sections |
| Golden tests | Golden tests |
Section payload
Codec. Section payloads are CBOR encoded with
minicbor. Types deriveEncode,DecodeandCborLen. 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 againstMAX_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
ConfigSectionmust declare its CBOR representation with#[cbor(array)]or#[cbor(map)]instead of relying on theminicborderive default, and pin the encoded bytes with a golden test (seeIdentityinrohi-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)]onWifiConfig): a CBOR map keyed by the stable field ids, where an absent key isOverride::Default(the field follows the firmware default) and a present key is an explicitOverride::Value. Nullable fields areOverride<Option<T>>:nullis the explicit value none, an absent key is the default. A section without overrides is the empty mapa0(the section is removed from the store instead). The effective configuration isC::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 areDefault(absent key),Value(None)(null) andValue(Some(v))(the value).Strict decode.
getrequires the decoder to consume the whole payload. A valid value followed by extra bytes fails withSectionError::TrailingBytes, never with an unrelated error.Secrets. Do not derive
Debugfor 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.
| Attribute | Meaning |
|---|---|
version = N | Required. 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 name | SectionId |
|---|---|
Identity | 0x94d63573d312fe9b |
WifiConfig | 0x335246ad20248b26 |
NetworkConfig | 0x48f89649eb74bec7 |
BleSettings | 0xcba3fd7b3d5ad5ef |
ConnectivityConfig | 0x9c57015a40662d0c |
LocationConfig | 0xce098b4a3e381f40 |
NtpServersConfig | 0x2feb7d230f6ed989 |
Renaming a persisted section type is a storage ABI change.
NetworkConfigrenamed toWirelessConfiggets a different ID and the stored data is no longer found. The same holds for the section type behind an overrides struct.
NetworkConfigholds IPv4 addressing and DHCP settings (version 1, overrides); Wi-Fi credentials and timing live inWifiConfig.
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-configtests pin the container (envelope, payload, record bytes, CRC) and the section ID hash values listed above.- Every production section pins its encoded bytes;
WifiOverridesinrohi-net(a map of overrides) andIdentityinrohi-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
| Term | Meaning |
|---|---|
| ABI (persistent) | The stored byte format other firmware versions rely on: container, section IDs, CBOR payloads. See Persistent ABI |
| Backend | A platform implementation of a portable trait: WifiBackend, IpBackend, BleTransport, ConfigStorage |
| BleTransport | Portable BLE peripheral API: one connected central as a bounded byte pipe |
| Action | An 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 section | A 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 |
| Command | A transient, bounded request to an owner task (scan, reconnect). Contrast desired state |
| ConfigHandle / ConfigStore / ConfigSection | Handle 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 state | The latest value a runtime should converge to (for example the desired WifiConfig); published, not queued. See Runtime ownership |
| Effective configuration | What 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 |
| EspWireless | The single owner of the ESP radio peripherals and the wireless heap; creates the Wi-Fi and BLE drivers |
| Golden test | A test pinning exact encoded bytes or IDs so accidental ABI changes fail |
| Device health | The overall ok / degraded / error of the device, decided by the firmware from its services, never by a client |
| Handle | A small copyable value giving other tasks bounded access to a service owned by one task |
| Identity | The 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 plane | NetworkHandle: DNS and TCP through embedded-nal-async / embedded-io-async, and bounded UDP sockets |
| Issue | A currently active problem of a service with a stable machine-readable code, a severity and an age; history is not an issue |
| Location | Where the device is (WGS84): set by the user now, GNSS later; sent in telemetry. See Location |
| MeasurementStore / MeasurementHandle / snapshot | Owner-side store of typed observations / handle for on-demand requests / immutable MeasurementsSnapshot generated at request time |
| Owner | The one task (or runtime object) that holds the mutable state of a service |
| Override | An 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 |
| Product | A firmware (Altruist Urban) that composes the SDK and owns its resource budget |
| Reconciliation | The Wi-Fi manager continuously moving the actual radio state towards the latest desired configuration |
| Section ID | FNV-1a 64-bit hash of the Rust struct name identifying a ConfigSection (for an overrides struct: the name of the section type) |
| SensorDriver / Runner | Driver 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 revision | A 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 anchor | The accepted absolute time together with the monotonic instant it was accepted at; wall-clock time is anchor + monotonic elapsed. See Time service |
| TimeService / TimeSource | The single source of optional wall-clock time / an abstract producer of absolute-time samples (NTP, future RTC) |
| State / health / ready | The three independent parts of a service status: lifecycle, operational quality, and whether it can do its job now |
| Wall-clock time | UnixTime: absolute time, available only after a valid sample and allowed to step; never used for durations (those use monotonic time) |
| Wireless heap | The 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
| Level | Command | What it proves | Hardware needed |
|---|---|---|---|
| Host unit tests | cargo test | codecs, state machines, bounds, error mapping, golden encodings | no |
| Host service / integration | cargo test | owner + handle + mock backend: reconnect logic, reconciliation, dispatcher, framing, rohi client end to end against the real dispatcher, cancellation | no |
| ESP compile checks | cargo check-esp32c3, cargo check-esp32c6, cargo build-esp32c3/6, ESP clippy | the platform crates and firmware type-check and link for both chips | no |
| Hardware functional validation | flash + examples/firmware | radios, flash, buses, pins, heap high-water mark | yes (Hardware testing) |
| Long-running stability | 24–72 h soak | leaks, deadlocks, recovery under churn | yes |
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 (
MemoryStoragefault injection: every torn write and erase); - the Wi-Fi manager and reconciliation (mock
WifiBackendwith 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 overFakeIp, no Internet); - the control protocol, dispatcher, framing and the
rohiclient (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
| Seam | Host implementation |
|---|---|
ConfigStorage | rohi_config::MemoryStorage |
WifiBackend | mock in rohi-net/src/wifi/manager/tests.rs |
IpBackend | rohi_net::testing::FakeIp (feature testing) |
BleTransport | scripted fake in rohi-control-ble tests; in-memory transport in tools/rohi |
ControlBackend / registries | Device built from fake services in rohi-control and rohi-control-ble tests; the real sections and services in tools/rohi tests |
| sensor buses and delays | MockI2c, 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:
| Item | Result |
|---|---|
| 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 before | ESP32-C3 after | ESP32-C6 before | ESP32-C6 after | |
|---|---|---|---|---|
.text (flash) | 970 092 | 1 149 952 | 1 012 322 | 1 335 452 |
.data | 10 024 | 15 640 | 12 876 | 18 396 |
.bss | 106 984 | 181 248 | 111 288 | 185 056 |
wireless heap (in .bss) | 73 728 | 131 072 | 73 728 | 131 072 |
.bss without heap | 33 256 | 50 176 | 37 560 | 53 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):
| Item | Size |
|---|---|
.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 |
| Heap | fixed radio heap only (see Memory model); application code does not allocate |
| Event queue | 16 events |
| Noise buffers | 256 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):
| Type | Before | After | Budget |
|---|---|---|---|
Request | 760 | 736 | 768 |
Response | 2 616 | 1 824 | 1 900 |
FieldDescriptor | 352 | 48 | 56 |
FieldValue | 88 | 56 | 64 |
GetServicesStatusResponse | 792 | 792 (12 services; 536 for 8) | 832 |
GetServiceStatusResponse | 1 272 (8 values) | 1 192 (20 values) | 1 280 |
GetConfigSectionResponse | n/a | 1 504 | 1 536 |
PatchConfigSectionRequest | n/a | 728 | 768 |
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):
- unconfigured device (no Wi-Fi, no identity)
- Wi-Fi configured
- identity provisioned
- Wi-Fi and identity both configured:
rohi status(the regression scenario) androhi status <service>for every service rohi config <section>androhi config <section> FIELD=VALUEof every section- Wi-Fi scan and
wifi reconnectaction invocation - identity
provisionanderaseaction invocation rohi factory-reset- a reboot request
Record per target:
| Field | ESP32-C3 | ESP32-C6 |
|---|---|---|
| Hardware (board, chip revision) | ||
| Firmware build (commit, profile) | ||
| Stack size (bytes) | ||
Static control_task future (bytes, nm -S) |
| Scenario | ESP32-C3 max usage / headroom | ESP32-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
| Place | Content |
|---|---|
README.md | project 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 comments | local 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:
| Concept | Page |
|---|---|
| Resource limits and constants | Resource limits |
| Persistent ABI rules | Persistent ABI |
| Targets, features and commands | Targets and features |
| Control protocol and its limits | Control plane |
| Cancellation rules | Async and cancellation |
| Measurements and sizes | Resource 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 blockrust,ignore; usetextorrust,ignorefor 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.