# 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
```
[](https://github.com/Fishwaldo/fsm/actions/workflows/ci.yml)
[](https://fishwaldo.github.io/fsm/)
API reference: **[fishwaldo.github.io/fsm](https://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 |
| 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 |
| 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
```cpp
#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; };
using Row = fsm::row<State, Event, Context>;
using Spec = fsm::state_spec<State, Event, Context>;
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;
fsm::machine<TurnstileTraits> m(ctx);
m.start(now_ms());
auto r = m.dispatch(TurnstileTraits::Event::Coin, now_ms());
if (fsm::accepted(r)) { /* ... */ }
```
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:
```cpp
{.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)` or
`fsm::no_timeout`; forgetting the column is a compile error, so a stale
deadline can't survive by omission. 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):
```cpp
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. 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* user` — `on_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/](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/](tests/c_caller/) and two
of the examples are C applications. The mirrored enums are pinned with
`static_assert`s so they cannot drift.
## Examples
| | |
|---|---|
| [examples/simple/](examples/simple/) | turnstile: flat machine, guard chain, reasoned refusals, auto-relock timeout |
| [examples/complex/](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/](examples/c_trafficlight/) | traffic light **in C**: pedestrian crossing, car detection, self-extending green, timed phases |
| [examples/esp32/](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`:
```yaml
dependencies:
fishwaldo/fsm: "^0.2.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/](examples/esp32/).
## Building & testing
```sh
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.
1bcae509a27163cb8d07624547dd072c703d1dc0
idf.py add-dependency "fishwaldo/fsm^0.2.0"