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,43 @@
|
|
|
1
|
+
"""lab-sync-driver-sdk: the driver-authoring SDK.
|
|
2
|
+
|
|
3
|
+
A driver subclasses `Device`, decorates async methods with `@command` and the
|
|
4
|
+
in/out decorators, and runs `serve(device)` (over stdio). It depends only on
|
|
5
|
+
the shared wire contract (`lab_sync_driver_protocol`), never on the supervisor that
|
|
6
|
+
launches the driver.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from lab_sync_driver_sdk.decorators import OutputValidationError
|
|
10
|
+
from lab_sync_driver_sdk.decorators import bool_in
|
|
11
|
+
from lab_sync_driver_sdk.decorators import bool_out
|
|
12
|
+
from lab_sync_driver_sdk.decorators import command
|
|
13
|
+
from lab_sync_driver_sdk.decorators import float_in
|
|
14
|
+
from lab_sync_driver_sdk.decorators import float_out
|
|
15
|
+
from lab_sync_driver_sdk.decorators import int_in
|
|
16
|
+
from lab_sync_driver_sdk.decorators import int_out
|
|
17
|
+
from lab_sync_driver_sdk.decorators import settings
|
|
18
|
+
from lab_sync_driver_sdk.decorators import string_in
|
|
19
|
+
from lab_sync_driver_sdk.decorators import string_out
|
|
20
|
+
from lab_sync_driver_sdk.decorators import telemetry
|
|
21
|
+
from lab_sync_driver_sdk.device import Device
|
|
22
|
+
from lab_sync_driver_sdk.device import DeviceCategory
|
|
23
|
+
from lab_sync_driver_sdk.device import NestOffset
|
|
24
|
+
from lab_sync_driver_sdk.host import serve
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"Device",
|
|
28
|
+
"DeviceCategory",
|
|
29
|
+
"NestOffset",
|
|
30
|
+
"OutputValidationError",
|
|
31
|
+
"bool_in",
|
|
32
|
+
"bool_out",
|
|
33
|
+
"command",
|
|
34
|
+
"float_in",
|
|
35
|
+
"float_out",
|
|
36
|
+
"int_in",
|
|
37
|
+
"int_out",
|
|
38
|
+
"serve",
|
|
39
|
+
"settings",
|
|
40
|
+
"string_in",
|
|
41
|
+
"string_out",
|
|
42
|
+
"telemetry",
|
|
43
|
+
]
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""Driver authoring decorators.
|
|
2
|
+
|
|
3
|
+
A driver author annotates async methods on a `Device` subclass:
|
|
4
|
+
|
|
5
|
+
@command("Echo", "Echo the message")
|
|
6
|
+
@string_in("message", "text to echo")
|
|
7
|
+
@string_out("message", "echoed text")
|
|
8
|
+
async def echo(self, message: str = "") -> dict[str, object]:
|
|
9
|
+
return {"message": message}
|
|
10
|
+
|
|
11
|
+
Decorators accumulate a CommandMeta on the function; the host runtime
|
|
12
|
+
(`lab_sync_driver_sdk.host`) builds the Describe message from it and validates outputs.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from dataclasses import field
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
from typing import TypeVar
|
|
22
|
+
|
|
23
|
+
F = TypeVar("F", bound=Callable[..., object])
|
|
24
|
+
|
|
25
|
+
# Wire type names (match the protocol ParamDescriptor.type and the SDK surface).
|
|
26
|
+
TYPE_STRING = "string"
|
|
27
|
+
TYPE_INT = "int"
|
|
28
|
+
TYPE_FLOAT = "float"
|
|
29
|
+
TYPE_BOOL = "bool"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Param:
|
|
34
|
+
name: str
|
|
35
|
+
description: str
|
|
36
|
+
type: str
|
|
37
|
+
options: list[str] | None = None # constrained-to-options choices, when the param is an enum
|
|
38
|
+
dynamic_options: bool = False # options are computed by the driver at runtime (see describe-options)
|
|
39
|
+
maps_to: str | None = None
|
|
40
|
+
default: object = None
|
|
41
|
+
min_value: float | None = None
|
|
42
|
+
max_value: float | None = None
|
|
43
|
+
string_type: str | None = None
|
|
44
|
+
type_details: str | None = None
|
|
45
|
+
allow_empty: bool = False
|
|
46
|
+
group: str = ""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class CommandMeta:
|
|
51
|
+
name: str = ""
|
|
52
|
+
description: str = ""
|
|
53
|
+
inputs: list[Param] = field(default_factory=list[Param])
|
|
54
|
+
outputs: list[Param] = field(default_factory=list[Param])
|
|
55
|
+
exclusive: bool = True
|
|
56
|
+
concurrent: bool = False
|
|
57
|
+
category: str = "" # display grouping in operator UIs; "" lands in the default group
|
|
58
|
+
operator_visible: bool = True # hidden commands stay callable but are not listed for operators
|
|
59
|
+
default_sim_sec: float | None = None # nominal duration, the UI's progress-bar estimate
|
|
60
|
+
# Workflow phase hint: setup/processing/teardown/maintenance/emergency, or
|
|
61
|
+
# storage_present/storage_receive/storage_store/storage_position/storage_inventory.
|
|
62
|
+
# "" is unset. See `command` for what each phase tells a scheduler.
|
|
63
|
+
operation_phase: str = ""
|
|
64
|
+
# The command simulates itself, so the host must not stand in for it by pacing
|
|
65
|
+
# out `default_sim_sec` and returning typed defaults.
|
|
66
|
+
handles_simulation: bool = False
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class TelemetryMeta:
|
|
71
|
+
"""Declaration of a sampler that runs only while somebody is watching.
|
|
72
|
+
|
|
73
|
+
A telemetry channel is the opposite of a command in every way that matters:
|
|
74
|
+
nobody asks for an individual reading, the frames are un-correlated (they
|
|
75
|
+
carry no request id), and a frame nobody wants should never be produced at
|
|
76
|
+
all. That last part is the reason this exists rather than a driver simply
|
|
77
|
+
publishing on a timer -- sampling usually costs a round trip on the same
|
|
78
|
+
link the device's real work goes over, so the supervisor tells the driver when to
|
|
79
|
+
start and stop, and the default is stopped.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
channel: str = ""
|
|
83
|
+
hz: float = 5.0
|
|
84
|
+
description: str = ""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
_ATTR = "__studio_command__"
|
|
88
|
+
_SETTINGS_ATTR = "__studio_settings__"
|
|
89
|
+
_TELEMETRY_ATTR = "__studio_telemetry__"
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _meta(fn: object) -> CommandMeta:
|
|
93
|
+
meta = getattr(fn, _ATTR, None)
|
|
94
|
+
if not isinstance(meta, CommandMeta):
|
|
95
|
+
meta = CommandMeta()
|
|
96
|
+
setattr(fn, _ATTR, meta)
|
|
97
|
+
return meta
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def get_command_meta(fn: object) -> CommandMeta | None:
|
|
101
|
+
meta = getattr(fn, _ATTR, None)
|
|
102
|
+
return meta if isinstance(meta, CommandMeta) else None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def command( # noqa: PLR0913 - the decorator surface is the API; each field is a distinct declaration
|
|
106
|
+
name: str,
|
|
107
|
+
description: str = "",
|
|
108
|
+
*,
|
|
109
|
+
exclusive: bool = True,
|
|
110
|
+
concurrent: bool = False,
|
|
111
|
+
category: str = "",
|
|
112
|
+
operator_visible: bool = True,
|
|
113
|
+
default_sim_sec: float | None = None,
|
|
114
|
+
operation_phase: str = "",
|
|
115
|
+
handles_simulation: bool = False,
|
|
116
|
+
) -> Callable[[F], F]:
|
|
117
|
+
"""Declare a command.
|
|
118
|
+
|
|
119
|
+
`exclusive` means it needs the device to itself: the supervisor admits it only when no other
|
|
120
|
+
exclusive command is in flight there, so hardware expecting one command at a time is not
|
|
121
|
+
driven twice at once. Clear it for a command that guards no physical resource.
|
|
122
|
+
|
|
123
|
+
`concurrent` allows several invocations of this same command at once, which a stateless
|
|
124
|
+
service device needs when every running sequence routes its steps through one instance.
|
|
125
|
+
|
|
126
|
+
`operation_phase` is a workflow phase hint — setup/processing/teardown/maintenance/emergency,
|
|
127
|
+
plus the storage phases for a storage device that acts on a plate's way in or out itself.
|
|
128
|
+
Leave it unset unless it is true: a scheduler treats every `processing` command as a step a
|
|
129
|
+
plate can be sent to, so marking a command that does not process a plate offers a
|
|
130
|
+
misleading step. A scheduler does not offer the storage phases as steps; it runs them
|
|
131
|
+
itself when a plate leaves or enters storage, each at a fixed point around the robot's pick
|
|
132
|
+
or place:
|
|
133
|
+
|
|
134
|
+
- `storage_present` fetches a plate onto the device's *own* nest, **before** the robot
|
|
135
|
+
picks it on a get.
|
|
136
|
+
- `storage_receive` gets the device ready to take a plate, **before** the robot places it
|
|
137
|
+
on a put — a stacker raising its stack so a plate set into its ring lands on top. Put
|
|
138
|
+
side only: it never runs before a get.
|
|
139
|
+
- `storage_store` files a plate away off the device's nest, **after** the robot placed it
|
|
140
|
+
on a put.
|
|
141
|
+
- `storage_position` moves the storage to the robot (a carousel turning a hotel round),
|
|
142
|
+
first on **both** sides.
|
|
143
|
+
- `storage_inventory` reports which barcode is in each location the supervisor asks about.
|
|
144
|
+
|
|
145
|
+
A put therefore runs `storage_position` -> `storage_receive` -> the robot ->
|
|
146
|
+
`storage_store`, skipping whichever the device does not declare. Pick by *when* the device
|
|
147
|
+
has to act: a command that must run before the robot lets go of the plate is
|
|
148
|
+
`storage_receive`, never `storage_store`.
|
|
149
|
+
|
|
150
|
+
`handles_simulation` says the command does its own simulation. Leave it clear and the host
|
|
151
|
+
stands in for a simulated device by pacing out `default_sim_sec` and returning typed
|
|
152
|
+
defaults; set it and the command body runs even in simulation.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def deco(fn: F) -> F:
|
|
156
|
+
meta = _meta(fn)
|
|
157
|
+
meta.name = name
|
|
158
|
+
meta.description = description
|
|
159
|
+
meta.exclusive = exclusive
|
|
160
|
+
meta.concurrent = concurrent
|
|
161
|
+
meta.category = category
|
|
162
|
+
meta.operator_visible = operator_visible
|
|
163
|
+
meta.default_sim_sec = default_sim_sec
|
|
164
|
+
meta.operation_phase = operation_phase
|
|
165
|
+
meta.handles_simulation = handles_simulation
|
|
166
|
+
return fn
|
|
167
|
+
|
|
168
|
+
return deco
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def get_telemetry_meta(fn: object) -> TelemetryMeta | None:
|
|
172
|
+
"""The telemetry declaration on a method, or None if it has none."""
|
|
173
|
+
meta = getattr(fn, _TELEMETRY_ATTR, None)
|
|
174
|
+
return meta if isinstance(meta, TelemetryMeta) else None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def telemetry(channel: str, *, hz: float = 5.0, description: str = "") -> Callable[[F], F]:
|
|
178
|
+
"""Declare a demand-gated sampler.
|
|
179
|
+
|
|
180
|
+
The decorated method takes no arguments and returns a JSON-serializable
|
|
181
|
+
mapping -- one reading. The host calls it about `hz` times a second, but
|
|
182
|
+
**only while the supervisor says someone is watching**, and never before then: a
|
|
183
|
+
driver that is connected but unobserved samples nothing.
|
|
184
|
+
|
|
185
|
+
That gating is the whole point. Reading a robot's joint angles means a round
|
|
186
|
+
trip on the serial link its motion commands share, so doing it for a page
|
|
187
|
+
nobody has open would steal bandwidth from the hardware. The supervisor
|
|
188
|
+
switches a channel on only while something downstream is subscribed to it,
|
|
189
|
+
so the thing that switches the sampler on is the thing that wants the data.
|
|
190
|
+
|
|
191
|
+
`hz` is a ceiling and a target, not a guarantee: a sampler that takes longer
|
|
192
|
+
than its period simply runs less often, and one that raises is logged and
|
|
193
|
+
retried on the next tick rather than tearing the channel down. Neither a
|
|
194
|
+
slow device nor a flaky read should end the stream.
|
|
195
|
+
|
|
196
|
+
@telemetry("articulation", hz=10)
|
|
197
|
+
async def sample_joints(self) -> dict[str, object]:
|
|
198
|
+
return {"joints": dict(zip(JOINT_IDS, await self.read_joints()))}
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
channel: Channel name, unique on the device. The supervisor publishes
|
|
202
|
+
its readings under this name.
|
|
203
|
+
hz: How often to sample while demand is on.
|
|
204
|
+
description: What the channel carries, for humans reading a describe.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
def deco(fn: F) -> F:
|
|
208
|
+
setattr(fn, _TELEMETRY_ATTR, TelemetryMeta(channel=channel, hz=hz, description=description))
|
|
209
|
+
return fn
|
|
210
|
+
|
|
211
|
+
return deco
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def settings() -> Callable[[F], F]:
|
|
215
|
+
"""Declare the device's connection settings on a method (conventionally `connect`).
|
|
216
|
+
|
|
217
|
+
The input decorators stacked under this one describe the settings form the operator fills in; the host reports them in describe and passes
|
|
218
|
+
the saved values dict into `connect(settings)`. Decorate `connect` itself with one parameter per setting (named by `maps_to`, else the
|
|
219
|
+
display name) and the host binds and coerces them instead -- see `lab_sync_driver_sdk.settings_binding`. A settings method is not a command.
|
|
220
|
+
"""
|
|
221
|
+
|
|
222
|
+
def deco(fn: F) -> F:
|
|
223
|
+
setattr(fn, _SETTINGS_ATTR, True)
|
|
224
|
+
return fn
|
|
225
|
+
|
|
226
|
+
return deco
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def is_settings_method(fn: object) -> bool:
|
|
230
|
+
return bool(getattr(fn, _SETTINGS_ATTR, False))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _InputFactory(Protocol):
|
|
234
|
+
def __call__( # noqa: PLR0913 - mirrors the input-decorator surface
|
|
235
|
+
self,
|
|
236
|
+
name: str,
|
|
237
|
+
description: str = "",
|
|
238
|
+
*,
|
|
239
|
+
default: object = None,
|
|
240
|
+
maps_to: str | None = None,
|
|
241
|
+
min_value: float | None = None,
|
|
242
|
+
max_value: float | None = None,
|
|
243
|
+
options: list[str] | None = None,
|
|
244
|
+
dynamic_options: bool = False,
|
|
245
|
+
string_type: str | None = None,
|
|
246
|
+
type_details: str | None = None,
|
|
247
|
+
allow_empty: bool = False,
|
|
248
|
+
group: str = "",
|
|
249
|
+
) -> Callable[[F], F]: ...
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _input(type_name: str) -> _InputFactory:
|
|
253
|
+
# insert(0) so that, since decorators apply bottom-up, source order is kept.
|
|
254
|
+
def make( # noqa: PLR0913 - the decorator surface is the API; each field is a distinct declaration
|
|
255
|
+
name: str,
|
|
256
|
+
description: str = "",
|
|
257
|
+
*,
|
|
258
|
+
default: object = None,
|
|
259
|
+
maps_to: str | None = None,
|
|
260
|
+
min_value: float | None = None,
|
|
261
|
+
max_value: float | None = None,
|
|
262
|
+
options: list[str] | None = None,
|
|
263
|
+
dynamic_options: bool = False,
|
|
264
|
+
string_type: str | None = None,
|
|
265
|
+
type_details: str | None = None,
|
|
266
|
+
allow_empty: bool = False,
|
|
267
|
+
group: str = "",
|
|
268
|
+
) -> Callable[[F], F]:
|
|
269
|
+
def deco(fn: F) -> F:
|
|
270
|
+
_meta(fn).inputs.insert(
|
|
271
|
+
0,
|
|
272
|
+
Param(
|
|
273
|
+
name,
|
|
274
|
+
description,
|
|
275
|
+
type_name,
|
|
276
|
+
options=options,
|
|
277
|
+
dynamic_options=dynamic_options,
|
|
278
|
+
maps_to=maps_to,
|
|
279
|
+
default=default,
|
|
280
|
+
min_value=min_value,
|
|
281
|
+
max_value=max_value,
|
|
282
|
+
string_type=string_type,
|
|
283
|
+
type_details=type_details,
|
|
284
|
+
allow_empty=allow_empty,
|
|
285
|
+
group=group,
|
|
286
|
+
),
|
|
287
|
+
)
|
|
288
|
+
return fn
|
|
289
|
+
|
|
290
|
+
return deco
|
|
291
|
+
|
|
292
|
+
return make
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _output(type_name: str) -> Callable[[str, str], Callable[[F], F]]:
|
|
296
|
+
def make(name: str, description: str = "") -> Callable[[F], F]:
|
|
297
|
+
def deco(fn: F) -> F:
|
|
298
|
+
_meta(fn).outputs.insert(0, Param(name, description, type_name))
|
|
299
|
+
return fn
|
|
300
|
+
|
|
301
|
+
return deco
|
|
302
|
+
|
|
303
|
+
return make
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
string_in = _input(TYPE_STRING)
|
|
307
|
+
int_in = _input(TYPE_INT)
|
|
308
|
+
float_in = _input(TYPE_FLOAT)
|
|
309
|
+
bool_in = _input(TYPE_BOOL)
|
|
310
|
+
|
|
311
|
+
string_out = _output(TYPE_STRING)
|
|
312
|
+
int_out = _output(TYPE_INT)
|
|
313
|
+
float_out = _output(TYPE_FLOAT)
|
|
314
|
+
bool_out = _output(TYPE_BOOL)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def param_dict(param: Param) -> dict[str, object]:
|
|
318
|
+
"""Serialize a Param to its wire descriptor (describe inputs / settings entries)."""
|
|
319
|
+
descriptor: dict[str, object] = {"name": param.name, "description": param.description, "type": param.type}
|
|
320
|
+
optional: dict[str, object | None] = {
|
|
321
|
+
"options": param.options,
|
|
322
|
+
"maps_to": param.maps_to,
|
|
323
|
+
"default": param.default,
|
|
324
|
+
"min_value": param.min_value,
|
|
325
|
+
"max_value": param.max_value,
|
|
326
|
+
"string_type": param.string_type,
|
|
327
|
+
"type_details": param.type_details,
|
|
328
|
+
}
|
|
329
|
+
descriptor.update({key: value for key, value in optional.items() if value is not None})
|
|
330
|
+
if param.dynamic_options:
|
|
331
|
+
descriptor["dynamic_options"] = True
|
|
332
|
+
if param.allow_empty:
|
|
333
|
+
descriptor["allow_empty"] = True
|
|
334
|
+
if param.group:
|
|
335
|
+
descriptor["group"] = param.group
|
|
336
|
+
return descriptor
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
class OutputValidationError(Exception):
|
|
340
|
+
"""A command returned fields not declared with an output decorator."""
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def validate_outputs(meta: CommandMeta, result: dict[str, object]) -> None:
|
|
344
|
+
"""Raise `OutputValidationError` if the result has a field no output decorator declared."""
|
|
345
|
+
declared = {p.name for p in meta.outputs}
|
|
346
|
+
extra = set(result) - declared
|
|
347
|
+
if extra:
|
|
348
|
+
msg = f"command '{meta.name}' returned undeclared output fields: {sorted(extra)}"
|
|
349
|
+
raise OutputValidationError(msg)
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The Device base class a driver subclasses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from enum import StrEnum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import ClassVar
|
|
13
|
+
|
|
14
|
+
from lab_sync_driver_protocol import CONFIGURATION_ENV
|
|
15
|
+
|
|
16
|
+
from lab_sync_driver_sdk.decorators import CommandMeta
|
|
17
|
+
from lab_sync_driver_sdk.decorators import TelemetryMeta
|
|
18
|
+
from lab_sync_driver_sdk.decorators import get_command_meta
|
|
19
|
+
from lab_sync_driver_sdk.decorators import get_telemetry_meta
|
|
20
|
+
from lab_sync_driver_sdk.decorators import is_settings_method
|
|
21
|
+
from lab_sync_driver_sdk.decorators import param_dict
|
|
22
|
+
from lab_sync_driver_sdk.settings_binding import described_settings
|
|
23
|
+
|
|
24
|
+
# How a driver may write one nest's offset in `Device.default_nests`: an
|
|
25
|
+
# (x, y, z) sequence, an {"x": .., "y": .., "z": ..} mapping, or None when the
|
|
26
|
+
# position is not known yet. `lab_sync_driver_sdk.host` normalizes all three onto the
|
|
27
|
+
# wire, so a driver author picks whichever reads best.
|
|
28
|
+
type NestOffset = Sequence[float] | Mapping[str, float] | None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class DeviceCategory(StrEnum):
|
|
32
|
+
"""What a device is for.
|
|
33
|
+
|
|
34
|
+
A scheduler reads it to find the plate handler that loads and unloads every
|
|
35
|
+
other device — hence `PLATE_HANDLING` rather than a generic "robot".
|
|
36
|
+
|
|
37
|
+
The value is carried as a plain string end to end and never validated
|
|
38
|
+
against this enum, so an out-of-tree driver declaring something else keeps
|
|
39
|
+
working; it simply matches nothing that looks a category up.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
SEALING_PEELING = "sealing_peeling"
|
|
43
|
+
CAPPING_DECAPPING = "capping_decapping"
|
|
44
|
+
LABELING = "labeling"
|
|
45
|
+
PCR = "pcr"
|
|
46
|
+
LIQUID_HANDLING = "liquid_handling"
|
|
47
|
+
BARCODE_READING = "barcode_reading"
|
|
48
|
+
LABEL_PRINTING = "label_printing"
|
|
49
|
+
PLATE_HANDLING = "plate_handling"
|
|
50
|
+
MOBILE_ROBOT = "mobile_robot"
|
|
51
|
+
TRANSPORT = "transport"
|
|
52
|
+
STORAGE = "storage"
|
|
53
|
+
SHAKING = "shaking"
|
|
54
|
+
DETECTION = "detection"
|
|
55
|
+
SENSOR = "sensor"
|
|
56
|
+
ACTUATOR = "actuator"
|
|
57
|
+
MISC = "misc"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Device:
|
|
61
|
+
device_type_id: str = ""
|
|
62
|
+
manufacturer: str = ""
|
|
63
|
+
model: str = ""
|
|
64
|
+
category: str = DeviceCategory.MISC
|
|
65
|
+
is_simulated: bool = False
|
|
66
|
+
# Plain-language summary of what this device does. Describes only when set.
|
|
67
|
+
description: str = ""
|
|
68
|
+
# How the driver reaches its hardware (serial, tcp, sila, ...). Describes only when set.
|
|
69
|
+
integration_mode: str = ""
|
|
70
|
+
# Physical envelope in millimetres — right-handed, +Z up — for the 3D layout
|
|
71
|
+
# view. Leave any axis None and the view falls back to a default cube.
|
|
72
|
+
width_mm: float | None = None
|
|
73
|
+
depth_mm: float | None = None
|
|
74
|
+
height_mm: float | None = None
|
|
75
|
+
# GLB model for the 3D layout view, resolved relative to the directory of the
|
|
76
|
+
# module defining the driver subclass (an absolute path is also accepted).
|
|
77
|
+
# Describes only when set; the view falls back to a box when there is none.
|
|
78
|
+
model_path: str = ""
|
|
79
|
+
# Top-down image of the device for the layout views, resolved like `model_path`
|
|
80
|
+
# (relative to the driver's own directory; an absolute path is also accepted).
|
|
81
|
+
# Describes only when set; without one the supervisor serves a placeholder
|
|
82
|
+
# rectangle proportional to `width_mm` x `depth_mm`.
|
|
83
|
+
top_view_image_path: str = ""
|
|
84
|
+
# The labware positions this device holds, keyed by nest name. The value is
|
|
85
|
+
# the nest's offset from the device's own location in millimetres —
|
|
86
|
+
# right-handed, +Z up, the same frame as `width_mm`/`depth_mm`/`height_mm` —
|
|
87
|
+
# written as a `(x, y, z)` sequence or an `{"x": .., "y": .., "z": ..}` dict,
|
|
88
|
+
# or None for a nest whose position nobody has measured yet.
|
|
89
|
+
#
|
|
90
|
+
# Describes only when non-empty. The supervisor records each entry the
|
|
91
|
+
# first time the device is added, and an operator may reposition it after
|
|
92
|
+
# that — so this is the *default* layout a driver ships, not a live read of
|
|
93
|
+
# where the nests are now.
|
|
94
|
+
# ClassVar because a driver declares its nests on the class, the way it
|
|
95
|
+
# declares its envelope; that also lets a subclass annotate its own literal
|
|
96
|
+
# as a ClassVar, which the mutable-class-attribute lint requires of a dict.
|
|
97
|
+
default_nests: ClassVar[Mapping[str, NestOffset] | None] = None
|
|
98
|
+
# Hardware variants this one driver serves -- a robot with or without an
|
|
99
|
+
# optional rail, an instrument with a different deck -- when they change how
|
|
100
|
+
# the driver behaves without being a different device type. The first entry is
|
|
101
|
+
# the default. Describes only when non-empty; the operator then picks one when
|
|
102
|
+
# adding the device and the supervisor hands it back as `configuration`.
|
|
103
|
+
configurations: ClassVar[Sequence[str]] = ()
|
|
104
|
+
|
|
105
|
+
@property
|
|
106
|
+
def configuration(self) -> str | None:
|
|
107
|
+
"""The variant this instance was added as: one of `configurations`, or None when the driver declares none.
|
|
108
|
+
|
|
109
|
+
The supervisor passes it in the process environment; unset (a device added
|
|
110
|
+
before the driver declared any) or unknown reads as the first.
|
|
111
|
+
"""
|
|
112
|
+
if not self.configurations:
|
|
113
|
+
return None
|
|
114
|
+
chosen = os.environ.get(CONFIGURATION_ENV, "")
|
|
115
|
+
return chosen if chosen in self.configurations else self.configurations[0]
|
|
116
|
+
|
|
117
|
+
def agent_context(self) -> str:
|
|
118
|
+
"""Return the long-form brief automated consumers read, or "" when there is none.
|
|
119
|
+
|
|
120
|
+
The default reads `agent_context.md` from the directory of the module that
|
|
121
|
+
defines the driver subclass, so each driver ships its own; the base class
|
|
122
|
+
has none and answers "".
|
|
123
|
+
"""
|
|
124
|
+
module = sys.modules.get(type(self).__module__)
|
|
125
|
+
source = getattr(module, "__file__", None)
|
|
126
|
+
if source is None:
|
|
127
|
+
return ""
|
|
128
|
+
context_file = Path(source).parent / "agent_context.md"
|
|
129
|
+
return context_file.read_text(encoding="utf-8") if context_file.is_file() else ""
|
|
130
|
+
|
|
131
|
+
async def connect(self, *args: Any, **kwargs: Any) -> None: # noqa: ANN401 - gradual, so any override shape fits
|
|
132
|
+
"""Open the device session.
|
|
133
|
+
|
|
134
|
+
Override as needed, in one of three shapes: no parameters;
|
|
135
|
+
`connect(self, settings)`, which receives the saved settings dict keyed by
|
|
136
|
+
display name; or `@settings()` on `connect` itself with one parameter per
|
|
137
|
+
setting, which the host binds and coerces (`lab_sync_driver_sdk.settings_binding`).
|
|
138
|
+
"""
|
|
139
|
+
_ = (args, kwargs)
|
|
140
|
+
|
|
141
|
+
async def disconnect(self) -> None:
|
|
142
|
+
"""Close the device session.
|
|
143
|
+
|
|
144
|
+
Override as needed.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
async def set_simulation(self, *, enabled: bool) -> None:
|
|
148
|
+
"""Toggle simulation mode live.
|
|
149
|
+
|
|
150
|
+
Override to open/close a sim backend.
|
|
151
|
+
"""
|
|
152
|
+
self.is_simulated = enabled
|
|
153
|
+
|
|
154
|
+
def get_dynamic_parameter_options(
|
|
155
|
+
self,
|
|
156
|
+
command_name: str,
|
|
157
|
+
parameter_name: str,
|
|
158
|
+
parameters: dict[str, object] | None = None,
|
|
159
|
+
) -> list[str]:
|
|
160
|
+
"""Compute runtime choices for a `dynamic_options=True` string parameter.
|
|
161
|
+
|
|
162
|
+
`parameters` carries the current values of all of the command's inputs, so
|
|
163
|
+
one parameter's options can depend on another. Keep it cheap (called on
|
|
164
|
+
every operator keystroke, debounced) and best-effort: a raised exception
|
|
165
|
+
yields `[]` for that parameter rather than failing the request.
|
|
166
|
+
"""
|
|
167
|
+
_ = (command_name, parameter_name, parameters)
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
def get_dynamic_simulation_time_sec(self, command_name: str, parameters: dict[str, object]) -> float | None:
|
|
171
|
+
"""Estimate how long one call of a command will take, from its inputs.
|
|
172
|
+
|
|
173
|
+
The supervisor asks before it runs a command (and from its estimated-time
|
|
174
|
+
route) and records the answer as the call's `estimated_duration_sec`, the
|
|
175
|
+
UI's progress-bar estimate. Override it when the duration depends on the
|
|
176
|
+
inputs or on the device's state -- a spin's time and speed, a move's
|
|
177
|
+
distance -- rather than being one number per command. `parameters` is
|
|
178
|
+
keyed by the inputs' declared (display) names, as an operator form holds
|
|
179
|
+
them. The default answers the command's `default_sim_sec`, or None when
|
|
180
|
+
it declares none; a raised exception falls back to that default too.
|
|
181
|
+
|
|
182
|
+
The name and signature match lab-device-sdk's, so a driver ported from it
|
|
183
|
+
keeps its override.
|
|
184
|
+
"""
|
|
185
|
+
_ = parameters
|
|
186
|
+
spec = type(self).command_specs().get(command_name)
|
|
187
|
+
return spec[1].default_sim_sec if spec is not None else None
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def command_specs(cls) -> dict[str, tuple[str, CommandMeta]]:
|
|
191
|
+
"""Map command name -> (method attribute name, metadata)."""
|
|
192
|
+
specs: dict[str, tuple[str, CommandMeta]] = {}
|
|
193
|
+
for attr in dir(cls):
|
|
194
|
+
fn = getattr(cls, attr, None)
|
|
195
|
+
meta = get_command_meta(fn) if fn is not None else None
|
|
196
|
+
if meta is None:
|
|
197
|
+
continue
|
|
198
|
+
if not meta.name:
|
|
199
|
+
if is_settings_method(fn):
|
|
200
|
+
continue # a @settings method declares inputs but is not a command
|
|
201
|
+
if meta.inputs or meta.outputs:
|
|
202
|
+
msg = f"{cls.__name__}.{attr} declares command params but is missing @command"
|
|
203
|
+
raise ValueError(msg)
|
|
204
|
+
continue
|
|
205
|
+
specs[meta.name] = (attr, meta)
|
|
206
|
+
return specs
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def telemetry_specs(cls) -> dict[str, tuple[str, TelemetryMeta]]:
|
|
210
|
+
"""Map telemetry channel -> (method attribute name, metadata).
|
|
211
|
+
|
|
212
|
+
Empty for every driver that declares no `@telemetry` sampler, which is
|
|
213
|
+
all of them until one opts in -- and an empty mapping is omitted from
|
|
214
|
+
`describe` entirely, so such a driver describes exactly as it did
|
|
215
|
+
before telemetry existed.
|
|
216
|
+
"""
|
|
217
|
+
specs: dict[str, tuple[str, TelemetryMeta]] = {}
|
|
218
|
+
for attr in dir(cls):
|
|
219
|
+
fn = getattr(cls, attr, None)
|
|
220
|
+
meta = get_telemetry_meta(fn) if fn is not None else None
|
|
221
|
+
if meta is None or not meta.channel:
|
|
222
|
+
continue
|
|
223
|
+
specs[meta.channel] = (attr, meta)
|
|
224
|
+
return specs
|
|
225
|
+
|
|
226
|
+
@classmethod
|
|
227
|
+
def settings_specs(cls) -> list[dict[str, object]]:
|
|
228
|
+
"""Serialized settings descriptors from the `@settings`-decorated method, if any."""
|
|
229
|
+
for attr in dir(cls):
|
|
230
|
+
fn = getattr(cls, attr, None)
|
|
231
|
+
if fn is None or not is_settings_method(fn):
|
|
232
|
+
continue
|
|
233
|
+
return [param_dict(p) for p in described_settings(fn)]
|
|
234
|
+
return []
|