UniteLabs
Guides

Configuration

How to write dynamic configurations for connectors.

Connectors commonly require some amount of user-facing configuration to get started. Whether this is something as simple as needing to know to which serial port a device is connected, or something as complex as allowing a single Connector to communicate with multiple related devices with different startup conditions, UniteLab's new configuration system, built on top of the popular pydantic library, is here to help.

Prerequisites

The Base Config

The easiest way to learn more about the configuration of a connector is with the config show command. After creating a new connector called connector-starter with the connector-factory, you would call config show as follows:

uv run config show --app unitelabs.connector_starter:create_app

This command will display a table of the configurable values and their associated type hints, descriptions, and default values.

The --app option is required for Windows. On MacOS and Linux, commands like connector start and config will generally work without providing this option. On these operating systems, needing to provide --app often points to an error in the connector package.

When creating a connector there are some subset of configurable values that are shared by all connectors. All of the requried and optional configuration values for a SiLA server are embedded in the ConnectorBaseConfig.

ConnectorBaseConfig contains four main sections:

  • sila_server - All of the configuration values for the SiLA server, documented in the SILA Server Config Reference.
  • cloud_server_endpoint - All of the configuration values for the cloud server, which allows one to expose and interact with the Connector on the UniteLabs platform, documented in the Cloud Server Config Reference.
  • discovery - All of the configuration values for multicast DNS (mDNS) and DNS-based Service Discovery(DNS-SD).
  • logging - Entrypoint for setting python logging configuration, accepts python logging dict as defined in the python logging documentation.

All the specific parameters contained in these base config groups can be viewed with the config show command using the --output argument, e.g. config show --output sila_server or config show --output cloud_server_endpoint.

User's Guide

Connector configuration comes with its own CLI to create configuration files. To create a new configuration file simply call:

uv run config create --app unitelabs.connector_starter:create_app

The create command by default will create a file called config.json in the current working directory. We support yaml and json file-types and allow users to specify their own path and file type by adding the --path argument:

uv run config create --app unitelabs.connector_starter:create_app --path /path/to/config.prod.yaml

Starting the connector named connector-starter with a configuration file at the default path is as simple as:

uv run connector start --app unitelabs.connector_starter:create_app -vvv

Both connector start and certificate generate CLIs have a --config-path or -cfg argument that allows one to pass in a path to their configuration file, enabling users to create and apply multiple configurations for their connectors.

uv run connector start --app unitelabs.connector_starter:create_app -cfg /path/to/config.prod.yaml -vvv

For more information about the config CLI, check out the help documentation:

uv run config --help

or for a sub-command, e.g. create:

uv run config create --help

Transitioning from .env

Transitioning .env files to the new system requires moving values from the .env file to the newly create config.json or config.yaml file.

Values prefixed with SILA_SERVER__ are moved into the "sila_server" section of the new config file, while values prefixed with CLOUD_SERVER_ENDPOINT__ are moved into the "cloud_server_endpoint" section.

.env
SILA_SERVER__UUID=00000000-0000-0000-0000-000000000000
SILA_SERVER__HOSTNAME=0.0.0.0
SILA_SERVER__PORT=50001
CLOUD_SERVER_ENDPOINT__HOSTNAME=ffffffff-ffff-ffff-ffff-ffffffffffff.unitelabs.io
CLOUD_SERVER_ENDPOINT__PORT=443
CLOUD_SERVER_ENDPOINT__TLS=True
SERIAL_PORT=/dev/ttyUSB0

The equivalent config.json file would include the following values:

config.json
{
  "sila_server": {
    "uuid": "00000000-0000-0000-0000-000000000000",
    "hostname": "0.0.0.0",
    "port": 50001
  },
  "cloud_server_endpoint": {
    "hostname": "ffffffff-ffff-ffff-ffff-ffffffffffff.unitelabs.io",
    "port": 443,
    "tls": true
  },
  "serial_port": "/dev/ttyUSB0"
}

Developer's Guide

To extend the config with your device specific variables the following points need to be considered.

Requirements:

  • The derived config class must be a dataclass.
  • All configurable values must have a default; this allows the creation of a "default" config, which users can then fill in with their own configuration values.

Limitations:

  • Does not allow for asynchronous default factories, e.g. if you need to fetch some data from a remote source to populate a default value.
  • Our pydantic integration is compatible with pydantic concepts which can be used withpydantic.dataclasses.dataclass or pydantic.TypeAdapter, but not those which are restricted to use with the pydantic.BaseModel.
  • We re-export a subset of pydantic methods as a utility, you must add pydantic as a dependency and import it directly to access additional pydantic methods that are not explicitly exported by the CDK.
