UniteLabs
Tutorial

Build an application with SiLA

Discover a SiLA server, read values, run commands, and build a small application with the Python client. A hands-on introduction for device integration hackathons.

Your device is integrated. Now make something with it: a live display, an experiment queue, or an AI-driven autonomous lab. This tutorial takes you from your first SiLA client connection to the pieces of a basic application.

This tutorial will teach the basics of making your first calls to your SiLA server using a SiLA client.

Prerequisites

You need Python 3.10 or newer, Git, and uv. The tutorial server runs entirely in source code, so you can follow along without an instrument or a UniteLabs platform account.

Prefer to explore a server through an application first? GroundControl discovers local SiLA servers and lets you read values and run operations.

What connects to what?

Your application  →  SiLA client  →  SiLA server / connector  →  Instrument
                                        │
                                  Tutorial simulation

SiLA 2 describes how laboratory devices and software expose their capabilities. A server offers those capabilities; a client uses them. A connector often translates between SiLA and an instrument's own protocol. The Connector Development Kit helps you build that server. Here, you will write the application that can discover and connect to it.

Each server exposes features: named groups of properties and commands with descriptions, data types, parameters, responses and errors. Every server has a SiLAService feature for discovering its identity and other features. The Python client can load these definitions at runtime; you do not need to generate client code for this exercise.

InteractionWhat your application doesTutorial example
Unobservable propertyRead one valueRead Number
Observable propertySubscribe to a stream of valuesWatch RandomNumbers
Unobservable commandSend parameters, start an execution, and await its final responseCall ParameterExample
Observable commandSend parameters, start an execution, follow progress, then retrieve its resultRun IterativeMethod

“Observable” describes the client interaction, not whether a command is permitted to change device state. An unobservable command can still perform an action on the instrument.

Start a tutorial server

In your first terminal, clone the Tutorial Connector at the release this walkthrough targets.

git clone --branch v0.3.0 https://gitlab.com/unitelabs/examples/tutorial.git tutorial
cd tutorial
uv sync

Create local.json in that directory:

local.json
{
  "sila_server": {
    "hostname": "127.0.0.1",
    "port": 50052,
    "tls": false,
    "name": "Hackathon Tutorial"
  },
  "cloud_server_endpoint": [],
  "discovery": null
}

Start the server and leave it running:

uv run --no-sync connector start --app unitelabs.tutorial:create_app --config-path local.json

This configuration listens on your own machine, uses plaintext for the local exercise, and disables cloud connections and network announcements. No hardware is required. Stop it with Ctrl+C. If port 50052 is already occupied, pick another port and use the same value in your client.

Keep server and client environments separate so the tutorial can install its own CDK and SiLA dependencies. Both client usages below use v0.12.0 of the unitelabs-sila package. The namespace API is the default walkthrough; the optional comparison uses the unopinionated library implementation.

Opinionated library implementation

Connect, read, and execute

In a second terminal, create a client project alongside the tutorial directory:

uv init sila-first-client
cd sila-first-client
uv add unitelabs-sila

Save this complete script as first_client.py:

first_client.py
import asyncio

from sila.client import Client, ClientConfig


async def main():
    config = ClientConfig(
        hostname="127.0.0.1", port=50052, tls=False, default_timeout=5000
    )
    # Opens the connection, loads features, and closes the client on exit.
    async with Client(config) as client:
        print("Connected to:", await client.SiLAService.ServerName.read())
        tutorial = client.SiLAEndpointTutorialService
        print("Number:", await tutorial.Number.read())
        result = await tutorial.ParameterExample(Seed=42)
        print("Result:", result.RandomNumber)


if __name__ == "__main__":
    asyncio.run(asyncio.wait_for(main(), timeout=20))

Run it:

uv run first_client.py

You should see the server name, a random integer from 0 to 42, and a command result of 0.6394267984578837 when no other client is using the tutorial's random generator.

await lets Python wait for a network response without blocking other asynchronous work. Entering async with opens the connection and loads the feature definitions; leaving it closes the connection even when a call fails. A property returns its value. A command returns named responses: read result.RandomNumber to access this command's result.

Feature and parameter identifiers are case-sensitive: Number, Seed, and RandomNumber come from the feature definition, not the server's Python method names. Accessing an attribute alone does not read a property; call and await .read().

