UniteLabs
Reference

Connector Architecture

How a connector splits hardware communication from its SiLA interface, and what happens at startup and shutdown.

A connector has two jobs: talk to the device, and expose a SiLA interface to clients. It keeps them in separate layers, so each can change, be tested, or be simulated without touching the other.

Two layers

LayerResponsibility
io/Talks to the hardware: wire protocol, encoding and decoding, connection management, permission checks, and the business logic. Has no knowledge of SiLA.
features/Defines the SiLA interface and calls into io/. Translates between user-facing concepts and protocol operations.

features/ imports from io/, never the other way around. Because io/ has no SiLA dependency, it can be unit-tested, reused, or swapped on its own.

A feature method stays thin: it declares the interface and calls what that interface needs. Don't add convenience or private helper methods to a feature; put that logic in io/. In the file-system connector, FileSystemProtocol does all path resolution, permission checks, and OS calls, and FileService only calls it:

@sila.UnobservableCommand()
async def read(self, path: File) -> bytes:
    """
    Read the contents of the given file.

    Args:
      Path: The absolute path of the file to read.
    """
    return await self.protocol.read_file(path)

The layers also use different vocabularies. io/ names commands the way the vendor's documentation does, so it stays traceable to the manual. features/ uses the UniteLabs standard vocabulary, and translating between the two is part of its job.

Simulation lives in the protocol

Simulation is implemented at the protocol level, inside io/, not in the features. The SiLA interface then runs the same code path whether the connector is simulated or talking to real hardware, so a test in simulation mode exercises the real interface. See Testing.

Lifecycle

create_app sets up the connector, then yields it to the server. Everything before yield app runs at startup; everything after runs at shutdown.

__init__.py
async def create_app(config: FileSystemConfig) -> collections.abc.AsyncGenerator[Connector]:
    app = Connector(config)

    protocol = FileSystemProtocol(config.permissions)

    app.register(FolderService(protocol))
    app.register(FileService(protocol))
    try:
        await protocol.open()  # connect before the server starts
        yield app
    finally:
        await protocol.close()  # disconnect on shutdown

Three rules follow from this:

  • Register every feature before yield app. Code after the yield only runs during shutdown, so a feature registered there is never served.
  • Open the connection at startup. Clients shouldn't have to call connect() first. Opening the connection before registering features also lets endpoints decide at startup which device variant they are talking to. See Support Device Variants.
  • Don't move anything when the transport opens. Starting the connector must not trigger physical movement, including initialization or calibration routines. A user doesn't expect anything to move when they "just" start the software, and the connector opens the transport again automatically whenever it reconnects after losing the device. Leave movement to explicit commands such as initialize or home.

Last updated