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,696 @@
|
|
|
1
|
+
"""Host runtime: serve a Device over the stdio driver protocol.
|
|
2
|
+
|
|
3
|
+
The driver runs as an out-of-process child; the supervisor speaks to it over the
|
|
4
|
+
child's stdin (requests) and stdout (frames). stdout is the protocol channel and
|
|
5
|
+
nothing else may write to it, so the host claims the real fd 1 for framing and
|
|
6
|
+
points `sys.stdout` at stderr -- any stray print from device code lands on the
|
|
7
|
+
log channel, not mid-frame. Generates Describe from the driver's decorators and
|
|
8
|
+
drives the decorated method on Execute, streaming progress. A command method may
|
|
9
|
+
declare a `progress` parameter; the host injects an async reporter
|
|
10
|
+
`progress(fraction, message)`.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import contextlib
|
|
17
|
+
import inspect
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import math
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
from collections.abc import Sequence
|
|
26
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
27
|
+
from enum import Enum
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
from typing import Any
|
|
30
|
+
from typing import BinaryIO
|
|
31
|
+
from typing import Protocol
|
|
32
|
+
from typing import cast
|
|
33
|
+
|
|
34
|
+
from lab_sync_driver_protocol import PROTOCOL_VERSION
|
|
35
|
+
|
|
36
|
+
from lab_sync_driver_sdk.decorators import CommandMeta
|
|
37
|
+
from lab_sync_driver_sdk.decorators import OutputValidationError
|
|
38
|
+
from lab_sync_driver_sdk.decorators import TelemetryMeta
|
|
39
|
+
from lab_sync_driver_sdk.decorators import param_dict
|
|
40
|
+
from lab_sync_driver_sdk.decorators import validate_outputs
|
|
41
|
+
from lab_sync_driver_sdk.settings_binding import bind_settings
|
|
42
|
+
from lab_sync_driver_sdk.settings_binding import binds_settings
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from collections.abc import Awaitable
|
|
46
|
+
from collections.abc import Callable
|
|
47
|
+
|
|
48
|
+
logger = logging.getLogger(__name__)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class Hostable(Protocol):
|
|
52
|
+
"""Define what the host needs from a servable target.
|
|
53
|
+
|
|
54
|
+
Both an SDK `Device` and the lab-device-sdk adapter satisfy this
|
|
55
|
+
structurally, so one servicer hosts both.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
device_type_id: str
|
|
59
|
+
manufacturer: str
|
|
60
|
+
model: str
|
|
61
|
+
|
|
62
|
+
# Gradual on purpose: `connect` takes nothing, the settings dict, or -- when it
|
|
63
|
+
# is itself the `@settings` method -- one parameter per setting.
|
|
64
|
+
async def connect(self, *args: Any, **kwargs: Any) -> None: ... # noqa: ANN401
|
|
65
|
+
|
|
66
|
+
def command_specs(self) -> dict[str, tuple[str, CommandMeta]]: ...
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _command_dict(meta: CommandMeta) -> dict[str, object]:
|
|
70
|
+
"""One command's wire entry; optional fields are omitted when unset."""
|
|
71
|
+
entry: dict[str, object] = {
|
|
72
|
+
"name": meta.name,
|
|
73
|
+
"description": meta.description,
|
|
74
|
+
"inputs": [param_dict(p) for p in meta.inputs],
|
|
75
|
+
"outputs": [{"name": p.name, "description": p.description, "type": p.type} for p in meta.outputs],
|
|
76
|
+
"exclusive": meta.exclusive,
|
|
77
|
+
"concurrent": meta.concurrent,
|
|
78
|
+
}
|
|
79
|
+
if meta.category:
|
|
80
|
+
entry["category"] = meta.category
|
|
81
|
+
if not meta.operator_visible:
|
|
82
|
+
entry["operator_visible"] = False
|
|
83
|
+
if meta.default_sim_sec is not None:
|
|
84
|
+
entry["default_sim_sec"] = meta.default_sim_sec
|
|
85
|
+
if meta.operation_phase:
|
|
86
|
+
entry["operation_phase"] = meta.operation_phase
|
|
87
|
+
if meta.handles_simulation:
|
|
88
|
+
entry["handles_simulation"] = True
|
|
89
|
+
return entry
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _declared_metadata(device: Hostable) -> dict[str, object]:
|
|
93
|
+
"""The device's optional self-description: identity text and physical envelope.
|
|
94
|
+
|
|
95
|
+
Every field is omitted when unset, so a driver that declares none of them describes byte-for-byte as it did before these fields existed.
|
|
96
|
+
"""
|
|
97
|
+
out: dict[str, object] = {}
|
|
98
|
+
for field_name in (
|
|
99
|
+
"category",
|
|
100
|
+
"description",
|
|
101
|
+
"agent_context",
|
|
102
|
+
"integration_mode",
|
|
103
|
+
"model_path",
|
|
104
|
+
"top_view_image_path",
|
|
105
|
+
):
|
|
106
|
+
raw = getattr(device, field_name, "")
|
|
107
|
+
# `agent_context` is commonly a method that reads a file next to the driver.
|
|
108
|
+
resolved = raw() if callable(raw) else raw
|
|
109
|
+
# A plain `(str, Enum)` stringifies to "DeviceCategory.PLATE_HANDLING",
|
|
110
|
+
# not to its value -- and only `lab_sync_driver_sdk`'s own DeviceCategory is a
|
|
111
|
+
# StrEnum. lab-device-sdk's is not, so a driver using it would describe a
|
|
112
|
+
# category nothing downstream matches (consumers look for the value).
|
|
113
|
+
value = str(resolved.value or "") if isinstance(resolved, Enum) else str(resolved or "")
|
|
114
|
+
if value:
|
|
115
|
+
out[field_name] = value
|
|
116
|
+
for axis in ("width_mm", "depth_mm", "height_mm"):
|
|
117
|
+
extent = getattr(device, axis, None)
|
|
118
|
+
if extent is not None:
|
|
119
|
+
out[axis] = float(cast("float", extent))
|
|
120
|
+
return out
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def valid_duration(value: object) -> float | None:
|
|
124
|
+
"""A duration estimate as seconds, or None when it is not a finite, non-negative number.
|
|
125
|
+
|
|
126
|
+
Booleans and numeric strings are refused rather than coerced, and so is
|
|
127
|
+
infinity: it is not valid JSON on the wire, and paced out it would never end.
|
|
128
|
+
"""
|
|
129
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
130
|
+
return None
|
|
131
|
+
seconds = float(value)
|
|
132
|
+
return seconds if math.isfinite(seconds) and seconds >= 0 else None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _nest_offset(raw: object) -> dict[str, float] | None:
|
|
136
|
+
"""Normalize one nest's declared offset onto the wire, or None when unset.
|
|
137
|
+
|
|
138
|
+
A driver may write the offset as an `(x, y, z)` sequence or as a mapping
|
|
139
|
+
with any subset of those keys; both land on the wire as a full
|
|
140
|
+
`{"x": .., "y": .., "z": ..}` object so the supervisor never has to guess a
|
|
141
|
+
missing axis. Anything unreadable — a string, a short sequence, a
|
|
142
|
+
non-numeric entry — reads as "no position declared" rather than failing the
|
|
143
|
+
whole describe, matching how `_dimension` treats a bad extent.
|
|
144
|
+
"""
|
|
145
|
+
if raw is None:
|
|
146
|
+
return None
|
|
147
|
+
axes: tuple[str, str, str] = ("x", "y", "z")
|
|
148
|
+
values: list[float] = []
|
|
149
|
+
if isinstance(raw, Mapping):
|
|
150
|
+
source: list[object] = [cast("Mapping[str, object]", raw).get(axis, 0.0) for axis in axes]
|
|
151
|
+
elif isinstance(raw, Sequence) and not isinstance(raw, (str, bytes)):
|
|
152
|
+
packed = list(cast("Sequence[object]", raw))
|
|
153
|
+
if len(packed) < len(axes):
|
|
154
|
+
return None
|
|
155
|
+
source = packed[: len(axes)]
|
|
156
|
+
else:
|
|
157
|
+
return None
|
|
158
|
+
for item in source:
|
|
159
|
+
try:
|
|
160
|
+
values.append(float(cast("float", item)))
|
|
161
|
+
except TypeError, ValueError:
|
|
162
|
+
return None
|
|
163
|
+
return dict(zip(axes, values, strict=True))
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _default_nests(device: Hostable) -> dict[str, dict[str, float] | None] | None:
|
|
167
|
+
"""The labware positions the device declares, or None when it declares none.
|
|
168
|
+
|
|
169
|
+
Omitted from `describe` entirely when empty, so a driver that has no nests
|
|
170
|
+
describes byte-for-byte as it did before the field existed. A nest whose
|
|
171
|
+
offset does not parse is kept with a null offset rather than dropped: the
|
|
172
|
+
nest exists on the hardware either way, and an operator can place it by
|
|
173
|
+
hand.
|
|
174
|
+
"""
|
|
175
|
+
raw = getattr(device, "default_nests", None)
|
|
176
|
+
resolved = raw() if callable(raw) else raw
|
|
177
|
+
if not isinstance(resolved, Mapping):
|
|
178
|
+
return None
|
|
179
|
+
nests = {str(name): _nest_offset(offset) for name, offset in cast("Mapping[str, object]", resolved).items()}
|
|
180
|
+
return nests or None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _configurations(device: Hostable) -> dict[str, object]:
|
|
184
|
+
"""The hardware variants the driver can be added as, and which one this instance is.
|
|
185
|
+
|
|
186
|
+
A lab-device-sdk driver lists them from its `get_configurations()` staticmethod,
|
|
187
|
+
a lab_sync_driver_sdk one in its `configurations` class attribute. Both fields are
|
|
188
|
+
omitted when the driver declares none, so every other driver describes as
|
|
189
|
+
before. An instance whose own `configuration` is unset -- or is not one of the
|
|
190
|
+
names, which only a hand-edited record can produce -- reports the first, which
|
|
191
|
+
is the variant a driver runs as by default.
|
|
192
|
+
"""
|
|
193
|
+
lister = getattr(device, "get_configurations", None)
|
|
194
|
+
raw = lister() if callable(lister) else getattr(device, "configurations", ())
|
|
195
|
+
if not isinstance(raw, Sequence) or isinstance(raw, (str, bytes)):
|
|
196
|
+
return {}
|
|
197
|
+
names = [str(name) for name in cast("Sequence[object]", raw) if str(name)]
|
|
198
|
+
if not names:
|
|
199
|
+
return {}
|
|
200
|
+
active = getattr(device, "configuration", None)
|
|
201
|
+
return {"configurations": names, "configuration": active if active in names else names[0]}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _describe(device: Hostable) -> dict[str, object]:
|
|
205
|
+
entry: dict[str, object] = {
|
|
206
|
+
"device_type_id": device.device_type_id,
|
|
207
|
+
"manufacturer": device.manufacturer,
|
|
208
|
+
"model": device.model,
|
|
209
|
+
"commands": [_command_dict(meta) for _, meta in device.command_specs().values()],
|
|
210
|
+
}
|
|
211
|
+
entry.update(_declared_metadata(device))
|
|
212
|
+
entry.update(_configurations(device))
|
|
213
|
+
settings_fn = getattr(device, "settings_specs", None)
|
|
214
|
+
if callable(settings_fn):
|
|
215
|
+
entry["settings"] = settings_fn()
|
|
216
|
+
picture_fn = getattr(device, "picture", None)
|
|
217
|
+
if callable(picture_fn):
|
|
218
|
+
picture = picture_fn()
|
|
219
|
+
if picture is not None:
|
|
220
|
+
entry["picture"] = picture
|
|
221
|
+
# What the device can draw on a lab map. Not in `_declared_metadata`
|
|
222
|
+
# because it is a document rather than a string, and omitted entirely when
|
|
223
|
+
# the driver declares none -- which is every driver that has not opted in.
|
|
224
|
+
artifacts_fn = getattr(device, "map_artifacts", None)
|
|
225
|
+
if callable(artifacts_fn):
|
|
226
|
+
artifacts = artifacts_fn()
|
|
227
|
+
if artifacts:
|
|
228
|
+
entry["map_artifacts"] = artifacts
|
|
229
|
+
# The labware positions the device holds. A document like `map_artifacts`
|
|
230
|
+
# rather than a scalar, and likewise omitted when the driver declares none.
|
|
231
|
+
nests = _default_nests(device)
|
|
232
|
+
if nests:
|
|
233
|
+
entry["default_nests"] = nests
|
|
234
|
+
# Which parts of the device's 3D model move. A document, omitted when unset.
|
|
235
|
+
# Accepts a plain attribute or a method, the same tolerance `default_nests`
|
|
236
|
+
# has: a fixed rig is naturally a literal, and one that depends on a
|
|
237
|
+
# connected device's configuration is naturally computed.
|
|
238
|
+
raw_articulation = getattr(device, "articulation", None)
|
|
239
|
+
articulation = raw_articulation() if callable(raw_articulation) else raw_articulation
|
|
240
|
+
if isinstance(articulation, Mapping):
|
|
241
|
+
entry["articulation"] = dict(cast("Mapping[str, object]", articulation))
|
|
242
|
+
# The demand-gated samplers the driver offers. The supervisor needs the list to
|
|
243
|
+
# know what it may switch on; omitted entirely when there are none, so a
|
|
244
|
+
# driver without samplers describes as it did before they existed.
|
|
245
|
+
channels = _telemetry_channels(device)
|
|
246
|
+
if channels:
|
|
247
|
+
entry["telemetry"] = channels
|
|
248
|
+
return {"devices": [entry]}
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _telemetry_channels(device: Hostable) -> list[dict[str, object]]:
|
|
252
|
+
"""The device's declared telemetry channels, as describe entries.
|
|
253
|
+
|
|
254
|
+
Only the declaration travels here -- what a channel *carries* is the channel's own business and reaches the supervisor as frames, not as a
|
|
255
|
+
schema.
|
|
256
|
+
"""
|
|
257
|
+
specs_fn = getattr(device, "telemetry_specs", None)
|
|
258
|
+
if not callable(specs_fn):
|
|
259
|
+
return []
|
|
260
|
+
specs = cast("dict[str, tuple[str, TelemetryMeta]]", specs_fn())
|
|
261
|
+
return [
|
|
262
|
+
{"channel": meta.channel, "hz": float(meta.hz), "description": meta.description} for _, meta in specs.values()
|
|
263
|
+
]
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _connect_takes_settings(connect: Callable[..., object]) -> bool:
|
|
267
|
+
return any(p.kind != p.VAR_KEYWORD for p in inspect.signature(connect).parameters.values())
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
class _Servicer:
|
|
271
|
+
def __init__(self, device: Hostable, driver_version: str) -> None:
|
|
272
|
+
super().__init__()
|
|
273
|
+
self._device = device
|
|
274
|
+
self._driver_version = driver_version
|
|
275
|
+
self._specs = device.command_specs()
|
|
276
|
+
# execution_id -> the running command task, so an abort can cancel it.
|
|
277
|
+
self._running: dict[str, asyncio.Task[None]] = {}
|
|
278
|
+
specs_fn = getattr(device, "telemetry_specs", None)
|
|
279
|
+
self._telemetry: dict[str, tuple[str, TelemetryMeta]] = (
|
|
280
|
+
cast("dict[str, tuple[str, TelemetryMeta]]", specs_fn()) if callable(specs_fn) else {}
|
|
281
|
+
)
|
|
282
|
+
# channel -> its running sampler. A channel absent here is switched off,
|
|
283
|
+
# which is the state every channel starts in.
|
|
284
|
+
self._samplers: dict[str, asyncio.Task[None]] = {}
|
|
285
|
+
# The methods whose handlers all take (request_id, params, send); a table
|
|
286
|
+
# rather than more elif branches, so adding one does not grow `handle`.
|
|
287
|
+
self._handlers: dict[
|
|
288
|
+
str, Callable[[object, dict[str, object], Callable[[dict[str, object]], Awaitable[None]]], Awaitable[None]]
|
|
289
|
+
] = {
|
|
290
|
+
"connect": self._connect,
|
|
291
|
+
"set_simulation": self._set_simulation,
|
|
292
|
+
"execute": self._execute,
|
|
293
|
+
"abort": self._abort,
|
|
294
|
+
"describe_options": self._describe_options,
|
|
295
|
+
"estimate_time": self._estimate_time,
|
|
296
|
+
"set_telemetry_demand": self._set_telemetry_demand,
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async def handle(self, frame: dict[str, object], send: Callable[[dict[str, object]], Awaitable[None]]) -> None:
|
|
300
|
+
request_id = frame.get("id")
|
|
301
|
+
method = frame.get("method")
|
|
302
|
+
raw_params = frame.get("params")
|
|
303
|
+
params = cast("dict[str, object]", raw_params) if isinstance(raw_params, dict) else {}
|
|
304
|
+
if method == "handshake":
|
|
305
|
+
await send(
|
|
306
|
+
{
|
|
307
|
+
"id": request_id,
|
|
308
|
+
"type": "reply",
|
|
309
|
+
"result": {
|
|
310
|
+
"driver_protocol_version": PROTOCOL_VERSION,
|
|
311
|
+
"driver_id": self._device.device_type_id,
|
|
312
|
+
"driver_version": self._driver_version,
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
elif method == "describe":
|
|
317
|
+
await send({"id": request_id, "type": "reply", "result": _describe(self._device)})
|
|
318
|
+
elif method == "ping":
|
|
319
|
+
await send({"id": request_id, "type": "reply", "result": {"ok": True}})
|
|
320
|
+
elif (handler := self._handlers.get(str(method))) is not None:
|
|
321
|
+
await handler(request_id, params, send)
|
|
322
|
+
else:
|
|
323
|
+
await send(
|
|
324
|
+
{
|
|
325
|
+
"id": request_id,
|
|
326
|
+
"type": "error",
|
|
327
|
+
"code": "unknown_method",
|
|
328
|
+
"message": str(method),
|
|
329
|
+
}
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
async def _connect(
|
|
333
|
+
self,
|
|
334
|
+
request_id: object,
|
|
335
|
+
params: dict[str, object],
|
|
336
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
337
|
+
) -> None:
|
|
338
|
+
raw_settings = params.get("settings")
|
|
339
|
+
settings = cast("dict[str, object]", raw_settings) if isinstance(raw_settings, dict) else None
|
|
340
|
+
connect = cast("Callable[..., Awaitable[None]]", self._device.connect)
|
|
341
|
+
try:
|
|
342
|
+
if "is_simulated" in params:
|
|
343
|
+
await self._apply_simulation(enabled=bool(params["is_simulated"]))
|
|
344
|
+
if binds_settings(connect):
|
|
345
|
+
await connect(**bind_settings(connect, settings))
|
|
346
|
+
elif _connect_takes_settings(connect):
|
|
347
|
+
await connect(settings)
|
|
348
|
+
else:
|
|
349
|
+
await connect()
|
|
350
|
+
except Exception as exc: # noqa: BLE001
|
|
351
|
+
await send(
|
|
352
|
+
{
|
|
353
|
+
"id": request_id,
|
|
354
|
+
"type": "reply",
|
|
355
|
+
"result": {"connected": False, "message": str(exc)},
|
|
356
|
+
}
|
|
357
|
+
)
|
|
358
|
+
return
|
|
359
|
+
await send(
|
|
360
|
+
{
|
|
361
|
+
"id": request_id,
|
|
362
|
+
"type": "reply",
|
|
363
|
+
"result": {"connected": True, "message": "connected"},
|
|
364
|
+
}
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
async def _apply_simulation(self, *, enabled: bool) -> None:
|
|
368
|
+
setter = getattr(self._device, "set_simulation", None)
|
|
369
|
+
if callable(setter):
|
|
370
|
+
await cast("Awaitable[None]", setter(enabled=enabled))
|
|
371
|
+
|
|
372
|
+
async def _set_simulation(
|
|
373
|
+
self,
|
|
374
|
+
request_id: object,
|
|
375
|
+
params: dict[str, object],
|
|
376
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
377
|
+
) -> None:
|
|
378
|
+
await self._apply_simulation(enabled=bool(params.get("enabled", False)))
|
|
379
|
+
await send({"id": request_id, "type": "reply", "result": {"ok": True}})
|
|
380
|
+
|
|
381
|
+
async def _execute( # noqa: C901
|
|
382
|
+
self,
|
|
383
|
+
request_id: object,
|
|
384
|
+
params: dict[str, object],
|
|
385
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
386
|
+
) -> None:
|
|
387
|
+
command = str(params.get("command", ""))
|
|
388
|
+
execution_id = str(params.get("execution_id", ""))
|
|
389
|
+
spec = self._specs.get(command)
|
|
390
|
+
if spec is None:
|
|
391
|
+
await send({"id": request_id, "type": "error", "code": "unknown_command", "message": command})
|
|
392
|
+
return
|
|
393
|
+
method_name, meta = spec
|
|
394
|
+
method = getattr(self._device, method_name)
|
|
395
|
+
kwargs = dict(json.loads(str(params.get("params_json") or "{}")))
|
|
396
|
+
# Callers key parameters by declared (display) name; `maps_to` names the
|
|
397
|
+
# method's actual keyword, so remap where declared.
|
|
398
|
+
for param in meta.inputs:
|
|
399
|
+
if param.maps_to and param.name in kwargs:
|
|
400
|
+
kwargs[param.maps_to] = kwargs.pop(param.name)
|
|
401
|
+
|
|
402
|
+
queue: asyncio.Queue[tuple[object, ...]] = asyncio.Queue()
|
|
403
|
+
|
|
404
|
+
async def progress(fraction: float, message: str = "") -> None:
|
|
405
|
+
await queue.put(("progress", fraction, message))
|
|
406
|
+
|
|
407
|
+
if "progress" in inspect.signature(method).parameters:
|
|
408
|
+
kwargs["progress"] = progress
|
|
409
|
+
|
|
410
|
+
async def run() -> None:
|
|
411
|
+
try:
|
|
412
|
+
result = await method(**kwargs)
|
|
413
|
+
validate_outputs(meta, result)
|
|
414
|
+
await queue.put(("result", result))
|
|
415
|
+
except asyncio.CancelledError:
|
|
416
|
+
await queue.put(("error", "aborted", "command aborted"))
|
|
417
|
+
except OutputValidationError as exc:
|
|
418
|
+
logger.exception("command %s output validation failed", command)
|
|
419
|
+
await queue.put(("error", "output_validation", str(exc)))
|
|
420
|
+
except Exception as exc:
|
|
421
|
+
logger.exception("command %s failed", command)
|
|
422
|
+
await queue.put(("error", "command_error", str(exc)))
|
|
423
|
+
|
|
424
|
+
task = asyncio.create_task(run())
|
|
425
|
+
if execution_id:
|
|
426
|
+
self._running[execution_id] = task
|
|
427
|
+
try:
|
|
428
|
+
while True:
|
|
429
|
+
item = await queue.get()
|
|
430
|
+
kind = item[0]
|
|
431
|
+
if kind == "progress":
|
|
432
|
+
await send(
|
|
433
|
+
{
|
|
434
|
+
"id": request_id,
|
|
435
|
+
"type": "progress",
|
|
436
|
+
"fraction": item[1],
|
|
437
|
+
"message": item[2],
|
|
438
|
+
}
|
|
439
|
+
)
|
|
440
|
+
elif kind == "result":
|
|
441
|
+
await send(
|
|
442
|
+
{
|
|
443
|
+
"id": request_id,
|
|
444
|
+
"type": "result",
|
|
445
|
+
"output_json": json.dumps(item[1]),
|
|
446
|
+
}
|
|
447
|
+
)
|
|
448
|
+
return
|
|
449
|
+
else:
|
|
450
|
+
await send(
|
|
451
|
+
{
|
|
452
|
+
"id": request_id,
|
|
453
|
+
"type": "error",
|
|
454
|
+
"code": item[1],
|
|
455
|
+
"message": item[2],
|
|
456
|
+
}
|
|
457
|
+
)
|
|
458
|
+
return
|
|
459
|
+
finally:
|
|
460
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
461
|
+
await task
|
|
462
|
+
if execution_id:
|
|
463
|
+
_ = self._running.pop(execution_id, None)
|
|
464
|
+
|
|
465
|
+
async def _describe_options(
|
|
466
|
+
self,
|
|
467
|
+
request_id: object,
|
|
468
|
+
params: dict[str, object],
|
|
469
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
470
|
+
) -> None:
|
|
471
|
+
"""Compute runtime choices for a command's dynamic-options parameters.
|
|
472
|
+
|
|
473
|
+
Best-effort per parameter: a missing or raising hook yields `[]` for that
|
|
474
|
+
parameter rather than an error frame, matching the operator-facing
|
|
475
|
+
contract (the dropdown just comes up empty).
|
|
476
|
+
"""
|
|
477
|
+
command = str(params.get("command", ""))
|
|
478
|
+
raw = params.get("parameters")
|
|
479
|
+
parameters = cast("dict[str, object]", raw) if isinstance(raw, dict) else {}
|
|
480
|
+
spec = self._specs.get(command)
|
|
481
|
+
if spec is None:
|
|
482
|
+
await send({"id": request_id, "type": "error", "code": "unknown_command", "message": command})
|
|
483
|
+
return
|
|
484
|
+
_, meta = spec
|
|
485
|
+
hook = getattr(self._device, "get_dynamic_parameter_options", None)
|
|
486
|
+
options: dict[str, list[str]] = {}
|
|
487
|
+
for param in meta.inputs:
|
|
488
|
+
if not param.dynamic_options:
|
|
489
|
+
continue
|
|
490
|
+
values: list[str] = []
|
|
491
|
+
if callable(hook):
|
|
492
|
+
try:
|
|
493
|
+
values = [str(v) for v in hook(command, param.name, parameters)]
|
|
494
|
+
except Exception: # a driver's options hook must not fail the request
|
|
495
|
+
logger.exception("dynamic options hook failed for %s / %s", command, param.name)
|
|
496
|
+
options[param.name] = values
|
|
497
|
+
await send({"id": request_id, "type": "reply", "result": {"options": options}})
|
|
498
|
+
|
|
499
|
+
async def _estimate_time(
|
|
500
|
+
self,
|
|
501
|
+
request_id: object,
|
|
502
|
+
params: dict[str, object],
|
|
503
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Estimate one call's duration through the device's `get_dynamic_simulation_time_sec`.
|
|
506
|
+
|
|
507
|
+
Best-effort like `_describe_options`: a missing hook, or one that raises
|
|
508
|
+
or answers something that is not a finite non-negative number, yields the
|
|
509
|
+
command's declared `default_sim_sec` rather than an error frame. The hook
|
|
510
|
+
may be a coroutine. `estimated_duration_sec` is null when there is
|
|
511
|
+
neither, and the supervisor then uses its own default.
|
|
512
|
+
"""
|
|
513
|
+
command = str(params.get("command", ""))
|
|
514
|
+
raw = params.get("parameters")
|
|
515
|
+
parameters = cast("dict[str, object]", raw) if isinstance(raw, dict) else {}
|
|
516
|
+
spec = self._specs.get(command)
|
|
517
|
+
if spec is None:
|
|
518
|
+
await send({"id": request_id, "type": "error", "code": "unknown_command", "message": command})
|
|
519
|
+
return
|
|
520
|
+
_, meta = spec
|
|
521
|
+
estimate: float | None = meta.default_sim_sec
|
|
522
|
+
hook = getattr(self._device, "get_dynamic_simulation_time_sec", None)
|
|
523
|
+
if callable(hook):
|
|
524
|
+
try:
|
|
525
|
+
answer = hook(command, parameters)
|
|
526
|
+
if inspect.isawaitable(answer):
|
|
527
|
+
answer = await answer
|
|
528
|
+
if (seconds := valid_duration(answer)) is not None:
|
|
529
|
+
estimate = seconds
|
|
530
|
+
except Exception: # an estimate must never fail the request
|
|
531
|
+
logger.exception("simulation time hook failed for %s", command)
|
|
532
|
+
await send({"id": request_id, "type": "reply", "result": {"estimated_duration_sec": estimate}})
|
|
533
|
+
|
|
534
|
+
async def _set_telemetry_demand(
|
|
535
|
+
self,
|
|
536
|
+
request_id: object,
|
|
537
|
+
params: dict[str, object],
|
|
538
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
539
|
+
) -> None:
|
|
540
|
+
"""Switch samplers on and off to match the supervisor's demand.
|
|
541
|
+
|
|
542
|
+
`params["channels"]` is the complete set that should be running, not a
|
|
543
|
+
delta: the supervisor knows who is watching and this driver does not, so
|
|
544
|
+
restating the whole set means a lost or reordered frame cannot leave a
|
|
545
|
+
sampler stuck on. An unknown channel is ignored rather than refused --
|
|
546
|
+
a supervisor rolled ahead of this driver should not get an error frame for
|
|
547
|
+
asking about something harmless.
|
|
548
|
+
|
|
549
|
+
Args:
|
|
550
|
+
request_id: The request to reply to.
|
|
551
|
+
params: Carries `channels`, the set that should be running.
|
|
552
|
+
send: Frame sink.
|
|
553
|
+
"""
|
|
554
|
+
raw = params.get("channels")
|
|
555
|
+
wanted = {str(c) for c in raw} if isinstance(raw, (list, tuple, set)) else set()
|
|
556
|
+
wanted &= set(self._telemetry)
|
|
557
|
+
for channel in list(self._samplers):
|
|
558
|
+
if channel not in wanted:
|
|
559
|
+
self._stop_sampler(channel)
|
|
560
|
+
for channel in wanted:
|
|
561
|
+
if channel not in self._samplers:
|
|
562
|
+
self._start_sampler(channel, send)
|
|
563
|
+
await send({"id": request_id, "type": "reply", "result": {"channels": sorted(self._samplers)}})
|
|
564
|
+
|
|
565
|
+
def _start_sampler(self, channel: str, send: Callable[[dict[str, object]], Awaitable[None]]) -> None:
|
|
566
|
+
"""Begin sampling one channel until it is switched off or the loop ends."""
|
|
567
|
+
attr, meta = self._telemetry[channel]
|
|
568
|
+
task = asyncio.create_task(self._sample_loop(channel, attr, meta, send))
|
|
569
|
+
self._samplers[channel] = task
|
|
570
|
+
task.add_done_callback(lambda _t, c=channel: self._samplers.pop(c, None) and None)
|
|
571
|
+
|
|
572
|
+
def _stop_sampler(self, channel: str) -> None:
|
|
573
|
+
"""Cancel one channel's sampler, if it is running."""
|
|
574
|
+
task = self._samplers.pop(channel, None)
|
|
575
|
+
if task is not None:
|
|
576
|
+
_ = task.cancel()
|
|
577
|
+
|
|
578
|
+
async def _sample_loop(
|
|
579
|
+
self,
|
|
580
|
+
channel: str,
|
|
581
|
+
attr: str,
|
|
582
|
+
meta: TelemetryMeta,
|
|
583
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
584
|
+
) -> None:
|
|
585
|
+
"""Sample one channel on its cadence and push un-correlated frames.
|
|
586
|
+
|
|
587
|
+
Two failure rules, both chosen so a stream degrades rather than ends: a
|
|
588
|
+
sampler that raises is logged and retried on the next tick, and a
|
|
589
|
+
sampler slower than its own period simply runs less often. Neither a
|
|
590
|
+
flaky read nor a slow device should take the channel down, because
|
|
591
|
+
nothing would ever switch it back on -- demand is edge-triggered from
|
|
592
|
+
the browser, and it has already fired.
|
|
593
|
+
|
|
594
|
+
Args:
|
|
595
|
+
channel: The channel being sampled.
|
|
596
|
+
attr: Attribute name of the decorated method.
|
|
597
|
+
meta: Its declaration, for the cadence.
|
|
598
|
+
send: Frame sink.
|
|
599
|
+
"""
|
|
600
|
+
period = 1.0 / meta.hz if meta.hz > 0 else 0.2
|
|
601
|
+
sampler = cast("Callable[[], Awaitable[object]]", getattr(self._device, attr))
|
|
602
|
+
while True:
|
|
603
|
+
started = time.monotonic()
|
|
604
|
+
try:
|
|
605
|
+
payload = await sampler()
|
|
606
|
+
if isinstance(payload, dict):
|
|
607
|
+
await send({"type": "telemetry", "channel": channel, "payload": payload})
|
|
608
|
+
except asyncio.CancelledError:
|
|
609
|
+
raise
|
|
610
|
+
except Exception: # noqa: BLE001 - a bad read must not end the stream
|
|
611
|
+
logger.warning("telemetry sampler %r failed; retrying next tick", channel, exc_info=True)
|
|
612
|
+
await asyncio.sleep(max(0.0, period - (time.monotonic() - started)))
|
|
613
|
+
|
|
614
|
+
def shutdown_samplers(self) -> None:
|
|
615
|
+
"""Cancel every running sampler.
|
|
616
|
+
|
|
617
|
+
Called when the serve loop exits.
|
|
618
|
+
"""
|
|
619
|
+
for channel in list(self._samplers):
|
|
620
|
+
self._stop_sampler(channel)
|
|
621
|
+
|
|
622
|
+
async def _abort(
|
|
623
|
+
self,
|
|
624
|
+
request_id: object,
|
|
625
|
+
params: dict[str, object],
|
|
626
|
+
send: Callable[[dict[str, object]], Awaitable[None]],
|
|
627
|
+
) -> None:
|
|
628
|
+
execution_id = str(params.get("execution_id", ""))
|
|
629
|
+
task = self._running.get(execution_id)
|
|
630
|
+
if task is not None and not task.done():
|
|
631
|
+
_ = task.cancel()
|
|
632
|
+
# Signal the device to stop hardware (lab Device.abort / an SDK Device override).
|
|
633
|
+
abort = getattr(self._device, "abort", None)
|
|
634
|
+
if callable(abort):
|
|
635
|
+
with contextlib.suppress(Exception):
|
|
636
|
+
_ = await cast("Awaitable[object]", abort("*"))
|
|
637
|
+
await send({"id": request_id, "type": "reply", "result": {"aborted": task is not None}})
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def claim_stdout() -> BinaryIO:
|
|
641
|
+
"""Claim fd 1 for protocol frames.
|
|
642
|
+
|
|
643
|
+
Redirect `sys.stdout` to stderr so device prints/logs cannot corrupt the stream.
|
|
644
|
+
"""
|
|
645
|
+
out: BinaryIO = os.fdopen(os.dup(1), "wb", buffering=0)
|
|
646
|
+
sys.stdout = sys.stderr
|
|
647
|
+
root = logging.getLogger()
|
|
648
|
+
for handler in list(root.handlers):
|
|
649
|
+
root.removeHandler(handler)
|
|
650
|
+
root.addHandler(logging.StreamHandler(sys.stderr))
|
|
651
|
+
return out
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
async def serve(device: Hostable, *, driver_version: str = "0.0.0", out: BinaryIO | None = None) -> None:
|
|
655
|
+
"""Serve the driver over stdio until the supervisor closes stdin (EOF).
|
|
656
|
+
|
|
657
|
+
`out` lets a caller that claimed fd 1 first (e.g. the lab adapter, which also
|
|
658
|
+
runs uvicorn) pass the protocol stream in; standalone drivers leave it None.
|
|
659
|
+
"""
|
|
660
|
+
loop = asyncio.get_running_loop()
|
|
661
|
+
servicer = _Servicer(device, driver_version)
|
|
662
|
+
stream = out if out is not None else claim_stdout()
|
|
663
|
+
# one thread parks on the blocking stdin.readline, one serves writes; 2 is structural, not a knob
|
|
664
|
+
executor = ThreadPoolExecutor(max_workers=2, thread_name_prefix=f"driver-stdio-{device.device_type_id}")
|
|
665
|
+
write_lock = asyncio.Lock()
|
|
666
|
+
stdin: BinaryIO = os.fdopen(os.dup(0), "rb")
|
|
667
|
+
|
|
668
|
+
async def send(frame: dict[str, object]) -> None:
|
|
669
|
+
data = json.dumps(frame).encode("utf-8") + b"\n"
|
|
670
|
+
async with write_lock:
|
|
671
|
+
_ = await loop.run_in_executor(executor, stream.write, data)
|
|
672
|
+
|
|
673
|
+
tasks: set[asyncio.Task[None]] = set()
|
|
674
|
+
try:
|
|
675
|
+
while True:
|
|
676
|
+
line = await loop.run_in_executor(executor, stdin.readline)
|
|
677
|
+
if not line:
|
|
678
|
+
break
|
|
679
|
+
try:
|
|
680
|
+
frame = json.loads(line)
|
|
681
|
+
except json.JSONDecodeError:
|
|
682
|
+
logger.warning("dropping non-JSON line on stdin: %r", line[:80])
|
|
683
|
+
continue
|
|
684
|
+
task = asyncio.create_task(servicer.handle(frame, send))
|
|
685
|
+
tasks.add(task)
|
|
686
|
+
task.add_done_callback(tasks.discard)
|
|
687
|
+
finally:
|
|
688
|
+
logger.info("stdio serve loop exiting on stdin EOF; cancelling %d in-flight command(s)", len(tasks))
|
|
689
|
+
# Samplers are not in `tasks` -- they are un-correlated and outlive the
|
|
690
|
+
# request that started them -- so they need cancelling by name.
|
|
691
|
+
servicer.shutdown_samplers()
|
|
692
|
+
for task in list(tasks):
|
|
693
|
+
_ = task.cancel()
|
|
694
|
+
if tasks:
|
|
695
|
+
_ = await asyncio.gather(*tasks, return_exceptions=True)
|
|
696
|
+
executor.shutdown(wait=False)
|