Arduino Tutorial: State Machine with Direct Hardware Control
This tutorial shows how to build a state machine that directly controls hardware from within SCXML. No glue code needed - the state machine itself calls digitalWrite(), pinMode(), and other Arduino functions.
What Makes This Different
Traditional approach:
SCXML → generates events → Arduino code checks state → controls hardware
Our approach:
SCXML → directly controls hardware via <script> blocks
The state machine is self-contained. When it enters a state, it executes the hardware commands immediately.
The Use Case: A Practical Light Switch
Think about how a well-designed light switch should work:
- Quick press → Light turns on for 10 seconds, then auto-off (useful for briefly checking something)
- Press again while lit → "Actually, keep it on" - upgrades to permanent mode
- Press in permanent mode → Turn off
This is intuitive UX. Let's model it as a state machine.
Understanding the State Machine
State Diagram
┌─────────────────────────────────────┐
│ │
▼ │
┌──────┐ ┌──────┐ button ┌──────────┐ button │┌──────────────┐
│ init │─────────────►│ off │──────────►│ timed_on │─────────►││ permanent_on │
└──────┘ └──────┘ └──────────┘ │└──────────────┘
pinMode() LED LOW LED HIGH │ LED HIGH
▲ + timer │
│ │ │
│ timeout │ │
│ (10s) │ │
└──────────────────┘ │
│ │
└─────────────────────────────────────┘
button
States Explained
| State | LED | What's Happening | On Button Press |
|---|---|---|---|
init |
- | Configure GPIO pin | (auto-transitions to off) |
off |
LOW | Waiting for user | → timed_on |
timed_on |
HIGH | Timer running (10s) | → permanent_on |
permanent_on |
HIGH | Waiting for user | → off |
Transitions Explained
A transition is an arrow between states. Each transition has:
- Source state: Where we're coming from
- Event (optional): What triggers the transition
- Target state: Where we're going to
<transition event="button" target="timed_on"/>
This reads as: "When the button event occurs, go to the timed_on state."
Eventless Transitions
The init state has a special transition:
<transition target="off"/>
No event attribute! This is an eventless transition - it fires immediately after the state's onentry actions complete. It's perfect for initialization states that do setup work and then move on.
Why This Design?
Single-direction flow with purpose:
off ──button──► timed_on ──button──► permanent_on ──button──► off
│
└──timeout──► off
Each button press has a clear meaning:
- First press: "Turn on" (with safety timeout)
- Second press: "Keep it on" (user is committed)
- Third press: "Turn off"
No confusing bidirectional arrows. The user always knows what their next button press will do.
The Timer Lifecycle
When entering timed_on:
<onentry>
<send id="auto_off" event="timeout" delay="10s"/>
</onentry>
This schedules a timeout event to fire in 10 seconds.
When leaving timed_on (for ANY reason):
<onexit>
<cancel sendid="auto_off"/>
</onexit>
This cancels the pending timer. Why is this important?
Scenario: User presses button, enters timed_on. After 3 seconds, presses again to enter permanent_on. Without the cancel, the timeout would still fire 7 seconds later - but there's no transition for timeout in permanent_on, so it would be ignored. Harmless, but wasteful.
With <cancel>, we clean up properly. The timer is removed from the pool, freeing resources.
Part 1: The SCXML Definition
<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml"
version="1.0"
datamodel="native-java"
initial="init"
name="LightSwitch">
<!-- Hardware initialization -->
<state id="init">
<onentry>
<script>
pinMode(LED_PIN, OUTPUT);
</script>
</onentry>
<transition target="off"/>
</state>
<!-- OFF: LED is off, waiting for button press -->
<state id="off">
<onentry>
<script>
digitalWrite(LED_PIN, LOW);
</script>
<log label="STATE" expr="'OFF'"/>
</onentry>
<!-- Single press: turn on with timer -->
<transition event="button" target="timed_on"/>
</state>
<!-- TIMED_ON: LED on with auto-off timer running -->
<state id="timed_on">
<onentry>
<script>
digitalWrite(LED_PIN, HIGH);
</script>
<log label="STATE" expr="'TIMED_ON (10s)'"/>
<!-- Start the auto-off timer -->
<send id="auto_off" event="timeout" delay="10s"/>
</onentry>
<onexit>
<!-- Always cancel timer when leaving this state -->
<cancel sendid="auto_off"/>
</onexit>
<!-- Press again: upgrade to permanent mode -->
<transition event="button" target="permanent_on"/>
<!-- Timer expired: turn off -->
<transition event="timeout" target="off"/>
</state>
<!-- PERMANENT_ON: LED on until manually turned off -->
<state id="permanent_on">
<onentry>
<script>
digitalWrite(LED_PIN, HIGH);
</script>
<log label="STATE" expr="'PERMANENT_ON'"/>
</onentry>
<!-- Press: turn off -->
<transition event="button" target="off"/>
</state>
</scxml>
SCXML Elements Reference
| Element | Purpose |
|---|---|
<scxml> |
Root element. initial sets the starting state. |
<state id="..."> |
A state. Must have unique ID. |
<onentry> |
Actions to run when entering the state. |
<onexit> |
Actions to run when leaving the state. |
<transition> |
Defines how to move between states. |
<script> |
Raw C code embedded in generated output. |
<log> |
Debug output (calls SCXML_LOG macro). |
<send> |
Schedule an event (optionally with delay). |
<cancel> |
Cancel a previously scheduled delayed event. |
Part 2: Generate C Code
For Arduino projects, use --code-only to generate just the state machine files without project scaffolding:
# Generate just the state machine code (no CMakeLists.txt, main.c, etc.)
scxml-gen light_switch.scxml --target c --code-only -o LightSwitch.c
Output:
LightSwitch.h- Header with API and constantsLightSwitch.c- Implementation
Without --code-only, the generator creates a complete CMake project with main.c and scxml_runtime.h. For Arduino, you only need the state machine files.
Generated Constants
// States (namespace_S_statename pattern)
#define LIGHTSWITCH_S_INIT 0
#define LIGHTSWITCH_S_OFF 1
#define LIGHTSWITCH_S_TIMED_ON 2
#define LIGHTSWITCH_S_PERMANENT_ON 3
// Events (namespace_EVT_eventname pattern)
#define LIGHTSWITCH_EVT_BUTTON 1
#define LIGHTSWITCH_EVT_TIMEOUT 2
Generated State Entry Function
static void LightSwitch_enter_S_TIMED_ON(LightSwitch* sm) {
SCXML_BIT_SET(LIGHTSWITCH_S_TIMED_ON, sm->configuration);
/* Native C script: */
digitalWrite(LED_PIN, HIGH);
SCXML_LOG("STATE", "TIMED_ON (10s)");
/* <send id="auto_off" event="timeout" delay="10s"/> */
uint32_t _delay_ms = 10000;
ScxmlTimer* _timer = scxml_timer_create(
sm, _delay_ms, LIGHTSWITCH_EVT_TIMEOUT, NULL,
(void(*)(void*,int,void*))LightSwitch_send
);
scxml_timer_register(&sm->timer_registry, "auto_off", _timer);
}
Part 3: Arduino Integration
Project Files
light_switch/
├── light_switch.ino # Arduino sketch
├── LightSwitch.h # Generated
├── LightSwitch.cpp # Generated (rename from .c)
└── scxml_transpiled.h # Runtime (copy from scxml-gen)
The Arduino Sketch
// =============================================================================
// PLATFORM CONFIGURATION (before any includes!)
// =============================================================================
#define SCXML_PLATFORM_BARE_METAL
#define SCXML_STATIC_ALLOCATION 1
#define SCXML_MAX_TIMERS 4
#define SCXML_LOG_ENABLED 1
// Custom logging to Serial
#define SCXML_LOG(label, msg, ...) \
do { Serial.print("["); Serial.print(label); Serial.print("] "); \
Serial.println(msg); } while(0)
// =============================================================================
// HARDWARE CONSTANTS (referenced by SCXML <script> blocks)
// =============================================================================
#define LED_PIN 13
#define BUTTON_PIN 2
// Arduino constants for generated code
#ifndef OUTPUT
#define OUTPUT 1
#endif
#ifndef HIGH
#define HIGH 1
#endif
#ifndef LOW
#define LOW 0
#endif
// =============================================================================
// INCLUDES
// =============================================================================
#include "scxml_transpiled.h"
#include "LightSwitch.h"
// =============================================================================
// STATE MACHINE INSTANCE
// =============================================================================
LightSwitch sm;
// =============================================================================
// TIME MANAGEMENT
// =============================================================================
unsigned long lastTick = 0;
// =============================================================================
// BUTTON DEBOUNCING
// =============================================================================
unsigned long lastDebounce = 0;
const unsigned long DEBOUNCE_MS = 50;
int lastReading = HIGH;
int buttonState = HIGH;
// =============================================================================
// SETUP
// =============================================================================
void setup() {
Serial.begin(9600);
while (!Serial) { }
pinMode(BUTTON_PIN, INPUT_PULLUP);
Serial.println(F("=== Light Switch State Machine ==="));
Serial.println(F("Press once: Timed ON (10s auto-off)"));
Serial.println(F("Press again: Permanent ON"));
Serial.println(F("Press again: OFF"));
Serial.println();
// Initialize and start state machine
// This runs: init.onentry (pinMode) -> off.onentry (LED LOW)
LightSwitch_init(&sm);
LightSwitch_start(&sm);
lastTick = millis();
}
// =============================================================================
// MAIN LOOP
// =============================================================================
void loop() {
// 1. Advance state machine time
unsigned long now = millis();
if (now > lastTick) {
scxml_bare_metal_tick(now - lastTick);
lastTick = now;
}
// 2. Fire expired timers
scxml_timers_process();
// 3. Process queued events
LightSwitch_process_delayed(&sm);
// 4. Handle button input
if (buttonPressed()) {
Serial.println(F(">> BUTTON"));
LightSwitch_send(&sm, LIGHTSWITCH_EVT_BUTTON, NULL);
}
}
// =============================================================================
// DEBOUNCED BUTTON
// =============================================================================
bool buttonPressed() {
int reading = digitalRead(BUTTON_PIN);
bool pressed = false;
if (reading != lastReading) {
lastDebounce = millis();
}
if ((millis() - lastDebounce) > DEBOUNCE_MS) {
if (reading != buttonState) {
buttonState = reading;
if (buttonState == LOW) {
pressed = true;
}
}
}
lastReading = reading;
return pressed;
}
Part 4: Execution Walkthrough
Let's trace through exactly what happens:
Startup
1. setup() calls LightSwitch_start(&sm)
2. State machine enters 'init'
3. init.onentry runs: pinMode(LED_PIN, OUTPUT)
4. Eventless transition fires immediately
5. State machine exits 'init', enters 'off'
6. off.onentry runs: digitalWrite(LED_PIN, LOW)
7. State machine is now stable in 'off', waiting for events
First Button Press
1. User presses button
2. loop() calls LightSwitch_send(&sm, BUTTON, NULL)
3. Transition: off --[button]--> timed_on
4. off.onexit runs: (nothing defined)
5. State machine enters 'timed_on'
6. timed_on.onentry runs:
- digitalWrite(LED_PIN, HIGH) ← LED turns ON
- SCXML_LOG("STATE", "TIMED_ON (10s)")
- scxml_timer_create(10000ms, TIMEOUT) ← Timer starts
7. State machine is now in 'timed_on' with active timer
Scenario A: User Waits 10 Seconds
1. 10 seconds pass...
2. scxml_timers_process() detects timer expired
3. Timer callback queues TIMEOUT event
4. LightSwitch_process_delayed() processes event
5. Transition: timed_on --[timeout]--> off
6. timed_on.onexit runs: cancel("auto_off") ← Timer cleanup
7. State machine enters 'off'
8. off.onentry runs: digitalWrite(LED_PIN, LOW) ← LED turns OFF
Scenario B: User Presses Button Again (Within 10s)
1. User presses button at t=3s
2. loop() calls LightSwitch_send(&sm, BUTTON, NULL)
3. Transition: timed_on --[button]--> permanent_on
4. timed_on.onexit runs: cancel("auto_off") ← Timer cancelled!
5. State machine enters 'permanent_on'
6. permanent_on.onentry runs:
- digitalWrite(LED_PIN, HIGH) ← LED stays ON
- SCXML_LOG("STATE", "PERMANENT_ON")
7. No timer - light stays on indefinitely
Third Button Press (From Permanent)
1. User presses button
2. Transition: permanent_on --[button]--> off
3. permanent_on.onexit runs: (nothing defined)
4. State machine enters 'off'
5. off.onentry runs: digitalWrite(LED_PIN, LOW) ← LED turns OFF
Part 5: The Three-Function Executor
Every bare-metal main loop needs these three calls:
// 1. Advance the clock
scxml_bare_metal_tick(elapsed_ms);
// 2. Check and fire expired timers
scxml_timers_process();
// 3. Let state machine process queued events
LightSwitch_process_delayed(&sm);
Why Three Functions?
scxml_bare_metal_tick(ms) - Updates the internal clock. The runtime doesn't have access to millis(), so you tell it how much time has passed.
scxml_timers_process() - Checks all active timers. If any have expired, it fires their callbacks (which queue events to the state machine).
LightSwitch_process_delayed(&sm) - Processes events in the state machine's queue. This is where transitions actually happen for timer-generated events.
Why Not Just One Function?
Separation of concerns:
- Time tracking is global (all state machines share it)
- Timer processing is global (shared timer pool)
- Event processing is per-state-machine
You might have multiple state machines. They all share the same clock and timer pool, but each processes its own events.
Part 6: Using Datamodel Variables
For more complex examples, add runtime state:
<scxml datamodel="native-java" ...>
<datamodel>
<data id="press_count" type="int" expr="0"/>
<data id="brightness" type="int" expr="255"/>
</datamodel>
<state id="dimmed">
<onentry>
<script>
analogWrite(LED_PIN, sm->data.brightness);
</script>
<assign location="press_count" expr="press_count + 1"/>
</onentry>
<transition event="dim" cond="brightness > 0" target="dimmed">
<assign location="brightness" expr="brightness - 32"/>
</transition>
</state>
</scxml>
Key points:
- In
<script>blocks: usesm->data.brightness(explicit path) - In
expr/condattributes: usebrightness(auto-prefixed by generator)
Part 7: Memory Footprint
| Component | Flash | RAM |
|---|---|---|
| Generated state machine | ~1.5 KB | 64 bytes |
| Runtime header | ~500 bytes | 32 bytes |
| Timer pool (4 timers) | 64 bytes | 80 bytes |
| Event queues | 128 bytes | 256 bytes |
| Total | ~2.2 KB | ~430 bytes |
Fits easily on ATmega328P (32KB flash, 2KB RAM).
Part 8: Production Build
Disable logging:
#define SCXML_LOG_ENABLED 0
Minimize resources:
#define SCXML_MAX_TIMERS 2
#define SCXML_INTERNAL_QUEUE_SIZE 8
Key Takeaways
<script>= Raw C Code - Embedded verbatim in generated codeHardware Constants as Macros - SCXML references them, Arduino defines them
Eventless Transitions - No
eventattribute means "fire immediately"<send delay="...">+<cancel>- Self-managing timers with proper cleanupThree-Function Executor - tick → timers → process_delayed
Unidirectional Flow - Design state machines with clear, predictable transitions
Source Files
- SCXML:
examples/arduino/light_switch.scxml - Runtime:
scxml-transpiled-c/include/scxml/scxml_transpiled.h
Next Steps
- Add PWM dimming: Use
analogWrite()with datamodel variables - Double-click detection: Use a
detectingstate with short timeout - Multiple LEDs: Add parallel states
- Sensor input: Read
analogRead()in transition conditions