Skip to content

Modbus Interface

The ModbusInterface enables Docker applications to communicate with external devices using the Modbus protocol. It supports both Modbus RTU (serial) and Modbus TCP (network) and provides methods for bus management, register reads and writes, polling subscriptions, and data conversion.

In a Docker application, the Modbus Interface is available as self.modbus_iface. The Application class also provides proxy methods (self.read_modbus_registers(), self.write_modbus_registers(), etc.) for convenience.

Import

from pydoover.docker import ModbusInterface

Constructor

modbus = ModbusInterface(
    app_key="my_app",
    modbus_uri="127.0.0.1:50054",
    config=None,
)
ParameterTypeDefaultDescription
app_keystr(required)Application identifier.
modbus_uristr"127.0.0.1:50054"gRPC address of the Modbus Service.
configModbusConfig or NoneNoneOptional Modbus configuration object for pre-configured bus setup.

The Modbus Service runs as a sidecar container on the Doovit and manages the physical serial ports and TCP connections. Your application communicates with it via gRPC.

Bus Management

Before reading or writing registers, you must open a Modbus bus. A bus represents a connection to one or more Modbus devices over a shared serial line or TCP connection.

open_bus(bus_type, name, ...)

Open a new Modbus bus. The parameters depend on whether you are using serial (RTU) or TCP.

Serial (RTU) bus:

bus_id = self.modbus_iface.open_bus(
    bus_type="serial",
    name="sensor_bus",
    serial_port="/dev/ttyUSB0",
    serial_baud=9600,
)
ParameterTypeDescription
bus_typestr"serial" for Modbus RTU.
namestrA human-readable name for this bus.
serial_portstrThe serial port device path (e.g., /dev/ttyUSB0).
serial_baudintBaud rate (e.g., 9600, 19200, 115200).

Additional serial parameters such as parity, stop bits, and data bits can also be specified.

TCP bus:

bus_id = self.modbus_iface.open_bus(
    bus_type="tcp",
    name="power_meter",
    tcp_uri="192.168.1.100:502",
    tcp_timeout=5,
)
ParameterTypeDescription
bus_typestr"tcp" for Modbus TCP.
namestrA human-readable name for this bus.
tcp_uristrThe IP address and port of the Modbus TCP device (e.g., "192.168.1.100:502").
tcp_timeoutintTimeout in seconds for TCP operations.

Both methods return a bus_id string that identifies the bus for subsequent operations.

close_bus(bus_id)

Close an open Modbus bus and release its resources.

self.modbus_iface.close_bus(bus_id)

Always close buses during application shutdown to release serial ports and TCP connections cleanly.

fetch_bus_status(bus_id)

Check the current status of an open bus.

status = self.modbus_iface.fetch_bus_status(bus_id)

Returns status information including whether the bus is connected, the number of successful and failed transactions, and error statistics.

Register Operations

Modbus devices expose data through registers. There are two main register types relevant to most applications:

Register TypeCodeAccessTypical Use
Input registers3Read-onlySensor measurements, device status
Holding registers4Read/writeConfiguration, setpoints, control values

read_registers(bus_id, modbus_id, start_address, num_registers, register_type)

Read one or more registers from a Modbus device.

# Read 2 input registers starting at address 0 from device ID 1
values = self.modbus_iface.read_registers(
    bus_id=bus_id,
    modbus_id=1,
    start_address=0,
    num_registers=2,
    register_type=3,  # Input registers
)
ParameterTypeDescription
bus_idstrThe bus ID returned by open_bus().
modbus_idintThe Modbus device address (1-247).
start_addressintThe starting register address.
num_registersintNumber of consecutive registers to read.
register_typeintRegister type: 3 for input registers, 4 for holding registers.

Returns a single int when reading one register, or a list[int] when reading multiple registers. Each register value is a 16-bit unsigned integer (0-65535).

write_registers(bus_id, modbus_id, start_address, values, register_type)

Write one or more values to Modbus holding registers.

# Write a setpoint value to holding register 10 on device 1
self.modbus_iface.write_registers(
    bus_id=bus_id,
    modbus_id=1,
    start_address=10,
    values=[1500],  # Setpoint value
    register_type=4,  # Holding registers
)

# Write multiple consecutive registers
self.modbus_iface.write_registers(
    bus_id=bus_id,
    modbus_id=1,
    start_address=10,
    values=[1500, 2000, 500],
    register_type=4,
)
ParameterTypeDescription
bus_idstrThe bus ID returned by open_bus().
modbus_idintThe Modbus device address.
start_addressintThe starting register address.
valueslist[int]List of 16-bit values to write.
register_typeintRegister type (typically 4 for holding registers).
Warning
Write Carefully

Writing to the wrong register address can misconfigure or damage connected equipment. Always verify register addresses against the device's documentation before writing.

Polling Subscriptions

Polling subscriptions automatically read registers at a regular interval and invoke a callback with the results. This is the preferred approach for continuous monitoring, as it handles timing and error recovery internally.

add_read_register_subscription(bus_id, modbus_id, start_address, num_registers, register_type, poll_secs, callback)

Set up automatic periodic register reads.

def on_temperature_read(values):
    # Convert two 16-bit registers to a 32-bit float
    temp = ModbusInterface.two_words_to_32bit_float(values[0], values[1])
    self.set_tag("modbus_temperature", temp)

self.modbus_iface.add_read_register_subscription(
    bus_id=bus_id,
    modbus_id=1,
    start_address=0,
    num_registers=2,
    register_type=3,
    poll_secs=5,
    callback=on_temperature_read,
)
ParameterTypeDescription
bus_idstrThe bus to read from.
modbus_idintThe Modbus device address.
start_addressintStarting register address.
num_registersintNumber of registers to read.
register_typeintRegister type (3 or 4).
poll_secsintSeconds between reads.
callbackcallableFunction called with the register values after each successful read.

