Modbus#

Using ModbusDevice for Modbus TCP and RTU device communication

ModbusDevice#

ModbusDevice is a config-driven Modbus TCP/RTU client that provides alias-based register access, automatic background polling, and full integration with the instro publisher system. Instead of writing raw Modbus function calls, you define your device’s register map in a JSON configuration file (or build it programmatically in Python) and access registers by semantic names.

Quickstart#

Publishing Modbus data to Nominal Core with instro

import time

from instro.lib.publishers import NominalCorePublisher
from instro.modbus import ModbusDevice

RID = "<dataset_rid>" # Replace with your dataset RID.

# Instantiate from a JSON config. autostart opens the connection and begins polling.
# It requires a `timing` section in the config (present in my_device.json below).
modbus_device = ModbusDevice(config="my_device.json", autostart=True)

# Add publisher to flow data to Nominal Core
modbus_device.add_publisher(NominalCorePublisher(dataset_rid=RID))

# Allow some time for background daemon reads
time.sleep(10)

modbus_device.close()
{
  "version": 1,
  "protocol": "modbus",
  "device": {
    "name": "example_device",
    "description": "Example device for instro docs",
    "manufacturer": "Nominal",
    "model": "Sim Device 3000"
  },
  "connection": {
    "transport": "tcp",
    "host": "<device ip address>",
    "port": 502,
    "timeout": 3.0,
    "unit_id": 1
  },
  "timing": {
    "poll_interval": 1.0
  },
  "registers": [
    {
      "name": "sample_channel",
      "starting_address": 0,
      "register_type": "holding",
      "data_type": "float32",
      "poll": true
    }
  ]
}

Key Benefits#

  • Config-driven: Define registers, connection, and timing in a single JSON file or build the config programmatically in Python

  • Alias-based access: Read and write registers by name (e.g., "temperature") instead of raw addresses

  • Automatic scaling: Apply linear transformations between raw register values and physical units

  • Read groups: Batch multiple registers into a single Modbus transaction for efficiency

  • Write safety: Enforce min/max limits and use human-readable string-to-value mappings

  • Byte ordering control: Handle vendor-specific byte/word/long swap requirements

  • Transparent reconnect: Dropped TCP connections are re-established on the next operation without any extra code

When to Use Each Feature#

Most devices can be brought online with just a connection, a few registers, and autostart=True. The features below each solve a specific problem you’ll run into as soon as you’re working with real hardware. Use this table to jump to the right section.

If you’re trying to…

Reach for…

Stream live data to Nominal Core with the shortest possible setup

autostart=True + add_publisher()

Read raw ADC counts or tick values as physical/engineering units

scale

Protect equipment from out-of-range or wrong-type writes

write_min / write_max

Replace magic numbers with human-readable labels for mode/state writes

write_value_map

Unpack a packed status word into individual boolean channels

bitmap

Minimize round-trips when polling many adjacent registers

read_group

Share one device config across test benches with different networking

Pass connection separately to the constructor

Address multiple physical devices over one RS-485 line or TCP gateway

Sharing one bus across multiple units

Work with a device that uses little-endian or “Modbus-style” word ordering

byte_swap / word_swap / long_swap

Throttle commands to a device that processes writes sequentially

write_delay_ms

Prototype a config without real hardware

The built-in simulator

Key Concepts#

Lifecycle Pattern#

The typical ModbusDevice workflow follows this pattern:

  1. Define your device: Create a JSON config file or build a ModbusConfig in Python

  2. ModbusDevice(config): Instantiate the client with your config (and optionally a separate connection)

  3. open(): Establish connection to the Modbus device

  4. start(): Begin background polling (if timing is configured)

  5. Read or Write: Access registers by alias with automatic type handling and scaling

  6. close(): Disconnect and stop any background polling

Tip

Pass autostart=True to combine steps 3 and 4. The connection opens and polling begins immediately on instantiation. This requires a timing section in the config.

