fieldsim 0.2.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.
- fieldsim/__init__.py +26 -0
- fieldsim/__main__.py +3 -0
- fieldsim/background.py +362 -0
- fieldsim/cli.py +137 -0
- fieldsim/config.py +560 -0
- fieldsim/core/__init__.py +0 -0
- fieldsim/core/clock.py +87 -0
- fieldsim/core/datastore.py +35 -0
- fieldsim/core/device.py +205 -0
- fieldsim/core/engine.py +88 -0
- fieldsim/core/faults.py +150 -0
- fieldsim/core/generators.py +109 -0
- fieldsim/core/scenario.py +140 -0
- fieldsim/core/types.py +133 -0
- fieldsim/protocols/__init__.py +0 -0
- fieldsim/protocols/modbus/__init__.py +18 -0
- fieldsim/protocols/modbus/constants.py +42 -0
- fieldsim/protocols/modbus/datastore.py +203 -0
- fieldsim/protocols/modbus/device.py +115 -0
- fieldsim/protocols/modbus/framer.py +196 -0
- fieldsim/protocols/modbus/pdu.py +157 -0
- fieldsim/protocols/modbus/server.py +135 -0
- fieldsim/py.typed +0 -0
- fieldsim/pytest_plugin.py +163 -0
- fieldsim/simulator.py +96 -0
- fieldsim-0.2.0.dist-info/METADATA +257 -0
- fieldsim-0.2.0.dist-info/RECORD +30 -0
- fieldsim-0.2.0.dist-info/WHEEL +4 -0
- fieldsim-0.2.0.dist-info/entry_points.txt +5 -0
- fieldsim-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Modbus framings over a TCP stream.
|
|
2
|
+
|
|
3
|
+
* :class:`TcpFramer` - native Modbus TCP (MBAP header, no checksum).
|
|
4
|
+
* :class:`RtuFramer` - RTU frames tunnelled over TCP (unit id + PDU + CRC16), as
|
|
5
|
+
spoken by many serial-to-Ethernet gateways.
|
|
6
|
+
|
|
7
|
+
Each framer also knows how to damage its own frames for fault injection.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import random
|
|
15
|
+
import struct
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
MBAP_HEADER = struct.Struct(">HHHB")
|
|
22
|
+
"""transaction id, protocol id, length (unit id + PDU), unit id"""
|
|
23
|
+
|
|
24
|
+
MAX_PDU_SIZE = 253
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FramingError(Exception):
|
|
28
|
+
"""The byte stream is not valid; the connection should be dropped."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Frame:
|
|
33
|
+
unit_id: int
|
|
34
|
+
pdu: bytes
|
|
35
|
+
transaction_id: int | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _flip_bit(data: bytes, start: int, end: int, rng: random.Random) -> bytearray:
|
|
39
|
+
out = bytearray(data)
|
|
40
|
+
if end > start:
|
|
41
|
+
index = rng.randrange(start, end)
|
|
42
|
+
out[index] ^= 1 << rng.randrange(8)
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Framer(ABC):
|
|
47
|
+
name: str
|
|
48
|
+
|
|
49
|
+
@abstractmethod
|
|
50
|
+
async def read_frame(self, reader: asyncio.StreamReader) -> Frame | None:
|
|
51
|
+
"""Read one request. ``None`` means "discard and keep reading".
|
|
52
|
+
|
|
53
|
+
Raises ``asyncio.IncompleteReadError`` on EOF and :class:`FramingError`
|
|
54
|
+
when the stream cannot be resynchronised.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
@abstractmethod
|
|
58
|
+
def encode(self, frame: Frame) -> bytes: ...
|
|
59
|
+
|
|
60
|
+
@abstractmethod
|
|
61
|
+
def corrupt(self, data: bytes, mode: str, rng: random.Random) -> bytes:
|
|
62
|
+
"""Return a damaged copy of an encoded response."""
|
|
63
|
+
|
|
64
|
+
def _corrupt_common(self, data: bytes, mode: str, rng: random.Random) -> bytes | None:
|
|
65
|
+
if mode == "truncate":
|
|
66
|
+
return data[: rng.randint(1, len(data) - 1)]
|
|
67
|
+
if mode == "garbage":
|
|
68
|
+
return rng.randbytes(len(data))
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------------------- Modbus TCP
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TcpFramer(Framer):
|
|
76
|
+
name = "tcp"
|
|
77
|
+
|
|
78
|
+
async def read_frame(self, reader: asyncio.StreamReader) -> Frame:
|
|
79
|
+
header = await reader.readexactly(MBAP_HEADER.size)
|
|
80
|
+
transaction_id, protocol_id, length, unit_id = MBAP_HEADER.unpack(header)
|
|
81
|
+
if protocol_id != 0:
|
|
82
|
+
raise FramingError(f"protocol id {protocol_id} is not Modbus (0)")
|
|
83
|
+
if not 2 <= length <= MAX_PDU_SIZE + 1:
|
|
84
|
+
raise FramingError(f"invalid MBAP length {length}")
|
|
85
|
+
pdu = await reader.readexactly(length - 1)
|
|
86
|
+
return Frame(unit_id, pdu, transaction_id)
|
|
87
|
+
|
|
88
|
+
def encode(self, frame: Frame) -> bytes:
|
|
89
|
+
return (
|
|
90
|
+
MBAP_HEADER.pack(frame.transaction_id or 0, 0, len(frame.pdu) + 1, frame.unit_id)
|
|
91
|
+
+ frame.pdu
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
def corrupt(self, data: bytes, mode: str, rng: random.Random) -> bytes:
|
|
95
|
+
common = self._corrupt_common(data, mode, rng)
|
|
96
|
+
if common is not None:
|
|
97
|
+
return common
|
|
98
|
+
if mode == "bit_flip":
|
|
99
|
+
return bytes(_flip_bit(data, MBAP_HEADER.size, len(data), rng))
|
|
100
|
+
transaction_id, protocol_id, length, unit_id = MBAP_HEADER.unpack_from(data)
|
|
101
|
+
if mode == "bad_length":
|
|
102
|
+
length = (length + rng.randint(1, 8)) & 0xFFFF
|
|
103
|
+
elif mode == "transaction_id":
|
|
104
|
+
transaction_id = (transaction_id + 1) & 0xFFFF
|
|
105
|
+
else:
|
|
106
|
+
raise ValueError(f"corrupt mode {mode!r} is not supported by Modbus TCP")
|
|
107
|
+
return MBAP_HEADER.pack(transaction_id, protocol_id, length, unit_id) + data[7:]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
# --------------------------------------------------------------------------- RTU over TCP
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _make_crc_table() -> list[int]:
|
|
114
|
+
table = []
|
|
115
|
+
for byte in range(256):
|
|
116
|
+
crc = byte
|
|
117
|
+
for _ in range(8):
|
|
118
|
+
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
|
|
119
|
+
table.append(crc)
|
|
120
|
+
return table
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
_CRC_TABLE = _make_crc_table()
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def crc16(data: bytes) -> int:
|
|
127
|
+
"""Modbus CRC-16 (polynomial 0xA001, initial value 0xFFFF)."""
|
|
128
|
+
crc = 0xFFFF
|
|
129
|
+
for byte in data:
|
|
130
|
+
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
|
|
131
|
+
return crc
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def with_crc(data: bytes) -> bytes:
|
|
135
|
+
return data + crc16(data).to_bytes(2, "little")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# Request PDU data length (after the function code) for fixed-size requests.
|
|
139
|
+
_FIXED_REQUEST_DATA = {0x01: 4, 0x02: 4, 0x03: 4, 0x04: 4, 0x05: 4, 0x06: 4}
|
|
140
|
+
_VARIABLE_REQUEST = {0x0F, 0x10} # address(2) quantity(2) byte count(1) + data
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class RtuFramer(Framer):
|
|
144
|
+
"""RTU framing has no length field; request size is derived from the function code."""
|
|
145
|
+
|
|
146
|
+
name = "rtu"
|
|
147
|
+
|
|
148
|
+
#: How long to wait for the rest of a request with an unknown function code.
|
|
149
|
+
unknown_request_gap = 0.05
|
|
150
|
+
|
|
151
|
+
async def read_frame(self, reader: asyncio.StreamReader) -> Frame | None:
|
|
152
|
+
head = await reader.readexactly(2)
|
|
153
|
+
fc = head[1]
|
|
154
|
+
if fc in _FIXED_REQUEST_DATA:
|
|
155
|
+
body = await reader.readexactly(_FIXED_REQUEST_DATA[fc] + 2)
|
|
156
|
+
elif fc in _VARIABLE_REQUEST:
|
|
157
|
+
fixed = await reader.readexactly(5)
|
|
158
|
+
body = fixed + await reader.readexactly(fixed[4] + 2)
|
|
159
|
+
else:
|
|
160
|
+
# Unknown length: take whatever arrives before the line goes quiet.
|
|
161
|
+
try:
|
|
162
|
+
body = await asyncio.wait_for(
|
|
163
|
+
reader.read(MAX_PDU_SIZE + 2), self.unknown_request_gap
|
|
164
|
+
)
|
|
165
|
+
except asyncio.TimeoutError:
|
|
166
|
+
body = b""
|
|
167
|
+
if not body and reader.at_eof():
|
|
168
|
+
raise asyncio.IncompleteReadError(head, None)
|
|
169
|
+
frame = head + body
|
|
170
|
+
if len(frame) < 4 or crc16(frame[:-2]) != int.from_bytes(frame[-2:], "little"):
|
|
171
|
+
# A real RTU slave silently ignores frames with a bad checksum.
|
|
172
|
+
logger.debug("discarding RTU frame with bad CRC: %s", frame.hex(" "))
|
|
173
|
+
return None
|
|
174
|
+
return Frame(frame[0], frame[1:-2])
|
|
175
|
+
|
|
176
|
+
def encode(self, frame: Frame) -> bytes:
|
|
177
|
+
return with_crc(bytes([frame.unit_id]) + frame.pdu)
|
|
178
|
+
|
|
179
|
+
def corrupt(self, data: bytes, mode: str, rng: random.Random) -> bytes:
|
|
180
|
+
common = self._corrupt_common(data, mode, rng)
|
|
181
|
+
if common is not None:
|
|
182
|
+
return common
|
|
183
|
+
if mode == "bit_flip":
|
|
184
|
+
# Valid checksum over wrong data: the error only a plausibility check catches.
|
|
185
|
+
return with_crc(bytes(_flip_bit(data, 1, len(data) - 2, rng))[:-2])
|
|
186
|
+
if mode == "bad_crc":
|
|
187
|
+
out = bytearray(data)
|
|
188
|
+
out[-1] ^= 0xFF
|
|
189
|
+
return bytes(out)
|
|
190
|
+
raise ValueError(f"corrupt mode {mode!r} is not supported by RTU framing")
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
FRAMERS: dict[str, type[Framer]] = {
|
|
194
|
+
"modbus_tcp": TcpFramer,
|
|
195
|
+
"modbus_rtu_over_tcp": RtuFramer,
|
|
196
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Modbus PDU processing (transport independent).
|
|
2
|
+
|
|
3
|
+
``handle_pdu`` takes a request PDU (function code + data) and returns the
|
|
4
|
+
response PDU. Validation order follows the Modbus Application Protocol
|
|
5
|
+
specification: function code (01), quantity / framing (03), address range (02),
|
|
6
|
+
then execution (04).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import struct
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
|
|
15
|
+
from fieldsim.protocols.modbus.constants import (
|
|
16
|
+
ADDRESS_SPACE,
|
|
17
|
+
MAX_READ_BITS,
|
|
18
|
+
MAX_READ_REGISTERS,
|
|
19
|
+
MAX_WRITE_BITS,
|
|
20
|
+
MAX_WRITE_REGISTERS,
|
|
21
|
+
ExceptionCode,
|
|
22
|
+
FunctionCode,
|
|
23
|
+
ModbusError,
|
|
24
|
+
)
|
|
25
|
+
from fieldsim.protocols.modbus.datastore import ModbusDataStore, Table
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
_ADDR_QTY = struct.Struct(">HH")
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def pack_bits(bits: Sequence[bool]) -> bytes:
|
|
33
|
+
"""Pack bits LSB-first, as Modbus does."""
|
|
34
|
+
out = bytearray((len(bits) + 7) // 8)
|
|
35
|
+
for i, bit in enumerate(bits):
|
|
36
|
+
if bit:
|
|
37
|
+
out[i // 8] |= 1 << (i % 8)
|
|
38
|
+
return bytes(out)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def unpack_bits(data: bytes, count: int) -> list[bool]:
|
|
42
|
+
return [bool(data[i // 8] >> (i % 8) & 1) for i in range(count)]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def exception_response(function_code: int, code: ExceptionCode | int) -> bytes:
|
|
46
|
+
return bytes([(function_code | 0x80) & 0xFF, code])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _addr_qty(data: bytes) -> tuple[int, int]:
|
|
50
|
+
if len(data) != 4:
|
|
51
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_VALUE, "expected 4 data bytes")
|
|
52
|
+
address, quantity = _ADDR_QTY.unpack(data)
|
|
53
|
+
return address, quantity
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _check_range(address: int, quantity: int, limit: int) -> None:
|
|
57
|
+
if not 1 <= quantity <= limit:
|
|
58
|
+
raise ModbusError(
|
|
59
|
+
ExceptionCode.ILLEGAL_DATA_VALUE, f"quantity {quantity} not in 1..{limit}"
|
|
60
|
+
)
|
|
61
|
+
if address + quantity > ADDRESS_SPACE:
|
|
62
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_ADDRESS, "range exceeds address space")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _read_bits(table: Table) -> Handler:
|
|
66
|
+
def handler(store: ModbusDataStore, fc: int, data: bytes) -> bytes:
|
|
67
|
+
address, quantity = _addr_qty(data)
|
|
68
|
+
_check_range(address, quantity, MAX_READ_BITS)
|
|
69
|
+
payload = pack_bits(store.read_bits(table, address, quantity))
|
|
70
|
+
return bytes([fc, len(payload)]) + payload
|
|
71
|
+
|
|
72
|
+
return handler
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _read_registers(table: Table) -> Handler:
|
|
76
|
+
def handler(store: ModbusDataStore, fc: int, data: bytes) -> bytes:
|
|
77
|
+
address, quantity = _addr_qty(data)
|
|
78
|
+
_check_range(address, quantity, MAX_READ_REGISTERS)
|
|
79
|
+
words = store.read_registers(table, address, quantity)
|
|
80
|
+
return bytes([fc, 2 * quantity]) + struct.pack(f">{quantity}H", *words)
|
|
81
|
+
|
|
82
|
+
return handler
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write_single_coil(store: ModbusDataStore, fc: int, data: bytes) -> bytes:
|
|
86
|
+
address, value = _addr_qty(data)
|
|
87
|
+
if value not in (0x0000, 0xFF00):
|
|
88
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_VALUE, "coil value must be 0x0000 or 0xFF00")
|
|
89
|
+
store.write_coils(address, [value == 0xFF00])
|
|
90
|
+
return bytes([fc]) + data
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _write_single_register(store: ModbusDataStore, fc: int, data: bytes) -> bytes:
|
|
94
|
+
address, value = _addr_qty(data)
|
|
95
|
+
store.write_registers(address, [value])
|
|
96
|
+
return bytes([fc]) + data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _write_multiple_coils(store: ModbusDataStore, fc: int, data: bytes) -> bytes:
|
|
100
|
+
if len(data) < 5:
|
|
101
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_VALUE, "request too short")
|
|
102
|
+
address, quantity = _ADDR_QTY.unpack_from(data)
|
|
103
|
+
byte_count = data[4]
|
|
104
|
+
if not 1 <= quantity <= MAX_WRITE_BITS or byte_count != (quantity + 7) // 8:
|
|
105
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_VALUE, "bad quantity or byte count")
|
|
106
|
+
if len(data) != 5 + byte_count:
|
|
107
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_VALUE, "byte count does not match payload")
|
|
108
|
+
_check_range(address, quantity, MAX_WRITE_BITS)
|
|
109
|
+
store.write_coils(address, unpack_bits(data[5:], quantity))
|
|
110
|
+
return bytes([fc]) + data[:4]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _write_multiple_registers(store: ModbusDataStore, fc: int, data: bytes) -> bytes:
|
|
114
|
+
if len(data) < 5:
|
|
115
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_VALUE, "request too short")
|
|
116
|
+
address, quantity = _ADDR_QTY.unpack_from(data)
|
|
117
|
+
byte_count = data[4]
|
|
118
|
+
if not 1 <= quantity <= MAX_WRITE_REGISTERS or byte_count != 2 * quantity:
|
|
119
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_VALUE, "bad quantity or byte count")
|
|
120
|
+
if len(data) != 5 + byte_count:
|
|
121
|
+
raise ModbusError(ExceptionCode.ILLEGAL_DATA_VALUE, "byte count does not match payload")
|
|
122
|
+
_check_range(address, quantity, MAX_WRITE_REGISTERS)
|
|
123
|
+
store.write_registers(address, list(struct.unpack_from(f">{quantity}H", data, 5)))
|
|
124
|
+
return bytes([fc]) + data[:4]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
Handler = Callable[[ModbusDataStore, int, bytes], bytes]
|
|
128
|
+
|
|
129
|
+
HANDLERS: dict[int, Handler] = {
|
|
130
|
+
FunctionCode.READ_COILS: _read_bits(Table.COILS),
|
|
131
|
+
FunctionCode.READ_DISCRETE_INPUTS: _read_bits(Table.DISCRETE_INPUTS),
|
|
132
|
+
FunctionCode.READ_HOLDING_REGISTERS: _read_registers(Table.HOLDING_REGISTERS),
|
|
133
|
+
FunctionCode.READ_INPUT_REGISTERS: _read_registers(Table.INPUT_REGISTERS),
|
|
134
|
+
FunctionCode.WRITE_SINGLE_COIL: _write_single_coil,
|
|
135
|
+
FunctionCode.WRITE_SINGLE_REGISTER: _write_single_register,
|
|
136
|
+
FunctionCode.WRITE_MULTIPLE_COILS: _write_multiple_coils,
|
|
137
|
+
FunctionCode.WRITE_MULTIPLE_REGISTERS: _write_multiple_registers,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def handle_pdu(store: ModbusDataStore, pdu: bytes) -> bytes:
|
|
142
|
+
"""Process one request PDU and return the response PDU."""
|
|
143
|
+
if not pdu:
|
|
144
|
+
raise ValueError("empty PDU")
|
|
145
|
+
fc = pdu[0]
|
|
146
|
+
handler = HANDLERS.get(fc)
|
|
147
|
+
if handler is None:
|
|
148
|
+
logger.debug("unsupported function code 0x%02X", fc)
|
|
149
|
+
return exception_response(fc, ExceptionCode.ILLEGAL_FUNCTION)
|
|
150
|
+
try:
|
|
151
|
+
return handler(store, fc, pdu[1:])
|
|
152
|
+
except ModbusError as exc:
|
|
153
|
+
logger.debug("fc 0x%02X -> exception %s: %s", fc, exc.code.name, exc)
|
|
154
|
+
return exception_response(fc, exc.code)
|
|
155
|
+
except Exception:
|
|
156
|
+
logger.exception("unexpected error while handling fc 0x%02X", fc)
|
|
157
|
+
return exception_response(fc, ExceptionCode.SERVER_DEVICE_FAILURE)
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""asyncio Modbus server for TCP streams (Modbus TCP or RTU over TCP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
from fieldsim.core.faults import FaultInjector, FaultPlan
|
|
10
|
+
from fieldsim.protocols.modbus.datastore import ModbusDataStore
|
|
11
|
+
from fieldsim.protocols.modbus.framer import Frame, Framer, FramingError, TcpFramer
|
|
12
|
+
from fieldsim.protocols.modbus.pdu import exception_response, handle_pdu
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ModbusTcpServer:
|
|
18
|
+
"""Serves a :class:`ModbusDataStore` over a TCP listener.
|
|
19
|
+
|
|
20
|
+
Requests on one connection are processed in order; any number of clients
|
|
21
|
+
can be connected at once. Every request is run through the fault injector
|
|
22
|
+
first.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
store: ModbusDataStore,
|
|
28
|
+
*,
|
|
29
|
+
host: str = "127.0.0.1",
|
|
30
|
+
port: int = 502,
|
|
31
|
+
unit_id: int | None = None,
|
|
32
|
+
framer: Framer | None = None,
|
|
33
|
+
faults: FaultInjector | None = None,
|
|
34
|
+
):
|
|
35
|
+
self.store = store
|
|
36
|
+
self.unit_id = unit_id
|
|
37
|
+
self.framer = framer or TcpFramer()
|
|
38
|
+
self.faults = faults or FaultInjector()
|
|
39
|
+
self._host = host
|
|
40
|
+
self._port = port
|
|
41
|
+
self._server: asyncio.Server | None = None
|
|
42
|
+
self._clients: set[asyncio.Task[None]] = set()
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def host(self) -> str:
|
|
46
|
+
return self._host
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def port(self) -> int:
|
|
50
|
+
return self._port
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def serving(self) -> bool:
|
|
54
|
+
return self._server is not None
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def client_count(self) -> int:
|
|
58
|
+
return len(self._clients)
|
|
59
|
+
|
|
60
|
+
async def start(self) -> None:
|
|
61
|
+
if self._server is not None:
|
|
62
|
+
raise RuntimeError("server already started")
|
|
63
|
+
self._server = await asyncio.start_server(self._handle_client, self._host, self._port)
|
|
64
|
+
# With port 0 the OS picks a free port; remember the real one so that a
|
|
65
|
+
# restart after an outage comes back on the same address.
|
|
66
|
+
self._host, self._port = self._server.sockets[0].getsockname()[:2]
|
|
67
|
+
logger.info("modbus %s server listening on %s:%s", self.framer.name, self._host, self._port)
|
|
68
|
+
|
|
69
|
+
async def stop(self) -> None:
|
|
70
|
+
server, self._server = self._server, None
|
|
71
|
+
if server is None:
|
|
72
|
+
return
|
|
73
|
+
server.close()
|
|
74
|
+
clients = list(self._clients)
|
|
75
|
+
for task in clients:
|
|
76
|
+
task.cancel()
|
|
77
|
+
await asyncio.gather(*clients, return_exceptions=True)
|
|
78
|
+
await server.wait_closed()
|
|
79
|
+
logger.info("modbus server on %s:%s stopped", self._host, self._port)
|
|
80
|
+
|
|
81
|
+
async def _handle_client(
|
|
82
|
+
self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter
|
|
83
|
+
) -> None:
|
|
84
|
+
task = asyncio.current_task()
|
|
85
|
+
assert task is not None
|
|
86
|
+
self._clients.add(task)
|
|
87
|
+
peer = writer.get_extra_info("peername")
|
|
88
|
+
logger.debug("client connected: %s", peer)
|
|
89
|
+
try:
|
|
90
|
+
while True:
|
|
91
|
+
frame = await self.framer.read_frame(reader)
|
|
92
|
+
if frame is None:
|
|
93
|
+
continue
|
|
94
|
+
if self.unit_id is not None and frame.unit_id != self.unit_id:
|
|
95
|
+
logger.debug("ignoring request for unit %s", frame.unit_id)
|
|
96
|
+
continue
|
|
97
|
+
plan = self.faults.plan(frame.pdu[0])
|
|
98
|
+
data = self._respond(frame, plan)
|
|
99
|
+
if plan.delay:
|
|
100
|
+
await asyncio.sleep(plan.delay)
|
|
101
|
+
if plan.disconnect:
|
|
102
|
+
logger.info("fault: disconnecting %s", peer)
|
|
103
|
+
break
|
|
104
|
+
if data is not None:
|
|
105
|
+
writer.write(data)
|
|
106
|
+
await writer.drain()
|
|
107
|
+
except FramingError as exc:
|
|
108
|
+
logger.warning("dropping %s: %s", peer, exc)
|
|
109
|
+
except (asyncio.IncompleteReadError, ConnectionError):
|
|
110
|
+
pass
|
|
111
|
+
finally:
|
|
112
|
+
self._clients.discard(task)
|
|
113
|
+
writer.close()
|
|
114
|
+
with contextlib.suppress(ConnectionError, OSError):
|
|
115
|
+
await writer.wait_closed()
|
|
116
|
+
logger.debug("client disconnected: %s", peer)
|
|
117
|
+
|
|
118
|
+
def _respond(self, frame: Frame, plan: FaultPlan) -> bytes | None:
|
|
119
|
+
"""Process a request according to the fault plan; ``None`` means send nothing."""
|
|
120
|
+
if plan.disconnect:
|
|
121
|
+
return None
|
|
122
|
+
if plan.drop:
|
|
123
|
+
if plan.execute:
|
|
124
|
+
handle_pdu(self.store, frame.pdu)
|
|
125
|
+
logger.info("fault: dropped fc 0x%02X", frame.pdu[0])
|
|
126
|
+
return None
|
|
127
|
+
if plan.exception is not None:
|
|
128
|
+
response = exception_response(frame.pdu[0], plan.exception)
|
|
129
|
+
else:
|
|
130
|
+
response = handle_pdu(self.store, frame.pdu)
|
|
131
|
+
data = self.framer.encode(Frame(frame.unit_id, response, frame.transaction_id))
|
|
132
|
+
if plan.corrupt:
|
|
133
|
+
logger.info("fault: corrupting response (%s)", plan.corrupt)
|
|
134
|
+
data = self.framer.corrupt(data, plan.corrupt, self.faults.rng)
|
|
135
|
+
return data
|
fieldsim/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""pytest plugin: virtual devices as fixtures.
|
|
2
|
+
|
|
3
|
+
Installed automatically with fieldsim (``pytest11`` entry point)::
|
|
4
|
+
|
|
5
|
+
def test_gateway_reads_temperature(modbus_device):
|
|
6
|
+
modbus_device.set("temperature", 85.0)
|
|
7
|
+
assert read_temperature(modbus_device.host, modbus_device.port) == 85.0
|
|
8
|
+
|
|
9
|
+
Configuration, from most to least specific:
|
|
10
|
+
|
|
11
|
+
* ``@pytest.mark.fieldsim("devices.yaml", device="plc", clock="virtual")``
|
|
12
|
+
* ini options ``fieldsim_config``, ``fieldsim_clock``, ``fieldsim_free_ports``
|
|
13
|
+
* otherwise a single Modbus TCP device whose whole address space is plain memory
|
|
14
|
+
|
|
15
|
+
Relative config paths are looked up next to the test file first, then in the
|
|
16
|
+
rootdir. Devices run on free ports by default so tests can run in parallel.
|
|
17
|
+
|
|
18
|
+
The fixtures are synchronous and the devices run in a background thread, so
|
|
19
|
+
they work with plain, asyncio and trio tests alike. In async tests use the
|
|
20
|
+
``*_async`` variants of blocking calls (``wait_for_write_async``,
|
|
21
|
+
``advance_async``).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
import pytest
|
|
31
|
+
|
|
32
|
+
from fieldsim.background import BackgroundSimulator, ConfigSource, DeviceHandle
|
|
33
|
+
from fieldsim.config import ConfigError
|
|
34
|
+
|
|
35
|
+
DEFAULT_DEVICE: dict[str, Any] = {
|
|
36
|
+
"name": "device",
|
|
37
|
+
"protocol": "modbus_tcp",
|
|
38
|
+
"port": 0,
|
|
39
|
+
"unmapped": "allow",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
MARKER_DOC = (
|
|
43
|
+
"fieldsim(config=None, *, device=None, clock=None, free_ports=None): "
|
|
44
|
+
"configure the fieldsim fixtures for this test (config: YAML path or dict)"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
49
|
+
parser.addini(
|
|
50
|
+
"fieldsim_config", "Default simulation file for the fieldsim fixtures.", default=""
|
|
51
|
+
)
|
|
52
|
+
parser.addini("fieldsim_clock", "'real' (default) or 'virtual'.", default="real")
|
|
53
|
+
parser.addini(
|
|
54
|
+
"fieldsim_free_ports",
|
|
55
|
+
"Ignore configured ports and let the OS pick free ones (default: true).",
|
|
56
|
+
type="bool",
|
|
57
|
+
default=True,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
62
|
+
config.addinivalue_line("markers", MARKER_DOC)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _find_file(request: pytest.FixtureRequest, value: str | Path) -> Path:
|
|
66
|
+
path = Path(value)
|
|
67
|
+
if path.is_absolute():
|
|
68
|
+
return path
|
|
69
|
+
candidates = [Path(request.path).parent / path, Path(request.config.rootpath) / path]
|
|
70
|
+
for candidate in candidates:
|
|
71
|
+
if candidate.exists():
|
|
72
|
+
return candidate
|
|
73
|
+
return candidates[0] # let the loader report a helpful "cannot read" error
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _options(request: pytest.FixtureRequest) -> dict[str, Any]:
|
|
77
|
+
marker = request.node.get_closest_marker("fieldsim")
|
|
78
|
+
kwargs: dict[str, Any] = dict(marker.kwargs) if marker else {}
|
|
79
|
+
if marker and marker.args:
|
|
80
|
+
kwargs.setdefault("config", marker.args[0])
|
|
81
|
+
|
|
82
|
+
source: Any = kwargs.get("config")
|
|
83
|
+
if source is None:
|
|
84
|
+
ini_path = request.config.getini("fieldsim_config")
|
|
85
|
+
source = ini_path or None
|
|
86
|
+
if source is None:
|
|
87
|
+
source = {"devices": [DEFAULT_DEVICE]}
|
|
88
|
+
elif isinstance(source, str | Path):
|
|
89
|
+
source = _find_file(request, source)
|
|
90
|
+
|
|
91
|
+
free_ports = kwargs.get("free_ports")
|
|
92
|
+
return {
|
|
93
|
+
"config": source,
|
|
94
|
+
"device": kwargs.get("device"),
|
|
95
|
+
"clock": kwargs.get("clock") or request.config.getini("fieldsim_clock"),
|
|
96
|
+
"free_ports": request.config.getini("fieldsim_free_ports")
|
|
97
|
+
if free_ports is None
|
|
98
|
+
else free_ports,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _start(source: ConfigSource, *, clock: Any, free_ports: bool) -> BackgroundSimulator:
|
|
103
|
+
try:
|
|
104
|
+
sim = BackgroundSimulator(source, clock=clock, free_ports=free_ports)
|
|
105
|
+
sim.start()
|
|
106
|
+
except (ConfigError, ValueError) as exc:
|
|
107
|
+
pytest.fail(f"fieldsim: {exc}", pytrace=False)
|
|
108
|
+
except OSError as exc:
|
|
109
|
+
pytest.fail(f"fieldsim: cannot start devices: {exc}", pytrace=False)
|
|
110
|
+
return sim
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@pytest.fixture
|
|
114
|
+
def fieldsim(request: pytest.FixtureRequest) -> Iterator[BackgroundSimulator]:
|
|
115
|
+
"""The running simulator with every device of the configured file."""
|
|
116
|
+
options = _options(request)
|
|
117
|
+
sim = _start(options["config"], clock=options["clock"], free_ports=options["free_ports"])
|
|
118
|
+
try:
|
|
119
|
+
yield sim
|
|
120
|
+
finally:
|
|
121
|
+
sim.stop()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@pytest.fixture
|
|
125
|
+
def modbus_device(fieldsim: BackgroundSimulator, request: pytest.FixtureRequest) -> DeviceHandle:
|
|
126
|
+
"""The Modbus device of the simulation (pick one with ``mark.fieldsim(device=...)``)."""
|
|
127
|
+
name = _options(request)["device"]
|
|
128
|
+
if name is not None:
|
|
129
|
+
return fieldsim[name]
|
|
130
|
+
candidates = [d for d in fieldsim if d.protocol.startswith("modbus")]
|
|
131
|
+
if len(candidates) != 1:
|
|
132
|
+
names = ", ".join(d.name for d in candidates) or "none"
|
|
133
|
+
pytest.fail(
|
|
134
|
+
f"fieldsim: modbus_device needs exactly one Modbus device, found: {names}. "
|
|
135
|
+
"Choose one with @pytest.mark.fieldsim(device='...').",
|
|
136
|
+
pytrace=False,
|
|
137
|
+
)
|
|
138
|
+
return candidates[0]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.fixture
|
|
142
|
+
def make_modbus_device() -> Iterator[Callable[..., DeviceHandle]]:
|
|
143
|
+
"""Factory for ad-hoc devices, cleaned up after the test::
|
|
144
|
+
|
|
145
|
+
def test_two_plcs(make_modbus_device):
|
|
146
|
+
a = make_modbus_device(registers={"coils": [{"name": "run", "address": 0}]})
|
|
147
|
+
b = make_modbus_device(protocol="modbus_rtu_over_tcp", unit_id=2)
|
|
148
|
+
"""
|
|
149
|
+
started: list[BackgroundSimulator] = []
|
|
150
|
+
|
|
151
|
+
def factory(
|
|
152
|
+
spec: Mapping[str, Any] | None = None, *, clock: Any = "real", **fields: Any
|
|
153
|
+
) -> DeviceHandle:
|
|
154
|
+
device = {**DEFAULT_DEVICE, "name": f"device{len(started) + 1}", **(spec or {}), **fields}
|
|
155
|
+
sim = _start({"devices": [device]}, clock=clock, free_ports=True)
|
|
156
|
+
started.append(sim)
|
|
157
|
+
return sim[device["name"]]
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
yield factory
|
|
161
|
+
finally:
|
|
162
|
+
for sim in reversed(started):
|
|
163
|
+
sim.stop()
|