Skip to main content
Yanked

This release has been yanked by its maintainers, and will be ignored by installers, except when explicitly specified.
Consider using release 0.1.8 instead.

Modbus Event Connect

Read and write devices over Modbus and micro_nabto in Python, and be told when a value changes.

A device is described once, as a model. The library then takes care of the rest:

  • reading only what is needed, as often as it is needed, in as few requests as possible
  • telling you when a value changes
  • checking a value before it is written
  • retrying and recovering when the device is busy or goes away
  • saying how trustworthy every value is, so "offline", "no reading" and a real value never look alike

Which part is for you

You are... Read
Building an application for a device that already has a model Part 1: Using a device
Describing a new device: its registers, units and settings Part 2: Describing a device
Working on the library itself docs/design.md
pip install modbus-event-connect

How the pieces fit

Model   what the device has: its points, where they live, how to decode them   (Part 2)
Device  how to reach it: ModbusDevice or MicroNabtoDevice                      (Part 1)
Client  joins the two: connect, subscribe, poll, write                          (Part 1)

A point is one value the device has, such as a temperature or a setting. Its key, Key("temperature", float), names it everywhere and says what its value is. A key is its text wherever it is stored, and a type checker knows from it what reading the point gives and what writing it takes.

Everything is imported from four places. The modules inside them are internal, and may change in any release.

Import from For
modbus_event_connect the client, values, errors, and what a model is made of
modbus_event_connect.modbus Modbus: the connection, the device, register kinds and numbering
modbus_event_connect.micro_nabto micro_nabto: discovery, the session, the device, register kinds
modbus_event_connect.testing testing a model: simulated devices, a fake clock, the model walker

Part 1: Using a device

A complete program

import asyncio
from modbus_event_connect import Client
from modbus_event_connect.modbus import ModbusDevice
from my_devices import TARGET, TEMPERATURE, THERMOSTAT   # a model and its keys, see Part 2

def on_change(key, old, new):
    print(f"{key}: {new.value} ({new.quality.name})")

async def main():
    client = Client(ModbusDevice.tcp("<device-ip>"), THERMOSTAT)
    await client.connect()                  # reads every point once
    client.subscribe(TEMPERATURE, on_change)
    await client.write(TARGET, 21.5)
    try:
        while True:
            await client.poll()             # reads whatever is due
            await asyncio.sleep(1)
    finally:
        await client.disconnect()

asyncio.run(main())

The client never reads on its own. Your program calls poll(), and each call reads the points that are due. A call when nothing is due returns at once without contacting the device, so calling it every second is cheap.

client.seconds_until_next_poll() says how long until something is due, if you would rather sleep longer. A subscription made while you sleep can make a point due sooner, so wake up at least as often as your fastest poll rate.

Connecting

from modbus_event_connect.modbus import ModbusDevice, ModbusTcpConnection
from modbus_event_connect.micro_nabto import MicroNabtoDevice

# Modbus TCP, one device
device = ModbusDevice.tcp("<device-ip>", unit_id=1)

# Several Modbus devices behind one gateway, sharing its connection
gateway = ModbusTcpConnection("<gateway-ip>")
heating = ModbusDevice(gateway, unit_id=1)
blinds = ModbusDevice(gateway, unit_id=3)

# micro_nabto; with device_id, the device is found again if its address changes
ventilation = MicroNabtoDevice.udp("<email-paired-with-the-device>", host="<device-ip>",
                                   device_id="<device-id>")

Behind a gateway, a device that stops answering does not slow down the others.

A micro_nabto device says what it is when you connect. Give the client a function instead of a model, and it picks the model from that:

def pick_model(identity):
    return VENTILATION if identity["device_model"] == 1140 else None

client = Client(ventilation, pick_model)

connect() raises when it cannot finish:

Error Meaning
CannotConnectError The device could not be reached, or did not answer every read. Try again later.
AuthenticationError A micro_nabto device refused the email. Ask the user to check it.
UnsupportedDeviceError The device answered, but no model matches it.

Reading values

client.subscribe(key, callback) calls callback(key, old, new) at once with the current value, then on every change. old is None the first time. It returns a function that unsubscribes.

client.value(key) gives the current value at any time, or None before the first read. Its type follows the key: client.value(TEMPERATURE) is a DataValue[float], and a key of another type than the model gives the point raises TypeError.