Register Types#

Modbus defines four register types, each occupying a separate address space:

Register Type

Function Codes

Access

Size

Description

Holding

FC03 (read) / FC06, FC16 (write)

Read/Write

16-bit

General-purpose writable registers

Input

FC04 (read)

Read-only

16-bit

Sensor/process input registers

Coil

FC01 (read) / FC05 (write)

Read/Write

1-bit

Boolean outputs (on/off)

Discrete

FC02 (read)

Read-only

1-bit

Boolean inputs (status bits)

Supported Data Types#

Multi-register data types span consecutive 16-bit registers:

Data Type

Register Count

Range

uint16

1

0 to 65,535

int16

1

-32,768 to 32,767

uint32

2

0 to 4,294,967,295

int32

2

-2,147,483,648 to 2,147,483,647

uint64

4

0 to 2^64-1

int64

4

-2^63 to 2^63-1

float32

2

IEEE 754 single precision

float64

4

IEEE 754 double precision

bool

1

Any non-zero register value is read as true, zero as false

Note

Coil and discrete input registers are always read and written as booleans regardless of data_type, their data_type field is ignored. Use the bool data type for a holding or input register that stores a 0/1 flag in a regular 16-bit register rather than a coil.

Byte Ordering#

Modbus devices can vary in how they order bytes within multi-register values. ModbusDevice provides three swap options to handle these differences:

  • byte_swap - Swaps bytes within each 16-bit word. Applies to all data types.

  • word_swap - Swaps 16-bit words within 32-bit or 64-bit values. Not valid for 16-bit types.

  • long_swap - Swaps 32-bit halves within 64-bit values. Only valid for 64-bit types (uint64, int64, float64).

Swaps are applied in order during reads: byte swap, then word swap, then long swap. The reverse order is applied during writes.

byte_swap: Swap bytes within each 16-bit word#

Applies to all data types. Example with a float32 value (0x40490FDB = 3.14159):

Register 0    Register 1
┌───────────┐ ┌───────────┐
│ 0x40 0x49 │ │ 0x0F 0xDB │ ← default (big-endian)
└───────────┘ └───────────┘
      ╳             ╳
┌───────────┐ ┌───────────┐
│ 0x49 0x40 │ │ 0xDB 0x0F │ ← byte_swap: true
└───────────┘ └───────────┘
word_swap: Swap 16-bit words#

Applies to 32-bit and 64-bit types. Example with the same float32:

Register 0    Register 1
┌───────────┐ ┌───────────┐
│ 0x40 0x49 │ │ 0x0F 0xDB │ ← default
└───────────┘ └───────────┘
             ╳
┌───────────┐ ┌───────────┐
│ 0x0F 0xDB │ │ 0x40 0x49 │ ← word_swap: true
└───────────┘ └───────────┘
long_swap: Swap 32-bit halves#

Only applies to 64-bit types. Example with a float64 value spanning 4 registers:

Register 0    Register 1    Register 2    Register 3
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ 0x40 0x09 │ │ 0x21 0xFB │ │ 0x54 0x44 │ │ 0x2D 0x18 │ ← default
└───────────┘ └───────────┘ └───────────┘ └───────────┘
 ────── hi 32-bit ────────   ─────── lo 32-bit ───────
                           ╳
 ────── lo 32-bit ────────   ─────── hi 32-bit ───────
┌───────────┐ ┌───────────┐ ┌───────────┐ ┌───────────┐
│ 0x54 0x44 │ │ 0x2D 0x18 │ │ 0x40 0x09 │ │ 0x21 0xFB │ ← long_swap: true
└───────────┘ └───────────┘ └───────────┘ └───────────┘
Combining swaps#

Swaps can be combined. For a float32 with both byte_swap and word_swap:

