Metadata-Version: 2.4
Name: lab-sync-driver-sdk
Version: 0.3.0
Summary: Public driver-authoring SDK for the device engine: subclass Device, decorate commands, serve() over stdio
Author: Lab Sync
License-Expression: MIT
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering
Classifier: Typing :: Typed
Requires-Python: >=3.14
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: lab-sync-driver-protocol==0.3.0
Provides-Extra: lab
Requires-Dist: lab-device-sdk>=0.1.12; extra == "lab"
Requires-Dist: fastapi>=0.110; extra == "lab"
Requires-Dist: uvicorn>=0.30; extra == "lab"
Dynamic: license-file

# lab-sync-driver-sdk

Write a lab device driver as a decorated Python class and serve it as a
supervised child process. A driver subclasses `Device`, decorates async
methods with `@command` and the typed in/out decorators, and calls
`serve(device)` to speak the stdio wire protocol defined by
[`lab-sync-driver-protocol`](https://pypi.org/project/lab-sync-driver-protocol/). The SDK depends on
nothing but that package, so a driver ships with its own runtime and never
imports the supervisor that launches it.

```bash
pip install lab-sync-driver-sdk
```

## A driver

```python
import asyncio

from lab_sync_driver_sdk import Device, command, float_in, string_in, string_out, serve


class Greeter(Device):
    device_type_id = "acme-greeter"
    manufacturer = "Acme"
    model = "Greeter 1"

    @command("Greet", "Say hello", default_sim_sec=1.0)
    @string_in("Name", "who to greet", default="world", maps_to="name")
    @float_in("Delay", "seconds to wait first", default=0.0, min_value=0.0, maps_to="delay")
    @string_out("Greeting", "what was said")
    async def greet(self, name: str = "world", delay: float = 0.0, progress=None) -> dict[str, object]:
        await asyncio.sleep(delay)
        if progress is not None:
            await progress(1.0, "greeted")
        return {"Greeting": f"Hello, {name}!"}


if __name__ == "__main__":
    asyncio.run(serve(Greeter()))
```

The decorators produce the driver's `describe` reply: its commands, their
inputs and outputs, and any optional metadata the class declares (`category`,
physical dimensions, `default_nests`, `configurations`, telemetry channels).
A command returns a dict, and every key in it must be declared with an output
decorator. An undeclared key fails the command with `OutputValidationError`.
A command that takes a `progress` parameter is handed an async
`progress(fraction, message)` reporter.

Once `serve` starts, stdout carries protocol frames only. The host points
`sys.stdout` at stderr, so a stray `print` lands on the log channel rather
than corrupting a frame.

## Testing a driver

`lab_sync_driver_sdk.testing.run_command` runs one command in-process, applying the
same output validation the host applies:

```python
from lab_sync_driver_sdk.testing import run_command

result = await run_command(Greeter(), "Greet", {"name": "lab"})
assert result == {"Greeting": "Hello, lab!"}
```

## Connection settings

`@settings()` plus the typed input decorators declare the form an operator fills
in. `connect` receives the saved values in one of two shapes:

```python
# The dict, keyed by display name, beside a carrier that holds the form.
@settings()
@string_in("Host", "device address", default="10.0.0.1")
async def connection_settings(self) -> None: ...

async def connect(self, settings: dict[str, Any] | None = None) -> None:
    host = str((settings or {}).get("Host", "10.0.0.1"))

# Or one parameter per setting, with @settings() on connect itself.
@settings()
@string_in("Host", "device address", default="10.0.0.1", maps_to="host")
@int_in("Port", "device port", default=4321, maps_to="port")
async def connect(self, host: str, port: int) -> None: ...
```

In the second shape the host binds each parameter by `maps_to` (else the
display name). The value comes from the saved setting, else the declared
default, else the signature's own default, and is coerced to the declared type,
so `connect` receives an `int` even when the value was saved as text. A setting
with no matching parameter, or a required parameter with no setting, fails the
connect with a message naming it. See `lab_sync_driver_sdk.settings_binding`.

## Hosting a lab-device-sdk driver

`lab_sync_driver_sdk.lab_host` serves a driver written against
[`lab-device-sdk`](https://pypi.org/project/lab-device-sdk/) over the same
protocol, unchanged. It needs the `lab` extra, which also brings in FastAPI
and uvicorn for drivers that serve HTTP routes of their own:

```bash
pip install "lab-sync-driver-sdk[lab]"
```

It hosts one device per process, so it has to be told which instance it is
running. A driver manifest declares that with the supervisor's `{device_id}`
token rather than a hardcoded id, which is what lets a site run several
devices of the same type:

```toml
[launch]
type = "command"
command = ["python", "-m", "lab_sync_driver_sdk.lab_host",
           "--device", "my_pkg.my_device:MyDevice",
           "--device-id", "{device_id}"]
```

## License

MIT