Every value is a DataValue with .value, .quality, .timestamp, and .raw: what the device answered, before it was decoded.

Quality Meaning Show it as
GOOD A real value. the value
NO_DATA The device says it has no reading, such as a sensor not fitted, or sent a number no state names. unknown
STALE The last read failed. .value and .timestamp are the last good read's. the value, or unavailable
OFFLINE Something behind the device is not answering. It will come back. unavailable
MISSING This device does not have the value. leave it out

How often values are read

Every point is read once when connecting. After that, a point is read on its poll rate while someone subscribes to it:

Poll rate Read every
FAST 10 s
MEDIUM (the default) 30 s
SLOW 60 s
RARE 15 min
STATIC once, when connecting

These are the library's defaults; a model may set its own. You can change them:

client.set_poll_interval(PollRate.FAST, 5)       # every FAST point
client.set_poll_interval(TEMPERATURE, 2)         # one point
client.set_poll_interval(TEMPERATURE, None)      # back to the model's interval

A model can set a floor, the shortest interval its device copes with. set_poll_interval returns the interval actually used.

To read now, call await client.refresh([TEMPERATURE]). refresh(PollRate.SLOW) reads a whole poll rate, and refresh() reads everything. subscribe(key, callback, poll=False) is told about changes without asking for the point to be read on a timer.

Writing

accepted = await client.write(TARGET, 21.5)

write returns whether the device accepted the value. A value of another type than the key's is a type error before the program runs. Before anything is sent, the value is also checked against the point's limits, or its states, and InvalidValueError says why it was refused.

  • Writes are sent one at a time, in order.
  • If a setting is written several times while earlier writes are still waiting, only the newest value is sent. Commands are always all sent.
  • After a write, the point is read back, so subscribers see what the device really did.
  • await client.write_sequence([Write(MODE, Mode.HEAT), Write(TARGET, 21.5)]) checks every value first, then writes them in order and stops at the first refusal.
  • client.status(Status.WRITE_PENDING).value is True while writes are waiting or being sent.

Client(device, model, read_only=True) refuses every write with ReadOnlyError before it reaches the device. Use it while developing against a real installation.

Connection state

After connect(), the client follows whether the device answers. You do not need to connect again after an outage.

  • client.status(Status.CONNECTED).value is False while the device does not answer.
  • client.subscribe_status(Status.CONNECTED, callback) tells you when that changes. A status is not a point, so a model's keys never collide with it.
  • When the device answers again, every polled point is read at once.
  • await client.disconnect() lets go of the device.

What this unit has

A model can describe several variants, and parts that may or may not be installed, such as rooms. After connect():

client.points the points this unit has, by their keys, each with its type
client.has(key), client.can_write(key) whether it has the key, and whether it can be written
client.instances("room") which rooms, zones or channels are installed, such as (1, 3)
client.unavailable_reasons keys the unit does not have, with the reason
await client.rescan() find out again, for example after a room was added

In a larger application

  • Run the poll loop as a task on the application's event loop. When the device is removed, cancel the task, then await client.disconnect().
  • Subscription callbacks run on that event loop, from within the client's own calls, never on another thread, so they may update the application's state directly.
  • Turn the errors connect() raises into the application's own: try again later after CannotConnectError, ask the user for new credentials after AuthenticationError.
  • How a value's quality is shown is the application's choice. client.consecutive_failures(key) lets it wait a few failed reads before calling a STALE value unavailable.

Errors

Error Raised by When
CannotConnectError connect(), rescan() The device could not be reached, or left a read unanswered.
AuthenticationError connect() A micro_nabto device refused the email.
UnsupportedDeviceError connect() No model matches the device.
NotConnectedError most methods Used before connect() succeeded.
InvalidValueError write() The point cannot take the value.
ReadOnlyError write() The client is read-only.
KeyError subscribe(), write() This unit has no such key.
TypeError value(), subscribe(), write() The key names another type than the model gives the point.

Part 2: Describing a device

A model is plain data: a list of points, each saying where its value lives and how to decode it. You write it once, from the device's manual, and every app uses it.

A first model

from enum import IntEnum
from modbus_event_connect import DataType, Key, Limits, Model, Point, PollRate, Section, Unit
from modbus_event_connect.modbus import HoldingRegister, InputRegister, ModbusOptions, plain

class Mode(IntEnum):                      # the states the device documents
    OFF = 0
    HEAT = 1
    COOL = 2

