Single-writer, multi-reader latest-state channel for RTOS/Arduino.
Publish a small POD struct from one task (or ESP32 ISR), and let any number of tasks read a consistent snapshot with minimal overhead.
Queues are for streams. SnapshotBus is for the latest state.
SnapshotRTOS in v1.1.x published std::array<float,N> channels and used a Policy{epsilon,min_interval_us}.
In v1.2.0, SnapshotRTOS publishes typed frames:
- The Reader/callback fills a Frame (
read(Frame&)orread(void*, Frame*)). - A Changed predicate decides publish vs. skip (payload-only; ignore stamp fields).
- A simple heartbeat is available via
PublishPolicy::min_interval_us. - Timestamping is injected via a
now_us()callable (improves portability/testability). - Internal robustness improvements: SnapshotRTOS task state is now constructed in-place using the provided reader, time source, and change detector. This allows non-default-constructible callables (such as lambdas) to be used safely and avoids unnecessary copies.
- SnapshotRTOS.h — tiny FreeRTOS publishers so you don’t write loop code:
snapshot::rtos::start_frame_publisher(...)snapshot::rtos::start_frame_publisher_cb(...)snapshot::rtos::start_frame_publisher_on_change(...)(semantic on-change; ignores stamps)snapshot::rtos::start_frame_publisher_cb_on_change(...)(semantic on-change; ignores stamps)PublishPolicy{min_interval_us}(optional heartbeat)Changed(prev,next)predicate (payload-level change gating)
snapshot::rtos::Policystart_publisher<N, Frame>(...)start_publisher_cb<Frame>(...)- Built-in float epsilon gating (replace with a
Changedpredicate)
snapshot::rtos::PublishPolicysnapshot::rtos::AlwaysPublish(default change detector)start_frame_publisher<Frame>(...)start_frame_publisher_cb<Frame>(...)
SnapshotRTOS task state is heap-allocated so you can run multiple publishers at once without a hidden “one instance only” limitation.
Arduino IDE
- Search SnapshotBus in Library Manager and install.
- Or download a release ZIP → Sketch → Include Library → Add .ZIP Library…
PlatformIO
Add to your environment:
lib_deps = littlemanbuilds/SnapshotBus@^1.2.0Manual
Copy src/ (headers only) into your project and include the headers you need.
SnapshotBus has two layers of support:
- Core layer:
SnapshotBus.h,InputModel.h,SnapshotTools.h - RTOS layer:
SnapshotRTOS.h(publisher task helpers)
| Target family | Core layer | SnapshotRTOS layer |
|---|---|---|
| ESP32 (Arduino) | Tested | Tested |
| ESP8266 | Tested | Not supported (no FreeRTOS publisher tasks) |
| SAMD (MKR/Zero) | Tested | Not supported (no FreeRTOS publisher tasks) |
| RP2040 / Teensy / STM32 | Not currently tested in this project (may compile; treat as experimental) | Not supported unless FreeRTOS headers are present |
Notes:
SnapshotRTOS.hrequires FreeRTOS headers (freertos/FreeRTOS.h,freertos/task.h).- On non-FreeRTOS targets, calling SnapshotRTOS start functions intentionally fails at compile time with a clear message.
- Some platforms are listed in package metadata for discoverability, but the table above reflects tested status.
- ISR publishing is treated as ESP32-only in this project.
- For non-ESP32 multicore targets, define
SNAPSHOTBUS_USE_ATOMICS=1if your toolchain/platform supports atomics safely.
- You have one writer (task, or ISR on ESP32) publishing a small struct.
- Many readers need the latest consistent value, not every intermediate value.
- You want zero allocation, header-only, and portable code.
Think: sensor fusion inputs, RC channel states, debounced switches, powertrain state, telemetry snapshots.
For non-RTOS usage, see examples/01_Basic_Polling/01_Basic_Polling.ino and examples/04_Model_Polling/04_Model_Polling.ino.
Example target scope:
examples/01_Basic_Polling/01_Basic_Polling.inoandexamples/04_Model_Polling/04_Model_Polling.inoare core, non-RTOS examples.examples/02_Polling_RTOS/02_Polling_RTOS.ino,examples/03_ISR_Publisher/03_ISR_Publisher.ino,examples/05_RTOS_Reader/05_RTOS_Reader.ino, andexamples/06_RTOS_Callback/06_RTOS_Callback.inoare ESP32-focused RTOS examples.examples/03_ISR_Publisher/03_ISR_Publisher.inois explicitly ESP32-only in code (#error "This example requires ESP32.").
#include <Arduino.h>
#include <SnapshotBus.h>
// Payload must be trivially copyable (POD)
struct InputState {
uint32_t buttons_mask;
uint32_t stamp_ms;
};
snapshot::SnapshotBus<InputState> g_bus;
void publisherTask(void*) {
pinMode(7, INPUT_PULLUP);
for (;;) {
InputState s{0, millis()};
if (digitalRead(7) == LOW) s.buttons_mask |= (1u << 0);
g_bus.publish(s);
vTaskDelay(pdMS_TO_TICKS(10));
}
}
void consumerTask(void*) {
for (;;) {
InputState s = g_bus.peek(); // stable snapshot
if (s.buttons_mask & (1u << 0)) {
// react to button press
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
void setup() {
xTaskCreate(publisherTask, "pub", 2048, nullptr, 1, nullptr);
xTaskCreate(consumerTask, "con", 2048, nullptr, 1, nullptr);
}
void loop() { vTaskDelete(nullptr); }Why this works: SnapshotBus uses an even/odd sequence counter (seqlock‑style). Reads retry if a write is in flight, returning a consistent frame without locking.
InputModel.h provides a simple State<N> to represent button levels and detect edges without writing bit‑twiddly code.
#include <InputModel.h>
using InputState = snapshot::input::State<8>; // 8 logical inputs
// Fill a snapshot (true == pressed)
InputState s{};
s.stamp_ms = millis();
s.set_button(0, true);
// Edges between two snapshots
InputState prev{}, cur{};
// ... fill cur ...
auto rising = snapshot::input::rising_edges(prev, cur);
auto falling = snapshot::input::falling_edges(prev, cur);
// Iterate only changes
snapshot::input::for_each_edge(prev, cur,
[](size_t i, bool pressed, uint32_t t_ms){
// handle edge i at time t_ms
});SwitchBank interop: convert to/from masks.
uint32_t mask = cur.mask32<8>(); // N<=32
uint64_t mask64 = cur.mask64<8>(); // N<=64
cur.from_mask32<8>(mask);
cur.from_mask64<8>(mask64);🔧 C++14 users: the header includes a no‑
if constexprfallback; no flags needed. C++17 still recommended.
SnapshotRTOS.h contains two helpers that build a FreeRTOS task for you and publish to a bus with change and/or heartbeat logic.
#include <SnapshotBus.h>
#include <SnapshotRTOS.h>
#include <InputModel.h>
#define BTN_PIN 4
#define NCH 1
struct InputFrame {
float out0{0.0f};
uint64_t stamp_us{0};
bool failsafe{false};
};
snapshot::SnapshotBus<InputFrame> g_bus;
struct ButtonReader {
void update() {}
void read(InputFrame& f) {
const bool pressed = (digitalRead(BTN_PIN) == LOW); // active-LOW
f.out0 = pressed ? 1.0f : 0.0f;
}
bool ok() const { return true; }
};
static uint64_t now_us() noexcept {
// Arduino-ESP32: micros() is fine for demo purposes.
// SnapshotRTOS takes a time source so it stays portable/testable.
return static_cast<uint64_t>(micros());
}
struct ButtonChanged {
bool operator()(const InputFrame& a, const InputFrame& b) const noexcept {
// Publish only on logical edges (equivalent to old epsilon=0.5 for 0/1 values).
return (a.out0 > 0.5f) != (b.out0 > 0.5f);
}
};
void setup() {
pinMode(BTN_PIN, INPUT_PULLUP);
snapshot::rtos::PublishPolicy pol{};
pol.min_interval_us = 0; // optional heartbeat
ButtonReader reader{};
snapshot::rtos::start_frame_publisher<InputFrame>(
g_bus, ///< SnapshotBus to publish into.
reader, ///< Reader: samples hardware and fills InputFrame.
now_us, ///< Time source (µs).
ButtonChanged{}, ///< Change detector: publish only on logical edges.
pol, ///< Publish policy (heartbeat / rate control).
"BtnPub", ///< FreeRTOS task name.
1536, ///< Stack size (words).
1, ///< Task priority.
5 ///< Poll period in milliseconds.
);
}Time source (now_us)
The time source may be a function pointer, lambda, or functor returning a monotonically increasing timestamp in microseconds.
The callable is stored in task-local state and invoked without copying (via const&), so it does not need to be default-constructible. This allows lightweight lambdas or custom time providers to be used safely with FreeRTOS tasks.
struct ButtonCtx { int pin; };
static void cb_update(void* /*ctx*/) { /* optional */ }
static void cb_read(void* ctx, InputFrame* out) {
if (!out) return;
const auto* c = static_cast<const ButtonCtx*>(ctx);
out->out0 = (digitalRead(c->pin) == LOW) ? 1.0f : 0.0f;
}
static bool cb_ok(void* /*ctx*/) { return true; }
void setup() {
static ButtonCtx ctx{4};
pinMode(ctx.pin, INPUT_PULLUP);
snapshot::rtos::PublishPolicy pol{};
pol.min_interval_us = 0;
snapshot::rtos::start_frame_publisher_cb<InputFrame>(
g_bus, ///< SnapshotBus to publish into.
&ctx, ///< Callback context.
cb_update, ///< update(): optional periodic work.
cb_read, ///< read(): fill payload fields.
cb_ok, ///< ok(): health check (nullptr => assumed OK).
now_us, ///< Time source (µs).
ButtonChanged{}, ///< Change detector: publish only on logical edges.
pol, ///< Publish policy (heartbeat / rate control).
"BtnPubCB", ///< FreeRTOS task name.
1536, ///< Stack size (words).
1, ///< Task priority.
5 ///< Poll period in milliseconds.
);
}using Bits = snapshot::input::State<NCH>;
void consumerTask(void*) {
Bits prev{};
for (;;) {
const InputFrame f = g_bus.peek();
Bits cur{};
cur.stamp_ms = static_cast<uint32_t>(f.stamp_us / 1000ULL); ///< µs → ms.
cur.set_button(0, f.out0 > 0.5f);
snapshot::input::for_each_edge(prev, cur, [](size_t i, bool pressed, uint32_t t_ms){
Serial.printf("[t=%lu ms] ch%u -> %s\n",
(unsigned long)t_ms, (unsigned)i, pressed ? "Pressed" : "Released");
});
prev = cur;
vTaskDelay(pdMS_TO_TICKS(20));
}
}SnapshotRTOS injects timestamp fields (e.g. stamp_us / stamp_ms / stamp_s) and failsafe before it calls your Changed(prev,next) predicate.
Therefore:
⚠️ Your change detector must ignore stamp fields or it will publish every cycle.
If you want a safe default, use the built-in semantic comparator wrappers:
snapshot::rtos::start_frame_publisher_on_change<Frame>(...)snapshot::rtos::start_frame_publisher_cb_on_change<Frame>(...)
These use PublishOnChange<Frame> internally, which performs semantic equality (not byte-wise) and is designed to ignore injected stamps.
For control loops or other “must publish every tick” cases:
snapshot::rtos::PublishPolicy pol{};
pol.min_interval_us = 0;
snapshot::rtos::start_frame_publisher<MyFrame>(
bus, reader, now_us,
snapshot::rtos::AlwaysPublish{},
pol,
"Task", 4096, 1, 10
);If you want on-change behavior with a custom predicate, make sure it ignores stamps:
struct MyChanged {
bool operator()(const MyFrame& a, const MyFrame& b) const noexcept {
return a.value != b.value; // ignore stamps / failsafe
}
};SnapshotRTOS publishers are long-lived FreeRTOS tasks. If a stack is undersized, failures can look like “random corruption”.
Recommended practice:
- Measure stack usage using FreeRTOS high-water marks.
- Track it consistently across your whole codebase (publishers, control tasks, orchestrators).
// Words remaining at the *minimum ever* watermark.
const UBaseType_t hw = uxTaskGetStackHighWaterMark(nullptr);SnapshotRTOS provides a lightweight, built-in task metadata registry to retain configured task parameters that FreeRTOS does not expose at runtime (e.g. original stack size, priority, and core affinity).
Tasks may be registered via register_task() using their TaskHandle_t. This enables
project-level diagnostics tools to compute consistent "used / allocated" stack
metrics and display task configuration information without modifying application
task creation code.
If your project already uses a StackWatch-style monitor, SnapshotRTOS publisher tasks can be registered and tracked in the same way as application tasks.
Alternatively, SnapshotRTOS provides its own optional TaskMeta registry
(register_task(), find_task(), etc.) that can be used to implement lightweight
stack and task diagnostics without external tooling.
// Example pattern (pseudo-API):
StackWatch::registerTask("BtnPub", /* TaskHandle_t */ handle);If you already use a standard wrapper (e.g. PW_Publisher.h / pw::publisher::start), it’s a good idea to centralize:
- task creation
- core pinning defaults
- stack sizing policy
- StackWatch registration
This makes examples copy/paste-safe for your YouTube audience and keeps production builds consistent.
void publish(const T& frame) noexcept— write new frame (single writer).template<class F> void publish_inplace(F&& fill) noexcept— fillT&in place.T peek() const noexcept— stable copy of latest frame.void peek_into(T& out) const noexcept— stable snapshot into an existing object.bool try_peek(T& out) const noexcept— bounded‑spin stable copy (false if not stable in limit).bool try_peek_with_seq(T& out, uint32_t& seq_out) const noexcept— bounded‑spin stable copy plus the stable sequence used for that copy.T peek_latest() const noexcept— zero‑spin best‑effort (may be mid‑write).uint32_t sequence() const noexcept— current seq (even=stable, odd=writing).uint32_t last_seq() const noexcept— last stable sequence captured by this instance.bool was_updated_since(uint32_t seq) const noexcept— check if a newer stable frame exists.template<class T> bool try_peek_new(const SnapshotBus<T>& bus, uint32_t& seen_seq, T& out) noexcept— latest-state helper; may advanceseen_seqeven if a stable capture is not possible in that call.template<class T> bool try_peek_new_strict(const SnapshotBus<T>& bus, uint32_t& seen_seq, T& out) noexcept— strict helper; advancesseen_seqonly when a stable new frame is actually captured.
Constraints
T must be trivially copyable. One writer per bus. Multi-core use requires atomic mode (SNAPSHOTBUS_USE_ATOMICS=1).
SnapshotTools is intentionally small: it provides a couple of “glue” helpers that show up a lot in real apps.
enum class UnstablePolicy { UseZero, UseLast }peek_with_fallback(bus, out, last, has_last, unstable_reads, policy)— reads from a bus and, if a stable snapshot can’t be proven, applies a fallback policy:UseZero: output a default-constructed frameUseLast: output the last-known-good frame (if available), otherwise default
snapshot::time::ms_to_us_sat(ms)— converts ms→µs with saturation to 32-bit (helps keep microsecond fields stable on long uptimes).
template<size_t N> struct Statewith:buttons(bitset),stamp_ms(ms since boot).set_button(id,bool),is_pressed(id),is_released(id),count(),mask32/64(),from_mask32/64().
- Free functions:
rising_edges,falling_edges,changed_edges,for_each_edge,to_mask32/64,to_bitset,assign_from_bits.
struct PublishPolicy { uint32_t min_interval_us; }struct AlwaysPublish(defaultChanged)start_frame_publisher<Frame>(bus, reader, now_us, changed, pol, name, stack_words, prio, period_ms, core_id, out_handle)start_frame_publisher_cb<Frame>(bus, ctx, update, read, ok, now_us, changed, pol, name, stack_words, prio, period_ms, core_id, out_handle)start_frame_publisher_on_change<Frame>(bus, reader, now_us, pol, name, stack_words, prio, period_ms, core_id, out_handle)start_frame_publisher_cb_on_change<Frame>(bus, ctx, update, read, ok, now_us, pol, name, stack_words, prio, period_ms, core_id, out_handle)
Reader contract
A Reader type provides:
void update()void read(Frame& out)bool ok() const(optional; if missing, the reader is assumed healthy)
Frame contract
Any trivially copyable Frame works. If these fields exist, SnapshotRTOS will populate them:
stamp_usand/orstamp_msand/orstamp_sfailsafe(set to!ok())
Define these before including SnapshotBus.h to customize behavior:
SNAPSHOTBUS_SPIN_LIMIT— bound reader spin count (default 0 = unlimited, typical stable quickly).SNAPSHOTBUS_ASSERT(x)— assert hook (defaults toconfigASSERTif present, elseassert).SNAPSHOTBUS_ASSERT_TASK_CONTEXT()— optional hook for non-atomic builds to assert publish paths are called from task/thread context.SNAPSHOTBUS_PAUSE()— hint in spin loops (defaults to Xtensanopon ESP).SNAPSHOTBUS_YIELD()— reader spin yield hook (defaults to no‑op; usestaskYIELD()on FreeRTOS when available).SNAPSHOTBUS_USE_ATOMICS— force atomic seqlock path (1) or interrupt-masked path (0). For non-ESP32 multicore targets, use1when supported by your toolchain.SNAPSHOTBUS_MULTICORE— set to1when your app may run writer/reader across cores (requiresSNAPSHOTBUS_USE_ATOMICS=1).
- Latest‑value channel (depth 1). Writers overwrite the previous frame.
- Seqlock‑style: writer flips seq to odd, writes payload, release‑fences, flips seq to even.
Readers load seq, copy payload, confirm seq unchanged & even. - Readers are fast; if a write collides, they retry.
peek_latest()trades strict consistency for zero‑spin reads (best‑effort).
- Don’t put dynamic containers in
T. Prefer plain arrays/fixed structs. - Keep frames small (a handful of scalars/arrays). Publish less often than you consume.
- In this project, ISR publishing is ESP32-only. When used, prefer
publish_inplaceto minimize copies (and do no blocking work). - Gate noisy inputs in the publisher (Change predicate, heartbeat policy, or domain logic).
SnapshotBus + InputModel behavior remains compatible with v1.1.x.
Breaking (SnapshotRTOS):
- Replace
Policy{epsilon,min_interval_us}with:- a
Changed(prev,next)predicate for payload gating, and PublishPolicy{min_interval_us}for heartbeat.
- a
- Replace:
start_publisher<N, Frame>(...)→start_frame_publisher<Frame>(...)start_publisher_cb<Frame>(...)→start_frame_publisher_cb<Frame>(...)
- Update Reader/callback read signature:
read(float* dst, size_t n)→read(Frame& out)read(void*, float*, size_t)→read(void*, Frame*)
- Provide a
now_us()callable (microseconds).
Introduced snapshot::input (renamed from snapshot::model), SnapshotRTOS helpers, and the C++14 fallback in InputModel.h. Examples updated.
Breaking: If you previously used snapshot::model, rename to snapshot::input, and replace setSnapshotButton(...) with set_button(...) (method on State).
- C++17 recommended (best diagnostics,
if constexprwhere available), especially for larger projects. - SnapshotBus core is designed to be lightweight;
InputModel.hsupports C++14 without flags. - SnapshotRTOS in v1.2.0 avoids C++20-only features (no
requires).
For Arduino/ESP32 + PlatformIO, if you want to force C++17:
build_unflags = -std=gnu++11 -std=gnu++14
build_flags = -std=gnu++17MIT © Little Man Builds