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
(inheritingBaseExecutor
) – 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
(inheritingBaseParser
) – 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
(inheritingBaseRunner
) – 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 ofautomancer.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 ofCSSStyleSheet
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.
- Backend
- Provide support for an ordinary device, with the device API
- Backend
Executor
– Processes the configuration and registers devices with the device API.
- Backend
- Provide a syntax extension to protocols, such as loops
- Backend
Parser
– Parses the syntax extension.
- Backend
- 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.
- Backend
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 thescience.ksp
module, thenscience.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'
}
];
}