ModbusDevice
ModbusDevice is a config-driven Modbus TCP/RTU client that provides alias-based register access, automatic background polling, and full integration with theinstro 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- Code
- Config (my_device.json)
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, andautostart=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.
Key Concepts
Lifecycle Pattern
The typical ModbusDevice workflow follows this pattern:- Define your device: Create a JSON config file or build a
ModbusConfigin Python ModbusDevice(config): Instantiate the client with your config (and optionally a separateconnection)open(): Establish connection to the Modbus devicestart(): Begin background polling (if timing is configured)- Read or Write: Access registers by alias with automatic type handling and scaling
close(): Disconnect and stop any background polling
Register Types
Modbus defines four register types, each occupying a separate address space:Supported Data Types
Multi-register data types span consecutive 16-bit registers: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).
byte_swap: Swap bytes within each 16-bit word
Applies to all data types. Example with a float32 value (0x40490FDB = 3.14159):
word_swap: Swap 16-bit words
Applies to 32-bit and 64-bit types. Example with the same float32:
long_swap: Swap 32-bit halves
Only applies to 64-bit types. Example with a float64 value spanning 4 registers:
Combining swaps
Swaps can be combined. For afloat32 with both byte_swap and word_swap:
Linear Scaling
Many devices store physical values as scaled integers to save bandwidth, like a pressure sensor that returns2500 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.
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).
Bitmap Extraction
Many devices pack several status or alarm flags into a single 16-bit status register to save address space. Bit 0 might bemotor_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.
bitmap defines:
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 sameread_group string to each register in the group:
- 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.
Write Value Maps
Industrial devices commonly use integer codes to select modes or states, such as0 = 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:
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. Declaringwrite_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:
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
Device Section
Metadata about the physical device. Thename field is used as a prefix for channel names when publishing data.
Connection Section
TCP Connection
RTU (Serial) Connection
Common serial port paths:
Register Definitions
Each register in theregisters array defines a named access point for a specific Modbus register or group of registers.
Timing Section
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_swapis not valid for 16-bit data types (uint16,int16,bool).long_swapis only valid for 64-bit data types (uint64,int64,float64).- Scaling is not allowed on
coilordiscreteregister types. bitmapis only supported onuint16holding or input registers. Bitmap names must be unique across the entire configuration and must not collide with register names. Duplicatebit_indexvalues within a single bitmap are not allowed.write_min,write_max, andwrite_value_mapare only allowed onholdingregisters.write_minmust be less than or equal towrite_max. Write value map entries must have unique values and must fall within any configured write limits.- All registers in a
read_groupmust share the sameregister_typeand must havepoll: 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 explicitopen() 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: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:
Creating a ModbusDevice Instance
- From JSON File
- From JSON + Separate Connection
- Programmatic Config
Parameters
Use
add_publisher() to attach a publisher for automatic data streaming:
Examples
Basic Read and Write
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: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 atiming 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:
- With autostart
- Explicit open/start
Write Value Maps and Write Limits
Write value maps eliminate magic numbers, and write limits provide protection against accidental out-of-range writes:RTU (Serial) Connection
The only difference for RTU is in the config file. The Python code is identical: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 useswrite_delay_ms: 300 because the F4T processes commands sequentially and word_swap: true for its byte ordering convention.