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
fieldsim/core/types.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Numeric data types and their encoding into 16-bit registers.
|
|
2
|
+
|
|
3
|
+
Industrial protocols move data around as 16-bit words. A ``float32`` occupies two
|
|
4
|
+
words, an ``int64`` four, and vendors disagree about the order of bytes inside a
|
|
5
|
+
word and of words inside a value. This module is the single place that knows how
|
|
6
|
+
to turn Python numbers into words and back.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import struct
|
|
13
|
+
from collections.abc import Sequence
|
|
14
|
+
from typing import Literal, get_args
|
|
15
|
+
|
|
16
|
+
RegisterType = Literal[
|
|
17
|
+
"int16", "uint16", "int32", "uint32", "int64", "uint64", "float32", "float64"
|
|
18
|
+
]
|
|
19
|
+
"""Data type of a register-backed point."""
|
|
20
|
+
|
|
21
|
+
Order = Literal["big", "little"]
|
|
22
|
+
"""Byte order inside a word, or word order inside a multi-word value."""
|
|
23
|
+
|
|
24
|
+
REGISTER_TYPES: tuple[RegisterType, ...] = get_args(RegisterType)
|
|
25
|
+
|
|
26
|
+
_STRUCT_FORMAT: dict[RegisterType, str] = {
|
|
27
|
+
"int16": "h",
|
|
28
|
+
"uint16": "H",
|
|
29
|
+
"int32": "i",
|
|
30
|
+
"uint32": "I",
|
|
31
|
+
"int64": "q",
|
|
32
|
+
"uint64": "Q",
|
|
33
|
+
"float32": "f",
|
|
34
|
+
"float64": "d",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
INTEGER_RANGES: dict[RegisterType, tuple[int, int]] = {
|
|
38
|
+
"int16": (-(2**15), 2**15 - 1),
|
|
39
|
+
"uint16": (0, 2**16 - 1),
|
|
40
|
+
"int32": (-(2**31), 2**31 - 1),
|
|
41
|
+
"uint32": (0, 2**32 - 1),
|
|
42
|
+
"int64": (-(2**63), 2**63 - 1),
|
|
43
|
+
"uint64": (0, 2**64 - 1),
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
FLOAT32_MAX = 3.4028234663852886e38
|
|
47
|
+
|
|
48
|
+
Number = int | float
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def register_count(data_type: RegisterType) -> int:
|
|
52
|
+
"""Number of 16-bit registers a value of ``data_type`` occupies."""
|
|
53
|
+
return struct.calcsize(">" + _STRUCT_FORMAT[data_type]) // 2
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def is_integer_type(data_type: RegisterType) -> bool:
|
|
57
|
+
return data_type in INTEGER_RANGES
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def coerce(value: Number, data_type: RegisterType, *, clamp: bool = False) -> Number:
|
|
61
|
+
"""Validate ``value`` for ``data_type`` and return it in canonical form.
|
|
62
|
+
|
|
63
|
+
With ``clamp=False`` (user supplied values) anything that does not fit raises
|
|
64
|
+
:class:`ValueError`. With ``clamp=True`` (generator output) floats are rounded
|
|
65
|
+
and out-of-range values are saturated to the type limits, the way a real
|
|
66
|
+
device's ADC or scaling block would.
|
|
67
|
+
"""
|
|
68
|
+
if isinstance(value, bool):
|
|
69
|
+
value = int(value)
|
|
70
|
+
if not isinstance(value, int | float):
|
|
71
|
+
raise TypeError(f"expected a number, got {type(value).__name__}")
|
|
72
|
+
|
|
73
|
+
if data_type in INTEGER_RANGES:
|
|
74
|
+
lo, hi = INTEGER_RANGES[data_type]
|
|
75
|
+
if isinstance(value, float):
|
|
76
|
+
if math.isnan(value):
|
|
77
|
+
raise ValueError(f"NaN cannot be stored as {data_type}")
|
|
78
|
+
if math.isinf(value):
|
|
79
|
+
if not clamp:
|
|
80
|
+
raise ValueError(f"{value} cannot be stored as {data_type}")
|
|
81
|
+
return hi if value > 0 else lo
|
|
82
|
+
if clamp:
|
|
83
|
+
value = round(value)
|
|
84
|
+
elif not value.is_integer():
|
|
85
|
+
raise ValueError(f"{value} is not a whole number, but {data_type} requires one")
|
|
86
|
+
value = int(value)
|
|
87
|
+
if lo <= value <= hi:
|
|
88
|
+
return value
|
|
89
|
+
if clamp:
|
|
90
|
+
return max(lo, min(hi, value))
|
|
91
|
+
raise ValueError(f"{value} is out of range for {data_type} [{lo}, {hi}]")
|
|
92
|
+
|
|
93
|
+
result = float(value)
|
|
94
|
+
if data_type == "float32" and math.isfinite(result) and abs(result) > FLOAT32_MAX:
|
|
95
|
+
if clamp:
|
|
96
|
+
return math.copysign(FLOAT32_MAX, result)
|
|
97
|
+
raise ValueError(f"{value} is out of range for float32")
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def encode(
|
|
102
|
+
value: Number,
|
|
103
|
+
data_type: RegisterType,
|
|
104
|
+
byte_order: Order = "big",
|
|
105
|
+
word_order: Order = "big",
|
|
106
|
+
) -> list[int]:
|
|
107
|
+
"""Encode ``value`` into a list of 16-bit register values."""
|
|
108
|
+
raw = struct.pack(">" + _STRUCT_FORMAT[data_type], coerce(value, data_type))
|
|
109
|
+
words = [raw[i : i + 2] for i in range(0, len(raw), 2)]
|
|
110
|
+
if word_order == "little":
|
|
111
|
+
words.reverse()
|
|
112
|
+
if byte_order == "little":
|
|
113
|
+
words = [w[::-1] for w in words]
|
|
114
|
+
return [int.from_bytes(w, "big") for w in words]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def decode(
|
|
118
|
+
words: Sequence[int],
|
|
119
|
+
data_type: RegisterType,
|
|
120
|
+
byte_order: Order = "big",
|
|
121
|
+
word_order: Order = "big",
|
|
122
|
+
) -> Number:
|
|
123
|
+
"""Decode 16-bit register values back into a Python number."""
|
|
124
|
+
expected = register_count(data_type)
|
|
125
|
+
if len(words) != expected:
|
|
126
|
+
raise ValueError(f"{data_type} needs {expected} registers, got {len(words)}")
|
|
127
|
+
chunks = [w.to_bytes(2, "big") for w in words]
|
|
128
|
+
if byte_order == "little":
|
|
129
|
+
chunks = [c[::-1] for c in chunks]
|
|
130
|
+
if word_order == "little":
|
|
131
|
+
chunks.reverse()
|
|
132
|
+
(value,) = struct.unpack(">" + _STRUCT_FORMAT[data_type], b"".join(chunks))
|
|
133
|
+
return value # type: ignore[no-any-return]
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Modbus TCP simulation."""
|
|
2
|
+
|
|
3
|
+
from fieldsim.protocols.modbus.constants import ExceptionCode, FunctionCode, ModbusError
|
|
4
|
+
from fieldsim.protocols.modbus.datastore import ModbusDataStore, PointLayout, Table, WriteEvent
|
|
5
|
+
from fieldsim.protocols.modbus.device import ModbusDevice
|
|
6
|
+
from fieldsim.protocols.modbus.server import ModbusTcpServer
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ExceptionCode",
|
|
10
|
+
"FunctionCode",
|
|
11
|
+
"ModbusDataStore",
|
|
12
|
+
"ModbusDevice",
|
|
13
|
+
"ModbusError",
|
|
14
|
+
"ModbusTcpServer",
|
|
15
|
+
"PointLayout",
|
|
16
|
+
"Table",
|
|
17
|
+
"WriteEvent",
|
|
18
|
+
]
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Modbus function codes, exception codes and protocol limits."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import IntEnum
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class FunctionCode(IntEnum):
|
|
9
|
+
READ_COILS = 0x01
|
|
10
|
+
READ_DISCRETE_INPUTS = 0x02
|
|
11
|
+
READ_HOLDING_REGISTERS = 0x03
|
|
12
|
+
READ_INPUT_REGISTERS = 0x04
|
|
13
|
+
WRITE_SINGLE_COIL = 0x05
|
|
14
|
+
WRITE_SINGLE_REGISTER = 0x06
|
|
15
|
+
WRITE_MULTIPLE_COILS = 0x0F
|
|
16
|
+
WRITE_MULTIPLE_REGISTERS = 0x10
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class ExceptionCode(IntEnum):
|
|
20
|
+
ILLEGAL_FUNCTION = 0x01
|
|
21
|
+
ILLEGAL_DATA_ADDRESS = 0x02
|
|
22
|
+
ILLEGAL_DATA_VALUE = 0x03
|
|
23
|
+
SERVER_DEVICE_FAILURE = 0x04
|
|
24
|
+
SERVER_DEVICE_BUSY = 0x06
|
|
25
|
+
GATEWAY_PATH_UNAVAILABLE = 0x0A
|
|
26
|
+
GATEWAY_TARGET_FAILED = 0x0B
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ModbusError(Exception):
|
|
30
|
+
"""Aborts request processing and is answered with a Modbus exception response."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, code: ExceptionCode, message: str = ""):
|
|
33
|
+
self.code = code
|
|
34
|
+
super().__init__(message or code.name)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# Limits from the Modbus Application Protocol Specification V1.1b3.
|
|
38
|
+
MAX_READ_BITS = 2000
|
|
39
|
+
MAX_READ_REGISTERS = 125
|
|
40
|
+
MAX_WRITE_BITS = 1968
|
|
41
|
+
MAX_WRITE_REGISTERS = 123
|
|
42
|
+
ADDRESS_SPACE = 65536
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""Modbus memory: four tables of bits and 16-bit registers, addressable by point name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Literal
|
|
9
|
+
|
|
10
|
+
from fieldsim.config import HoldingRegisterPoint, InputRegisterPoint, RegisterMap
|
|
11
|
+
from fieldsim.core.datastore import PointNotFoundError, Value
|
|
12
|
+
from fieldsim.core.types import Order, RegisterType, coerce, decode, encode, register_count
|
|
13
|
+
from fieldsim.protocols.modbus.constants import ExceptionCode, ModbusError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Table(str, Enum):
|
|
17
|
+
COILS = "coils"
|
|
18
|
+
DISCRETE_INPUTS = "discrete_inputs"
|
|
19
|
+
HOLDING_REGISTERS = "holding_registers"
|
|
20
|
+
INPUT_REGISTERS = "input_registers"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def is_bit(self) -> bool:
|
|
24
|
+
return self in (Table.COILS, Table.DISCRETE_INPUTS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class PointLayout:
|
|
29
|
+
name: str
|
|
30
|
+
table: Table
|
|
31
|
+
address: int
|
|
32
|
+
count: int
|
|
33
|
+
data_type: RegisterType | None
|
|
34
|
+
byte_order: Order
|
|
35
|
+
word_order: Order
|
|
36
|
+
writable: bool
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def addresses(self) -> range:
|
|
40
|
+
return range(self.address, self.address + self.count)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class WriteEvent:
|
|
45
|
+
"""A write performed by a Modbus client."""
|
|
46
|
+
|
|
47
|
+
table: Table
|
|
48
|
+
address: int
|
|
49
|
+
values: tuple[int, ...] | tuple[bool, ...]
|
|
50
|
+
points: tuple[str, ...]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
WriteListener = Callable[[WriteEvent], None]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ModbusDataStore:
|
|
57
|
+
"""Single source of truth is the raw register/bit memory.
|
|
58
|
+
|
|
59
|
+
Named points are views over that memory, so a client writing half of a
|
|
60
|
+
``float32`` behaves exactly as it would on real hardware.
|
|
61
|
+
|
|
62
|
+
Not thread-safe: use it from the event loop that runs the server.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, registers: RegisterMap, *, unmapped: Literal["error", "allow"] = "error"):
|
|
66
|
+
self._unmapped = unmapped
|
|
67
|
+
self._bits: dict[Table, dict[int, bool]] = {Table.COILS: {}, Table.DISCRETE_INPUTS: {}}
|
|
68
|
+
self._words: dict[Table, dict[int, int]] = {
|
|
69
|
+
Table.HOLDING_REGISTERS: {},
|
|
70
|
+
Table.INPUT_REGISTERS: {},
|
|
71
|
+
}
|
|
72
|
+
self._points: dict[str, PointLayout] = {}
|
|
73
|
+
self._owners: dict[Table, dict[int, PointLayout]] = {t: {} for t in Table}
|
|
74
|
+
self._listeners: list[WriteListener] = []
|
|
75
|
+
|
|
76
|
+
for coil in registers.coils:
|
|
77
|
+
self._add(Table.COILS, coil.name, coil.address, coil.value, writable=coil.writable)
|
|
78
|
+
for di in registers.discrete_inputs:
|
|
79
|
+
self._add(Table.DISCRETE_INPUTS, di.name, di.address, di.value, writable=False)
|
|
80
|
+
for hr in registers.holding_registers:
|
|
81
|
+
self._add(Table.HOLDING_REGISTERS, hr.name, hr.address, hr.value, hr, hr.writable)
|
|
82
|
+
for ir in registers.input_registers:
|
|
83
|
+
self._add(Table.INPUT_REGISTERS, ir.name, ir.address, ir.value, ir, writable=False)
|
|
84
|
+
|
|
85
|
+
def _add(
|
|
86
|
+
self,
|
|
87
|
+
table: Table,
|
|
88
|
+
name: str,
|
|
89
|
+
address: int,
|
|
90
|
+
value: Value,
|
|
91
|
+
reg: HoldingRegisterPoint | InputRegisterPoint | None = None,
|
|
92
|
+
writable: bool = True,
|
|
93
|
+
) -> None:
|
|
94
|
+
layout = PointLayout(
|
|
95
|
+
name=name,
|
|
96
|
+
table=table,
|
|
97
|
+
address=address,
|
|
98
|
+
count=register_count(reg.type) if reg else 1,
|
|
99
|
+
data_type=reg.type if reg else None,
|
|
100
|
+
byte_order=reg.byte_order if reg else "big",
|
|
101
|
+
word_order=reg.word_order if reg else "big",
|
|
102
|
+
writable=writable,
|
|
103
|
+
)
|
|
104
|
+
self._points[name] = layout
|
|
105
|
+
for a in layout.addresses:
|
|
106
|
+
self._owners[table][a] = layout
|
|
107
|
+
self._store(layout, value)
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------ by name
|
|
110
|
+
|
|
111
|
+
def names(self) -> list[str]:
|
|
112
|
+
return list(self._points)
|
|
113
|
+
|
|
114
|
+
def point(self, name: str) -> PointLayout:
|
|
115
|
+
try:
|
|
116
|
+
return self._points[name]
|
|
117
|
+
except KeyError:
|
|
118
|
+
raise PointNotFoundError(name, self._points) from None
|
|
119
|
+
|
|
120
|
+
def points(self) -> list[PointLayout]:
|
|
121
|
+
return list(self._points.values())
|
|
122
|
+
|
|
123
|
+
def get(self, name: str) -> Value:
|
|
124
|
+
layout = self.point(name)
|
|
125
|
+
if layout.data_type is None:
|
|
126
|
+
return self._bits[layout.table][layout.address]
|
|
127
|
+
words = [self._words[layout.table][a] for a in layout.addresses]
|
|
128
|
+
return decode(words, layout.data_type, layout.byte_order, layout.word_order)
|
|
129
|
+
|
|
130
|
+
def set(self, name: str, value: Value, *, clamp: bool = False) -> None:
|
|
131
|
+
layout = self.point(name)
|
|
132
|
+
if layout.data_type is not None:
|
|
133
|
+
value = coerce(value, layout.data_type, clamp=clamp)
|
|
134
|
+
self._store(layout, value)
|
|
135
|
+
|
|
136
|
+
def _store(self, layout: PointLayout, value: Value) -> None:
|
|
137
|
+
if layout.data_type is None:
|
|
138
|
+
self._bits[layout.table][layout.address] = bool(value)
|
|
139
|
+
return
|
|
140
|
+
words = encode(value, layout.data_type, layout.byte_order, layout.word_order)
|
|
141
|
+
table = self._words[layout.table]
|
|
142
|
+
for a, w in zip(layout.addresses, words, strict=True):
|
|
143
|
+
table[a] = w
|
|
144
|
+
|
|
145
|
+
# ------------------------------------------------------------------ raw access
|
|
146
|
+
|
|
147
|
+
def read_bits(self, table: Table, address: int, count: int) -> list[bool]:
|
|
148
|
+
if not table.is_bit:
|
|
149
|
+
raise ValueError(f"{table.value} is not a bit table")
|
|
150
|
+
self._check(table, address, count, writing=False)
|
|
151
|
+
bits = self._bits[table]
|
|
152
|
+
return [bits.get(a, False) for a in range(address, address + count)]
|
|
153
|
+
|
|
154
|
+
def read_registers(self, table: Table, address: int, count: int) -> list[int]:
|
|
155
|
+
if table.is_bit:
|
|
156
|
+
raise ValueError(f"{table.value} is not a register table")
|
|
157
|
+
self._check(table, address, count, writing=False)
|
|
158
|
+
words = self._words[table]
|
|
159
|
+
return [words.get(a, 0) for a in range(address, address + count)]
|
|
160
|
+
|
|
161
|
+
def write_coils(self, address: int, values: Sequence[bool]) -> None:
|
|
162
|
+
points = self._check(Table.COILS, address, len(values), writing=True)
|
|
163
|
+
bits = self._bits[Table.COILS]
|
|
164
|
+
for offset, value in enumerate(values):
|
|
165
|
+
bits[address + offset] = bool(value)
|
|
166
|
+
self._notify(WriteEvent(Table.COILS, address, tuple(bool(v) for v in values), points))
|
|
167
|
+
|
|
168
|
+
def write_registers(self, address: int, values: Sequence[int]) -> None:
|
|
169
|
+
points = self._check(Table.HOLDING_REGISTERS, address, len(values), writing=True)
|
|
170
|
+
words = self._words[Table.HOLDING_REGISTERS]
|
|
171
|
+
for offset, value in enumerate(values):
|
|
172
|
+
words[address + offset] = value & 0xFFFF
|
|
173
|
+
self._notify(WriteEvent(Table.HOLDING_REGISTERS, address, tuple(values), points))
|
|
174
|
+
|
|
175
|
+
def _check(self, table: Table, address: int, count: int, *, writing: bool) -> tuple[str, ...]:
|
|
176
|
+
"""Validate a raw access and return the names of the points it touches."""
|
|
177
|
+
owners = self._owners[table]
|
|
178
|
+
touched: dict[str, None] = {}
|
|
179
|
+
for a in range(address, address + count):
|
|
180
|
+
owner = owners.get(a)
|
|
181
|
+
if owner is None:
|
|
182
|
+
if self._unmapped == "error":
|
|
183
|
+
raise ModbusError(
|
|
184
|
+
ExceptionCode.ILLEGAL_DATA_ADDRESS, f"{table.value}[{a}] is not mapped"
|
|
185
|
+
)
|
|
186
|
+
continue
|
|
187
|
+
if writing and not owner.writable:
|
|
188
|
+
raise ModbusError(
|
|
189
|
+
ExceptionCode.ILLEGAL_DATA_ADDRESS, f"{owner.name!r} is read-only"
|
|
190
|
+
)
|
|
191
|
+
touched[owner.name] = None
|
|
192
|
+
return tuple(touched)
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------------ events
|
|
195
|
+
|
|
196
|
+
def add_write_listener(self, listener: WriteListener) -> Callable[[], None]:
|
|
197
|
+
"""Call ``listener`` after every client write. Returns a function that unsubscribes."""
|
|
198
|
+
self._listeners.append(listener)
|
|
199
|
+
return lambda: self._listeners.remove(listener)
|
|
200
|
+
|
|
201
|
+
def _notify(self, event: WriteEvent) -> None:
|
|
202
|
+
for listener in list(self._listeners):
|
|
203
|
+
listener(event)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Modbus device: datastore + generators + faults + scenario + server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
|
|
8
|
+
from fieldsim.config import CorruptFault, FaultSpec, ModbusDeviceSpec, check_corrupt_mode
|
|
9
|
+
from fieldsim.core.clock import Clock
|
|
10
|
+
from fieldsim.core.device import Device
|
|
11
|
+
from fieldsim.core.generators import build_generator
|
|
12
|
+
from fieldsim.protocols.modbus.datastore import ModbusDataStore, WriteEvent
|
|
13
|
+
from fieldsim.protocols.modbus.framer import FRAMERS
|
|
14
|
+
from fieldsim.protocols.modbus.server import ModbusTcpServer
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ModbusDevice(Device[ModbusDataStore]):
|
|
18
|
+
"""A virtual Modbus device built from a :class:`ModbusDeviceSpec`.
|
|
19
|
+
|
|
20
|
+
Besides the common :class:`Device` API it records every client write, so a
|
|
21
|
+
test can assert on what the software under test sent::
|
|
22
|
+
|
|
23
|
+
event = await device.wait_for_write("setpoint")
|
|
24
|
+
assert device.get("setpoint") == 80
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, spec: ModbusDeviceSpec, *, clock: Clock | None = None):
|
|
28
|
+
self.protocol = spec.protocol
|
|
29
|
+
store = ModbusDataStore(spec.registers, unmapped=spec.unmapped)
|
|
30
|
+
generators = {
|
|
31
|
+
point.name: build_generator(point.generator)
|
|
32
|
+
for point in spec.registers.all_points()
|
|
33
|
+
if point.generator is not None
|
|
34
|
+
}
|
|
35
|
+
super().__init__(
|
|
36
|
+
spec.name,
|
|
37
|
+
store,
|
|
38
|
+
generators,
|
|
39
|
+
update_interval=spec.update_interval,
|
|
40
|
+
faults=spec.faults,
|
|
41
|
+
scenario=spec.scenario,
|
|
42
|
+
seed=spec.seed,
|
|
43
|
+
clock=clock,
|
|
44
|
+
)
|
|
45
|
+
self.spec = spec
|
|
46
|
+
self.server = ModbusTcpServer(
|
|
47
|
+
store,
|
|
48
|
+
host=spec.host,
|
|
49
|
+
port=spec.port,
|
|
50
|
+
unit_id=spec.unit_id,
|
|
51
|
+
framer=FRAMERS[spec.protocol](),
|
|
52
|
+
faults=self.faults,
|
|
53
|
+
)
|
|
54
|
+
self.writes: list[WriteEvent] = []
|
|
55
|
+
self._write_cursor = 0
|
|
56
|
+
self._write_signal = asyncio.Event()
|
|
57
|
+
store.add_write_listener(self._on_write)
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def host(self) -> str:
|
|
61
|
+
return self.server.host
|
|
62
|
+
|
|
63
|
+
@property
|
|
64
|
+
def port(self) -> int:
|
|
65
|
+
return self.server.port
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def unit_id(self) -> int:
|
|
69
|
+
"""Unit id clients should use (1 when the device answers every unit id)."""
|
|
70
|
+
return 1 if self.spec.unit_id is None else self.spec.unit_id
|
|
71
|
+
|
|
72
|
+
async def _start_server(self) -> None:
|
|
73
|
+
await self.server.start()
|
|
74
|
+
|
|
75
|
+
async def _stop_server(self) -> None:
|
|
76
|
+
await self.server.stop()
|
|
77
|
+
|
|
78
|
+
def validate_fault(self, spec: FaultSpec) -> None:
|
|
79
|
+
if isinstance(spec, CorruptFault):
|
|
80
|
+
check_corrupt_mode(spec.mode, self.protocol)
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------ write log
|
|
83
|
+
|
|
84
|
+
def _on_write(self, event: WriteEvent) -> None:
|
|
85
|
+
self.writes.append(event)
|
|
86
|
+
self._write_signal.set()
|
|
87
|
+
|
|
88
|
+
def clear_writes(self) -> None:
|
|
89
|
+
self.writes.clear()
|
|
90
|
+
self._write_cursor = 0
|
|
91
|
+
|
|
92
|
+
async def wait_for_write(self, point: str | None = None, timeout: float = 5.0) -> WriteEvent:
|
|
93
|
+
"""Wait for a client write (touching ``point``, if given) and return it.
|
|
94
|
+
|
|
95
|
+
Writes are consumed in order: each call returns the next matching write
|
|
96
|
+
that no earlier call has returned, including writes that happened before
|
|
97
|
+
the call. Raises :class:`TimeoutError` if none arrives in time.
|
|
98
|
+
"""
|
|
99
|
+
if point is not None:
|
|
100
|
+
self.store.point(point) # validates the name
|
|
101
|
+
loop = asyncio.get_running_loop()
|
|
102
|
+
deadline = loop.time() + timeout
|
|
103
|
+
while True:
|
|
104
|
+
for index in range(self._write_cursor, len(self.writes)):
|
|
105
|
+
event = self.writes[index]
|
|
106
|
+
if point is None or point in event.points:
|
|
107
|
+
self._write_cursor = index + 1
|
|
108
|
+
return event
|
|
109
|
+
remaining = deadline - loop.time()
|
|
110
|
+
if remaining <= 0:
|
|
111
|
+
target = f"to {point!r} " if point else ""
|
|
112
|
+
raise TimeoutError(f"no write {target}within {timeout}s on {self.name!r}")
|
|
113
|
+
self._write_signal.clear()
|
|
114
|
+
with contextlib.suppress(asyncio.TimeoutError):
|
|
115
|
+
await asyncio.wait_for(self._write_signal.wait(), remaining)
|