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,
)
| Parameter | Type | Default | Description |
|---|---|---|---|
app_key | str | (required) | Application identifier. |
modbus_uri | str | "127.0.0.1:50054" | gRPC address of the Modbus Service. |
config | ModbusConfig or None | None | Optional 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,
)
| Parameter | Type | Description |
|---|---|---|
bus_type | str | "serial" for Modbus RTU. |
name | str | A human-readable name for this bus. |
serial_port | str | The serial port device path (e.g., /dev/ttyUSB0). |
serial_baud | int | Baud 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,
)
| Parameter | Type | Description |
|---|---|---|
bus_type | str | "tcp" for Modbus TCP. |
name | str | A human-readable name for this bus. |
tcp_uri | str | The IP address and port of the Modbus TCP device (e.g., "192.168.1.100:502"). |
tcp_timeout | int | Timeout 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 Type | Code | Access | Typical Use |
|---|---|---|---|
| Input registers | 3 | Read-only | Sensor measurements, device status |
| Holding registers | 4 | Read/write | Configuration, 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
)
| Parameter | Type | Description |
|---|---|---|
bus_id | str | The bus ID returned by open_bus(). |
modbus_id | int | The Modbus device address (1-247). |
start_address | int | The starting register address. |
num_registers | int | Number of consecutive registers to read. |
register_type | int | Register 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,
)
| Parameter | Type | Description |
|---|---|---|
bus_id | str | The bus ID returned by open_bus(). |
modbus_id | int | The Modbus device address. |
start_address | int | The starting register address. |
values | list[int] | List of 16-bit values to write. |
register_type | int | Register type (typically 4 for holding registers). |
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,
)
| Parameter | Type | Description |
|---|---|---|
bus_id | str | The bus to read from. |
modbus_id | int | The Modbus device address. |
start_address | int | Starting register address. |
num_registers | int | Number of registers to read. |
register_type | int | Register type (3 or 4). |
poll_secs | int | Seconds between reads. |
callback | callable | Function 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
| Parameter | Type | Default | Description |
|---|---|---|---|
word1 | int | (required) | First 16-bit register value (high word by default). |
word2 | int | (required) | Second 16-bit register value (low word by default). |
swap | bool | False | Swap word order for devices that use little-endian word ordering. |
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.
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
- Application Class -- the Application base class with Modbus proxies
- Platform Interface -- hardware I/O operations
- Device Agent Interface -- cloud communication
- Examples -- complete example applications
- Configuration Overview -- configuration schema system