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,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 []