SiLA request timeouts such as default_timeout=5000 are milliseconds. The outer asyncio.wait_for(..., timeout=20) is seconds and bounds the whole example. Subscription setup timeouts do not bound the lifetime of a stream.

uv add unitelabs-sila selects the latest stable registry release for a new project. Both usages need the namespace-capable release (0.12 or newer). The starter repository records its tested registry dependencies in uv.lock; run uv sync --locked to reproduce them. To refresh an existing checkout, run uv lock --upgrade-package unitelabs-sila. By default your dependencies will sync with the locked dependencies the next time you call uv run. You can explicitly update your environment after changing the lock with uv sync. No Git dependency or separate script environment is needed.

Discover and inspect a different server

SiLA servers can advertise themselves on the local network using mDNS/DNS-SD. Save this as discover.py in your client project:

discover.py
import asyncio

from sila.client.discovery import Discovery


async def main():
    async with Discovery() as discovery:
        await asyncio.sleep(8)
        for service in discovery.services.values():
            print(service.name, service.uuid, service.addresses, service.port)


asyncio.run(main())

Run uv run discover.py. The local tutorial configuration above deliberately disables announcements, so connect to it by its explicit address. For other devices, choose a server by UUID and confirm its identity instead of executing commands on the first discovery result.

An empty result means no announcements arrived during that window. Multicast may be blocked by guest Wi-Fi, VPNs, host firewalls or VLAN boundaries. Ask the server operator for an address and port if discovery is unavailable. Discovery(interface_address="YOUR_LOCAL_IPV4") selects a particular network interface.

127.0.0.1 and localhost always refer to the machine running the client. Another laptop needs the server machine's reachable address. 0.0.0.0 is a server bind address; do not use it as a client destination.

Within an open client connection, inspect the server and discovered names:

print(await client.SiLAService.ServerUUID.read())
print(await client.SiLAService.ImplementedFeatures.read())
print(dir(client))
print(dir(client.SiLAEndpointTutorialService))

These are fragments to use inside async with Client(config) as client. On a different connector, use the identifiers it exports. The namespaces are discovered dynamically; they are not generated static Python types.

If two features have the same short identifier, select the fully qualified identifier:

tutorial = client.feature("io.unitelabs/tutorial/SiLAEndpointTutorialService/v1")

This selects an already-loaded feature; it does not fetch a definition. The identifier contains originator io.unitelabs, category tutorial, feature name, and major version v1. See the unopinionated library implementation below for selective feature loading and direct endpoint inspection.

For a TLS-enabled server, use its certificate hostname and trusted CA certificate:

from pathlib import Path

config = ClientConfig(
    hostname="device.lab.example",
    port=50052,
    tls=True,
    root_certificates=Path("lab-ca.pem").read_bytes(),
)

Use the server's actual port and trust configuration. Turning TLS off only works when the server itself accepts plaintext. Some servers also require authentication or metadata; follow their feature definitions and the operator's setup instructions.

Turn readings into a live display

An observable property gives you an asynchronous stream. Inside the first script's async with block, after selecting tutorial, add:

stream = await tutorial.RandomNumbers.subscribe()
try:
    for _ in range(4):
        value = await asyncio.wait_for(anext(stream), timeout=5)
        print(f"{value:2d} | {'#' * value}")
finally:
    stream.close()

Run the script again to see four live ASCII bars. A temperature sensor could drive the same display. The timeout bounds how long you wait for each reading; closing the stream releases the subscription. A real sensor may emit only when its value changes, so choose timeouts to match the device.

Follow a running experiment

An observable command returns an execution handle when the server accepts it. Acceptance is not completion. Use the handle to follow updates, wait for the terminal state and retrieve the final result.

This fragment also belongs inside the first script's async with block, after selecting tutorial:

execution = await tutorial.IterativeMethod(Seed=42, NumberOfIterations=3)
try:
    updates = await execution.subscribe_intermediate_responses()
    try:
        async for update in updates:
            print("Iteration:", update.CurrentIteration)
    finally:
        updates.close()

    while not execution.done:
        await asyncio.sleep(0.05)
    result = await execution.get_responses()
    print("Final result:", result.RandomNumber)
