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/config.py
ADDED
|
@@ -0,0 +1,560 @@
|
|
|
1
|
+
"""YAML configuration schema.
|
|
2
|
+
|
|
3
|
+
A simulation file describes one or more virtual devices::
|
|
4
|
+
|
|
5
|
+
version: 1
|
|
6
|
+
devices:
|
|
7
|
+
- name: boiler-plc
|
|
8
|
+
protocol: modbus_tcp
|
|
9
|
+
port: 5020
|
|
10
|
+
registers:
|
|
11
|
+
holding_registers:
|
|
12
|
+
- name: temperature
|
|
13
|
+
address: 0
|
|
14
|
+
type: float32
|
|
15
|
+
generator: {type: sine, min: 60, max: 90, period: 30s}
|
|
16
|
+
|
|
17
|
+
The models here are the public contract of the file format. Everything is
|
|
18
|
+
validated up front so that mistakes are reported with a precise location instead
|
|
19
|
+
of surfacing as a confusing protocol error at runtime.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import re
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Annotated, Any, Literal
|
|
28
|
+
|
|
29
|
+
import yaml
|
|
30
|
+
from pydantic import (
|
|
31
|
+
BaseModel,
|
|
32
|
+
BeforeValidator,
|
|
33
|
+
ConfigDict,
|
|
34
|
+
Field,
|
|
35
|
+
ValidationError,
|
|
36
|
+
WithJsonSchema,
|
|
37
|
+
field_validator,
|
|
38
|
+
model_validator,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
from fieldsim.core.types import Order, RegisterType, coerce, register_count
|
|
42
|
+
|
|
43
|
+
# --------------------------------------------------------------------------- primitives
|
|
44
|
+
|
|
45
|
+
_DURATION_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*(ms|s|m|h)?\s*$")
|
|
46
|
+
_DURATION_UNITS = {"ms": 0.001, "s": 1.0, "m": 60.0, "h": 3600.0}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_duration(value: Any) -> float:
|
|
50
|
+
"""Parse ``"500ms"``, ``"2s"``, ``"1.5m"``, ``"1h"`` or a plain number of seconds."""
|
|
51
|
+
if isinstance(value, bool):
|
|
52
|
+
raise ValueError("expected a duration, got a boolean")
|
|
53
|
+
if isinstance(value, int | float):
|
|
54
|
+
seconds = float(value)
|
|
55
|
+
elif isinstance(value, str):
|
|
56
|
+
match = _DURATION_RE.match(value)
|
|
57
|
+
if not match:
|
|
58
|
+
raise ValueError(f"invalid duration {value!r}; use e.g. '500ms', '2s', '1m' or '1h'")
|
|
59
|
+
seconds = float(match.group(1)) * _DURATION_UNITS[match.group(2) or "s"]
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(f"expected a duration, got {type(value).__name__}")
|
|
62
|
+
if seconds < 0:
|
|
63
|
+
raise ValueError("duration must not be negative")
|
|
64
|
+
return seconds
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
Duration = Annotated[
|
|
68
|
+
float,
|
|
69
|
+
BeforeValidator(parse_duration),
|
|
70
|
+
WithJsonSchema(
|
|
71
|
+
{
|
|
72
|
+
"anyOf": [
|
|
73
|
+
{"type": "number", "minimum": 0},
|
|
74
|
+
{"type": "string", "pattern": _DURATION_RE.pattern},
|
|
75
|
+
],
|
|
76
|
+
"description": "Seconds, or a string such as '500ms', '2s', '1m', '1h'.",
|
|
77
|
+
}
|
|
78
|
+
),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
Name = Annotated[str, Field(pattern=r"^[A-Za-z_][A-Za-z0-9_.\-]*$", max_length=128)]
|
|
82
|
+
Address = Annotated[int, Field(ge=0, le=65535)]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class _Model(BaseModel):
|
|
86
|
+
model_config = ConfigDict(extra="forbid", frozen=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --------------------------------------------------------------------------- generators
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class ConstantGenerator(_Model):
|
|
93
|
+
"""Always produces the same value."""
|
|
94
|
+
|
|
95
|
+
type: Literal["constant"]
|
|
96
|
+
value: float
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SineGenerator(_Model):
|
|
100
|
+
"""Oscillates between ``min`` and ``max``, starting at the midpoint."""
|
|
101
|
+
|
|
102
|
+
type: Literal["sine"]
|
|
103
|
+
min: float
|
|
104
|
+
max: float
|
|
105
|
+
period: Duration
|
|
106
|
+
phase: Duration = 0.0
|
|
107
|
+
|
|
108
|
+
@model_validator(mode="after")
|
|
109
|
+
def _check(self) -> SineGenerator:
|
|
110
|
+
if self.min > self.max:
|
|
111
|
+
raise ValueError("min must be <= max")
|
|
112
|
+
if self.period <= 0:
|
|
113
|
+
raise ValueError("period must be > 0")
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class RampGenerator(_Model):
|
|
118
|
+
"""Moves linearly from ``start`` to ``end`` over ``period``.
|
|
119
|
+
|
|
120
|
+
``sawtooth`` jumps back to ``start``, ``triangle`` walks back down, ``once``
|
|
121
|
+
stops at ``end``.
|
|
122
|
+
"""
|
|
123
|
+
|
|
124
|
+
type: Literal["ramp"]
|
|
125
|
+
start: float
|
|
126
|
+
end: float
|
|
127
|
+
period: Duration
|
|
128
|
+
mode: Literal["sawtooth", "triangle", "once"] = "sawtooth"
|
|
129
|
+
|
|
130
|
+
@field_validator("period")
|
|
131
|
+
@classmethod
|
|
132
|
+
def _positive(cls, v: float) -> float:
|
|
133
|
+
if v <= 0:
|
|
134
|
+
raise ValueError("period must be > 0")
|
|
135
|
+
return v
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class RandomWalkGenerator(_Model):
|
|
139
|
+
"""Adds a random step in ``[-step, step]`` on every update, bounded by min/max."""
|
|
140
|
+
|
|
141
|
+
type: Literal["random_walk"]
|
|
142
|
+
start: float
|
|
143
|
+
step: float = Field(gt=0)
|
|
144
|
+
min: float
|
|
145
|
+
max: float
|
|
146
|
+
seed: int | None = None
|
|
147
|
+
|
|
148
|
+
@model_validator(mode="after")
|
|
149
|
+
def _check(self) -> RandomWalkGenerator:
|
|
150
|
+
if self.min > self.max:
|
|
151
|
+
raise ValueError("min must be <= max")
|
|
152
|
+
if not self.min <= self.start <= self.max:
|
|
153
|
+
raise ValueError("start must be between min and max")
|
|
154
|
+
return self
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class SequenceGenerator(_Model):
|
|
158
|
+
"""Steps through ``values``, holding each one for ``interval``."""
|
|
159
|
+
|
|
160
|
+
type: Literal["sequence"]
|
|
161
|
+
values: list[float] = Field(min_length=1)
|
|
162
|
+
interval: Duration
|
|
163
|
+
repeat: bool = True
|
|
164
|
+
|
|
165
|
+
@field_validator("interval")
|
|
166
|
+
@classmethod
|
|
167
|
+
def _positive(cls, v: float) -> float:
|
|
168
|
+
if v <= 0:
|
|
169
|
+
raise ValueError("interval must be > 0")
|
|
170
|
+
return v
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
GeneratorSpec = Annotated[
|
|
174
|
+
ConstantGenerator | SineGenerator | RampGenerator | RandomWalkGenerator | SequenceGenerator,
|
|
175
|
+
Field(discriminator="type"),
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
# --------------------------------------------------------------------------- points
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
class _Point(_Model):
|
|
182
|
+
name: Name
|
|
183
|
+
address: Address
|
|
184
|
+
description: str | None = None
|
|
185
|
+
generator: GeneratorSpec | None = None
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class _BitPoint(_Point):
|
|
189
|
+
value: bool = False
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class _RegisterPoint(_Point):
|
|
193
|
+
type: RegisterType = "uint16"
|
|
194
|
+
value: int | float = 0
|
|
195
|
+
byte_order: Order = "big"
|
|
196
|
+
word_order: Order = "big"
|
|
197
|
+
|
|
198
|
+
@property
|
|
199
|
+
def count(self) -> int:
|
|
200
|
+
return register_count(self.type)
|
|
201
|
+
|
|
202
|
+
@model_validator(mode="after")
|
|
203
|
+
def _check(self) -> _RegisterPoint:
|
|
204
|
+
if self.address + self.count - 1 > 65535:
|
|
205
|
+
raise ValueError(
|
|
206
|
+
f"{self.type} at address {self.address} needs {self.count} registers "
|
|
207
|
+
"and runs past address 65535"
|
|
208
|
+
)
|
|
209
|
+
coerce(self.value, self.type) # raises ValueError with a readable message
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
class CoilPoint(_BitPoint):
|
|
214
|
+
"""Read/write bit (Modbus function codes 1, 5, 15)."""
|
|
215
|
+
|
|
216
|
+
writable: bool = True
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class DiscreteInputPoint(_BitPoint):
|
|
220
|
+
"""Read-only bit (Modbus function code 2)."""
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class HoldingRegisterPoint(_RegisterPoint):
|
|
224
|
+
"""Read/write register (Modbus function codes 3, 6, 16)."""
|
|
225
|
+
|
|
226
|
+
writable: bool = True
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class InputRegisterPoint(_RegisterPoint):
|
|
230
|
+
"""Read-only register (Modbus function code 4)."""
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
AnyPoint = CoilPoint | DiscreteInputPoint | HoldingRegisterPoint | InputRegisterPoint
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
class RegisterMap(_Model):
|
|
237
|
+
coils: list[CoilPoint] = Field(default_factory=list)
|
|
238
|
+
discrete_inputs: list[DiscreteInputPoint] = Field(default_factory=list)
|
|
239
|
+
holding_registers: list[HoldingRegisterPoint] = Field(default_factory=list)
|
|
240
|
+
input_registers: list[InputRegisterPoint] = Field(default_factory=list)
|
|
241
|
+
|
|
242
|
+
def all_points(self) -> Iterator[AnyPoint]:
|
|
243
|
+
yield from self.coils
|
|
244
|
+
yield from self.discrete_inputs
|
|
245
|
+
yield from self.holding_registers
|
|
246
|
+
yield from self.input_registers
|
|
247
|
+
|
|
248
|
+
@model_validator(mode="after")
|
|
249
|
+
def _check(self) -> RegisterMap:
|
|
250
|
+
seen: dict[str, str] = {}
|
|
251
|
+
for table in ("coils", "discrete_inputs", "holding_registers", "input_registers"):
|
|
252
|
+
owners: dict[int, str] = {}
|
|
253
|
+
for point in getattr(self, table):
|
|
254
|
+
if point.name in seen:
|
|
255
|
+
raise ValueError(
|
|
256
|
+
f"duplicate point name {point.name!r} (in {seen[point.name]} and {table})"
|
|
257
|
+
)
|
|
258
|
+
seen[point.name] = table
|
|
259
|
+
count = point.count if isinstance(point, _RegisterPoint) else 1
|
|
260
|
+
for address in range(point.address, point.address + count):
|
|
261
|
+
if address in owners:
|
|
262
|
+
raise ValueError(
|
|
263
|
+
f"{table}: {point.name!r} overlaps {owners[address]!r} "
|
|
264
|
+
f"at address {address}"
|
|
265
|
+
)
|
|
266
|
+
owners[address] = point.name
|
|
267
|
+
return self
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --------------------------------------------------------------------------- faults
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
class _Fault(_Model):
|
|
274
|
+
probability: float = Field(
|
|
275
|
+
default=1.0, ge=0, le=1, description="Chance that a matching request is affected."
|
|
276
|
+
)
|
|
277
|
+
function_codes: list[Annotated[int, Field(ge=1, le=127)]] | None = Field(
|
|
278
|
+
default=None, description="Only affect these function codes (default: all)."
|
|
279
|
+
)
|
|
280
|
+
count: int | None = Field(
|
|
281
|
+
default=None, ge=1, description="Remove the fault after it affected this many requests."
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class LatencyFault(_Fault):
|
|
286
|
+
"""Delay responses by ``delay`` plus a random ``0..jitter``."""
|
|
287
|
+
|
|
288
|
+
type: Literal["latency"]
|
|
289
|
+
delay: Duration
|
|
290
|
+
jitter: Duration = 0.0
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class DropFault(_Fault):
|
|
294
|
+
"""Never answer the request. With ``executed: true`` the request is still
|
|
295
|
+
carried out and only the response is lost - the nasty case for retries."""
|
|
296
|
+
|
|
297
|
+
type: Literal["drop"]
|
|
298
|
+
executed: bool = False
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
class DisconnectFault(_Fault):
|
|
302
|
+
"""Close the client connection instead of answering."""
|
|
303
|
+
|
|
304
|
+
type: Literal["disconnect"]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class ExceptionFault(_Fault):
|
|
308
|
+
"""Answer with a Modbus exception code instead of the real response."""
|
|
309
|
+
|
|
310
|
+
type: Literal["exception"]
|
|
311
|
+
code: int = Field(default=6, ge=1, le=255, description="6 = SERVER DEVICE BUSY")
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
CorruptMode = Literal["bit_flip", "truncate", "garbage", "bad_length", "transaction_id", "bad_crc"]
|
|
315
|
+
|
|
316
|
+
TCP_ONLY_CORRUPT_MODES = frozenset({"bad_length", "transaction_id"})
|
|
317
|
+
RTU_ONLY_CORRUPT_MODES = frozenset({"bad_crc"})
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class CorruptFault(_Fault):
|
|
321
|
+
"""Send a damaged response.
|
|
322
|
+
|
|
323
|
+
``bit_flip`` flips one random bit of the PDU, ``truncate`` sends only part of
|
|
324
|
+
the frame, ``garbage`` replaces it with random bytes of the same length,
|
|
325
|
+
``bad_length`` (TCP) breaks the MBAP length field, ``transaction_id`` (TCP)
|
|
326
|
+
answers with the wrong transaction id and ``bad_crc`` (RTU) breaks the CRC.
|
|
327
|
+
"""
|
|
328
|
+
|
|
329
|
+
type: Literal["corrupt"]
|
|
330
|
+
mode: CorruptMode = "bit_flip"
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
FaultSpec = Annotated[
|
|
334
|
+
LatencyFault | DropFault | DisconnectFault | ExceptionFault | CorruptFault,
|
|
335
|
+
Field(discriminator="type"),
|
|
336
|
+
]
|
|
337
|
+
|
|
338
|
+
# --------------------------------------------------------------------------- scenario
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
class ScenarioStep(_Model):
|
|
342
|
+
"""One timed action. Exactly one of ``set``, ``release``, ``fault`` or ``offline``."""
|
|
343
|
+
|
|
344
|
+
at: Duration = Field(description="Time since the device started.")
|
|
345
|
+
set: dict[str, bool | int | float] | None = Field(
|
|
346
|
+
default=None, description="Point values to set (pauses their generators)."
|
|
347
|
+
)
|
|
348
|
+
release: list[str] | None = Field(
|
|
349
|
+
default=None, description="Points to hand back to their generators."
|
|
350
|
+
)
|
|
351
|
+
fault: FaultSpec | None = Field(default=None, description="Fault to activate.")
|
|
352
|
+
duration: Duration | None = Field(
|
|
353
|
+
default=None, description="How long the fault stays active (default: forever)."
|
|
354
|
+
)
|
|
355
|
+
offline: Duration | None = Field(
|
|
356
|
+
default=None, description="Stop listening and drop all clients for this long."
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
@model_validator(mode="after")
|
|
360
|
+
def _check(self) -> ScenarioStep:
|
|
361
|
+
actions = [
|
|
362
|
+
a for a in ("set", "release", "fault", "offline") if getattr(self, a) is not None
|
|
363
|
+
]
|
|
364
|
+
if len(actions) != 1:
|
|
365
|
+
raise ValueError(
|
|
366
|
+
"each step needs exactly one of 'set', 'release', 'fault', 'offline'"
|
|
367
|
+
+ (f" (got {', '.join(actions)})" if actions else "")
|
|
368
|
+
)
|
|
369
|
+
if self.duration is not None and self.fault is None:
|
|
370
|
+
raise ValueError("'duration' only applies to 'fault' steps")
|
|
371
|
+
if self.offline is not None and self.offline <= 0:
|
|
372
|
+
raise ValueError("offline duration must be > 0")
|
|
373
|
+
return self
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
# --------------------------------------------------------------------------- devices
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
class ModbusDeviceSpec(_Model):
|
|
380
|
+
"""A Modbus server reachable over TCP (native Modbus TCP or RTU framing over TCP)."""
|
|
381
|
+
|
|
382
|
+
name: Name
|
|
383
|
+
protocol: Literal["modbus_tcp", "modbus_rtu_over_tcp"]
|
|
384
|
+
host: str = "127.0.0.1"
|
|
385
|
+
port: int = Field(default=502, ge=0, le=65535, description="0 picks a free port.")
|
|
386
|
+
unit_id: int | None = Field(
|
|
387
|
+
default=None,
|
|
388
|
+
ge=0,
|
|
389
|
+
le=255,
|
|
390
|
+
description="Only answer requests for this unit id. Omit to answer every unit id.",
|
|
391
|
+
)
|
|
392
|
+
unmapped: Literal["error", "allow"] = Field(
|
|
393
|
+
default="error",
|
|
394
|
+
description=(
|
|
395
|
+
"'error' answers requests touching undefined addresses with exception 02 "
|
|
396
|
+
"(ILLEGAL DATA ADDRESS); 'allow' treats them as plain memory initialised to 0."
|
|
397
|
+
),
|
|
398
|
+
)
|
|
399
|
+
update_interval: Duration = Field(
|
|
400
|
+
default=0.1, description="How often generators refresh their points."
|
|
401
|
+
)
|
|
402
|
+
registers: RegisterMap = Field(default_factory=RegisterMap)
|
|
403
|
+
faults: list[FaultSpec] = Field(
|
|
404
|
+
default_factory=list, description="Faults active from the moment the device starts."
|
|
405
|
+
)
|
|
406
|
+
scenario: list[ScenarioStep] = Field(
|
|
407
|
+
default_factory=list, description="Timed actions, relative to device start."
|
|
408
|
+
)
|
|
409
|
+
seed: int | None = Field(
|
|
410
|
+
default=None, description="Seed for fault randomness, for reproducible runs."
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
@field_validator("update_interval")
|
|
414
|
+
@classmethod
|
|
415
|
+
def _positive(cls, v: float) -> float:
|
|
416
|
+
if v <= 0:
|
|
417
|
+
raise ValueError("update_interval must be > 0")
|
|
418
|
+
return v
|
|
419
|
+
|
|
420
|
+
@model_validator(mode="after")
|
|
421
|
+
def _check(self) -> ModbusDeviceSpec:
|
|
422
|
+
points = {p.name: p for p in self.registers.all_points()}
|
|
423
|
+
faults = list(self.faults)
|
|
424
|
+
for i, step in enumerate(self.scenario):
|
|
425
|
+
where = f"scenario[{i}]"
|
|
426
|
+
for name, value in (step.set or {}).items():
|
|
427
|
+
point = points.get(name)
|
|
428
|
+
if point is None:
|
|
429
|
+
raise ValueError(f"{where}: unknown point {name!r}")
|
|
430
|
+
if isinstance(point, _RegisterPoint):
|
|
431
|
+
try:
|
|
432
|
+
coerce(value, point.type)
|
|
433
|
+
except ValueError as exc:
|
|
434
|
+
raise ValueError(f"{where}: {name}: {exc}") from None
|
|
435
|
+
for name in step.release or []:
|
|
436
|
+
if name not in points:
|
|
437
|
+
raise ValueError(f"{where}: unknown point {name!r}")
|
|
438
|
+
if step.fault is not None:
|
|
439
|
+
faults.append(step.fault)
|
|
440
|
+
for fault in faults:
|
|
441
|
+
if isinstance(fault, CorruptFault):
|
|
442
|
+
check_corrupt_mode(fault.mode, self.protocol)
|
|
443
|
+
return self
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def check_corrupt_mode(mode: str, protocol: str) -> None:
|
|
447
|
+
if protocol == "modbus_tcp" and mode in RTU_ONLY_CORRUPT_MODES:
|
|
448
|
+
raise ValueError(
|
|
449
|
+
f"corrupt mode {mode!r} needs RTU framing (CRC); use it with modbus_rtu_over_tcp"
|
|
450
|
+
)
|
|
451
|
+
if protocol == "modbus_rtu_over_tcp" and mode in TCP_ONLY_CORRUPT_MODES:
|
|
452
|
+
raise ValueError(f"corrupt mode {mode!r} only exists in Modbus TCP framing")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
ModbusTcpDevice = ModbusDeviceSpec
|
|
456
|
+
"""Backwards compatible alias."""
|
|
457
|
+
|
|
458
|
+
DeviceSpec = ModbusDeviceSpec
|
|
459
|
+
"""Union of all device kinds. Grows as protocols are added."""
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
class SimulationConfig(_Model):
|
|
463
|
+
"""Root of a simulation file."""
|
|
464
|
+
|
|
465
|
+
version: Literal[1] = 1
|
|
466
|
+
devices: list[DeviceSpec] = Field(min_length=1)
|
|
467
|
+
|
|
468
|
+
@model_validator(mode="after")
|
|
469
|
+
def _check(self) -> SimulationConfig:
|
|
470
|
+
names: set[str] = set()
|
|
471
|
+
endpoints: dict[tuple[str, int], str] = {}
|
|
472
|
+
for device in self.devices:
|
|
473
|
+
if device.name in names:
|
|
474
|
+
raise ValueError(f"duplicate device name {device.name!r}")
|
|
475
|
+
names.add(device.name)
|
|
476
|
+
if device.port:
|
|
477
|
+
key = (device.host, device.port)
|
|
478
|
+
if key in endpoints:
|
|
479
|
+
raise ValueError(
|
|
480
|
+
f"devices {endpoints[key]!r} and {device.name!r} both listen on "
|
|
481
|
+
f"{device.host}:{device.port}"
|
|
482
|
+
)
|
|
483
|
+
endpoints[key] = device.name
|
|
484
|
+
return self
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
# --------------------------------------------------------------------------- loading
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class ConfigError(Exception):
|
|
491
|
+
"""Raised when a simulation file cannot be read or is invalid."""
|
|
492
|
+
|
|
493
|
+
def __init__(self, message: str, *, source: str | None = None, errors: list[str] | None = None):
|
|
494
|
+
self.source = source
|
|
495
|
+
self.errors = errors or []
|
|
496
|
+
lines = [f"{source}: {message}" if source else message]
|
|
497
|
+
lines += [f" - {e}" for e in self.errors]
|
|
498
|
+
super().__init__("\n".join(lines))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _format_location(loc: tuple[int | str, ...]) -> str:
|
|
502
|
+
out = ""
|
|
503
|
+
for part in loc:
|
|
504
|
+
if isinstance(part, int):
|
|
505
|
+
out += f"[{part}]"
|
|
506
|
+
else:
|
|
507
|
+
out += f".{part}" if out else part
|
|
508
|
+
return out or "(root)"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _format_validation_error(exc: ValidationError) -> list[str]:
|
|
512
|
+
messages = []
|
|
513
|
+
for err in exc.errors(include_url=False):
|
|
514
|
+
# Drop the discriminator tag pydantic inserts for tagged unions, it is noise.
|
|
515
|
+
loc = tuple(p for p in err["loc"] if p not in _UNION_TAGS)
|
|
516
|
+
msg = err["msg"].removeprefix("Value error, ")
|
|
517
|
+
messages.append(f"{_format_location(loc)}: {msg}")
|
|
518
|
+
return messages
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
_UNION_TAGS = {
|
|
522
|
+
# generators
|
|
523
|
+
"constant",
|
|
524
|
+
"sine",
|
|
525
|
+
"ramp",
|
|
526
|
+
"random_walk",
|
|
527
|
+
"sequence",
|
|
528
|
+
# faults
|
|
529
|
+
"latency",
|
|
530
|
+
"drop",
|
|
531
|
+
"disconnect",
|
|
532
|
+
"exception",
|
|
533
|
+
"corrupt",
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def parse_config(data: Any, *, source: str | None = None) -> SimulationConfig:
|
|
538
|
+
"""Validate already-parsed YAML/JSON data."""
|
|
539
|
+
if not isinstance(data, dict):
|
|
540
|
+
raise ConfigError("top level must be a mapping with a 'devices' key", source=source)
|
|
541
|
+
try:
|
|
542
|
+
return SimulationConfig.model_validate(data)
|
|
543
|
+
except ValidationError as exc:
|
|
544
|
+
raise ConfigError(
|
|
545
|
+
"invalid configuration", source=source, errors=_format_validation_error(exc)
|
|
546
|
+
) from None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def load_config(path: str | Path) -> SimulationConfig:
|
|
550
|
+
"""Read and validate a simulation file."""
|
|
551
|
+
path = Path(path)
|
|
552
|
+
try:
|
|
553
|
+
text = path.read_text(encoding="utf-8")
|
|
554
|
+
except OSError as exc:
|
|
555
|
+
raise ConfigError(f"cannot read file: {exc.strerror}", source=str(path)) from None
|
|
556
|
+
try:
|
|
557
|
+
data = yaml.safe_load(text)
|
|
558
|
+
except yaml.YAMLError as exc:
|
|
559
|
+
raise ConfigError(f"invalid YAML: {exc}", source=str(path)) from None
|
|
560
|
+
return parse_config(data, source=str(path))
|
|
File without changes
|
fieldsim/core/clock.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""Clocks that drive generators and scenarios.
|
|
2
|
+
|
|
3
|
+
:class:`RealClock` follows wall time. :class:`VirtualClock` only moves when
|
|
4
|
+
:meth:`VirtualClock.advance` is called, so a test can play a 10 minute scenario
|
|
5
|
+
in milliseconds and still get exactly the same sequence of events every run.
|
|
6
|
+
|
|
7
|
+
Network behaviour (latency faults, socket timeouts) always uses real time.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import heapq
|
|
14
|
+
import itertools
|
|
15
|
+
import time
|
|
16
|
+
from typing import Protocol
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Clock(Protocol):
|
|
20
|
+
def time(self) -> float:
|
|
21
|
+
"""Current time in seconds (arbitrary origin)."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
async def sleep(self, delay: float) -> None:
|
|
25
|
+
"""Suspend the caller for ``delay`` seconds of this clock's time."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class RealClock:
|
|
30
|
+
def time(self) -> float:
|
|
31
|
+
return time.monotonic()
|
|
32
|
+
|
|
33
|
+
async def sleep(self, delay: float) -> None:
|
|
34
|
+
await asyncio.sleep(max(delay, 0.0))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class VirtualClock:
|
|
38
|
+
"""A clock that stands still until told to move.
|
|
39
|
+
|
|
40
|
+
All coroutines sleeping on it are woken in deadline order while
|
|
41
|
+
:meth:`advance` runs, and each gets the chance to schedule its next sleep
|
|
42
|
+
before time moves on. Use it from a single event loop.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
#: Event-loop iterations granted to woken tasks before time moves on.
|
|
46
|
+
settle_iterations = 8
|
|
47
|
+
|
|
48
|
+
def __init__(self, start: float = 0.0):
|
|
49
|
+
self._now = start
|
|
50
|
+
self._sleepers: list[tuple[float, int, asyncio.Future[None]]] = []
|
|
51
|
+
self._counter = itertools.count()
|
|
52
|
+
|
|
53
|
+
def time(self) -> float:
|
|
54
|
+
return self._now
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def pending(self) -> int:
|
|
58
|
+
"""Number of coroutines currently sleeping on this clock."""
|
|
59
|
+
return sum(1 for *_, fut in self._sleepers if not fut.done())
|
|
60
|
+
|
|
61
|
+
async def sleep(self, delay: float) -> None:
|
|
62
|
+
if delay <= 0:
|
|
63
|
+
await asyncio.sleep(0)
|
|
64
|
+
return
|
|
65
|
+
future: asyncio.Future[None] = asyncio.get_running_loop().create_future()
|
|
66
|
+
heapq.heappush(self._sleepers, (self._now + delay, next(self._counter), future))
|
|
67
|
+
await future
|
|
68
|
+
|
|
69
|
+
async def advance(self, seconds: float) -> None:
|
|
70
|
+
"""Move time forward by ``seconds``, running everything that falls due."""
|
|
71
|
+
if seconds < 0:
|
|
72
|
+
raise ValueError("cannot move a clock backwards")
|
|
73
|
+
target = self._now + seconds
|
|
74
|
+
await self._settle()
|
|
75
|
+
while self._sleepers and self._sleepers[0][0] <= target:
|
|
76
|
+
deadline, _, future = heapq.heappop(self._sleepers)
|
|
77
|
+
if future.done(): # sleeper was cancelled
|
|
78
|
+
continue
|
|
79
|
+
self._now = max(self._now, deadline)
|
|
80
|
+
future.set_result(None)
|
|
81
|
+
await self._settle()
|
|
82
|
+
self._now = target
|
|
83
|
+
await self._settle()
|
|
84
|
+
|
|
85
|
+
async def _settle(self) -> None:
|
|
86
|
+
for _ in range(self.settle_iterations):
|
|
87
|
+
await asyncio.sleep(0)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Protocol-independent access to a device's points by name."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Protocol
|
|
7
|
+
|
|
8
|
+
Value = bool | int | float
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PointNotFoundError(KeyError):
|
|
12
|
+
"""Raised when a point name does not exist on a device."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, name: str, available: Iterable[str] = ()):
|
|
15
|
+
self.name = name
|
|
16
|
+
self.available = sorted(available)
|
|
17
|
+
super().__init__(name)
|
|
18
|
+
|
|
19
|
+
def __str__(self) -> str:
|
|
20
|
+
hint = f"; available: {', '.join(self.available)}" if self.available else ""
|
|
21
|
+
return f"no point named {self.name!r}{hint}"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class PointStore(Protocol):
|
|
25
|
+
"""What generators, scenarios and tests need from a device's memory.
|
|
26
|
+
|
|
27
|
+
Each protocol keeps its own native representation (Modbus: registers and
|
|
28
|
+
bits, OPC UA: a node tree) and exposes it through this interface.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def get(self, name: str) -> Value: ...
|
|
32
|
+
|
|
33
|
+
def set(self, name: str, value: Value, *, clamp: bool = False) -> None: ...
|
|
34
|
+
|
|
35
|
+
def names(self) -> list[str]: ...
|