TEMPERATURE = Key("temperature", float)
TARGET = Key("target", float)
MODE = Key("mode", Mode)
SERIAL_NUMBER = Key("serial_number", int)

THERMOSTAT = Model(name="Thermostat", manufacturer="Example",
                   options=ModbusOptions(numbering=plain(first_address=1)),  # the manual gives addresses
                   read_back_after=2.0,   # seconds before a write shows in a read
                   sections=[Section([
    Point(TEMPERATURE,
          read=InputRegister(10),         # where the value is
          data_type=DataType.INT16,       # a signed 16-bit number
          scale=0.1,                      # the device sends 215 for 21.5
          unit=Unit.CELSIUS,
          valid_raw=range(-0x8000, 0x7FFF)),   # all but 0x7FFF, what it sends with no reading
    Point(TARGET,
          read=HoldingRegister(20), write=HoldingRegister(20),
          data_type=DataType.INT16, scale=0.1, unit=Unit.CELSIUS,
          limits=Limits(min=5, max=30, step=0.5)),   # checked before anything is written
    Point(MODE,
          read=HoldingRegister(21), write=HoldingRegister(21)),   # 0, 1 or 2; nothing else
    Point(SERIAL_NUMBER,
          read=InputRegister(1), data_type=DataType.UINT32,
          poll_rate=PollRate.STATIC),     # read once, when connecting
                   ])])

A key's type is what the point's value is: bool for BOOL or a bit, str for text, int for an integer that stays whole after scaling, float for any number, and an IntEnum for a number that names a state. Publish the keys with the model; they are how an application names the points, with their types.

A mistake in a point, such as limits on a point that cannot be written, or a key type its registers cannot hold, raises ValueError when the point is created, naming every problem at once. A mistake between points, such as two overlapping, raises ModelError from connect(); test for it first, see Testing a model.

Keys are forever. Applications store them, for example in the ids of what they build. Choose them carefully, and never rename one. The names of a state's members are forever too: an application may show or store them.

Where a value lives

Protocol Read side Write side
Modbus InputRegister, HoldingRegister, DiscreteInput, Coil HoldingRegister, Coil
micro_nabto DatapointRegister, SetpointRegister SetpointRegister

A point has a read side, a write side, or both. They may differ, for a device that reports a setting in one place and takes it in another.

Every model states its protocol's options, ModbusOptions(...) or MicroNabtoOptions(), and read_back_after, see Writing. A model without them cannot be created.

Modbus addresses. Manuals number registers in different ways, and a wrong guess shifts every value by one register while still looking plausible. So there is no default: the model says once how its manual numbers registers: which address its first register is.

The manual numbering=
gives the addresses themselves: register 1 is address 1 plain(first_address=1)
counts from 1, register 1 being address 0 plain(first_address=0)
uses 0xxxx to 4xxxx, 40001 being address 0 (Modicon) modicon(digits=5, first_address=0)
uses 0xxxx to 4xxxx, 40001 being address 1 modicon(digits=5, first_address=1)
uses 000001 to 465536, 400001 being address 0 modicon(digits=6, first_address=0)
uses 000001 to 465535, 400001 being address 1 modicon(digits=6, first_address=1)
numbers its own way a RegisterNumbering of your own, as below

A RegisterNumbering lists, for each table, ranges of numbers and the address each range starts at. A manual that continues a table in another range gets one NumberRange for each.

from modbus_event_connect.modbus import ModbusOptions, NumberRange, RegisterNumbering

NUMBERING = RegisterNumbering(
    input_registers=[NumberRange(first=30001, last=39999, address=0)],
    holding_registers=[NumberRange(first=40001, last=49999, address=0)],
)
Model(..., options=ModbusOptions(numbering=NUMBERING))

Point("temperature", read=InputRegister(30011))     # sent as address 10

A number in none of its table's ranges is a mistake in the model. It is reported before anything is read, never guessed at.

ModbusOptions also says how many registers the device takes in one read. The Modbus specification allows 125 registers or 2000 bits; set max_registers lower for a device that takes fewer. Neighbouring points are read together, up to that limit.

micro_nabto points name an object and an address, DatapointRegister(27, obj=0), as the device's documentation gives them.

How a value is decoded

