Desktop Tutorial: State Machine with ECMAScript Datamodel
This tutorial shows how to build the same light switch state machine for Linux and Windows using:
- ECMAScript datamodel - JavaScript expressions evaluated at runtime
- CMake - Cross-platform build system
- C++ project - Modern C++ wrapper around generated C code
- JerryScript - Lightweight JavaScript engine for embedded/IoT
Arduino vs Desktop: Key Differences
| Aspect | Arduino (Bare Metal) | Desktop (Linux/Windows) |
|---|---|---|
| Datamodel | native-java (compiled to C) |
ecmascript (runtime evaluation) |
| Timers | Polling in main loop | Background threads |
| Expressions | Transpiled to C code | Evaluated by JerryScript |
| Memory | ~2KB RAM | ~512KB heap for JerryScript |
| Build | Arduino IDE | CMake |
Why ECMAScript on Desktop?
- Runtime flexibility - Change expressions without recompiling
- Rich syntax - Full JavaScript for complex logic
- Dynamic typing - No need to declare types
- Debugging - Expressions visible in logs
The State Machine
Same design as Arduino - intuitive unidirectional flow:
off ──button──► timed_on ──button──► permanent_on ──button──► off
│
└──timeout (10s)──► off
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="ecmascript"
initial="off"
name="LightSwitch">
<datamodel>
<!-- JavaScript variables - stored in JerryScript engine -->
<data id="led_on" expr="false"/>
<data id="press_count" expr="0"/>
<data id="timeout_seconds" expr="10"/>
</datamodel>
<!-- OFF: LED is off -->
<state id="off">
<onentry>
<assign location="led_on" expr="false"/>
<log label="STATE" expr="'OFF (press count: ' + press_count + ')'"/>
</onentry>
<transition event="button" target="timed_on">
<!-- JavaScript expression - increment counter -->
<assign location="press_count" expr="press_count + 1"/>
</transition>
</state>
<!-- TIMED_ON: LED on with auto-off timer -->
<state id="timed_on">
<onentry>
<assign location="led_on" expr="true"/>
<log label="STATE" expr="'TIMED_ON (' + timeout_seconds + 's auto-off)'"/>
<!-- Dynamic delay using JavaScript expression -->
<send id="auto_off" event="timeout" delayexpr="timeout_seconds + 's'"/>
</onentry>
<onexit>
<cancel sendid="auto_off"/>
</onexit>
<transition event="button" target="permanent_on">
<assign location="press_count" expr="press_count + 1"/>
</transition>
<transition event="timeout" target="off"/>
</state>
<!-- PERMANENT_ON: LED on permanently -->
<state id="permanent_on">
<onentry>
<assign location="led_on" expr="true"/>
<log label="STATE" expr="'PERMANENT_ON'"/>
</onentry>
<transition event="button" target="off">
<assign location="press_count" expr="press_count + 1"/>
</transition>
</state>
</scxml>
ECMAScript Features Demonstrated
1. JavaScript Variables
<data id="press_count" expr="0"/>
Variables are stored in JerryScript's heap, not C structs.
2. String Concatenation
<log label="STATE" expr="'OFF (press count: ' + press_count + ')'"/>
Full JavaScript string operations available.
3. Dynamic Delay Expression
<send id="auto_off" event="timeout" delayexpr="timeout_seconds + 's'"/>
The delayexpr attribute evaluates a JavaScript expression at runtime.
4. Runtime Expression Evaluation
All expr and cond attributes are evaluated by JerryScript, not compiled to C.
Part 2: Generate C Code
The generator creates a complete, ready-to-build project by default:
scxml-gen light_switch.scxml --target c -o LightSwitch.c
Generated files:
LightSwitch.h- Header with APILightSwitch.c- Implementationscxml_runtime.h- Single-header runtime (amalgamated)CMakeLists.txt- Build configurationmain.c- Sample application
For existing projects, use --code-only to generate just the state machine:
scxml-gen light_switch.scxml --target c --code-only -o src/LightSwitch.c
Generated Code Differences (ECMAScript vs Native)
ECMAScript version uses runtime evaluation:
static void LightSwitch_enter_S_OFF(LightSwitch* sm) {
SCXML_BIT_SET(LIGHTSWITCH_S_OFF, sm->configuration);
// Variable assignment via JerryScript
sm->datamodel_ops->assign(sm->datamodel_ctx, "led_on", "false");
// Log with expression evaluated at runtime
char _log_buf[256];
sm->datamodel_ops->eval_string(sm->datamodel_ctx,
"'OFF (press count: ' + press_count + ')'",
_log_buf, sizeof(_log_buf));
SCXML_LOG("STATE", _log_buf);
}
Compare to the Arduino version (using datamodel="native-java"):
// Native datamodel: expressions become direct C code (no runtime evaluation)
sm->data.led_on = false;
SCXML_LOG("STATE", "OFF");
Note on naming: The datamodel is called
native-javabecause it uses Java-like expression syntax in the SCXML file. But when using--target c, those expressions are compiled to C code, not Java. There is no runtime interpreter.
Part 3: Project Structure
examples/desktop/
├── light_switch.scxml # State machine definition
├── CMakeLists.txt # Build configuration
├── main.cpp # C++ application
└── generated/
├── LightSwitch.h # Generated header
└── LightSwitch.c # Generated implementation
Part 4: CMakeLists.txt
cmake_minimum_required(VERSION 3.16)
project(light_switch_demo VERSION 1.0 LANGUAGES C CXX)
set(CMAKE_C_STANDARD 11)
set(CMAKE_CXX_STANDARD 17)
# Fetch JerryScript v3.0.0
include(FetchContent)
FetchContent_Declare(jerryscript
GIT_REPOSITORY https://github.com/jerryscript-project/jerryscript.git
GIT_TAG v3.0.0
GIT_SHALLOW TRUE
)
# Configure JerryScript (512KB heap is sufficient for a single state machine)
set(JERRY_GLOBAL_HEAP_SIZE "(512)" CACHE STRING "" FORCE)
FetchContent_MakeAvailable(jerryscript)
# SCXML Runtime with ECMAScript support
set(SCXML_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../../scxml-transpiled-c")
add_library(scxml_runtime STATIC
${SCXML_ROOT}/src/datamodel_ecmascript.c
${SCXML_ROOT}/src/datamodel_null.c
${SCXML_ROOT}/src/scxml_trace.c
${SCXML_ROOT}/src/scxml_xml_dom.c
)
target_include_directories(scxml_runtime PUBLIC ${SCXML_ROOT}/include)
target_link_libraries(scxml_runtime PUBLIC jerry-core jerry-port)
target_compile_definitions(scxml_runtime PUBLIC SCXML_USE_JERRYSCRIPT=1)
# Platform threading
if(NOT WIN32)
find_package(Threads REQUIRED)
target_link_libraries(scxml_runtime PUBLIC Threads::Threads)
endif()
# Generated state machine
add_library(light_switch_sm STATIC generated/LightSwitch.c)
target_include_directories(light_switch_sm PUBLIC generated)
target_link_libraries(light_switch_sm PUBLIC scxml_runtime)
# Main application
add_executable(light_switch main.cpp)
target_link_libraries(light_switch PRIVATE light_switch_sm)
Part 5: C++ Application (main.cpp)
#include <iostream>
#include <string>
#include <thread>
#include <atomic>
#include <chrono>
extern "C" {
#include "scxml/scxml_transpiled.h"
#include "scxml/scxml_datamodel.h"
#include "LightSwitch.h"
}
std::atomic<bool> running{true};
LightSwitch sm;
// LED visualization
void print_led_state() {
std::cout << "\n+-------------------------------------+\n";
if (LightSwitch_is_in(&sm, LIGHTSWITCH_S_OFF)) {
std::cout << "| LED: [ OFF ] |\n";
std::cout << "| State: OFF |\n";
} else if (LightSwitch_is_in(&sm, LIGHTSWITCH_S_TIMED_ON)) {
std::cout << "| LED: [*ON*] |\n";
std::cout << "| State: TIMED_ON (auto-off 10s) |\n";
} else if (LightSwitch_is_in(&sm, LIGHTSWITCH_S_PERMANENT_ON)) {
std::cout << "| LED: [*ON*] |\n";
std::cout << "| State: PERMANENT_ON |\n";
}
std::cout << "| [Enter] = button [q] = quit |\n";
std::cout << "+-------------------------------------+\n> ";
}
// Background thread processes timer events
void event_processor_thread() {
int last_state = -1;
while (running) {
// Process delayed events (timer callbacks queue events here)
LightSwitch_process_delayed(&sm);
// Check for state change
int current = LightSwitch_is_in(&sm, LIGHTSWITCH_S_OFF) ? 0 :
LightSwitch_is_in(&sm, LIGHTSWITCH_S_TIMED_ON) ? 1 : 2;
if (current != last_state) {
last_state = current;
print_led_state();
}
std::this_thread::sleep_for(std::chrono::milliseconds(50));
}
}
int main() {
std::cout << "Light Switch Demo (ECMAScript Datamodel)\n\n";
// Initialize state machine (creates JerryScript context)
LightSwitch_init(&sm);
LightSwitch_start(&sm);
print_led_state();
// Start background event processor
std::thread processor(event_processor_thread);
// Input loop
std::string input;
while (running) {
std::getline(std::cin, input);
if (input == "q") { running = false; break; }
std::cout << ">> Button pressed!\n";
LightSwitch_send(&sm, LIGHTSWITCH_EVT_BUTTON, nullptr);
}
processor.join();
LightSwitch_destroy(&sm);
return 0;
}
Part 6: How Desktop Timers Work
Unlike Arduino's polling approach, desktop platforms use background threads:
┌─────────────────────────────────────────────────────────────────┐
│ Main Thread │
├─────────────────────────────────────────────────────────────────┤
│ 1. LightSwitch_send(BUTTON) │
│ 2. State machine enters timed_on │
│ 3. <send delay="10s"> creates timer │
│ └──► Spawns background thread │
│ 4. Main thread continues (non-blocking) │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Timer Thread (Background) │
├─────────────────────────────────────────────────────────────────┤
│ 1. Sleep for 10 seconds │
│ 2. Wake up, call LightSwitch_send(TIMEOUT) │
│ 3. Event queued to state machine │
│ 4. Thread exits │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ Event Processor Thread │
├─────────────────────────────────────────────────────────────────┤
│ while (running) { │
│ LightSwitch_process_delayed(&sm); // Handle queued events │
│ sleep(50ms); │
│ } │
└─────────────────────────────────────────────────────────────────┘
No Polling Required!
On desktop, you don't need:
// NOT needed on desktop:
scxml_bare_metal_tick(elapsed);
scxml_timers_process();
The platform headers (platform_posix.h / platform_windows.h) spawn threads for timers automatically.
You only need:
LightSwitch_process_delayed(&sm); // Process queued events
Part 7: Build and Run
Linux
# Generate C code
scxml-gen light_switch.scxml --target c -o generated/LightSwitch.c
# Build
cmake -B build
cmake --build build
# Run
./build/light_switch
Windows (Visual Studio)
# Generate C code
scxml-gen light_switch.scxml --target c -o generated/LightSwitch.c
# Build
cmake -B build -G "Visual Studio 17 2022"
cmake --build build --config Release
# Run
.\build\Release\light_switch.exe
Windows (MinGW)
cmake -B build -G "MinGW Makefiles"
cmake --build build
./build/light_switch.exe
Part 8: User Interaction
Unlike Arduino with a physical button, the desktop demo uses keyboard input:
| Key | Action |
|---|---|
| Enter | Send button event (simulates button press) |
| q + Enter | Quit the program |
The input loop is simple:
std::string input;
while (running) {
std::getline(std::cin, input);
if (input == "q") break;
// Any input (including just Enter) = button press
LightSwitch_send(&sm, LIGHTSWITCH_EVT_BUTTON, nullptr);
}
Part 9: Expected Output
Light Switch Demo (ECMAScript Datamodel)
+-------------------------------------+
| LED: [ OFF ] |
| State: OFF |
| [Enter] = button [q] = quit |
+-------------------------------------+
>
>> Button pressed!
+-------------------------------------+
| LED: [*ON*] |
| State: TIMED_ON (auto-off 10s) |
| [Enter] = button [q] = quit |
+-------------------------------------+
>
Wait 10 seconds without pressing:
+-------------------------------------+
| LED: [ OFF ] |
| State: OFF |
| [Enter] = button [q] = quit |
+-------------------------------------+
>
Part 10: How Events Are Raised
Events can come from three sources:
1. External Code (C++ Application)
// Send event by ID (fastest)
LightSwitch_send(&sm, LIGHTSWITCH_EVT_BUTTON, nullptr);
// Send event by name (for dynamic events)
LightSwitch_send_name(&sm, "button", nullptr);
2. SCXML <raise> - Internal Events
<onentry>
<raise event="internal_check"/>
</onentry>
Internal events are processed immediately in the current macrostep (before external events).
3. SCXML <send> - External Events
<!-- Immediate external event -->
<send event="done"/>
<!-- Delayed event (creates timer) -->
<send event="timeout" delay="10s"/>
<!-- Dynamic delay -->
<send event="timeout" delayexpr="timeout_seconds + 's'"/>
External events are queued and processed in the next macrostep.
Event Processing Order (W3C Compliant)
1. Process all internal events (from <raise>)
2. Check eventless transitions
3. Process ONE external event
4. Repeat
Part 11: ECMAScript vs Native - When to Use Which
| Use ECMAScript When | Use Native When |
|---|---|
| Desktop/server applications | Embedded/microcontrollers |
| Complex business logic | Minimal footprint needed |
| Runtime configuration needed | Maximum performance |
| Rapid prototyping | No dynamic memory available |
| Full JavaScript syntax needed | Compile-time optimization |
Memory comparison:
- ECMAScript: ~512KB heap for JerryScript (configurable)
- Native: ~500 bytes RAM total
Part 12: Accessing Variables from C++
ECMAScript variables live in JerryScript. To access them from C++:
// Read a variable
char buffer[64];
sm.datamodel_ops->eval_string(sm.datamodel_ctx, "press_count", buffer, sizeof(buffer));
int count = std::stoi(buffer);
// Modify a variable
sm.datamodel_ops->assign(sm.datamodel_ctx, "timeout_seconds", "30");
For native datamodel (Arduino), variables are direct C struct fields:
int count = sm.data.press_count; // Direct access
sm.data.timeout_seconds = 30; // Direct modification
Key Takeaways
datamodel="ecmascript"- Expressions evaluated by JerryScript at runtimeBackground threads for timers - No polling needed on desktop
CMake + FetchContent - Automatic JerryScript download and build
process_delayed()only - No tick/timers_process on desktopSame SCXML, different datamodel - Switch between native and ECMAScript by changing one attribute
Source Files
- SCXML:
examples/desktop/light_switch.scxml - CMake:
examples/desktop/CMakeLists.txt - C++ Main:
examples/desktop/main.cpp
Next Steps
- Add complex expressions: Use JavaScript objects, arrays, string methods
- Runtime reconfiguration: Modify
timeout_secondsvia UI - Multiple machines: Run several state machines with shared JerryScript context
- Invoke child machines: Use
<invoke>for hierarchical state machines