modbus-connection 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,31 @@
1
+ """modbus_connection — a small, backend-neutral Modbus connection abstraction.
2
+
3
+ The top-level package is the pure interface: the ``ModbusConnection`` /
4
+ ``ModbusUnit`` Protocols, the shared ``WordOrder`` type, and the exception
5
+ hierarchy. It imports no Modbus backend and no Home Assistant.
6
+
7
+ Pick a backend to actually talk to a device:
8
+
9
+ - ``modbus_connection.pymodbus`` — ``connect_tcp`` / ``connect_serial`` over
10
+ pymodbus (install the ``[pymodbus]`` extra).
11
+ - ``modbus_connection.tmodbus`` — the same over tmodbus (the ``[tmodbus]`` extra).
12
+ """
13
+
14
+ from ._protocol import ModbusConnection, ModbusUnit
15
+ from ._types import WordOrder
16
+ from .exceptions import (
17
+ ModbusConnectionError,
18
+ ModbusError,
19
+ ModbusExceptionError,
20
+ ModbusTimeoutError,
21
+ )
22
+
23
+ __all__ = [
24
+ "ModbusConnection",
25
+ "ModbusConnectionError",
26
+ "ModbusError",
27
+ "ModbusExceptionError",
28
+ "ModbusTimeoutError",
29
+ "ModbusUnit",
30
+ "WordOrder",
31
+ ]
@@ -0,0 +1,105 @@
1
+ """The backend-neutral Modbus Protocols.
2
+
3
+ This module defines the contract that every backend (pymodbus, tmodbus, ...)
4
+ implements. It imports nothing from any Modbus library and nothing from Home
5
+ Assistant. Consumers type against ``ModbusUnit`` / ``ModbusConnection`` and stay
6
+ ignorant of which backend produced them.
7
+ """
8
+
9
+ from collections.abc import Callable
10
+ from typing import Protocol, runtime_checkable
11
+
12
+ from ._types import WordOrder
13
+
14
+
15
+ @runtime_checkable
16
+ class ModbusUnit(Protocol):
17
+ """A stateless handle bound to one unit (unit ID) on a shared connection.
18
+
19
+ Holds no buffered state beyond the address. Methods RAISE on any failure
20
+ (timeout, exception response, link down); they never return ``None`` or
21
+ swallow errors. A unit has NO lifecycle methods: a consumer cannot connect
22
+ or close the link it rides on.
23
+ """
24
+
25
+ @property
26
+ def connected(self) -> bool: ...
27
+
28
+ # raw register I/O
29
+ async def read_holding_registers(self, address: int, count: int) -> list[int]: ...
30
+ async def read_input_registers(self, address: int, count: int) -> list[int]: ...
31
+ async def write_register(self, address: int, value: int) -> None: ...
32
+ async def write_registers(self, address: int, values: list[int]) -> None: ...
33
+
34
+ # raw coil / discrete-input I/O
35
+ async def read_coils(self, address: int, count: int) -> list[bool]: ...
36
+ async def read_discrete_inputs(self, address: int, count: int) -> list[bool]: ...
37
+ async def write_coil(self, address: int, value: bool) -> None: ...
38
+ async def write_coils(self, address: int, values: list[bool]) -> None: ...
39
+
40
+ # typed reads/writes — the package owns datatypes + word/byte ordering
41
+ async def read_uint16(self, address: int) -> int: ...
42
+ async def read_uint32(
43
+ self, address: int, *, word_order: WordOrder = "big"
44
+ ) -> int: ...
45
+ async def read_int16(self, address: int) -> int: ...
46
+ async def read_float32(
47
+ self, address: int, *, word_order: WordOrder = "big"
48
+ ) -> float: ...
49
+ async def read_string(self, address: int, length: int) -> str: ...
50
+ async def write_uint16(self, address: int, value: int) -> None: ...
51
+ async def write_float32(
52
+ self, address: int, value: float, *, word_order: WordOrder = "big"
53
+ ) -> None: ...
54
+
55
+ # The full Modbus function-code set (complete spec). A backend that doesn't
56
+ # implement a given code raises NotImplementedError.
57
+ async def read_exception_status(self) -> int: ... # 0x07
58
+ async def report_server_id(self) -> bytes: ... # 0x11
59
+ async def mask_write_register(
60
+ self, address: int, and_mask: int, or_mask: int
61
+ ) -> None: ... # 0x16
62
+ async def read_write_registers(
63
+ self,
64
+ read_address: int,
65
+ read_count: int,
66
+ write_address: int,
67
+ write_values: list[int],
68
+ ) -> list[int]: ... # 0x17
69
+ async def read_fifo_queue(self, address: int) -> list[int]: ... # 0x18
70
+ async def read_device_identification(self) -> dict[int, bytes]: ... # 0x2B / 0x0E
71
+ async def read_file_record(
72
+ self, file: int, record: int, length: int
73
+ ) -> list[int]: ... # 0x14
74
+ async def write_file_record(
75
+ self, file: int, record: int, values: list[int]
76
+ ) -> None: ... # 0x15
77
+ async def diagnostics(self, sub_function: int, data: int = 0) -> int: ... # 0x08
78
+ async def get_comm_event_counter(self) -> tuple[int, int]: ... # 0x0B
79
+ async def get_comm_event_log(self) -> bytes: ... # 0x0C
80
+
81
+ def on_connection_lost(self, callback: Callable[[], None]) -> Callable[[], None]:
82
+ """Register a callback fired when the link drops; returns an unsubscribe."""
83
+
84
+
85
+ @runtime_checkable
86
+ class ModbusConnection(Protocol):
87
+ """A shared, internally-serialized, already-connected link to a Modbus network.
88
+
89
+ You never construct or ``connect()`` this — a backend connect function returns
90
+ a live instance (e.g. ``modbus_connection.pymodbus.connect_tcp(...)``).
91
+ Consumers NEVER receive this object — only a ``ModbusUnit``. It is held by the
92
+ connection's OWNER.
93
+ """
94
+
95
+ @property
96
+ def connected(self) -> bool: ...
97
+
98
+ def for_unit(self, unit_id: int) -> ModbusUnit: ...
99
+
100
+ def on_connection_lost(self, callback: Callable[[], None]) -> Callable[[], None]:
101
+ """Owner-level drop callback; returns an unsubscribe."""
102
+
103
+ # Teardown — OWNER ONLY. There is no connect(): the instance is already live
104
+ # and reconnects are the owner's job, never the abstraction's.
105
+ async def close(self) -> None: ...
@@ -0,0 +1,11 @@
1
+ """Shared types for the modbus_connection abstraction."""
2
+
3
+ from typing import Literal
4
+
5
+ WordOrder = Literal["big", "little"]
6
+ """Order of 16-bit registers within a multi-register value.
7
+
8
+ ``"big"`` puts the most-significant word first (the common Modbus convention);
9
+ ``"little"`` puts the least-significant word first. Byte order *within* each
10
+ register is always big-endian, per the Modbus spec.
11
+ """
@@ -0,0 +1,33 @@
1
+ """Exceptions raised by modbus_connection backends.
2
+
3
+ These are backend-neutral: both the pymodbus and tmodbus implementations map
4
+ their library-specific errors onto this small hierarchy, so consumers catch the
5
+ same types regardless of which backend is in use.
6
+ """
7
+
8
+
9
+ class ModbusError(Exception):
10
+ """Base class for every error raised by a modbus_connection backend."""
11
+
12
+
13
+ class ModbusConnectionError(ModbusError):
14
+ """The link is down: not connected, connection lost, or transport failure."""
15
+
16
+
17
+ class ModbusTimeoutError(ModbusError):
18
+ """The request was sent but no (valid) response arrived in time."""
19
+
20
+
21
+ class ModbusExceptionError(ModbusError):
22
+ """The device answered with a Modbus exception response (a valid error PDU).
23
+
24
+ ``exception_code`` is the raw Modbus exception code (1 = illegal function,
25
+ 2 = illegal data address, 3 = illegal data value, ...). It is ``None`` only
26
+ when the backend could not decode a specific code.
27
+ """
28
+
29
+ def __init__(self, exception_code: int | None, message: str | None = None) -> None:
30
+ self.exception_code = exception_code
31
+ super().__init__(
32
+ message or f"Device returned Modbus exception code {exception_code}"
33
+ )
@@ -0,0 +1,358 @@
1
+ """An in-memory mock backend implementing the modbus_connection Protocols.
2
+
3
+ This is a test double, not a wire backend: it never opens a socket. Reads pull
4
+ from per-unit, address-keyed register stores and writes mutate them, so a test
5
+ configures device state up front and asserts on it afterwards. It depends only
6
+ on the standard library plus this package's own types — no pymodbus, no tmodbus.
7
+
8
+ Register / coil values are *value specs* — each store entry may be:
9
+
10
+ - a single value (``store.holding[0] = 1234``),
11
+ - a list, occupying consecutive addresses from its key
12
+ (``store.holding[2] = [0x0001, 0x86A0]`` fills addresses 2 and 3), or
13
+ - a zero-argument callable, evaluated on every read for dynamic values
14
+ (``store.holding[9] = lambda: next(counter)``). A callable that raises lets a
15
+ test simulate a device-side failure.
16
+
17
+ Writes additionally fire any callbacks registered with ``unit.on_write(...)``,
18
+ so a test can react to a write by mocking other registers (e.g. flip a "ready"
19
+ flag once a command register is set).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import struct
25
+ from collections.abc import Callable
26
+ from dataclasses import dataclass
27
+ from typing import Any, Literal
28
+
29
+ from ._types import WordOrder
30
+ from .exceptions import ModbusConnectionError
31
+
32
+ __all__ = [
33
+ "CoilSpec",
34
+ "MockModbusConnection",
35
+ "MockModbusUnit",
36
+ "RegisterSpec",
37
+ "WriteEvent",
38
+ ]
39
+
40
+ RegisterSpec = int | list[int] | Callable[[], "int | list[int]"]
41
+ """A holding/input register store value: a single int, a list of consecutive
42
+ ints, or a zero-arg callable returning either."""
43
+
44
+ CoilSpec = bool | list[bool] | Callable[[], "bool | list[bool]"]
45
+ """A coil/discrete-input store value: a single bool, a list, or a zero-arg
46
+ callable returning either."""
47
+
48
+ RegisterType = Literal["holding", "coil"]
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class WriteEvent:
53
+ """A write that just landed on a unit's store, passed to ``on_write`` callbacks.
54
+
55
+ ``register_type`` is ``"holding"`` for register writes and ``"coil"`` for coil
56
+ writes. ``values`` holds the written values, already materialized.
57
+ """
58
+
59
+ register_type: RegisterType
60
+ address: int
61
+ values: list[int] | list[bool]
62
+
63
+
64
+ def _materialize(
65
+ space: dict[int, Any], convert: Callable[[Any], Any]
66
+ ) -> dict[int, Any]:
67
+ """Flatten a value-spec store into a plain address -> value mapping.
68
+
69
+ Callables are evaluated and lists are spread across consecutive addresses.
70
+ """
71
+ out: dict[int, Any] = {}
72
+ for base, spec in space.items():
73
+ value = spec() if callable(spec) else spec
74
+ if isinstance(value, (list, tuple)):
75
+ for offset, item in enumerate(value):
76
+ out[base + offset] = convert(item)
77
+ else:
78
+ out[base] = convert(value)
79
+ return out
80
+
81
+
82
+ def _read_registers(space: dict[int, Any], address: int, count: int) -> list[int]:
83
+ materialized = _materialize(space, int)
84
+ return [int(materialized.get(address + i, 0)) for i in range(count)]
85
+
86
+
87
+ def _read_bits(space: dict[int, Any], address: int, count: int) -> list[bool]:
88
+ materialized = _materialize(space, bool)
89
+ return [bool(materialized.get(address + i, False)) for i in range(count)]
90
+
91
+
92
+ # -- 16-bit register codec (big-endian bytes; configurable word order) --------
93
+
94
+
95
+ def _registers_to_value(
96
+ registers: list[int], fmt: str, word_order: WordOrder
97
+ ) -> object:
98
+ ordered = list(reversed(registers)) if word_order == "little" else registers
99
+ raw = b"".join(int(reg).to_bytes(2, "big") for reg in ordered)
100
+ return struct.unpack(">" + fmt, raw)[0]
101
+
102
+
103
+ def _value_to_registers(value: object, fmt: str, word_order: WordOrder) -> list[int]:
104
+ raw = struct.pack(">" + fmt, value)
105
+ registers = [int.from_bytes(raw[i : i + 2], "big") for i in range(0, len(raw), 2)]
106
+ return list(reversed(registers)) if word_order == "little" else registers
107
+
108
+
109
+ class MockModbusConnection:
110
+ """An in-memory ``ModbusConnection``. Construct it directly in tests.
111
+
112
+ ``for_unit`` returns the same ``MockModbusUnit`` for a given id, so the unit a
113
+ test configures is the unit the code under test reads. ``connected`` starts
114
+ ``True``; ``close`` or ``simulate_connection_lost`` flip it ``False``, after
115
+ which unit I/O raises ``ModbusConnectionError`` like a real dropped link.
116
+ """
117
+
118
+ def __init__(self) -> None:
119
+ self._units: dict[int, MockModbusUnit] = {}
120
+ self._connected = True
121
+ self._lost_callbacks: list[Callable[[], None]] = []
122
+
123
+ @property
124
+ def connected(self) -> bool:
125
+ return self._connected
126
+
127
+ def for_unit(self, unit_id: int) -> MockModbusUnit:
128
+ if unit_id not in self._units:
129
+ self._units[unit_id] = MockModbusUnit(self, unit_id)
130
+ return self._units[unit_id]
131
+
132
+ def on_connection_lost(self, callback: Callable[[], None]) -> Callable[[], None]:
133
+ self._lost_callbacks.append(callback)
134
+
135
+ def unsubscribe() -> None:
136
+ try:
137
+ self._lost_callbacks.remove(callback)
138
+ except ValueError:
139
+ pass
140
+
141
+ return unsubscribe
142
+
143
+ async def close(self) -> None:
144
+ self._connected = False
145
+
146
+ def simulate_connection_lost(self) -> None:
147
+ """Flip the link down and fire every ``on_connection_lost`` callback."""
148
+ self._connected = False
149
+ for callback in list(self._lost_callbacks):
150
+ callback()
151
+
152
+
153
+ class MockModbusUnit:
154
+ """An in-memory ``ModbusUnit`` backed by per-space value-spec stores.
155
+
156
+ Configure ``holding``, ``input``, ``coils`` and ``discrete_inputs`` directly
157
+ (e.g. ``unit.holding[0] = 1234``). Reads resolve against them; writes mutate
158
+ ``holding`` / ``coils`` and notify ``on_write`` callbacks. The exotic
159
+ function codes (report-server-id, fifo, device-id, ...) raise
160
+ ``NotImplementedError`` until configured via ``set_response``.
161
+ """
162
+
163
+ def __init__(self, connection: MockModbusConnection, unit_id: int) -> None:
164
+ self._conn = connection
165
+ self._unit_id = unit_id
166
+ self.holding: dict[int, RegisterSpec] = {}
167
+ self.input: dict[int, RegisterSpec] = {}
168
+ self.coils: dict[int, CoilSpec] = {}
169
+ self.discrete_inputs: dict[int, CoilSpec] = {}
170
+ self._write_callbacks: list[Callable[[WriteEvent], None]] = []
171
+ self._responses: dict[str, object] = {}
172
+
173
+ @property
174
+ def connected(self) -> bool:
175
+ return self._conn.connected
176
+
177
+ def _ensure_connected(self) -> None:
178
+ if not self._conn.connected:
179
+ raise ModbusConnectionError("connection is not established")
180
+
181
+ # -- test configuration helpers -------------------------------------------
182
+
183
+ def on_write(self, callback: Callable[[WriteEvent], None]) -> Callable[[], None]:
184
+ """Register a callback fired after each register/coil write.
185
+
186
+ The callback receives a ``WriteEvent`` and runs *after* the store is
187
+ updated, so it can read current state and mutate other registers. Returns
188
+ an unsubscribe.
189
+ """
190
+ self._write_callbacks.append(callback)
191
+
192
+ def unsubscribe() -> None:
193
+ try:
194
+ self._write_callbacks.remove(callback)
195
+ except ValueError:
196
+ pass
197
+
198
+ return unsubscribe
199
+
200
+ def set_response(self, method: str, value: object) -> None:
201
+ """Set the canned result for an exotic function code (e.g.
202
+ ``"report_server_id"``). ``value`` may be a plain value or a zero-arg
203
+ callable evaluated per call."""
204
+ self._responses[method] = value
205
+
206
+ def _fire_write(self, event: WriteEvent) -> None:
207
+ for callback in list(self._write_callbacks):
208
+ callback(event)
209
+
210
+ def _canned(self, method: str) -> Any:
211
+ if method not in self._responses:
212
+ raise NotImplementedError(
213
+ f"mock has no response configured for {method}(); "
214
+ f"call unit.set_response({method!r}, ...)"
215
+ )
216
+ value = self._responses[method]
217
+ return value() if callable(value) else value
218
+
219
+ # -- raw register I/O -----------------------------------------------------
220
+
221
+ async def read_holding_registers(self, address: int, count: int) -> list[int]:
222
+ self._ensure_connected()
223
+ return _read_registers(self.holding, address, count)
224
+
225
+ async def read_input_registers(self, address: int, count: int) -> list[int]:
226
+ self._ensure_connected()
227
+ return _read_registers(self.input, address, count)
228
+
229
+ async def write_register(self, address: int, value: int) -> None:
230
+ self._ensure_connected()
231
+ self.holding[address] = int(value)
232
+ self._fire_write(WriteEvent("holding", address, [int(value)]))
233
+
234
+ async def write_registers(self, address: int, values: list[int]) -> None:
235
+ self._ensure_connected()
236
+ ints = [int(v) for v in values]
237
+ for offset, value in enumerate(ints):
238
+ self.holding[address + offset] = value
239
+ self._fire_write(WriteEvent("holding", address, ints))
240
+
241
+ # -- raw coil / discrete-input I/O ----------------------------------------
242
+
243
+ async def read_coils(self, address: int, count: int) -> list[bool]:
244
+ self._ensure_connected()
245
+ return _read_bits(self.coils, address, count)
246
+
247
+ async def read_discrete_inputs(self, address: int, count: int) -> list[bool]:
248
+ self._ensure_connected()
249
+ return _read_bits(self.discrete_inputs, address, count)
250
+
251
+ async def write_coil(self, address: int, value: bool) -> None:
252
+ self._ensure_connected()
253
+ self.coils[address] = bool(value)
254
+ self._fire_write(WriteEvent("coil", address, [bool(value)]))
255
+
256
+ async def write_coils(self, address: int, values: list[bool]) -> None:
257
+ self._ensure_connected()
258
+ bools = [bool(v) for v in values]
259
+ for offset, value in enumerate(bools):
260
+ self.coils[address + offset] = value
261
+ self._fire_write(WriteEvent("coil", address, bools))
262
+
263
+ # -- typed reads / writes (always over holding registers) -----------------
264
+
265
+ async def read_uint16(self, address: int) -> int:
266
+ registers = await self.read_holding_registers(address, 1)
267
+ return int(_registers_to_value(registers, "H", "big"))
268
+
269
+ async def read_int16(self, address: int) -> int:
270
+ registers = await self.read_holding_registers(address, 1)
271
+ return int(_registers_to_value(registers, "h", "big"))
272
+
273
+ async def read_uint32(self, address: int, *, word_order: WordOrder = "big") -> int:
274
+ registers = await self.read_holding_registers(address, 2)
275
+ return int(_registers_to_value(registers, "I", word_order))
276
+
277
+ async def read_float32(
278
+ self, address: int, *, word_order: WordOrder = "big"
279
+ ) -> float:
280
+ registers = await self.read_holding_registers(address, 2)
281
+ return float(_registers_to_value(registers, "f", word_order))
282
+
283
+ async def read_string(self, address: int, length: int) -> str:
284
+ registers = await self.read_holding_registers(address, length)
285
+ raw = b"".join(int(reg).to_bytes(2, "big") for reg in registers)
286
+ return raw.decode("ascii", errors="ignore").rstrip("\x00")
287
+
288
+ async def write_uint16(self, address: int, value: int) -> None:
289
+ await self.write_registers(address, _value_to_registers(value, "H", "big"))
290
+
291
+ async def write_float32(
292
+ self, address: int, value: float, *, word_order: WordOrder = "big"
293
+ ) -> None:
294
+ await self.write_registers(address, _value_to_registers(value, "f", word_order))
295
+
296
+ # -- full function-code surface -------------------------------------------
297
+
298
+ async def mask_write_register(
299
+ self, address: int, and_mask: int, or_mask: int
300
+ ) -> None: # 0x16
301
+ self._ensure_connected()
302
+ current = _read_registers(self.holding, address, 1)[0]
303
+ new = (current & and_mask) | (or_mask & ~and_mask)
304
+ self.holding[address] = new
305
+ self._fire_write(WriteEvent("holding", address, [new]))
306
+
307
+ async def read_write_registers(
308
+ self,
309
+ read_address: int,
310
+ read_count: int,
311
+ write_address: int,
312
+ write_values: list[int],
313
+ ) -> list[int]: # 0x17
314
+ await self.write_registers(write_address, write_values)
315
+ return await self.read_holding_registers(read_address, read_count)
316
+
317
+ async def read_exception_status(self) -> int: # 0x07
318
+ self._ensure_connected()
319
+ return int(self._canned("read_exception_status"))
320
+
321
+ async def report_server_id(self) -> bytes: # 0x11
322
+ self._ensure_connected()
323
+ return bytes(self._canned("report_server_id"))
324
+
325
+ async def read_fifo_queue(self, address: int) -> list[int]: # 0x18
326
+ self._ensure_connected()
327
+ return list(self._canned("read_fifo_queue"))
328
+
329
+ async def read_device_identification(self) -> dict[int, bytes]: # 0x2B / 0x0E
330
+ self._ensure_connected()
331
+ return dict(self._canned("read_device_identification"))
332
+
333
+ async def read_file_record(
334
+ self, file: int, record: int, length: int
335
+ ) -> list[int]: # 0x14
336
+ self._ensure_connected()
337
+ return list(self._canned("read_file_record"))
338
+
339
+ async def write_file_record(
340
+ self, file: int, record: int, values: list[int]
341
+ ) -> None: # 0x15
342
+ self._ensure_connected()
343
+
344
+ async def diagnostics(self, sub_function: int, data: int = 0) -> int: # 0x08
345
+ self._ensure_connected()
346
+ return int(self._canned("diagnostics"))
347
+
348
+ async def get_comm_event_counter(self) -> tuple[int, int]: # 0x0B
349
+ self._ensure_connected()
350
+ status, count = self._canned("get_comm_event_counter")
351
+ return int(status), int(count)
352
+
353
+ async def get_comm_event_log(self) -> bytes: # 0x0C
354
+ self._ensure_connected()
355
+ return bytes(self._canned("get_comm_event_log"))
356
+
357
+ def on_connection_lost(self, callback: Callable[[], None]) -> Callable[[], None]:
358
+ return self._conn.on_connection_lost(callback)
File without changes