Advanced ESP32 gateway automation: 12 macro recipes with logic, sensors and loops
These recipes use variables (set with a little math), conditions (if / else), sensor
reads (read_tag), and backend calls (call_backend). Author them on the Code tab —
the visual blocks editor is flat-only, so nested repeat/if show a "code view only" notice.
Start from the starter recipes if you haven't yet; the syntax primer there explains literals, parameters, and variables.
A macro runs once per trigger. A
read_tag+ifevaluates the sensor a single time and stops. To keep reacting (thermostat, hysteresis, watchdog), give the macro a schedule ("every 30 s") or fire it from a rule — see Trigger it automatically in Macros — program your devices.
A reading has to be recent.
read_tagand a{ "tag": … }value use the gateway's most recent poll of that tag; they don't trigger a new read. With gateway firmware 1.5.5 or later, if that reading is missing (the sensor is unplugged, the tag name doesn't match, nothing has been read since the gateway started) or older than two of the device's read cycles (about 40 s at the default interval), the run stops with an error naming the tag, before the step that needed it. Older firmware used the last value it had, or 0, so "turn the light on if it's below 20 lux" could fire with no sensor attached.
A note on math: set takes either a value or one flat operation, e.g.
"value": { "a": { "var": "duty" }, "op": "+", "b": { "lit": 15 } } means duty = duty + 15.
Operators are + - * / % — no chaining, so build up step by step inside a loop.
PWM sunrise fade-in
For: easing a lamp from off to full over time — a wake-up light. Ramps duty 0 → 255 in steps.
Needs: a Level actuator (gpio-pwm).
{
"params": [
{ "name": "STEP_MS", "type": "number", "default": 2000, "min": 0, "max": 600000 }
],
"blocks": [
{ "type": "set", "var": "duty", "value": { "lit": 0 } },
{ "type": "repeat", "count": { "lit": 17 }, "body": [
{ "type": "pwm_write", "deviceRef": "<dimmerDeviceId>", "duty": { "var": "duty" } },
{ "type": "wait", "ms": { "param": "STEP_MS" } },
{ "type": "set", "var": "duty", "value": { "a": { "var": "duty" }, "op": "+", "b": { "lit": 15 } } }
] },
{ "type": "pwm_write", "deviceRef": "<dimmerDeviceId>", "duty": { "lit": 255 } }
]
}
PWM "breathing" effect
For: a soft ambient/status light that fades up and down repeatedly. One up-loop plus one
down-loop, wrapped in CYCLES.
Needs: a Level actuator (gpio-pwm).
{
"params": [
{ "name": "CYCLES", "type": "number", "default": 3, "min": 1, "max": 1000 },
{ "name": "BREATH_MS", "type": "number", "default": 60, "min": 0, "max": 600000 }
],
"blocks": [
{ "type": "set", "var": "duty", "value": { "lit": 0 } },
{ "type": "repeat", "count": { "param": "CYCLES" }, "body": [
{ "type": "repeat", "count": { "lit": 17 }, "body": [
{ "type": "pwm_write", "deviceRef": "<dimmerDeviceId>", "duty": { "var": "duty" } },
{ "type": "wait", "ms": { "param": "BREATH_MS" } },
{ "type": "set", "var": "duty", "value": { "a": { "var": "duty" }, "op": "+", "b": { "lit": 15 } } }
] },
{ "type": "repeat", "count": { "lit": 17 }, "body": [
{ "type": "set", "var": "duty", "value": { "a": { "var": "duty" }, "op": "-", "b": { "lit": 15 } } },
{ "type": "pwm_write", "deviceRef": "<dimmerDeviceId>", "duty": { "var": "duty" } },
{ "type": "wait", "ms": { "param": "BREATH_MS" } }
] }
] }
]
}
Smooth servo scan
For: stepping a servo across its range in small increments (a panning sensor, a slow sweep)
instead of jumping between two positions.
Needs: a Position actuator (gpio-servo, firmware 1.4.0+).
{
"params": [
{ "name": "MIN_US", "type": "number", "default": 1000, "min": 500, "max": 2500 },
{ "name": "STEP_US", "type": "number", "default": 50, "min": 1, "max": 500 },
{ "name": "STEPS", "type": "number", "default": 20, "min": 1, "max": 1000 },
{ "name": "SCAN_MS", "type": "number", "default": 100, "min": 0, "max": 600000 }
],
"blocks": [
{ "type": "set", "var": "pos", "value": { "param": "MIN_US" } },
{ "type": "repeat", "count": { "param": "STEPS" }, "body": [
{ "type": "servo_write", "deviceRef": "<servoDeviceId>", "us": { "var": "pos" } },
{ "type": "wait", "ms": { "param": "SCAN_MS" } },
{ "type": "set", "var": "pos", "value": { "a": { "var": "pos" }, "op": "+", "b": { "param": "STEP_US" } } }
] }
]
}
Scheduled thermostat
For: turning a fan on above a setpoint and off below it. Reads the temperature, decides, acts.
Needs: a temperature sensor and a Level actuator (gpio-pwm) fan, both
on the gateway. Give this macro a schedule (e.g. every 30 s) so it keeps regulating.
{
"params": [
{ "name": "SETPOINT", "type": "number", "default": 28, "min": -40, "max": 125 }
],
"blocks": [
{ "type": "read_tag", "deviceRef": "<tempSensorDeviceId>", "tag": "temperature", "var": "t" },
{ "type": "if", "left": { "var": "t" }, "op": "gt", "right": { "param": "SETPOINT" },
"then": [ { "type": "pwm_write", "deviceRef": "<fanDeviceId>", "duty": { "lit": 255 } } ],
"else": [ { "type": "pwm_write", "deviceRef": "<fanDeviceId>", "duty": { "lit": 0 } } ] }
]
}
Staged cooling (nested if / else)
For: three fan speeds by temperature band — off, half, full — using a nested if.
Needs: a temperature sensor and a Level actuator fan. Schedule it like the
thermostat above.
{
"params": [
{ "name": "HIGH", "type": "number", "default": 32, "min": -40, "max": 125 },
{ "name": "MID", "type": "number", "default": 26, "min": -40, "max": 125 }
],
"blocks": [
{ "type": "read_tag", "deviceRef": "<tempSensorDeviceId>", "tag": "temperature", "var": "t" },
{ "type": "if", "left": { "var": "t" }, "op": "gt", "right": { "param": "HIGH" },
"then": [ { "type": "pwm_write", "deviceRef": "<fanDeviceId>", "duty": { "lit": 255 } } ],
"else": [
{ "type": "if", "left": { "var": "t" }, "op": "gt", "right": { "param": "MID" },
"then": [ { "type": "pwm_write", "deviceRef": "<fanDeviceId>", "duty": { "lit": 128 } } ],
"else": [ { "type": "pwm_write", "deviceRef": "<fanDeviceId>", "duty": { "lit": 0 } } ] }
] }
]
}
Pump hysteresis (two thresholds)
For: filling a tank — start the pump below a low level, stop it above a high level, and do nothing
in between (the classic anti-chatter pattern). Two separate ifs.
Needs: a level sensor and an On/Off actuator pump. Schedule it.
{
"params": [
{ "name": "LOW", "type": "number", "default": 20, "min": 0, "max": 100 },
{ "name": "HIGH", "type": "number", "default": 80, "min": 0, "max": 100 }
],
"blocks": [
{ "type": "read_tag", "deviceRef": "<levelSensorDeviceId>", "tag": "level", "var": "l" },
{ "type": "if", "left": { "var": "l" }, "op": "lt", "right": { "param": "LOW" },
"then": [ { "type": "digital_write", "deviceRef": "<pumpDeviceId>", "value": { "lit": 1 } } ] },
{ "type": "if", "left": { "var": "l" }, "op": "gt", "right": { "param": "HIGH" },
"then": [ { "type": "digital_write", "deviceRef": "<pumpDeviceId>", "value": { "lit": 0 } } ] }
]
}
Step-and-shoot timelapse / indexing table
For: advance a stage, let it settle, fire a shutter (or trigger a station), wait the interval,
repeat — a motion timelapse or an indexing/dosing table. Nested loop + several device types.
Needs: a Move actuator (gpio-stepper) and an
On/Off actuator wired to the shutter/trigger.
{
"params": [
{ "name": "FRAMES", "type": "number", "default": 40, "min": 1, "max": 10000 },
{ "name": "STEP", "type": "number", "default": 200, "min": 0, "max": 1000000 },
{ "name": "SETTLE_MS", "type": "number", "default": 500, "min": 0, "max": 600000 },
{ "name": "INTERVAL_MS", "type": "number", "default": 3000, "min": 0, "max": 600000 }
],
"blocks": [
{ "type": "repeat", "count": { "param": "FRAMES" }, "body": [
{ "type": "move", "deviceRef": "<stepperDeviceId>", "steps": { "param": "STEP" }, "dir": { "lit": 1 }, "maxSpeedUs": { "lit": 400 }, "minSpeedUs": { "lit": 1200 }, "rampSteps": { "lit": 80 } },
{ "type": "wait", "ms": { "param": "SETTLE_MS" } },
{ "type": "digital_write", "deviceRef": "<shutterDeviceId>", "value": { "lit": 1 } },
{ "type": "wait", "ms": { "lit": 200 } },
{ "type": "digital_write", "deviceRef": "<shutterDeviceId>", "value": { "lit": 0 } },
{ "type": "wait", "ms": { "param": "INTERVAL_MS" } }
] }
]
}
Modbus soft-start setpoint ramp
For: ramping a Modbus register (a VFD speed reference, a heater setpoint) up gradually instead of
commanding the target in one jump.
Needs: an RS485/Modbus device. fc 6 writes one holding register at address.
{
"params": [
{ "name": "START", "type": "number", "default": 600, "min": 0, "max": 65535 },
{ "name": "INC", "type": "number", "default": 60, "min": 0, "max": 65535 },
{ "name": "STEPS", "type": "number", "default": 24, "min": 1, "max": 1000 },
{ "name": "RAMP_MS", "type": "number", "default": 500, "min": 0, "max": 600000 }
],
"blocks": [
{ "type": "set", "var": "sp", "value": { "param": "START" } },
{ "type": "repeat", "count": { "param": "STEPS" }, "body": [
{ "type": "modbus_write", "deviceRef": "<modbusDeviceId>", "fc": 6, "address": 100, "value": { "var": "sp" } },
{ "type": "wait", "ms": { "param": "RAMP_MS" } },
{ "type": "set", "var": "sp", "value": { "a": { "var": "sp" }, "op": "+", "b": { "param": "INC" } } }
] }
]
}
Precise dosing
For: dispensing a measured amount by pulsing a pump/valve a known number of equal shots. Needs: an On/Off actuator pump or valve.
{
"params": [
{ "name": "DOSES", "type": "number", "default": 10, "min": 1, "max": 10000 },
{ "name": "PULSE_MS", "type": "number", "default": 250, "min": 0, "max": 600000 },
{ "name": "GAP_MS", "type": "number", "default": 750, "min": 0, "max": 600000 }
],
"blocks": [
{ "type": "repeat", "count": { "param": "DOSES" }, "body": [
{ "type": "digital_write", "deviceRef": "<pumpDeviceId>", "value": { "lit": 1 } },
{ "type": "wait", "ms": { "param": "PULSE_MS" } },
{ "type": "digital_write", "deviceRef": "<pumpDeviceId>", "value": { "lit": 0 } },
{ "type": "wait", "ms": { "param": "GAP_MS" } }
] }
]
}
Out-of-range alert + webhook
For: raising an alarm and notifying an external system when a reading crosses a limit. emit
shows up in the run log / app; call_backend fires a saved action (a webhook or MQTT publish)
that runs on our servers.
Needs: a sensor and a saved Action (Rules → Actions) — paste its id into actionId.
{
"params": [
{ "name": "LIMIT", "type": "number", "default": 6, "min": 0, "max": 1000 }
],
"blocks": [
{ "type": "read_tag", "deviceRef": "<pressureSensorDeviceId>", "tag": "pressure", "var": "p" },
{ "type": "if", "left": { "var": "p" }, "op": "gt", "right": { "param": "LIMIT" },
"then": [
{ "type": "emit", "message": "Overpressure detected", "severity": "critical" },
{ "type": "call_backend", "actionId": "<actionId>" }
] }
]
}
Dead-sensor / no-flow watchdog
For: flagging a stuck reading — e.g. a flow sensor reporting exactly 0 while the pump should be
running — and calling out to an external system. Schedule it to run periodically.
Needs: a sensor and a saved Action (paste its id into actionId).
{
"params": [],
"blocks": [
{ "type": "read_tag", "deviceRef": "<flowSensorDeviceId>", "tag": "flow", "var": "f" },
{ "type": "if", "left": { "var": "f" }, "op": "eq", "right": { "lit": 0 },
"then": [
{ "type": "emit", "message": "No flow — check the sensor or pump", "severity": "error" },
{ "type": "call_backend", "actionId": "<actionId>" }
] }
]
}
Multi-pass ramped camera slider
For: the full camera slider — glide forward and back with smooth accel/decel, several passes,
logging each one. The µs-level timing and the ramp run on the gateway.
Needs: a Move actuator (gpio-stepper, firmware 1.2.0+). maxSpeedUs
is the fast end (smaller µs/step), minSpeedUs the slow ends of the ramp; rampSteps is how many
steps the accel/decel spans.
{
"params": [
{ "name": "DISTANCE", "type": "number", "default": 16400, "min": 0, "max": 1000000 },
{ "name": "MAX_SPEED_US", "type": "number", "default": 220, "min": 50, "max": 100000 },
{ "name": "MIN_SPEED_US", "type": "number", "default": 900, "min": 50, "max": 100000 },
{ "name": "RAMP_STEPS", "type": "number", "default": 700, "min": 0, "max": 100000 },
{ "name": "PASSES", "type": "number", "default": 3, "min": 1, "max": 1000 },
{ "name": "PAUSE_MS", "type": "number", "default": 300, "min": 0, "max": 600000 }
],
"blocks": [
{ "type": "repeat", "count": { "param": "PASSES" }, "body": [
{ "type": "move", "deviceRef": "<stepperDeviceId>", "steps": { "param": "DISTANCE" }, "dir": { "lit": 1 }, "maxSpeedUs": { "param": "MAX_SPEED_US" }, "minSpeedUs": { "param": "MIN_SPEED_US" }, "rampSteps": { "param": "RAMP_STEPS" } },
{ "type": "wait", "ms": { "param": "PAUSE_MS" } },
{ "type": "move", "deviceRef": "<stepperDeviceId>", "steps": { "param": "DISTANCE" }, "dir": { "lit": 0 }, "maxSpeedUs": { "param": "MAX_SPEED_US" }, "minSpeedUs": { "param": "MIN_SPEED_US" }, "rampSteps": { "param": "RAMP_STEPS" } },
{ "type": "wait", "ms": { "param": "PAUSE_MS" } },
{ "type": "emit", "message": "Pass complete", "severity": "info" }
] }
]
}
Staying inside the limits
Macros are bounded by design so a run can't get stuck:
- Up to 256 instructions after compiling (loops expand, so a big
repeatbody counts more). - A 10-minute total run cap — a longer run is aborted automatically.
- One macro runs per gateway at a time.
- Your plan may cap how many macros you can create and how many steps each can have — the Macros page shows your usage and any caps.
If a save is rejected for being too large, shorten a loop body or split the work into two macros.