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-sensor

Edit 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 rangeTypeAccessUse case
30001 to 39999Input RegistersRead-onlySensor measurements
40001 to 49999Holding RegistersRead/WriteConfiguration, setpoints
00001 to 09999CoilsRead/WriteOn/off switches
10001 to 19999Discrete InputsRead-onlyOn/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")     # coil

A 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.

PolicyBehavior
skipDrop the sample silently. Try again next tick.
last_knownEmit the previous successful value with quality="stale".
failSurface 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/s

Check 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 40003

Writing to a read-only register raises an error:

MySensor.temperature = 50   # ERROR: register 30001 is read-only

Historian

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-meter
from 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

MistakeFix
Wrong register address (off by 1)Modbus docs use 0-based addresses (40000); Scadable uses 1-based (40001). Check the datasheet.
Wrong scale factorraw_value * scale should give the correct engineering unit. Confirm with scadable verify.
Writing to a read-only registerOnly 40xxx and 0xxxx are writable.
Wrong slave IDEach device on the bus has a unique ID (1 to 247). Check device DIP switches.
Connection timeoutVerify the IP is reachable: ping 192.168.1.50. Check firewall.
Wrong endianness on multi-word registersTry the other byte order. If both look like nonsense, try dtype="uint32" first to confirm the device speaks Modbus correctly.

Next steps