uploaded 1 hour ago
Header-only C++20 hierarchical table-driven FSM: declarative constexpr transition tables with compile-time exhaustiveness validation, arbitrary guards, first-class timeouts, refusal-as-a-verdict, and optional observer/introspection debugging. Zero heap, no exceptions/RTTI, C-callable via a thin facade pattern.

Readme

# 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](https://github.com/Fishwaldo/fsm/actions/workflows/ci.yml/badge.svg)](https://github.com/Fishwaldo/fsm/actions/workflows/ci.yml)
[![docs](https://github.com/Fishwaldo/fsm/actions/workflows/docs.yml/badge.svg)](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 |
| 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

```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)`,
`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. 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. To make the event↔payload pairing compiler-checked at the call site,
declare per-event factories in the traits and use the typed wrappers:

```cpp
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
```

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* 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.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/](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.

Links

Supports all targets

Maintainer

  • Justin <justin@dynam.ac>
To add this component to your project, run:

idf.py add-dependency "fishwaldo/fsm^0.4.0"

download archive

Stats

  • Archive size
    Archive size ~ 71.65 KB
  • Downloaded in total
    Downloaded in total 0 times
  • Downloaded this version
    This version: 0 times

Badge

fishwaldo/fsm version: 0.4.0
|