opcua-sim 0.1.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.
- opcua_sim/__init__.py +17 -0
- opcua_sim/__main__.py +3 -0
- opcua_sim/bench.py +424 -0
- opcua_sim/cli.py +390 -0
- opcua_sim/control.py +150 -0
- opcua_sim/device.py +313 -0
- opcua_sim/faults.py +157 -0
- opcua_sim/link.py +109 -0
- opcua_sim/models.py +450 -0
- opcua_sim/profile.py +363 -0
- opcua_sim/profiles/beckhoff-twincat3.yaml +68 -0
- opcua_sim/profiles/codesys-control.yaml +68 -0
- opcua_sim/profiles/drive.yaml +26 -0
- opcua_sim/profiles/energy-meter.yaml +24 -0
- opcua_sim/profiles/generic-sensor.yaml +16 -0
- opcua_sim/profiles/leuze-bcl300.yaml +48 -0
- opcua_sim/profiles/omron-nx.yaml +78 -0
- opcua_sim/profiles/phoenix-plcnext.yaml +65 -0
- opcua_sim/profiles/pressure-transmitter.yaml +33 -0
- opcua_sim/profiles/siemens-s7-1500.yaml +68 -0
- opcua_sim/profiles/temperature-controller.yaml +26 -0
- opcua_sim/pytest_plugin.py +93 -0
- opcua_sim/server.py +481 -0
- opcua_sim/signals.py +30 -0
- opcua_sim-0.1.0.dist-info/METADATA +326 -0
- opcua_sim-0.1.0.dist-info/RECORD +30 -0
- opcua_sim-0.1.0.dist-info/WHEEL +5 -0
- opcua_sim-0.1.0.dist-info/entry_points.txt +5 -0
- opcua_sim-0.1.0.dist-info/licenses/LICENSE +21 -0
- opcua_sim-0.1.0.dist-info/top_level.txt +1 -0
opcua_sim/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Simulated OPC UA devices with behaviour, NE 107 health, real alarms and fault injection."""
|
|
2
|
+
__version__ = "0.1.0"
|
|
3
|
+
|
|
4
|
+
from typing import Any # noqa: E402
|
|
5
|
+
|
|
6
|
+
from .profile import ProfileError, bundled_profiles, load_profile # noqa: E402
|
|
7
|
+
|
|
8
|
+
__all__ = ["Bench", "BenchError", "ProfileError", "__version__", "bundled_profiles",
|
|
9
|
+
"load_profile"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def __getattr__(name: str) -> Any:
|
|
13
|
+
# The server needs asyncua; devices, models and profiles also run without it (browser demo).
|
|
14
|
+
if name in ("Bench", "BenchError"):
|
|
15
|
+
from . import bench
|
|
16
|
+
return getattr(bench, name)
|
|
17
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
opcua_sim/__main__.py
ADDED
opcua_sim/bench.py
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""A bench: several devices behind one OPC UA server, running in a background thread."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import logging
|
|
6
|
+
import socket
|
|
7
|
+
import tempfile
|
|
8
|
+
import threading
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from .control import CONTROL_PORT, ControlServer
|
|
15
|
+
from .device import Device
|
|
16
|
+
from .faults import SERVER_STATES, FaultError, check_server, check_server_kind
|
|
17
|
+
from .link import Link
|
|
18
|
+
from .profile import Profile, ProfileError, bundled_profiles, load_profile, parse_profile
|
|
19
|
+
from .server import APPLICATION_URI, ROLES, SECURITY, UaServer
|
|
20
|
+
|
|
21
|
+
DEFAULT_PORT = 4840
|
|
22
|
+
SERVER_TARGET = "server"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class BenchError(ValueError):
|
|
26
|
+
"""A bench description is invalid or the bench cannot start."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class Bench:
|
|
30
|
+
def __init__(self, devices: list[Device], *, name: str = "opcua-sim",
|
|
31
|
+
security: list[str] | None = None, users: dict[str, dict[str, str]] | None = None,
|
|
32
|
+
anonymous: bool = True, tick: float = 0.1):
|
|
33
|
+
if not devices:
|
|
34
|
+
raise BenchError("a bench needs at least one device")
|
|
35
|
+
by_name = {device.name: device for device in devices}
|
|
36
|
+
if len(by_name) != len(devices):
|
|
37
|
+
raise BenchError("device names must be unique")
|
|
38
|
+
if SERVER_TARGET in by_name:
|
|
39
|
+
raise BenchError(f"{SERVER_TARGET!r} is reserved, call the device something else")
|
|
40
|
+
if len(devices) > 1 and any(d.profile.address_space for d in devices):
|
|
41
|
+
raise BenchError("a profile of a real product is a whole server: it must be the "
|
|
42
|
+
"only device of its bench")
|
|
43
|
+
security = list(security or ["none"])
|
|
44
|
+
for level in security:
|
|
45
|
+
if level not in SECURITY:
|
|
46
|
+
raise BenchError(f"unknown security level {level!r}, expected one of "
|
|
47
|
+
f"{', '.join(SECURITY)}")
|
|
48
|
+
users = {user: dict(entry) if isinstance(entry, dict) else entry
|
|
49
|
+
for user, entry in (users or {}).items()}
|
|
50
|
+
for user, entry in users.items():
|
|
51
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("password"), str):
|
|
52
|
+
raise BenchError(f"user {user}: needs a password")
|
|
53
|
+
if entry.setdefault("role", "operator") not in ROLES:
|
|
54
|
+
raise BenchError(f"user {user}: role must be one of {', '.join(ROLES)}")
|
|
55
|
+
if not anonymous and not users:
|
|
56
|
+
raise BenchError("anonymous: false needs at least one user")
|
|
57
|
+
if not 0 < tick <= 10:
|
|
58
|
+
raise BenchError("tick must be between 0 and 10 seconds")
|
|
59
|
+
self.devices = by_name
|
|
60
|
+
self.name = name
|
|
61
|
+
self.security = security
|
|
62
|
+
self.users = users
|
|
63
|
+
self.anonymous = anonymous
|
|
64
|
+
self.tick = tick
|
|
65
|
+
self.url: str | None = None
|
|
66
|
+
self.port: int | None = None
|
|
67
|
+
self.control_port: int | None = None
|
|
68
|
+
self.server_faults: dict[str, dict[str, Any]] = {}
|
|
69
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
70
|
+
self._thread: threading.Thread | None = None
|
|
71
|
+
self._stopping: asyncio.Event | None = None
|
|
72
|
+
self._ua: UaServer | None = None
|
|
73
|
+
self._link: Link | None = None
|
|
74
|
+
self._control: ControlServer | None = None
|
|
75
|
+
self._restart: asyncio.Task | None = None
|
|
76
|
+
self._certificate_dir: tempfile.TemporaryDirectory | None = None
|
|
77
|
+
self._certificate: tuple[Path, Path] | None = None
|
|
78
|
+
self._endpoint = ""
|
|
79
|
+
|
|
80
|
+
# -- construction ----------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
@classmethod
|
|
83
|
+
def single(cls, profile: str | Path | Profile, name: str | None = None,
|
|
84
|
+
params: dict[str, Any] | None = None, **options: Any) -> "Bench":
|
|
85
|
+
"""A bench with one device."""
|
|
86
|
+
loaded = profile if isinstance(profile, Profile) else load_profile(profile)
|
|
87
|
+
device_name = name or loaded.name.replace("-", "_").replace(".", "_")
|
|
88
|
+
try:
|
|
89
|
+
return cls([Device(device_name, loaded, params)], **options)
|
|
90
|
+
except BenchError:
|
|
91
|
+
raise
|
|
92
|
+
except ValueError as error:
|
|
93
|
+
raise BenchError(f"device {device_name}: {error}") from error
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def from_yaml(cls, path: str | Path) -> "Bench":
|
|
97
|
+
path = Path(path)
|
|
98
|
+
try:
|
|
99
|
+
data = yaml.safe_load(path.read_text("utf-8"))
|
|
100
|
+
except OSError as error:
|
|
101
|
+
raise BenchError(f"cannot read {path}: {error}") from error
|
|
102
|
+
except yaml.YAMLError as error:
|
|
103
|
+
raise BenchError(f"{path}: invalid YAML: {error}") from error
|
|
104
|
+
return cls.from_dict(data, base=path.parent)
|
|
105
|
+
|
|
106
|
+
@classmethod
|
|
107
|
+
def from_dict(cls, data: Any, base: Path | None = None) -> "Bench":
|
|
108
|
+
if not isinstance(data, dict):
|
|
109
|
+
raise BenchError("a bench must be a mapping")
|
|
110
|
+
extra = set(data) - {"name", "devices", "security", "users", "anonymous", "tick", "seed"}
|
|
111
|
+
if extra:
|
|
112
|
+
raise BenchError(f"unknown bench key(s): {', '.join(sorted(extra))}")
|
|
113
|
+
items = data.get("devices")
|
|
114
|
+
if not isinstance(items, list) or not items:
|
|
115
|
+
raise BenchError("a bench needs a non-empty 'devices' list")
|
|
116
|
+
seed = int(data.get("seed", 0))
|
|
117
|
+
devices = [_device(item, base, seed) for item in items]
|
|
118
|
+
return cls(devices, name=str(data.get("name", "opcua-sim")),
|
|
119
|
+
security=data.get("security"), users=data.get("users"),
|
|
120
|
+
anonymous=bool(data.get("anonymous", True)), tick=float(data.get("tick", 0.1)))
|
|
121
|
+
|
|
122
|
+
# -- life cycle ------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
def start(self, host: str = "127.0.0.1", port: int = DEFAULT_PORT,
|
|
125
|
+
control_port: int | None = CONTROL_PORT, endpoint_host: str | None = None) -> "Bench":
|
|
126
|
+
"""Start in a background thread and return once clients can connect.
|
|
127
|
+
|
|
128
|
+
``port=0`` and ``control_port=0`` pick free ports; ``control_port=None`` has no
|
|
129
|
+
control channel. ``endpoint_host`` is the host name in the endpoint URL, needed when
|
|
130
|
+
binding to all interfaces.
|
|
131
|
+
"""
|
|
132
|
+
if self._thread is not None:
|
|
133
|
+
raise BenchError("bench is already running")
|
|
134
|
+
started: dict[str, Any] = {}
|
|
135
|
+
ready = threading.Event()
|
|
136
|
+
|
|
137
|
+
def run() -> None:
|
|
138
|
+
asyncio.run(self._main(host, port, control_port, endpoint_host, ready, started))
|
|
139
|
+
|
|
140
|
+
self._thread = threading.Thread(target=run, name="opcua-sim", daemon=True)
|
|
141
|
+
self._thread.start()
|
|
142
|
+
ready.wait()
|
|
143
|
+
if "error" in started:
|
|
144
|
+
self._thread.join()
|
|
145
|
+
self._thread = None
|
|
146
|
+
raise started["error"]
|
|
147
|
+
return self
|
|
148
|
+
|
|
149
|
+
def stop(self) -> None:
|
|
150
|
+
if self._thread is None:
|
|
151
|
+
return
|
|
152
|
+
loop, stopping = self._loop, self._stopping
|
|
153
|
+
if loop is not None and stopping is not None and not loop.is_closed():
|
|
154
|
+
loop.call_soon_threadsafe(stopping.set)
|
|
155
|
+
self._thread.join()
|
|
156
|
+
self._thread = None
|
|
157
|
+
self._loop = None
|
|
158
|
+
|
|
159
|
+
def __enter__(self) -> "Bench":
|
|
160
|
+
if self._thread is None:
|
|
161
|
+
self.start(port=0, control_port=0)
|
|
162
|
+
return self
|
|
163
|
+
|
|
164
|
+
def __exit__(self, *_exc: Any) -> None:
|
|
165
|
+
self.stop()
|
|
166
|
+
|
|
167
|
+
async def _main(self, host: str, port: int, control_port: int | None,
|
|
168
|
+
endpoint_host: str | None, ready: threading.Event,
|
|
169
|
+
started: dict[str, Any]) -> None:
|
|
170
|
+
self._loop = asyncio.get_running_loop()
|
|
171
|
+
self._stopping = asyncio.Event()
|
|
172
|
+
all_interfaces = host in ("", "0.0.0.0")
|
|
173
|
+
try:
|
|
174
|
+
await self._prepare_certificate()
|
|
175
|
+
self._link = Link()
|
|
176
|
+
await self._link.start(None if all_interfaces else host, port)
|
|
177
|
+
self.port = self._link.port
|
|
178
|
+
shown = endpoint_host or (socket.gethostname() if all_interfaces else host)
|
|
179
|
+
self._endpoint = f"opc.tcp://{shown}:{self.port}"
|
|
180
|
+
self.url = self._endpoint
|
|
181
|
+
await self._start_server()
|
|
182
|
+
if control_port is not None:
|
|
183
|
+
self._control = ControlServer(self)
|
|
184
|
+
self.control_port = await self._control.start(
|
|
185
|
+
"0.0.0.0" if all_interfaces else "127.0.0.1", control_port)
|
|
186
|
+
except Exception as error: # reported to the caller of start()
|
|
187
|
+
started["error"] = error
|
|
188
|
+
await self._shutdown()
|
|
189
|
+
ready.set()
|
|
190
|
+
return
|
|
191
|
+
ready.set()
|
|
192
|
+
ticker = asyncio.ensure_future(self._ticker())
|
|
193
|
+
await self._stopping.wait()
|
|
194
|
+
ticker.cancel()
|
|
195
|
+
await asyncio.gather(ticker, return_exceptions=True)
|
|
196
|
+
await self._shutdown()
|
|
197
|
+
|
|
198
|
+
async def _prepare_certificate(self) -> None:
|
|
199
|
+
if self.security == ["none"] or self._certificate is not None:
|
|
200
|
+
return
|
|
201
|
+
from asyncua.crypto.cert_gen import setup_self_signed_certificate
|
|
202
|
+
from cryptography.x509.oid import ExtendedKeyUsageOID
|
|
203
|
+
|
|
204
|
+
self._certificate_dir = tempfile.TemporaryDirectory(prefix="opcua-sim-")
|
|
205
|
+
folder = Path(self._certificate_dir.name)
|
|
206
|
+
pair = (folder / "server-cert.der", folder / "server-key.pem")
|
|
207
|
+
await setup_self_signed_certificate(
|
|
208
|
+
pair[1], pair[0], APPLICATION_URI, socket.gethostname(),
|
|
209
|
+
[ExtendedKeyUsageOID.CLIENT_AUTH, ExtendedKeyUsageOID.SERVER_AUTH],
|
|
210
|
+
{"organizationName": "steitec", "commonName": "opcua-sim"})
|
|
211
|
+
self._certificate = pair
|
|
212
|
+
|
|
213
|
+
async def _start_server(self) -> None:
|
|
214
|
+
self._ua = UaServer(self.devices, self._endpoint, name=self.name, security=self.security,
|
|
215
|
+
users=self.users, anonymous=self.anonymous,
|
|
216
|
+
certificate=self._certificate)
|
|
217
|
+
inner = await self._ua.start()
|
|
218
|
+
self._link.target = ("127.0.0.1", inner)
|
|
219
|
+
await self._apply_status_faults()
|
|
220
|
+
|
|
221
|
+
async def _apply_status_faults(self) -> None:
|
|
222
|
+
state = self.server_faults.get("server_state")
|
|
223
|
+
if state:
|
|
224
|
+
await self._ua.set_server_state(SERVER_STATES[state["state"]])
|
|
225
|
+
level = self.server_faults.get("service_level")
|
|
226
|
+
if level:
|
|
227
|
+
await self._ua.set_service_level(level["value"])
|
|
228
|
+
|
|
229
|
+
async def _shutdown(self) -> None:
|
|
230
|
+
if self._restart is not None:
|
|
231
|
+
self._restart.cancel()
|
|
232
|
+
await asyncio.gather(self._restart, return_exceptions=True)
|
|
233
|
+
self._restart = None
|
|
234
|
+
if self._control is not None:
|
|
235
|
+
await self._control.stop()
|
|
236
|
+
self._control = None
|
|
237
|
+
if self._link is not None:
|
|
238
|
+
await self._link.stop()
|
|
239
|
+
self._link = None
|
|
240
|
+
if self._ua is not None:
|
|
241
|
+
await self._ua.stop()
|
|
242
|
+
self._ua = None
|
|
243
|
+
if self._certificate_dir is not None:
|
|
244
|
+
self._certificate_dir.cleanup()
|
|
245
|
+
self._certificate_dir = None
|
|
246
|
+
self._certificate = None
|
|
247
|
+
|
|
248
|
+
async def _ticker(self) -> None:
|
|
249
|
+
loop = asyncio.get_running_loop()
|
|
250
|
+
last = loop.time()
|
|
251
|
+
while True:
|
|
252
|
+
await asyncio.sleep(self.tick)
|
|
253
|
+
now = loop.time()
|
|
254
|
+
dt, last = now - last, now
|
|
255
|
+
try:
|
|
256
|
+
for device in self.devices.values():
|
|
257
|
+
device.step(dt)
|
|
258
|
+
if self._ua is not None and not self._link.down:
|
|
259
|
+
await self._ua.flush()
|
|
260
|
+
except Exception: # keep the bench alive, a test will notice the wrong value
|
|
261
|
+
logging.getLogger(__name__).exception("simulation step failed")
|
|
262
|
+
|
|
263
|
+
async def _do_restart(self, downtime: float) -> None:
|
|
264
|
+
link = self._link
|
|
265
|
+
link.down = True
|
|
266
|
+
link.drop_all()
|
|
267
|
+
old, self._ua = self._ua, None
|
|
268
|
+
await old.stop()
|
|
269
|
+
await asyncio.sleep(downtime)
|
|
270
|
+
await self._start_server()
|
|
271
|
+
link.down = False
|
|
272
|
+
|
|
273
|
+
# -- thread-safe API -------------------------------------------------------------------
|
|
274
|
+
|
|
275
|
+
def _run(self, coroutine: Any) -> Any:
|
|
276
|
+
if self._loop is None or self._thread is None:
|
|
277
|
+
coroutine.close()
|
|
278
|
+
raise BenchError("bench is not running")
|
|
279
|
+
if threading.current_thread() is self._thread:
|
|
280
|
+
coroutine.close()
|
|
281
|
+
raise BenchError("use the async_ methods on the bench thread")
|
|
282
|
+
return asyncio.run_coroutine_threadsafe(coroutine, self._loop).result(60)
|
|
283
|
+
|
|
284
|
+
def _device(self, name: str) -> Device:
|
|
285
|
+
try:
|
|
286
|
+
return self.devices[name]
|
|
287
|
+
except KeyError:
|
|
288
|
+
raise FaultError(f"no device {name!r}, the bench has "
|
|
289
|
+
f"{', '.join(self.devices)} and {SERVER_TARGET!r}") from None
|
|
290
|
+
|
|
291
|
+
def inject(self, target: str, kind: str, **params: Any) -> None:
|
|
292
|
+
"""Inject a fault into a device, or into ``"server"``. See :mod:`opcua_sim.faults`."""
|
|
293
|
+
self._run(self.async_inject(target, kind, params))
|
|
294
|
+
|
|
295
|
+
def clear(self, target: str, kind: str | None = None) -> None:
|
|
296
|
+
"""Clear one fault kind, or all of them, on a device or on ``"server"``."""
|
|
297
|
+
self._run(self.async_clear(target, kind))
|
|
298
|
+
|
|
299
|
+
def set(self, device: str, variable: str, value: Any) -> None:
|
|
300
|
+
"""Force a value from outside; ``None`` hands it back to the simulation."""
|
|
301
|
+
self._run(self.async_set(device, variable, value))
|
|
302
|
+
|
|
303
|
+
def get(self, device: str, variable: str) -> Any:
|
|
304
|
+
"""The simulated value, regardless of status codes or client access."""
|
|
305
|
+
return self._run(self.async_get(device, variable))
|
|
306
|
+
|
|
307
|
+
def status(self) -> dict[str, Any]:
|
|
308
|
+
return self._run(self.async_status())
|
|
309
|
+
|
|
310
|
+
def wait_restarted(self) -> None:
|
|
311
|
+
"""Block until a ``restart`` fault has finished."""
|
|
312
|
+
self._run(self.async_wait_restarted())
|
|
313
|
+
|
|
314
|
+
async def async_wait_restarted(self) -> None:
|
|
315
|
+
if self._restart is not None:
|
|
316
|
+
await asyncio.shield(self._restart)
|
|
317
|
+
|
|
318
|
+
async def async_inject(self, target: str, kind: str, params: dict[str, Any]) -> None:
|
|
319
|
+
if target != SERVER_TARGET:
|
|
320
|
+
device = self._device(target)
|
|
321
|
+
device.inject(kind, params)
|
|
322
|
+
await self._refresh(device)
|
|
323
|
+
return
|
|
324
|
+
full = check_server(kind, params)
|
|
325
|
+
link = self._link
|
|
326
|
+
if kind == "drop_connections":
|
|
327
|
+
link.drop_all()
|
|
328
|
+
return
|
|
329
|
+
if kind == "restart":
|
|
330
|
+
if self._restart is not None and not self._restart.done():
|
|
331
|
+
raise FaultError("a restart is already running")
|
|
332
|
+
self._restart = asyncio.ensure_future(self._do_restart(full["downtime"]))
|
|
333
|
+
return
|
|
334
|
+
if kind == "delay":
|
|
335
|
+
link.delay = full["seconds"]
|
|
336
|
+
elif kind == "blackhole":
|
|
337
|
+
link.blackhole = True
|
|
338
|
+
elif kind == "refuse":
|
|
339
|
+
link.refuse = True
|
|
340
|
+
self.server_faults[kind] = full
|
|
341
|
+
if self._ua is not None and kind in ("server_state", "service_level"):
|
|
342
|
+
await self._apply_status_faults()
|
|
343
|
+
|
|
344
|
+
async def async_clear(self, target: str, kind: str | None) -> None:
|
|
345
|
+
if target != SERVER_TARGET:
|
|
346
|
+
device = self._device(target)
|
|
347
|
+
device.clear(kind)
|
|
348
|
+
await self._refresh(device)
|
|
349
|
+
return
|
|
350
|
+
if kind is not None:
|
|
351
|
+
check_server_kind(kind)
|
|
352
|
+
link = self._link
|
|
353
|
+
for item in [kind] if kind else list(self.server_faults):
|
|
354
|
+
self.server_faults.pop(item, None)
|
|
355
|
+
if item == "delay":
|
|
356
|
+
link.delay = 0.0
|
|
357
|
+
elif item == "blackhole":
|
|
358
|
+
link.blackhole = False
|
|
359
|
+
elif item == "refuse":
|
|
360
|
+
link.refuse = False
|
|
361
|
+
elif item == "server_state" and self._ua is not None:
|
|
362
|
+
await self._ua.set_server_state(SERVER_STATES["Running"])
|
|
363
|
+
elif item == "service_level" and self._ua is not None:
|
|
364
|
+
await self._ua.set_service_level(255)
|
|
365
|
+
|
|
366
|
+
async def async_set(self, device: str, variable: str, value: Any) -> None:
|
|
367
|
+
target = self._device(device)
|
|
368
|
+
target.set(variable, value)
|
|
369
|
+
await self._refresh(target)
|
|
370
|
+
|
|
371
|
+
async def _refresh(self, device: Device) -> None:
|
|
372
|
+
if self._ua is not None and not self._link.down:
|
|
373
|
+
await self._ua.refresh(device)
|
|
374
|
+
else:
|
|
375
|
+
device.step(0.0)
|
|
376
|
+
|
|
377
|
+
async def async_get(self, device: str, variable: str | None = None) -> Any:
|
|
378
|
+
target = self._device(device)
|
|
379
|
+
if variable is None:
|
|
380
|
+
return {name: target.get(name) for name in target.variables}
|
|
381
|
+
return target.get(variable)
|
|
382
|
+
|
|
383
|
+
async def async_status(self) -> dict[str, Any]:
|
|
384
|
+
return {
|
|
385
|
+
"url": self.url,
|
|
386
|
+
"server": {"faults": self.server_faults, "connections": self._link.connections,
|
|
387
|
+
"restarting": self._link.down},
|
|
388
|
+
"devices": {
|
|
389
|
+
name: {
|
|
390
|
+
"profile": device.profile.name,
|
|
391
|
+
"health": device.health,
|
|
392
|
+
"faults": device.active_faults(),
|
|
393
|
+
"fault_kinds": device.fault_kinds(),
|
|
394
|
+
"alarms": {alarm.spec.name: {"active": alarm.active, "acked": alarm.acked}
|
|
395
|
+
for alarm in device.alarms.values()},
|
|
396
|
+
"requests": device.requests,
|
|
397
|
+
} for name, device in self.devices.items()},
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _device(item: Any, base: Path | None, seed: int) -> Device:
|
|
402
|
+
if not isinstance(item, dict):
|
|
403
|
+
raise BenchError("every device must be a mapping")
|
|
404
|
+
extra = set(item) - {"name", "profile", "params", "serial_number", "inline"}
|
|
405
|
+
if extra:
|
|
406
|
+
raise BenchError(f"unknown device key(s): {', '.join(sorted(extra))}")
|
|
407
|
+
name = item.get("name")
|
|
408
|
+
if not isinstance(name, str) or not name or "." in name or "/" in name or " " in name:
|
|
409
|
+
raise BenchError("a device needs a 'name' without '.', '/' or spaces")
|
|
410
|
+
try:
|
|
411
|
+
if "inline" in item:
|
|
412
|
+
profile = parse_profile(item["inline"], f"device {name}")
|
|
413
|
+
else:
|
|
414
|
+
source = item.get("profile")
|
|
415
|
+
if not isinstance(source, str):
|
|
416
|
+
raise BenchError(f"device {name}: needs 'profile' or 'inline'")
|
|
417
|
+
if source not in bundled_profiles() and base is not None:
|
|
418
|
+
source = str(base / source)
|
|
419
|
+
profile = load_profile(source)
|
|
420
|
+
return Device(name, profile, item.get("params"), item.get("serial_number"), seed)
|
|
421
|
+
except (BenchError, ProfileError):
|
|
422
|
+
raise
|
|
423
|
+
except ValueError as error:
|
|
424
|
+
raise BenchError(f"device {name}: {error}") from error
|