lab-sync-driver-sdk 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lab_sync_driver_sdk/__init__.py +43 -0
- lab_sync_driver_sdk/decorators.py +349 -0
- lab_sync_driver_sdk/device.py +234 -0
- lab_sync_driver_sdk/host.py +696 -0
- lab_sync_driver_sdk/lab_host.py +543 -0
- lab_sync_driver_sdk/py.typed +0 -0
- lab_sync_driver_sdk/settings_binding.py +162 -0
- lab_sync_driver_sdk/testing.py +41 -0
- lab_sync_driver_sdk-0.3.0.dist-info/METADATA +144 -0
- lab_sync_driver_sdk-0.3.0.dist-info/RECORD +13 -0
- lab_sync_driver_sdk-0.3.0.dist-info/WHEEL +5 -0
- lab_sync_driver_sdk-0.3.0.dist-info/licenses/LICENSE +21 -0
- lab_sync_driver_sdk-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Bind a device's saved settings onto a `connect` that declares them as parameters.
|
|
2
|
+
|
|
3
|
+
The supervisor sends a device's connection settings as one dict, keyed by each
|
|
4
|
+
setting's display name. A driver can take them in either of two shapes, and the
|
|
5
|
+
host picks by looking at `connect`:
|
|
6
|
+
|
|
7
|
+
* ``connect(self, settings)`` -- the dict, as sent. Every driver written before
|
|
8
|
+
this module existed takes this shape, usually beside an empty
|
|
9
|
+
``@settings()``-decorated ``connection_settings`` method that carries the form.
|
|
10
|
+
* ``@settings()`` on ``connect`` itself, with one parameter per setting --
|
|
11
|
+
lab-device-sdk's shape::
|
|
12
|
+
|
|
13
|
+
@settings()
|
|
14
|
+
@string_in("Host", "device address", default="10.0.0.1", maps_to="host")
|
|
15
|
+
@int_in("Port", "device port", default=4321, maps_to="port")
|
|
16
|
+
async def connect(self, host: str, port: int) -> None: ...
|
|
17
|
+
|
|
18
|
+
Each parameter is named by its setting's ``maps_to``, else its display name,
|
|
19
|
+
and is filled from the saved value, else the declared default, else the
|
|
20
|
+
signature's own default, else the type's zero value -- the same fallback the
|
|
21
|
+
supervisor's settings form uses -- and coerced to the declared type, since a
|
|
22
|
+
value saved from a form may arrive as text.
|
|
23
|
+
|
|
24
|
+
Binding needs both the decoration and a parameter named for a declared setting.
|
|
25
|
+
The host has always passed a decorated ``connect`` the dict, whatever its one
|
|
26
|
+
parameter is called, so ``connect(self, settings)``, ``connect(self, config)``
|
|
27
|
+
and a parameterless ``connect(self)`` keep exactly the call they had.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import dataclasses
|
|
33
|
+
import inspect
|
|
34
|
+
from typing import TYPE_CHECKING
|
|
35
|
+
|
|
36
|
+
from lab_sync_driver_sdk.decorators import TYPE_BOOL
|
|
37
|
+
from lab_sync_driver_sdk.decorators import TYPE_FLOAT
|
|
38
|
+
from lab_sync_driver_sdk.decorators import TYPE_INT
|
|
39
|
+
from lab_sync_driver_sdk.decorators import TYPE_STRING
|
|
40
|
+
from lab_sync_driver_sdk.decorators import get_command_meta
|
|
41
|
+
from lab_sync_driver_sdk.decorators import is_settings_method
|
|
42
|
+
|
|
43
|
+
if TYPE_CHECKING:
|
|
44
|
+
from collections.abc import Callable
|
|
45
|
+
from collections.abc import Mapping
|
|
46
|
+
|
|
47
|
+
from lab_sync_driver_sdk.decorators import Param
|
|
48
|
+
|
|
49
|
+
# What the supervisor's command coercion accepts as a true boolean given as text.
|
|
50
|
+
_TRUTHY_TEXT = ("true", "yes", "1", "on")
|
|
51
|
+
_ZERO: dict[str, object] = {TYPE_INT: 0, TYPE_FLOAT: 0.0, TYPE_BOOL: False, TYPE_STRING: ""}
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class SettingsBindingError(ValueError):
|
|
55
|
+
"""The declared settings and `connect`'s parameters do not fit together, or a value will not coerce."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def binds_settings(connect: Callable[..., object]) -> bool:
|
|
59
|
+
"""Whether `connect` takes its settings as keyword arguments rather than as one dict.
|
|
60
|
+
|
|
61
|
+
Pass the bound method: an unbound function's `self` would count as a parameter.
|
|
62
|
+
"""
|
|
63
|
+
meta = get_command_meta(connect)
|
|
64
|
+
if meta is None or not is_settings_method(connect):
|
|
65
|
+
return False
|
|
66
|
+
parameters = inspect.signature(connect).parameters.values()
|
|
67
|
+
named = {p.name for p in parameters if p.kind not in {p.VAR_POSITIONAL, p.VAR_KEYWORD}}
|
|
68
|
+
if not named:
|
|
69
|
+
return any(p.kind is p.VAR_KEYWORD for p in parameters)
|
|
70
|
+
return any((param.maps_to or param.name) in named for param in meta.inputs)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def described_settings(fn: Callable[..., object]) -> list[Param]:
|
|
74
|
+
"""The settings `fn` declares, each defaulting to its parameter's default when it declares none.
|
|
75
|
+
|
|
76
|
+
The supervisor fills a setting nobody has saved with the declared default, else
|
|
77
|
+
the type's zero value, before it ever reaches the driver. So a default that
|
|
78
|
+
exists only in `connect`'s signature has to be described, or the form shows
|
|
79
|
+
0 and `connect(timeout_s=7.5)` is handed 0.0. Undecorated carriers have no
|
|
80
|
+
parameters to read, so their settings come back unchanged.
|
|
81
|
+
"""
|
|
82
|
+
meta = get_command_meta(fn)
|
|
83
|
+
if meta is None:
|
|
84
|
+
return []
|
|
85
|
+
parameters = inspect.signature(fn).parameters
|
|
86
|
+
described: list[Param] = []
|
|
87
|
+
for param in meta.inputs:
|
|
88
|
+
target = parameters.get(param.maps_to or param.name)
|
|
89
|
+
if (
|
|
90
|
+
param.default is None
|
|
91
|
+
and target is not None
|
|
92
|
+
and target.default is not target.empty
|
|
93
|
+
and target.default is not None
|
|
94
|
+
):
|
|
95
|
+
param = dataclasses.replace(param, default=target.default) # noqa: PLW2901 - a copy, never the decorator's
|
|
96
|
+
described.append(param)
|
|
97
|
+
return described
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def bind_settings(connect: Callable[..., object], saved: Mapping[str, object] | None) -> dict[str, object]:
|
|
101
|
+
"""Build `connect`'s keyword arguments from the saved settings and their declarations.
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
SettingsBindingError: A setting names a parameter `connect` does not have,
|
|
105
|
+
a required parameter has no setting, or a value does not fit its type.
|
|
106
|
+
"""
|
|
107
|
+
declared = described_settings(connect)
|
|
108
|
+
parameters = inspect.signature(connect).parameters
|
|
109
|
+
accepts_any = any(p.kind is p.VAR_KEYWORD for p in parameters.values())
|
|
110
|
+
values = saved or {}
|
|
111
|
+
|
|
112
|
+
kwargs: dict[str, object] = {}
|
|
113
|
+
for param in declared:
|
|
114
|
+
kwarg = param.maps_to or param.name
|
|
115
|
+
target = parameters.get(kwarg)
|
|
116
|
+
if target is None and not accepts_any:
|
|
117
|
+
msg = f"setting {param.name!r} maps to {kwarg!r}, which connect() has no parameter for"
|
|
118
|
+
raise SettingsBindingError(msg)
|
|
119
|
+
# Keyed by display name on the wire; the kwarg spelling is accepted too.
|
|
120
|
+
value = values.get(param.name, values.get(kwarg))
|
|
121
|
+
if value is None:
|
|
122
|
+
value = param.default
|
|
123
|
+
if value is None:
|
|
124
|
+
if target is not None and target.default is not target.empty:
|
|
125
|
+
continue # the signature's own default applies
|
|
126
|
+
value = _ZERO.get(param.type, "")
|
|
127
|
+
kwargs[kwarg] = _coerce(param, value)
|
|
128
|
+
|
|
129
|
+
unbound = [
|
|
130
|
+
name
|
|
131
|
+
for name, p in parameters.items()
|
|
132
|
+
if p.kind in {p.POSITIONAL_OR_KEYWORD, p.KEYWORD_ONLY} and p.default is p.empty and name not in kwargs
|
|
133
|
+
]
|
|
134
|
+
if unbound:
|
|
135
|
+
msg = f"connect() parameter(s) {', '.join(map(repr, unbound))} have no @settings declaration"
|
|
136
|
+
raise SettingsBindingError(msg)
|
|
137
|
+
return kwargs
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _coerce(param: Param, value: object) -> object:
|
|
141
|
+
try:
|
|
142
|
+
match param.type:
|
|
143
|
+
case "int":
|
|
144
|
+
# bool is an int subclass; a flag silently becoming 0/1 is a bug, not a value.
|
|
145
|
+
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
146
|
+
raise TypeError(param.type)
|
|
147
|
+
return int(value)
|
|
148
|
+
case "float":
|
|
149
|
+
if isinstance(value, bool) or not isinstance(value, int | float | str):
|
|
150
|
+
raise TypeError(param.type)
|
|
151
|
+
return float(value)
|
|
152
|
+
case "bool":
|
|
153
|
+
if isinstance(value, str):
|
|
154
|
+
return value.strip().lower() in _TRUTHY_TEXT
|
|
155
|
+
return bool(value)
|
|
156
|
+
case "string":
|
|
157
|
+
return str(value)
|
|
158
|
+
case _:
|
|
159
|
+
return value
|
|
160
|
+
except (TypeError, ValueError) as exc:
|
|
161
|
+
msg = f"setting {param.name!r} expects {param.type}, got {value!r}"
|
|
162
|
+
raise SettingsBindingError(msg) from exc
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""In-process test harness: drive a Device's command without the stdio protocol.
|
|
2
|
+
|
|
3
|
+
With the same output validation the host applies. Lets a driver author
|
|
4
|
+
unit-test commands.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Awaitable
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
from lab_sync_driver_sdk.decorators import validate_outputs
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from lab_sync_driver_sdk.device import Device
|
|
18
|
+
|
|
19
|
+
ProgressFn = Callable[[float, str], Awaitable[None]]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async def run_command(
|
|
23
|
+
device: Device,
|
|
24
|
+
name: str,
|
|
25
|
+
params: dict[str, object] | None = None,
|
|
26
|
+
*,
|
|
27
|
+
progress: ProgressFn | None = None,
|
|
28
|
+
) -> dict[str, object]:
|
|
29
|
+
specs = type(device).command_specs()
|
|
30
|
+
if name not in specs:
|
|
31
|
+
msg = f"no such command: {name}"
|
|
32
|
+
raise KeyError(msg)
|
|
33
|
+
method_name, meta = specs[name]
|
|
34
|
+
method = getattr(device, method_name)
|
|
35
|
+
kwargs: dict[str, object] = {}
|
|
36
|
+
kwargs.update(params or {})
|
|
37
|
+
if progress is not None and "progress" in inspect.signature(method).parameters:
|
|
38
|
+
kwargs["progress"] = progress
|
|
39
|
+
result: dict[str, object] = await method(**kwargs)
|
|
40
|
+
validate_outputs(meta, result)
|
|
41
|
+
return result
|
|
@@ -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,13 @@
|
|
|
1
|
+
lab_sync_driver_sdk/__init__.py,sha256=oarZ4mkv-INByECcozf68s3kJA4CgQ7JOkRZQ67sC2M,1433
|
|
2
|
+
lab_sync_driver_sdk/decorators.py,sha256=qNqxg0CpLHxuQFgOslozz9mOmA8CNd0hzd4MAVvQuUA,13410
|
|
3
|
+
lab_sync_driver_sdk/device.py,sha256=123GNc05IktIFaHLceGatm2nmhjKFCY-S5IbvXk45Gg,10536
|
|
4
|
+
lab_sync_driver_sdk/host.py,sha256=GLhki8CcH9EwsZQgjRNJd2AWRQiPG97ftfVjcp3DH5U,29922
|
|
5
|
+
lab_sync_driver_sdk/lab_host.py,sha256=dLBWF8ActlQQvi2g8_BuQYP5iKGO74qXCRxFLWQFqbg,22852
|
|
6
|
+
lab_sync_driver_sdk/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
lab_sync_driver_sdk/settings_binding.py,sha256=IZ77Qr3hMjjbPJ0CQXG2WN67rPkqTMTsQIQuWbXxWyA,7084
|
|
8
|
+
lab_sync_driver_sdk/testing.py,sha256=2dTkyz85IM1ZS-cOMczGWsAnPCwZMew0r3yo_KXjWZY,1208
|
|
9
|
+
lab_sync_driver_sdk-0.3.0.dist-info/licenses/LICENSE,sha256=jm-ZYaMIQMfZVTbmqsfK44orj0ZnKzzsKwwJwq21xOI,1065
|
|
10
|
+
lab_sync_driver_sdk-0.3.0.dist-info/METADATA,sha256=YR-oz5bWrHdkuU88-lq-M2GAqxnlF2xrX-BqWtz3c_w,5293
|
|
11
|
+
lab_sync_driver_sdk-0.3.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
12
|
+
lab_sync_driver_sdk-0.3.0.dist-info/top_level.txt,sha256=Q_-3AaXxeJ0cCC3dq3r_W5wB189zkmt0OvlyX5os1qg,20
|
|
13
|
+
lab_sync_driver_sdk-0.3.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
lab_sync_driver_sdk
|