UniteLabs

Action

The callable primitive on a module. Every action has a type — Property, Sensor, or Control — that determines how you consume it.

An action is anything you can call on a module. Reading the current temperature, subscribing to a file-system change stream, triggering a centrifuge spin — these are all actions. Every action has a type that tells you how to consume it:

  • Property — read a single scalar value and complete
  • Sensor — subscribe to a stream of values
  • Control — trigger a change, optionally with parameters, and get a result

The platform UI groups actions into three sections by type. The SDK and REST API expose the type as a field on each action. See the Terminology table for the cross-surface mapping.

Property actions

A Property reads a single value and returns it right away. Use one when you need to read something once, not follow it over time. Properties take no parameters.

Examples:

  • The connector's server name or version, metadata that rarely changes
  • The target temperature a thermocycler is currently set to
  • Whether a heater-shaker is actively holding a temperature, a boolean status (True or False)
# Read the connector's server name
name = await file_system.sila_service.get_server_name()
print(name)  # "FileSystem"

# Read a structured value: the folder whitelist
whitelist = await file_system.folder_service.get_folder_whitelist()
print(whitelist)  # ['/opt/fs_demo']

In the REST API, a Property is read via POST /v1/data/{actionId}, which returns the value once and completes.

Sensor actions

A Sensor exposes a stream of values that change over time. Use one when you want to follow a value, not just read it once. A sensor keeps emitting until you stop it; it does not complete on its own.

Examples:

  • A live temperature feed from a thermocycler
  • The current shaking speed of a heater-shaker while it runs
  • A file-system watcher that emits an event whenever a folder's contents change
# Subscribe to file-system changes
subscription = await file_system.folder_service.subscribe_changes()
async with subscription:
    async for change in subscription:
        print(change)

Consuming a Sensor holds open a long-lived stream. See Subscription for lifecycle, reliability, and cancellation. In the REST API, a Sensor is consumed by creating a subscription (POST /v1/subscriptions) and reading its stream of value events.

Control actions

A Control triggers a change on the instrument, optionally with parameters. Unlike a property, it makes something happen rather than reading a value. Controls come in two kinds, named for whether you can watch them execute. An unobservable Control offers no view into its execution: you call it, and the call returns once the command has completed. An observable Control tells you while it runs how the execution is going (its state, progress, and estimated remaining time) and can send partial results along the way.

Unobservable Controls. The call returns once the instrument confirms the change; there are no updates in between. Examples:

  • Set a target temperature: the setpoint is stored and the call returns; you watch the actual temperature climb through the sensor
  • Tare or zero a balance
  • Delete a file, or set the server name
# Trigger a file deletion
await file_system.file_service.delete_file(path="/opt/fs_demo/old.txt")

# Set a parameter the server remembers afterward
await file_system.sila_service.set_server_name(server_name="FileSystem (Bench 3)")

When the call returns, the command succeeded; if something goes wrong, the connector raises a defined error instead. Many Controls return None, but some hand back a value. Taring a balance, for instance, returns the new tare weight and its unit:

tare_weight, unit, is_stable = await balance.weighing_service.tare()
print(tare_weight, unit)  # e.g. 0.0021 'g'

Observable Controls. These run for a while and report on their execution until the task finishes. While the command runs, it reports its Status (the execution state, e.g. running), its Estimated Time Remaining (in seconds), and its Progress (in percent). On top of these generic updates, a command can emit intermediate responses: typed partial results, delivered as the command produces them. Unlike a sensor, the stream does end: namely when the command completes.

For example, starting a read on a plate reader returns a Status (running), an estimated time remaining, and a progress value, plus intermediate responses with the read data per well as it is created. The final response then returns the complete data object for the full read.

Consuming these updates is optional. If you only want the final result, call the Control and await it like any other. Observable Controls can also be cancelled while they run.

Observable Controls are consumed as subscriptions; the stream delivers the execution updates and optional intermediate responses until the command completes.

Whether a given change is modeled as an unobservable Control plus a Sensor, or as a single observable Control, is a choice the connector makes. Reaching a target temperature, for example, is often a set_target_temperature Control paired with a subscribe_current_temperature Sensor, rather than one command that blocks until the setpoint is reached.

How SiLA maps to action types

Connectors built on the UniteLabs CDK expose their functionality as SiLA features. Each SiLA primitive surfaces as one of the three action types:

SiLA primitiveAction typeSDK method
Unobservable PropertyPROPERTYget_<name>()
Observable PropertySENSORsubscribe_<name>()
Unobservable CommandCONTROL<name>(...)
Observable CommandCONTROL<name>(...)

Both command kinds surface as CONTROL. The difference is that an observable command runs long enough to report progress and intermediate responses while it executes. Those updates arrive as a subscription, and the final result is returned when the command completes.

Discovering actions and their types

Ask a module what actions it has, then read each action's type:

module = file_system.folder_service
print(module.actions.keys())
# dict_keys(['get_folder_whitelist', 'subscribe_changes', 'get_folder_contents'])

for name, action in module.actions.items():
    print(name, action.type)
# get_folder_whitelist   PROPERTY
# subscribe_changes      SENSOR
# get_folder_contents    CONTROL

From the REST API:

# All actions on a module — each record carries its type
curl "$BASE_URL/v1/modules/$MODULE_ID/actions" \
  -H "Authorization: Bearer $TOKEN"

# One action in detail — type, parameters, responses, source (SiLA URI)
curl "$BASE_URL/v1/actions/$ACTION_ID" \
  -H "Authorization: Bearer $TOKEN"

Each action record includes its type (one of PROPERTY, SENSOR, CONTROL), a human-readable name, typed parameters and responses schemas, and a source field that identifies the underlying SiLA URI for connector developers who need it.

Introspection: parameters and responses

Every action carries typed schemas describing what it expects and what it returns. The schema is programmatically accessible so you can build UIs, validate payloads before sending them, or generate client code:

# What parameters does this Control accept?
print(await file_system.sila_service.set_server_name.parameters)
# {'ServerName': Parameter(name='Server Name', schema={...})}

# What does it return?
print(await file_system.sila_service.set_server_name.responses)
# None

Parameters come with names, data types, and constraints — units, min / max ranges, enumerations. Responses have the same shape. The SDK uses this schema to serialize your arguments correctly; the platform UI uses it to render a parameter form on Controls and a value view on Properties.