The subscription runs independently of your main loop. The callback is invoked with the raw register values, which you can then convert and store as needed.

Data Conversion

two_words_to_32bit_float(word1, word2, swap)

Convert two 16-bit Modbus register values into a 32-bit IEEE 754 floating-point number. Many Modbus devices store float values across two consecutive registers.

# Standard byte order (big-endian)
temperature = ModbusInterface.two_words_to_32bit_float(
    word1=0x4248,
    word2=0x0000,
)
# Result: 50.0

# Swapped byte order (some devices use little-endian word order)
temperature = ModbusInterface.two_words_to_32bit_float(
    word1=0x0000,
    word2=0x4248,
    swap=True,
)
# Result: 50.0
ParameterTypeDefaultDescription
word1int(required)First 16-bit register value (high word by default).
word2int(required)Second 16-bit register value (low word by default).
swapboolFalseSwap word order for devices that use little-endian word ordering.
Information Circle
Byte Order

Modbus does not define a standard word order for multi-register values. Some devices use big-endian (high word first), others use little-endian (low word first). Check the device documentation and use the swap parameter accordingly.

Configuration Classes

pydoover provides configuration schema classes for defining Modbus bus parameters in your application's configuration.

ModbusConfig

A single Modbus bus configuration. Use this when your application connects to one Modbus bus.

from pydoover.docker.modbus import ModbusConfig
from pydoover.config import Schema

class MyConfig(Schema):
    modbus = ModbusConfig()

The ModbusConfig is a configuration Object that includes fields for serial port, baud rate, TCP URI, TCP timeout, and other bus parameters. When deployed through the web portal, it renders as an editable form section.

ManyModbusConfig

An array of Modbus bus configurations. Use this when your application connects to multiple Modbus buses.

from pydoover.docker.modbus import ManyModbusConfig
from pydoover.config import Schema

class MyConfig(Schema):
    modbus_buses = ManyModbusConfig()

The ManyModbusConfig is a configuration Array of ModbusConfig objects, allowing users to add or remove bus configurations through the web portal.

Practical Example: Reading a Modbus Sensor

This complete example demonstrates reading temperature and humidity from a Modbus RTU sensor and publishing the data to the Doover cloud.

from pydoover.docker import Application, run_app
from pydoover.docker.modbus import ModbusConfig, ModbusInterface
from pydoover import config
from pydoover.tags import Tags, Number

class Config(config.Schema):
    modbus = ModbusConfig()
    poll_interval = config.Number("Poll Interval", default=10, description="Seconds between reads")

class SensorTags(Tags):
    temperature = Number(default=0.0)
    humidity = Number(default=0.0)

class ModbusSensorApp(Application):
    config_cls = Config
    tags_cls = SensorTags

    def setup(self):
        self.loop_target_period = self.config.poll_interval

        # Open the Modbus bus using deployment configuration
        self.bus_id = self.modbus_iface.open_bus(
            bus_type="serial",
            name="sensor_bus",
            serial_port=self.config.modbus.serial_port,
            serial_baud=self.config.modbus.serial_baud,
        )

    def main_loop(self):
        # Read temperature (2 registers starting at address 0)
        temp_regs = self.modbus_iface.read_registers(
            bus_id=self.bus_id,
            modbus_id=1,
            start_address=0,
            num_registers=2,
            register_type=3,
        )
        temp = ModbusInterface.two_words_to_32bit_float(
            temp_regs[0], temp_regs[1]
        )

        # Read humidity (2 registers starting at address 2)
        hum_regs = self.modbus_iface.read_registers(
            bus_id=self.bus_id,
            modbus_id=1,
            start_address=2,
            num_registers=2,
            register_type=3,
        )
        humidity = ModbusInterface.two_words_to_32bit_float(
            hum_regs[0], hum_regs[1]
        )

        # Update tags
        self.set_tag("temperature", temp)
        self.set_tag("humidity", humidity)

        # Publish to cloud
        self.create_message("sensor_data", {
            "temperature": temp,
            "humidity": humidity,
        })

    def on_shutdown_at(self, dt):
        self.modbus_iface.close_bus(self.bus_id)

if __name__ == "__main__":
    run_app(ModbusSensorApp)

This application opens a serial Modbus bus during setup using parameters from the deployment configuration, reads two 32-bit float values from a sensor on each loop iteration, and publishes the data as tags and channel messages. The bus is closed cleanly during shutdown.

Information Circle
Example — Bulk Read and Write

An engine controller reads all holding registers each loop iteration to capture the full device state, then writes command registers to control the engine. The read uses a bulk read_registers call, and writes use individual write_registers calls:

from pydoover.docker import Application

class EngineControllerApp(Application):
    async def _read_all_registers(self) -> list[int] | None:
        try:
            values = await self.modbus_iface.read_registers(
                bus_id=self.config.modbus_config.name.value,
                modbus_id=int(self.config.modbus_id.value),
                start_address=0,
                num_registers=61,
                register_type=4,  # Holding registers
            )
        except Exception:
            return None
        if isinstance(values, int):
            values = [values]
        return list(values) if len(values) >= 61 else None

    async def _write_register(self, address: int, value: int) -> bool:
        try:
            await self.modbus_iface.write_registers(
                bus_id=self.config.modbus_config.name.value,
                modbus_id=int(self.config.modbus_id.value),
                start_address=address,
                values=[int(value) & 0xFFFF],
                register_type=4,
            )
        except Exception:
            return False
        return True

The bus_id comes from the ModbusConfig element in the application's config schema. Using self.config.modbus_config.name.value retrieves the bus name that the operator configured at deployment.

Next Steps