config.py
import dataclasses

from unitelabs.cdk.config import ConnectorBaseConfig


@dataclasses.dataclass
class ConnectorConfig(ConnectorBaseConfig):
    number_of_pipettes: int = 8
    """The number of pipettes connected to the device."""

Listing 1: Basic ConnectorBaseConfig derivation with an additional parameter.

In the Connector's __init__.py:

__init__.py
from .config import ConnectorConfig

from unitelabs.cdk import Connector

async def create_app(config: ConnectorConfig):
    app = Connector(config)
    # use config values to adapt the connector
    yield app

Listing 2: Incorporation of a derived ConnectorBaseConfig into a Connector.

You may put your config in the __init__.py file or in a separate file, as shown here. However, if your config is in a separate file, you must import it into your __init__.py. Our configuration system relies on the config class being present when the connector is loaded during connector start to validate the configuration values.

If we were to now run config create, it would create the following default config file:

config.json
{
  "sila_server": {
    "hostname": "0.0.0.0",
    "port": 0,
    "tls": false,
    "require_client_auth": false,
    "root_certificates": null,
    "certificate_chain": null,
    "private_key": null,
    "options": {},
    "uuid": "adf054d6-4503-486a-82cc-7b3f4c159b46",
    "name": "SiLA Server",
    "type": "ExampleServer",
    "description": "",
    "version": "0.1",
    "vendor_url": "https://sila-standard.com"
  },
  "cloud_server_endpoint": {
    "hostname": "localhost",
    "port": 50001,
    "tls": false,
    "root_certificates": null,
    "certificate_chain": null,
    "private_key": null,
    "reconnect_delay": 10000.0,
    "options": {}
  },
  "discovery": {
    "network_interfaces": [],
    "ip_version": "ipv4",
  },
  "logging": null,
  "number_of_pipettes": 8
}

Listing 3: Basic derived ConnectorBaseConfig's config.json output.

Running connector start would start our connector which would have the default name of "SiLA Server" as we have not yet overridden the default Connector configuration values.

Let's modify our config to add override the default values for our SiLA server:

config.py
import dataclasses
from importlib.metadata import version

from unitelabs.cdk.config import ConnectorBaseConfig, SiLAServerConfig


@dataclasses.dataclass
class MyConfig(ConnectorBaseConfig):
    number_of_pipettes: int = 8
    """The number of pipettes connected to the device."""
    sila_server: SiLAServerConfig = dataclasses.field(default_factory=lambda: SiLAServerConfig(
      name="My Server",
      type="Test",
      description="My Test Server written with the UniteLabs CDK.",
      version=str(version("unitelabs-my-connector")),
      vendor_url= "https://unitelabs.io/",
    ))

Listing 4: A derived ConnnectorBaseConfig with SiLAServerConfig overrides.

Now running config create we see the default value for sila_server.name is set to "My Server".

{
  "sila_server": {
    "hostname": "0.0.0.0",
    "port": 0,
    "tls": false,
    "require_client_auth": false,
    "root_certificates": null,
    "certificate_chain": null,
    "private_key": null,
    "options": {},
    "uuid": "adf054d6-4503-486a-82cc-7b3f4c159b46",
    "name": "My Server",
    "type": "Test",
    "description": "My Test Server written with the UniteLabs CDK.",
    "version": "0.1.0",
    "vendor_url": "https://unitelabs.io/"
  },
  "cloud_server_endpoint": {
    "hostname": "localhost",
    "port": 50001,
    "tls": false,
    "root_certificates": null,
    "certificate_chain": null,
    "private_key": null,
    "reconnect_delay": 10000.0,
    "options": {}
  },
  "discovery": {
    "network_interfaces": [],
    "ip_version": "ipv4",
  },
  "logging": null,
  "number_of_pipettes": 8
}

Delayed Defaults

We may want to have some dynamically generated default values for our connector. We can use the delayed_default method to allow values to have a delayed evaluation.

config.py
import dataclasses
import typing

from unitelabs.cdk.config import ConnectorBaseConfig, delayed_default


@dataclasses.dataclass
class MyConfig(ConnectorBaseConfig):
    type: typing.Literal["A", "B"] = "A"
    """The type of connector to run."""
    name: str = dataclasses.field(
        default_factory=delayed_default(lambda self: f"{self.type} Connector")
    )
    """The human readable name of the connector type."""

