Skip to content

3.2.0

Choose a tag to compare

@balloob balloob released this 02 Jul 18:03

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 hostname

Pass 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 access

Per-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 0

Write 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 - 100

Validator 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 / … raise ValueError at 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_lost fires 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 raises ModbusConnectionError (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).

Docs & CI

  • Recommend probatio for write validators (#12); README Home Assistant-reference
    cleanup (#16); GitHub Actions bumped to Node 24 (#6).