#Rules Engine

Both Edge Hub ingestion services — tsend2mqtt (PLC) and iolinkmaster2mqtt (IO-Link) — embed the same rules engine and use the same XML schema. This page is the reference for that schema.

The engine evaluates conditions against the service's decoded data on every cycle (a PLC frame for tsend2mqtt, a poll cycle for iolinkmaster2mqtt) and fires MQTT actions and/or incidents when conditions are met. Rules are loaded once at startup and are designed for zero-allocation operation on the hot path.

Each service references its own rules file from its config.yaml:

rules_file: /path/to/rules.xml

#XML schema

<rules>
  <rule name="..." cooldown="..." edge="...">
    <!-- exactly one top-level condition: <cond>, <and>, or <or> -->
    <actions>
      <publish topic="..." payload='...'/>
    </actions>
    <incident source="..." severity="..." summary="..."/>
  </rule>
</rules>

#<rule> attributes

Attribute Required Description
name yes Unique rule identifier. Used in the incident dedup_key and in log messages.
cooldown no Minimum time between firings. Go duration string (e.g. 30s, 1m, 500ms). Default 0 (no cooldown). Applies to actions and incident triggers, but not to incident resolves.
edge no rising or none (default). See Edge detection.

A rule must have <actions>, <incident>, or both.

#Conditions

Each rule has exactly one top-level condition element: <cond>, <and>, or <or>. Conditions can be nested up to 4 levels deep, with a maximum of 16 children per <and>/<or>.

#<cond> — leaf condition

Compares a single decoded field against a value.

<cond device="vibration1" tag="temperature" op="gt" value="50.0"/>
Attribute Required Description
device IO-Link only The IO-Link device name from config.yaml ports. Used by iolinkmaster2mqtt, which hosts multiple devices. tsend2mqtt has a single implicit PLC source and omits device.
tag yes Field name from the service's decoded output (see Fields below).
op yes Comparison operator (see table below).
value depends Comparison value. Required for all operators except changed.

On tsend2mqtt (a single PLC source), omit device — a condition is just tag, op, and value:

<cond tag="Bool137" op="eq" value="true"/>

#Operators

Operator Aliases Types Description
eq =, == boolean, number, integer, string Equal
neq != boolean, number, integer, string Not equal
lt < number, integer Less than
leq <= number, integer Less than or equal
gt > number, integer Greater than
geq >= number, integer Greater than or equal
changed any Field value changed since the last cycle. No value attribute needed.

XML escaping: When using < or <= in an XML attribute value, use the word forms (lt, leq) or XML-escape them (&lt;, &lt;=).

Boolean and string fields support only eq and neq.

#<and> / <or> — logic groups

Combine multiple conditions. Short-circuit evaluation (AND stops on the first false, OR stops on the first true).

<and>
  <cond device="vibration1" tag="alert_temp" op="eq" value="true"/>
  <cond device="vibration1" tag="temperature" op="geq" value="50.0"/>
</and>

Groups can be nested:

<or>
  <and>
    <cond device="vibration1" tag="alert_vrms_max" op="eq" value="true"/>
    <cond device="vibration1" tag="temperature" op="gt" value="40.0"/>
  </and>
  <cond device="vibration1" tag="alert_acc_peak" op="eq" value="true"/>
</or>

#Fields

The comparison value is parsed to match the field's type. Which fields and types are available depends on the service.

Field types come from the driver's decoded output (JSON Schema types):

Schema type Example value Notes
boolean true, false Alert bits
number 5.0, 36.5 Float32 sensor values (temperature, RMS, …)
integer 0, 224 Raw alert word, counters
string hello String fields (eq/neq only)

Example — SICK MPB10 vibration sensor:

Tag Type Description
alert_temp boolean Temperature alert bit
alert_acc_peak boolean Acceleration peak alert bit
alert_vrms_prewarn boolean V-RMS prewarn alert bit
alert_vrms_max boolean V-RMS max alert bit
alerts_raw integer Raw 16-bit alert word
vrms_x, vrms_y, vrms_z number Velocity RMS (default v-rms profile)
temperature number Sensor temperature in °C

Slot fields depend on the active profile; the default v-rms profile provides vrms_x, vrms_y, vrms_z, and temperature.

#tsend2mqtt (PLC)

Tags are the field names from the .udt definition (exact match). The value is parsed based on the tag's S7 type:

S7 type Parsed as Example
Bool boolean true, false, 1, 0
Int, DInt, SInt, USInt, UInt, UDInt, Byte, Word, DWord integer 42, -1, 0
Real, LReal number 85.0, -1.5, 0
String, WString string hello

#Actions

Actions are ordered <publish> elements inside <actions>. They fire sequentially (fire-and-forget, QoS 0, non-retained). If payload is omitted, {} is used.

<actions>
  <publish topic="sensingcam/sick1/trigger" payload='{"event_name":"alarm"}'/>
  <publish topic="iolink/vibration1/param/reset_alerts" payload='{}'/>
</actions>

Actions publish to absolute topics (no prefix prepended). This lets a rule trigger external systems (cameras, other services) or send commands back to a device — for IO-Link, ISDU commands via the iolink/<name>/param/<command> topic.

#Edge detection

