Skip to content

Latest commit

 

History

29 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HFSM — C++ Hierarchical Finite State Machine

Header-only, no-allocation, tick-based HFSM for C++17/20. Designed as a foundation for embedded control systems where allocation is prohibited, exceptions are off, and execution order must be deterministic per clock tick.

C++17+  |  Single header  |  MIT


Overview

  • Hierarchical: states nest inside composite states; events bubble up the parent chain, and on_update ticks down it — every active state runs, outermost-in.
  • Header-only: include hfsm.h in your project, no external dependencies needed.
  • No heap, no exceptions: all tables are constexpr; storage lives in the machine object.
  • Tick-based: update() drives execution — each call runs auto-transitions, then on_update, then the queued event, in that order. Dispatch never re-enters the engine.
  • Context by reference: every callback receives Context&. States and transitions hold no data of their own — they are duck-typed tags with static callbacks, never instantiated.
  • Single-threaded by design: a machine is a single-owner object with no internal locking; drive each instance from one thread (see Thread safety).
  • Compile-time verification: hierarchy correctness checked with static_assert; a typo'd state tag is a hard compile error. Optional hfsm::structure::validate<FSM>() adds deeper checks (duplicate triggers, automatic self-loops) at the call site.

Quick Start

A minimal power-toggle machine — two states, one event, one transition type.

#include "hfsm.h"
#include <iostream>

enum class Events{
  EVT_POWER
};

struct AppCtx { bool powered = false; };
using M = hfsm::Machine<AppCtx&, Events>;

struct RootState {};

struct Off {
    static void on_enter(AppCtx& ctx) { ctx.powered = false; }
};

struct On {
    static void on_enter(AppCtx& ctx) { ctx.powered = true; }
};

struct Toggle {
    // static bool on_guard(AppCtx& ctx) { return true; } // omit for the default (always-true) guard
    static void on_action(AppCtx& ctx) {
        std::cout << "Toggling power" << std::endl;
    }
};

using States = M::Root<hfsm::Composite<RootState, Off, On>>;  // Off is initial child
using Transitions = M::Transitions<
    M::Transition<Off, On,  Events::EVT_POWER, Toggle>,
    M::Transition<On,  Off, Events::EVT_POWER, Toggle>
>;
using FSM = M::Tcontroller<States, Transitions>;

int main() {
    AppCtx ctx;
    FSM machine{ctx};

    machine.update();               // first tick: enters RootState then Off
    // ctx.powered == false

    machine.on_event(Events::EVT_POWER);
    machine.update();               // exits Off, enters On
    // ctx.powered == true

    machine.on_event(Events::EVT_POWER);
    machine.update();               // exits On, enters Off
    // ctx.powered == false
}

Steps

  1. Define a Context struct — the shared data carrier for all callbacks.
  2. Alias using M = hfsm::Machine<Context&>; (add EventType as a second arg for typed events).
  3. Write each state as a plain struct with any of the static callbacks on_enter, on_exit, on_update — provide only the ones you need.
  4. Write each transition as a plain struct with static callbacks on_guard (return false to reject) and/or on_action — omit either to accept the default.
  5. Declare M::Root<Composite<...>> and M::Transitions<...>.
  6. Instantiate M::Tcontroller<States, Transitions>, then call update() every tick.

Concepts

States and hierarchy

Every machine has at least one Composite at the top level. Composite<Parent, Child1, Child2, ...> nests children under a parent state; the first child is the default initial state. Leaf states are plain (non-composite) types. The hierarchy is arbitrarily deep.

Composite<RootState, Idle, Composite<Active, Listening, Transmitting>, Sleep>

  RootState
  ├── Idle        ← initial child of RootState
  ├── Active
  │   ├── Listening      ← initial child of Active
  │   └── Transmitting
  └── Sleep

A state is just a tag type — it derives from nothing. Supply any subset of the static callbacks on_enter, on_exit, on_update; each one you omit defaults to a no-op. A callback with the wrong signature (not static, wrong parameter type) is a hard compile error, so typos don't silently fall back to the no-op.

State types must be complete where the machine's tables are built — a forward declaration is not enough, since the library detects callbacks by probing the type. Declaring the machine as a member of the class that also declares its states is fully supported (including states forward-declared above the member and defined further down in the class body); the callback tables are instantiated lazily, at the point the controller's constructor is instantiated, by which time the enclosing class is complete. A state type that is never defined in the translation unit is a compile error, not a silent no-op.

