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/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""fieldsim - virtual industrial devices for testing software without hardware."""
|
|
2
|
+
|
|
3
|
+
from fieldsim.background import BackgroundSimulator, DeviceHandle, FaultHandle
|
|
4
|
+
from fieldsim.config import ConfigError, SimulationConfig, load_config
|
|
5
|
+
from fieldsim.core.clock import RealClock, VirtualClock
|
|
6
|
+
from fieldsim.core.device import Device
|
|
7
|
+
from fieldsim.core.faults import ActiveFault
|
|
8
|
+
from fieldsim.simulator import Simulator, create_device
|
|
9
|
+
|
|
10
|
+
__version__ = "0.2.0"
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"ActiveFault",
|
|
14
|
+
"BackgroundSimulator",
|
|
15
|
+
"ConfigError",
|
|
16
|
+
"Device",
|
|
17
|
+
"DeviceHandle",
|
|
18
|
+
"FaultHandle",
|
|
19
|
+
"RealClock",
|
|
20
|
+
"SimulationConfig",
|
|
21
|
+
"Simulator",
|
|
22
|
+
"VirtualClock",
|
|
23
|
+
"__version__",
|
|
24
|
+
"create_device",
|
|
25
|
+
"load_config",
|
|
26
|
+
]
|
fieldsim/__main__.py
ADDED
fieldsim/background.py
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Run a simulation in a background thread and control it from synchronous code.
|
|
2
|
+
|
|
3
|
+
The devices live on their own event loop in a daemon thread, so they work the
|
|
4
|
+
same whether the code under test is synchronous, asyncio, trio or a separate
|
|
5
|
+
process. Every method of :class:`DeviceHandle` is thread-safe.
|
|
6
|
+
|
|
7
|
+
::
|
|
8
|
+
|
|
9
|
+
with BackgroundSimulator("plant.yaml") as sim:
|
|
10
|
+
plc = sim["boiler-plc"]
|
|
11
|
+
plc.set("temperature", 85.0)
|
|
12
|
+
with plc.fault("latency", delay="300ms"):
|
|
13
|
+
run_my_gateway(plc.host, plc.port)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
import inspect
|
|
20
|
+
import threading
|
|
21
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
22
|
+
from contextlib import contextmanager
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from types import TracebackType
|
|
25
|
+
from typing import Any, Literal, TypeVar
|
|
26
|
+
|
|
27
|
+
from fieldsim.config import SimulationConfig, load_config, parse_config
|
|
28
|
+
from fieldsim.core.clock import Clock, RealClock, VirtualClock
|
|
29
|
+
from fieldsim.core.datastore import Value
|
|
30
|
+
from fieldsim.core.device import Device
|
|
31
|
+
from fieldsim.core.faults import ActiveFault
|
|
32
|
+
from fieldsim.protocols.modbus.datastore import WriteEvent
|
|
33
|
+
from fieldsim.protocols.modbus.device import ModbusDevice
|
|
34
|
+
from fieldsim.simulator import Simulator
|
|
35
|
+
|
|
36
|
+
T = TypeVar("T")
|
|
37
|
+
|
|
38
|
+
ConfigSource = SimulationConfig | Mapping[str, Any] | str | Path
|
|
39
|
+
ClockKind = Literal["real", "virtual"]
|
|
40
|
+
|
|
41
|
+
CALL_TIMEOUT = 10.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def resolve_config(source: ConfigSource) -> SimulationConfig:
|
|
45
|
+
"""Accept a config object, a dict or a path to a YAML file."""
|
|
46
|
+
if isinstance(source, SimulationConfig):
|
|
47
|
+
return source
|
|
48
|
+
if isinstance(source, str | Path):
|
|
49
|
+
return load_config(source)
|
|
50
|
+
return parse_config(dict(source))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def with_free_ports(config: SimulationConfig) -> SimulationConfig:
|
|
54
|
+
"""Copy of ``config`` where every device listens on an OS-assigned port.
|
|
55
|
+
|
|
56
|
+
Lets tests run in parallel (pytest-xdist) without port clashes.
|
|
57
|
+
"""
|
|
58
|
+
devices = [device.model_copy(update={"port": 0}) for device in config.devices]
|
|
59
|
+
return config.model_copy(update={"devices": devices})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class BackgroundSimulator:
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
config: ConfigSource,
|
|
66
|
+
*,
|
|
67
|
+
clock: ClockKind = "real",
|
|
68
|
+
free_ports: bool = False,
|
|
69
|
+
):
|
|
70
|
+
if clock not in ("real", "virtual"):
|
|
71
|
+
raise ValueError(f"clock must be 'real' or 'virtual', not {clock!r}")
|
|
72
|
+
resolved = resolve_config(config)
|
|
73
|
+
self.config = with_free_ports(resolved) if free_ports else resolved
|
|
74
|
+
self.clock_kind: ClockKind = clock
|
|
75
|
+
self._clock: Clock = VirtualClock() if clock == "virtual" else RealClock()
|
|
76
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
77
|
+
self._thread: threading.Thread | None = None
|
|
78
|
+
self._stop_event: asyncio.Event | None = None
|
|
79
|
+
self._error: BaseException | None = None
|
|
80
|
+
self._handles: dict[str, DeviceHandle] = {}
|
|
81
|
+
|
|
82
|
+
# ------------------------------------------------------------------ lifecycle
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def running(self) -> bool:
|
|
86
|
+
return self._thread is not None and self._thread.is_alive()
|
|
87
|
+
|
|
88
|
+
def start(self, timeout: float = CALL_TIMEOUT) -> None:
|
|
89
|
+
if self._thread is not None:
|
|
90
|
+
raise RuntimeError("simulator already started")
|
|
91
|
+
ready = threading.Event()
|
|
92
|
+
self._error = None
|
|
93
|
+
self._thread = threading.Thread(
|
|
94
|
+
target=self._thread_main, args=(ready,), name="fieldsim", daemon=True
|
|
95
|
+
)
|
|
96
|
+
self._thread.start()
|
|
97
|
+
if not ready.wait(timeout): # pragma: no cover - only on a wedged machine
|
|
98
|
+
raise TimeoutError("simulator did not start in time")
|
|
99
|
+
if self._error is not None:
|
|
100
|
+
self._thread.join()
|
|
101
|
+
self._thread = None
|
|
102
|
+
raise self._error
|
|
103
|
+
|
|
104
|
+
def stop(self, timeout: float = CALL_TIMEOUT) -> None:
|
|
105
|
+
thread, self._thread = self._thread, None
|
|
106
|
+
if thread is None:
|
|
107
|
+
return
|
|
108
|
+
if self._loop is not None and self._stop_event is not None:
|
|
109
|
+
self._loop.call_soon_threadsafe(self._stop_event.set)
|
|
110
|
+
thread.join(timeout)
|
|
111
|
+
self._handles = {}
|
|
112
|
+
|
|
113
|
+
def __enter__(self) -> BackgroundSimulator:
|
|
114
|
+
self.start()
|
|
115
|
+
return self
|
|
116
|
+
|
|
117
|
+
def __exit__(
|
|
118
|
+
self,
|
|
119
|
+
exc_type: type[BaseException] | None,
|
|
120
|
+
exc: BaseException | None,
|
|
121
|
+
tb: TracebackType | None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self.stop()
|
|
124
|
+
|
|
125
|
+
def _thread_main(self, ready: threading.Event) -> None:
|
|
126
|
+
loop = asyncio.new_event_loop()
|
|
127
|
+
self._loop = loop
|
|
128
|
+
try:
|
|
129
|
+
loop.run_until_complete(self._serve(ready))
|
|
130
|
+
finally:
|
|
131
|
+
loop.close()
|
|
132
|
+
|
|
133
|
+
async def _serve(self, ready: threading.Event) -> None:
|
|
134
|
+
self._stop_event = asyncio.Event()
|
|
135
|
+
try:
|
|
136
|
+
sim = Simulator(self.config, clock=self._clock)
|
|
137
|
+
await sim.start()
|
|
138
|
+
except BaseException as exc:
|
|
139
|
+
self._error = exc
|
|
140
|
+
ready.set()
|
|
141
|
+
return
|
|
142
|
+
self._handles = {name: DeviceHandle(self, dev) for name, dev in sim.devices.items()}
|
|
143
|
+
ready.set()
|
|
144
|
+
try:
|
|
145
|
+
await self._stop_event.wait()
|
|
146
|
+
finally:
|
|
147
|
+
await sim.stop()
|
|
148
|
+
|
|
149
|
+
# ------------------------------------------------------------------ access
|
|
150
|
+
|
|
151
|
+
@property
|
|
152
|
+
def devices(self) -> dict[str, DeviceHandle]:
|
|
153
|
+
return dict(self._handles)
|
|
154
|
+
|
|
155
|
+
def __getitem__(self, name: str) -> DeviceHandle:
|
|
156
|
+
try:
|
|
157
|
+
return self._handles[name]
|
|
158
|
+
except KeyError:
|
|
159
|
+
available = ", ".join(self._handles) or "none (simulator not running)"
|
|
160
|
+
raise KeyError(f"no device named {name!r}; available: {available}") from None
|
|
161
|
+
|
|
162
|
+
def __iter__(self) -> Iterator[DeviceHandle]:
|
|
163
|
+
return iter(list(self._handles.values()))
|
|
164
|
+
|
|
165
|
+
def call(
|
|
166
|
+
self, fn: Callable[..., Any], *args: Any, timeout: float = CALL_TIMEOUT, **kwargs: Any
|
|
167
|
+
) -> Any:
|
|
168
|
+
"""Run ``fn`` on the simulator's event loop and return its result.
|
|
169
|
+
|
|
170
|
+
Coroutine results are awaited. Exceptions are re-raised in the caller.
|
|
171
|
+
"""
|
|
172
|
+
loop = self._loop
|
|
173
|
+
if loop is None or not self.running:
|
|
174
|
+
raise RuntimeError("simulator is not running")
|
|
175
|
+
if threading.current_thread() is self._thread:
|
|
176
|
+
raise RuntimeError("call() would deadlock: already on the simulator thread")
|
|
177
|
+
|
|
178
|
+
async def runner() -> Any:
|
|
179
|
+
result = fn(*args, **kwargs)
|
|
180
|
+
if inspect.isawaitable(result):
|
|
181
|
+
result = await result
|
|
182
|
+
return result
|
|
183
|
+
|
|
184
|
+
return asyncio.run_coroutine_threadsafe(runner(), loop).result(timeout)
|
|
185
|
+
|
|
186
|
+
# ------------------------------------------------------------------ time
|
|
187
|
+
|
|
188
|
+
def time(self) -> float:
|
|
189
|
+
"""Current simulation time (seconds; virtual time starts at 0)."""
|
|
190
|
+
return float(self.call(self._clock.time))
|
|
191
|
+
|
|
192
|
+
def advance(self, seconds: float) -> None:
|
|
193
|
+
"""Move the virtual clock forward, running everything that falls due."""
|
|
194
|
+
if not isinstance(self._clock, VirtualClock):
|
|
195
|
+
raise RuntimeError("advance() needs a virtual clock: use clock='virtual'")
|
|
196
|
+
self.call(self._clock.advance, seconds, timeout=CALL_TIMEOUT + seconds / 100)
|
|
197
|
+
|
|
198
|
+
async def advance_async(self, seconds: float) -> None:
|
|
199
|
+
""":meth:`advance` for async tests: does not block the caller's event loop."""
|
|
200
|
+
await asyncio.to_thread(self.advance, seconds)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class FaultHandle:
|
|
204
|
+
"""Thread-safe view of an :class:`~fieldsim.core.faults.ActiveFault`."""
|
|
205
|
+
|
|
206
|
+
def __init__(self, sim: BackgroundSimulator, fault: ActiveFault):
|
|
207
|
+
self._sim = sim
|
|
208
|
+
self._fault = fault
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def kind(self) -> str:
|
|
212
|
+
"""Fault type, e.g. ``"latency"``."""
|
|
213
|
+
return self._fault.kind
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def hits(self) -> int:
|
|
217
|
+
"""How many requests this fault affected so far."""
|
|
218
|
+
return int(self._sim.call(lambda: self._fault.hits))
|
|
219
|
+
|
|
220
|
+
@property
|
|
221
|
+
def active(self) -> bool:
|
|
222
|
+
return bool(self._sim.call(lambda: self._fault.active))
|
|
223
|
+
|
|
224
|
+
def remove(self) -> None:
|
|
225
|
+
if self._sim.running:
|
|
226
|
+
self._sim.call(self._fault.remove)
|
|
227
|
+
|
|
228
|
+
def __enter__(self) -> FaultHandle:
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def __exit__(
|
|
232
|
+
self,
|
|
233
|
+
exc_type: type[BaseException] | None,
|
|
234
|
+
exc: BaseException | None,
|
|
235
|
+
tb: TracebackType | None,
|
|
236
|
+
) -> None:
|
|
237
|
+
self.remove()
|
|
238
|
+
|
|
239
|
+
def __repr__(self) -> str:
|
|
240
|
+
return f"<FaultHandle {self.kind}>"
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class DeviceHandle:
|
|
244
|
+
"""Thread-safe remote control for one device of a :class:`BackgroundSimulator`."""
|
|
245
|
+
|
|
246
|
+
def __init__(self, sim: BackgroundSimulator, device: Device[Any]):
|
|
247
|
+
self.simulator = sim
|
|
248
|
+
#: The underlying device. Only touch it from the simulator thread.
|
|
249
|
+
self.device = device
|
|
250
|
+
self.name = device.name
|
|
251
|
+
self.protocol = device.protocol
|
|
252
|
+
|
|
253
|
+
# Connection details are fixed once the device is running, so plain reads are safe.
|
|
254
|
+
@property
|
|
255
|
+
def host(self) -> str:
|
|
256
|
+
return self.device.host
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def port(self) -> int:
|
|
260
|
+
return self.device.port
|
|
261
|
+
|
|
262
|
+
@property
|
|
263
|
+
def address(self) -> tuple[str, int]:
|
|
264
|
+
return self.device.address
|
|
265
|
+
|
|
266
|
+
@property
|
|
267
|
+
def unit_id(self) -> int:
|
|
268
|
+
return int(getattr(self.device, "unit_id", 1))
|
|
269
|
+
|
|
270
|
+
def _call(self, fn: Callable[..., T], *args: Any, **kwargs: Any) -> T:
|
|
271
|
+
return self.simulator.call(fn, *args, **kwargs) # type: ignore[no-any-return]
|
|
272
|
+
|
|
273
|
+
# ------------------------------------------------------------------ values
|
|
274
|
+
|
|
275
|
+
def get(self, name: str) -> Value:
|
|
276
|
+
return self._call(self.device.get, name)
|
|
277
|
+
|
|
278
|
+
def set(self, name: str, value: Value) -> None:
|
|
279
|
+
self._call(self.device.set, name, value)
|
|
280
|
+
|
|
281
|
+
def release(self, name: str) -> None:
|
|
282
|
+
self._call(self.device.release, name)
|
|
283
|
+
|
|
284
|
+
# ------------------------------------------------------------------ faults
|
|
285
|
+
|
|
286
|
+
def fault(self, fault_type: str, **params: Any) -> FaultHandle:
|
|
287
|
+
"""Activate a fault; use the result as a context manager to scope it."""
|
|
288
|
+
active = self._call(self.device.fault, fault_type, **params)
|
|
289
|
+
return FaultHandle(self.simulator, active)
|
|
290
|
+
|
|
291
|
+
def clear_faults(self) -> None:
|
|
292
|
+
self._call(self.device.clear_faults)
|
|
293
|
+
|
|
294
|
+
# ------------------------------------------------------------------ outages
|
|
295
|
+
|
|
296
|
+
@property
|
|
297
|
+
def online(self) -> bool:
|
|
298
|
+
return bool(self._call(lambda: self.device.online))
|
|
299
|
+
|
|
300
|
+
def go_offline(self) -> None:
|
|
301
|
+
self.simulator.call(self.device.go_offline)
|
|
302
|
+
|
|
303
|
+
def go_online(self) -> None:
|
|
304
|
+
self.simulator.call(self.device.go_online)
|
|
305
|
+
|
|
306
|
+
@contextmanager
|
|
307
|
+
def offline(self) -> Iterator[None]:
|
|
308
|
+
"""``with device.offline(): ...`` - outage for the duration of the block."""
|
|
309
|
+
self.go_offline()
|
|
310
|
+
try:
|
|
311
|
+
yield
|
|
312
|
+
finally:
|
|
313
|
+
if self.simulator.running:
|
|
314
|
+
self.go_online()
|
|
315
|
+
|
|
316
|
+
# ------------------------------------------------------------------ writes
|
|
317
|
+
|
|
318
|
+
def _modbus(self) -> ModbusDevice:
|
|
319
|
+
if not isinstance(self.device, ModbusDevice): # pragma: no cover - only Modbus so far
|
|
320
|
+
raise TypeError(f"{self.protocol} devices do not record writes")
|
|
321
|
+
return self.device
|
|
322
|
+
|
|
323
|
+
@property
|
|
324
|
+
def writes(self) -> list[WriteEvent]:
|
|
325
|
+
"""Snapshot of every client write recorded so far."""
|
|
326
|
+
device = self._modbus()
|
|
327
|
+
return self._call(lambda: list(device.writes))
|
|
328
|
+
|
|
329
|
+
def clear_writes(self) -> None:
|
|
330
|
+
self._call(self._modbus().clear_writes)
|
|
331
|
+
|
|
332
|
+
def wait_for_write(self, point: str | None = None, timeout: float = 5.0) -> WriteEvent:
|
|
333
|
+
"""Block until the next client write (touching ``point``).
|
|
334
|
+
|
|
335
|
+
See :meth:`fieldsim.protocols.modbus.ModbusDevice.wait_for_write`.
|
|
336
|
+
"""
|
|
337
|
+
result: WriteEvent = self.simulator.call(
|
|
338
|
+
self._modbus().wait_for_write, point, timeout, timeout=timeout + 5
|
|
339
|
+
)
|
|
340
|
+
return result
|
|
341
|
+
|
|
342
|
+
async def wait_for_write_async(
|
|
343
|
+
self, point: str | None = None, timeout: float = 5.0
|
|
344
|
+
) -> WriteEvent:
|
|
345
|
+
""":meth:`wait_for_write` for async tests: does not block the caller's event loop."""
|
|
346
|
+
return await asyncio.to_thread(self.wait_for_write, point, timeout)
|
|
347
|
+
|
|
348
|
+
# ------------------------------------------------------------------ scenario
|
|
349
|
+
|
|
350
|
+
def advance(self, seconds: float) -> None:
|
|
351
|
+
"""Shortcut for ``simulator.advance`` (the clock is shared by all devices)."""
|
|
352
|
+
self.simulator.advance(seconds)
|
|
353
|
+
|
|
354
|
+
async def advance_async(self, seconds: float) -> None:
|
|
355
|
+
await self.simulator.advance_async(seconds)
|
|
356
|
+
|
|
357
|
+
@property
|
|
358
|
+
def scenario_done(self) -> bool:
|
|
359
|
+
return bool(self._call(lambda: self.device.scenario.done))
|
|
360
|
+
|
|
361
|
+
def __repr__(self) -> str:
|
|
362
|
+
return f"<DeviceHandle {self.name!r} {self.protocol} {self.host}:{self.port}>"
|
fieldsim/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Command line interface: ``fieldsim run | validate | schema``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated, Optional
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
|
|
13
|
+
from fieldsim import __version__
|
|
14
|
+
from fieldsim.config import ConfigError, SimulationConfig, load_config
|
|
15
|
+
from fieldsim.simulator import Simulator
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(
|
|
18
|
+
name="fieldsim",
|
|
19
|
+
help="Virtual industrial devices for testing software without hardware.",
|
|
20
|
+
no_args_is_help=True,
|
|
21
|
+
add_completion=False,
|
|
22
|
+
pretty_exceptions_enable=False,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
FileArg = Annotated[
|
|
26
|
+
Path, typer.Argument(exists=False, dir_okay=False, help="Simulation YAML file.")
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _version(value: bool) -> None:
|
|
31
|
+
if value:
|
|
32
|
+
typer.echo(f"fieldsim {__version__}")
|
|
33
|
+
raise typer.Exit()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@app.callback()
|
|
37
|
+
def main(
|
|
38
|
+
version: Annotated[
|
|
39
|
+
Optional[bool], # noqa: UP045 - typer needs Optional on 3.10
|
|
40
|
+
typer.Option("--version", callback=_version, is_eager=True, help="Show version."),
|
|
41
|
+
] = None,
|
|
42
|
+
) -> None:
|
|
43
|
+
"""fieldsim command line."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _load(path: Path) -> SimulationConfig:
|
|
47
|
+
try:
|
|
48
|
+
return load_config(path)
|
|
49
|
+
except ConfigError as exc:
|
|
50
|
+
typer.echo(f"ERROR {exc}", err=True)
|
|
51
|
+
raise typer.Exit(code=1) from None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _describe(config: SimulationConfig) -> str:
|
|
55
|
+
devices = len(config.devices)
|
|
56
|
+
points = sum(len(list(d.registers.all_points())) for d in config.devices)
|
|
57
|
+
return f"{devices} device{'s' * (devices != 1)}, {points} point{'s' * (points != 1)}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@app.command()
|
|
61
|
+
def validate(
|
|
62
|
+
files: Annotated[list[Path], typer.Argument(help="One or more simulation files.")],
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Check simulation files without starting anything."""
|
|
65
|
+
failed = False
|
|
66
|
+
for path in files:
|
|
67
|
+
try:
|
|
68
|
+
config = load_config(path)
|
|
69
|
+
except ConfigError as exc:
|
|
70
|
+
typer.echo(f"ERROR {exc}", err=True)
|
|
71
|
+
failed = True
|
|
72
|
+
else:
|
|
73
|
+
typer.echo(f"OK {path}: {_describe(config)}")
|
|
74
|
+
if failed:
|
|
75
|
+
raise typer.Exit(code=1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@app.command()
|
|
79
|
+
def schema(
|
|
80
|
+
output: Annotated[
|
|
81
|
+
Optional[Path], # noqa: UP045
|
|
82
|
+
typer.Option("--output", "-o", help="Write to a file instead of stdout."),
|
|
83
|
+
] = None,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Print the JSON Schema of the simulation file format (for editor autocompletion)."""
|
|
86
|
+
document = SimulationConfig.model_json_schema()
|
|
87
|
+
document["$schema"] = "https://json-schema.org/draft/2020-12/schema"
|
|
88
|
+
document["title"] = "fieldsim simulation file"
|
|
89
|
+
text = json.dumps(document, indent=2)
|
|
90
|
+
if output is None:
|
|
91
|
+
typer.echo(text)
|
|
92
|
+
else:
|
|
93
|
+
output.write_text(text + "\n", encoding="utf-8")
|
|
94
|
+
typer.echo(f"wrote {output}")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@app.command()
|
|
98
|
+
def run(
|
|
99
|
+
file: FileArg,
|
|
100
|
+
duration: Annotated[
|
|
101
|
+
Optional[float], # noqa: UP045
|
|
102
|
+
typer.Option(help="Stop after this many seconds (default: run until Ctrl+C)."),
|
|
103
|
+
] = None,
|
|
104
|
+
log_level: Annotated[
|
|
105
|
+
str, typer.Option("--log-level", "-l", help="debug, info, warning or error.")
|
|
106
|
+
] = "info",
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Start every device in FILE and serve until interrupted."""
|
|
109
|
+
level = getattr(logging, log_level.upper(), None)
|
|
110
|
+
if not isinstance(level, int):
|
|
111
|
+
raise typer.BadParameter(f"unknown log level {log_level!r}", param_hint="--log-level")
|
|
112
|
+
logging.basicConfig(level=level, format="%(asctime)s %(levelname)-7s %(name)s: %(message)s")
|
|
113
|
+
|
|
114
|
+
config = _load(file)
|
|
115
|
+
try:
|
|
116
|
+
asyncio.run(_serve(Simulator(config), duration))
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
pass
|
|
119
|
+
except OSError as exc:
|
|
120
|
+
typer.echo(f"ERROR cannot start devices: {exc}", err=True)
|
|
121
|
+
raise typer.Exit(code=1) from None
|
|
122
|
+
typer.echo("stopped")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
async def _serve(sim: Simulator, duration: float | None) -> None:
|
|
126
|
+
async with sim:
|
|
127
|
+
for device in sim:
|
|
128
|
+
generated = len(device.generated_points)
|
|
129
|
+
typer.echo(
|
|
130
|
+
f"{device.name:<20} {device.protocol:<11} {device.host}:{device.port}"
|
|
131
|
+
f" ({len(device.store.names())} points, {generated} generated)"
|
|
132
|
+
)
|
|
133
|
+
typer.echo("serving - press Ctrl+C to stop" if duration is None else f"serving {duration}s")
|
|
134
|
+
if duration is None:
|
|
135
|
+
await asyncio.Event().wait()
|
|
136
|
+
else:
|
|
137
|
+
await asyncio.sleep(duration)
|