Arduino Tutorial: State Machine with Direct Hardware Control

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:

  1. Quick press → Light turns on for 10 seconds, then auto-off (useful for briefly checking something)
  2. Press again while lit → "Actually, keep it on" - upgrades to permanent mode
  3. 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
xml
<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:

xml
<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:

  1. First press: "Turn on" (with safety timeout)
  2. Second press: "Keep it on" (user is committed)
  3. 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:

xml
<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):

xml
<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
<?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:

bash
# 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 constants
  • LightSwitch.c - Implementation
note

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

c
// 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

c
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

cpp
// =============================================================================
// 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:

cpp
// 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:

xml
<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 &gt; 0" target="dimmed">
            <assign location="brightness" expr="brightness - 32"/>
        </transition>
    </state>
</scxml>

Key points:

  • In <script> blocks: use sm->data.brightness (explicit path)
  • In expr/cond attributes: use brightness (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:

cpp
#define SCXML_LOG_ENABLED 0

Minimize resources:

cpp
#define SCXML_MAX_TIMERS 2
#define SCXML_INTERNAL_QUEUE_SIZE 8

Key Takeaways

  1. <script> = Raw C Code - Embedded verbatim in generated code

  2. Hardware Constants as Macros - SCXML references them, Arduino defines them

  3. Eventless Transitions - No event attribute means "fire immediately"

  4. <send delay="..."> + <cancel> - Self-managing timers with proper cleanup

  5. Three-Function Executor - tick → timers → process_delayed

  6. Unidirectional Flow - Design state machines with clear, predictable transitions


Source Files


Next Steps

  • Add PWM dimming: Use analogWrite() with datamodel variables
  • Double-click detection: Use a detecting state with short timeout
  • Multiple LEDs: Add parallel states
  • Sensor input: Read analogRead() in transition conditions