lab-sync-driver-sdk 0.3.0__tar.gz
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-0.3.0/LICENSE +21 -0
- lab_sync_driver_sdk-0.3.0/MANIFEST.in +2 -0
- lab_sync_driver_sdk-0.3.0/PKG-INFO +144 -0
- lab_sync_driver_sdk-0.3.0/README.md +121 -0
- lab_sync_driver_sdk-0.3.0/__init__.py +43 -0
- lab_sync_driver_sdk-0.3.0/decorators.py +349 -0
- lab_sync_driver_sdk-0.3.0/device.py +234 -0
- lab_sync_driver_sdk-0.3.0/host.py +696 -0
- lab_sync_driver_sdk-0.3.0/lab_host.py +543 -0
- lab_sync_driver_sdk-0.3.0/lab_sync_driver_sdk.egg-info/PKG-INFO +144 -0
- lab_sync_driver_sdk-0.3.0/lab_sync_driver_sdk.egg-info/SOURCES.txt +25 -0
- lab_sync_driver_sdk-0.3.0/lab_sync_driver_sdk.egg-info/dependency_links.txt +1 -0
- lab_sync_driver_sdk-0.3.0/lab_sync_driver_sdk.egg-info/requires.txt +6 -0
- lab_sync_driver_sdk-0.3.0/lab_sync_driver_sdk.egg-info/top_level.txt +1 -0
- lab_sync_driver_sdk-0.3.0/py.typed +0 -0
- lab_sync_driver_sdk-0.3.0/pyproject.toml +50 -0
- lab_sync_driver_sdk-0.3.0/settings_binding.py +162 -0
- lab_sync_driver_sdk-0.3.0/setup.cfg +4 -0
- lab_sync_driver_sdk-0.3.0/testing.py +41 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Lab Sync
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lab-sync-driver-sdk
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Public driver-authoring SDK for the device engine: subclass Device, decorate commands, serve() over stdio
|
|
5
|
+
Author: Lab Sync
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
12
|
+
Classifier: Topic :: Scientific/Engineering
|
|
13
|
+
Classifier: Typing :: Typed
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: lab-sync-driver-protocol==0.3.0
|
|
18
|
+
Provides-Extra: lab
|
|
19
|
+
Requires-Dist: lab-device-sdk>=0.1.12; extra == "lab"
|
|
20
|
+
Requires-Dist: fastapi>=0.110; extra == "lab"
|
|
21
|
+
Requires-Dist: uvicorn>=0.30; extra == "lab"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# lab-sync-driver-sdk
|
|
25
|
+
|
|
26
|
+
Write a lab device driver as a decorated Python class and serve it as a
|
|
27
|
+
supervised child process. A driver subclasses `Device`, decorates async
|
|
28
|
+
methods with `@command` and the typed in/out decorators, and calls
|
|
29
|
+
`serve(device)` to speak the stdio wire protocol defined by
|
|
30
|
+
[`lab-sync-driver-protocol`](https://pypi.org/project/lab-sync-driver-protocol/). The SDK depends on
|
|
31
|
+
nothing but that package, so a driver ships with its own runtime and never
|
|
32
|
+
imports the supervisor that launches it.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
pip install lab-sync-driver-sdk
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## A driver
|
|
39
|
+
|
|
40
|
+
```python
|
|
41
|
+
import asyncio
|
|
42
|
+
|
|
43
|
+
from lab_sync_driver_sdk import Device, command, float_in, string_in, string_out, serve
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Greeter(Device):
|
|
47
|
+
device_type_id = "acme-greeter"
|
|
48
|
+
manufacturer = "Acme"
|
|
49
|
+
model = "Greeter 1"
|
|
50
|
+
|
|
51
|
+
@command("Greet", "Say hello", default_sim_sec=1.0)
|
|
52
|
+
@string_in("Name", "who to greet", default="world", maps_to="name")
|
|
53
|
+
@float_in("Delay", "seconds to wait first", default=0.0, min_value=0.0, maps_to="delay")
|
|
54
|
+
@string_out("Greeting", "what was said")
|
|
55
|
+
async def greet(self, name: str = "world", delay: float = 0.0, progress=None) -> dict[str, object]:
|
|
56
|
+
await asyncio.sleep(delay)
|
|
57
|
+
if progress is not None:
|
|
58
|
+
await progress(1.0, "greeted")
|
|
59
|
+
return {"Greeting": f"Hello, {name}!"}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
asyncio.run(serve(Greeter()))
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
The decorators produce the driver's `describe` reply: its commands, their
|
|
67
|
+
inputs and outputs, and any optional metadata the class declares (`category`,
|
|
68
|
+
physical dimensions, `default_nests`, `configurations`, telemetry channels).
|
|
69
|
+
A command returns a dict, and every key in it must be declared with an output
|
|
70
|
+
decorator. An undeclared key fails the command with `OutputValidationError`.
|
|
71
|
+
A command that takes a `progress` parameter is handed an async
|
|
72
|
+
`progress(fraction, message)` reporter.
|
|
73
|
+
|
|
74
|
+
Once `serve` starts, stdout carries protocol frames only. The host points
|
|
75
|
+
`sys.stdout` at stderr, so a stray `print` lands on the log channel rather
|
|
76
|
+
than corrupting a frame.
|
|
77
|
+
|
|
78
|
+
## Testing a driver
|
|
79
|
+
|
|
80
|
+
`lab_sync_driver_sdk.testing.run_command` runs one command in-process, applying the
|
|
81
|
+
same output validation the host applies:
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
from lab_sync_driver_sdk.testing import run_command
|
|
85
|
+
|
|
86
|
+
result = await run_command(Greeter(), "Greet", {"name": "lab"})
|
|
87
|
+
assert result == {"Greeting": "Hello, lab!"}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Connection settings
|
|
91
|
+
|
|
92
|
+
`@settings()` plus the typed input decorators declare the form an operator fills
|
|
93
|
+
in. `connect` receives the saved values in one of two shapes:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
# The dict, keyed by display name, beside a carrier that holds the form.
|
|
97
|
+
@settings()
|
|
98
|
+
@string_in("Host", "device address", default="10.0.0.1")
|
|
99
|
+
async def connection_settings(self) -> None: ...
|
|
100
|
+
|
|
101
|
+
async def connect(self, settings: dict[str, Any] | None = None) -> None:
|
|
102
|
+
host = str((settings or {}).get("Host", "10.0.0.1"))
|
|
103
|
+
|
|
104
|
+
# Or one parameter per setting, with @settings() on connect itself.
|
|
105
|
+
@settings()
|
|
106
|
+
@string_in("Host", "device address", default="10.0.0.1", maps_to="host")
|
|
107
|
+
@int_in("Port", "device port", default=4321, maps_to="port")
|
|
108
|
+
async def connect(self, host: str, port: int) -> None: ...
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
In the second shape the host binds each parameter by `maps_to` (else the
|
|
112
|
+
display name). The value comes from the saved setting, else the declared
|
|
113
|
+
default, else the signature's own default, and is coerced to the declared type,
|
|
114
|
+
so `connect` receives an `int` even when the value was saved as text. A setting
|
|
115
|
+
with no matching parameter, or a required parameter with no setting, fails the
|
|
116
|
+
connect with a message naming it. See `lab_sync_driver_sdk.settings_binding`.
|
|
117
|
+
|
|
118
|
+
## Hosting a lab-device-sdk driver
|
|
119
|
+
|
|
120
|
+
`lab_sync_driver_sdk.lab_host` serves a driver written against
|
|
121
|
+
[`lab-device-sdk`](https://pypi.org/project/lab-device-sdk/) over the same
|
|
122
|
+
protocol, unchanged. It needs the `lab` extra, which also brings in FastAPI
|
|
123
|
+
and uvicorn for drivers that serve HTTP routes of their own:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pip install "lab-sync-driver-sdk[lab]"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
It hosts one device per process, so it has to be told which instance it is
|
|
130
|
+
running. A driver manifest declares that with the supervisor's `{device_id}`
|
|
131
|
+
token rather than a hardcoded id, which is what lets a site run several
|
|
132
|
+
devices of the same type:
|
|
133
|
+
|
|
134
|
+
```toml
|
|
135
|
+
[launch]
|
|
136
|
+
type = "command"
|
|
137
|
+
command = ["python", "-m", "lab_sync_driver_sdk.lab_host",
|
|
138
|
+
"--device", "my_pkg.my_device:MyDevice",
|
|
139
|
+
"--device-id", "{device_id}"]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# lab-sync-driver-sdk
|
|
2
|
+
|
|
3
|
+
Write a lab device driver as a decorated Python class and serve it as a
|
|
4
|
+
supervised child process. A driver subclasses `Device`, decorates async
|
|
5
|
+
methods with `@command` and the typed in/out decorators, and calls
|
|
6
|
+
`serve(device)` to speak the stdio wire protocol defined by
|
|
7
|
+
[`lab-sync-driver-protocol`](https://pypi.org/project/lab-sync-driver-protocol/). The SDK depends on
|
|
8
|
+
nothing but that package, so a driver ships with its own runtime and never
|
|
9
|
+
imports the supervisor that launches it.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pip install lab-sync-driver-sdk
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## A driver
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import asyncio
|
|
19
|
+
|
|
20
|
+
from lab_sync_driver_sdk import Device, command, float_in, string_in, string_out, serve
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Greeter(Device):
|
|
24
|
+
device_type_id = "acme-greeter"
|
|
25
|
+
manufacturer = "Acme"
|
|
26
|
+
model = "Greeter 1"
|
|
27
|
+
|
|
28
|
+
@command("Greet", "Say hello", default_sim_sec=1.0)
|
|
29
|
+
@string_in("Name", "who to greet", default="world", maps_to="name")
|
|
30
|
+
@float_in("Delay", "seconds to wait first", default=0.0, min_value=0.0, maps_to="delay")
|
|
31
|
+
@string_out("Greeting", "what was said")
|
|
32
|
+
async def greet(self, name: str = "world", delay: float = 0.0, progress=None) -> dict[str, object]:
|
|
33
|
+
await asyncio.sleep(delay)
|
|
34
|
+
if progress is not None:
|
|
35
|
+
await progress(1.0, "greeted")
|
|
36
|
+
return {"Greeting": f"Hello, {name}!"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
asyncio.run(serve(Greeter()))
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The decorators produce the driver's `describe` reply: its commands, their
|
|
44
|
+
inputs and outputs, and any optional metadata the class declares (`category`,
|
|
45
|
+
physical dimensions, `default_nests`, `configurations`, telemetry channels).
|
|
46
|
+
A command returns a dict, and every key in it must be declared with an output
|
|
47
|
+
decorator. An undeclared key fails the command with `OutputValidationError`.
|
|
48
|
+
A command that takes a `progress` parameter is handed an async
|
|
49
|
+
`progress(fraction, message)` reporter.
|
|
50
|
+
|
|
51
|
+
Once `serve` starts, stdout carries protocol frames only. The host points
|
|
52
|
+
`sys.stdout` at stderr, so a stray `print` lands on the log channel rather
|
|
53
|
+
than corrupting a frame.
|
|
54
|
+
|
|
55
|
+
## Testing a driver
|
|
56
|
+
|
|
57
|
+
`lab_sync_driver_sdk.testing.run_command` runs one command in-process, applying the
|
|
58
|
+
same output validation the host applies:
|
|
59
|
+
|
|
60
|
+
```python
|
|
61
|
+
from lab_sync_driver_sdk.testing import run_command
|
|
62
|
+
|
|
63
|
+
result = await run_command(Greeter(), "Greet", {"name": "lab"})
|
|
64
|
+
assert result == {"Greeting": "Hello, lab!"}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
## Connection settings
|
|
68
|
+
|
|
69
|
+
`@settings()` plus the typed input decorators declare the form an operator fills
|
|
70
|
+
in. `connect` receives the saved values in one of two shapes:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
# The dict, keyed by display name, beside a carrier that holds the form.
|
|
74
|
+
@settings()
|
|
75
|
+
@string_in("Host", "device address", default="10.0.0.1")
|
|
76
|
+
async def connection_settings(self) -> None: ...
|
|
77
|
+
|
|
78
|
+
async def connect(self, settings: dict[str, Any] | None = None) -> None:
|
|
79
|
+
host = str((settings or {}).get("Host", "10.0.0.1"))
|
|
80
|
+
|
|
81
|
+
# Or one parameter per setting, with @settings() on connect itself.
|
|
82
|
+
@settings()
|
|
83
|
+
@string_in("Host", "device address", default="10.0.0.1", maps_to="host")
|
|
84
|
+
@int_in("Port", "device port", default=4321, maps_to="port")
|
|
85
|
+
async def connect(self, host: str, port: int) -> None: ...
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
In the second shape the host binds each parameter by `maps_to` (else the
|
|
89
|
+
display name). The value comes from the saved setting, else the declared
|
|
90
|
+
default, else the signature's own default, and is coerced to the declared type,
|
|
91
|
+
so `connect` receives an `int` even when the value was saved as text. A setting
|
|
92
|
+
with no matching parameter, or a required parameter with no setting, fails the
|
|
93
|
+
connect with a message naming it. See `lab_sync_driver_sdk.settings_binding`.
|
|
94
|
+
|
|
95
|
+
## Hosting a lab-device-sdk driver
|
|
96
|
+
|
|
97
|
+
`lab_sync_driver_sdk.lab_host` serves a driver written against
|
|
98
|
+
[`lab-device-sdk`](https://pypi.org/project/lab-device-sdk/) over the same
|
|
99
|
+
protocol, unchanged. It needs the `lab` extra, which also brings in FastAPI
|
|
100
|
+
and uvicorn for drivers that serve HTTP routes of their own:
|
|
101
|
+
|
|
102
|
+
```bash
|
|
103
|
+
pip install "lab-sync-driver-sdk[lab]"
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
It hosts one device per process, so it has to be told which instance it is
|
|
107
|
+
running. A driver manifest declares that with the supervisor's `{device_id}`
|
|
108
|
+
token rather than a hardcoded id, which is what lets a site run several
|
|
109
|
+
devices of the same type:
|
|
110
|
+
|
|
111
|
+
```toml
|
|
112
|
+
[launch]
|
|
113
|
+
type = "command"
|
|
114
|
+
command = ["python", "-m", "lab_sync_driver_sdk.lab_host",
|
|
115
|
+
"--device", "my_pkg.my_device:MyDevice",
|
|
116
|
+
"--device-id", "{device_id}"]
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
@@ -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)
|