fsm 0.4.1
Header-only C++20 hierarchical table-driven FSM
Loading...
Searching...
No Matches
fsm — a header-only, table-driven, hierarchical FSM for C++20

A state machine you can read: the whole machine is two constexpr tables — transition rows and state specs — interpreted by one template, validated at compile time, with first-class timeouts and refusal-as-a-verdict. Built for embedded (ESP32/ESP-IDF component out of the box) and desktop alike.

zero heap · zero virtuals · -fno-exceptions -fno-rtti · no dependencies

CI docs

API reference: fishwaldo.github.io/fsm (Doxygen, republished on every commit to master).

Why another FSM library?

Existing embedded FSM libraries tend to make you pick two of three: a real declarative transition table, arbitrary guards, and event-driven dispatch. This library provides all three — and validates the whole table at compile time, so a forgotten (state, event) pair is a build error instead of a runtime surprise.

Feature
Declarative table {from, on, when, then, to} enumerable, dumpable, diagrammable
Arbitrary guards bool(Ctx&) / bool(Ctx&, const Payload&) — any code
Event-driven dispatch events are consumed, not conditions polled
Hierarchical states parent links, bubbling, LCA entry/exit, initial descent
Timeouts as table columns mandatory per-state declaration; machine owns nearest-deadline
Dynamic deadlines fsm::after_dyn(Ev) + a deadline_ms(State, const Ctx&) hook: durations from runtime data
Refusal by default, with reasons every dispatch returns a full verdict
Run-to-completion post() actions/hooks queue follow-up events, drained FIFO before the call returns
Typed dispatch wrappers dispatch<Ev::X>(TypedData{...}, now) — wrong payload for an event is a compile error
Compile-time validation exhaustiveness, ambiguity, hierarchy, 20 static_asserts
Optional payloads mixed input types per event via a tagged union
Debugging observer callbacks, table dump, Graphviz export — all optional
C callers thin extern "C" facade pattern, shipped and tested
Kconfig / CMake feature gates observer & introspection compile out entirely

Quick start

#include "fsm/fsm.hpp"
struct TurnstileTraits {
enum class State : uint8_t { Locked, Unlocked, Count }; // Count sentinel required
enum class Event : uint8_t { Coin, Push, Relock, Count };
struct Context { uint32_t credit = 0; };
static constexpr auto states = std::to_array<Spec>({
{.state = State::Locked, .parent = fsm::root, .deadline = fsm::no_timeout},
{.state = State::Unlocked, .parent = fsm::root, .deadline = fsm::after(5000, Event::Relock)},
});
static constexpr auto rows = std::to_array<Row>({
{.from = State::Locked, .on = Event::Coin, .to = fsm::to(State::Unlocked)},
{.from = State::Locked, .on = Event::Push, .to = fsm::refuse(uint8_t{1})},
{.from = State::Locked, .on = Event::Relock, .to = fsm::internal},
{.from = State::Unlocked, .on = Event::Push, .to = fsm::to(State::Locked)},
{.from = State::Unlocked, .on = Event::Coin, .to = fsm::internal},
{.from = State::Unlocked, .on = Event::Relock, .to = fsm::to(State::Locked)},
});
static constexpr State initial = State::Locked;
};
TurnstileTraits::Context ctx;
m.start(now_ms());
auto r = m.dispatch(TurnstileTraits::Event::Coin, now_ms());
if (fsm::accepted(r)) { /* ... */ }
The state machine engine interpreting a Traits' constexpr tables.
Definition fsm.hpp:972
Header-only C++20 hierarchical, table-driven finite state machine.
constexpr internal_t internal
Row target: handle the event (run the action) without exit/entry.
Definition fsm.hpp:153
constexpr bool accepted(const result< State, Event, Reason > &r)
True when the event was acted on (transitioned or handled internally).
Definition fsm.hpp:137
constexpr timeout_spec< Event > after(uint32_t ms, Event e)
Declare "dispatch @p e after @p ms milliseconds in this state".
Definition fsm.hpp:371
constexpr detail::refuse_t< Reason > refuse(Reason r)
Make a declarative refusal target carrying a reason.
Definition fsm.hpp:271
constexpr no_timeout_t no_timeout
State deadline declaration: this state has no timeout (explicitly).
Definition fsm.hpp:160
constexpr root_t root
Parent declaration: this state is a hierarchy root (no parent).
Definition fsm.hpp:295
One transition-table row: {from, on, when, then, to}.
Definition fsm.hpp:416
One state's specification: hierarchy links, actions, deadline.
Definition fsm.hpp:441

