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.
Files changed (23) hide show
  1. lab_sync_driver_sdk-0.4.0/PKG-INFO +210 -0
  2. lab_sync_driver_sdk-0.4.0/README.md +187 -0
  3. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/__init__.py +8 -0
  4. lab_sync_driver_sdk-0.4.0/articulation.py +310 -0
  5. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/device.py +69 -3
  6. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/host.py +192 -23
  7. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/lab_host.py +17 -8
  8. lab_sync_driver_sdk-0.4.0/lab_sync_driver_sdk.egg-info/PKG-INFO +210 -0
  9. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/lab_sync_driver_sdk.egg-info/SOURCES.txt +2 -0
  10. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/lab_sync_driver_sdk.egg-info/requires.txt +1 -1
  11. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/pyproject.toml +2 -2
  12. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/testing.py +6 -3
  13. lab_sync_driver_sdk-0.3.0/PKG-INFO +0 -144
  14. lab_sync_driver_sdk-0.3.0/README.md +0 -121
  15. lab_sync_driver_sdk-0.3.0/lab_sync_driver_sdk.egg-info/PKG-INFO +0 -144
  16. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/LICENSE +0 -0
  17. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/MANIFEST.in +0 -0
  18. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/decorators.py +0 -0
  19. {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
  20. {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
  21. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/py.typed +0 -0
  22. {lab_sync_driver_sdk-0.3.0 → lab_sync_driver_sdk-0.4.0}/settings_binding.py +0 -0
  23. {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
  ]