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/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)