Designing Endpoints
Per the SiLA 2 standard, a feature exposes its capabilities as Commands and Properties, collectively called Endpoints here. Properties read data from the device; commands act on it, and only commands accept parameters.
| SiLA endpoint type | Unobservable | Observable |
|---|---|---|
| Property | A single value read on demand. Best for static or rarely changing data: serial number, firmware version, configuration. | A stream of values. Best for anything the client should be notified about: sensor readings, device status, events. |
| Command | An action that returns at most one response. Best for quick actions, setters, and parameterized reads. | An action with status updates and intermediate responses. Best for physical movement and long-running operations. |
For naming rules and the identifiers the CDK derives from method names, see Naming Conventions.
Properties
A property is for reading. If reading a value would change device state, it is a command, not a property.
Make a property observable when the client should be told about changes, such as a temperature or a door state. Make it unobservable when the value is static or only read on demand, such as a serial number.
One value per endpoint
Expose each piece of information as its own property. Don't bundle distinct values into one get_device_information endpoint: the client then has to find their value inside a catalogue, and each value loses its own constraints and units.
# Do
async def get_serial_number(self) -> str: ...
async def get_instrument_name(self) -> str: ...
# Don't
async def get_device_information(self) -> DeviceInformation: ...
For the same reason, don't write a command whose only parameter selects which value to return. Split it into one property per value worth exposing:
# Do
async def get_current_x_position(self) -> typing.Annotated[int, MILLIMETER]: ...
async def get_x_axis_speed(self) -> typing.Annotated[int, MILLIMETER_PER_SECOND]: ...
# Don't
async def get_current_x_axis_parameter(self, selector: typing.Literal["speed", "position"]) -> int: ...
The selector version can't carry a unit, because the unit depends on the selector.
Observable or unobservable commands
Make a command observable when it drives physical movement, or when it can take long enough, roughly 300 ms or more, that a dropped connection mid-call is a real risk. An unobservable command gives the client no way to find out whether it completed after the connection drops; an observable one does.
Every observable command that drives movement should be cancellable, so a client isn't stuck waiting out a motion it can no longer act on.
Report progress only when you can calculate it. If the device gives no timing information, go straight from 0 to 100 percent instead of inventing intermediate steps.
An action with no natural end point, such as shaking until told to stop, is not an observable command: a client expects to await an observable command until it finishes. Model it as two unobservable commands instead:
@sila.UnobservableCommand()
async def start(self, target_speed: typing.Annotated[int, RPM]) -> None:
"""
Start shaking at the given speed until `Stop` is called.
Args:
TargetSpeed: The shaking speed.
"""
@sila.UnobservableCommand()
async def stop(self) -> None:
"""Stop shaking."""
Stateless endpoints
Calling an endpoint with the same parameters should always do the same thing. If the device has internal state, the connector hides it.
Instead of set_temperature_target followed by start_incubation, offer one start_incubation that takes the target temperature. The same applies to complex commands: an aspirate should take every parameter that affects it, including position and speeds, rather than depending on earlier calls.
Connections work the same way. Don't require a client to call connect() before anything else; the connector opens the connection at startup. See Connector Architecture.
If the device offers both a stateful and a stateless API for the same operation, expose only the stateless one.
Designing for retries
In automated workflows, a caller may retry a command when the network dropped before the response arrived. Design for that.
- Prefer idempotent commands. Calling a command twice should have the same effect as calling it once, wherever the device allows it.
set_target(37)twice is harmless.open()on a door that is already open succeeds without doing anything. This extends to commands that wrap several device steps: avoid failing halfway through in a way that leaves the device in an awkward state. - Raise when the operation is already running. Where idempotency isn't possible, such as starting a run that is already running, raise a clear execution error. Don't silently do nothing, and don't let an opaque hardware error surface instead.
- The outcome is always clear. A successful call returns normally, a failed call raises. An idempotent command returns normally even when the device was already in the requested state; the caller doesn't need to know which case it was.
- Every state is recoverable. Any state the normal commands can reach, the normal commands can leave. A user should never need a debugging feature to get out of a state an ordinary call put them in.
Execution modes control what happens when the same command is called again while it is still running. See Execution Modes.
Parameters and responses
No success flags. Don't return a Success: bool. Returning normally already means success, and a failure raises an error.
Booleans for strictly two-state settings. When a setting has exactly two states and can never gain a third, take a bool on one endpoint, such as set_force_sensing(on: bool), rather than enable_force_sensing() and disable_force_sensing(). If a third state is plausible, use an enum from the start. This doesn't apply to actions: open() and close() are better than open(open: bool).
Parameter order. Put spatial axes first and keep x, y, z adjacent. Then order by impact, highest first. When there are only a few parameters, put time or duration last: seal(temperature, duration), not seal(duration, temperature).
No default values. The SiLA standard doesn't allow default values for command parameters. Every parameter is explicit.
Last updated