Module Documentation Index » Configuration Module

Configuration Module

src/config is the persistent configuration store. It is what makes "set this value, reboot, see it again" actually work on a PacketRF device — across power loss, across firmware updates, and across multiple FreeRTOS tasks reading and writing concurrently.

It is designed for embedded use first, which means: robust under power loss, small in RAM, safe under concurrent access. Anything fancier than that would be the wrong shape for this hardware.

The split that matters

The module separates schema enforcement from persistence. ConfigStore owns schema, defaults, normalization, and commit behavior. The backends own how bytes get to and from durable storage. That separation is what lets the same high-level API run on host tests (UnixFileConfigBackend) and on real hardware (PicoLittleFsConfigBackend) without anybody noticing.

The module also intentionally does not define the remote control interface. Reading and writing configuration over the management plane is implemented in src/control, which uses ConfigSectionView as one of its consumers. Storage stays transport-agnostic, transport stays storage-agnostic, and neither has to know what the other one is doing.

Architecture

There are four layers, from the bottom up:

Value model. ConfigValue and ConfigValueType represent strongly typed values that can be serialized. Supported categories are boolean, signed integer, unsigned integer, floating point, string, and byte array.

Schema layer. SectionSchema carries a section name and a list of key specifications. Each key has a name, a default value, and an optional validator callback.

Store and view. ConfigStore and ConfigSectionView are the runtime read/write API. A section is loaded lazily on first access. Defaults are applied when a key is missing or invalid. Writes stay in memory until an explicit commit.

Backends. IConfigBackend is the abstraction. UnixFileConfigBackend is for host tests; PicoLittleFsConfigBackend is for real RP2350 firmware. The store does not care which one it is talking to.

Persistence model

One file per configuration section. Each file is a single CBOR map. Naming convention: <section>.cbor, for example npr1.cbor.

This keeps section boundaries explicit, and it makes partial updates practical — one component can commit its own section without touching anyone else's data.

Why LittleFS on Pico

The Pico backend uses LittleFS over the internal flash. LittleFS gives us the two properties that matter most for runtime-writable settings:

Power-loss safety. Metadata updates are journaled, so an interrupted write does not silently corrupt the whole filesystem state. That is the difference between "lose the most recent change" and "lose every setting you have ever made on this device".

Wear leveling. Erase and write activity is distributed across blocks, which prevents concentrated wear in one fixed location.

The backend writes with temporary-file-and-rename semantics (write_file_atomic), which gives section updates an atomic feel at file level.

Flash placement

The LittleFS partition lives at the end of on-chip flash. Its size is controlled at build time by CONFIG_LFS_PARTITION_SIZE_BYTES, and the offset is computed from flash size and partition size — so placement stays deterministic and is not affected by the firmware binary growing.

That keeps the data area stable across normal firmware updates, which rewrite only the application image region. A flashing workflow that does a full chip erase will of course wipe settings too; that is expected behavior for a full erase.

CBOR encoding

Section files are CBOR maps. The normative reference is RFC 8949. PacketRF uses a deliberately small CBOR implementation in src/cbor/cbor.cpp that supports only the subset needed for firmware config values. It is not a general-purpose canonical CBOR toolchain, and it is not trying to be — bounded scope is the whole point on a constrained target.

Defaults, unknown keys, invalid data

Schema is enforced both on load and on set:

  • a missing file means the section starts from defaults,
  • unknown keys in the stored data are ignored in memory and removed on the next successful commit,
  • a stored key with the wrong type falls back to the schema default,
  • a stored value that fails its validator falls back to the default,
  • a validator that normalizes a value persists the normalized form on the next commit.

The result is forward and backward tolerance for section evolution without surprising runtime behavior. A reader always sees effective values that already satisfy the schema's constraints.

Validators and normalization

Validators are attached per key in the schema. A validator can either reject a value or normalize it in place. Common shapes that show up across the firmware:

  • mask validators for bit-bounded integer fields,
  • range validators for bounded numeric fields,
  • clamp validators for minimum/maximum policy,
  • string-length validators for fixed protocol limits.

Validation runs both on load (so persisted data cannot leak through unsanitized) and on set (so a bad write is rejected at the source rather than at the next reader).

Threading model

The store has internal locking. Each section has its own lock for value access; the backend has a backend-level lock for I/O. Concurrent tasks can read and write through independent section views without races on the internal data structures. Application-level discipline is still required when several tasks are updating the same semantic setting; the module protects its own invariants, not the application's.

Defining a typed section

The recommended pattern is a key table with typed descriptors and validators, plus a typed section alias built on top:

#include "config/key_descriptor.hpp"
#include "config/typed_section.hpp"
#include "config/validators.hpp"

namespace demo::cfg {

namespace validators {
inline bool tx_power(prf::config::ConfigValue* value) {
    return prf::config::validator_u64_mask<0x7Fu>(value);
}
}

#define DEMO_KEY_TABLE(X) \
    X(Enabled,     "enabled",       bool,        true,                                 nullptr) \
    X(Name,        "name",          std::string, std::string("node-a"),                nullptr) \
    X(TxPowerRaw,  "tx_power_raw",  uint64_t,    static_cast<uint64_t>(5u),            &validators::tx_power)

DEMO_KEY_TABLE(CONFIG_DEFINE_KEY_STRUCT_VALIDATED)

struct DemoSectionDefinition final {
    static prf::config::SectionSchema schema(const std::string& name) {
        std::vector<prf::config::SectionKeySpec> keys{};
        DEMO_KEY_TABLE(CONFIG_APPEND_KEY_SPEC_VALIDATED)
        return prf::config::SectionSchema(name, std::move(keys));
    }
};

using DemoSection = prf::config::TypedConfigSection<DemoSectionDefinition>;

#undef DEMO_KEY_TABLE

}

Usage at runtime

Open the section once, then read and write through typed keys:

auto section = demo::cfg::DemoSection::open(store, "demo1");
if (!section.valid()) {
    return false;
}

const bool enabled = section.get<demo::cfg::Enabled>();
const uint64_t tx_power = section.get<demo::cfg::TxPowerRaw>();

if (enabled) {
    section.set<demo::cfg::Name>(std::string("field-node"));
    section.set<demo::cfg::TxPowerRaw>(0xFFu);  // normalized by validator
    if (!section.commit()) {
        return false;
    }
}

Commit strategy

Do not commit after every set in normal operation. Commits have a real flash and filesystem cost; batching related updates into one commit reduces write amplification and shortens worst-case latency. The practical pattern is: apply several set operations in memory, commit once after the logical transaction is complete, and on commit failure keep the old persisted state and retry under an explicit policy.

Tests

The module is covered by host tests with UnixFileConfigBackend, split by concern:

The split keeps failures easy to localize and keeps the config module independent from feature-specific code in its own test suite.

Design summary

One file per section keeps blast radius small. Schema-first API gives deterministic defaults and strict typing. Validator callbacks keep policy near the key definition while reusing common primitives. LittleFS on Pico provides power-loss safety and wear leveling. A small CBOR subset matches the embedded reality.

Together those choices make a configuration system that is both practical on constrained hardware and pleasant to use from module code.