Single-header configuration manager for embedded nodes that receive their configuration over a lossy transport (CAN bus, in the intended use case).
A configuration is a struct of parameters (setpoints, timings, thresholds) that needs to be delivered from a master node to one or more remote boards. The transport is unreliable: frames can be dropped, corrupted in transit, or arrive out of order relative to other traffic on the bus. A node must never act on a partially-received or corrupted configuration.
Each node holds two copies of ConfigClass:
buffer- the staging area. Incoming data is written here as it arrives.commited- the active configuration actually used by the rest of the firmware.
buffer is never read by application logic, and commited is never
written to directly by the transport layer. The only transition from one
to the other is an explicit commit, performed by the node itself once it
has independently verified that buffer is complete and correct.
Nothing in this design tracks individual chunk delivery. There is no per-chunk acknowledgment and no retry-by-offset. Instead:
- The master continuously cycles through the full
ConfigClassstruct, broadcasting fixed-size chunks at increasing offsets, wrapping back to 0 after reaching the end (ConfigDispatcher::tick, backed byCANConfigChannel::put). - Every node writes whatever chunk it receives into its own
bufferat the given offset (CANConfigChannel::load), regardless of whether previous chunks were received. - Each node periodically reports back the CRC32 of its
bufferand itscommitedconfig. - The master compares each reported CRC against its own target (the CRC
of its
commitedconfig). A node whosebufferCRC matches the target has, with high probability, received a complete and correct copy - a partial or corruptedbufferwill not produce a matching CRC. - Once a node's
bufferCRC matches the target, the master instructs that node to commit. The node copiesbufferintocommitedand begins reporting the newcommitedCRC.
This means individual dropped frames are not treated as errors - they are resolved by the fact that the missing data comes around again on the next cycle. The tradeoff is that convergence time is probabilistic rather than bounded: nothing here guarantees a specific offset is delivered within a specific number of cycles, only that it becomes overwhelmingly likely given enough of them. Calling code is expected to impose its own timeout on top of this and treat "still not synced after N seconds" as a fault, not as a reason to keep waiting indefinitely.
ConfigManager<ConfigClass>- ownsbufferandcommited, exposes their CRCs with a small invalidation cache so repeated CRC queries between writes don't recompute.ConfigDispatcher<ConfigClass, Dispatch, NumBoards>- master-side. Tracks each board's last-reported(buffer_crc, commited_crc)pair and drives the broadcast-and-commit cycle described above via a caller-suppliedDispatch(any type exposingsend(config)andcommit(board_id)).CANConfigChannel<ConfigClass>- marshals a chunk of raw bytes into or out of aConfigClassat a given offset, with bounds checking. Used on both the sending side (extract a chunk to put on the wire) and the receiving side (write an incoming chunk intobuffer).
- No persistence.
commitedlives in RAM only. A reset loses it. If your application needs configuration to survive a reboot, load/save to non-volatile storage is not provided here and must be added at the point where commit happens. - No bounded convergence time. See above. Add your own timeout at the call site.
- Sentinel initial state.
ConfigDispatcherstarts each board's tracked CRCs at two different placeholder values specifically so that no real CRC pair can match both simultaneously, ensuring a board that hasn't reported anything yet is never mistaken for one that has fully synced. This is a deliberate property of the comparison logic incheck_sync, not an incidental default - don't change one sentinel without checking that the reasoning still holds. - Single-writer assumption. Nothing here is thread-safe. If
ConfigDispatcher::tick()and the CAN receive path that feedsonBoardUpdate()run in different contexts (e.g. main loop vs. interrupt), synchronization is the caller's responsibility. - No struct layout guarantees across compilers/toolchains.
ConfigClassshould be a packed, standard-layout, trivially copyable type with an explicit, fixed field order. If the sender and receiver are built with different compilers or settings, verify padding/ alignment match, or the CRCs will legitimately disagree on identical logical data.
Two boards on a shared CAN bus. The master (e.g. the flight computer)
holds the authoritative configuration and drives convergence. The
receiver (e.g. a pressure regulator board) only ever writes into its
buffer and reports CRCs back - it never decides on its own to commit.
// shared_config.hpp - identical on both boards
#pragma pack(push, 1)
struct RegulatorConfig {
uint32_t schema_version;
uint32_t target_pressure_kpa;
uint32_t ramp_rate_kpa_per_s;
uint32_t max_valve_authority_pct;
};
#pragma pack(pop)
static_assert(std::is_standard_layout<RegulatorConfig>::value, "");
static_assert(std::is_trivially_copyable<RegulatorConfig>::value, "");
static_assert(sizeof(RegulatorConfig) <= 255, "offset must fit in one payload byte");
constexpr size_t CHUNK_SIZE = 8; // fits one classic CAN 2.0 payload// master.cpp - flight computer side
#include "confman.hpp"
#include "shared_config.hpp"
struct CANDispatch {
private:
CANConfigChannel<RegulatorConfig> channel;
public:
void send(const RegulatorConfig &cfg) {
// send {offset, bytes[offset:offset + 7]
uint8_t payload[CHUNK_SIZE];
payload[0] = channel.put(cfg, payload + 1, sizeof(payload) - 1);
can_transmit(CAN_ID_CONFIG_CHUNK, payload, CHUNK_SIZE);
}
void commit(size_t board_id) {
uint8_t payload[1] = { board_id };
can_transmit(CAN_ID_CONFIG_COMMIT, payload, 1);
}
};
ConfigDispatcher<RegulatorConfig, CANDispatch, 1> lox_regulator(CANDispatch{});
// called from CAN RX when the regulator reports its CRC pair
void on_can_crc_report(size_t board_id, uint32_t buf_crc, uint32_t commited_crc) {
lox_regulator.onBoardUpdate(board_id, buf_crc, commited_crc);
}
// main loop
void loop() {
lox_regulator.tick(); // no-ops once synced
}
void set_new_config(const RegulatorConfig &cfg) {
lox_regulator.write_buffer() = cfg;
lox_regulator.commit();
// subsequent tick() calls will drive the new config out and commit it
}// receiver.cpp -f regulator board side
#include "confman.hpp"
#include "shared_config.hpp"
ConfigManager<RegulatorConfig> config;
CANConfigChannel<RegulatorConfig> channel;
// CAN RX interrupt or RX task: config chunk received
void on_can_config_chunk(const uint8_t *payload, size_t len) {
channel.load(config.write_buffer(), payload[0], const_cast<uint8_t*>(payload + 1), len - 1);
}
// CAN RX: commit instruction received
void on_can_commit(const uint8_t* payload, size_t /* len = 1 always */) {
if (payload[0] == my_id)
config.commit(); // moves buffer -> commited
}
// periodic (e.g. 10 Hz) status report
void report_status() {
uint32_t buf_crc = config.get_buffer_crc();
uint32_t commited_crc = config.get_commited_crc();
can_transmit(CAN_ID_CRC_REPORT, buf_crc, commited_crc);
}