All posts
esp32arduinoarchitecture

PulseHSM v2.0.0 — I Moved the State Table to Compile Time

· 5 min read Beginner NEW

What PulseHSM does

Most embedded firmware has state machines. You have states, transitions between them, things that happen on entry and exit, events you respond to, timeouts. Writing this by hand is repetitive and error-prone, especially when you need hierarchy where unhandled events bubble up to a parent state.

PulseHSM handles that structure. You define the states and their relationships, and the library takes care of: entering and exiting states in the right order, running entry and exit callbacks, managing timeouts, bubbling events up the hierarchy when a state does not handle them. The event queue is a fixed-size ring buffer and sendEvent() is ISR-safe, so you can call it from a UART interrupt without disabling interrupts in your main loop.


The problem with v1

In the first version, you built the state table at runtime by calling addState() from setup():

PulseHSM fsm;

void setup() {
  fsm.addState(ST_IDLE,    nullptr, idle_entry,    nullptr,      idle_event,    0, -1, -1);
  fsm.addState(ST_RUNNING, nullptr, running_entry, running_exit, running_event, 0, -1, -1);
  fsm.addState(ST_FAULT,   nullptr, fault_entry,   nullptr,      fault_event,   0, -1, -1);
  fsm.setInitial(ST_MACHINE, ST_IDLE);
  fsm.begin(ST_MACHINE);
}

This worked, but two things were off.

First, the table lived in RAM. On an AVR there’s only 2 KB of SRAM total. The state table never changes at runtime — it is just configuration. There is no reason it should sit in RAM instead of flash, but with addState() there was no clean way to put it in PROGMEM automatically.

Second, the compiler could not check anything. If you wrote the wrong parent index, or pointed setInitial() at a state that was not actually a child of the parent, nothing would catch this at compile time. You would find out at runtime, maybe in an obvious way, maybe not.


What changed in v2

Now the state table is a constexpr array with designated initializers:

#include "PulseHSM.h"

enum StateID : int8_t { ST_IDLE = 0, ST_RUNNING, ST_FAULT, ST_COUNT };

// Forward-declare callbacks that reference fsm
bool idle_event(uint8_t e);
bool running_event(uint8_t e);
bool fault_event(uint8_t e);
void idle_entry(); void running_entry(); void running_exit(); void fault_entry();

//                                              name            upd   entry          exit           ms  next  event          parent  initChild
constexpr PulseHSM::StaticState TABLE[ST_COUNT] PULSEHSM_TABLE = {
    [ST_IDLE]    = { PULSEHSM_NAME("IDLE"),    nullptr, idle_entry,    nullptr,       0,  -1,  idle_event,    -1,  -1 },
    [ST_RUNNING] = { PULSEHSM_NAME("RUNNING"), nullptr, running_entry, running_exit,  0,  -1,  running_event, -1,  -1 },
    [ST_FAULT]   = { PULSEHSM_NAME("FAULT"),   nullptr, fault_entry,   nullptr,       0,  -1,  fault_event,   -1,  -1 },
};
PULSEHSM_VALIDATE_TABLE(TABLE, ST_COUNT);

PulseHSM fsm(TABLE, ST_COUNT);

The PULSEHSM_TABLE macro expands to PROGMEM on AVR and to nothing on other platforms. So on AVR the table goes into flash automatically, you do not need to do anything special.

PULSEHSM_VALIDATE_TABLE runs static_assert checks on the table before compilation finishes:

  • State count matches the enum
  • All parent indices are in range
  • All initialChild fields point to direct children of that state
  • All timeoutNext fields point to valid state indices

If something is wrong, you get a compile error with a message telling you what failed. The device never sees bad configuration.


Hierarchy and initialChild

In v1 you called setInitial(parent, child) to set the default substate. In v2 this is the initialChild field, the last column in each row. Here is an example with hierarchy:

enum StateID : int8_t {
    ST_OPERATIONAL = 0, ST_OUT_OF_SERVICE,
    ST_IDLE, ST_HAS_MONEY, ST_DISPENSING,
    ST_SELECTING, ST_CONFIRMED,
    ST_COUNT
};