finally:
    execution.cancel()

Keep the outer asyncio.wait_for(main(), timeout=20) from the complete script to bound the exercise. Final-result retrieval also surfaces execution errors. Ending a stream is not proof that a command succeeded.

In this client, execution.cancel() closes client-side subscriptions. It does not stop the command on the server. A timeout or disconnect can leave an operation running; inspect the device state before deciding to resubmit. Cancelling on the client never halts a device that is already moving. To stop physical motion, use the instrument's own stop mechanism or implement a dedicated SiLA stop command on the connector, and follow the device's operating procedure.

Unopinionated library implementation

Use this when your application selects features and operations dynamically or needs precise control over loading. It uses the same Client class and installed package as the default walkthrough.

Here is a complete first-call example with the same inputs and responses:

unopinionated_first_client.py
import asyncio

from sila.client import Client, ClientConfig

FEATURE = "io.unitelabs/tutorial/SiLAEndpointTutorialService/v1"


async def main():
    config = ClientConfig("127.0.0.1", 50052, tls=False, default_timeout=5000)
    client = Client(config)
    try:
        await client.open(sync=False)
        print("Connected to:", await client.get_server_name())
        feature = await client.load_feature(FEATURE)
        print("Number:", await feature.properties["Number"].read())
        result = await feature.commands["ParameterExample"].execute({"Seed": 42})
        print("Result:", result["RandomNumber"])
    finally:
        await client.close()


asyncio.run(asyncio.wait_for(main(), timeout=20))

Save and run uv run unopinionated_first_client.py.

Code explanation

open(sync=False) skips loading every feature; load_feature(FEATURE) fetches the selected definition. You can inspect feature.properties, feature.commands, and a command's parameters mapping. Unlike entering the context manager, calling open() directly also lets you inspect any returned feature-load errors when synchronizing.

OperationDefault usageunopinionated library implementation
Select a featureclient.SiLAEndpointTutorialService after connectionawait client.load_feature(FEATURE)
Read a propertyawait tutorial.Number.read()await feature.properties["Number"].read()
Execute a commandawait tutorial.ParameterExample(Seed=42)await feature.commands["ParameterExample"].execute({"Seed": 42})
Read a responseresult.RandomNumberresult["RandomNumber"]
Subscribe to valuesawait tutorial.RandomNumbers.subscribe()await feature.properties["RandomNumbers"].subscribe()
Start an observable commandawait tutorial.IterativeMethod(Seed=42, NumberOfIterations=3)await feature.commands["IterativeMethod"].execute({"Seed": 42, "NumberOfIterations": 3})
Read progressupdate.CurrentIterationupdate["CurrentIteration"]

Subscription cleanup, execution handles, terminal-state checks, and final-response retrieval work the same way in both usages. Responses still support dictionary access. Command options parameters, metadata, and timeout are separate from device parameters: supply either a parameter dictionary or keyword parameters, not both.

Take it toward a self-driving lab

Start with an observe → decide → act → record loop. Read a value, choose the next input, submit the next experiment, wait for completion and store the result with sample and execution identifiers.

The tutorial's random scores teach the plumbing. They are not physical measurements or a meaningful optimization objective. Its random generator is shared, so concurrent tutorial calls can change seeded results. Use a simulator with a defined response surface before testing an optimization policy on an instrument.

For JavaScript and web applications, see the SiLA JS client. Its gRPC client runs in Node.js; your browser UI can communicate with that backend over HTTP or WebSocket. Its separate framework entry point can parse feature definitions in the browser.

Troubleshooting

SymptomCheck
Connection waits or times outConfirm the server process, address, port and matching TLS configuration. Use bounded waits.
Discovery is emptyConfirm announcements are enabled, select the correct interface, or use an explicit endpoint.
Unknown feature, property or parameterInspect the server's definitions. Identifiers are case-sensitive and vary between connectors.
Command accepted, but no result yetWait for execution completion. Retrieve final responses to detect execution errors.
Subscription is quietThe source may update only on change. Check the device's expected cadence and connection.
Job timed out after submissionIts outcome may be unknown. Check state instead of automatically retrying.

To implement your own server-side operations, continue with the CDK tutorial. To use connectors through the UniteLabs platform, see the SDK and REST API guide.

Last updated