Library entrypoint
modbus-connection is a foundation you build a device library on. A good device
library exposes one top-level object that a consumer constructs from a
ModbusUnit, and reads sub-systems as plain Python attributes. This page shows
the shape, using the trovis-modbus
library (a Samson TROVIS 557x heating controller) as the worked example.
The shape
Section titled “The shape”The entrypoint class:
- takes a
ModbusUnit— never a connection, and never a host/port. The consumer owns the connection and hands you a unit. - constructs its sub-systems as
Componentinstances, - applies the device’s readable ranges to them,
- pools them into one
ComponentGroup, and - exposes
async_update()plus typed access to each sub-system.
from __future__ import annotations
from collections.abc import Iterablefrom dataclasses import dataclassfrom typing import TYPE_CHECKING
from modbus_connection.model import Component, ComponentGroup
from .sensors import Sensorsfrom .controller import Controllerfrom .heating_circuit import HeatingCircuitfrom .hot_water import HotWaterfrom .ranges import ranges_for_model, heating_circuit_count
if TYPE_CHECKING: from modbus_connection import ModbusUnit
class Trovis557x: """A Samson TROVIS 557x heating controller."""
def __init__( self, unit: ModbusUnit, *, model: int = 5578, detected_sensors: Iterable[str] = (), ) -> None: self._unit = unit self.model = model self.detected_sensors = frozenset(detected_sensors)
# Sub-systems, each a Component. Repeated ones take an index. self.controller = Controller(unit) self.sensors = Sensors(unit) self.heating_circuit_1 = HeatingCircuit(unit, index=1) self.heating_circuit_2 = HeatingCircuit(unit, index=2) self.hot_water = HotWater(unit)
# Apply the model's readable ranges to every sub-system, so the group's # pooled reads never cross an unreadable gap. register_ranges, coil_ranges = ranges_for_model(model) for component in self.components: component.register_ranges = register_ranges component.coil_ranges = coil_ranges
# One pooled reader for the whole device. self._group = ComponentGroup(unit, self.components)
@property def components(self) -> tuple[Component, ...]: """Every actively polled sub-system.""" return ( self.controller, self.sensors, self.heating_circuit_1, self.heating_circuit_2, self.hot_water, )
async def async_update(self) -> None: """Refresh all sub-systems in pooled Modbus reads.""" await self._group.async_update()The consumer then works entirely in Python objects:
import asynciofrom modbus_connection.tmodbus import connect_tcpfrom trovis_modbus import Trovis557x
async def main() -> None: connection = await connect_tcp("192.168.1.50", port=502, framer="rtu") try: unit = connection.for_unit(246) device = Trovis557x(unit) await device.async_update()
print("Outside temperature:", device.sensors.outside_1) print("Rk1 day setpoint:", device.heating_circuit_1.room_setpoint_day) finally: await connection.close()
asyncio.run(main())A setup probe
Section titled “A setup probe”A device whose layout depends on its model shouldn’t read everything before it knows the model. Expose a lightweight classmethod probe that reads only the identity registers it needs to configure the full object:
@dataclass(frozen=True)class TrovisProbe: model: int detected_sensors: tuple[str, ...]
class Trovis557x: @classmethod async def async_probe(cls, unit: ModbusUnit) -> TrovisProbe: """Read only the safe identity + sensor data needed for setup.""" model = (await unit.read_holding_registers(0, 1))[0]
register_ranges, coil_ranges = ranges_for_model(model) sensors = Sensors(unit) sensors.register_ranges = register_ranges sensors.coil_ranges = coil_ranges await sensors.async_update()
return TrovisProbe(model=model, detected_sensors=sensors.detected_sensor_names)The consumer probes first, then constructs the full device from the result:
probe = await Trovis557x.async_probe(unit)device = Trovis557x(unit, model=probe.model, detected_sensors=probe.detected_sensors)await device.async_update()Writes behind a switch
Section titled “Writes behind a switch”Writing to industrial devices is often gated. A common pattern is a global
“writing enabled” switch the consumer flips explicitly, so a write can never
happen by accident. Many devices back this with a lock register — write 1
to unlock writes, 0 to lock them again — so the switch is just a register write:
# The device's write-enable register (1 = unlocked, 0 = locked)._WRITE_LOCK_ADDRESS = 100
class Trovis557x: async def async_enable_writing(self) -> None: await self._unit.write_register(_WRITE_LOCK_ADDRESS, 1) self._writing_enabled = True
async def async_disable_writing(self) -> None: await self._unit.write_register(_WRITE_LOCK_ADDRESS, 0) self._writing_enabled = FalseSome devices instead expect an access code rather than a plain 1; write that
value to the same register. Either way it’s an ordinary Modbus write — no special
helper needed.
await device.async_enable_writing()try: await device.heating_circuit_1.write("room_setpoint_day", 21.5)finally: await device.async_disable_writing()Principles
Section titled “Principles”- Take a
ModbusUnit, not a connection. The consumer owns and closes the link; your library only reads and writes registers. This keeps the library backend-neutral — it works over pymodbus, tmodbus, or the mock unchanged. - One sub-system per
Component. Group registers by function; give each its own file. It keeps the address map readable and lets a sub-system refresh alone. - Pool with a
ComponentGroup. The whole device reads in a handful of Modbus calls instead of one per field. - Carry metadata on the fields.
unit=, ranges, and validators live next to the address, so the model is the datasheet.
- Query helper — a standalone CLI to dump every value from a real device without any application around it.