constexpr PulseHSM::StaticState TABLE[ST_COUNT] PULSEHSM_TABLE = {
    //                                                            parent           initChild
    [ST_OPERATIONAL]    = { PULSEHSM_NAME("OPERATIONAL"), ...,   -1,              ST_IDLE      },
    [ST_OUT_OF_SERVICE] = { PULSEHSM_NAME("OOS"),         ...,   -1,              -1           },
    [ST_IDLE]           = { PULSEHSM_NAME("IDLE"),         ...,  ST_OPERATIONAL,  -1           },
    [ST_HAS_MONEY]      = { PULSEHSM_NAME("HAS_MONEY"),   ...,   ST_OPERATIONAL,  ST_SELECTING },
    [ST_DISPENSING]     = { PULSEHSM_NAME("DISPENSING"),  ...,   ST_OPERATIONAL,  -1           },
    [ST_SELECTING]      = { PULSEHSM_NAME("SELECTING"),   ...,   ST_HAS_MONEY,    -1           },
    [ST_CONFIRMED]      = { PULSEHSM_NAME("CONFIRMED"),   ...,   ST_HAS_MONEY,    -1           },
};
PULSEHSM_VALIDATE_TABLE(TABLE, ST_COUNT);

When you call fsm.begin(ST_OPERATIONAL), the library follows the initialChild chain: ST_OPERATIONALST_IDLE. When you transitionTo(ST_HAS_MONEY), it resolves to ST_SELECTING automatically. No manual tracking needed.

Note on forward declarations: The callbacks (idle_entry, idle_event, etc.) reference fsm, and fsm is defined after the table. So you must forward-declare the callbacks before the table, then implement them after fsm is defined. The examples in the docs show this pattern.


New: getDroppedEvents()

The event queue is a fixed-size ring buffer. If events arrive from an ISR faster than update() drains them, the oldest events are silently dropped. In v1 this was not visible. Now there is a saturating counter:

void loop() {
  uint8_t dropped = fsm.getDroppedEvents();
  if (dropped > 0) {
    Serial.print("Warning — dropped events: ");
    Serial.println(dropped);
  }
  fsm.update();
}

The counter stops at 255 and does not wrap around. If you see it increment, increase the queue size by defining PULSEHSM_MAX_EVENTS before the include. The default is 8. For a UART parser at 115200 baud, 32 is a safer choice.


Migrating from v1

The API changed completely. Here is the short version:

  1. Add an enum StateID : int8_t { ... ST_COUNT } for your states
  2. Replace each addState() call with one row in a constexpr table
  3. Move setInitial(parent, child) → set initialChild field (last column) in the parent row
  4. Remove any #define PULSEHSM_MAX_STATES — no longer needed
  5. Add PULSEHSM_VALIDATE_TABLE(TABLE, ST_COUNT) after the table
  6. Change the constructor: PulseHSM fsm(TABLE, ST_COUNT)

The full migration guide with before/after examples is in the changelog in the docs.


Install

Arduino Library Manager: search for PulseHSM and install version 2.0.0.

PlatformIO:

lib_deps = pulsecoreengineering/PulseHSM@^2.0.0

Why a breaking change

I know breaking changes are not always welcome. But keeping addState() as a compatibility layer would have made the design worse, it implies a mutable table, which is the opposite of what we now have.

If you are on v1 and cannot migrate now, the v1 branch is preserved at tag v1.2.1. It will not get new features, but critical bug fixes I will still look at.

If you do migrate, you get compile-time safety, flash storage on AVR, and the dropped-events counter. I think the migration cost is worth it for most projects.

If you find something broken, or the docs are missing something, open an issue. I read all of them.

Related posts

esp32arduinoarchitecture
Jul 27, 2026
esp32arduinoarchitecture
Jun 18, 2026
esp32architecture
Jun 19, 2026

Comments

Enjoyed this tutorial?

Get new ESP32, Arduino, and industrial IoT tutorials straight to your inbox — no spam, unsubscribe anytime.