Build an application with SiLA
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.
| Interaction | What your application does | Tutorial example |
|---|---|---|
| Unobservable property | Read one value | Read Number |
| Observable property | Subscribe to a stream of values | Watch RandomNumbers |
| Unobservable command | Send parameters, start an execution, and await its final response | Call ParameterExample |
| Observable command | Send parameters, start an execution, follow progress, then retrieve its result | Run 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:
{
"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:
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:
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:
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.
| Operation | Default usage | unopinionated library implementation |
|---|---|---|
| Select a feature | client.SiLAEndpointTutorialService after connection | await client.load_feature(FEATURE) |
| Read a property | await tutorial.Number.read() | await feature.properties["Number"].read() |
| Execute a command | await tutorial.ParameterExample(Seed=42) | await feature.commands["ParameterExample"].execute({"Seed": 42}) |
| Read a response | result.RandomNumber | result["RandomNumber"] |
| Subscribe to values | await tutorial.RandomNumbers.subscribe() | await feature.properties["RandomNumbers"].subscribe() |
| Start an observable command | await tutorial.IterativeMethod(Seed=42, NumberOfIterations=3) | await feature.commands["IterativeMethod"].execute({"Seed": 42, "NumberOfIterations": 3}) |
| Read progress | update.CurrentIteration | update["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
| Symptom | Check |
|---|---|
| Connection waits or times out | Confirm the server process, address, port and matching TLS configuration. Use bounded waits. |
| Discovery is empty | Confirm announcements are enabled, select the correct interface, or use an explicit endpoint. |
| Unknown feature, property or parameter | Inspect the server's definitions. Identifiers are case-sensitive and vary between connectors. |
| Command accepted, but no result yet | Wait for execution completion. Retrieve final responses to detect execution errors. |
| Subscription is quiet | The source may update only on change. Check the device's expected cadence and connection. |
| Job timed out after submission | Its 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