UniteLabs SDK & REST API
The UniteLabs SDK and REST API expose the same connector model: services (connectors), modules (features), and actions (properties, sensors, and controls). Each section below shows both approaches side by side.
Prerequisites
- Access to the UniteLabs platform
- An environment with an installed SDK — see SDK installation
- A connector deployed and connected to your tenant (the thermocycler demo works for these examples)
- Access to the UniteLabs platform
- API credentials: your tenant ID, client ID, and client secret: ask your UniteLabs contact if you don't have these
- A connector deployed and connected to your tenant
Authentication
The UniteLabs SDK handles authentication automatically using environment variables. For the REST API you need a Bearer token.
The SDK reads credentials from environment variables. Set them in your .env file:
BASE_URL=your-tenant-base-url
AUTH_URL=your-authentication-url
CLIENT_ID=your-client-id
CLIENT_SECRET=your-client-secret
Then instantiate the client — no token handling required:
from unitelabs.sdk import AsyncApiClient
client = AsyncApiClient()
Request a Bearer token via OAuth2 client credentials:
curl -X POST "https://auth.unitelabs.io/realms/{tenant-id}/protocol/openid-connect/token" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id={client-id}&client_secret={client-secret}"
Store the token and set your base URL for subsequent requests:
TOKEN="<access_token from response>"
BASE_URL="https://api.unitelabs.io/{tenant-id}/v1"
List connectors
Retrieve all connectors connected to your tenant.
services = await client.list_services()
print(services)
(Thermocycler(client=..., id='daa46515-49bc-4a7d-944f-369732edde2e', name='Thermocycler'),)
curl "$BASE_URL/services" \
-H "Authorization: Bearer $TOKEN"
[
{
"id": "daa46515-49bc-4a7d-944f-369732edde2e",
"name": "Thermocycler",
"category": "thermocycler"
}
]
Get a specific connector
Look up a connector by name or ID.
By name:
thermocycler = await client.get_service_by_name(name="Thermocycler")
print(thermocycler.id)
print(thermocycler.name)
By ID:
thermocycler = await client.get_service(service_id="daa46515-49bc-4a7d-944f-369732edde2e")
curl "$BASE_URL/services/daa46515-49bc-4a7d-944f-369732edde2e" \
-H "Authorization: Bearer $TOKEN"
Explore modules (features)
A connector's modules are its features — logical groupings of related actions.
print(thermocycler.modules.keys())
dict_keys(['sila_service', 'temperature_controller', 'door_controller'])
curl "$BASE_URL/services/daa46515-49bc-4a7d-944f-369732edde2e/modules" \
-H "Authorization: Bearer $TOKEN"
[
{ "id": "...", "name": "temperature_controller" },
{ "id": "...", "name": "door_controller" }
]
Explore actions
Each module exposes actions: the individual properties, sensors, and controls you can call.
print(thermocycler.temperature_controller.actions.keys())
dict_keys(['get_target_temperature', 'subscribe_current_temperature', 'set_target_temperature'])
Check the type of an action (PROPERTY, SENSOR, or CONTROL):
print(thermocycler.temperature_controller.get_target_temperature.type)
# PROPERTY
List all actions for a module:
curl "$BASE_URL/modules/{moduleId}/actions" \
-H "Authorization: Bearer $TOKEN"
Get detailed info (parameters, response schema) for a specific action:
curl "$BASE_URL/actions/{actionId}" \
-H "Authorization: Bearer $TOKEN"
Every action has one of three types. The type determines how you consume it, and each maps to an underlying SiLA interaction:
| Type | What it does | How you consume it | SiLA interaction |
|---|---|---|---|
PROPERTY | Reads a single value and completes | Call it and await the value | Unobservable Property |
SENSOR | Streams values as they change | Subscribe and read each value | Observable Property |
CONTROL | Triggers a change, with optional parameters | Call it and await the result | Unobservable Command |
CONTROL | Triggers a change and reports progress while it runs | Call it and await the result, or subscribe to follow its updates | Observable Command |
The three sections below show how to consume each type. For the concept behind action types see Action, and for the cross-surface mapping see the Terminology table.
Read a property
A property (PROPERTY) reads a single value and completes. Properties take no parameters, and the SDK method name is prefixed with get_.
target = await thermocycler.temperature_controller.get_target_temperature()
print(target) # e.g. 42
In the REST API, a property is implemented as a one-off read that returns a single value only:
curl -X POST "$BASE_URL/data/{actionId}" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: application/json"
{ "data": 42 }
Read a sensor
A sensor (SENSOR) streams values that change over time. Subscribing returns a subscription object: await the call to get it, then open it as an async context manager. The SDK method name is prefixed with subscribe_.
Subscribe and read each value as it changes:
subscription = await thermocycler.temperature_controller.subscribe_current_temperature()
async with subscription:
async for temperature in subscription:
print(temperature) # prints each new reading as it arrives
A sensor emits only when its value changes, so iteration blocks until the next update. Leaving the async with block closes the stream. You can also break out of the loop or use asyncio cancellation.
If you only need a single snapshot, take the first value and stop. This is how you read a live value that has no property of its own: the actual current temperature, for instance, since the property only holds the target.
subscription = await thermocycler.temperature_controller.subscribe_current_temperature()
async with subscription:
temperature = await anext(subscription)
print(temperature) # the next reading, e.g. 41.7
Create a subscription by passing the action ID and an optional polling interval (milliseconds):
curl -X POST "$BASE_URL/subscriptions" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"action": "{actionId}",
"parameters": {},
"interval": 1000
}'
{
"id": "f47ac10b-58cc-4372-a567-0e02b2c3d479",
"action": "{actionId}",
"source": "..."
}
To stream values directly instead, request the event stream and keep the connection open:
curl -N -X POST "$BASE_URL/subscriptions" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream" \
-H "Content-Type: application/json" \
-d '{"action": "{actionId}", "parameters": {}}'
Cancel a polling subscription when done:
curl -X DELETE "$BASE_URL/subscriptions/f47ac10b-58cc-4372-a567-0e02b2c3d479" \
-H "Authorization: Bearer $TOKEN"
See Subscription for lifecycle, reliability, and cancellation.
Execute a control
A control (CONTROL) triggers a change on the instrument, optionally with parameters, and returns its result once the execution finishes. Unlike properties and sensors, a control's method carries no prefix: it is the action name itself.
Call the control and await its result. Pass parameters as keyword arguments:
await thermocycler.temperature_controller.set_target_temperature(target_temperature=42)
The call resolves when the instrument reports the command finished, and returns the final response (or None if the control has no response).
Invoke a control by creating a subscription with its parameters. The final response frame carries the result:
curl -N -X POST "$BASE_URL/subscriptions" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream" \
-H "Content-Type: application/json" \
-d '{
"action": "{actionId}",
"parameters": {"TargetTemperature": 42}
}'
event: response
data: {...}
Observe a long-running control
Short-running and long-running controls are called the same way; the distinction only matters when you want to watch a long one while it runs. Some controls run long enough to report progress and intermediate responses before they finish. These are observable commands, for example a door that streams its opening status. The generated action method returns only the final result and discards those updates. To observe them, create the subscription yourself and iterate the raw events:
open_door = thermocycler.door_controller.open_door
subscription = await open_door.client.create_subscription(action_id=open_door.id, parameters={})
async with subscription:
async for event, value in subscription:
if event == "error":
raise value
if event == "response":
print("done:", value) # final result
else:
print("update:", value) # progress or intermediate result
This is a low-level escape hatch. For a plain call-and-wait, use the action method directly as shown above. For controls that take parameters, pass them under the names defined in the action's parameter schema.
Keep the event stream open and read each frame as it arrives. Intermediate updates arrive as value frames; the final result arrives as a response frame:
curl -N -X POST "$BASE_URL/subscriptions" \
-H "Authorization: Bearer $TOKEN" \
-H "Accept: text/event-stream" \
-H "Content-Type: application/json" \
-d '{"action": "{actionId}", "parameters": {}}'
event: value
data: {"progress": 0.5}
event: response
data: {...}
Next steps
- Execute commands: read properties and call commands on the instrument from a script: Calling a Connector
- Liquid handling & robots: additional SDK packages for deck building and liquid transfers: Operate