Events and typed event IDs

The event type is the second template parameter: Machine<Context, EventType = int>. Any integral-like type works — int, a plain enum, or an enum class. Using an enum makes the event table self-documenting and prevents silent integer coercions.

enum class Events { EVT_START = 1, EVT_STOP = 2 };
using M = hfsm::Machine<AppCtx&, Events>;

// EventId must be exactly Events — passing int here is a compile error:
M::Transition<Idle, Active, Events::EVT_START, StartTransition>

Transition and InternalTransition are nested inside Machine.

Transition types

Type Effect
M::Transition<From, To, Evt, T, [Kind]> External: full exit/enter cascade, UML semantics
M::InternalTransition<State, Evt, T> Internal: calls on_action only, no exit/enter
M::AutomaticTransition<From, To, T, [Kind]> Guard-driven; fires at the top of each update() tick

Guard and action

struct MyTransition {
    static bool on_guard(AppCtx& ctx) { return ctx.ready; }  // return false to block
    static void on_action(AppCtx& ctx) { ctx.ready = false; }
};

If the guard returns false, the engine skips the row and continues bubbling up. Multiple rows with the same source state and event form a priority chain — first guard that passes wins. Omit on_guard to accept unconditionally; omit on_action for a transition that only moves state.

State and transition types are pure compile-time tags: they carry no data and are never instantiated — the engine only takes the address of their static callbacks. Because there is no per-type instance, any member variables would be pointless (there is nowhere to store them per machine); the Context& passed to every callback is the only valid location for state.

Event dispatch and bubbling

Unhandled events walk up the parent chain. A row registered on RootState acts as a catch-all for any descendant that doesn't handle the event itself.

on_event(EVT_X) → check leaf → check leaf's parent → ... → check root → discard

Tick-based execution model

update()
  1. dispatch queued event (if any)
  2. evaluate auto-transitions (loop until none fires)
  3. call on_update on every active state, outermost-in (root ancestor -> active leaf)

The first update() performs the initial entry cascade. There is no separate initialize().

Transitions resolve before the tick they land in. Because dispatch is step 1, the update() that follows an on_event() exits the old state, runs the action, enters the new one, and then ticks the chain the transition just entered — the state being left does not get a farewell on_update. An event queued from inside an on_update is dispatched at the top of the next update().

The whole active chain ticks. on_update is not leaf-only. If A is a composite whose current child B is a composite with current leaf C, one update() call runs:

A::on_update -> B::on_update -> C::on_update

Outermost-in, the same order on_enter uses when entering that chain. This is what makes on_update on a composite worth having: a parent runs the per-tick behaviour shared by all of its children (integrating physics for every Grounded substate, feeding a watchdog for every substate of Connected) while the leaf handles what is specific to it. States without an on_update are a no-op in the walk, so a deep chain of callback-less ancestors costs a table lookup each and nothing more. The chain is computed once, before any callback runs, from the leaf active at the start of step 3 — a state cannot change the set of states ticked in the same tick, because on_update can only queue an event (dispatched at the top of the next tick), never transition inline.

One event per tick. The engine holds a single pending-event slot. on_event(id) fills it and returns true; if the slot is already occupied it returns false and the event is not stored — the engine never silently drops or reorders events on your behalf. At most one event-driven transition fires per update() call.

Event queuing is the caller's responsibility. If your system can produce events faster than one per tick, maintain your own queue and drain it in coordination with update():

while (!my_queue.empty() && machine.on_event(my_queue.front()))
    my_queue.pop();
machine.update();   // consumes at most one event from the slot

History

transition_kind controls how a composite state is re-entered:

Kind Behaviour
normal (default) Enter initial child chain
shallow Re-enter the last direct child that was active; then enter its initial child chain
deep Re-enter the exact leaf state that was last active in the subtree

Serialization

The machine's entire mutable state is the active leaf index plus the per-composite history table — small and pointer-free, so it snapshots trivially (e.g. for power-loss / warm-restart recovery). The engine doesn't impose a wire format: save() hands you the data and you persist it however you like (file, EEPROM, flash, network); restore() / restore_cascade() rebuild from values you read back.

// save: your sink receives (currentState, history pointer, count)
struct MySink {
    void operator()(int current, const int* history, std::size_t count) { /* write however */ }
};
MySink sink;
machine.save(sink);

