This is how Binions reaches off the network and onto the factory floor. Where the other integrations talk to software — mail servers, databases, web APIs — this one talks to machines: programmable logic controllers (PLCs), variable-frequency drives, energy meters, and the countless field devices that speak MODBUS. You register a device once, and from then on a playbook can read a sensor, flip a coil, or push a setpoint exactly the way it sends an email or runs a query — in engineering units, with alarm hysteresis and read-back-verified writes built in. And when the roles reverse, Binions can be the device: a built-in MODBUS server publishes your computed values to the SCADA tools you already run. This page is the integration-author’s view; for the full service reference see the modbus-service.
Good to know. A reading from a PLC is just another event on the bus. The moment a value changes, the integration emits a
Fact.Modbus.*event — and from there it is indistinguishable from an arriving email or a firing webhook. The same playbook grammar —run:,parallel:,loop:, andwait_for:steps — the same logs and traces, all apply. See Playbook anatomy for the authoritative grammar reference.
Binions speaks MODBUS in both directions. As a client — the usual posture — it reaches out to the PLCs you name on your own network, reads and writes their registers, and turns changes into events, decoded into physical values where you ask for it. As a server it stands in as a MODBUS device itself, publishing live values for a SCADA system to poll and, if you explicitly allow it, accepting writes that trigger playbooks. The client side is built on MODBUS TCP (the default), and also speaks RTU-over-TCP for serial devices sitting behind an Ethernet gateway — both are live today. Everything is driven from playbooks under the modbus. prefix; you never write protocol code.
| Playbook prefix | modbus. (event domain Modbus) |
| Transports | MODBUS TCP (default port 502) and RTU-over-TCP for serial-to-Ethernet gateways — both live |
| Register types | coil, discrete_input, input, holding |
| Engineering units | Optional decode per subscription or read: u16…f64, register word order, scale & offset — events carry the physical value |
| Alarms | thresholds + deadband per subscription — hysteresis emits exactly one ThresholdCrossed per alarm-episode edge |
| Reports back | Fact.Modbus.* — PlcRegistered, PlcConnected / PlcDisconnected, ValueChanged, ThresholdCrossed, ReadSucceeded, WriteSucceeded, ServerRegisterWritten, and more |
| Safety | Deny-by-default write allow-list, optional read-back verification on writes, per-device circuit breaker, and a write rate limiter |
| Server mode | Built in — playbooks publish live values for SCADA to poll; opt-in writable turns inbound master writes into playbook triggers |
A playbook step names a verb under the modbus. prefix and supplies its arguments under a with: block. Reads and writes always name the alias of a device you registered earlier, so business playbooks stay free of host addresses and unit ids. This mirrors the platform’s wider split — provisioning playbooks set up named resources once; business playbooks reference them by alias. See Provisioning vs business playbooks for the pattern, and The integration model for how every connector follows it.
| Operation | Kind | What it does |
|---|---|---|
modbus.register_plc | Provisioning | Register a device under an alias, open the connection, and start its background poller. Accepts a vendor profile. Run once at boot. |
modbus.unregister_plc | Provisioning | Stop polling, close the connection, and forget a device. |
modbus.list | Business | List every registered device with its address, unit id, and connection state. |
modbus.read | Business | Read a range of registers or coils once, on demand — optionally decoded into an engineering value. |
modbus.write | Business | Write to a device — a single coil, a setpoint, or a block of registers — gated by the allow-list, optionally verified by read-back. |
modbus.set_server_registers | Business | Publish values into the built-in server’s register map — the platform computes, SCADA reads. |
modbus.read_device_id | Business | Read a device’s identification block: vendor name, product code, and revision. |
modbus.read_fifo | Business | Read a device’s FIFO queue. |
modbus.read_file_record / modbus.write_file_record | Business | Read or write a record in a device’s file area (extended addressing). |
Verbs vs. control. The list above is the complete set of playbook verbs. Operator commands such as forcing a reconnect, reloading config, or switching a device’s emit mode are control-plane actions you issue from the admin console — they are not
modbus.verbs and never appear asrun:steps. See the admin console.
MODBUS organises a device’s data into four address spaces, and Binions supports all four. You name a type in a read, a write, or a polling subscription:
| Type | Access | Holds |
|---|---|---|
coil | Read / write | Single on/off bits you can set — relays, enable flags, an emergency-stop line |
discrete_input | Read only | Single on/off bits the device reports — limit switches, status flags |
input | Read only | 16-bit measured values — temperatures, pressures, counts |
holding | Read / write | 16-bit values you read and write — setpoints, configuration, working registers |
A background subscription is written as <type>/<address>:<quantity> — for example holding/40001:20 reads twenty holding registers starting at the classic address 40001. You can use those familiar 5-digit addresses (the integration translates them) or raw addresses counting from 0. When a plain string is not enough, a subscription can be an object instead: the same range under spec, plus an optional decode block for engineering units and deadband / thresholds for alarms. Both forms mix freely in one list; the next sections cover what the extras buy you.
The interesting part of MODBUS automation is not the one-off read — it is the continuous one. When you register a device, Binions starts a background polling task that watches the subscriptions you listed and emits a fact whenever a value moves. Three behaviours make that safe and quiet:
deadband suppresses analog noise on top. A sensor that rarely moves costs almost nothing; a fast one is captured promptly.On top of those, writes are deny-by-default. A device registered with an empty allowed-range list rejects every write; to permit one you must list the exact register ranges that may be written. A write outside the list never reaches the machine — it is reported as a failure instead.
Security. MODBUS itself has no authentication or encryption, so Binions adds the guard rails: a strict write allow-list, read-back verification for writes that matter, the rate limiter, and the circuit breaker, all on top of a host firewall that should permit outbound traffic only to the PLC addresses you trust. Treat the allow-list as your last line of defence against a misconfigured playbook touching live machinery — see Hardening.
Raw MODBUS registers are 16-bit words; real-world quantities rarely are. A temperature is often stored as tenths of a degree in one register, a flow rate as a 32-bit float spread across two, a totaliser across four. Instead of re-deriving that arithmetic in every playbook, you attach a decode spec at the point where data enters the platform — on a polling subscription or an on-demand read — and everything downstream carries the physical value. A decode spec has four fields:
type — u16, i16, u32, i32, u64, i64, f32 or f64. Wider types span multiple registers, so the subscribed quantity must be a multiple of the type’s width — a mismatch is rejected loudly at registration, not discovered on the shop floor. Decode applies to the word spaces, input and holding.word_order — big or little: the order of the registers that make up a wide value. Bytes inside each register are always big-endian, as the protocol dictates; register order is the part vendors disagree on.scale and offset — the physical value is raw × scale + offset, so a register holding tenths of a degree reaches the bus as 72.5 °C, not 725.read_subscriptions:
- spec: "holding/100:2"
decode:
type: f32
word_order: big # register order; bytes are always big-endian
scale: 0.1
- spec: "holding/102:1"
decode:
type: u16
- "coil/0:8" # plain string subscriptions still work
A decoded subscription emits one Fact.Modbus.ValueChanged per type-width chunk: the physical value in new_value, the previous engineering value in old_value, and the raw words alongside in raw for audits. Change detection runs on the raw chunks, never on the decoded floats, and 64-bit integers read without scaling keep exact integer precision. On a modbus.read, decoded chunks arrive in decoded[] — and when there is exactly one, the fact’s flat value is the decoded number, ready to interpolate. (Without decoding, a read of quantity: 1 also exposes its lone bool or number as flat value.)
Vendor quirks are handled once, at registration. modbus.register_plc accepts a profile — siemens is available — that fills vendor defaults into whatever you leave unspecified: under the Siemens profile, a decode with no explicit word_order gets little, the classic S7 low-word-first trap. Fields you set explicitly always win, and Fact.Modbus.PlcRegistered echoes the profile it applied.
A value hovering at its limit is the classic way to flood an event bus — 80.1, 79.9, 80.0, 80.2 is four crossings in four seconds and four alerts nobody wants. Binions solves this at the source. A subscription can carry thresholds — each an op (gt, ge, lt or le), a value and a label — together with a deadband. Per (address, label) pair the poller runs a hysteresis state machine: Fact.Modbus.ThresholdCrossed fires with direction: enter the moment the condition first becomes true, and with direction: exit only once the value has receded past the threshold by at least the deadband. Exactly one fact per episode edge — an alarm and an all-clear, never a storm. After a restart, the first sample that still satisfies a condition emits a fresh enter, so an active alarm is never invisible.
The fact carries everything an alert needs — alias, register_type, address, label, op, threshold, the offending value, the direction and polled_at — so a one-step playbook can page an operator. The same deadband also gates noise-level ValueChanged events on that subscription (heartbeats still pass), and with a decode block in place — it is optional here — thresholds and deadband are expressed in the same physical units your events carry:
name: register-furnace-with-thresholds
description: |
Provisioning — furnace PLC with a decoded temperature subscription,
0.5 °C deadband and two alarm thresholds.
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: modbus.register_plc
with:
alias: furnace-7
profile: siemens
transport:
kind: tcp
host: 10.20.30.71
port: 502
unit_id: 1
poll_interval_ms: 1000
read_subscriptions:
- spec: "holding/100:2"
decode:
type: f32 # no word_order — the siemens profile fills `little`
scale: 0.1
deadband: 0.5
thresholds:
- { op: gt, value: 80.0, label: temp_high }
- { op: lt, value: 5.0, label: temp_low }
allowed_register_ranges: []
emit_mode: on_change
---
name: furnace-threshold-alert
description: Business — ONE mail per alarm episode (hysteresis edges).
trigger:
event: Fact.Modbus.ThresholdCrossed
filter:
alias.eq: furnace-7
direction.eq: enter
steps:
- id: page_ops
run: mail.send
with:
from_alias: ops-out
to: [ "ops@example.com" ]
subject: "Furnace furnace-7 alarm: ${trigger.label}"
body_text: |
Threshold ${trigger.label} crossed on furnace-7.
value: ${trigger.value}
threshold: ${trigger.op} ${trigger.threshold}
address: ${trigger.address} (${trigger.register_type})
at: ${trigger.polled_at}
Industrial flows use the same building blocks as every other Binions integration: a provisioning step to register the device, then business steps that read and write it. The examples below cover the three most common patterns; for the complete playbook grammar — including loop: for bounded iteration and wait_for: for joining async results — see Playbook anatomy.
1. Provisioning — register a PLC. Run once at boot. This opens a MODBUS TCP connection, polls twenty holding registers every second, and — with an empty allow-list — keeps the device strictly read-only:
name: register-reactor-1
trigger:
event: Fact.System.Boot
steps:
- run: modbus.register_plc
with:
alias: reactor-1
transport:
kind: tcp # or rtu_over_tcp for a serial gateway
host: 10.20.30.42
port: 502
unit_id: 1
timeout_ms: 1000
poll_interval_ms: 1000
read_subscriptions:
- holding/40001:20 # 20 holding registers from 40001
allowed_register_ranges: [] # empty = no writes permitted (deny by default)
2. Read a physical value and store it. A business step reads two registers on demand — an f32 spans two — and decodes them in place; the single decoded chunk arrives as the fact’s flat value, already in engineering units, and the next step hands it to the database:
steps:
- id: read
run: modbus.read
with:
alias: reactor-1
register_type: holding
address: 40001
quantity: 2 # an f32 spans two registers
decode:
type: f32
word_order: big # register order; bytes are always big-endian
scale: 0.1
- id: persist
run: database.write
with:
table: readings
row:
tag: "reactor-1.temp"
value: ${steps.read.value}
3. Write a setpoint — and verify it. Writing requires the target address to fall inside the device’s allowed ranges, and the operation: field names exactly which MODBUS write is performed. Add verify: true and the daemon reads the target back after a successful write: a device that quietly ignored the value — a locked parameter, an out-of-range clamp — becomes a loud Fact.Modbus.Failed (“verify mismatch”) instead of a silent lie, and a genuine success carries verified: true on its WriteSucceeded fact:
steps:
- id: setpoint
run: modbus.write
with:
alias: reactor-1
operation: write_single_register
address: 19 # the device's allow-list must cover this address
value: 4096
verify: true # read back and compare before reporting success
Each operation succeeds with a Fact.Modbus.WriteSucceeded (or ReadSucceeded) event, and any failure — a rejected write, a device exception, a timeout, a verify mismatch — surfaces as Fact.Modbus.Failed carrying the reason, so a failure path is never silent. Every event shares the run’s correlation id, so you can trace an incoming trigger, a decision, and a write to a PLC end to end.
Normally Binions is the client — it reaches out to your devices. Server mode flips this around: Binions itself listens as a MODBUS device, so an existing SCADA package or engineering station can poll a slice of your platform’s state with the MODBUS tools it already speaks. You turn it on with an optional configuration block in the daemon’s application.toml; the file named by register_map_path seeds the map’s initial values:
[modbus_server]
enabled = true
listen_addr = "0.0.0.0:5020"
register_map_path = "server-registers.toml" # seed values
writable = true # accept FC 05/0F/06/10 from masters
What makes the served map useful is that playbooks write into it. modbus.set_server_registers publishes computed values — a stock level from SQL, a KPI from the analyser — into the map’s holding / input registers (numbers) and coil / discrete bits (booleans), answered by Fact.Modbus.ServerRegistersSet. The map lives in memory and heals itself through boot-time provisioning replay, the same pattern the rest of the platform uses for registrations; calling the verb while the server is disabled fails loudly with Fact.Modbus.Failed rather than publishing into the void.
With writable = true the flow reverses entirely: an external master’s write — function codes 05/0F for coils and 06/10 for holding registers, the only spaces the protocol defines writes for — updates the served map and lands on the bus as Fact.Modbus.ServerRegisterWritten, carrying the space, address, the written value (flat value for a single write, values[] for a block), quantity, the writing peer, and written_at. That fact is a trigger event: the SCADA operator flips a register, and a playbook runs — the outside world pushing into your automation. The default stays read-only (inbound writes are rejected with the protocol’s IllegalFunction), and the input / discrete spaces are never writable. Three playbooks make the full round trip — a cadence, a publisher, and an audit trail for inbound writes:
name: publish-live-stock-schedule
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: cadence
run: scheduler.register_schedule
with:
name: publish-live-stock
interval_seconds: 60
---
name: publish-live-stock
description: Business — DB → served registers; SCADA polls holding/0.
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: publish-live-stock
steps:
- id: stock
run: database.query
with:
table: warehouse_stock
where:
item: widget-a
single: one
- id: expose
run: modbus.set_server_registers
with:
holding:
- address: 0
value: ${steps.stock.row.qty}
coil:
- address: 0
value: true
---
name: audit-master-writes
description: Business — every external master write becomes an audit row.
trigger:
event: Fact.Modbus.ServerRegisterWritten
steps:
- id: audit
run: database.write
with:
table: modbus_writes_audit
row:
space: ${trigger.space}
address: ${trigger.address}
value: ${trigger.value}
peer: ${trigger.peer}
written_at: ${trigger.written_at}
Not everything on a modern floor speaks MODBUS — plenty of sensors and gateways publish JSON over MQTT instead. That path enters Binions through the messaging integration and is just as playbook-friendly: frames arriving from a broker mailbox carry a parsed body_json (up to 256 KiB, recognised by declared content type or sniffing), so a trigger reads ${trigger.body_json.temp} with no extraction step. And a topic_pattern declared inside the mqtt settings of the mailbox registration — named segments like sensors/+device/+metric, with an optional trailing #rest — captures each message’s topic segments into topic_params{}, so a playbook filters on topic_params.device.eq without ever parsing a topic string. See Email & messaging for the full broker story.
Industrial readings are rarely the whole story — they are the trigger or the target of a larger flow. Because Fact.Modbus.* events land on the same bus as everything else, the obvious partners are already there:
ValueChanged as time-series telemetry, already in engineering units. See database-service.ThresholdCrossed, or push readings to an outside API the moment a device drops offline.The result is one coherent loop: hardware emits a reading, a playbook decides what it means, and the right services act on it — logging telemetry, raising an alert, or writing a value straight back to the machine (verified, if you asked). And with server mode, the loop closes in the other direction too: the platform computes, SCADA reads — or writes, and playbooks react. See example workflows for end-to-end pipelines.