Modbus
Modbus is the production protocol surface in v0.2.0. Modbus TCP, Modbus RTU, every register type, every dtype, every endianness. This page is the full Modbus reference.
Quick start
scadable add device modbus-tcp my-sensorEdit devices/my_sensor.py:
from scadable import Device, Register, modbus_tcp, every, SECONDS, MINUTES
class MySensor(Device):
id = "my-sensor"
name = "My Sensor"
connection = modbus_tcp(host="192.168.1.50", port=502, slave=1)
poll = every(5, SECONDS)
historian = every(5, MINUTES)
registers = [
Register(40001, "temperature", unit="C", scale=0.1),
Register(40002, "pressure", unit="bar", scale=0.01),
Register(40003, "setpoint", unit="C", writable=True),
]That is a complete device. The compiler emits a YAML driver config, the gateway spawns a Modbus subprocess, and telemetry flows.
Connection
connection = modbus_tcp(
host="192.168.1.50", # IP address or hostname
port=502, # 502 is the standard Modbus TCP port
slave=1, # Modbus slave/unit ID (1 to 247)
)For values that change per gateway, use an env-var placeholder:
connection = modbus_tcp(host="${SENSOR_HOST}", port=502, slave=1)Set the variable in fleet.toml:
[gateway.env]
SENSOR_HOST = "192.168.1.50"Register address ranges
The address tells the SDK which register type you are reading and whether it is writable.
| Address range | Type | Access | Use case |
|---|---|---|---|
| 30001 to 39999 | Input Registers | Read-only | Sensor measurements |
| 40001 to 49999 | Holding Registers | Read/Write | Configuration, setpoints |
| 00001 to 09999 | Coils | Read/Write | On/off switches |
| 10001 to 19999 | Discrete Inputs | Read-only | On/off status |
The SDK auto-detects access mode from the address:
registers = [
Register(30001, "temperature"), # 3xxxx -> read-only
Register(40001, "setpoint"), # 4xxxx -> writable
]Override with writable=True or writable=False if your device does not follow the convention.
dtype
The data type controls how many words the driver reads and how it decodes them. Default is uint16.
Register(40001, "temp", dtype="uint16") # 1 word, 0 to 65535
Register(40001, "temp", dtype="int16") # 1 word, signed
Register(40001, "flow", dtype="uint32") # 2 words
Register(40001, "flow", dtype="int32") # 2 words, signed
Register(40001, "temp", dtype="float32") # 2 words, IEEE-754
Register(40001, "temp", dtype="float64") # 4 words, IEEE-754 double
Register(00001, "relay", dtype="bool") # coilA 32-bit value occupies 2 consecutive registers starting at the address you give. Make sure your register list does not skip the second word.
registers = [
Register(40001, "flow", dtype="uint32"), # consumes 40001 and 40002
Register(40003, "temp", dtype="uint16"), # next free address is 40003
]endianness
Multi-word reads need a byte order. Default is big. Set little for devices that swap word order.
Register(40001, "flow", dtype="uint32", endianness="little")If your device's documentation does not say, the easy test: read the register, log both endiannesses, see which gives you a sensible number.
on_error
What happens when a register read fails (timeout, CRC error, exception code). Default is skip.
| Policy | Behavior |
|---|---|
skip | Drop the sample silently. Try again next tick. |
last_known | Emit the previous successful value with quality="stale". |
fail | Surface as an alert and skip the sample. |
Register(40001, "temp", on_error="last_known")last_known is what most operators want for non-critical telemetry: dashboards keep showing a value, but it is tagged as stale so you can color-code uncertain readings.
Scaling
Modbus values are 16-bit integers. The scale parameter converts to engineering units.
Register(40001, "temperature", unit="C", scale=0.1) # raw 225 -> 22.5 C
Register(40002, "pressure", unit="bar", scale=0.01) # raw 1013 -> 10.13 bar
Register(30001, "speed", unit="RPM", scale=1) # raw 1500 -> 1500 RPM
Register(30003, "vibration", unit="mm/s", scale=0.001) # raw 5000 -> 5.0 mm/sCheck your sensor's datasheet for the right scale factor.
Reading from a controller
Import the device and access registers by name. The runtime keeps the latest scaled value on the class.
from devices.my_sensor import MySensor
class Monitor(Controller):
@on.interval(5, SECONDS)
def check(self):
temp = MySensor.temperature
pressure = MySensor.pressure
self.publish("data", {"temperature": temp, "pressure": pressure})Writing to a register
Holding registers (40xxx) and coils (0xxxx) are writable. Assignment writes through the driver.
MySensor.setpoint = 75.0 # writes 750 (after inverse scaling) to register 40003Writing to a read-only register raises an error:
MySensor.temperature = 50 # ERROR: register 30001 is read-onlyHistorian
Push register values to the cloud time-series store at a slower-than-poll cadence:
historian = every(5, MINUTES)Exclude noisy registers:
registers = [
Register(40001, "temperature", unit="C", scale=0.1),
Register(40002, "debug_flag", store=False), # not historized
]OTA via Modbus
If your device supports firmware updates over Modbus:
from scadable.ota import ModbusOTA
class SmartSensor(Device):
# ... connection, registers ...
ota = ModbusOTA(
version = 30100, # register holding the current firmware version
firmware = (50001, 1000), # register range for writing firmware chunks
trigger = 50010, # write 1 to trigger flash
)The platform handles chunking, progress, and rollback. Dashboards show an "Update Firmware" button for devices with ota configured.
Multiple devices on one network
Multiple devices on the same Modbus network use different slave IDs:
class Sensor1(Device):
id = "sensor-1"
connection = modbus_tcp(host="192.168.1.50", port=502, slave=1)
class Sensor2(Device):
id = "sensor-2"
connection = modbus_tcp(host="192.168.1.50", port=502, slave=2)
class VFD(Device):
id = "vfd-motor"
connection = modbus_tcp(host="192.168.1.50", port=502, slave=3)The gateway pools the TCP connection across devices that share a host.
Modbus RTU (serial)
Same register model, different transport.
scadable add device modbus-rtu power-meterfrom scadable import Device, Register, modbus_rtu, every, SECONDS
class PowerMeter(Device):
id = "power-meter"
connection = modbus_rtu(
port="/dev/ttyUSB0",
baudrate=9600,
slave=1,
parity="N",
stopbits=1,
)
poll = every(5, SECONDS)
registers = [
Register(30001, "voltage", unit="V", scale=0.1),
Register(30002, "current", unit="A", scale=0.01),
Register(30003, "power", unit="kW", scale=0.001),
]Same addressing, same scaling, same controller access. Only the transport differs.
Worked example: a real device
A Schneider PM5350 power meter on Modbus TCP. Reads voltage, current, power, and energy. The energy register is a 32-bit float in little-endian word order, which is common for that vendor.
from scadable import Device, Register, modbus_tcp, every, SECONDS, MINUTES
class PM5350(Device):
id = "pm5350-main"
name = "Main panel power meter"
connection = modbus_tcp(host="${PM5350_HOST}", port=502, slave=1)
poll = every(2, SECONDS)
historian = every(1, MINUTES)
registers = [
Register(3000, "voltage_l1n", dtype="float32", endianness="little",
unit="V", scale=1.0, on_error="last_known"),
Register(3002, "current_l1", dtype="float32", endianness="little",
unit="A", scale=1.0, on_error="last_known"),
Register(3060, "power_total", dtype="float32", endianness="little",
unit="kW", scale=1.0, on_error="last_known"),
Register(3204, "energy_total", dtype="float32", endianness="little",
unit="kWh", scale=1.0, on_error="last_known"),
]Every register uses last_known so a flaky network does not break the dashboard. Every 32-bit float uses endianness="little" because Schneider devices typically store float words little-endian.
Common mistakes
| Mistake | Fix |
|---|---|
| Wrong register address (off by 1) | Modbus docs use 0-based addresses (40000); Scadable uses 1-based (40001). Check the datasheet. |
| Wrong scale factor | raw_value * scale should give the correct engineering unit. Confirm with scadable verify. |
| Writing to a read-only register | Only 40xxx and 0xxxx are writable. |
| Wrong slave ID | Each device on the bus has a unique ID (1 to 247). Check device DIP switches. |
| Connection timeout | Verify the IP is reachable: ping 192.168.1.50. Check firewall. |
| Wrong endianness on multi-word registers | Try the other byte order. If both look like nonsense, try dtype="uint32" first to confirm the device speaks Modbus correctly. |
Next steps
- scadable add device: scaffold a Modbus device from a template
- scadable verify: catch register and protocol errors before compile
- scadable compile: build the bundle for the gateway
Updated 4 days ago