Register 0    Register 1
┌───────────┐ ┌───────────┐
│ 0x40 0x49 │ │ 0x0F 0xDB │ ← default (big-endian)
└───────────┘ └───────────┘
        ① byte_swap
┌───────────┐ ┌───────────┐
│ 0x49 0x40 │ │ 0xDB 0x0F │ ← bytes swapped within words
└───────────┘ └───────────┘
        ② word_swap
┌───────────┐ ┌───────────┐
│ 0xDB 0x0F │ │ 0x49 0x40 │ ← words swapped (little-endian)
└───────────┘ └───────────┘

Tip

If you are seeing unexpected values when reading registers, try enabling byte_swap or word_swap in your register definition. Consult your device’s documentation for its byte ordering convention.

Linear Scaling#

Many devices store physical values as scaled integers to save bandwidth, like a pressure sensor that returns 2500 to mean 25.00 PSI, or a temperature sensor that returns 731 to mean 73.1 °F. Linear scaling lets your application code work in physical units while the driver handles the conversion.

physical = offset + (gain * raw)
{
  "name": "pressure",
  "starting_address": 10,
  "register_type": "input",
  "data_type": "uint16",
  "scale": {"type": "linear", "gain": 0.01, "offset": 0}
}

With the config above, device.read("pressure") returns 25.0 when the register holds 2500. Writes are reversed automatically. device.write("pressure", 25.0) sends 2500 to the device.

Scaling is applied automatically on reads (raw to physical) and reversed on writes (physical to raw).

Warning

Scaling is only supported on holding and input registers. Coils and discrete inputs are single-bit values and cannot be scaled.

Bitmap Extraction#

Many devices pack several status or alarm flags into a single 16-bit status register to save address space. Bit 0 might be motor_running, bit 1 fault_active, bit 7 at_setpoint, etc. Rather than unpacking those bits by hand in application code, you can declare a bitmap and have each bit published as its own named channel alongside the raw register value.

Registers with uint16 data type on holding or input register types can include an optional bitmap to extract individual bits as separate channels. Each bit is published as its own channel with a value of 0 or 1.

{
  "name": "status_word",
  "starting_address": 100,
  "register_type": "holding",
  "data_type": "uint16",
  "bitmap": [
    { "name": "motor_running", "bit_index": 0 },
    { "name": "fault_active", "bit_index": 1 },
    { "name": "at_setpoint", "bit_index": 7 }
  ]
}

Each entry in bitmap defines:

Field

Required

Description

name

Yes

Channel name for this bit

bit_index

Yes

0-based bit position from LSB (0 to 15)

When this register is read, the full uint16 value is published on the status_word channel, and each bit is published as a separate channel (e.g., device_name.motor_running). Bitmap names must be unique across the entire configuration and must not collide with register names.

Read Groups#

Registers that are physically adjacent can be grouped so they are read in a single Modbus transaction instead of individual requests. This reduces bus traffic and improves poll cycle time.

Reach for this when you’re polling many registers on a single device and want to keep total round-trip time low. The difference is especially pronounced on RTU links or over high-latency networks, where each extra request adds measurable delay.

Assign the same read_group string to each register in the group:

{
  "registers": [
    {
      "name": "inlet_temp",
      "starting_address": 0,
      "register_type": "input",
      "data_type": "float32",
      "read_group": "temperatures"
    },
    {
      "name": "outlet_temp",
      "starting_address": 2,
      "register_type": "input",
      "data_type": "float32",
      "read_group": "temperatures"
    }
  ]
}

Rules:

  • All registers in a group must have the same register_type (e.g., all "input" or all "holding").

  • All registers in a group must have poll: true (the default).

  • The total span of a group must not exceed the Modbus read limit: 125 registers for holding/input, 2000 bits for coils/discrete inputs.

  • Group names must be unique across the entire config. Pick a separate name for each group, even when the registers occupy different Modbus address spaces.

During background polling, one bulk read covers the entire address range of each group. Each register is then decoded from the bulk response.

Tip

If you’re polling 30 adjacent holding registers at 100 ms intervals, placing them all in a single read_group reduces the poll cycle from 30 round-trips per interval to 1, a large win for devices on slow links or under tight polling requirements.

Write Value Maps#

Industrial devices commonly use integer codes to select modes or states, such as 0 = off, 1 = standby, 2 = run, and 3 = flush. Hard-coding those integers in application code is error-prone and hides intent. A write_value_map lets callers pass the label ("run") while the driver sends the correct integer.

Registers can include a write_value_map that maps human-readable string labels to raw numeric values. This replaces magic numbers with meaningful names:

{
  "name": "operating_mode",
  "starting_address": 102,
  "register_type": "holding",
  "data_type": "uint16",
  "write_value_map": {
    "off": 0,
    "standby": 1,
    "run": 2,
    "flush": 3
  }
}

With this config, you write strings instead of raw numbers:

device.write("operating_mode", "run")   # writes 2 to the register
device.write("operating_mode", "off")   # writes 0 to the register

You can still write raw numeric values directly, the map is only used when a string is passed.

Field

Required

Description

write_value_map

No

Dictionary mapping string labels to numeric values

Note

Write value maps are only allowed on holding registers. Map values must be unique within the register and must fall within any configured write_min/write_max limits.

Write Limits#

When a write can physically affect equipment (moving an actuator, setting a temperature, commanding a flow rate), you usually have a safe operating range that’s narrower than the data type’s range. Declaring write_min and write_max catches out-of-range writes on the client before they reach the device, replacing an accidental 999.0 setpoint with a clean ValueError.

Registers can include write_min and/or write_max to enforce bounds on written values. This provides protection against accidental out-of-range writes:

{
  "name": "setpoint",
  "starting_address": 100,
  "register_type": "holding",
  "data_type": "float32",
  "write_min": 50.0,
  "write_max": 250.0
}
device.write("setpoint", 150.0)  # OK
device.write("setpoint", 999.0)  # Raises ValueError

Field

Required

Default

Description

write_min

No

Minimum allowed write value (in physical units, before scaling)

write_max

No

Maximum allowed write value (in physical units, before scaling)

Write limits are validated before scaling is applied, so they represent the physical value you’re writing, not the raw register value. Write limits are only allowed on holding registers.

Configuration#

ModbusDevice is configured through a JSON file. Every config file must include:

  • version: identifies the config schema version, ensuring forward compatibility as the format evolves.

  • protocol: must be "modbus". This field identifies which protocol the config is intended for, so that an incorrect config file is caught immediately with a clear error rather than failing with confusing type mismatches.

Full Configuration Example#

{
  "version": 1,
  "protocol": "modbus",
  "device": {
    "name": "heat_exchanger",
    "description": "Heat exchanger monitoring and control",
    "manufacturer": "Acme Thermal",
    "model": "HX-200"
  },
  "connection": {
    "transport": "tcp",
    "host": "<device ip address>",
    "port": 502,
    "timeout": 3.0,
    "unit_id": 1
  },
  "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": "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}
      ]
    }
  ]
}

Device Section#

Metadata about the physical device. The name field is used as a prefix for channel names when publishing data.

Field

Required

Description

name

Yes

Device name, used as channel name prefix

description

No

Human-readable description

manufacturer

No

Device manufacturer

model

No

Device model number

Connection Section#

Set the unit/slave address with the connection block’s unit_id field, or pass unit_id to the ModbusDevice constructor; an address given in both places must agree. When the device shares a transport with others, the constructor argument is required; see Sharing One Bus Across Multiple Units.

TCP Connection#

Field

Required

Default

Description

transport

Yes

Must be "tcp"

host

Yes

IP address or hostname

port

No

502

TCP port (1 to 65535)

timeout

No

3.0

Response timeout in seconds

unit_id

No

1

Unit/slave address (0 to 255)