Field What it does
data_type UINT16 (the default), INT16, UINT32, INT32, UINT64, INT64, FLOAT32, FLOAT64, BCD16, BCD32, BOOL
DataType.bit(3) one bit of a register
DataType.string(8) text over 8 registers
word_order, byte_order for values over several registers; high word and big-endian by default
scale, offset value = raw × scale + offset
precision decimals to round to; by default, enough for scale and offset
transform a conversion after scaling, such as Transforms.SECONDS_AS_MINUTES
valid_raw the raw numbers that are values, of an integer or a BOOL register; any other reads as NO_DATA and cannot be written

valid_raw takes a range or a set, and a range costs nothing however wide:

valid_raw=range(0, 0xFFFFFFFF)     # a UINT32's 0 to max-1; 0xFFFFFFFF means no reading
valid_raw=range(0, 2)              # a switch's 0 and 1; 255, or 11, is no value
valid_raw={*range(0, 230), 255}    # everything but 230 to 254

It names the device's own numbers, before scale and offset, and a number the data type cannot hold is refused when the point is created: range(0, 0xFFFF) on an INT16 would never match, as the device's 0xFFFF is -1 to it.

A FLOAT32 or FLOAT64 that reads NaN or infinity is NO_DATA by itself. A point whose key is an IntEnum reads its number as that state; a number no state names is NO_DATA, and .raw still holds it.

Units

unit=Unit.CELSIUS and so on. Units are written with their international symbols, following the SI's rules (°C, kW·h, m³/h). For a unit the SI does not define, the symbol is UCUM's: wk, mo, a.

str(Unit.KILOWATT_HOUR) is its symbol, kW·h. Unit.KILOWATT_HOUR.code is its UCUM code, kW.h, plain ASCII for machines.

Convert to the unit a person expects in the model, not in the app: a device that counts minutes for a setting people think of in hours gets transform=Transforms.MINUTES_AS_HOURS and unit=Unit.HOURS.

How often

Field What it does
poll_rate FAST, MEDIUM (the default), SLOW, RARE or STATIC; see the table in Part 1
poll_always=True read even when no one subscribes, for a value the model itself relies on
deadband=0.2 changes smaller than this are not reported
Model(poll_intervals=...) this device's own seconds per poll rate
Model(min_poll_interval=5) the shortest interval this device copes with; apps cannot go below it

Pick the rate from how fast the value changes, not from how often it is looked at: temperatures MEDIUM, on/off states FAST, settings SLOW, firmware versions STATIC.

Writing

Field What it does
limits=Limits(min=5, max=30, step=0.5) refuses anything else, in the units a user sees
write_kind=WriteKind.STATE a setting (the default): writes that queue up collapse to the newest
write_kind=WriteKind.COMMAND an action: every write is sent
pulse=Pulse(idle=0, after=1.0) a COMMAND that is written back to idle after 1 s
read_back_after=5.0 this point takes longer than its device to show a write
on_write=Refresh(["mode", "target"]) also read these after writing, since the write changes them
on_change=Refresh(Labels(room=3)) read these when this point's value changes

After a write, the written point is read back, so apps see what the device holds. How long a device takes before a read shows a write differs from device to device, and only you can know it for yours. So every model states it: Model(read_back_after=...), in seconds. A point that is slower than the rest of its device gets its own read_back_after.

A point with on_write is read back together with its targets, after the same delay, or after Refresh(targets, after=...) seconds. With until_stable=30, the targets are read again while they keep changing, for up to 30 s, for a device that moves slowly to a new value.

Measure it on your device, with a point you may change and two values it may take:

from modbus_event_connect.testing import measure_read_back

measured = await measure_read_back(client, TARGET, (20.0, 21.0), delays=(1, 2, 3, 4, 5))
print(measured)
target: read back after a write
       1 s   0 of 3
       2 s   0 of 3
       3 s   3 of 3
       4 s   3 of 3
       5 s   3 of 3
  read_back_after=3

It writes to the device, so use a client that is not read-only, on a device you may change. It writes the point's old value back at the end. Before each measured write it writes the other value and waits the longest delay, so a late earlier write cannot pass for a quick one.

Variants of a device

A model can cover several variants. The identity says which one this unit is: what the device reported when connecting, plus any identity_points the model reads first.

Model(...,
      identity_points=[Point(Key("firmware", int), read=InputRegister(0), poll_rate=PollRate.STATIC)],
      sections=[Section(common_points),
                Section(cooling_points, when=lambda identity: identity["firmware"] >= 20)])

