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 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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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