Delete the {Locked, Relock} row and the machine stops compiling: static assertion failed: fsm: unhandled (state, event) pair in strict machine. That is the point. (Opt out per machine with static constexpr bool strict = false;.)

Concepts

Everything is a verdict

dispatch() never swallows an event. The returned fsm::result carries {status, from, to, event, row, reason} where status is one of transitioned, handled_internally, refused_by_row, refused_guard, unhandled, refused_reentrant, not_started. Refusal is the default: an event no row matches is unhandled, and an explicit fsm::refuse(Reason) row refuses with a reason an operator can act on ("refused because latched" stops a retry loop; a bare "no" does not).

The guard chain with reasoned fallback pattern:

{.from = St::Standby, .on = Ev::PowerGood, .when = supply_sufficient,
.then = latch_power, .to = fsm::to(St::Energized)},
{.from = St::Standby, .on = Ev::PowerGood, .to = fsm::refuse(Rsn::InsufficientSupply)},

Rows are tried in table order, first passing guard wins; the trailing refuse row turns "all guards declined" into a reasoned verdict.

Hierarchy

Every state declares its place: .parent = fsm::root or .parent = fsm::child_of(Outer) — the column is mandatory (omission is a compile error), so a state can never silently fall out of its composite. Composites declare which child .initial = fsm::start_at(...) enters. Dispatch matches rows on the leaf first, then each ancestor, then fsm::any rows — so *"from any state, on EStop → EStopped"* is one row, and a parent can own behaviour all its children inherit (and any child can shadow). Transitions exit up to the least common ancestor (exit hooks innermost-first), run the row action, and enter down to the target (entry hooks outermost-first). Self-transitions fully exit and re-enter — which restamps the state's timeout.

Timeouts are events, armed by the table

Every state must declare its deadline — fsm::after(ms, Event), fsm::after_dyn(Event), or fsm::no_timeout; forgetting the column is a compile error, so a stale deadline can't survive by omission. after_dyn covers runtime-valued holds (pulse widths, profile-authored delays): declare static uint32_t deadline_ms(State, const Context&) in the traits (compile error otherwise) and it is consulted at every entry — self-transition re-entry included, so a stepping state re-arms its gap from live data. A return of 0 means "due now" (fires on the next service()); returns above half the Time range clamp there and assert in debug builds. Note how those two rules compose: service(now) fires all due deadlines in one call, so a deadline_ms() of 0 on a self-transitioning state re-arms due-now and re-fires within that same call until the iteration fuse (4 * state_count) stops it, leaving the rest for your next call. That is intended — a zero-length hold means "one more pass", and pacing is the caller's — but it is the first thing a dynamic-deadline design composes, so size your holds knowing a 0 does not yield to the event loop. Entry stamps the deadline, exit disarms it, and service(now) fires expired ones through the normal table (verdict, observer and all). Leaf and ancestor deadlines coexist: a session watchdog on a composite runs while its children tick.

The event loop shape (see the ESP32 example for the FreeRTOS version):

uint32_t deadline;
TickType_t wait = portMAX_DELAY;
if (m.next_deadline(deadline)) {
const uint32_t delta = deadline - now_ms(); // wraparound-safe
wait = (int32_t)delta <= 0 ? 0 : pdMS_TO_TICKS(delta); // clamp if already due
}
if (xQueueReceive(queue, &ev, wait) == pdTRUE) m.dispatch(ev.id, ev.payload, now_ms());
m.service(now_ms());

The machine iterates its own deadline storage — the loop cannot forget a timeout source. The clock is injected everywhere (default uint32_t monotonic milliseconds, wraparound-safe up to 2^31 ms ahead; override with using Time = uint64_t; in traits). One task by design: arm, fire and transit on the caller's task removes timer races by construction. ISRs post events to a queue; they never dispatch.

Payloads and dynamic parameters

Declare a Payload in the row type and dispatch carries it to every guard and action by const reference — mixed per-event types live in a tagged union. To make the event↔payload pairing compiler-checked at the call site, declare per-event factories in the traits and use the typed wrappers:

struct FloorCall { uint8_t floor; }; // typed view
static constexpr Payload make_payload(fsm::event_tag<Ev::Call>, FloorCall c) {
Payload p{.kind = Payload::kFloor, .u = {}};
p.u.floor = c.floor;
return p;
}
m.dispatch<Ev::Call>(FloorCall{.floor = 4}, now); // OK
m.dispatch<Ev::Call>(WeightReading{...}, now); // compile error
m.post<Ev::Call>(FloorCall{.floor = 2}); // typed post() too
Tag carrying an event as a compile-time value.
Definition fsm.hpp:173

For the erased path (queues, C facades, post() internals), an optional payload_matches(Event, const Payload&) traits hook adds a debug-build assert that the payload's content agrees with the event — on when NDEBUG is not defined, tunable via FSM_ENABLE_PAYLOAD_CHECKS / FSM_PAYLOAD_ASSERT. Rows, guards and actions are unchanged either way: they receive the machine-wide Payload. Only the machine's structure is compile-time: Context is held by reference, so setpoints, limits and targets change freely at runtime and guards read them as they are at dispatch time. Outputs are written by actions into your Context (each action gets its own typed output; the C facade exposes them as getters).

Debugging

  • Observer (FSM_ENABLE_OBSERVER): a plain struct of nullable function pointers + void* useron_event (every verdict, refusals included), on_transition, on_timeout_fired. Attach/detach at runtime with set_observer(); C-friendly by construction.
  • Table dump (FSM_ENABLE_INTROSPECTION): m.dump(sink) streams the live table, current leaf and armed deadlines line-by-line through any callable — describe a machine over a control channel. for_each_row / for_each_state give programmatic access.
  • Graphviz: #include "fsm/dot.hpp" and fsm::write_dot<Traits>(sink) emits a diagram from the executing tables (composites as clusters, timeout edges dashed, refusals as an octagon sink) — it cannot go stale. Pre-rendered diagrams for every example live in docs/diagrams/; regenerate them with cmake --build build --target diagrams (SVGs too when Graphviz's dot is installed).
  • Names: optional state_name/event_name/reason_name traits hooks (numeric fallback otherwise), and an optional .label on any row — dump and dot output can then say why a row exists, not just what it does.

Both features default on and compile out entirely (Kconfig on ESP-IDF, -DFSM_OBSERVER=OFF -DFSM_INTROSPECTION=OFF on desktop CMake).

C callers

The library stays C++; C code drives a machine through a thin extern "C" facade you write once per machine (opaque handle, mirrored enums, POD result, observer bridged to a C callback, dump bridged to a line callback). A complete worked facade ships in tests/c_caller/ and two of the examples are C applications. The mirrored enums are pinned with static_asserts so they cannot drift.

Examples

examples/simple/ turnstile: flat machine, guard chain, reasoned refusals, auto-relock timeout
examples/complex/ elevator: hierarchical cab controller + cooperating door machine, overweight refusals, emergency stop via an any-row, per-floor timer restamps, Graphviz dump
examples/c_trafficlight/ traffic light in C: pedestrian crossing, car detection, self-extending green, timed phases
examples/esp32/ ESP-IDF app in C: button/LED with debounce/long-press/blink timers, ISR→queue→next_deadline loop

The desktop examples are deterministic simulations (no sleeps) that assert their own scenario — they run as part of the test suite.

ESP-IDF integration

The repo root is an ESP-IDF component (idf_component_register under ESP_PLATFORM, idf_component.yml, Kconfig). Depend on it via the component manager in your project's main/idf_component.yml:

dependencies:
fishwaldo/fsm: "^0.4.0"

(or vendor the checkout and point EXTRA_COMPONENT_DIRS at it — the directory must then be named fsm, since IDF names components after their directory). IDF v5+ (C++20) is required (enforced by idf_component.yml); IDF v6 (gnu++26) verified by the requirements evaluation. Feature gates appear under Component config → FSM library; the flashable example is examples/esp32/.

Building & testing

cmake -B build && cmake --build build -j && ctest --test-dir build --output-on-failure
cmake --build build --target diagrams # regenerate docs/diagrams (Graphviz)
cmake --build build --target docs # Doxygen API docs -> build/docs/api/html

The suite covers dispatch semantics, hierarchy order (literal entry/exit traces), timeouts (including wraparound), payloads, verdicts, observer, introspection, dot export, a pure-C caller — and 19 compile-fail tests that each pin one validator's static_assert message (a validator that only ever passes proves nothing).

License

MIT — see [LICENSE](LICENSE). SPDX-License-Identifier: MIT in every file.