|
fsm 0.4.1
Header-only C++20 hierarchical table-driven FSM
|
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.
API reference: fishwaldo.github.io/fsm (Doxygen, republished on every commit to master).
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 |
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;.)
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:
Rows are tried in table order, first passing guard wins; the trailing refuse row turns "all guards declined" into a reasoned verdict.
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.
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):
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.
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:
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).
FSM_ENABLE_OBSERVER): a plain struct of nullable function pointers + void* user — on_event (every verdict, refusals included), on_transition, on_timeout_fired. Attach/detach at runtime with set_observer(); C-friendly by construction.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.#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).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).
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/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.
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:
(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/.
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).
MIT — see [LICENSE](LICENSE). SPDX-License-Identifier: MIT in every file.