Macro examples — advanced

These recipes use variables (set with a little math), conditions (if / else), live 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 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.