lab-sync-driver-sdk 0.3.0__tar.gz → 0.4.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.4.0/PKG-INFO +210 -0
- lab_sync_driver_sdk-0.4.0/README.md +187 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/__init__.py +8 -0
- lab_sync_driver_sdk-0.4.0/articulation.py +310 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/device.py +69 -3
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/host.py +192 -23
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/lab_host.py +17 -8
- lab_sync_driver_sdk-0.4.0/lab_sync_driver_sdk.egg-info/PKG-INFO +210 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/lab_sync_driver_sdk.egg-info/SOURCES.txt +2 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/lab_sync_driver_sdk.egg-info/requires.txt +1 -1
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/pyproject.toml +2 -2
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/testing.py +6 -3
- lab_sync_driver_sdk-0.3.0/PKG-INFO +0 -144
- lab_sync_driver_sdk-0.3.0/README.md +0 -121
- lab_sync_driver_sdk-0.3.0/lab_sync_driver_sdk.egg-info/PKG-INFO +0 -144
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/LICENSE +0 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/MANIFEST.in +0 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/decorators.py +0 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/lab_sync_driver_sdk.egg-info/dependency_links.txt +0 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/lab_sync_driver_sdk.egg-info/top_level.txt +0 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/py.typed +0 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/settings_binding.py +0 -0
- {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/setup.cfg +0 -0
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lab-sync-driver-sdk
|
|
3
|
+
Version: 0.4.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.4.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
|
+
A device may define `async def abort(self, command_name: str)`. The host calls
|
|
75
|
+
it after cancelling a command's task, with the name of the command that was
|
|
76
|
+
cancelled -- so a driver waiting on a run can tell that wait from a
|
|
77
|
+
non-exclusive status read cancelled beside it, and stop the hardware only for
|
|
78
|
+
the one that owns it. The name is `"*"` only when the host does not know the
|
|
79
|
+
execution being aborted.
|
|
80
|
+
|
|
81
|
+
Once `serve` starts, stdout carries protocol frames only. The host points
|
|
82
|
+
`sys.stdout` at stderr, so a stray `print` lands on the log channel rather
|
|
83
|
+
than corrupting a frame.
|
|
84
|
+
|
|
85
|
+
## Moving parts in the 3D views
|
|
86
|
+
|
|
87
|
+
A device whose 3D model moves declares its joints in `articulation` (each names
|
|
88
|
+
a node in the GLB, or one of its animation clips; see the protocol's
|
|
89
|
+
`articulation` field). Where they are reaches the views as `articulation`
|
|
90
|
+
telemetry frames, only while a view is open.
|
|
91
|
+
|
|
92
|
+
A part only commands move -- a lid, a door, a stage -- needs no state of the
|
|
93
|
+
driver's own. Declare it on the commands and the SDK reports it:
|
|
94
|
+
|
|
95
|
+
```python
|
|
96
|
+
from typing import ClassVar
|
|
97
|
+
|
|
98
|
+
from lab_sync_driver_sdk import UNKNOWN, Device, articulates, bool_in, command, when_param
|
|
99
|
+
|
|
100
|
+
class Sealer(Device):
|
|
101
|
+
articulation: ClassVar[dict[str, object]] = {
|
|
102
|
+
"joints": [{"id": "stage", "label": "Stage", "kind": "clip", "clip": "Stage_In"}],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
@command("Seal", "Take the stage in, seal, bring it out")
|
|
106
|
+
@articulates("stage", during=1.0, after=0.0)
|
|
107
|
+
async def seal(self) -> dict[str, object]: ...
|
|
108
|
+
|
|
109
|
+
@command("Load", "Bring the stage in, and out again unless told to hold it")
|
|
110
|
+
@bool_in("Hold", maps_to="hold")
|
|
111
|
+
@articulates("stage", during=1.0, after=when_param("hold", truthy=1.0, falsy=0.0))
|
|
112
|
+
async def load(self, *, hold: bool = False) -> dict[str, object]: ...
|
|
113
|
+
|
|
114
|
+
@command("Send Command", "A raw line that may move anything")
|
|
115
|
+
@articulates("stage", after=UNKNOWN)
|
|
116
|
+
async def send_command(self, line: str = "") -> dict[str, object]: ...
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`during` is reported as the command starts and `after` when it returns (left
|
|
120
|
+
out, the joint keeps `during`). A command that raises or is aborted leaves every
|
|
121
|
+
joint it declares unknown, so the views keep the last pose rather than being
|
|
122
|
+
told a guess -- a refusal before anything moved included, since the SDK cannot
|
|
123
|
+
tell the two apart. `when_param` picks by one of the method's own keywords,
|
|
124
|
+
after defaults; a branch left out leaves the joint as it is.
|
|
125
|
+
|
|
126
|
+
What a command cannot declare -- a position read from the hardware, the state
|
|
127
|
+
seeded at connect -- the driver writes with `self.set_joint(id, value)` and
|
|
128
|
+
`self.forget_joint(id)`, and reads back with `self.joint_value(id)`. One rule
|
|
129
|
+
orders every write: it carries the start order of the command it is made in (or
|
|
130
|
+
a fresh one outside any command), and is dropped if a command that started
|
|
131
|
+
later has already written that joint. A status read asked for before a move
|
|
132
|
+
cannot overwrite where the move is taking the part, and a slow command's
|
|
133
|
+
`after` cannot undo a newer command's `during`.
|
|
134
|
+
|
|
135
|
+
A device that declares `articulation` and no `@telemetry("articulation")`
|
|
136
|
+
sampler of its own gets that channel from the host, which pushes the known pose
|
|
137
|
+
when a view opens and each change as it is written. A device with a sampler
|
|
138
|
+
keeps it, and its readings are filled in with the joints the store knows (the
|
|
139
|
+
sample wins for any joint it reads). The declarations apply when a command runs
|
|
140
|
+
through the host or `run_command`; a command method called directly from
|
|
141
|
+
Python -- one command calling another, or a test -- is a plain method call.
|
|
142
|
+
|
|
143
|
+
## Testing a driver
|
|
144
|
+
|
|
145
|
+
`lab_sync_driver_sdk.testing.run_command` runs one command in-process, applying the
|
|
146
|
+
same output validation the host applies and the same `@articulates` reporting
|
|
147
|
+
(read it back with `device.joint_value(...)`):
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
from lab_sync_driver_sdk.testing import run_command
|
|
151
|
+
|
|
152
|
+
result = await run_command(Greeter(), "Greet", {"name": "lab"})
|
|
153
|
+
assert result == {"Greeting": "Hello, lab!"}
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Connection settings
|
|
157
|
+
|
|
158
|
+
`@settings()` plus the typed input decorators declare the form an operator fills
|
|
159
|
+
in. Put them on `connect` itself, with one parameter per setting — this is the
|
|
160
|
+
standard shape, and every driver in this repository uses it:
|
|
161
|
+
|
|
162
|
+
```python
|
|
163
|
+
@settings()
|
|
164
|
+
@string_in("Host", "device address", default="10.0.0.1", maps_to="host")
|
|
165
|
+
@int_in("Port", "device port", default=4321, maps_to="port")
|
|
166
|
+
async def connect(self, host: str = "10.0.0.1", port: int = 4321) -> None: ...
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
The host binds each parameter by `maps_to` (else the display name). The value
|
|
170
|
+
comes from the saved setting, else the declared default, else the signature's
|
|
171
|
+
own default, and is coerced to the declared type, so `connect` receives an `int`
|
|
172
|
+
even when the value was saved as text. A setting with no matching parameter, or
|
|
173
|
+
a required parameter with no setting, fails the connect with a message naming
|
|
174
|
+
it. Saved settings are keyed by display name, so adding `maps_to` to an existing
|
|
175
|
+
driver keeps its devices' saved values. A driver that reconnects by itself (on a
|
|
176
|
+
simulation switch, say) keeps the keyword arguments of its last connect and
|
|
177
|
+
replays them. See `lab_sync_driver_sdk.settings_binding`.
|
|
178
|
+
|
|
179
|
+
The older shape — an empty `@settings()` carrier such as `connection_settings`
|
|
180
|
+
holding the form, beside `connect(self, settings)` taking the saved dict keyed
|
|
181
|
+
by display name — is still served, for drivers written before binding existed,
|
|
182
|
+
but new and updated drivers should not use it.
|
|
183
|
+
|
|
184
|
+
## Hosting a lab-device-sdk driver
|
|
185
|
+
|
|
186
|
+
`lab_sync_driver_sdk.lab_host` serves a driver written against
|
|
187
|
+
[`lab-device-sdk`](https://pypi.org/project/lab-device-sdk/) over the same
|
|
188
|
+
protocol, unchanged. It needs the `lab` extra, which also brings in FastAPI
|
|
189
|
+
and uvicorn for drivers that serve HTTP routes of their own:
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
pip install "lab-sync-driver-sdk[lab]"
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
It hosts one device per process, so it has to be told which instance it is
|
|
196
|
+
running. A driver manifest declares that with the supervisor's `{device_id}`
|
|
197
|
+
token rather than a hardcoded id, which is what lets a site run several
|
|
198
|
+
devices of the same type:
|
|
199
|
+
|
|
200
|
+
```toml
|
|
201
|
+
[launch]
|
|
202
|
+
type = "command"
|
|
203
|
+
command = ["python", "-m", "lab_sync_driver_sdk.lab_host",
|
|
204
|
+
"--device", "my_pkg.my_device:MyDevice",
|
|
205
|
+
"--device-id", "{device_id}"]
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
## License
|
|
209
|
+
|
|
210
|
+
MIT
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
A device may define `async def abort(self, command_name: str)`. The host calls
|
|
52
|
+
it after cancelling a command's task, with the name of the command that was
|
|
53
|
+
cancelled -- so a driver waiting on a run can tell that wait from a
|
|
54
|
+
non-exclusive status read cancelled beside it, and stop the hardware only for
|
|
55
|
+
the one that owns it. The name is `"*"` only when the host does not know the
|
|
56
|
+
execution being aborted.
|
|
57
|
+
|
|
58
|
+
Once `serve` starts, stdout carries protocol frames only. The host points
|
|
59
|
+
`sys.stdout` at stderr, so a stray `print` lands on the log channel rather
|
|
60
|
+
than corrupting a frame.
|
|
61
|
+
|
|
62
|
+
## Moving parts in the 3D views
|
|
63
|
+
|
|
64
|
+
A device whose 3D model moves declares its joints in `articulation` (each names
|
|
65
|
+
a node in the GLB, or one of its animation clips; see the protocol's
|
|
66
|
+
`articulation` field). Where they are reaches the views as `articulation`
|
|
67
|
+
telemetry frames, only while a view is open.
|
|
68
|
+
|
|
69
|
+
A part only commands move -- a lid, a door, a stage -- needs no state of the
|
|
70
|
+
driver's own. Declare it on the commands and the SDK reports it:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from typing import ClassVar
|
|
74
|
+
|
|
75
|
+
from lab_sync_driver_sdk import UNKNOWN, Device, articulates, bool_in, command, when_param
|
|
76
|
+
|
|
77
|
+
class Sealer(Device):
|
|
78
|
+
articulation: ClassVar[dict[str, object]] = {
|
|
79
|
+
"joints": [{"id": "stage", "label": "Stage", "kind": "clip", "clip": "Stage_In"}],
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
@command("Seal", "Take the stage in, seal, bring it out")
|
|
83
|
+
@articulates("stage", during=1.0, after=0.0)
|
|
84
|
+
async def seal(self) -> dict[str, object]: ...
|
|
85
|
+
|
|
86
|
+
@command("Load", "Bring the stage in, and out again unless told to hold it")
|
|
87
|
+
@bool_in("Hold", maps_to="hold")
|
|
88
|
+
@articulates("stage", during=1.0, after=when_param("hold", truthy=1.0, falsy=0.0))
|
|
89
|
+
async def load(self, *, hold: bool = False) -> dict[str, object]: ...
|
|
90
|
+
|
|
91
|
+
@command("Send Command", "A raw line that may move anything")
|
|
92
|
+
@articulates("stage", after=UNKNOWN)
|
|
93
|
+
async def send_command(self, line: str = "") -> dict[str, object]: ...
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`during` is reported as the command starts and `after` when it returns (left
|
|
97
|
+
out, the joint keeps `during`). A command that raises or is aborted leaves every
|
|
98
|
+
joint it declares unknown, so the views keep the last pose rather than being
|
|
99
|
+
told a guess -- a refusal before anything moved included, since the SDK cannot
|
|
100
|
+
tell the two apart. `when_param` picks by one of the method's own keywords,
|
|
101
|
+
after defaults; a branch left out leaves the joint as it is.
|
|
102
|
+
|
|
103
|
+
What a command cannot declare -- a position read from the hardware, the state
|
|
104
|
+
seeded at connect -- the driver writes with `self.set_joint(id, value)` and
|
|
105
|
+
`self.forget_joint(id)`, and reads back with `self.joint_value(id)`. One rule
|
|
106
|
+
orders every write: it carries the start order of the command it is made in (or
|
|
107
|
+
a fresh one outside any command), and is dropped if a command that started
|
|
108
|
+
later has already written that joint. A status read asked for before a move
|
|
109
|
+
cannot overwrite where the move is taking the part, and a slow command's
|
|
110
|
+
`after` cannot undo a newer command's `during`.
|
|
111
|
+
|
|
112
|
+
A device that declares `articulation` and no `@telemetry("articulation")`
|
|
113
|
+
sampler of its own gets that channel from the host, which pushes the known pose
|
|
114
|
+
when a view opens and each change as it is written. A device with a sampler
|
|
115
|
+
keeps it, and its readings are filled in with the joints the store knows (the
|
|
116
|
+
sample wins for any joint it reads). The declarations apply when a command runs
|
|
117
|
+
through the host or `run_command`; a command method called directly from
|
|
118
|
+
Python -- one command calling another, or a test -- is a plain method call.
|
|
119
|
+
|
|
120
|
+
## Testing a driver
|
|
121
|
+
|
|
122
|
+
`lab_sync_driver_sdk.testing.run_command` runs one command in-process, applying the
|
|
123
|
+
same output validation the host applies and the same `@articulates` reporting
|
|
124
|
+
(read it back with `device.joint_value(...)`):
|
|
125
|
+
|
|
126
|
+
```python
|
|
127
|
+
from lab_sync_driver_sdk.testing import run_command
|
|
128
|
+
|
|
129
|
+
result = await run_command(Greeter(), "Greet", {"name": "lab"})
|
|
130
|
+
assert result == {"Greeting": "Hello, lab!"}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
## Connection settings
|
|
134
|
+
|
|
135
|
+
`@settings()` plus the typed input decorators declare the form an operator fills
|
|
136
|
+
in. Put them on `connect` itself, with one parameter per setting — this is the
|
|
137
|
+
standard shape, and every driver in this repository uses it:
|
|
138
|
+
|
|
139
|
+
```python
|
|
140
|
+
@settings()
|
|
141
|
+
@string_in("Host", "device address", default="10.0.0.1", maps_to="host")
|
|
142
|
+
@int_in("Port", "device port", default=4321, maps_to="port")
|
|
143
|
+
async def connect(self, host: str = "10.0.0.1", port: int = 4321) -> None: ...
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
The host binds each parameter by `maps_to` (else the display name). The value
|
|
147
|
+
comes from the saved setting, else the declared default, else the signature's
|
|
148
|
+
own default, and is coerced to the declared type, so `connect` receives an `int`
|
|
149
|
+
even when the value was saved as text. A setting with no matching parameter, or
|
|
150
|
+
a required parameter with no setting, fails the connect with a message naming
|
|
151
|
+
it. Saved settings are keyed by display name, so adding `maps_to` to an existing
|
|
152
|
+
driver keeps its devices' saved values. A driver that reconnects by itself (on a
|
|
153
|
+
simulation switch, say) keeps the keyword arguments of its last connect and
|
|
154
|
+
replays them. See `lab_sync_driver_sdk.settings_binding`.
|
|
155
|
+
|
|
156
|
+
The older shape — an empty `@settings()` carrier such as `connection_settings`
|
|
157
|
+
holding the form, beside `connect(self, settings)` taking the saved dict keyed
|
|
158
|
+
by display name — is still served, for drivers written before binding existed,
|
|
159
|
+
but new and updated drivers should not use it.
|
|
160
|
+
|
|
161
|
+
## Hosting a lab-device-sdk driver
|
|
162
|
+
|
|
163
|
+
`lab_sync_driver_sdk.lab_host` serves a driver written against
|
|
164
|
+
[`lab-device-sdk`](https://pypi.org/project/lab-device-sdk/) over the same
|
|
165
|
+
protocol, unchanged. It needs the `lab` extra, which also brings in FastAPI
|
|
166
|
+
and uvicorn for drivers that serve HTTP routes of their own:
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
pip install "lab-sync-driver-sdk[lab]"
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
It hosts one device per process, so it has to be told which instance it is
|
|
173
|
+
running. A driver manifest declares that with the supervisor's `{device_id}`
|
|
174
|
+
token rather than a hardcoded id, which is what lets a site run several
|
|
175
|
+
devices of the same type:
|
|
176
|
+
|
|
177
|
+
```toml
|
|
178
|
+
[launch]
|
|
179
|
+
type = "command"
|
|
180
|
+
command = ["python", "-m", "lab_sync_driver_sdk.lab_host",
|
|
181
|
+
"--device", "my_pkg.my_device:MyDevice",
|
|
182
|
+
"--device-id", "{device_id}"]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
## License
|
|
186
|
+
|
|
187
|
+
MIT
|
|
@@ -6,6 +6,9 @@ the shared wire contract (`lab_sync_driver_protocol`), never on the supervisor t
|
|
|
6
6
|
launches the driver.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
|
+
from lab_sync_driver_sdk.articulation import UNKNOWN
|
|
10
|
+
from lab_sync_driver_sdk.articulation import articulates
|
|
11
|
+
from lab_sync_driver_sdk.articulation import when_param
|
|
9
12
|
from lab_sync_driver_sdk.decorators import OutputValidationError
|
|
10
13
|
from lab_sync_driver_sdk.decorators import bool_in
|
|
11
14
|
from lab_sync_driver_sdk.decorators import bool_out
|
|
@@ -21,13 +24,17 @@ from lab_sync_driver_sdk.decorators import telemetry
|
|
|
21
24
|
from lab_sync_driver_sdk.device import Device
|
|
22
25
|
from lab_sync_driver_sdk.device import DeviceCategory
|
|
23
26
|
from lab_sync_driver_sdk.device import NestOffset
|
|
27
|
+
from lab_sync_driver_sdk.device import SimulationReconnectError
|
|
24
28
|
from lab_sync_driver_sdk.host import serve
|
|
25
29
|
|
|
26
30
|
__all__ = [
|
|
31
|
+
"UNKNOWN",
|
|
27
32
|
"Device",
|
|
28
33
|
"DeviceCategory",
|
|
29
34
|
"NestOffset",
|
|
30
35
|
"OutputValidationError",
|
|
36
|
+
"SimulationReconnectError",
|
|
37
|
+
"articulates",
|
|
31
38
|
"bool_in",
|
|
32
39
|
"bool_out",
|
|
33
40
|
"command",
|
|
@@ -40,4 +47,5 @@ __all__ = [
|
|
|
40
47
|
"string_in",
|
|
41
48
|
"string_out",
|
|
42
49
|
"telemetry",
|
|
50
|
+
"when_param",
|
|
43
51
|
]
|