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.
- modbus_connection/__init__.py +31 -0
- modbus_connection/_protocol.py +105 -0
- modbus_connection/_types.py +11 -0
- modbus_connection/exceptions.py +33 -0
- modbus_connection/mock.py +358 -0
- modbus_connection/py.typed +0 -0
- modbus_connection/pymodbus/__init__.py +445 -0
- modbus_connection/pytest_plugin.py +33 -0
- modbus_connection/tmodbus/__init__.py +355 -0
- modbus_connection-1.0.0.dist-info/METADATA +157 -0
- modbus_connection-1.0.0.dist-info/RECORD +14 -0
- modbus_connection-1.0.0.dist-info/WHEEL +4 -0
- modbus_connection-1.0.0.dist-info/entry_points.txt +2 -0
- modbus_connection-1.0.0.dist-info/licenses/LICENSE +202 -0
|
@@ -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
|