Advanced ESP32 gateway automation: 12 macro recipes with logic, sensors and loops

Updated

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 + if evaluates 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_tag and 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:

If a save is rejected for being too large, shorten a loop body or split the work into two macros.