The modbus-service daemon connects Binions to industrial hardware. It speaks MODBUS — the protocol that programmable logic controllers (PLCs), variable-frequency drives, energy meters, and countless field devices have used for decades — so a sensor reading or a relay on the factory floor becomes just another event in your automations, sitting right next to your email, databases, and web APIs.
What is MODBUS? MODBUS is the most widely deployed protocol in industrial automation. Devices expose their state as numbered registers and coils; a client (Binions) reads and writes those numbers over the network. Binions talks MODBUS over TCP/IP, including RTU-framed traffic tunnelled through a serial-to-Ethernet gateway.
Most daemons in Binions reach out to software — a mail server, a SQL database, an HTTP endpoint. modbus-service reaches out to machines. You register a PLC once, and from then on you can:
That single daemon covers the full loop, in both directions: hardware emits a reading, a playbook decides what it means, and the right daemons act on it — whether that is logging telemetry, raising an alert, writing a value straight back to the machine, or publishing a computed result for the SCADA to display.
| What it is | The industrial-connectivity daemon — talks MODBUS to PLCs and field devices |
| Playbook prefix | modbus. — for example modbus.read or modbus.write |
| Transports | MODBUS TCP (default port 502) and RTU-over-TCP (for serial gateways) |
| Reads | Coils, discrete inputs, input registers, holding registers — raw or decoded |
| Decoding | u16/i16/u32/i32/u64/i64/f32/f64, word order, scale & offset — events carry physical values |
| Writes | Single & multiple coils and registers, masked writes, atomic read/write — allow-listed, with optional read-back verification |
| Background polling | Per-device, configurable interval; emits an event only when a value changes, with an optional deadband |
| Alarms | Per-subscription thresholds with hysteresis — one enter and one exit event per episode |
| Safety | Deny-by-default write allow-list, per-device write rate limit, automatic circuit breaker |
| Server mode | Optional — serve a live register map to SCADA; read-only by default, optionally writable by external masters |
| Licensing | One of the 13 binions — £1 in a paid set (£13 for the full set, per host) |
You drive the daemon from playbooks using the modbus. prefix — ten operations in total. Each operation takes its arguments under a with: block. Reads and writes always name the alias of a PLC you registered earlier.
| Operation | What it does | Key arguments |
|---|---|---|
modbus.register_plc | Register a device, open the connection, and start its background poller. This is a provisioning step you run once at boot. | alias, transport, read_subscriptions, allowed_register_ranges, profile |
modbus.unregister_plc | Stop polling, close the connection, and forget a device. | alias |
modbus.set_server_registers | Publish values into the register map that server mode serves to external MODBUS clients. | holding, input, coil, discrete |
modbus.list | List every registered device with its address, unit id, and connection state. | — |
modbus.read | Read a range of registers or coils once, on demand — optionally decoded to engineering units. | alias, register_type, address, quantity, decode |
modbus.write | Write to a device. The exact function is chosen by the operation you name. | alias, operation, verify, plus per-operation fields |
modbus.read_device_id | Read a device’s identification block — vendor name, product code, and revision. | alias |
modbus.read_file_record | Read a record from a device’s file area (extended addressing). | alias, file_number, record_number, record_length |
modbus.write_file_record | Write a record into a device’s file area. | alias, file_number, record_number, values |
modbus.read_fifo | Read a device’s FIFO queue. | alias, address |
The operation on a write picks the underlying MODBUS function. The available operations are write_single_coil, write_multiple_coils, write_single_register, write_multiple_registers, mask_write_register (atomic bit manipulation), and read_write_multiple_registers (read and write in one packet).
Registration is a one-off provisioning playbook. Here it opens a MODBUS TCP connection, polls 20 holding registers once a second, and — with an empty write allow-list — treats the device as strictly read-only:
name: register-reactor-1
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: modbus.register_plc
with:
alias: reactor-1
transport:
kind: tcp
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, starting at 40001
allowed_register_ranges: [] # empty = no writes permitted (deny by default)
emit_mode: on_change
heartbeat_every_n_polls: 60
A plain subscription is written as <type>/<address>:<quantity>. You can use classic 5-digit addresses (such as 40001 for holding registers or 30001 for input registers) — the daemon translates them — or raw addresses from 0 upward. Subscriptions can also carry a decode block, a deadband, and thresholds — covered in the sections below.
An on-demand read names the alias. Read a single point and the resulting fact carries it as a flat value — the lone number or boolean, no list indexing needed:
steps:
- id: level
run: modbus.read
with:
alias: reactor-1
register_type: holding
address: 19
quantity: 1
The next step reads ${steps.level.value} directly. Reads of more than one point return the full set of raw registers, and an optional decode block returns engineering values instead (see Decoding to engineering units).
Writing requires that the target address falls inside allowed_register_ranges for that device. This example pushes a setpoint into a single holding register that the device was registered to accept:
steps:
- id: apply_setpoint
run: modbus.write
with:
alias: reactor-1
operation: write_single_register
address: 19 # raw address 19 (classic 40020)
value: 4096
Writes are denied by default. A device with an empty
allowed_register_rangesrejects every write. To permit writes, you must list each allowed range explicitly, for example- { type: holding, start: 19, end: 19 }. A write outside the list never reaches the device — it is reported as a failure instead. This is your last line of defence against a misconfigured playbook touching live machinery.
A successful MODBUS write means the device acknowledged the request — not that the value stuck. Some devices clamp setpoints to a valid range; some registers are overwritten by device logic a moment later. Add verify: true to a plain coil or register write (single or multiple) and the daemon reads the target back immediately after the write succeeds and compares. On a match, the Fact.Modbus.WriteSucceeded fact carries verified: true; on a mismatch, the operation is reported as a loud Fact.Modbus.Failed (“verify mismatch”) — so your playbook’s failure path notices, not a stale dashboard three shifts later.
Here a scheduled playbook pulls the latest setpoint from SQL and pushes it to the PLC with read-back verification:
name: verified-setpoint-write
description: |
Business — read the target setpoint from SQL, write it to the PLC and
verify the device really took it (read-back, verify: true).
trigger:
event: Fact.Schedule.Fired
filter:
name.eq: setpoint-sync
steps:
- id: target
run: database.query
with:
table: setpoints
order_by: id
order_dir: DESC
limit: 1
single: first
- id: write
run: modbus.write
with:
alias: line-1
operation: write_single_register
address: 40010
value: ${steps.target.row.value}
verify: true
The setpoint-sync cadence comes from a schedule registered with scheduler-service; the write itself is still subject to the device’s allow-list.
MODBUS organises a device’s data into four address spaces. Binions supports all four:
| 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 reported by the device — limit switches, status flags |
input | Read only | 16-bit measured values — temperatures, pressures, counts |
holding | Read / write | 16-bit values you can both read and write — setpoints, configuration, working registers |
When you register a device you can give it a list of read_subscriptions. A background poller then reads those ranges on a loop at the poll_interval_ms you set. Two things control how often it emits events:
emit_mode: on_change — the default. The poller only emits an event when a value actually differs from the last reading, so a steady signal produces no traffic. A subscription’s optional deadband tightens this further: a movement smaller than the deadband does not count as a change. To stay visible, the poller still sends a heartbeat reading every heartbeat_every_n_polls cycles (60 by default) — heartbeats bypass the deadband.emit_mode: every_poll — emit on every cycle. Useful for debugging, but chatty.Each change becomes a Fact.Modbus.ValueChanged event carrying the device alias, register type, address, the previous value in old_value, the fresh value in new_value, and the poll timestamp in polled_at — a trigger event ready for a playbook to log, chart, or alert on.
Change-driven by design. Because the poller filters out steady values for you, a sensor that rarely moves costs almost nothing, while a fast-moving one is still captured promptly. Add a deadband and thresholds, and even a noisy analog signal produces bounded traffic and exactly one alarm per episode. Your downstream storage and alerting stay bounded by the rate of real-world change, not by your polling rate.
Real instruments rarely hand you a tidy number. A temperature often spans two registers as an IEEE-754 float, an energy counter spans four registers as a 64-bit integer, and a pressure arrives as a raw count that must be scaled by 0.1. A decode block turns all of that into physical values inside the daemon, so playbooks never do register arithmetic:
| Field | Values | Meaning |
|---|---|---|
type | u16, i16, u32, i32, u64, i64, f32, f64 | The wire type. 32-bit types span two registers, 64-bit types span four. |
word_order | big or little | The register order for multi-register types. Bytes inside each register are always big-endian on the wire — only the word order varies between vendors. |
scale, offset | numbers, optional | Physical value = raw × scale + offset. Left out, the raw value passes through — an unscaled u64/i64 keeps exact integer precision. |
In read_subscriptions, a decoded entry is an object with a spec and a decode block — and it sits happily next to plain string subscriptions, so you can mix both freely. Decoding changes what the poller emits:
Fact.Modbus.ValueChanged per decoded value — per chunk of the type’s width. Its new_value is the physical value, old_value is the previous engineering value, and the underlying register words ride along in raw.decode applies to the word spaces only (holding and input registers), and the subscription’s quantity must be a multiple of the type’s width. A misaligned subscription is rejected before it ever polls.On-demand modbus.read accepts the same decode block: the fact then carries a decoded[] list alongside the raw registers, and when the read decodes to a single value, the flat value field prefers that decoded value.
Multi-register word order is the classic cross-vendor trap: Siemens S7 PLCs put the low word first, and a float read with the wrong order decodes to convincing-looking nonsense. Register the device with profile: siemens and every decode that does not name a word_order defaults to little — the S7 convention. A profile only fills gaps: any field you set explicitly always wins, and the Fact.Modbus.PlcRegistered fact echoes the profile so an audit trail shows which defaults applied.
Here is a boiler with two decoded subscriptions and one plain string, feeding physical values straight into SQL:
name: register-boiler-decoded
description: Provisioning — two decoded subscriptions plus one plain string.
trigger:
event: Fact.System.Boot
filter:
component.eq: playbook-service
steps:
- id: register
run: modbus.register_plc
with:
alias: boiler-1
transport:
kind: tcp
host: 10.20.30.60
port: 502
unit_id: 1
poll_interval_ms: 1000
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"
allowed_register_ranges: []
emit_mode: on_change
---
name: boiler-decoded-telemetry-to-sql
description: Business — every decoded change lands in SQL as a physical value.
trigger:
event: Fact.Modbus.ValueChanged
filter:
alias.eq: boiler-1
register_type.eq: holding
steps:
- id: persist
run: database.write
with:
table: plc_metrics
row:
alias: ${trigger.alias}
address: ${trigger.address}
value: ${trigger.new_value}
polled_at: ${trigger.polled_at}
A limit alarm should fire once per incident — not once per poll. A subscription can carry a list of thresholds, each with an op (gt, ge, lt, or le), a value, and a label, plus a deadband. Thresholds work with or without a decode block — they compare whatever value the subscription produces.
For each (address, label) pair the daemon keeps a small hysteresis state machine and emits Fact.Modbus.ThresholdCrossed only on the edges of an episode: exactly one fact with direction: enter when the condition first becomes true, and exactly one with direction: exit — and only once the value has receded past the threshold by at least the deadband. A value chattering right at the limit therefore produces one alarm, not a storm. After a daemon restart, the first sample that satisfies a condition counts as a fresh enter, so an alarm that is still active is never invisible. The fact carries alias, register_type, address, label, op, threshold, value, direction, and polled_at.
This furnace registration combines a Siemens profile, a decoded temperature, a deadband, and two alarm labels — and the alert playbook mails operations exactly once per episode:
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}
A second playbook filtering on direction.eq: exit can send the all-clear the same way.
Normally Binions is the client — it reaches out to your PLCs. 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 using the MODBUS tools it already speaks.
You enable it with an optional block in the daemon’s configuration. It binds to a local, non-privileged address by default and serves a register map seeded from a separate file:
[modbus_server]
enabled = true
listen_addr = "127.0.0.1:5020"
register_map_path = "/opt/binions/modbus-service/config/server-registers.toml" # seed values
writable = false # set true to accept coil & holding writes from external masters
The seed file lists the initial values to expose, by type and address:
[[holding]]
address = 0
value = 13
description = "live daemon count"
[[coil]]
address = 0
value = true
description = "platform health flag"
A static map is only the starting point. The modbus.set_server_registers operation writes fresh values into the served map from any playbook — the platform computes, the SCADA reads. It updates any of the four spaces in one call: holding and input take lists of {address, value} pairs with numeric values, coil and discrete take boolean values. Each call emits Fact.Modbus.ServerRegistersSet; calling it while server mode is disabled fails loudly with Fact.Modbus.Failed. The served map lives in memory — on boot the seed file restores the baseline and provisioning playbooks replay and re-publish their values, so the map heals itself after a restart.
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
Out of the box the served map is read-only: any write from an outside master is rejected with the protocol’s IllegalFunction exception, so server mode grants visibility without granting control. Set writable = true and the server also accepts coil writes (MODBUS functions 0x05 and 0x0F) and holding-register writes (0x06 and 0x10) from external masters. A successful write updates the served map and emits Fact.Modbus.ServerRegisterWritten — carrying the space, address, the values[] written (plus a flat value when a single point was written), the quantity, the peer that wrote, and written_at. The input and discrete spaces can never be written from outside, writable or not — the protocol defines no write functions for them.
That fact is the inbound-industrial trigger: an operator pressing a button on an existing SCADA screen, or a PLC pushing a value, starts a Binions playbook — no new software on their side. The simplest reaction is an audit trail:
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}
Like every Binions daemon, modbus-service reads a small TOML file. It needs its private event-bus connection and a health-check address; the tracing and server-mode blocks are optional. Per-device settings such as host, port, polling, decoding, thresholds, and write allow-lists are not in this file — they live in your modbus.register_plc playbooks, so devices can be added and changed without touching the daemon.
[service]
name = "modbus-service"
log_level = "info"
[redis]
host = "127.0.0.1"
port = 6402
password_file = "/opt/binions/modbus-service/secrets/redis.password"
[healthcheck]
listen_addr = "127.0.0.1:9112"
# [otel] # optional — send traces to a collector
# endpoint = "http://127.0.0.1:4317"
# sample_rate = 1.0
# [modbus_server] # optional — see "Server mode" above
On start-up the daemon validates this file before it runs, so a typo stops it cleanly instead of failing later.
Playbooks call the daemon with an Action.Modbus.* event, and the daemon reports back with Fact.Modbus.* events that other playbooks can react to. Every event carries a correlation id, so you can trace a chain — an incoming mail, a decision, a write to a PLC — end to end.
| You send (action) | You receive (fact) |
|---|---|
Action.Modbus.RegisterPlc | Fact.Modbus.PlcRegistered + Fact.Modbus.PlcConnected |
Action.Modbus.UnregisterPlc | Fact.Modbus.PlcDeregistered |
Action.Modbus.SetServerRegisters | Fact.Modbus.ServerRegistersSet |
Action.Modbus.List | Fact.Modbus.PlcList |
Action.Modbus.Read | Fact.Modbus.ReadSucceeded |
Action.Modbus.Write | Fact.Modbus.WriteSucceeded |
Action.Modbus.ReadDeviceId | Fact.Modbus.DeviceIdRead |
Action.Modbus.ReadFileRecord | Fact.Modbus.FileRecordRead |
Action.Modbus.WriteFileRecord | Fact.Modbus.FileRecordWritten |
Action.Modbus.ReadFifo | Fact.Modbus.FifoRead |
Results are easy to consume in the next step. A single-point read (quantity: 1) carries its lone bool or number as a flat value field — ${prev.value} feeds it straight onward. A decoded read adds a decoded[] list, and value prefers the single decoded chunk. A write made with verify: true reports verified: true on its WriteSucceeded.
Several more facts arrive without you asking for them. The background poller emits Fact.Modbus.ValueChanged whenever a watched value moves, and Fact.Modbus.ThresholdCrossed when a value enters or exits an alarm threshold. In writable server mode, a write from an external master emits Fact.Modbus.ServerRegisterWritten. Connection state surfaces as Fact.Modbus.PlcConnected and Fact.Modbus.PlcDisconnected. Any operation that fails — a rejected write, a device exception, a timeout, a verification mismatch — emits Fact.Modbus.Failed with the reason, so a failure path is never silent.
The daemon runs as a managed background service alongside its own private event-bus instance. It starts automatically with the rest of the platform, restarts on its own if it stops, and shuts down gracefully — draining work in flight and closing device connections cleanly. A built-in watchdog restarts it if it ever stops responding.
It also exposes a local health endpoint you can probe at any time:
curl http://127.0.0.1:9112/health/ready
curl http://127.0.0.1:9112/health/live
Health checks answer in well under a millisecond, and the same address serves a /metrics endpoint with counters for reads, writes, and connection failures — handy for a dashboard. The daemon keeps a tight memory budget and listens only on the local machine; it reaches out to the PLC addresses you whitelist over your own network, so nothing about your industrial setup is exposed to the public internet by Binions.
Two safeguards keep an automation honest with real hardware. A per-device circuit breaker protects you from a dead or flapping PLC: after repeated failures the daemon stops hammering it and fails fast for a short cool-down, then tries again — while the background poller keeps attempting to reconnect in its own loop. A per-device write rate limit (100 writes per second by default, adjustable per device) keeps a runaway playbook from overwhelming a controller. If a device drops, you can force a fresh connection from the admin console without restarting the daemon.
* Binions runs on 64-bit ARM hardware, such as a Raspberry Pi, from the public 1.0 release. The current alpha packages are for 64-bit x86 (amd64) only.