Etheras MicroScript (EMS)

EMS is a Domain-Specific Language (DSL) and embedded control runtime for event-driven automation on embedded controllers. Scripts are compiled at save time and executed by a fast runtime integrated with device services such as I/O, timers, communication protocols, and UI.

Field Guide & Wiki

Intended for non-safety-critical automation; not certified for life-safety systems.

Everything you need to write, schedule, and deploy Etheras MicroScript (EMS) programs on ESP32 controller firmware. The language is case-insensitive, supports both integers and floating-point numbers, and is designed for concise automation logic. This wiki documents the scripting language, runtime behavior, and the firmware services that make EMS scripts useful. It is written to help both human and AI developers build automation logic for ESP32-based controllers.

Overview

  • Etheras Microscript is an embedded automation language for ESP32-based controllers, meant to run inside the device firmware as lightweight logic.
  • Keywords and built-ins are lowercase and case-sensitive (example: temp()).
  • Integer and float runtime; non-zero is true, zero is false. String literals are only allowed as direct arguments to built-ins like log, publish, or notify.
  • Top-level statements run once at script load. Scheduled blocks (every, after, onmqtt, ontrigger) register tasks that execute later. macro defines reusable macros expanded inline, while func defines reusable runtime functions.
  • Comments: // line or /* block */ (no nested block comments).
  • Avoid naming variables after built-ins (e.g., do not use now as a variable when calling now()).
  • Inside macro macros, use _-prefixed locals (e.g., _bit) to avoid variable collisions across expansions.
  • Error reporting note: macro expansion changes line/col positions, so when macros are present the UI shows a near: ... snippet instead of strict line/col to help you locate the issue.
  • Avoid shared temporaries inside delay blocks; ensure guard/value expressions remain stable until the delay fires.
  • Scripts rely on underlying firmware services, including multithreaded execution, automatic updates, webserver hosting, MQTT and LoRaWAN connectivity, RS485 and Modbus protocols, and NVS flash access for persistence.
  • ThingsBoard is the main platform for provisioning, data ingestion, dashboards, and remote control of scripts and devices.
  • Keep scripts small (target < 5000 bytes). macro expansion repeats the body at each call, so large macros used many times can exceed memory limits during save/validation.

Syntax & Operators

Syntax note: commands use parentheses consistently (e.g., every(1000), load(name, default), publish(...), logn(...)). The only exception is output assignment, which remains out(index) = value.

Expressions

  • Arithmetic: + - * / % (division and modulo return 0 on divide-by-zero).
  • Comparison: == != < <= > >= (yield 1 or 0).
  • Logical: and/&&, xor/^^, or/||, not/!.
  • Bitwise: ~ << >> & | ^ (C++ precedence).
  • Conditional: cond ? expr1 : expr2 (C-style; lowest precedence).
  • Parentheses force evaluation order and are valid anywhere an expression is expected.

Built-in Calls in Expressions

Variables & Persistence

  • Assignment creates variables implicitly: counter = counter + 1. All vars start at 0 until assigned.
  • Variables can be integers or floats. When an integer is required (indices, timers, persistence), floats are rounded on load.
  • Arrays are declared at the top of the script with int_array and float_array (max 400 elements, max 10 arrays, total elements across arrays limited to 400).
  • For tight timing loops, pre-declare variables near the top (e.g., counter = 0) to avoid a one-time runtime allocation on first use.
  • load(name, default_expr pulls a stored value or uses default_expr when missing (does not save automatically).
  • )
  • save(name) writes the current value to non-volatile storage; call it whenever you intentionally update a persisted variable.
  • The parser expects a write (=, load, or for) before the first read of a variable.
// Persistent counter template
load(counter, 0);
every(1000) {
  counter = counter + 1;
  save(counter);
}

I/O & Side Effects

Digital & Analog

Actuators & Sensors

Environment

EMS Web Widgets

Control Flow