Listing 5: Applying delayed default values to a derived ConnnectorBaseConfig.

Delayed defaults are evaluated on accession if no explicit override value has been provided so:

>>> config = MyConfig()
>>> name = config.name  # config.name is resolved now
>>> print(name)
"A Connector"
>>> config = MyConfig(type="B", name="overridden")
>>> name = config.name  # no evaluation here as `name` is already set
>>> print(name)
"overridden"

Listing 6: Attribute setters with delayed_default are evaluated on accession.

Validations

Standard typing validations come baked in with no special declaration. All built-in validations come with informative error messages that point directly to the misconfigured field.

Validations fall into two categories: field validations, which apply to one or more fields of the configuration, and config validations, which apply to the entire configuration, e.g. for cases of inter-dependent fields.

These validations are applied when the configuration is loaded through the ConnnectorBaseConfig.validate method, which is called automatically when the connector is started. The validate method returns a validated Configuration instance as a pydantic dataclass, which also applies field validations on assignment.

It is important to note that pydantic relies on a ValueError being raised as a result of invalid configuration, we therefore suggest always using the CDK's ConfigurationError (our custom ValueError) as shown in the upcoming sections. All validation methods MUST throw some ValueError for failure cases in order to be caught by pydantic.

Field Validation

Field validations apply to single fields in the configuration, and can be used to further constrain allowed user-supplied values. Field-level validations are applied as property descriptors, meaning that they are applied both when the configuration is loaded via the validate method and when a field is set post-validation.

unitelabs.cdk.config.ConfigurationError: Invalid configuration for <ConfigClassName>: 1 validation error for <ConfigClassName>"
<Field>
  Value error: <ErrorMessage>

Listing 7: How field-level ConfigurationErrors are displayed.

import dataclasses
import typing

from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig, Field


LargeInt: typing.TypeAlias = typing.Annotated[int, Field(gt=1_000_000)]


@dataclasses.dataclass
class ConnectorConfig(ConnectorBaseConfig):
    large_int: LargeInt = 1_000_001
    """A large integer."""
    another_large_int: LargeInt = 1_000_001
    """Another large integer."""

Listing 8: Using typing.Annotated field annotation for validating that numbers are greater than (gt) 1,000,000.

Calling connector start with a configuration where large_int=1 will result in the following error message:

unitelabs.cdk.config.ConfigurationError: Invalid configuration for MyConfig: 1 validation error for MyConfig
large_int
  Input should be greater than 1000000 [type=greater_than, input_value=1, input_type=int]
    For further information visit https://errors.pydantic.dev/2.11/v/greater_than

Additionally, Field annotations allow one to add metadata to fields, such as descriptions and examples, which are included in the config's JSONschema representation, accessible with the config schema CLI command.

config.py
import uuid

import pydantic
from unitelabs.cdk.config import Field

@dataclasses.dataclass
class ConnectorConfig(ConnectorBaseConfig):
    large_int: typing.Annotated[int, Field(gt=1_000_000, description="A large integer.", example=5_000_000)] = 1_000_001
    uuid: typing.Annotated[
        str, 
        pydantic.WithJsonSchema({"type": "string", "format": "uuid"})
    ] = dataclasses.field(default_factory=lambda: str(uuid.uuid4()))
    """A unique identifier for the connector instance."""

Listing 9: Using typing.Annotated field annotation for JSONschema specification.

import dataclasses
import typing

from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig, validate_field

@dataclasses.dataclass
class MyConfig(ConnectorBaseConfig):
    large_int: int = -1
    """A large integer."""
    another_large_int: int = -1
    """Another large integer."""

    @validate_field("large_int", "another_large_int")
    @classmethod
    def must_be_large(cls, value: int) -> int:
        if value < 1_000_000:
            msg = "large_int must be larger than 1,000,000"
            raise ConfigurationError(msg)
        return value

Listing 10: Using validate_field classmethod decorator for validations.

The validate_field decorator is an alias for pydantic.field_validator. It takes as arguments one or more field names for which the wrapped classmethod should be called, here we apply must_be_large to the large_int and another_large_int fields. The method that validate_field wraps must be a classmethod and it must have a signature (cls, value: Type) -> Type, where the type matches the type of the field or fields being validated. Validation methods must return the value after validation is complete.

Calling connector start with a configuration where large_int=1 will result in the following error message:

unitelabs.cdk.config.ConfigurationError: Invalid configuration for MyConfig: 1 validation error for MyConfig
large_int
  Value error, large_int must be larger than 1,000,000. [type=value_error, input_value=1, input_type=int]
    For further information visit https://errors.pydantic.dev/2.11/v/value_error

Pydantic uses Field annotations and the validate_field decorator to create property descriptors for the dataclass field, thus ensuring that validations are always applied. When using the either of the above MyConfig classes:

config = MyConfig.validate({"large_int":5_000_000})
print(config.large_int)  # 5000000
config.large_int = 500  # Raises pydantic.ValidationError

Listing 11: Field-level validations are also evaluated on assignment when using a validated config, i.e. one that has been created through the validate entrypoint.

Field annotation arguments

Using the Annotated pattern with the Field annotation additionally ensures that all validators are included in the Config's JSONschema representation, i.e. output of config schema will include Structural Validations.

Number Validations:

ArgumentDescription
gtinput must be greater than value
geinput must be greater than or equal to value
ltinput must be less than value
leinput must be less than or equal value
multiple_ofinput must be a multiple of this value, e.g. for multple_of=2 valid values would include 2 and 4 but not 3 or 1.5
decimal_placesinput's decimal precision must not exceed this value

String Validations:

ArgumentDescription
patterninput must match the pattern of this regex value
max_digitsinput must contain a maximum of this number of allowed digits
min_lengthinput must be at least of the given length
max_lengthinput must not exceed the given length

List Validations:

ArgumentDescription
min_lengthinput list must contain a minimum of this number of values
max_lengthinput list must contain a maximum of this number of values

Config Validation

Config-level validations do not automatically offer feedback pointing to a specific field and are not re-evaluated when fields are set post-validation.

unitelabs.cdk.config.ConfigurationError: Invalid configuration for <ConfigClassName>: 1 validation error for <ConfigClassName>"
  Value error, <ErrorMessage>

Listing 12: How config-level ConfigurationErrors are displayed.

This is because config-level configurations look to validate multiple inter-dependent fields and the validity of specific fields is determined in the context of the entire config.

Let's take for example a setup where we have two modes for running something, one of which requires an additional configuration value:

Validations can be applied in the __post_init__ of the configuration dataclass.

dependent_fields_post_init.py
import dataclasses
import typing_extensions as typing

from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig

@dataclasses.dataclass
class DependentFieldsConfig(ConnectorBaseConfig):
    mode: typing.Literal["A", "B"] = "A"
    b_number: int = -1
    
    def __post_init__(self) -> None:
        if self.mode == "B" and self.b_number == -1:
            msg = "When mode=B, b_number must also be supplied."
            raise ConfigurationError(msg)

Listing 13: Using dataclasses __post_init__ for validations.

dependent_fields.py
import dataclasses
import typing_extensions as typing

from unitelabs.cdk.config import ConfigurationError, ConnectorBaseConfig, validate_config

@dataclasses.dataclass
class DependentFieldsConfig(ConnectorBaseConfig):
    mode: typing.Literal["A", "B"] = "A"
    """The mode to run the connector in."""
    b_number: int = -1
    """An integer required when mode is 'B'."""

    @validate_config()
    def check_b_config(self) -> typing.Self:
        if self.mode == "B" and self.b_number == -1:
            msg = "When 'mode=B', 'b_number' must also be supplied."
            raise ConfigurationError(msg)

        return self

Listing 14: Using validate_config method decorator for validations.

This decorator pattern is preferred over the post_init approach when implementing multiple separate methods to validate different aspects of a configuration.

The validate_config decorator is an alias for pydantic.model_validator withmode="after". The method that validate_config wraps must be an instance method and it must have a signature (self) -> Self, where Self is the type of the configuration class. Validation methods must return self after validation is complete.

This setup will expose to the user a default configuration for mode "A". When the user adjusts the values in their config file and tries to run the connector they will receive clear feedback from the ConfigurationError about the changes that are necessary to make their configuration valid.

Best Practices

Do not change configuration values in the Connector create_app function; this is important in order to best respect user settings.

Values which can be derived from the device itself should rather be retrieved from the device in the create_app function after establishing communication, especially in instances when the user would be penalized for incorrectly setting the configuration value.

When creating a configuration value of this sort, design the create_app function to ensure the value is not required for starting the connector and clearly document the endpoint that users should call to retrieve the correct configuration values in the docstring for that value.