For devices so different that they deserve their own model, give the client a function that picks one from the identity, as shown in Connecting.

Repeated parts, and what is installed

A device with rooms, zones or channels describes one of them once. A scan step, run when connecting, finds out which ones this installation has:

from modbus_event_connect import RepeatedSection, Labels, Scan

def room(n):
    return [Point(Key(f"room_{n}_installed", int), read=InputRegister(100 + 10 * n),
                  poll_rate=PollRate.STATIC),
            Point(Key(f"room_{n}_temperature", float), read=InputRegister(101 + 10 * n),
                  data_type=DataType.INT16, scale=0.1, unit=Unit.CELSIUS)]

async def skip_empty_rooms(scan: Scan):
    for n in range(1, 9):
        installed = await scan.read([f"room_{n}_installed"])
        if installed[f"room_{n}_installed"].value == 0:
            scan.set_available(Labels(room=n), False, reason="no room")

HEATING = Model(name="Heating", manufacturer="Example",
                options=ModbusOptions(numbering=plain(first_address=1)), read_back_after=2.0,
                sections=[RepeatedSection(room, range(1, 9), label="room")],
                scan_steps=[skip_empty_rooms])

Every point room(n) returns is labelled room=n, so Labels(room=3) selects room 3's points. After connect(), client.instances("room") lists the installed rooms, and client.points has only their points.

Scan steps run in order. If one of their reads goes unanswered, connect() fails rather than guess what is installed.

Testing a model

Test a model without the device. assert_models_valid resolves each model against every identity you expect to meet, and fails listing every problem: overlapping registers, a refresh naming a key that does not exist, a transform that does not convert back, a section no identity includes.

from modbus_event_connect.testing import (SimulatedModbusDevice, SimulatedModbusGateway,
                                          assert_models_valid)

def test_models_are_valid():
    assert_models_valid(THERMOSTAT, identities=[{}])

async def test_temperature_is_read():
    device = SimulatedModbusDevice(input_registers={1: 0, 2: 1234, 10: 215},
                                   holding_registers={20: 210})
    client = Client(ModbusDevice(SimulatedModbusGateway({1: device})), THERMOSTAT)
    await client.connect()
    assert client.value(TEMPERATURE).value == 21.5

SimulatedModbusDevice refuses an unknown address with exception 0x02, Illegal Data Address, as the Modbus specification says, and can be made busy or silent; SimulatedModbusGateway can be made slow or cut off. SimulatedMicroNabtoDevice answers as a Nilan CTS 402 was measured to. FakeClock lets a test move time forward without waiting.


Disclaimer

Provided "as is", without warranty of any kind. You are responsible for the safe operation of your devices.

License

MIT. See LICENSE.

Release files for modbus-event-connect 0.1.9

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for modbus-event-connect 0.1.9
File Size Uploaded
modbus_event_connect-0.1.9.tar.gz 137.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for modbus-event-connect 0.1.9
File Interpreter ABI Platform
modbus_event_connect-0.1.9-py3-none-any.whl Python 3 none any Details

Total release size: 219.4 kB

Release files / modbus_event_connect-0.1.9.tar.gz

Download URL modbus_event_connect-0.1.9.tar.gz
Size 137.8 kB
Tags Source
SHA-256 checksum
How to use checksums
59ea7a756a7d2d8d3b321067f48b3211dd5a56ccdaf03c09d3a39a3fb5abd775
BLAKE2b-256 checksum
How to use checksums
0b5da45dbf2c402d80720e22b040ac59a016515157eec846ed2288275f923b43
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release files / modbus_event_connect-0.1.9-py3-none-any.whl

Download URL modbus_event_connect-0.1.9-py3-none-any.whl
Size 81.5 kB
Tags Python 3
SHA-256 checksum
How to use checksums
3301778232ec1c3612ba3f7d78cfebd206ce4f35fef589d161c2e30db2bf599c
BLAKE2b-256 checksum
How to use checksums
2c5da942e62891d31c3a914cf04f4d895ffa45858cd68b7c2c273c8a196e581a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 25, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.9 This release

2 release files

0.1.8

2 release files

0.1.7

2 release files

0.1.6

2 release files

0.1.5

2 release files

0.1.4

2 release files

0.1.3

2 release files

0.1.2

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page