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.
@@ -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()