scpi-sim 0.3.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.
scpi_sim/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """Simulated SCPI lab instruments on a raw TCP socket."""
2
+
3
+ __version__ = "0.3.0"
4
+
5
+ from .instrument import Instrument, scpi # noqa: E402
6
+ from .instruments import Multimeter, Multiplexer, PowerSupply # noqa: E402
7
+ from .loads import Parallel, Resistor # noqa: E402
8
+ from .transport import InstrumentServer # noqa: E402
9
+ from .bench import Bench, BenchError # noqa: E402
10
+ from .device import Console, Device # noqa: E402
11
+
12
+ __all__ = [
13
+ "Bench", "BenchError", "Console", "Device", "Instrument", "InstrumentServer", "Multimeter", "Multiplexer",
14
+ "Parallel", "PowerSupply", "Resistor", "scpi", "__version__",
15
+ ]
scpi_sim/bench.py ADDED
@@ -0,0 +1,534 @@
1
+ """A simulated test bench: instruments wired together, described in YAML.
2
+
3
+ Wiring lines join terminals (``psu.ch1``, ``dmm.input``, ``mux.101``,
4
+ ``mux.common`` or a node name) into nets. A closed multiplexer relay joins its
5
+ channel terminal to the common terminal. Loads sit on nodes. Every reading is
6
+ evaluated against the nets as they are at that moment.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import importlib
12
+ import math
13
+ import re
14
+ import sys
15
+ import threading
16
+ from collections.abc import Iterable
17
+ from pathlib import Path
18
+
19
+ import yaml
20
+
21
+ from .console import ConsoleServer
22
+ from .control import ControlServer
23
+ from .device import Console, Device
24
+ from .errors import ScpiError
25
+ from .instrument import Instrument
26
+ from .instruments import Multimeter, Multiplexer, PowerSupply
27
+ from .loads import Load, Parallel, Resistor
28
+ from .transport import InstrumentServer
29
+
30
+ def _whole(low: int, high: int | None = None):
31
+ def convert(value):
32
+ if isinstance(value, bool) or not isinstance(value, int) or value < low or (high is not None and value > high):
33
+ raise ValueError(f"must be a whole number {low}-{high}" if high else f"must be a whole number >= {low}")
34
+ return value
35
+ return convert
36
+
37
+
38
+ def _real(low: float | None = None, above: float | None = None):
39
+ def convert(value):
40
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) \
41
+ or (low is not None and value < low) or (above is not None and value <= above):
42
+ bound = f" >= {low}" if low is not None else f" > {above}" if above is not None else ""
43
+ raise ValueError(f"must be a number{bound}")
44
+ return float(value)
45
+ return convert
46
+
47
+
48
+ # type -> (class, {option: converter})
49
+ _TYPES = {
50
+ "psu": (PowerSupply, {"channels": _whole(1), "max_volts": _real(above=0), "max_amps": _real(above=0)}),
51
+ "dmm": (Multimeter, {"volts": _real(), "amps": _real(), "ohms": _real(low=0), "noise": _real(low=0),
52
+ "seed": _whole(0)}),
53
+ "mux": (Multiplexer, {"channels": _whole(1, 99)}),
54
+ }
55
+ # names an instrument cannot have: they are attributes of Bench
56
+ _RESERVED = {"instruments", "devices", "ports", "control_port", "host", "start", "stop", "port", "resource",
57
+ "console_port", "console_url"}
58
+ _NAME = re.compile(r"[a-z][a-z0-9_]*")
59
+ _NODE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*")
60
+ _WIRE = re.compile(r"\s*(\S+)\s*->\s*(\S+)\s*")
61
+
62
+
63
+ class BenchError(ValueError):
64
+ """A problem in the bench description."""
65
+
66
+
67
+ class _Loader(yaml.SafeLoader):
68
+ """SafeLoader that refuses duplicate keys instead of keeping the last one."""
69
+
70
+
71
+ def _unique_mapping(loader: _Loader, node: yaml.MappingNode, deep: bool = False) -> dict:
72
+ seen = set()
73
+ for key_node, _ in node.value:
74
+ key = loader.construct_object(key_node, deep=deep)
75
+ if key in seen:
76
+ raise BenchError(f"duplicate key {key!r} in line {key_node.start_mark.line + 1}")
77
+ seen.add(key)
78
+ return yaml.SafeLoader.construct_mapping(loader, node, deep)
79
+
80
+
81
+ _Loader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _unique_mapping)
82
+
83
+
84
+ class _Nets:
85
+ """Union-find over terminal names."""
86
+
87
+ def __init__(self, parent: dict[str, str] | None = None):
88
+ self.parent = dict(parent or {})
89
+
90
+ def find(self, terminal: str) -> str:
91
+ self.parent.setdefault(terminal, terminal)
92
+ while self.parent[terminal] != terminal:
93
+ self.parent[terminal] = self.parent[self.parent[terminal]]
94
+ terminal = self.parent[terminal]
95
+ return terminal
96
+
97
+ def join(self, a: str, b: str) -> None:
98
+ self.parent[self.find(a)] = self.find(b)
99
+
100
+
101
+ class _NetLoad:
102
+ """The load a supply output sees: everything on its net, resolved per call."""
103
+
104
+ def __init__(self, bench: Bench, terminal: str):
105
+ self.bench, self.terminal = bench, terminal
106
+
107
+ def _parallel(self) -> Parallel:
108
+ return Parallel(tuple(self.bench._loads_on(self.terminal)))
109
+
110
+ def current(self, volts: float) -> float:
111
+ return self._parallel().current(volts)
112
+
113
+ def voltage(self, amps: float) -> float | None:
114
+ return self._parallel().voltage(amps)
115
+
116
+ def snapshot(self) -> Parallel:
117
+ """The loads on the net right now, fixed for one operating-point calculation."""
118
+ return self._parallel()
119
+
120
+
121
+ class _DeviceInput:
122
+ """A device input terminal seen as a load on its net."""
123
+
124
+ def __init__(self, device: Device, terminal: str):
125
+ self.device, self.terminal = device, terminal
126
+
127
+ def current(self, volts: float) -> float:
128
+ return self.device.current(self.terminal, volts)
129
+
130
+
131
+ class _DmmInput:
132
+ """What the multimeter input is connected to."""
133
+
134
+ def __init__(self, bench: Bench, terminal: str):
135
+ self.bench, self.terminal = bench, terminal
136
+
137
+ def volts(self) -> float:
138
+ return self.bench._net_voltage(self.terminal)
139
+
140
+ def ohms(self) -> float:
141
+ resistors = [load for load in self.bench._loads_on(self.terminal) if isinstance(load, Resistor)]
142
+ if not resistors:
143
+ return math.inf
144
+ if any(r.ohms == 0 for r in resistors):
145
+ return 0.0
146
+ return 1 / sum(1 / r.ohms for r in resistors)
147
+
148
+
149
+ class Bench:
150
+ def __init__(self) -> None:
151
+ self.instruments: dict[str, Instrument] = {}
152
+ self.devices: dict[str, Device] = {}
153
+ self._console_ports: dict[str, int] = {} # device name -> configured console port
154
+ self.ports: dict[str, int] = {}
155
+ self.control_port: int | None = None
156
+ self.host = "127.0.0.1"
157
+ self._loads: dict[str, Load] = {}
158
+ self._static = _Nets()
159
+ # terminals that drive their net: PSU channels -> (psu, channel), device outputs -> (device, terminal)
160
+ self._sources: dict[str, tuple[PowerSupply | Device, int | str]] = {}
161
+ self._device_inputs: dict[str, _DeviceInput] = {}
162
+ self._update_failed: set[str] = set()
163
+ self._update_lock = threading.Lock() # one device update at a time, across instrument threads
164
+ self._servers: dict[str, InstrumentServer] = {}
165
+ self._control: ControlServer | None = None
166
+ self._consoles: dict[str, ConsoleServer] = {}
167
+ self._switch_lock = threading.Lock() # relay check + switch, across all multiplexers
168
+
169
+ # --- construction -------------------------------------------------------------
170
+
171
+ @classmethod
172
+ def from_file(cls, path: str | Path) -> Bench:
173
+ path = Path(path)
174
+ try:
175
+ data = yaml.load(path.read_text(encoding="utf-8"), Loader=_Loader)
176
+ except OSError as error:
177
+ raise BenchError(f"{path}: {error.strerror or error}") from None
178
+ except BenchError as error:
179
+ raise BenchError(f"{path}: {error}") from None
180
+ except yaml.YAMLError as error:
181
+ mark = getattr(error, "problem_mark", None)
182
+ where = f"line {mark.line + 1}, column {mark.column + 1}: " if mark else ""
183
+ problem = getattr(error, "problem", None) or str(error).splitlines()[0]
184
+ raise BenchError(f"{path}: {where}{problem}") from None
185
+ # python loads may live in modules next to the bench file
186
+ folder = str(path.resolve().parent)
187
+ sys.path.insert(0, folder)
188
+ try:
189
+ return cls.from_dict(data)
190
+ except BenchError as error:
191
+ raise BenchError(f"{path}: {error}") from None
192
+ finally:
193
+ sys.path.remove(folder)
194
+
195
+ @classmethod
196
+ def from_dict(cls, data: dict) -> Bench:
197
+ if not isinstance(data, dict):
198
+ raise BenchError("a bench is a mapping with an 'instruments' key")
199
+ unknown = set(data) - {"instruments", "devices", "loads", "wiring", "control"}
200
+ if unknown:
201
+ raise BenchError(f"unknown key {sorted(unknown)[0]!r} "
202
+ "(allowed: instruments, devices, loads, wiring, control)")
203
+ bench = cls()
204
+ bench._add_instruments(data.get("instruments"))
205
+ bench._add_devices(data.get("devices") or {})
206
+ bench._add_loads(data.get("loads") or {})
207
+ bench._add_wiring(data.get("wiring") or [])
208
+ bench._check()
209
+ bench.control_port = bench._control_setting(data.get("control"))
210
+ used = {**{f"instrument {n!r}": p for n, p in bench.ports.items()},
211
+ **{f"console of {n!r}": p for n, p in bench._console_ports.items()}}
212
+ seen: dict[int, str] = {}
213
+ for owner, port in used.items():
214
+ if port and port in seen:
215
+ raise BenchError(f"port {port} is used by {seen[port]} and {owner}")
216
+ seen[port] = owner
217
+ if bench.control_port and bench.control_port in seen:
218
+ raise BenchError(f"control port {bench.control_port} is already used by {seen[bench.control_port]}")
219
+ for instrument in bench.instruments.values():
220
+ instrument.listeners.append(bench._update_devices)
221
+ return bench
222
+
223
+ def _add_instruments(self, config) -> None:
224
+ if not isinstance(config, dict) or not config:
225
+ raise BenchError("'instruments' must map names to instrument settings")
226
+ used_ports: dict[int, str] = {}
227
+ for name, settings in config.items():
228
+ where = f"instrument {name!r}"
229
+ if not isinstance(name, str) or not _NAME.fullmatch(name):
230
+ raise BenchError(f"{where}: names use lowercase letters, digits and '_'")
231
+ if name in _RESERVED:
232
+ raise BenchError(f"{where}: reserved name, choose another one")
233
+ if not isinstance(settings, dict):
234
+ raise BenchError(f"{where}: settings must be a mapping")
235
+ settings = dict(settings)
236
+ kind = settings.pop("type", None)
237
+ if kind not in _TYPES:
238
+ raise BenchError(f"{where}: unknown type {kind!r} (allowed: {', '.join(_TYPES)})")
239
+ port = settings.pop("port", None)
240
+ if not isinstance(port, int) or isinstance(port, bool) or not 0 <= port <= 65535:
241
+ raise BenchError(f"{where}: 'port' must be a number 0-65535")
242
+ if port and port in used_ports:
243
+ raise BenchError(f"{where}: port {port} is already used by {used_ports[port]!r}")
244
+ used_ports[port] = name
245
+ factory, options = _TYPES[kind]
246
+ unknown = set(settings) - set(options)
247
+ if unknown:
248
+ raise BenchError(f"{where}: unknown option {sorted(unknown)[0]!r} "
249
+ f"(allowed for {kind}: {', '.join(sorted(options))})")
250
+ for option, value in settings.items():
251
+ try:
252
+ settings[option] = options[option](value)
253
+ except ValueError as error:
254
+ raise BenchError(f"{where}: option {option!r} {error}") from None
255
+ try:
256
+ instrument = factory(**settings)
257
+ except (TypeError, ValueError) as error:
258
+ raise BenchError(f"{where}: {error}") from None
259
+ self.instruments[name] = instrument
260
+ self.ports[name] = port
261
+ if isinstance(instrument, PowerSupply):
262
+ for channel in range(1, instrument.channel_count + 1):
263
+ terminal = f"{name}.ch{channel}"
264
+ self._sources[terminal] = (instrument, channel)
265
+ instrument.loads[channel - 1] = _NetLoad(self, terminal)
266
+ elif isinstance(instrument, Multimeter):
267
+ instrument.source = _DmmInput(self, f"{name}.input")
268
+ elif isinstance(instrument, Multiplexer):
269
+ instrument.before_close = lambda closed, mux=name: self._check_relays(mux, closed)
270
+ instrument.switch_lock = self._switch_lock
271
+
272
+ def _add_loads(self, config) -> None:
273
+ if not isinstance(config, dict):
274
+ raise BenchError("'loads' must map node names to loads")
275
+ for node, settings in config.items():
276
+ where = f"load {node!r}"
277
+ if not isinstance(node, str) or not _NODE.fullmatch(node) or node in self.instruments:
278
+ raise BenchError(f"{where}: a load sits on a node name (letters, digits, '_'), "
279
+ "not on an instrument")
280
+ if node in self.devices:
281
+ raise BenchError(f"{where}: {node!r} is a device; put the load on a node and wire "
282
+ "that node to a device terminal")
283
+ if not isinstance(settings, dict):
284
+ raise BenchError(f"{where}: settings must be a mapping")
285
+ kind = settings.get("type")
286
+ if kind == "resistor":
287
+ try:
288
+ self._loads[node] = Resistor(float(settings["ohms"]))
289
+ except KeyError:
290
+ raise BenchError(f"{where}: a resistor needs 'ohms'") from None
291
+ except (TypeError, ValueError) as error:
292
+ raise BenchError(f"{where}: {error}") from None
293
+ elif kind == "python":
294
+ self._loads[node] = self._python_load(where, settings)
295
+ else:
296
+ raise BenchError(f"{where}: unknown type {kind!r} (allowed: resistor, python)")
297
+
298
+ @staticmethod
299
+ def _import(where: str, target) -> type:
300
+ module_name, _, class_name = str(target).partition(":")
301
+ if not module_name or not class_name:
302
+ raise BenchError(f"{where}: 'class' must look like 'package.module:ClassName'")
303
+ try:
304
+ return getattr(importlib.import_module(module_name), class_name)
305
+ except Exception as error: # import errors, missing class
306
+ raise BenchError(f"{where}: cannot import {target}: {error}") from None
307
+
308
+ @classmethod
309
+ def _python_load(cls, where: str, settings: dict) -> Load:
310
+ target = settings.get("class", "")
311
+ factory = cls._import(where, target)
312
+ try:
313
+ load = factory(**(settings.get("args") or {}))
314
+ except Exception as error: # bad args, errors in the constructor
315
+ raise BenchError(f"{where}: cannot create {target}: {error}") from None
316
+ if not callable(getattr(load, "current", None)):
317
+ raise BenchError(f"{where}: {target} has no current(volts) method")
318
+ return load
319
+
320
+ def _add_devices(self, config) -> None:
321
+ if not isinstance(config, dict):
322
+ raise BenchError("'devices' must map names to device settings")
323
+ for name, settings in config.items():
324
+ where = f"device {name!r}"
325
+ if not isinstance(name, str) or not _NAME.fullmatch(name):
326
+ raise BenchError(f"{where}: names use lowercase letters, digits and '_'")
327
+ if name in _RESERVED:
328
+ raise BenchError(f"{where}: reserved name, choose another one")
329
+ if name in self.instruments:
330
+ raise BenchError(f"{where}: an instrument is already called {name!r}")
331
+ if not isinstance(settings, dict):
332
+ raise BenchError(f"{where}: settings must be a mapping")
333
+ unknown = set(settings) - {"class", "args", "console"}
334
+ if unknown:
335
+ raise BenchError(f"{where}: unknown key {sorted(unknown)[0]!r} (allowed: class, args, console)")
336
+ target = settings.get("class", "")
337
+ factory = self._import(where, target)
338
+ if not (isinstance(factory, type) and issubclass(factory, Device)):
339
+ raise BenchError(f"{where}: {target} is not a scpi_sim Device subclass")
340
+ args = settings.get("args") or {}
341
+ if not isinstance(args, dict):
342
+ raise BenchError(f"{where}: 'args' must be a mapping")
343
+ try:
344
+ device = factory(**args)
345
+ except Exception as error:
346
+ raise BenchError(f"{where}: cannot create {target}: {error}") from None
347
+ if not isinstance(getattr(device, "console", None), Console):
348
+ raise BenchError(f"{where}: {target} is not set up as a Device -- "
349
+ "does its __init__ call super().__init__()?")
350
+ if "console" in settings:
351
+ port = settings["console"]
352
+ if not isinstance(port, int) or isinstance(port, bool) or not 0 <= port <= 65535:
353
+ raise BenchError(f"{where}: 'console' must be a port number 0-65535")
354
+ self._console_ports[name] = port
355
+ self.devices[name] = device
356
+ for terminal, kind in device.terminals.items():
357
+ full = f"{name}.{terminal}"
358
+ if kind == "output":
359
+ self._sources[full] = (device, terminal)
360
+ else:
361
+ self._device_inputs[full] = _DeviceInput(device, terminal)
362
+ device.attach(lambda terminal, device_name=name: self._net_voltage(f"{device_name}.{terminal}"))
363
+
364
+ def _terminal(self, text: str, line: str) -> str:
365
+ where = f"wiring line {line!r}"
366
+ if "." not in text:
367
+ if not _NODE.fullmatch(text):
368
+ raise BenchError(f"{where}: invalid node name {text!r}")
369
+ if text in self.instruments:
370
+ raise BenchError(f"{where}: {text!r} is an instrument, name one of its terminals")
371
+ if text in self.devices:
372
+ raise BenchError(f"{where}: {text!r} is a device, name one of its terminals "
373
+ f"({', '.join(self.devices[text].terminals)})")
374
+ return text
375
+ name, _, pin = text.partition(".")
376
+ if name in self.devices:
377
+ if pin not in self.devices[name].terminals:
378
+ raise BenchError(f"{where}: unknown terminal {text!r} "
379
+ f"(device terminals: {', '.join(self.devices[name].terminals)})")
380
+ return text
381
+ instrument = self.instruments.get(name)
382
+ if instrument is None:
383
+ raise BenchError(f"{where}: unknown instrument or device {name!r}")
384
+ valid = (
385
+ (pin == "input") if isinstance(instrument, Multimeter) else
386
+ (text in self._sources) if isinstance(instrument, PowerSupply) else
387
+ (pin == "common" or (pin.isdigit() and int(pin) in instrument.valid_channels))
388
+ )
389
+ if not valid:
390
+ raise BenchError(f"{where}: unknown terminal {text!r}")
391
+ return text
392
+
393
+ def _add_wiring(self, lines) -> None:
394
+ if not isinstance(lines, list):
395
+ raise BenchError("'wiring' must be a list of 'A -> B' lines")
396
+ for line in lines:
397
+ m = _WIRE.fullmatch(str(line))
398
+ if not m:
399
+ raise BenchError(f"wiring line {line!r}: expected 'A -> B'")
400
+ self._static.join(self._terminal(m.group(1), line), self._terminal(m.group(2), line))
401
+
402
+ def _check(self) -> None:
403
+ for node in self._loads:
404
+ if node not in self._static.parent:
405
+ raise BenchError(f"load {node!r}: node {node!r} is not wired to anything")
406
+ self._check_sources(self._static, error=lambda msg: BenchError(msg))
407
+
408
+ @staticmethod
409
+ def _control_setting(value) -> int | None:
410
+ if value is None:
411
+ return None
412
+ if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 65535:
413
+ raise BenchError("'control' must be a port number 0-65535")
414
+ return value
415
+
416
+ # --- evaluation -----------------------------------------------------------------
417
+
418
+ def _nets(self, closed_override: tuple[str, set[int]] | None = None) -> _Nets:
419
+ nets = _Nets(self._static.parent)
420
+ for name, instrument in self.instruments.items():
421
+ if isinstance(instrument, Multiplexer):
422
+ closed = instrument.closed
423
+ if closed_override is not None and closed_override[0] == name:
424
+ closed = closed_override[1]
425
+ for channel in tuple(closed):
426
+ nets.join(f"{name}.{channel}", f"{name}.common")
427
+ return nets
428
+
429
+ def _check_sources(self, nets: _Nets, error) -> None:
430
+ seen: dict[str, str] = {}
431
+ for terminal in self._sources:
432
+ net = nets.find(terminal)
433
+ if net in seen:
434
+ raise error(f"{terminal} and {seen[net]} would drive the same net")
435
+ seen[net] = terminal
436
+
437
+ def _check_relays(self, mux: str, closed: set[int]) -> None:
438
+ self._check_sources(self._nets((mux, closed)), error=lambda msg: ScpiError(-221))
439
+
440
+ def _loads_on(self, terminal: str) -> Iterable[Load]:
441
+ nets = self._nets()
442
+ net = nets.find(terminal)
443
+ loads = [load for node, load in self._loads.items() if nets.find(node) == net]
444
+ loads += [load for name, load in self._device_inputs.items() if nets.find(name) == net]
445
+ return loads
446
+
447
+ def _net_voltage(self, terminal: str) -> float:
448
+ """Voltage of the net ``terminal`` is on: from the supply or device output driving it, else 0."""
449
+ nets = self._nets()
450
+ net = nets.find(terminal)
451
+ for source, (owner, pin) in self._sources.items():
452
+ if nets.find(source) == net:
453
+ if isinstance(owner, PowerSupply):
454
+ return owner.output(pin)[0]
455
+ volts = owner.voltage(pin)
456
+ return 0.0 if volts is None else float(volts)
457
+ return 0.0
458
+
459
+ def _update_devices(self) -> None:
460
+ with self._update_lock:
461
+ self._update_each()
462
+
463
+ def _update_each(self) -> None:
464
+ for name, device in self.devices.items():
465
+ try:
466
+ device.update()
467
+ except Exception as error:
468
+ if name not in self._update_failed:
469
+ self._update_failed.add(name)
470
+ print(f"scpi-sim: device {name}: update() failed: {type(error).__name__}: {error}",
471
+ file=sys.stderr)
472
+
473
+ # --- running ----------------------------------------------------------------------
474
+
475
+ def start(self, host: str = "127.0.0.1", free_ports: bool = False) -> None:
476
+ try:
477
+ for name, instrument in self.instruments.items():
478
+ server = InstrumentServer(instrument, host=host, port=0 if free_ports else self.ports[name])
479
+ server.start()
480
+ self._servers[name] = server
481
+ for name, port in self._console_ports.items():
482
+ console = ConsoleServer(self.devices[name], host=host, port=0 if free_ports else port, name=name)
483
+ console.start()
484
+ self._consoles[name] = console
485
+ if self.control_port is not None:
486
+ self._control = ControlServer(self, host=host, port=0 if free_ports else self.control_port)
487
+ self.control_port = self._control.start()
488
+ except BaseException:
489
+ self.stop()
490
+ raise
491
+ self.host = host
492
+
493
+ def stop(self) -> None:
494
+ """Stop every server; if one fails, still stop the others, then re-raise."""
495
+ servers = [*([self._control] if self._control else []), *self._consoles.values(), *self._servers.values()]
496
+ self._control = None
497
+ self._consoles.clear()
498
+ self._servers.clear()
499
+ failure = None
500
+ for server in servers:
501
+ try:
502
+ server.stop()
503
+ except Exception as error:
504
+ failure = failure or error
505
+ if failure is not None:
506
+ raise failure
507
+
508
+ def port(self, name: str) -> int:
509
+ return self._servers[name].port
510
+
511
+ def resource(self, name: str) -> str:
512
+ return f"TCPIP0::{self.host}::{self.port(name)}::SOCKET"
513
+
514
+ def console_port(self, name: str) -> int:
515
+ """Bound console port of device ``name``; KeyError if it has no console."""
516
+ return self._consoles[name].port
517
+
518
+ def console_url(self, name: str) -> str:
519
+ """pyserial URL of a device console: ``socket://host:port``."""
520
+ return f"socket://{self.host}:{self.console_port(name)}"
521
+
522
+ def __enter__(self) -> Bench:
523
+ self.start()
524
+ return self
525
+
526
+ def __exit__(self, *exc) -> None:
527
+ self.stop()
528
+
529
+ def __getattr__(self, name: str) -> Instrument | Device:
530
+ for registry in ("instruments", "devices"):
531
+ items = self.__dict__.get(registry, {})
532
+ if name in items:
533
+ return items[name]
534
+ raise AttributeError(name)