Modbus#
ModbusDevice is a config-driven Modbus client. Describe your device in a JSON file (registers, addresses, data types) and interact using human-readable aliases instead of raw addresses.
from instro.modbus import ModbusDevice
connection = {"transport": "tcp", "host": "192.168.1.10", "port": 502}
device = ModbusDevice("my_device.json", connection=connection, autostart=True)
device.read("temperature")
device.write("setpoint", 75.5)
device.write("mode", "auto") # string values via write_value_map
device.close()
Or build the config in code with full IDE autocomplete:
from instro.lib.transports import ModbusTCPTransport
from instro.modbus import ModbusDevice, ModbusConfig
from instro.modbus.types import (
DeviceInfo, RegisterDef, TimingConfig,
)
config = ModbusConfig(
device=DeviceInfo(name="my_device"),
timing=TimingConfig(poll_interval=1.0, write_delay_ms=300),
registers=[
RegisterDef(name="temperature", starting_address=0, data_type="float32",
write_min=-40.0, write_max=500.0),
RegisterDef(name="mode", starting_address=100, data_type="uint16",
write_value_map={"off": 0, "auto": 1, "manual": 2}),
],
)
transport = ModbusTCPTransport(host="192.168.1.10")
device = ModbusDevice(config, connection=transport, unit_id=1, autostart=True)
Sample Config#
A complete config for a heat exchanger with temperature sensors, flow rate, a setpoint, a pump coil, and a status register with bitmap extraction:
{
"version": 1,
"protocol": "modbus",
"device": {
"name": "heat_exchanger",
"description": "Heat exchanger monitoring and control",
"manufacturer": "Acme Thermal",
"model": "HX-200"
},
"timing": {
"poll_interval": 1.0,
"write_delay_ms": 100
},
"registers": [
{
"name": "inlet_temp",
"starting_address": 0,
"register_type": "input",
"data_type": "float32",
"word_swap": true,
"read_group": "temperatures"
},
{
"name": "outlet_temp",
"starting_address": 2,
"register_type": "input",
"data_type": "float32",
"word_swap": true,
"read_group": "temperatures"
},
{
"name": "flow_rate",
"starting_address": 4,
"register_type": "input",
"data_type": "uint16",
"scale": {
"type": "linear",
"gain": 0.1,
"offset": 0
}
},
{
"name": "pressure_psi",
"starting_address": 5,
"register_type": "input",
"data_type": "uint16",
"scale": {
"type": "linear",
"gain": 0.01,
"offset": 0
}
},
{
"name": "setpoint",
"starting_address": 100,
"register_type": "holding",
"data_type": "float32",
"word_swap": true,
"write_min": 50.0,
"write_max": 250.0
},
{
"name": "operating_mode",
"starting_address": 102,
"register_type": "holding",
"data_type": "uint16",
"write_value_map": {
"off": 0,
"standby": 1,
"run": 2,
"flush": 3
},
"write_min": 0,
"write_max": 3
},
{
"name": "pump_enable",
"starting_address": 0,
"register_type": "coil"
},
{
"name": "status_register",
"starting_address": 200,
"register_type": "input",
"data_type": "uint16",
"bitmap": [
{"name": "pump_running", "bit_index": 0},
{"name": "alarm_high_temp", "bit_index": 1},
{"name": "alarm_low_flow", "bit_index": 2},
{"name": "fault", "bit_index": 15}
]
}
]
}
JSON Config Reference#
Connection#
Connection can be provided in the config or passed to the ModbusDevice constructor.
The constructor parameter takes precedence, allowing the config to be a standalone device
description shared across environments.
device = ModbusDevice(
"my_device.json",
connection={"transport": "tcp", "host": "192.168.1.10", "port": 502},
)
{
"connection": {
"transport": "tcp",
"host": "192.168.1.10",
"port": 502,
"timeout": 3.0,
"unit_id": 1
}
}
{
"connection": {
"transport": "rtu",
"port": "/dev/ttyUSB0",
"baudrate": 9600,
"parity": "N",
"stopbits": 1,
"bytesize": 8,
"timeout": 3.0,
"unit_id": 1
}
}
Serial port paths vary by platform:
Linux:
/dev/ttyUSB0,/dev/ttyACM0macOS:
/dev/cu.usbserial-1234,/dev/cu.usbmodem1234Windows:
COM3,COM4
Timing#
Controls background polling interval and write delay:
{
"timing": {
"poll_interval": 1.0,
"write_delay_ms": 300
}
}
Field |
Type |
Default |
Description |
|---|---|---|---|
|
float |
required |
Seconds between polling cycles (0.01 to 10.0) |
|
int |
|
Milliseconds to sleep after each write |
Activate polling with autostart=True in the constructor, or call open() then start() manually.
The write delay is applied automatically after every write() call, with no manual time.sleep() needed.
Registers#
Each register entry defines a named channel:
Field |
Type |
Default |
Description |
|---|---|---|---|
|
string |
required |
Alias used in |
|
int |
required |
Modbus register address (0 to 65535) |
|
string |
|
|
|
string |
|
|
|
bool |
|
Swap bytes within 16-bit words |
|
bool |
|
Swap 16-bit words (32-bit and 64-bit types) |
|
bool |
|
Swap 32-bit halves (64-bit types only) |
|
object |
|
Linear scaling config, e.g. |
|
list |
|
Bit extraction: |
|
bool |
|
Include in background polling |
|
number |
|
Minimum allowed write value, in the same units the caller passes to |
|
number |
|
Maximum allowed write value, in the same units the caller passes to |
|
object |
|
Map string labels to register values (holding registers only) |
|
string |
|
Group ID for batched reads (all registers in a group are read in one transaction) |
Scaling#
Linear scaling converts between raw register values and physical units:
physical = offset + (gain * raw)
{
"name": "pressure_psi",
"starting_address": 10,
"data_type": "uint16",
"scale": {"type": "linear", "gain": 0.01, "offset": 0}
}
Write Value Map#
Map human-readable strings to raw register values. Eliminates magic numbers in application code:
{
"name": "control_mode",
"starting_address": 100,
"data_type": "uint16",
"write_value_map": {
"off": 0,
"auto": 1,
"manual": 2
}
}
device.write("control_mode", "auto") # writes 1
device.write("control_mode", 1) # also works
Values in the map must be unique and must fall within write_min/write_max if those are set.
Write Limits#
Reject writes outside a safe range before they reach the device:
{
"name": "setpoint",
"starting_address": 0,
"data_type": "float32",
"write_min": 32.0,
"write_max": 300.0
}
device.write("setpoint", 150.0) # ok
device.write("setpoint", 999.0) # raises ValueError
Limits are checked in physical units (before scaling).
Read Groups#
Registers with the same read_group are read in a single Modbus transaction, reducing
the number of round trips per polling cycle:
{
"name": "heat_power",
"starting_address": 100,
"data_type": "float32",
"read_group": "power"
},
{
"name": "cool_power",
"starting_address": 102,
"data_type": "float32",
"read_group": "power"
}
Constraints:
All registers in a group must share the same
register_typeAll registers in a group must have
poll: trueHolding/input groups cannot span more than 125 registers
Coil/discrete groups cannot span more than 2000 addresses
Bitmap#
Extract individual bits from a uint16 holding or input register as named channels:
{
"name": "status_register",
"starting_address": 100,
"data_type": "uint16",
"bitmap": [
{"name": "alarm_high", "bit_index": 0},
{"name": "alarm_low", "bit_index": 1},
{"name": "motor_running", "bit_index": 5}
]
}
Reading status_register returns the raw value plus each bit as a separate channel (0 or 1).
API Reference#
ModbusDevice#
Config-driven Modbus client. |
Configuration Types#
Complete Modbus device configuration. |
|
Timing configuration for Modbus polling and write delays. |
|
Definition of a Modbus register. |
|
Definition of a single bit to extract from a uint16 register. |