if (dry) {
  out(1)=0;
}
else{
  if (p<target) { out(1)=1; }
  else{ out(1)=0; }
}

for (i = 1; i <= 4; i = i + 1) {
  out(i) = in(i);
}

Scheduling

Timing note: with a 100 ms cycle the observed jitter is about 1%. For more deterministic timing, avoid every intervals below 100 ms.

every(5000, "status") {
  publish("plc/status", "temp", temp(), "pressure", ain(1));
}

onmqtt("force_status") {
  trigger("status");
}

Timers & Edges

// Dry-run alarm with retentive on-delay
if (ton("dry_run", w<DRY && pump, 10000, 1)) {
  publish("alarm","DRY_RUN");
}
if (p_edge(in(8)) && w>=DRY) { t_reset("dry_run"); }

MQTT Handlers

  • onmqtt("key") { ... } runs when firmware delivers a matching key. Use "*" to receive all messages.
  • Attribute payloads containing multiple _script entries are fanned out per entry before delivery.
  • If no key matches, no onmqtt block is executed.
  • Helper variables available inside handlers:
    • msg_is_on is 1 for true/on payloads, else 0.
    • msg_is_off is 1 for false/off payloads, else 0.
    • msg_is_number is 1 for numeric payloads, else 0.
    • msg_value is numeric: true/on -> 1, false/off -> 0, numeric payload -> parsed value. On parse failure, previous value is kept.
load(setpoint, 220);

onmqtt("plc_sp") {
  if (msg_is_number) {
    setpoint = msg_value;
    save(setpoint);
  }
}

onmqtt("plc_output") {
  if (msg_is_number) { out(1)= (msg_value != 0); }
}

Logging & Publishing

  • log(arg1, ...) concatenates arguments without a newline; logn(...) appends a newline.
  • publish("topic", arg1, ...) sends a variable/value payload to the firmware:
    • Single argument: sends the literal string or numeric value.
    • Alternating "key", value pairs form a JSON object.
    • Unlabeled values become a JSON array.
    • Quoted markers "$NAME", "$MACADDR", "$IPADDR" are replaced at publish send-time.
  • notify(message) sends a notification message (max 32 chars) using the default MQTT path or the configured Telegram bot credentials, depending on the Messaging tab setting.
  • ioreport() emits an IO report event with the current device report payload.
log("Pressure: ", p, " temp: ", temp());
logn("Done.");

publish("status",;
  "pressure", p,;
  "battery", ain(1),;
  "water", ain(2));

Patterns & Examples

One-second status loop

every(1000) {
  t = temp();
  if (t >= desired) {
    out(3) = 1;
    log("Heater ON");
  }
  else { out(3) = 0; }
}

Light panel (excerpt from example_light_panel.ems)

DELAY_MS = 3000;
SCAN_MS = 100;

macro ToggleRelay(idx){
  delay(DELAY_MS){ toggle(idx); }
}

every(SCAN_MS) {
  if (p_edge(in(1))) { ToggleRelay(1); }
  if (p_edge(in(2))) { ToggleRelay(2); }
}

Irrigation booster pump (excerpt from example_irrigation_booster_pump_1.ems)

load(JON, 20);
load(JOFF, 40);
load(MON, 10);
load(MOFF, 30);

every(200) {
  p=ain(1)/mul + 10;
  b=ain(2)*20/4;
  w=ain(3)/mul;

  if (ton("dry_run", w<DRY && pump, BDELAY)) {
    dry=1;
    out(1)=0;
    out(2)=0;
    out(3)=0;
    publish("alarm","DRY_RUN");
  }

  if (dry) {
    out(1)=0;
    out(2)=0;
    out(3)=0;
  }
}

Block Library

Ready-to-use macros live in lib/Interpreter/plc_macros.ems and lib/Interpreter/time_macros.ems. Copy the ones you need into your script or download the files below.

PLC Blocks

Time Blocks (UTC-based)

Analog Blocks

Quick Reference