3.2.0
A feature release: new TLS / UDP / ASCII transports, inter-request pacing, and a
batch of device-model features.
New features
New transports: UDP, TLS, ASCII (#7, #25)
New connect functions on the pymodbus backend, with framer spanning
socket / rtu / ascii across TCP and UDP:
from modbus_connection.pymodbus import connect_udp, connect_tls, connect_serial
udp = await connect_udp("192.168.1.50", port=502)
ascii_serial = await connect_serial("/dev/ttyUSB0", framer="ascii")connect_tls (new) verifies the server certificate by default — system trust
store plus hostname check. Opt out, point at a private CA, or present a client
certificate for mutual TLS via verify / check_hostname / client_cert:
await connect_tls("device.local") # verify (default)
await connect_tls("192.168.1.50", verify=False) # self-signed device
await connect_tls("device.local", verify="/etc/ssl/my-ca.pem") # private CA (file or dir)
await connect_tls("192.168.1.50", check_hostname=False) # verify cert, skip hostnamePass a fully configured ssl.SSLContext via sslctx for complete control. tmodbus
has no UDP / TLS / ASCII-over-TCP transport; those raise NotImplementedError — use
the pymodbus backend.
Inter-request pacing: message_spacing (#10)
For devices that need a pause between frames, pass message_spacing (seconds) to
any connect function. Every request across every unit sharing the link waits until
that gap has elapsed since the previous one finished.
conn = await connect_serial("/dev/ttyUSB0", message_spacing=0.05)Device-model framework (modbus_connection.model)
Runtime-built groups — ManualComponent (#8). For layouts that come from config
(e.g. YAML) rather than a typed Component subclass. Add targets by key at runtime;
it pools them across all four tables into as few reads as possible.
mc = ManualComponent(unit, max_gap=16)
mc.add("flow_temp", gauge(40, 0.1)) # holding (default)
mc.add("energy", uint32(2), space="input") # input registers
mc.add("relay", coil(5, writable=True)) # coils (FC01)
mc.add("alarm", discrete_input(9)) # discrete inputs (FC02)
data = await mc.async_update() # {"flow_temp": 21.5, "energy": ...}
await mc.write("relay", True)Repeated sub-units — repeating_group (#20, #22). Model one sub-unit once and
get back a typed list of instances with pooled reads. The count is either a
fixed int or a count register read at poll time (e.g. a SunSpec
multiple-MPPT model that advertises how many modules follow).
from modbus_connection.model import Component, integer, repeating_group
from modbus_connection.model.sunspec import uint16
class MPPTModule(Component): # one module, at instance 0's addresses
dc_w = integer(11, scale_register=2)
dc_v = integer(10, scale_register=1)
class Inverter(Component):
modules = repeating_group(uint16(8), MPPTModule, stride=20) # N read from register 8
# or a fixed count:
# modules = repeating_group(3, MPPTModule, stride=20)
inv = Inverter(unit)
await inv.async_update()
inv.modules[0].dc_w # typed per-instance accessPer-instance address shift — base_offset (#19). For contiguous repeating blocks
where every field shares one step, instead of repeating stride on each field.
modules = [MPPTModule(unit, base_offset=i * 20) for i in range(n)]Discrete-input fields (#18). Read-only bits over the FC02 space, a distinct
address space from coils.
class IO(Component):
relay = coil(0, writable=True) # FC01, read/write
fault = discrete_input(0) # FC02, read-only — distinct from coil 0Write a single register via FC16 — force_fc16 (#14). For devices that honour
only FC16, even for one register.
class Inverter(Component):
limit = integer(0, writable=True, force_fc16=True)Affine offset on scaled fields (#15). Numeric fields decode as raw * scale + offset; writable fields invert it.
temp = gauge(0, 0.1, offset=-100) # value = raw * 0.1 - 100Validator callable on writable fields (#13). Pass a callable instead of
writable=True to both mark the field writable and vet the value before each write
(it returns the value to write, or raises to reject).
def in_range(value: int) -> int:
if not 0 <= value <= 100:
raise ValueError(f"{value} out of range")
return value
class Boiler(Component):
setpoint = integer(0, writable=in_range)(We don't ship validators; for ready-made ones, see
probatio.)
Improvements
- Readable ranges are now validated (#28) — overlapping or reversed
register_ranges/coil_ranges/ … raiseValueErrorat declaration instead of
silently mis-planning reads (both were always config bugs). - tmodbus 0.4.0 and its new file-record API (#30). The
[tmodbus]extra now
requires tmodbus ≥ 0.4.0. on_connection_lostfires at most once per connection (#29) — a single drop no
longer triggers multiple owner reloads.- Connect/close errors map to the neutral hierarchy (#21) — a failed connect or
close now raisesModbusConnectionError(etc.), consistent with every other call. - Register and coil block reads share one internal loop (#17); FC43/14 device
identification is covered on both backends (#27); pre-release cleanup and docstring
trims (#23).