// restore: feed the values back. Range-checked; returns false and leaves the machine
// untouched if anything is out of range or the count doesn't match.
bool ok = machine.restore(current, history, count);          // position only, no callbacks
bool ok = machine.restore_cascade(current, history, count);  // also replays on_enter, root -> leaf

What's captured: currentState + the history table. Not captured: the queued event. restore marks the machine initialized, so the next update() resumes normally without re-running the initial entry cascade.

Frames are native-endian — intended for a device persisting and restoring its own state.


Tooling — introspection & diagram export

Optional, dev-time headers under tools/. They depend on the vendored nameof and magic_enum (third_party/); the core hfsm.h does not.

Structure validation — tools/hfsm_structure.h

hfsm::structure::validate<FSM>() performs additional compile-time checks on a fully wired Tcontroller. It is opt-in and intended to be called alongside the machine definition or in a dedicated test translation unit.

#include "tools/hfsm_structure.h"

using FSM = M::Tcontroller<States, Transitions>;
hfsm::structure::validate<FSM>();   // compile error if anything below fires

Checks performed:

Check Error message
Two event rows share the same (from, eventId) — second is silently unreachable "duplicate (from, eventId) pair in transition table: second transition is unreachable"
An AutomaticTransition has From == To — guaranteed infinite loop "automatic transition self-loop detected: from == to causes an infinite loop"

The function has no runtime cost: it instantiates an internal validator type (inheriting from FSM to access its protected tables) and discards it with sizeof. Nothing executes at run time.

State-name table — tools/hfsm_annotations.h

hfsm::annotations::get_names<States>() returns a constexpr std::array<const char*, States::Size> indexed by state ID. Each entry is the state's type name (via nameof), a NUL-terminated string with static lifetime. It underlies the diagram generator, and lets you map any state ID the machine reports — e.g. from get_current_state() — back to a readable name:

#include "tools/hfsm_annotations.h"

static constexpr auto names = hfsm::annotations::get_names<States>();
std::printf("now in %s\n", names[machine.get_current_state()]);

For automatic per-transition tracing without wiring names yourself, use the logger (tools/hfsm_logger.h), which resolves state and transition names internally.

Transition labels — HFSM_ACTION / HFSM_GUARD

Place above the overridden method inside a transition struct; each expands to a sibling static constexpr std::string_view the generator reads via SFINAE. Optional and additive — unannotated transitions fall back to generic markers, and the core is never aware of them.

struct AdvanceToRed {
    HFSM_ACTION("ActionToRed")
    static void on_action(int&);
    HFSM_GUARD("ToRedGuard")
    static bool on_guard(int&);
};

PlantUML export — tools/hfsm_generator.h

hfsm::diagram::to_plantuml<States, Transitions>(title = {}) returns a std::string. It walks the compile-time hierarchy (parentTable / initialStateTable) to emit nested state { … } blocks, and the published Transitions::metas list for edges. State names come from get_names; event names from magic_enum (non-enum EventType falls back to the integer value).

#include "tools/hfsm_generator.h"
std::cout << hfsm::diagram::to_plantuml<States, Transitions>("Traffic Light");

Edge labels follow UML event [guard] / action, each segment emitted only when present. Guard/action presence is detected from whether the transition type defines a static on_guard / on_action; the bracketed/slash text is the HFSM_GUARD / HFSM_ACTION label when annotated, otherwise the generic Guard / Action.

Transition Emitted label
no guard/action Green --> Yellow : TICK
on_guard, unannotated … : TICK [Guard]
on_guard + HFSM_GUARD("Ready") … : TICK [Ready]
on_action + HFSM_ACTION("Move") … : TICK/Move
AutomaticTransition + guard A --> B : [Guard]
InternalTransition + action A : EVT/Action (internal)

History kinds render the target as a PlantUML history pseudostate: To[H] (shallow), To[H*] (deep). See examples/traffic_light_annotated.cpp for a complete annotated machine.

YAML export — tools/hfsm_generator.h

hfsm::diagram::to_yaml<States, Transitions>(name = {}) emits a machine-readable YAML description of the same machine. It is intended as an intermediate format for external tools (e.g. a Python script) that render PlantUML, SCXML, or any other diagram format without requiring a C++ toolchain.

#include "tools/hfsm_generator.h"
std::cout << hfsm::diagram::to_yaml<States, Transitions>("TrafficLight");

Example output:

name: TrafficLight
states:
  - name: RootState
    initial: Red
    states:
      - name: Red
      - name: Yellow
      - name: Green
transitions:
  - from: Red
    to: Green
    event: GO
    guard: IsReady
    action: StartGreen
  - from: Green
    to: Yellow
    automatic: true
    guard: TimerExpired
  - from: Yellow
    to: Red
    event: TICK
    kind: shallow

Schema rules:

  • initial and states are emitted only for composite states.
  • event is omitted for automatic transitions; automatic: true is emitted instead.
  • internal: true is emitted only when the transition is internal.
  • kind is omitted for normal; "shallow" or "deep" otherwise.
  • guard and action are omitted when the transition type does not define the corresponding static callback.

The two export functions (to_plantuml, to_yaml) share the same edge-collection pass; both reflect HFSM_ACTION / HFSM_GUARD labels when present.

Logging — tools/hfsm_logger.h

Set-and-forget tracing that never touches the core. hfsm.h has no logging hooks; instead, hfsm::log::Controller is a drop-in replacement for M::Tcontroller that builds its state- and transition-ops tables from trampolines which log, then delegate to exactly the function the core would have called. The engine runs byte-identical — it just happens to dispatch through entries that log first. All naming lives here (type names via nameof); the core stays a pure integer world.

The only change to a machine is swapping the controller type and carrying a sink in your Context:

#include "tools/hfsm_logger.h"

// A sink is any type exposing the on_* methods you care about — implement only those.
// It's stream-free: this example uses printf, but RTT/UART/std::ostream all work.
struct Trace {
    void on_enter (std::string_view s) { std::printf("-> enter  %.*s\n", (int)s.size(), s.data()); }
    void on_exit  (std::string_view s) { std::printf("<- exit   %.*s\n", (int)s.size(), s.data()); }
    void on_action(std::string_view s) { std::printf(" / action %.*s\n", (int)s.size(), s.data()); }
};

struct AppCtx { Trace logger; bool powered = false; };   // sink located at ctx.logger by default
using M = hfsm::Machine<AppCtx&, Events>;
// ... States / Transitions declared exactly as usual ...

// The whole opt-in: hfsm::log::Controller instead of M::Tcontroller.
using FSM = hfsm::log::Controller<AppCtx&, Events, States, Transitions>;

The sink is duck-typed — implement any subset of these; missing methods are detected with SFINAE and skipped at compile time. Each is passed the state's or transition-type's name:

Sink method Called when
on_enter(std::string_view) a state is entered — once per state, including every intermediate state along the LCA path and the initial-state descent
on_exit(std::string_view) a state is exited
on_update(std::string_view) a state's on_update runs — once per active state per tick, root ancestor first
on_guard(std::string_view, bool) a transition guard is evaluated; second arg is the result
on_action(std::string_view) a transition or internal action runs

Notes and boundaries:

  • Where the sink lives. By default at ctx.logger. Point it elsewhere by specializing hfsm::log::sink_access<YourCtx> — to a differently-named member, a pointer/reference deref, a reference to a virtual base (duck-typing is a superset of virtual dispatch), or a static instance for a global sink. Carrying it in the Context gives each machine its own sink with its own state, reachable from the single Context& the engine threads through every callback.
  • No-op default. hfsm::log::EmptyLogger is a sink with empty on_* methods, handy as a placeholder or a compile-time off switch.
  • Not observable: an event that matches no transition in the active path produces no callbacks (no user code runs, so there is nothing to wrap) — you see the on_event submission, then silence.
  • Guard noise: guards are evaluated while scanning automatic transitions on every tick, so a chatty on_guard fires frequently; gate it in your sink if needed.
  • Cost when unused: zero — the core carries no logging machinery. Use M::Tcontroller for a machine with no logging overhead, hfsm::log::Controller for one with it.

Reference Example — LoRa Radio Controller

tests/RadioControllerTests.cpp is a realistic embedded reference: 16 states, 4 levels deep, 26 transition rows, 18 events. It exercises every dispatch mechanism in the engine.

Root
├── Idle                         (initial)
├── Active
│   ├── Listening                (initial)
│   ├── Configuring
│   │   ├── SetFrequency         (initial)
│   │   ├── SetPower
│   │   └── SetSpreadingFactor
│   └── Transmitting
│       ├── Preparing            (initial)
│       ├── Sending
│       └── AwaitingAck
├── Fault
│   ├── Recoverable              (initial)
│   └── Critical
└── Sleep

Highlights:

  • Internal transitions at two different ancestor scopes (Configuring and RootState).
  • Guarded retry chain: AckTimeoutRetryTransition passes until retryCount == MAX_RETRIES, then falls through to the parent Transmitting → Idle row.
  • 3-level guard fallthrough on EVT_EMERGENCY: leaf → mid-ancestor → root catch-all.
  • A permanently-blocked guard (Critical → Recoverable on EVT_RESET) verified by test.
  • Shallow vs. deep history compared side-by-side from the same Sleep escape state.
  • C++20 StrLiteral NTTP gives every state and transition type a unique compile-time name for event-log assertions without any runtime string storage.
  • Compile-time table verification with static_assert against hand-authored baselines:
static_assert(States::Size == 16);
static_assert(States::Depth == 4);
static_assert(States::parentTable() == std::array<int, 16>{-1, 0, 0, 2, 2, 4, 4, 4, 2, 8, 8, 8, 0, 12, 12, 0});

Design Notes

Tick vs. reactive dispatch

The engine holds a single pending-event slot: on_event() fills it, update() drains it — one event dispatched per tick, no internal queue. This is a deliberate scope boundary: queuing policy (bounded ring buffer, priority queue, drop-oldest, etc.) varies too much between applications to bake into the engine, and adding it would require allocation or a size parameter. Keeping the slot at depth-1 means the engine itself never allocates, and the caller retains full control over backpressure. The false return from on_event() is the backpressure signal; what the caller does with it — retry next tick, push to an external queue, or drop — is application logic.

The tick model also eliminates re-entrant dispatch entirely. Execution order per update() is fixed: pending event → auto-transitions → on_update. There is no dispatching guard; the engine is never called from inside a callback.

