Automancer docs

Developing plugins

Understanding a plugin's structure

Extension points

At its core, a plugin is a Python object, usually a module, referenced by an entry point named automancer.plugins. This object corresponds to the plugin's backend and is the main extension a plugin provides. It can do anything a typical Python program could do: interact with devices, make HTTP requests, write files, etc. This Python object can have different attributes that correspond to extension points recognized by Automancer.

In addition extending Automancer's backend, a plugin can contain a frontend to add custom UI elements to the application. These can go from simple icons and labels to full views, e.g. to manually control certain devices or report sensor data. User interface components are written in the form of React components. These components should preferably use style classes of the standard application to ensure a consistent look and feel across the application. When in need of custom styles, they can also import their own CSS through the styleSheets extension point.

Here is a list of the various extension points that a plugin can or should implement:

  • Backend
    • Executor (inheriting BaseExecutor) – The main class of a plugin which handles the plugin's configuration and communication with devices. There is a single executor instance per plugin.
    • Parser (inheriting BaseParser) – A class providing syntax extensions to protocols. There is a single parser per protocol per plugin, which is re-created each time the protocol changes.
    • Runner (inheriting BaseRunner) – A class responsible for triggering actions when a protocol is running. There is a single runner instance per experiment and per plugin.
    • client_path – An absolute path to the plugin's frontend entry point. All files in the directory and subdirectories will also be made available for requests by the frontend.
    • metadata (required) – An instance of automancer.Metadata which provides information about the plugin.
    • namespace (required) – The plugin's name.
  • Frontend (only required if client_path is provided by the backend)
    • blocks (optional) – An object mapping block name to block implementations, used to render custom blocks in protocols (such as a repeat block or a process).
    • persistentStoreDefaults/sessionStoreDefaults (optional) – A map of default values for the plugin's persistent/session storage. The session storage is erased when closing the application, unlike the persistent storage.
    • SettingsComponent (optional) – A React component to be displayed in the settings view.
    • namespace – The plugin's name.
    • styleSheets (optional) – An array of CSSStyleSheet objects to be loaded with the plugin. A common way of providing such objects is by using CSS Module Scripts.

Examples

This section lists examples of which extension points a plugin would typically implement, excluding metadata which is mandatory and client_path which is necessary in the presence of a frontend.

  • Provide support for an ordinary device, without the device API
    • Backend
      • Executor – Processes the device's configuration (e.g. address) and communicates with it through a third-party API (e.g. pySerial).
      • Parser – Parses the protocol attributes responsible for writing to the device.
    • Frontend
      • createFeatures() – Provides a feature for the device's action.
      • getGeneralTabs() / getChipTabs() (optional) – Adds a manual control UI that communicates with the executor or runner, respectively.
  • Provide support for an ordinary device, with the device API
    • Backend
      • Executor – Processes the configuration and registers devices with the device API.
  • Provide a syntax extension to protocols, such as loops
    • Backend
      • Parser – Parses the syntax extension.
  • Provide functionality independently of an experiment and protocol, such as allowing to remotely download files generated by other plugins
    • Backend
      • Executor – Provides this new functionality.
    • Frontend
      • getGeneralTabs() – Provides a UI for the new functionality.

Many built-in features of the application are in fact provided by plugins: experiment metadata, built-in protocol constructs, timers, etc.

Understanding a plugin's file structure

The simplest structure possible for a plugin with a frontend is the following:

.
├── pyproject.toml
└── pr1_thermostat
    ├── __init__.py
    ├── index.js
    └── executor.py

The plugin is composed of a single package, pr1_thermostat, registered as an automancer.plugins entry point in pyproject.toml. The __init__.py file defines the required attributes and declares the Executor extension point by imported the executor.py file:

# pr1_thermostat/__init__.py

from automancer import Metadata
from importlib.resources import files

# Create the 'Executor' extension point
from .executor import Executor

# Required attributes
namespace = "timer"
version = 0
metadata = Metadata(...)

# Register a client
# files(__name__) corresponds to the current directory and will automatically be resolved to index.js
client_path = files(__name__)

For more complex plugins, the frontend or client can be placed in a separate directory, a symbolically linked to the Python package. It can also be written as TypeScript, JSX, or both:

.
├── pyproject.toml
├── client
│   ├── package.json
│   ├── dist (generated)
│   │   ├── __init__.py
│   │   └── index.js
│   └── src
│       └── index.tsx
└── pr1_thermostat
    ├── __init__.py
    ├── client -> ../../client/dist
    ├── executor.py
    └── matrix.py

Plugin development mode

Introduction

When developing plugins, it can be cumbersome to reload the software each time you make a change to the plugin's source code, be it on the backend or the frontend. To speed up the development process, such plugins can be loaded in development mode, which provides the following features:

  • Pressing Alt+R reloads the plugin's frontend.
  • Pressing Ctrl+R reloads the plugin's backend.
  • Pressing Alt+Ctrl+R reloads both.

Enabling the development mode

The development mode must be activated individually to plugins in the setup configuration.

plugins:
  thermostat:
    development: true
    options:
      # ...

Effects of reloading the backend

Reloading the backend will have many specific effects:

  • All Python modules which are children of the plugin's entry point module will be reloaded using importlib.reload(). For example, if the plugin's entry point is the science.ksp module, then science.ksp and all its submodules (i.e. science.ksp.*) will be reloaded. Dependencies of these modules that do not match this pattern will not be reloaded.
  • The existing executor, if any, will be destroyed.
  • A new executor will be created and initialized if the plugin provides an Executor class.
  • If the user is currently working on a draft protocol, this draft will be re-compiled to make use of the updated parser, if any.

Reloading comes with important caveats:

  • Files imported by the entry frontend file will not be reloaded, including style sheets.

If things do not behave as expected after reloading, restarting the application will likely resolve most issues.

Units that do not provide a Python module as entry point cannot be reloaded.

Effects of reloading the frontend

Reloading the frontend will cause the app to import() the plugin's client again. A query parameter with a timestamp with millisecond resolution (e.g. ?1660675147822) will be appended to the request URL to avoid caching.

The current state of the plugin's components will be discarded. Unwanted effects should be minimal as the state of each component should be kept contained and devoid of side effects.

Extension points

Executor

The executor is the central piece of a plugin as there is only one executor per plugin and per setup. The executor is responsible for consuming the plugin's configuration and taking appropriate action. All executors must inherit from the BaseExecutor class:

from automancer import BaseExecutor

class Executor(BaseExecutor):
  def __init__(self, conf, *, host):
    # Do something with 'conf'
    pass

The optional start() method of executors is called when the setup is started, and cancelled when the user exits. It can be used to perform any asynchronous operations and must yield once the executor is ready. If its initialization process it too long, it is better for it to yield immediately and perform the initialization in the background. See Using asynchronous patterns for details.

class Executor(BaseExecutor):
  async def start(self):
    # Those are all imaginary methods
    await self._perform_initialization()
    yield

    try:
      await self._perform_operation_tasks()
    finally:
      await self._perform_cleanup()

Creating custom general tabs

Units can create custom tabs on the sidebar by providing a getGeneralTabs() function. This function should return an array of navigation entries with the following properties:

  • id – The identifier of the tab. It will be shared across other plugins, therefore it is a good practice to prefix it with the plugin's namespace.
  • label – The tab's label.
  • icon – A mandatory icon for the tab.
  • component – The component to render when the tab is selected.

The props passed to this component are the following:

  • host – The current host.
  • setRoute(route) – A function to set the current route.

This code sample shows how the corresponding TypeScript types.

import { GeneralTabComponentProps, NavEntry } from 'pr1';

export function getGeneralTabs(): NavEntry<GeneralTabComponentProps> {
  return [
    {
      id: 'ksp.rocket',
      label: 'Rocket',
      icon: 'rocket',
      component: RocketTab
    }
  ];
}

export function RocketTab(props: GeneralTabComponentProps) {
  return (
    <main>
      <h1>Rocket</h1>
    </main>
  );
}

Creating custom chip tabs

Units can also create custom tabs in the chip view by providing a getChipTabs() function, in a similar way to general tabs.

import { ChipTabComponentProps, NavEntry } from 'pr1';

export function getChipTabs(chip: Chip): NavEntry<ChipTabComponentProps> {
  return [
    {
      id: 'ksp.rocket',
      label: 'Rocket',
      icon: 'rocket',
      disabled: false,
      component: RocketTab
    }
  ];
}

export function RocketTab(props: ChipTabComponentProps) {
  return (
    <div>
      <h1>Rocket</h1>
    </div>
  );
}

The props passed to this component are the following:

  • chipId – The current chip's id.
  • host – The current host.
  • setRoute(route) – A function to set the current route.

This code sample shows how the corresponding TypeScript types.

Unlike general tabs, chip tabs can be disabled depending on the chip's status.

export function getChipTabs(chip: Chip): NavEntry<ChipTabComponentProps> {
  return [
    {
      // ...
      disabled: chip.runners.ksp.rocketStatus === 'offline'
    }
  ];
}