lab-sync-driver-sdk 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.
- lab_sync_driver_sdk/__init__.py +43 -0
- lab_sync_driver_sdk/decorators.py +349 -0
- lab_sync_driver_sdk/device.py +234 -0
- lab_sync_driver_sdk/host.py +696 -0
- lab_sync_driver_sdk/lab_host.py +543 -0
- lab_sync_driver_sdk/py.typed +0 -0
- lab_sync_driver_sdk/settings_binding.py +162 -0
- lab_sync_driver_sdk/testing.py +41 -0
- lab_sync_driver_sdk-0.3.0.dist-info/METADATA +144 -0
- lab_sync_driver_sdk-0.3.0.dist-info/RECORD +13 -0
- lab_sync_driver_sdk-0.3.0.dist-info/WHEEL +5 -0
- lab_sync_driver_sdk-0.3.0.dist-info/licenses/LICENSE +21 -0
- lab_sync_driver_sdk-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,543 @@
|
|
|
1
|
+
"""Host a ``lab-device-sdk`` Device over the stdio driver protocol.
|
|
2
|
+
|
|
3
|
+
A ``lab-device-sdk`` driver is written to be called in-process. This adapter
|
|
4
|
+
wraps such a Device instance and presents the SDK host's structural
|
|
5
|
+
contract -- translating lab command/param metadata and wire type names -- so
|
|
6
|
+
the unchanged ``lab_sync_driver_sdk.host`` serves it out-of-process, with no rewrite of
|
|
7
|
+
the driver. Install the ``lab`` extra (``pip install "lab-sync-driver-sdk[lab]"``).
|
|
8
|
+
|
|
9
|
+
This host serves exactly one device, so it has to be told which instance it is.
|
|
10
|
+
A manifest declares that with the supervisor's ``{device_id}`` token rather than
|
|
11
|
+
a hardcoded id -- that is what lets a site run several devices of one type (the
|
|
12
|
+
supervisor appends ``--http-port <n>`` when the driver serves routes)::
|
|
13
|
+
|
|
14
|
+
python -m lab_sync_driver_sdk.lab_host --device my_pkg.my_device:MyDevice \
|
|
15
|
+
--device-id {device_id}
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import base64
|
|
23
|
+
import contextlib
|
|
24
|
+
import functools
|
|
25
|
+
import importlib
|
|
26
|
+
import inspect
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
from contextlib import asynccontextmanager
|
|
31
|
+
from dataclasses import dataclass
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import TYPE_CHECKING
|
|
34
|
+
from typing import cast
|
|
35
|
+
|
|
36
|
+
from lab_sync_driver_protocol import CONFIGURATION_ENV
|
|
37
|
+
|
|
38
|
+
from lab_sync_driver_sdk.decorators import TYPE_STRING
|
|
39
|
+
from lab_sync_driver_sdk.decorators import CommandMeta
|
|
40
|
+
from lab_sync_driver_sdk.decorators import Param
|
|
41
|
+
from lab_sync_driver_sdk.decorators import TelemetryMeta
|
|
42
|
+
from lab_sync_driver_sdk.decorators import get_telemetry_meta
|
|
43
|
+
from lab_sync_driver_sdk.host import Hostable
|
|
44
|
+
from lab_sync_driver_sdk.host import claim_stdout
|
|
45
|
+
from lab_sync_driver_sdk.host import serve
|
|
46
|
+
from lab_sync_driver_sdk.host import valid_duration
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from collections.abc import AsyncGenerator
|
|
50
|
+
from collections.abc import Awaitable
|
|
51
|
+
from collections.abc import Callable
|
|
52
|
+
|
|
53
|
+
from fastapi import APIRouter
|
|
54
|
+
from fastapi import FastAPI
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
# lab-device-sdk emits these wire type names; the driver protocol expects the short forms.
|
|
59
|
+
_LAB_TYPE_MAP = {
|
|
60
|
+
"integer": "int",
|
|
61
|
+
"floating_point": "float",
|
|
62
|
+
"boolean": "bool",
|
|
63
|
+
"string": "string",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _wire_type(lab_type: str) -> str:
|
|
68
|
+
if not lab_type:
|
|
69
|
+
return TYPE_STRING
|
|
70
|
+
mapped = _LAB_TYPE_MAP.get(lab_type)
|
|
71
|
+
if mapped is None:
|
|
72
|
+
logger.warning("lab driver param type %r has no wire mapping; defaulting to string", lab_type)
|
|
73
|
+
return TYPE_STRING
|
|
74
|
+
return mapped
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
_IS_COMMAND = "_is_command"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _params(raw: object) -> list[Param]:
|
|
81
|
+
return [
|
|
82
|
+
Param(
|
|
83
|
+
name=str(entry.get("name", "")),
|
|
84
|
+
description=str(entry.get("description", "")),
|
|
85
|
+
type=_wire_type(str(entry.get("type", ""))),
|
|
86
|
+
options=_options(entry.get("options")),
|
|
87
|
+
dynamic_options=bool(entry.get("dynamic_options", False)),
|
|
88
|
+
maps_to=str(entry["maps_to"]) if entry.get("maps_to") is not None else None,
|
|
89
|
+
default=entry.get("default"),
|
|
90
|
+
min_value=cast("float | None", entry["min_value"]) if entry.get("min_value") is not None else None,
|
|
91
|
+
max_value=cast("float | None", entry["max_value"]) if entry.get("max_value") is not None else None,
|
|
92
|
+
string_type=str(entry["string_type"]) if entry.get("string_type") is not None else None,
|
|
93
|
+
type_details=str(entry["type_details"]) if entry.get("type_details") is not None else None,
|
|
94
|
+
allow_empty=bool(entry.get("allow_empty", False)),
|
|
95
|
+
group=str(entry.get("group", "")),
|
|
96
|
+
)
|
|
97
|
+
for entry in cast("list[dict[str, object]]", raw or [])
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _options(raw: object) -> list[str] | None:
|
|
102
|
+
if not raw:
|
|
103
|
+
return None
|
|
104
|
+
return [str(option) for option in cast("list[object]", raw)]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _marshal(result: object, out_names: list[str]) -> dict[str, object]:
|
|
108
|
+
"""Shape a lab command's raw return into the SDK's dict-output contract.
|
|
109
|
+
|
|
110
|
+
lab commands return scalars/strings/None or tuples; the SDK host expects a dict keyed by the declared output params and validates against
|
|
111
|
+
them.
|
|
112
|
+
"""
|
|
113
|
+
if isinstance(result, dict):
|
|
114
|
+
return cast("dict[str, object]", result)
|
|
115
|
+
if not out_names:
|
|
116
|
+
return {}
|
|
117
|
+
if len(out_names) == 1:
|
|
118
|
+
return {out_names[0]: result}
|
|
119
|
+
if isinstance(result, (list, tuple)):
|
|
120
|
+
items: list[object] = list(cast("list[object]", result))
|
|
121
|
+
if len(items) == len(out_names):
|
|
122
|
+
return {name: items[i] for i, name in enumerate(out_names)}
|
|
123
|
+
return {out_names[0]: result}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
_SIM_DEFAULT: dict[str, object] = {"int": 0, "float": 0.0, "bool": False}
|
|
127
|
+
_SIM_PROGRESS_STEPS = 4
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _sim_default(param: Param) -> object:
|
|
131
|
+
return _SIM_DEFAULT.get(param.type, "")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _sim_run(seconds: float, progress: object, outputs: list[Param]) -> dict[str, object]:
|
|
135
|
+
"""Fake a command: pace out default_sim_sec (streaming progress), return typed defaults."""
|
|
136
|
+
reporter = cast("Callable[[float, str], Awaitable[None]] | None", progress if callable(progress) else None)
|
|
137
|
+
for i in range(_SIM_PROGRESS_STEPS):
|
|
138
|
+
await asyncio.sleep(seconds / _SIM_PROGRESS_STEPS if seconds > 0 else 0)
|
|
139
|
+
if reporter is not None:
|
|
140
|
+
await reporter((i + 1) / _SIM_PROGRESS_STEPS, "simulating")
|
|
141
|
+
return {p.name: _sim_default(p) for p in outputs}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class _InputSpec:
|
|
146
|
+
declared: str # the name on the wire (the describe entry's `name`)
|
|
147
|
+
kwarg: str # the method's actual parameter (lab `maps_to`)
|
|
148
|
+
default: object
|
|
149
|
+
has_default: bool
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _input_specs(raw: object) -> list[_InputSpec]:
|
|
153
|
+
specs: list[_InputSpec] = []
|
|
154
|
+
for entry in cast("list[dict[str, object]]", raw or []):
|
|
155
|
+
name = str(entry.get("name", ""))
|
|
156
|
+
maps_to = entry.get("maps_to")
|
|
157
|
+
specs.append(
|
|
158
|
+
_InputSpec(
|
|
159
|
+
declared=name,
|
|
160
|
+
kwarg=str(maps_to) if maps_to else name,
|
|
161
|
+
default=entry.get("default"),
|
|
162
|
+
has_default="default" in entry,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
return specs
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _resolve_inputs(inputs: list[_InputSpec], provided: dict[str, object]) -> list[tuple[_InputSpec, object]]:
|
|
169
|
+
"""Each declared input's value for one call, its declared default when omitted.
|
|
170
|
+
|
|
171
|
+
The SDK host has already remapped declared names to `maps_to` by the
|
|
172
|
+
time a wrapper runs, while a direct caller passes declared names, so
|
|
173
|
+
either spelling is accepted (declared first).
|
|
174
|
+
"""
|
|
175
|
+
values: list[tuple[_InputSpec, object]] = []
|
|
176
|
+
for spec in inputs:
|
|
177
|
+
if spec.declared in provided:
|
|
178
|
+
values.append((spec, provided[spec.declared]))
|
|
179
|
+
elif spec.kwarg in provided:
|
|
180
|
+
values.append((spec, provided[spec.kwarg]))
|
|
181
|
+
elif spec.has_default:
|
|
182
|
+
values.append((spec, spec.default))
|
|
183
|
+
return values
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def _sim_seconds(
|
|
187
|
+
sim_time: Callable[[dict[str, object]], object], declared: dict[str, object], fallback: float
|
|
188
|
+
) -> float:
|
|
189
|
+
"""How long to pace a simulated call: the device's own estimate, else `fallback`."""
|
|
190
|
+
try:
|
|
191
|
+
answer = sim_time(declared)
|
|
192
|
+
if inspect.isawaitable(answer):
|
|
193
|
+
answer = await answer
|
|
194
|
+
except Exception: # an estimate must never fail the simulated command
|
|
195
|
+
logger.exception("simulation time hook failed; using default_sim_sec")
|
|
196
|
+
return fallback
|
|
197
|
+
seconds = valid_duration(answer)
|
|
198
|
+
return fallback if seconds is None else seconds
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _wrap_command(
|
|
202
|
+
method: Callable[..., Awaitable[object]],
|
|
203
|
+
inputs: list[_InputSpec],
|
|
204
|
+
outputs: list[Param],
|
|
205
|
+
is_simulated: Callable[[], bool],
|
|
206
|
+
sim_time: Callable[[dict[str, object]], object] | None = None,
|
|
207
|
+
) -> Callable[..., Awaitable[dict[str, object]]]:
|
|
208
|
+
"""Bridge the SDK's call convention to a lab command.
|
|
209
|
+
|
|
210
|
+
Remap declared input names to the method's kwargs (lab `maps_to`), apply
|
|
211
|
+
declared defaults, then marshal the raw return into the studio dict-output
|
|
212
|
+
contract. When simulated, skip the hardware call and return typed defaults
|
|
213
|
+
unless the command opts into `handles_simulation` -- pacing out the
|
|
214
|
+
device's `get_dynamic_simulation_time_sec` for these inputs when `sim_time`
|
|
215
|
+
is given, as lab-device-sdk's own host did, else `default_sim_sec`.
|
|
216
|
+
"""
|
|
217
|
+
out_names = [p.name for p in outputs]
|
|
218
|
+
handles_sim = bool(getattr(method, "_cmd_handles_simulation", False))
|
|
219
|
+
sim_sec = float(getattr(method, "_cmd_default_sim_sec", 3.0))
|
|
220
|
+
|
|
221
|
+
@functools.wraps(method)
|
|
222
|
+
async def wrapper(**provided: object) -> dict[str, object]:
|
|
223
|
+
values = _resolve_inputs(inputs, provided)
|
|
224
|
+
if is_simulated() and not handles_sim:
|
|
225
|
+
# The hook is keyed by declared (display) name, as `estimate_time` asks it.
|
|
226
|
+
declared = {spec.declared: value for spec, value in values}
|
|
227
|
+
seconds = sim_sec if sim_time is None else await _sim_seconds(sim_time, declared, sim_sec)
|
|
228
|
+
return await _sim_run(seconds, provided.get("progress"), outputs)
|
|
229
|
+
kwargs: dict[str, object] = {spec.kwarg: value for spec, value in values}
|
|
230
|
+
if "progress" in provided:
|
|
231
|
+
kwargs["progress"] = provided["progress"]
|
|
232
|
+
return _marshal(await method(**kwargs), out_names)
|
|
233
|
+
|
|
234
|
+
return wrapper
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _telemetry_specs(device: object) -> dict[str, tuple[str, TelemetryMeta]]:
|
|
238
|
+
"""Build SDK telemetry specs from a lab Device's decorated methods.
|
|
239
|
+
|
|
240
|
+
The mirror of `_command_specs`, and it is what makes `@telemetry` usable
|
|
241
|
+
from a driver written against lab-device-sdk rather than lab_sync_driver_sdk. The
|
|
242
|
+
decorator is lab_sync_driver_sdk's either way; only the base class differs.
|
|
243
|
+
"""
|
|
244
|
+
specs: dict[str, tuple[str, TelemetryMeta]] = {}
|
|
245
|
+
for attr in dir(device):
|
|
246
|
+
fn = getattr(device, attr, None)
|
|
247
|
+
meta = get_telemetry_meta(fn) if fn is not None else None
|
|
248
|
+
if meta is None or not meta.channel:
|
|
249
|
+
continue
|
|
250
|
+
specs[meta.channel] = (attr, meta)
|
|
251
|
+
return specs
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _command_specs(device: object) -> dict[str, tuple[str, CommandMeta]]:
|
|
255
|
+
"""Build SDK command specs from a lab Device's decorated methods."""
|
|
256
|
+
specs: dict[str, tuple[str, CommandMeta]] = {}
|
|
257
|
+
for attr in dir(device):
|
|
258
|
+
fn = getattr(device, attr, None)
|
|
259
|
+
if fn is None or not getattr(fn, _IS_COMMAND, False):
|
|
260
|
+
continue
|
|
261
|
+
name = str(getattr(fn, "_cmd_name", "") or attr)
|
|
262
|
+
raw_sim_sec = getattr(fn, "_cmd_default_sim_sec", None)
|
|
263
|
+
meta = CommandMeta(
|
|
264
|
+
name=name,
|
|
265
|
+
description=str(getattr(fn, "_cmd_desc", "")),
|
|
266
|
+
inputs=_params(getattr(fn, "_params_in", None)),
|
|
267
|
+
outputs=_params(getattr(fn, "_params_out", None)),
|
|
268
|
+
category=str(getattr(fn, "_cmd_category", "") or ""),
|
|
269
|
+
operator_visible=bool(getattr(fn, "_cmd_operator_visible", True)),
|
|
270
|
+
default_sim_sec=float(cast("float", raw_sim_sec)) if raw_sim_sec is not None else None,
|
|
271
|
+
# lab-device-sdk 0.1.12 stamps `_cmd_operation_phase`; older releases and
|
|
272
|
+
# hand-rolled metadata used the bare name. Read both so neither is dropped.
|
|
273
|
+
operation_phase=str(getattr(fn, "_cmd_operation_phase", None) or getattr(fn, "operation_phase", "") or ""),
|
|
274
|
+
handles_simulation=bool(getattr(fn, "_cmd_handles_simulation", False)),
|
|
275
|
+
# `@command(..., exclusive=False)` marks a command that may run beside an
|
|
276
|
+
# exclusive one (a light toggled while motion commands run, say).
|
|
277
|
+
# Dropping the flag would have the supervisor serialise them behind every move.
|
|
278
|
+
exclusive=bool(getattr(fn, "_cmd_exclusive", True)),
|
|
279
|
+
concurrent=bool(getattr(fn, "_cmd_concurrent", False)),
|
|
280
|
+
)
|
|
281
|
+
specs[name] = (attr, meta)
|
|
282
|
+
return specs
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
class LabDeviceAdapter:
|
|
286
|
+
"""Wrap a lab-device-sdk Device so the SDK host can serve it.
|
|
287
|
+
|
|
288
|
+
Attribute access (command methods, lifecycle) delegates to the wrapped device.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
def __init__(self, device: object, config: dict[str, object] | None = None) -> None:
|
|
292
|
+
super().__init__()
|
|
293
|
+
self._device = device
|
|
294
|
+
self._config = config or {}
|
|
295
|
+
self._is_simulated = False
|
|
296
|
+
cls = type(device)
|
|
297
|
+
self.device_type_id = str(getattr(cls, "device_type_id", ""))
|
|
298
|
+
self.manufacturer = str(getattr(cls, "manufacturer", ""))
|
|
299
|
+
self.model = str(getattr(cls, "model", ""))
|
|
300
|
+
self._specs = _command_specs(device)
|
|
301
|
+
# Bind a bridging wrapper for each command so the host's
|
|
302
|
+
# getattr(device, method)(**declared) maps to the lab method + dict output.
|
|
303
|
+
hook = getattr(device, "get_dynamic_simulation_time_sec", None)
|
|
304
|
+
for attr, meta in self._specs.values():
|
|
305
|
+
method = cast("Callable[..., Awaitable[object]]", getattr(device, attr))
|
|
306
|
+
inputs = _input_specs(getattr(getattr(device, attr), "_params_in", None))
|
|
307
|
+
sim_time = functools.partial(hook, meta.name) if callable(hook) else None
|
|
308
|
+
setattr(
|
|
309
|
+
self,
|
|
310
|
+
attr,
|
|
311
|
+
_wrap_command(method, inputs, meta.outputs, lambda: self._is_simulated, sim_time),
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
def command_specs(self) -> dict[str, tuple[str, CommandMeta]]:
|
|
315
|
+
return self._specs
|
|
316
|
+
|
|
317
|
+
def telemetry_specs(self) -> dict[str, tuple[str, TelemetryMeta]]:
|
|
318
|
+
"""The demand-gated samplers the wrapped device declares, if any."""
|
|
319
|
+
return _telemetry_specs(self._device)
|
|
320
|
+
|
|
321
|
+
def settings_specs(self) -> list[dict[str, object]]:
|
|
322
|
+
"""Wire descriptors for the device's settings (its @settings connect params)."""
|
|
323
|
+
connect = getattr(self._device, "connect", None)
|
|
324
|
+
raw = getattr(connect, "_params_in", None) if connect is not None else None
|
|
325
|
+
specs: list[dict[str, object]] = []
|
|
326
|
+
for entry in cast("list[dict[str, object]]", raw or []):
|
|
327
|
+
spec: dict[str, object] = {
|
|
328
|
+
"name": str(entry.get("name", "")),
|
|
329
|
+
"description": str(entry.get("description", "")),
|
|
330
|
+
"type": _wire_type(str(entry.get("type", ""))),
|
|
331
|
+
"dynamic_options": bool(entry.get("dynamic_options", False)),
|
|
332
|
+
}
|
|
333
|
+
options = _options(entry.get("options"))
|
|
334
|
+
if options is not None:
|
|
335
|
+
spec["options"] = options
|
|
336
|
+
if entry.get("maps_to") is not None:
|
|
337
|
+
spec["maps_to"] = str(entry["maps_to"])
|
|
338
|
+
if "default" in entry:
|
|
339
|
+
spec["default"] = entry["default"]
|
|
340
|
+
for key in ("min_value", "max_value", "string_type", "type_details", "allow_empty", "group"):
|
|
341
|
+
if entry.get(key) is not None:
|
|
342
|
+
spec[key] = entry[key]
|
|
343
|
+
specs.append(spec)
|
|
344
|
+
return specs
|
|
345
|
+
|
|
346
|
+
def picture(self) -> str | None:
|
|
347
|
+
"""Return the device image as base64, resolved from the driver's picture_url."""
|
|
348
|
+
# The instance's, not the class's: a driver with configurations picks its
|
|
349
|
+
# picture per variant in `__init__`.
|
|
350
|
+
url = str(getattr(self._device, "picture_url", "") or "")
|
|
351
|
+
if not url:
|
|
352
|
+
return None
|
|
353
|
+
module = importlib.import_module(type(self._device).__module__)
|
|
354
|
+
module_file = getattr(module, "__file__", None)
|
|
355
|
+
if module_file is None:
|
|
356
|
+
return None
|
|
357
|
+
path = (Path(module_file).parent / url).resolve()
|
|
358
|
+
if not path.is_file():
|
|
359
|
+
return None
|
|
360
|
+
return base64.b64encode(path.read_bytes()).decode("ascii")
|
|
361
|
+
|
|
362
|
+
async def connect(self, settings: dict[str, object] | None = None) -> None:
|
|
363
|
+
# The protocol's connect carries one settings dict; lab connect is an @settings method
|
|
364
|
+
# with explicit params. Bind them from configuration, then apply any
|
|
365
|
+
# per-instance settings the user saved (keyed by the declared name -> maps_to).
|
|
366
|
+
if self._is_simulated:
|
|
367
|
+
return
|
|
368
|
+
connect = getattr(self._device, "connect", None)
|
|
369
|
+
if connect is None:
|
|
370
|
+
return
|
|
371
|
+
params = inspect.signature(connect).parameters
|
|
372
|
+
raw = cast("list[dict[str, object]]", getattr(connect, "_params_in", None) or [])
|
|
373
|
+
kwarg_by_name = {str(e.get("name", "")): str(e.get("maps_to") or e.get("name", "")) for e in raw}
|
|
374
|
+
|
|
375
|
+
# Start from the values the driver declares on its own @settings params.
|
|
376
|
+
# A lab connect takes them as *required* positional arguments, so any the
|
|
377
|
+
# operator has not overridden must still be supplied -- otherwise a device
|
|
378
|
+
# whose settings were never edited cannot connect at all.
|
|
379
|
+
kwargs: dict[str, object] = {}
|
|
380
|
+
for entry in raw:
|
|
381
|
+
kwarg = kwarg_by_name.get(str(entry.get("name", "")), "")
|
|
382
|
+
if kwarg in params and entry.get("default") is not None:
|
|
383
|
+
kwargs[kwarg] = entry["default"]
|
|
384
|
+
|
|
385
|
+
kwargs.update({name: self._config[name] for name in params if name in self._config})
|
|
386
|
+
for declared, value in (settings or {}).items():
|
|
387
|
+
kwarg = kwarg_by_name.get(declared, declared)
|
|
388
|
+
if kwarg in params:
|
|
389
|
+
kwargs[kwarg] = value
|
|
390
|
+
await connect(**kwargs)
|
|
391
|
+
|
|
392
|
+
async def set_simulation(self, *, enabled: bool) -> None:
|
|
393
|
+
"""Gate hardware at the adapter; also poke the wrapped device if it tracks sim itself."""
|
|
394
|
+
self._is_simulated = enabled
|
|
395
|
+
setter = getattr(self._device, "set_simulation_mode", None)
|
|
396
|
+
if callable(setter):
|
|
397
|
+
with contextlib.suppress(TypeError):
|
|
398
|
+
_ = setter(enabled=enabled)
|
|
399
|
+
else:
|
|
400
|
+
with contextlib.suppress(AttributeError):
|
|
401
|
+
setattr(self._device, "is_simulated", enabled)
|
|
402
|
+
|
|
403
|
+
def __getattr__(self, name: str) -> object:
|
|
404
|
+
"""Delegate lifecycle methods to the wrapped device."""
|
|
405
|
+
if name.startswith("_"):
|
|
406
|
+
raise AttributeError(name)
|
|
407
|
+
return getattr(object.__getattribute__(self, "_device"), name)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _load_device(target: str, device_id: str, configuration: str | None) -> object:
|
|
411
|
+
module_name, _, class_name = target.partition(":")
|
|
412
|
+
if not class_name:
|
|
413
|
+
msg = f"--device must be 'module:Class', got {target!r}"
|
|
414
|
+
raise SystemExit(msg)
|
|
415
|
+
module = importlib.import_module(module_name)
|
|
416
|
+
cls = getattr(module, class_name)
|
|
417
|
+
return cls(device_id, configuration)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class _LocalHub:
|
|
421
|
+
"""Hub for the driver's own routes to resolve through.
|
|
422
|
+
|
|
423
|
+
Out-of-process, the driver process hosts exactly one device, so every lookup returns it -- the same `device_hub.get_device(id)` contract an
|
|
424
|
+
in-process hub provides.
|
|
425
|
+
"""
|
|
426
|
+
|
|
427
|
+
def __init__(self, device: object) -> None:
|
|
428
|
+
super().__init__()
|
|
429
|
+
self._device = device
|
|
430
|
+
|
|
431
|
+
def get_device(self, device_id: str) -> object:
|
|
432
|
+
del device_id # single-device process: every lookup returns the hosted device
|
|
433
|
+
return self._device
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def _http_app(device: object, routes_target: str) -> FastAPI | None:
|
|
437
|
+
"""Build the driver's FastAPI app from its `get_routers(hub, state)` factory.
|
|
438
|
+
|
|
439
|
+
Inject a local hub. Returns None if the driver declares no routes.
|
|
440
|
+
"""
|
|
441
|
+
module_name, _, factory_name = routes_target.partition(":")
|
|
442
|
+
try:
|
|
443
|
+
routes_module = importlib.import_module(module_name)
|
|
444
|
+
except ModuleNotFoundError:
|
|
445
|
+
return None
|
|
446
|
+
get_routers = getattr(routes_module, factory_name or "get_routers", None)
|
|
447
|
+
if get_routers is None:
|
|
448
|
+
return None
|
|
449
|
+
|
|
450
|
+
from fastapi import FastAPI # noqa: PLC0415
|
|
451
|
+
|
|
452
|
+
get_hooks = getattr(routes_module, "get_lifespan_hooks", None)
|
|
453
|
+
hooks = cast("dict[str, Callable[[], Awaitable[None]]]", get_hooks()) if get_hooks else {}
|
|
454
|
+
startup = hooks.get("startup")
|
|
455
|
+
# An in-process host runs a driver's `shutdown` hook when the driver is
|
|
456
|
+
# replaced or the host stops; here the process ends when the supervisor
|
|
457
|
+
# closes stdin, but a driver holding sockets or a session open to hardware
|
|
458
|
+
# still wants to say goodbye rather than be dropped.
|
|
459
|
+
shutdown = hooks.get("shutdown")
|
|
460
|
+
|
|
461
|
+
@asynccontextmanager
|
|
462
|
+
async def lifespan(_app: FastAPI) -> AsyncGenerator[None]:
|
|
463
|
+
if startup is not None:
|
|
464
|
+
await startup()
|
|
465
|
+
try:
|
|
466
|
+
yield
|
|
467
|
+
finally:
|
|
468
|
+
if shutdown is not None:
|
|
469
|
+
await shutdown()
|
|
470
|
+
|
|
471
|
+
app = FastAPI(lifespan=lifespan)
|
|
472
|
+
routers = cast("list[APIRouter]", get_routers(_LocalHub(device), {}))
|
|
473
|
+
for router in routers:
|
|
474
|
+
app.include_router(router)
|
|
475
|
+
return app
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
async def _serve_all(
|
|
479
|
+
device: Hostable,
|
|
480
|
+
*,
|
|
481
|
+
http_app: FastAPI | None,
|
|
482
|
+
http_port: int,
|
|
483
|
+
host: str,
|
|
484
|
+
driver_version: str,
|
|
485
|
+
) -> None:
|
|
486
|
+
# Claim fd 1 for the protocol before uvicorn starts so its access logs (which
|
|
487
|
+
# default to stdout) cannot corrupt frames; serve() then reuses that stream.
|
|
488
|
+
out = claim_stdout()
|
|
489
|
+
if http_app is None:
|
|
490
|
+
await serve(device, driver_version=driver_version, out=out)
|
|
491
|
+
return
|
|
492
|
+
import uvicorn # noqa: PLC0415
|
|
493
|
+
|
|
494
|
+
config = uvicorn.Config(http_app, host=host, port=http_port, log_level="warning", log_config=None)
|
|
495
|
+
server = uvicorn.Server(config)
|
|
496
|
+
|
|
497
|
+
async def _run_stdio() -> None:
|
|
498
|
+
try:
|
|
499
|
+
await serve(device, driver_version=driver_version, out=out)
|
|
500
|
+
finally:
|
|
501
|
+
server.should_exit = True # stdin EOF ends the protocol -> stop the HTTP server so the process exits
|
|
502
|
+
|
|
503
|
+
_ = await asyncio.gather(_run_stdio(), server.serve())
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def main() -> None:
|
|
507
|
+
parser = argparse.ArgumentParser(prog="lab_sync_driver_sdk.lab_host")
|
|
508
|
+
_ = parser.add_argument("--device", required=True, help="import path 'module:Class'")
|
|
509
|
+
_ = parser.add_argument("--device-id", required=True)
|
|
510
|
+
_ = parser.add_argument("--config", default=None, help="JSON: connect settings to bind")
|
|
511
|
+
_ = parser.add_argument(
|
|
512
|
+
"--configuration",
|
|
513
|
+
default=os.environ.get(CONFIGURATION_ENV) or None,
|
|
514
|
+
help=f"hardware variant to construct the device as (default: ${CONFIGURATION_ENV})",
|
|
515
|
+
)
|
|
516
|
+
_ = parser.add_argument("--http-port", type=int, default=None, help="serve driver routes here")
|
|
517
|
+
_ = parser.add_argument("--routes", default=None, help="routes factory 'module:get_routers' (default <pkg>.routes)")
|
|
518
|
+
_ = parser.add_argument("--host", default="127.0.0.1", help="bind host for the HTTP routes")
|
|
519
|
+
_ = parser.add_argument("--driver-version", default="0.0.0")
|
|
520
|
+
args = parser.parse_args()
|
|
521
|
+
|
|
522
|
+
config = cast("dict[str, object]", json.loads(args.config)) if args.config else {}
|
|
523
|
+
device = _load_device(args.device, args.device_id, args.configuration)
|
|
524
|
+
adapter = LabDeviceAdapter(device, config)
|
|
525
|
+
|
|
526
|
+
http_app = None
|
|
527
|
+
if args.http_port is not None:
|
|
528
|
+
top_pkg = args.device.split(":", 1)[0].split(".", 1)[0]
|
|
529
|
+
http_app = _http_app(device, args.routes or f"{top_pkg}.routes:get_routers")
|
|
530
|
+
|
|
531
|
+
asyncio.run(
|
|
532
|
+
_serve_all(
|
|
533
|
+
adapter,
|
|
534
|
+
http_app=http_app,
|
|
535
|
+
http_port=args.http_port or 0,
|
|
536
|
+
host=args.host,
|
|
537
|
+
driver_version=args.driver_version,
|
|
538
|
+
)
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
if __name__ == "__main__":
|
|
543
|
+
main()
|
|
File without changes
|