RTU (Serial) Connection#

Field

Required

Default

Description

transport

Yes

Must be "rtu"

port

Yes

Serial port path (see examples below)

baudrate

No

9600

Serial baud rate

parity

No

"N"

Parity: "N" (none), "E" (even), "O" (odd)

stopbits

No

1

Stop bits: 1 or 2

bytesize

No

8

Data bits: 5, 6, 7, or 8

timeout

No

3.0

Response timeout in seconds

framer

No

"rtu"

Serial transmission mode: "rtu" or "ascii"

unit_id

No

1

Unit/slave address (0 to 247; 248-255 are reserved on a serial line)

Common serial port paths:

Platform

Example Paths

Description

Windows

"COM3", "COM4"

COM ports (check Device Manager)

Linux

"/dev/ttyUSB0", "/dev/ttyUSB1"

USB-to-serial adapters (FTDI, CH340, etc.)

Linux

"/dev/ttyS0", "/dev/ttyS1"

Built-in serial ports

Linux

"/dev/ttyACM0"

USB CDC/ACM devices (Arduino, etc.)

macOS

"/dev/tty.usbserial-1410"

USB-to-serial adapters

macOS

"/dev/cu.usbserial-1410"

USB-to-serial adapters (callout/outgoing)

macOS

"/dev/tty.usbmodem14101"

USB modem devices

macOS

"/dev/cu.usbmodem14101"

USB modem devices (callout/outgoing)

Tip

On Linux and macOS, you can list available serial ports with ls /dev/tty*. On Windows, check Device Manager → Ports (COM & LPT) to find the correct COM port number.

Register Definitions#

Each register in the registers array defines a named access point for a specific Modbus register or group of registers.

Field

Required

Default

Description

name

Yes

Unique alias for this register

description

No

Optional human-readable description of the register

starting_address

Yes

Modbus register address (0 to 65535)

register_type

No

"holding"

"holding", "input", "coil", or "discrete"

data_type

No

"uint16"

See Supported Data Types

byte_swap

No

false

Swap bytes within 16-bit words

word_swap

No

false

Swap 16-bit words (32/64-bit types only)

long_swap

No

false

Swap 32-bit halves (64-bit types only)

scale

No

Optional linear scaling (holding/input only)

bitmap

No

Extract individual bits as separate channels (uint16 holding/input registers only)

poll

No

true

Include in background polling

write_min

No

Minimum allowed write value (holding only)

write_max

No

Maximum allowed write value (holding only)

write_value_map

No

String-to-number mapping for writes (holding only)

read_group

No

Group ID for batched reads (see Read Groups)

Timing Section#

Field

Required

Default

Description

poll_interval

Yes

Polling interval in seconds (0.01 to 10.0)

write_delay_ms

No

0

Delay in milliseconds applied after every write operation

When the timing section is present, all registers with poll: true are read at the specified interval by the background daemon. Polled measurements are automatically published and buffered for retrieval.

The write_delay_ms field is useful for devices that need a short delay between consecutive writes (e.g., controllers that process commands sequentially).

Validation Rules#

The configuration is validated at load time. Invalid configs produce clear error messages.

  • Register names must be unique within the configuration.

  • Registers of the same type must not have overlapping address ranges. Different register types can share addresses (they occupy separate address spaces in Modbus).

  • word_swap is not valid for 16-bit data types (uint16, int16, bool).

  • long_swap is only valid for 64-bit data types (uint64, int64, float64).

  • Scaling is not allowed on coil or discrete register types.

  • bitmap is only supported on uint16 holding or input registers. Bitmap names must be unique across the entire configuration and must not collide with register names. Duplicate bit_index values within a single bitmap are not allowed.

  • write_min, write_max, and write_value_map are only allowed on holding registers. write_min must be less than or equal to write_max. Write value map entries must have unique values and must fall within any configured write limits.

  • All registers in a read_group must share the same register_type and must have poll: true. A group’s address span must not exceed the Modbus read limit (125 registers for holding/input, 2000 bits for coils/discrete).

Connection Resilience#

Modbus TCP connections drop for all the usual reasons, such as network blips, device reboots, and power cycles on a PLC. ModbusDevice handles this transparently. When a read or write hits a transport error, the driver closes the dead socket and pymodbus opens a fresh one on the next operation. No custom retry loop, no explicit open() after a disconnect.

Your code just needs to tolerate the occasional OSError or ConnectionError being raised from read() / write(). If you’re running with autostart=True and background polling, transient errors from a single poll are logged without tearing down the daemon. The next tick will succeed as soon as the device comes back.

Prototyping Without a Device#

Not every development environment has a PLC sitting on the bench. For config authoring, CI tests, and demos, the Modbus package ships with a lightweight TCP simulator you can run alongside your code:

python -m instro.modbus.sim_server

The sim server listens on 127.0.0.1:5020 and exposes holding registers, input registers, coils, and discrete inputs seeded with representative values. Point any TCP config at it by overriding the connection at construction:

from instro.modbus import ModbusDevice

device = ModbusDevice(
    config="my_device.json",
    connection={"transport": "tcp", "host": "127.0.0.1", "port": 5020},
)

Sharing One Bus Across Multiple Units#

An RS-485 multi-drop line, or a TCP-to-serial gateway fronting one, often serves several physical devices at different unit addresses over what is one physical connection. Build a single transport and pass it as each device’s connection, with each device’s own unit_id:

from instro.lib.transports import ModbusTCPTransport
from instro.modbus import ModbusDevice

transport = ModbusTCPTransport(host="192.168.1.100", port=502)

flow_meter = ModbusDevice(config="flow_meter.json", connection=transport, unit_id=1)
level_sensor = ModbusDevice(config="level_sensor.json", connection=transport, unit_id=2)

flow_meter.open()
level_sensor.open()   # shares the connection flow_meter already opened

flow_meter.close()    # connection stays open: level_sensor still holds it
level_sensor.close()  # last owner leaves: connection tears down

The connection opens for the first device and stays open until the last one closes, the same shared-ownership rule every instro transport follows. A shared transport requires an explicit unit_id per device: two devices silently defaulting to the same address on one line is the failure this guards against. See Sharing one connection across unit addresses for the transport-level detail.

Creating a ModbusDevice Instance#

from instro.modbus import ModbusDevice

device = ModbusDevice(config="my_device.json")
device.open()
from instro.modbus import ModbusDevice

# Connection is separate: useful when config is shared across environments
connection = {"transport": "tcp", "host": "192.168.1.100", "port": 502}
device = ModbusDevice(config="my_device.json", connection=connection)
device.open()
from instro.lib.transports import ModbusTCPTransport
from instro.modbus import ModbusConfig, ModbusDevice
from instro.modbus import (
    DeviceInfo, LinearScale, RegisterDef, TimingConfig,
)

config = ModbusConfig(
    device=DeviceInfo(name="my_device", manufacturer="Acme", model="X-100"),
    timing=TimingConfig(poll_interval=0.5),
    registers=[
        RegisterDef(
            name="temperature",
            starting_address=0,
            register_type="holding",
            data_type="float32",
            write_min=0.0,
            write_max=500.0,
        ),
        RegisterDef(
            name="mode",
            starting_address=100,
            register_type="holding",
            data_type="uint16",
            write_value_map={"off": 0, "standby": 1, "run": 2},
        ),
    ],
)

transport = ModbusTCPTransport(host="192.168.1.100", port=502)
device = ModbusDevice(config, connection=transport, unit_id=1, autostart=True)

Parameters#

Parameter

Required

Default

Description

config

Yes

Path to a JSON file, a dict, or a ModbusConfig instance

connection

No

A ModbusTransport (pass the same instance to several devices to share one line) or a dict. Takes precedence over the connection in the config. Required if the config does not include a connection section.

unit_id

No

Unit/slave address (0 to 255; 0 to 247 on an RTU/ASCII transport, since 248-255 are reserved on a real serial line). May also come from the connection block’s unit_id field; values given in both places must agree. Required alongside a shared ModbusTransport. Defaults to 1 only when the device builds its own private transport.

name

No

device.name

Override the instrument name used for channel naming

publishers

No

None

List of publishers to attach at construction time

autostart

No

False

If True and the config contains a timing section, opens the connection and starts background polling on instantiation

Tip

The connection parameter allows you to separate device-specific register maps from environment-specific connection details. One JSON config can be shared across test benches while each environment passes its own connection.

Use add_publisher() to attach a publisher for automatic data streaming:

from instro.lib.publishers import NominalCorePublisher

device.add_publisher(NominalCorePublisher(dataset_rid="<dataset_rid>"))

Examples#

Basic Read and Write#

from instro.modbus import ModbusDevice

device = ModbusDevice(config="temperature_controller.json")
device.open()

# Read a register value by alias
temp = device.read("process_temp")
print(f"Temperature: {temp.latest}")

# Write a register by alias
device.write("setpoint", 75.0)

device.close()

Note

Published channels. Every read/write produces a channel keyed under {name}.{descriptor}, where {name} is the constructor argument and {descriptor} is built from the names in your ModbusConfig. The descriptors are:

Method

Descriptor

Type

read(alias) (per register)

{register.name}

telemetry

read(alias) (per bit in a register’s bitmap)

{bit.name}

telemetry

write(alias, value)

{alias}.cmd

command

Modbus had no v1.0 channel-naming change. legacy_naming is accepted but is a no-op for this category.

Background Polling with Publishers#

When a timing section is present in the config, you can use autostart=True for a one-liner setup, or call open() and start() explicitly for more control:

from instro.modbus import ModbusDevice
from instro.lib.publishers import NominalCorePublisher

# autostart opens the connection and begins polling immediately
device = ModbusDevice(config="heat_exchanger.json", autostart=True)
device.add_publisher(NominalCorePublisher(dataset_rid="ri.datasets.main.dataset.abc123"))

# All registers with poll: true are now being read at the configured interval
# and published to Nominal Core automatically.

# You can also retrieve buffered poll data:
recent_temps = device.get_channel("heat_exchanger.inlet_temp", length=100)

# When done, close stops polling and disconnects
device.close()
from instro.modbus import ModbusDevice
from instro.lib.publishers import NominalCorePublisher

device = ModbusDevice(config="heat_exchanger.json")
device.add_publisher(NominalCorePublisher(dataset_rid="ri.datasets.main.dataset.abc123"))

# Open connection and start polling when ready
device.open()
device.start()

recent_temps = device.get_channel("heat_exchanger.inlet_temp", length=100)

device.close()

Write Value Maps and Write Limits#

Write value maps eliminate magic numbers, and write limits provide protection against accidental out-of-range writes:

from instro.modbus import ModbusDevice

device = ModbusDevice(config="heat_exchanger.json", autostart=True)

# Write using human-readable string labels
device.write("operating_mode", "run")
device.write("operating_mode", "standby")

# Write limits protect against out-of-range values
device.write("setpoint", 150.0)  # OK: within 50.0 to 250.0
try:
    device.write("setpoint", 999.0)  # Raises ValueError
except ValueError as e:
    print(f"Rejected: {e}")

device.close()

RTU (Serial) Connection#

The only difference for RTU is in the config file. The Python code is identical:

{
  "version": 1,
  "protocol": "modbus",
  "device": {
    "name": "field_sensor",
    "description": "RS-485 field temperature sensor"
  },
  "connection": {
    "transport": "rtu",
    "port": "/dev/ttyUSB0",
    "baudrate": 9600,
    "parity": "N",
    "stopbits": 1,
    "bytesize": 8,
    "timeout": 2.0,
    "unit_id": 1
  },
  "registers": [
    {
      "name": "temperature",
      "starting_address": 0,
      "register_type": "input",
      "data_type": "float32",
      "poll": true
    }
  ]
}

Real-World Example: Watlow F4T Thermal Controller#

This example demonstrates programming a thermal profile on a Watlow F4T controller using write value maps and write delays. The config uses write_delay_ms: 300 because the F4T processes commands sequentially and word_swap: true for its byte ordering convention.

import time

from instro.modbus import ModbusDevice

connection = {"transport": "tcp", "host": "192.168.5.222", "port": 502}
f4t = ModbusDevice(config="watlow_f4t.json", connection=connection, autostart=True)

try:
    # Set zone 1 to auto mode using write_value_map (no magic numbers)
    f4t.write("z1_control_mode", "auto")

    # Write a setpoint with built-in write_min/write_max protection
    f4t.write("z1_setpoint", 150.0)

    # Start a thermal profile
    f4t.write("profile_start_profile_num", 1)
    f4t.write("profile_start", "start")

    # Monitor: polled registers stream to publishers automatically
    while True:
        time.sleep(1)

except KeyboardInterrupt:
    f4t.write("profile_pause_terminate", "terminate")
finally:
    f4t.close()

Method Reference#

Method

Purpose

ModbusDevice(config, connection=None, name=None, publishers=None, autostart=False, unit_id=None)

Create a new Modbus client from a JSON config file, dict, or ModbusConfig instance. Optionally pass a separate connection and/or unit address.

add_publisher(publisher)

Attach a publisher for automatic data streaming

open()

Open connection to the Modbus device (TCP or RTU)

close()

Close connection and stop background polling

read(alias, **kwargs)

Read a register by alias, apply scaling, return Measurement

write(alias, value, **kwargs)

Write a value to a register by alias (accepts strings for write_value_map registers), apply reverse scaling, return Command

unit_id

Property (read-only): the Modbus unit/slave address this device is bound to

get_channel(channel_name, length=1, wait_for_new_samples=False, timeout=10.0)

Retrieve buffered poll data for a channel

start()

Start background polling daemon

stop()

Stop background polling daemon

Error Handling#

ModbusDevice raises descriptive errors for common issues:

Error

Cause

ConnectionError: Failed to connect to Modbus device at {target}

Device unreachable, connection refused, or (for RTU) serial port cannot be opened. {target} is host:port for TCP and the serial port path for RTU.

RuntimeError: Modbus client not connected. Call open() first.

Attempting to read/write before opening

KeyError: Register '{alias}' not found. Available: [...]

Alias not defined in config

ValueError: Register '{alias}' is read-only

Attempting to write to an input or discrete register

ValueError: Register '{alias}' value {v} is below write_min ({min})

Value below configured write_min

ValueError: Register '{alias}' value {v} is above write_max ({max})

Value above configured write_max

KeyError: '{value}' is not a valid value for register '{alias}'

String not found in write_value_map

KeyError: Register '{alias}' has no write_value_map. Cannot write string '{value}'

String value passed to a register that has no write_value_map defined

TypeError: Register '{alias}' is a bool/coil type but got float

Incorrect value type for coil/discrete

TypeError: Register '{alias}' is an integer type but got float

Float with fractional part written to integer register

ValueError: Register '{alias}' value {v} is out of range for {data_type} [{min}, {max}]

Value exceeds the bounds of the register’s data type

RuntimeError: Modbus error ... IllegalDataAddress (0x02)

Device rejected the register address

Tip

When writing to integer registers that have scaling configured, the physical value is converted to a raw integer value. Ensure the scaled result produces a whole number. For example, with gain: 0.01 and offset: 0, writing 50.0 produces raw value 5000, which is valid.