The edge attribute controls when actions fire:

Edge Behavior
none (default) Fire every cycle where the condition is true (subject to cooldown).
rising Fire only on the false→true transition. Does not re-fire while the condition stays true.

For most alerting use cases, rising is recommended to avoid repeated firings. Incidents always use rising-edge semantics regardless of this setting (see below).

#Cooldown

The cooldown attribute sets a minimum interval between firings, using Go duration syntax:

30s     → 30 seconds
1m      → 1 minute
1m30s   → 1 minute 30 seconds
500ms   → 500 milliseconds

Cooldown applies to action firing (both none and rising modes) and incident triggers. Cooldown does not apply to incident resolves — resolves are never suppressed.

#Incidents

The optional <incident> element enables the trigger/resolve lifecycle for human-facing alerting. Incidents are published to the common incidents/ topic and surfaced in the Edge Hub web interface; see the Incident Protocol for the full contract.

<incident source="vibration1" severity="warning"
          summary="Vibration1 alarm triggered"/>
Attribute Required Description
source yes The data source (must match a configured device/port). Used to build the source ID: {topic_prefix}_{source}.
severity yes critical, error, warning, or info.
summary yes Human-readable description (max 120 characters).

#Lifecycle

Incidents always use rising-edge semantics, regardless of the rule's edge setting:

  • Trigger (condition becomes true): publishes a trigger message to incidents/ with QoS 1.
  • Resolve (condition becomes false): publishes a resolve message to incidents/. Resolves are never suppressed by cooldown.

If cooldown suppresses a trigger, no active flag is set, so no orphan resolve is sent when the condition later clears.

#Message format

Trigger:

{
  "action": "trigger",
  "dedup_key": "iolink_vibration1-vibration1-alarm",
  "source": "iolink_vibration1",
  "severity": "warning",
  "summary": "Vibration1 alarm triggered",
  "time": "2026-07-11T12:34:56.789Z",
  "data": {"rule": "vibration1-alarm"}
}

Resolve:

{
  "action": "resolve",
  "dedup_key": "iolink_vibration1-vibration1-alarm",
  "source": "iolink_vibration1",
  "time": "2026-07-11T12:35:26.789Z",
  "data": {"rule": "vibration1-alarm"}
}

The dedup_key format is {topic_prefix}_{source}-{rule_name}, matching the discovery ID scheme.

#Cross-device rules

A condition may reference more than one source:

<rule name="both-overtemp" cooldown="60s" edge="rising">
  <and>
    <cond device="vibration1" tag="temperature" op="gt" value="50.0"/>
    <cond device="vibration2" tag="temperature" op="gt" value="50.0"/>
  </and>
  <actions>
    <publish topic="alerts/critical" payload='{"alert":"both_overtemp"}'/>
  </actions>
</rule>

#Limits

Limit Value
Maximum rules 1000
Maximum nesting depth 4
Maximum children per <and>/<or> 16
Maximum incident summary length 120 characters

#Examples

<rules>

  <!-- Action + incident: vibration max alarm while running warm. -->
  <rule name="vibration1-alarm" cooldown="45s" edge="rising">
    <or>
      <and>
        <cond device="vibration1" tag="alert_vrms_max" op="eq" value="true"/>
        <cond device="vibration1" tag="temperature" op="gt" value="40.0"/>
      </and>
      <cond device="vibration1" tag="alert_acc_peak" op="eq" value="true"/>
    </or>
    <actions>
      <publish topic="sensingcam/sick1/trigger" payload='{"event_name":"alarm"}'/>
      <publish topic="iolink/vibration1/param/reset_alerts" payload='{}'/>
    </actions>
    <incident source="vibration1" severity="warning"
              summary="Vibration1 alarm triggered"/>
  </rule>

</rules>

#PLC (tsend2mqtt)

The same schema, with PLC tags from the .udt and no device attribute (a single PLC source):

<rules>

  <!-- Action + incident: alarm bit trips a camera AND raises an incident. -->
  <rule name="alarm-camera" cooldown="45s" edge="rising">
    <cond tag="Bool137" op="eq" value="true"/>
    <actions>
      <publish topic="camera/record" payload='{"duration":40}'/>
    </actions>
    <incident source="plc1" severity="critical"
              summary="PLC alarm Bool137 active"/>
  </rule>

  <!-- Compound AND, incident only: alarm active while temperature high. -->
  <rule name="alarm-hot" cooldown="30s" edge="rising">
    <and>
      <cond tag="Bool137" op="eq" value="true"/>
      <cond tag="Real70" op="geq" value="85.0"/>
    </and>
    <incident source="plc1" severity="critical"
              summary="Alarm active while temperature high"/>
  </rule>

  <!-- Action only: fire when the alarm code changes to a non-zero value. -->
  <rule name="new-alarm-code" cooldown="5s" edge="rising">
    <and>
      <cond tag="Int93" op="changed"/>
      <cond tag="Int93" op="neq" value="0"/>
    </and>
    <actions>
      <publish topic="alerts/alarm_code" payload='{"event":"new_code"}'/>
    </actions>
  </rule>

</rules>

The <incident source="…"> still names the PLC source (used to build the dedup_key); only the per-condition device attribute is IO-Link-specific.