This is also what makes ticking the full active chain safe and cheap. The walk reuses the same scratch path buffer the transition machinery uses (sized at compile time to the machine's maximum depth, so no allocation), and because no callback can re-enter the engine, nothing can clobber that buffer mid-walk. Cost per tick is O(depth) rather than O(1) — depth being the nesting depth of the machine, typically a handful of levels.

Context by reference

States and transitions store nothing. Every callback receives Context& — the entire shared state of the machine lives there. This makes unit-testing trivial (construct a context, observe it after update()), enables the same transition type to be reused across different machines, and means state/transition types are never instantiated at all.

Type erasure — no virtuals, no per-type instances

State and transition tags are duck-typed: the runtime engine lives in a non-template base_controller and stores each callback as a plain function pointer (void (*)(void*)). The templated controller<Context, EventType> layer only synthesizes a tiny thunk per type that recovers the concrete Context& from the void* and forwards to your static callback. Detection is via SFINAE on the expected signature, with a static_assert that fires when a callback of the right name but wrong signature is present (so typos are caught, not ignored).

This has three consequences the previous virtual-based design did not:

  • No template bloat. The dispatch engine (update, event bubbling, LCA / path computation, history) is compiled once in base_controller, not re-instantiated per Context/EventType.
  • Smaller footprint. No vtables and no per-type instances; transition rows reference their ops by a small integer index into a de-duplicated ops table rather than by pointer.
  • One indirection per call. A single function-pointer call replaces the two-load virtual dispatch — and there is nothing to instantiate, so states/transitions cost zero bytes of storage.

constexpr tables

parentTable, initialStateTable, stateOpsTable, transitionOpsTable, eventRows, and autoRows are all resolved at compile time. parentTable, initialStateTable, eventRows, and autoRows are plain static constexpr members of Tcontroller; the two that probe user callbacks live in their own holder templates, reached as stateOpsStorage::value and transitionOpsStorage::value. A holder is instantiated the first time ::value is named — in the controller's constructor — rather than when Tcontroller is completed, which is what lets a machine live inside the class that declares its states. Everything is evaluated at compile time; the generated machine object carries only mutable runtime state (current state, history table, pending event). A static_assert fires if a state tag appears twice, a referenced tag isn't in the tree, or the number of states overflows int.

No RTTI, no virtuals

Dispatch goes through plain function pointers — there are no virtual functions and no vtables. The library compiles and runs correctly with -fno-rtti (and has nothing that would need -fno-exceptions relaxed either).

Thread safety

The machine is not thread-safe, by design. A Tcontroller is a single-owner object: all of its mutating methods — update(), on_event(), restore() / restore_cascade() — read and write the same unsynchronized state (current state, history table, pending-event slot) with no internal locking. Calling any of them concurrently on the same instance is a data race.

This is deliberate: locking policy belongs to the application, and forcing a mutex into every tick would penalize the common embedded case of a single control loop. The supported patterns are to drive each machine from one thread, or to serialize access behind your own mutex. Distinct Tcontroller instances are fully independent and may be driven from different threads without coordination (the shared constexpr tables are read-only).

C++17 compatibility

The library header requires C++17. A hfsm::span polyfill is defined for compilers below C++20; in C++20+ it aliases std::span. The test suite stays at C++20 intentionally — it uses class-type NTTPs (StrLiteral) that have no C++17 equivalent.


API Reference

Entity Purpose
hfsm::Machine<Context, EventType = int> Top-level alias hub; sets event ID type for all transitions
state tag (any struct) static on_enter / on_exit / on_update, each optional (defaults to no-op)
transition tag (any struct) static on_guard (default true) / on_action (default no-op), each optional
M::Transition<From, To, Evt, T, [Kind]> External transition; Evt must be EventType
M::InternalTransition<State, Evt, T> Internal transition (action only); Evt must be EventType
M::Root<Composite<...>, ...> Declares state hierarchy
M::Transitions<...> Declares event and automatic transition tables
M::Tcontroller<States, Transitions> Concrete machine type to instantiate
hfsm::Composite<State, Children...> Nests states; first child = default initial; namespace scope
M::AutomaticTransition<From, To, T, [Kind]> Guard-driven automatic transition; no event ID
hfsm::transition_kind normal / shallow / deep
Tcontroller::update() One execution tick
Tcontroller::on_event(EventType) Fill the single pending-event slot; returns false (event not stored) if already occupied — at most one event dispatched per update()
Tcontroller::is_active_s<T>() true if state type T is currently active (includes ancestors)
Tcontroller::is_current_s<T>() true if T is the active leaf state
Tcontroller::get_current_state() Returns the active leaf state's integer ID
Tcontroller::save(sink) Hands the snapshot to sink(int current, const int* history, std::size_t count); you persist it
Tcontroller::restore(current, history, count) Restore position only (no callbacks); false if out of range / wrong count
Tcontroller::restore_cascade(current, history, count) Restore and replay on_enter, root → leaf; false if invalid
Tcontroller::layout_digest() std::uint32_t fingerprint of the structural tables, for your own integrity check
hfsm::annotations::get_names<States>() constexpr std::array<const char*, Size> of state type names, indexed by state ID (tools/hfsm_annotations.h)
HFSM_ACTION(name) / HFSM_GUARD(name) Annotate a transition's action/guard with a diagram label (tools/hfsm_annotations.h)
hfsm::diagram::to_plantuml<States, Transitions>([title]) Generate a PlantUML state-diagram string (tools/hfsm_generator.h)
hfsm::diagram::to_yaml<States, Transitions>([name]) Generate a YAML machine description for external diagram tools (tools/hfsm_generator.h)
hfsm::structure::validate<FSM>() Compile-time structural checks: duplicate triggers, auto self-loops (tools/hfsm_structure.h)
hfsm::log::Controller<Context, EventType, States, Transitions> Drop-in Tcontroller that logs every enter/exit/update/guard/action to a Context-carried sink (tools/hfsm_logger.h)
hfsm::log::sink_access<Ctx> Customization point for locating the sink; defaults to ctx.logger (tools/hfsm_logger.h)
hfsm::log::EmptyLogger No-op sink with empty on_* methods (tools/hfsm_logger.h)

Building

The library is header-only. For the tests:

cmake -B build
cmake --build build
ctest --test-dir build

Tests require C++20. The library itself requires only C++17. The tools/ headers additionally need the vendored nameof / magic_enum.


License

MIT — see LICENSE.

About

Header-only, no-allocation, tick-based HFSM for C++17/20. Designed as a foundation for embedded control systems where allocation is prohibited, exceptions are off, and execution order must be deterministic per clock tick.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages