Skip to content

Device modelling overview

modbus_connection.model is an optional, backend-neutral framework. It maps a device’s registers and coils to typed Python attributes, then reads the whole device — or one sub-system — in as few Modbus calls as possible. It talks only to a ModbusUnit, so it runs over any backend and over the mock.

A Component is a device sub-system. Declare its registers and coils as class attributes using the built-in fields:

from modbus_connection.model import Component, gauge, uint32, coil
class Meter(Component):
voltage = gauge(0, 0.1, unit="V") # scaled 16-bit
"""Grid voltage."""
current = gauge(1, 0.1, unit="A")
"""Grid current."""
energy = uint32(2, unit="Wh") # 32-bit over two registers
"""Lifetime energy."""
relay = coil(0, writable=True)
"""Load relay."""
meter = Meter(unit)
await meter.async_update() # one block read of registers 0-3, one of coil 0
meter.voltage # float | None
await meter.write("relay", True)

The string under each field is an attribute docstring. It is optional, but editors show it when hovering meter.voltage anywhere in the codebase.

async_update() reads every field, decodes it, and stores the result. Reading an attribute returns the decoded value, or None for a field that has not been read yet or a device sentinel that decodes to “no value”. A component reads only its own registers, so it can refresh independently.

The update is not one request per field. Neighbouring addresses are pooled into block reads, bounded by what the device is willing to serve. See Reading a device for the pooling knobs, the readable ranges, and what a refused block does to an update.

A component’s register fields default to the holding space (FC03). For a read-only sub-system whose data lives in input registers (FC04), set register_space = "input". The field declarations are unchanged:

class Sensors(Component):
register_space = "input"
flow_temp = gauge(5, 0.1, unit="°C") # read with FC04

Input and holding are separate address spaces (input 507 ≠ holding 507), so the planner never merges them into one read. Input registers are physically read-only, so writing an "input" field raises.

Bits work the same way over their own pair of spaces:

from modbus_connection.model import Component, coil, discrete_input
class IO(Component):
relay = coil(0, writable=True) # FC01, read/write
fault = discrete_input(0) # FC02, read-only — distinct from coil 0

coil fields are read and written via FC01. discrete_input fields are read from FC02 and are read-only. A single component may declare both. Coil 12 and discrete input 12 are different addresses, so they are planned and read separately.

Component.write(field, value) writes a register or coil by attribute name:

await meter.write("relay", True)

The field must be marked writable, optionally with a validator that vets the value before it reaches the device. Override write() in a subclass for any device-specific write sequencing.

Each component has its own update listeners, fired after each update:

unsubscribe = meter.add_update_listener(lambda: print("updated", meter.voltage))
await meter.async_update() # prints
unsubscribe()

Pass async_update(notify=False) to read without firing the listeners, for a caller that notifies them itself.

Building a device library? Read The device object once you know the basics here — it shows how components combine into a full library.

The rest of this section, in reading order: