anymodbus 0.1.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.
anymodbus/__init__.py ADDED
@@ -0,0 +1,86 @@
1
+ """Async-native Modbus RTU client for Python, built on AnyIO and anyserial.
2
+
3
+ This module re-exports the package's public surface.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from anymodbus._types import (
9
+ ByteOrder,
10
+ Capability,
11
+ ExceptionCode,
12
+ FunctionCode,
13
+ WordOrder,
14
+ is_idempotent_function,
15
+ is_read_function,
16
+ is_write_function,
17
+ )
18
+ from anymodbus._version import __version__
19
+ from anymodbus.bus import Bus
20
+ from anymodbus.capabilities import SlaveCapabilities
21
+ from anymodbus.config import BusConfig, RetryPolicy, TimingConfig
22
+ from anymodbus.exceptions import (
23
+ AcknowledgeError,
24
+ BusClosedError,
25
+ ConfigurationError,
26
+ ConnectionLostError,
27
+ CRCError,
28
+ FrameError,
29
+ FrameTimeoutError,
30
+ GatewayPathUnavailableError,
31
+ GatewayTargetFailedToRespondError,
32
+ IllegalDataAddressError,
33
+ IllegalDataValueError,
34
+ IllegalFunctionError,
35
+ MemoryParityError,
36
+ ModbusError,
37
+ ModbusExceptionResponse,
38
+ ModbusUnknownExceptionError,
39
+ ModbusUnsupportedFunctionError,
40
+ ProtocolError,
41
+ SlaveDeviceBusyError,
42
+ SlaveDeviceFailureError,
43
+ UnexpectedResponseError,
44
+ )
45
+ from anymodbus.slave import Slave
46
+ from anymodbus.stream import open_modbus_rtu
47
+
48
+ __all__ = [
49
+ "AcknowledgeError",
50
+ "Bus",
51
+ "BusClosedError",
52
+ "BusConfig",
53
+ "ByteOrder",
54
+ "CRCError",
55
+ "Capability",
56
+ "ConfigurationError",
57
+ "ConnectionLostError",
58
+ "ExceptionCode",
59
+ "FrameError",
60
+ "FrameTimeoutError",
61
+ "FunctionCode",
62
+ "GatewayPathUnavailableError",
63
+ "GatewayTargetFailedToRespondError",
64
+ "IllegalDataAddressError",
65
+ "IllegalDataValueError",
66
+ "IllegalFunctionError",
67
+ "MemoryParityError",
68
+ "ModbusError",
69
+ "ModbusExceptionResponse",
70
+ "ModbusUnknownExceptionError",
71
+ "ModbusUnsupportedFunctionError",
72
+ "ProtocolError",
73
+ "RetryPolicy",
74
+ "Slave",
75
+ "SlaveCapabilities",
76
+ "SlaveDeviceBusyError",
77
+ "SlaveDeviceFailureError",
78
+ "TimingConfig",
79
+ "UnexpectedResponseError",
80
+ "WordOrder",
81
+ "__version__",
82
+ "is_idempotent_function",
83
+ "is_read_function",
84
+ "is_write_function",
85
+ "open_modbus_rtu",
86
+ ]
@@ -0,0 +1,14 @@
1
+ """Private mock-slave + fault-injection internals.
2
+
3
+ Public entry points live in :mod:`anymodbus.testing`. This subpackage
4
+ may be restructured between releases; do not import from it directly
5
+ in user code.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from anymodbus._mock.faults import FaultPlan
11
+ from anymodbus._mock.pair import client_slave_pair
12
+ from anymodbus._mock.slave import MockSlave
13
+
14
+ __all__ = ["FaultPlan", "MockSlave", "client_slave_pair"]
@@ -0,0 +1,37 @@
1
+ """Fault-injection plan for :class:`MockSlave`.
2
+
3
+ Lets tests script transient failures (CRC corruption, response delay,
4
+ wrong slave address, dropped bytes) without writing custom mocks for
5
+ each scenario.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+
12
+
13
+ @dataclass(frozen=True, slots=True, kw_only=True)
14
+ class FaultPlan:
15
+ """A scripted sequence of faults to inject into a :class:`MockSlave`.
16
+
17
+ Each field describes an independent fault mode; faults compose. ``None``
18
+ on an integer field means "never trigger this mode".
19
+
20
+ Attributes:
21
+ corrupt_crc_after_n: After this many requests, return one response
22
+ with a corrupted CRC then resume normal operation.
23
+ delay_response_seconds: Hold every response by this many seconds
24
+ before sending. Useful for timeout testing.
25
+ wrong_slave_address: Echo this address in the response instead of
26
+ the slave's real address.
27
+ drop_response_after_n: After this many requests, drop one response
28
+ entirely.
29
+ """
30
+
31
+ corrupt_crc_after_n: int | None = None
32
+ delay_response_seconds: float = 0.0
33
+ wrong_slave_address: int | None = None
34
+ drop_response_after_n: int | None = None
35
+
36
+
37
+ __all__ = ["FaultPlan"]
@@ -0,0 +1,99 @@
1
+ """``client_slave_pair`` — wire a :class:`Bus` to a :class:`MockSlave` over a serial pair.
2
+
3
+ Builds on :func:`anyserial.testing.serial_port_pair` so the test setup looks
4
+ identical to real RTU traffic on the wire — bytes flow through a backed
5
+ mock fd, the framer runs unchanged, and timing-dependent code paths can be
6
+ exercised.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from contextlib import asynccontextmanager
12
+ from typing import TYPE_CHECKING
13
+
14
+ import anyio
15
+ from anyserial import SerialConfig
16
+ from anyserial.testing import serial_port_pair
17
+
18
+ from anymodbus._mock.slave import MockSlave
19
+ from anymodbus.bus import Bus
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import AsyncGenerator
23
+
24
+ from anymodbus._mock.faults import FaultPlan
25
+ from anymodbus.config import BusConfig
26
+
27
+ # Reference baud — picks up the spec-floor timing path (1.75 ms / 0.75 ms)
28
+ # without driving real bit-rate latency. Tests that need a different baud
29
+ # can build their own pair from anyserial.testing directly.
30
+ _DEFAULT_TEST_BAUD = 19_200
31
+
32
+
33
+ @asynccontextmanager
34
+ async def client_slave_pair(
35
+ *,
36
+ slave_address: int = 1,
37
+ register_count: int = 256,
38
+ coil_count: int = 256,
39
+ discrete_input_count: int | None = None,
40
+ input_register_count: int | None = None,
41
+ faults: FaultPlan | None = None,
42
+ disabled_function_codes: frozenset[int] | None = None,
43
+ bus_config: BusConfig | None = None,
44
+ baudrate: int = _DEFAULT_TEST_BAUD,
45
+ ) -> AsyncGenerator[tuple[Bus, MockSlave]]:
46
+ """Yield ``(bus, mock_slave)`` connected over an in-process serial pair.
47
+
48
+ The :class:`MockSlave` runs in a background task in its own task group;
49
+ on context exit, the slave task is cancelled and both ends of the
50
+ underlying serial pair are closed.
51
+
52
+ Args:
53
+ slave_address: Modbus address the mock slave responds to. Defaults to 1.
54
+ register_count: Size of the holding register bank (and the default
55
+ size of the input register bank).
56
+ coil_count: Size of the coils bit bank (and the default size of the
57
+ discrete-inputs bank).
58
+ discrete_input_count: Optional independent size for the
59
+ discrete-inputs bank. Defaults to ``coil_count``.
60
+ input_register_count: Optional independent size for the input
61
+ register bank. Defaults to ``register_count``.
62
+ faults: Optional :class:`FaultPlan` for the mock slave.
63
+ disabled_function_codes: FCs the mock slave should refuse with
64
+ :class:`anymodbus.IllegalFunctionError`. Useful for capability-
65
+ probe tests that need a slave with specific gaps.
66
+ bus_config: Optional :class:`BusConfig` for the bus side.
67
+ baudrate: Serial baudrate applied to both ends. Affects auto-resolved
68
+ inter-frame timing.
69
+
70
+ Yields:
71
+ ``(bus, mock_slave)``. The bus is fully wired and ready for I/O; the
72
+ mock_slave's register banks may be mutated mid-test.
73
+ """
74
+ cfg = SerialConfig(baudrate=baudrate)
75
+ client_end, slave_end = serial_port_pair(config_a=cfg, config_b=cfg)
76
+ bus = Bus(client_end, config=bus_config)
77
+ slave = MockSlave(
78
+ address=slave_address,
79
+ register_count=register_count,
80
+ coil_count=coil_count,
81
+ discrete_input_count=discrete_input_count,
82
+ input_register_count=input_register_count,
83
+ faults=faults,
84
+ disabled_function_codes=disabled_function_codes,
85
+ )
86
+ try:
87
+ async with anyio.create_task_group() as tg:
88
+ tg.start_soon(slave.serve, slave_end)
89
+ try:
90
+ yield bus, slave
91
+ finally:
92
+ tg.cancel_scope.cancel()
93
+ finally:
94
+ with anyio.CancelScope(shield=True):
95
+ await bus.aclose()
96
+ await slave_end.aclose()
97
+
98
+
99
+ __all__ = ["client_slave_pair"]
@@ -0,0 +1,364 @@
1
+ """Mock Modbus slave for tests — a register bank that speaks the wire format.
2
+
3
+ :class:`MockSlave` is a pure-Python server that runs alongside a :class:`Bus`
4
+ in tests, decoding requests and producing responses against four mutable
5
+ register banks (coils, discrete inputs, holding registers, input registers).
6
+
7
+ It deliberately mirrors the on-wire framing the real :mod:`anymodbus.framer`
8
+ expects, so integration tests exercise the same length-aware reader, CRC
9
+ verification, and timing behaviour they would against real hardware.
10
+
11
+ :class:`FaultPlan` lets a test script transient failures (CRC corruption,
12
+ response delay, wrong slave address, dropped responses) without having to
13
+ write a custom mock for each scenario.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ import struct
20
+ from typing import TYPE_CHECKING, Final
21
+
22
+ import anyio
23
+ import anyio.abc
24
+
25
+ from anymodbus._mock.faults import FaultPlan
26
+ from anymodbus._types import ExceptionCode, FunctionCode
27
+ from anymodbus.crc import crc16_modbus_bytes, verify_crc
28
+
29
+ if TYPE_CHECKING:
30
+ from collections.abc import Mapping
31
+
32
+ _LOGGER = logging.getLogger("anymodbus.mock")
33
+
34
+ _BROADCAST_ADDRESS = 0
35
+ _MIN_SLAVE_ADDRESS = 1
36
+ _MAX_SLAVE_ADDRESS = 247 # *Modbus over Serial Line v1.02 §2.2*
37
+
38
+ # Wire encodings for FC 0x05 Write Single Coil (*app §6.5*).
39
+ _COIL_ON = 0xFF00
40
+ _COIL_OFF = 0x0000
41
+
42
+ # Spec quantity bounds — see *app §6.x*. These mirror the client-side bounds
43
+ # in :mod:`anymodbus.pdu`; we duplicate the constants rather than importing
44
+ # from a private module to keep the mock self-contained.
45
+ _MAX_READ_BITS = 2000
46
+ _MAX_READ_REGISTERS = 125
47
+ _MAX_WRITE_COILS = 1968
48
+ _MAX_WRITE_REGISTERS = 123
49
+
50
+ # Bytes after the 2-byte (slave + fc) request header for fixed-length request
51
+ # bodies, INCLUDING the 2-byte trailing CRC. FC 0x0F / 0x10 carry a 1-byte
52
+ # byte_count after this fixed prefix and are handled separately.
53
+ _FIXED_REQUEST_TAIL: Final[Mapping[int, int]] = {
54
+ FunctionCode.READ_COILS: 6, # addr(2) + count(2) + crc(2)
55
+ FunctionCode.READ_DISCRETE_INPUTS: 6,
56
+ FunctionCode.READ_HOLDING_REGISTERS: 6,
57
+ FunctionCode.READ_INPUT_REGISTERS: 6,
58
+ FunctionCode.WRITE_SINGLE_COIL: 6, # addr(2) + value(2) + crc(2)
59
+ FunctionCode.WRITE_SINGLE_REGISTER: 6,
60
+ }
61
+
62
+ _VARIABLE_REQUEST_FCS: Final[frozenset[int]] = frozenset(
63
+ {FunctionCode.WRITE_MULTIPLE_COILS, FunctionCode.WRITE_MULTIPLE_REGISTERS}
64
+ )
65
+
66
+ _CRC_LEN = 2
67
+
68
+ # Length of the FC 0x0F / 0x10 request prefix on the wire (FC byte + address(2) +
69
+ # count(2) + byte_count(1)). The variable-length data payload follows.
70
+ _WRITE_MULTIPLE_REQUEST_PREFIX_LEN = 6
71
+
72
+
73
+ class _ServerException(Exception): # noqa: N818 — internal sentinel, not user-visible
74
+ """Internal: signals that a handler wants the loop to emit an exception PDU."""
75
+
76
+ def __init__(self, code: ExceptionCode) -> None:
77
+ super().__init__(int(code))
78
+ self.code = code
79
+
80
+
81
+ class MockSlave:
82
+ """Pure-Python Modbus slave for integration tests.
83
+
84
+ The four register banks (coils, discrete inputs, holding registers,
85
+ input registers) are exposed as mutable sequences so tests can preload
86
+ state, observe writes, and inject failure modes via :class:`FaultPlan`.
87
+
88
+ Address validation matches *Modbus over Serial Line v1.02 §2.2*:
89
+ addresses 1-247 are unicast, 0 is broadcast (the slave still applies
90
+ write requests but does not respond), and 248-255 are reserved.
91
+ """
92
+
93
+ address: int
94
+ coils: bytearray
95
+ discrete_inputs: bytearray
96
+ holding_registers: list[int]
97
+ input_registers: list[int]
98
+ faults: FaultPlan
99
+ disabled_function_codes: frozenset[int]
100
+
101
+ def __init__(
102
+ self,
103
+ *,
104
+ address: int = 1,
105
+ register_count: int = 256,
106
+ coil_count: int = 256,
107
+ discrete_input_count: int | None = None,
108
+ input_register_count: int | None = None,
109
+ faults: FaultPlan | None = None,
110
+ disabled_function_codes: frozenset[int] | None = None,
111
+ ) -> None:
112
+ """Construct a mock slave.
113
+
114
+ Args:
115
+ address: Modbus unit address. Must be 1-247.
116
+ register_count: Size of the holding register bank, and the
117
+ default size of the input register bank when
118
+ ``input_register_count`` is not supplied.
119
+ coil_count: Size of the coils bit bank, and the default size of
120
+ the discrete-inputs bank when ``discrete_input_count`` is
121
+ not supplied.
122
+ discrete_input_count: Optional independent size for the
123
+ discrete-inputs bank. Defaults to ``coil_count``.
124
+ input_register_count: Optional independent size for the input
125
+ register bank. Defaults to ``register_count``.
126
+ faults: Optional :class:`FaultPlan` for transient failure modes.
127
+ disabled_function_codes: FCs to refuse with
128
+ :attr:`ExceptionCode.ILLEGAL_FUNCTION` even though the mock
129
+ otherwise implements them. Used by capability-probe tests to
130
+ simulate a slave that lacks specific function codes.
131
+ """
132
+ if not (_MIN_SLAVE_ADDRESS <= address <= _MAX_SLAVE_ADDRESS):
133
+ msg = (
134
+ f"MockSlave address must be 1-247 (got {address!r}); "
135
+ f"address 0 is broadcast and 248-255 are reserved"
136
+ )
137
+ raise ValueError(msg)
138
+ if discrete_input_count is None:
139
+ discrete_input_count = coil_count
140
+ if input_register_count is None:
141
+ input_register_count = register_count
142
+ self.address = address
143
+ self.coils = bytearray((coil_count + 7) // 8)
144
+ self.discrete_inputs = bytearray((discrete_input_count + 7) // 8)
145
+ self.holding_registers = [0] * register_count
146
+ self.input_registers = [0] * input_register_count
147
+ self.faults = faults if faults is not None else FaultPlan()
148
+ self.disabled_function_codes = (
149
+ disabled_function_codes if disabled_function_codes is not None else frozenset()
150
+ )
151
+ self._coil_count = coil_count
152
+ self._discrete_input_count = discrete_input_count
153
+ self._register_count = register_count
154
+ self._input_register_count = input_register_count
155
+ # Index of the next response we will emit. Used for FaultPlan's
156
+ # corrupt_crc_after_n / drop_response_after_n one-shot triggers.
157
+ self._responses_emitted = 0
158
+
159
+ async def serve(self, stream: anyio.abc.ByteStream) -> None:
160
+ """Accept requests on ``stream``, write responses, until cancelled.
161
+
162
+ Loops forever (or until the stream closes / the task is cancelled)
163
+ reading one request per iteration. Bad CRCs are logged and dropped
164
+ — there is no in-band recovery mechanism on a real Modbus bus, so
165
+ the mock mirrors that behaviour.
166
+ """
167
+ while True:
168
+ try:
169
+ continue_loop = await self._serve_one(stream)
170
+ except (anyio.EndOfStream, anyio.ClosedResourceError):
171
+ return
172
+ if not continue_loop:
173
+ return
174
+
175
+ async def _serve_one(self, stream: anyio.abc.ByteStream) -> bool:
176
+ head = await self._read_exact(stream, 2)
177
+ addr = head[0]
178
+ fc = head[1]
179
+
180
+ if fc in _FIXED_REQUEST_TAIL:
181
+ tail = await self._read_exact(stream, _FIXED_REQUEST_TAIL[fc])
182
+ elif fc in _VARIABLE_REQUEST_FCS:
183
+ # addr(2) + count(2) + byte_count(1) + data(byte_count) + crc(2)
184
+ prefix = await self._read_exact(stream, 5)
185
+ byte_count = prefix[4]
186
+ rest = await self._read_exact(stream, byte_count + _CRC_LEN)
187
+ tail = prefix + rest
188
+ else:
189
+ # Unknown FC. We don't have length info to drain the body
190
+ # safely; the safest action is to log and stop serving so the
191
+ # test fails loudly rather than silently corrupting later frames.
192
+ _LOGGER.warning("MockSlave: unsupported FC 0x%02x; closing serve loop", fc)
193
+ return False
194
+
195
+ full_request = head + tail
196
+ if not verify_crc(full_request):
197
+ _LOGGER.warning("MockSlave: CRC mismatch on request, dropping")
198
+ return True
199
+
200
+ if addr not in (self.address, _BROADCAST_ADDRESS):
201
+ return True
202
+
203
+ request_pdu = full_request[1:-_CRC_LEN] # strip slave addr + CRC
204
+ is_broadcast = addr == _BROADCAST_ADDRESS
205
+
206
+ try:
207
+ response_pdu = self._handle_request(request_pdu)
208
+ except _ServerException as exc:
209
+ response_pdu = bytes((fc | 0x80, int(exc.code)))
210
+
211
+ if is_broadcast:
212
+ # *serial §2.1*: broadcasts elicit no response.
213
+ return True
214
+
215
+ await self._send_response(stream, response_pdu)
216
+ return True
217
+
218
+ async def _send_response(self, stream: anyio.abc.ByteStream, response_pdu: bytes) -> None:
219
+ plan = self.faults
220
+ idx = self._responses_emitted
221
+ self._responses_emitted += 1
222
+
223
+ if plan.drop_response_after_n is not None and idx == plan.drop_response_after_n:
224
+ _LOGGER.info("MockSlave: dropping response %d per FaultPlan", idx)
225
+ return
226
+
227
+ if plan.delay_response_seconds > 0:
228
+ await anyio.sleep(plan.delay_response_seconds)
229
+
230
+ slave_byte = (
231
+ plan.wrong_slave_address if plan.wrong_slave_address is not None else self.address
232
+ )
233
+ head = bytes((slave_byte,)) + response_pdu
234
+ crc = crc16_modbus_bytes(head)
235
+
236
+ if plan.corrupt_crc_after_n is not None and idx == plan.corrupt_crc_after_n:
237
+ _LOGGER.info("MockSlave: corrupting CRC on response %d per FaultPlan", idx)
238
+ crc = bytes((crc[0] ^ 0x01, crc[1]))
239
+
240
+ await stream.send(head + crc)
241
+
242
+ # ------------------------------------------------------------------
243
+ # Per-FC handlers. Each takes the request PDU (FC byte + body) and
244
+ # returns the response PDU (FC byte + body), or raises
245
+ # :class:`_ServerException` to be translated into an exception PDU.
246
+ # ------------------------------------------------------------------
247
+
248
+ def _handle_request(self, pdu: bytes) -> bytes: # noqa: PLR0911 — one return per FC
249
+ fc = pdu[0]
250
+ if fc in self.disabled_function_codes:
251
+ raise _ServerException(ExceptionCode.ILLEGAL_FUNCTION)
252
+ if fc == FunctionCode.READ_COILS:
253
+ return self._handle_read_bits(pdu, self.coils, self._coil_count)
254
+ if fc == FunctionCode.READ_DISCRETE_INPUTS:
255
+ return self._handle_read_bits(pdu, self.discrete_inputs, self._discrete_input_count)
256
+ if fc == FunctionCode.READ_HOLDING_REGISTERS:
257
+ return self._handle_read_registers(pdu, self.holding_registers)
258
+ if fc == FunctionCode.READ_INPUT_REGISTERS:
259
+ return self._handle_read_registers(pdu, self.input_registers)
260
+ if fc == FunctionCode.WRITE_SINGLE_COIL:
261
+ return self._handle_write_single_coil(pdu)
262
+ if fc == FunctionCode.WRITE_SINGLE_REGISTER:
263
+ return self._handle_write_single_register(pdu)
264
+ if fc == FunctionCode.WRITE_MULTIPLE_COILS:
265
+ return self._handle_write_multiple_coils(pdu)
266
+ if fc == FunctionCode.WRITE_MULTIPLE_REGISTERS:
267
+ return self._handle_write_multiple_registers(pdu)
268
+ raise _ServerException(ExceptionCode.ILLEGAL_FUNCTION)
269
+
270
+ def _handle_read_bits(self, pdu: bytes, bank: bytearray, bank_size: int) -> bytes:
271
+ fc, addr, count = struct.unpack(">BHH", pdu)
272
+ if not (1 <= count <= _MAX_READ_BITS):
273
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
274
+ if addr + count > bank_size:
275
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_ADDRESS)
276
+ nbytes = (count + 7) // 8
277
+ out = bytearray(nbytes)
278
+ for i in range(count):
279
+ src = addr + i
280
+ if bank[src >> 3] & (1 << (src & 7)):
281
+ out[i >> 3] |= 1 << (i & 7)
282
+ return bytes((fc, nbytes)) + bytes(out)
283
+
284
+ def _handle_read_registers(self, pdu: bytes, bank: list[int]) -> bytes:
285
+ fc, addr, count = struct.unpack(">BHH", pdu)
286
+ if not (1 <= count <= _MAX_READ_REGISTERS):
287
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
288
+ if addr + count > len(bank):
289
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_ADDRESS)
290
+ values = bank[addr : addr + count]
291
+ return bytes((fc, count * 2)) + struct.pack(f">{count}H", *values)
292
+
293
+ def _handle_write_single_coil(self, pdu: bytes) -> bytes:
294
+ _, addr, value = struct.unpack(">BHH", pdu)
295
+ if value not in (_COIL_ON, _COIL_OFF):
296
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
297
+ if addr >= self._coil_count:
298
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_ADDRESS)
299
+ if value == _COIL_ON:
300
+ self.coils[addr >> 3] |= 1 << (addr & 7)
301
+ else:
302
+ self.coils[addr >> 3] &= (~(1 << (addr & 7))) & 0xFF
303
+ return pdu # FC 0x05 echoes the request
304
+
305
+ def _handle_write_single_register(self, pdu: bytes) -> bytes:
306
+ _, addr, value = struct.unpack(">BHH", pdu)
307
+ if addr >= len(self.holding_registers):
308
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_ADDRESS)
309
+ self.holding_registers[addr] = value
310
+ return pdu # FC 0x06 echoes the request
311
+
312
+ def _handle_write_multiple_coils(self, pdu: bytes) -> bytes:
313
+ if len(pdu) < _WRITE_MULTIPLE_REQUEST_PREFIX_LEN:
314
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
315
+ fc, addr, count, byte_count = struct.unpack(
316
+ ">BHHB", pdu[:_WRITE_MULTIPLE_REQUEST_PREFIX_LEN]
317
+ )
318
+ data = pdu[_WRITE_MULTIPLE_REQUEST_PREFIX_LEN:]
319
+ if not (1 <= count <= _MAX_WRITE_COILS):
320
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
321
+ if byte_count != (count + 7) // 8 or len(data) != byte_count:
322
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
323
+ if addr + count > self._coil_count:
324
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_ADDRESS)
325
+ for i in range(count):
326
+ target = addr + i
327
+ bit = (data[i >> 3] >> (i & 7)) & 1
328
+ if bit:
329
+ self.coils[target >> 3] |= 1 << (target & 7)
330
+ else:
331
+ self.coils[target >> 3] &= (~(1 << (target & 7))) & 0xFF
332
+ return struct.pack(">BHH", fc, addr, count)
333
+
334
+ def _handle_write_multiple_registers(self, pdu: bytes) -> bytes:
335
+ if len(pdu) < _WRITE_MULTIPLE_REQUEST_PREFIX_LEN:
336
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
337
+ fc, addr, count, byte_count = struct.unpack(
338
+ ">BHHB", pdu[:_WRITE_MULTIPLE_REQUEST_PREFIX_LEN]
339
+ )
340
+ data = pdu[_WRITE_MULTIPLE_REQUEST_PREFIX_LEN:]
341
+ if not (1 <= count <= _MAX_WRITE_REGISTERS):
342
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
343
+ if byte_count != count * 2 or len(data) != byte_count:
344
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_VALUE)
345
+ if addr + count > len(self.holding_registers):
346
+ raise _ServerException(ExceptionCode.ILLEGAL_DATA_ADDRESS)
347
+ values = struct.unpack(f">{count}H", data)
348
+ for i, v in enumerate(values):
349
+ self.holding_registers[addr + i] = v
350
+ return struct.pack(">BHH", fc, addr, count)
351
+
352
+ @staticmethod
353
+ async def _read_exact(stream: anyio.abc.ByteStream, n: int) -> bytes:
354
+ buf = bytearray()
355
+ while len(buf) < n:
356
+ chunk = await stream.receive(n - len(buf))
357
+ if not chunk:
358
+ # AnyIO contract: receive returns >=1 byte or raises EndOfStream.
359
+ raise anyio.EndOfStream
360
+ buf.extend(chunk)
361
+ return bytes(buf)
362
+
363
+
364
+ __all__ = ["MockSlave"]