dynamic-config-embedded 0.6.1

Hot-reloadable configuration for no_std targets: no filesystem, no allocator, no runtime.
Documentation

dynamic-config-embedded

Hot-reloadable configuration for no_std targets: no filesystem, no allocator, no runtime.

[dependencies]
dynamic-config-embedded = { version = "0.6.1", default-features = false, features = ["json"] }
use dynamic_config_embedded::{ConfigCell, Format, Validate};
use serde::Deserialize;

#[derive(Debug, Clone, Deserialize)]
struct Settings {
    interval_ms: u32,
    verbose: bool,
}

impl Validate for Settings {}

static SETTINGS: ConfigCell<Settings> = ConfigCell::new();

// Compiled-in defaults, so the device is configured before anything arrives.
SETTINGS.store(Settings { interval_ms: 1000, verbose: false });

// A document from wherever this device gets one: a serial link, an MQTT
// message, a page of flash.
SETTINGS.apply(br#"{"interval_ms": 250, "verbose": true}"#, Format::Json)?;

Why this is a separate crate

dynamic-config reads files, searches directories and merges layers with figment. A microcontroller has no files, no directories and no allocator, and figment is std — so this is not that crate with a feature switched off. It is the same shape, built from what a device actually has.

What it keeps

  • A snapshot in a static, replaced whole. A reader never sees a half-applied configuration.
  • A bad document cannot take the device down. Parsing and validation happen before anything is installed; a failure leaves the previous configuration serving.
  • changes(), a Future that resolves on the next configuration — the same generation-counter-and-wakers design as the std crate, which is why it drives on Embassy, RTIC, or a hand-written executor.
  • Validation, through the same Validate shape.

What it cannot keep, and why

Files, directory search, profiles there is no filesystem
Environment variables there is no environment
Layered merging figment is std, and merging needs a value tree that allocates
Arc snapshots no allocator; readers clone the value out
Provenance (source_of) there is one source, so the question does not arise

A device gets one document at a time and replaces the whole configuration. That is not a reduced version of layering — it is what configuring a device looks like.

No allocator, and no lock a reader can block on

Storage is a critical-section around a plain slot: a handful of instructions with interrupts masked, which is the primitive every embedded HAL provides and the only one this crate needs. The section is held for a clone of the value and nothing else.

That makes T: Clone the price of admission. For a configuration struct of scalars — which is what a device's configuration is — the clone is a memcpy.

Awaiting a change, with no allocator

changes() needs somewhere to keep a waker, and a Vec<Waker> needs an allocator. So there are four fixed slots — ConfigCell<Settings, 8> for eight — chosen for the shape of the problem: a device has a handful of tasks that care about configuration, not thousands. A fifth waiter replaces the occupant of a slot rather than being dropped: a task that is never woken is a hang, and one woken early merely polls again.

Past the budget the device stops idling. The evicted task wakes, polls, sees no change and re-registers, displacing somebody else — so five tasks on a four-slot cell trade wake-ups for as long as both are waiting, with no configuration change between them. No wake-up is lost; the cost is entirely power, and it is measured rather than feared (one_task_past_the_budget_costs_the_device_its_idle_loop).

waiter_evictions() is how a firmware finds out, because the symptom otherwise is a battery that empties early:

// On a bench, after the firmware has run everything it does.
assert_eq!(SETTINGS.waiter_evictions(), 0, "raise WAITERS");

There is no queue here and no plan for one. An intrusive list would lift the cap without an allocator, at the price of unsafe in a crate that forbids it, self-referential futures that must unlink on drop, and — measured — more RAM per waiting task than a slot costs: a list node is a Waker plus its links, where a slot is the Waker alone. A device knows its tasks at compile time, so the compile-time budget is not a limitation to be worked around; it is the right model, and the number belongs to the firmware.

The budget is not free either, and the sizes are small enough to state: on thumbv7em-none-eabihf a ConfigCell<Settings, 4> is 56 bytes of RAM and a ConfigCell<Settings, 8> is 88 — eight bytes per slot, for identical code size. That is why the default is not simply larger.

Interrupts

Every read and write of the shared state happens inside a critical section, so storing a configuration, reading one and registering a waker are all sound from an interrupt handler. Two rules make that true, and the tests hold them: no borrow outlives its critical section, and no waker is ever woken while one is held — a wake belongs to the executor and may store a configuration or poll the task it just woke before it returns.

The section is a scan of WAITERS slots and at most one Waker clone. Nothing on that path parses, allocates or waits.

Features

Feature Default Effect
json Format::Json, via serde-json-core, which allocates nothing
async changes()
std the critical-section implementation a device gets from its HAL — for tests and host simulators

A field the firmware does not know is ignored

serde skips it, which on a device is the difference between a rolling upgrade and a fleet that stops taking configuration. A firmware that wants the opposite says so with #[serde(deny_unknown_fields)].

Testing

Unit and integration tests run on a host with the std feature, which supplies the critical-section implementation a device gets from its HAL — the same code otherwise. CI also builds for thumbv7em-none-eabihf, because "it is no_std" is a claim a host build cannot check.

cargo test -p dynamic-config-embedded --features std,async
cargo check -p dynamic-config-embedded --target thumbv7em-none-eabihf \
    --no-default-features --features json,async

The features matter: without std the integration tests compile to zero tests, silently, and the run still reports green.

MSRV

1.83 — higher than the core crate's 1.71 because of language features, not dependencies: core::error::Error in no_std needs 1.81 and inline const blocks need 1.79. 1.83 is the floor CI verifies. Still far below anything a device toolchain would struggle with.

License

MIT