Connector configuration should best be focused on:

  • establishing connection to the device
  • adjustment to communication behaviors, e.g. reconnection delays or timeouts
  • adjustment to connector reactivity behaviors, e.g. tolerance settings for sensors
  • establishing default start-up conditions

Transitioning to V0.5.0

pre_v0.5.0_config.py
import collections.abc
from importlib.metadata import version

from unitelabs.cdk import Config, Connector

__version__ = version("unitelabs-connector")


class ConnectorConfig(Config):
    serial_port: str
    device: typing.Literal["DeviceTypeA", "DeviceTypeB"]


async def create_app() -> collections.abc.AsyncGenerator[Connector]:
    config = CustomConfig() # here env was already modified by the start command and is now loaded in
    app = Connector(
          {
            "sila_server": {
                "name": f"{config.device}",
                "type": "Device Type",
                "description": f"{config.device} is a Device Type instrument.",
                "version": str(__version__),
                "vendor_url": "https://unitelabs.io/",
            }
        }

    )
    
    # app.register() # register Features on the Connector
    
    yield app
    
    # cleanup

Listing 15: Usage of old Config for versions of the CDK < v0.5.0.

All fields should have a default value; these default values should, when possible, allow for users to start the connector without input.

v0.5.0_config.py
import collections.abc
import dataclasses
import typing
from importlib.metadata import version

from unitelabs.cdk import Connector
from unitelabs.cdk.config import ConnectorBaseConfig, SiLAServerConfig, delayed_default
from unitelabs.bus.utils.device_manager import SerialDeviceManager

__version__ = version("unitelabs-connector")


@dataclasses.dataclass
class ConnectorConfig(ConnectorBaseConfig):
    serial_port: str = "/dev/ttyUSB0"
    """The serial port to which the device is connected."""
    device: typing.Literal["DeviceTypeA", "DeviceTypeB"] = "DeviceTypeA"
    """The type of device to which the connector should connect."""
  
    sila_server: SiLAServerConfig = dataclasses.field(
        default_factory=delayed_default(lambda self: SiLAServerConfig(
          name=f"{self.device}",
          type="Device Type",
          description=f"Connector for the {self.device} instrument.",
          version=str(__version__),
          vendor_url= "https://unitelabs.io/",
    )))



async def create_app(config: CustomConfig) -> collections.abc.AsyncGenerator[Connector]:
    app = Connector(config)
    
    # app.register() # register Features on the Connector
    
    yield app

    # cleanup

Listing 16: Upgraded v0.5.0 configuration using ConnectorBaseConfig.

The SILA Server Config

Required fields

  • hostname - The name of the host which the server should bind to.
  • port - The port which the server should bind to.
  • name - The human readable name of the server.
  • type - A human readable identifier for the grouping or type of instrument the server connects to.
  • description - The use and purpose of the server.
  • version - The version for the server, following the Semantic Version Specification.
  • vendor_url - The URL to the website of the vendor or product which the server connects to.

Optional fields

For TLS encryption configuration (Further information available in our Security Guide.)

  • tls - Whether or not TLS encryption should be applied to the server.
  • certificate_chain - A path to, or the contents of, the PEM-encoded certificate chain, or None if no certificate should be used.
  • private_key - A path to, or the contents of, the PEM-encoded private key, or None if no private key should be used.

For gRPC connection configuration

  • require_client_auth - Whether or not to require clients to be authenticated, used in combination with root_certificates.
  • root_certificates - The PEM-encoded root certificates, or None which uses the gRPC default location.
  • options - A dictionary of values for configuring the underlying gRPC connection, advanced configuration.

The Cloud Server Config

Connecting to a cloud server is optional for connectors. In order to configure a Connector to interact with the UniteLabs platform, we must set the required values of the CloudServerConfig.

Required fields

  • hostname - The target hostname to connect to.
  • port - The target port to connect to.

Optional fields

  • tls - Whether or not TLS encryption should be applied to the server.
  • root_certificates - The PEM-encoded root certificates, or None which uses the gRPC default location.
  • certificate_chain - A path to, or the contents of, the PEM-encoded certificate chain, or None if no certificate should be used.
  • private_key - A path to, or the contents of, the PEM-encoded private key, or None if no private key should be used.
  • reconnect_delay - The time in ms to wait before attempting to reconnect the channel after an error occurs.
  • options - A dictionary of values for configuring the underlying gRPC connection, advanced configuration.

Copyright © 2025