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.
@@ -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)