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 +86 -0
- anymodbus/_mock/__init__.py +14 -0
- anymodbus/_mock/faults.py +37 -0
- anymodbus/_mock/pair.py +99 -0
- anymodbus/_mock/slave.py +364 -0
- anymodbus/_types.py +164 -0
- anymodbus/_version.py +24 -0
- anymodbus/bus.py +475 -0
- anymodbus/capabilities.py +58 -0
- anymodbus/config.py +150 -0
- anymodbus/crc.py +78 -0
- anymodbus/decoders.py +221 -0
- anymodbus/exceptions.py +312 -0
- anymodbus/framer.py +371 -0
- anymodbus/pdu.py +360 -0
- anymodbus/py.typed +0 -0
- anymodbus/slave.py +404 -0
- anymodbus/stream.py +67 -0
- anymodbus/sync.py +508 -0
- anymodbus/testing.py +13 -0
- anymodbus-0.1.0.dist-info/METADATA +218 -0
- anymodbus-0.1.0.dist-info/RECORD +24 -0
- anymodbus-0.1.0.dist-info/WHEEL +4 -0
- anymodbus-0.1.0.dist-info/licenses/LICENSE +21 -0
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"]
|
anymodbus/_mock/pair.py
ADDED
|
@@ -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"]
|
anymodbus/_mock/slave.py
ADDED
|
@@ -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"]
|