Add a New Hardware Plugin
navigate’s plugin system enables users to add new devices, features, and acquisition modes. In this guide, we add a new device type called CustomDevice and a dedicated GUI window to control it. This hypothetical device can move a set distance, rotate by a specified angle, and stop on command.
navigate plugins follow a Model-View-Controller architecture. The model contains device-specific code, the view contains GUI code, and the controller coordinates communication between them.
Initial Steps
Use the template plugin as your starting point.
Go to navigate-plugin-template.
In the upper right, click
Use this template, thenCreate a new repository.In this repository, rename the
plugin_devicefolder tocustom_device.Rename the file
plugin_device.pytocustom_device.py.
Model Code
Create a new custom device using the following code.
class CustomDevice:
""" A Custom Device Class """
def __init__(self, device_connection, *args):
""" Initialize the Custom Device
Parameters
----------
device_connection : object
The device connection object
args : list
The arguments for the device
"""
self.device_connection = device_connection
def move(self, step_size=1.0):
""" Move the Custom Device
Parameters
----------
step_size : float
The step size of the movement. Default is 1.0 micron.
"""
print("*** Custom Device is moving by", step_size)
def stop(self):
""" Stop the Custom Device """
print("*** Stopping the Custom Device!")
def rotate(self, angle=0.1):
""" Rotate the Custom Device
Parameters
----------
angle : float
The angle of the rotation. Default is 0.1 degree.
"""
print("*** Custom Device is rotating by", angle)
@property
def commands(self):
""" Return the commands for the Custom Device
Returns
-------
dict
The commands for the Custom Device
"""
return {
"move_custom_device": lambda *args: self.move(args[0]),
"stop_custom_device": lambda *args: self.stop(),
"rotate_custom_device": lambda *args: self.rotate(args[0]),
}
All devices should include synthetic versions, which allow the software to run without physical hardware connected. Following the same pattern as CustomDevice, implement synthetic_device.py without hardware I/O calls.
class SyntheticCustomDevice:
""" A Synthetic Device Class """
def __init__(self, device_connection, *args):
""" Initialize the Synthetic Device
Parameters
----------
device_connection : object
The device connection object
args : list
The arguments for the device
"""
pass
def move(self, step_size=1.0):
""" Move the Synthetic Device
Parameters
----------
step_size : float
The step size of the movement. Default is 1.0 micron.
"""
print("*** Synthetic Device received command: move", step_size)
def stop(self):
""" Stop the Synthetic Device """
print("*** Synthetic Device received command: stop")
def rotate(self, angle=0.1):
""" Turn the Synthetic Device
Parameters
----------
angle : float
The angle of the rotation. Default is 0.1 degree.
"""
print("*** Synthetic Device received command: rotate", angle)
@property
def commands(self):
""" Return the commands for the Synthetic Device.
Returns
-------
dict
The commands for the Synthetic Device
"""
return {
"move_custom_device": lambda *args: self.move(args[0]),
"stop_custom_device": lambda *args: self.stop(),
"rotate_custom_device": lambda *args: self.rotate(args[0]),
}
Edit device_startup_functions.py to tell navigate how to connect to and start CustomDevice. This is where hardware connections are created. load_device() should return a connection object.
navigate initializes each device connection independently and passes that connection to the class that controls it (in this example, CustomDevice). This allows startup with multiple microscope configurations, including configurations that share devices.
# Standard library imports
import os
from pathlib import Path
# Third party imports
# Local application imports
from navigate.tools.common_functions import load_module_from_file
from navigate.model.device_startup_functions import (
auto_redial,
device_not_found,
DummyDeviceConnection,
)
DEVICE_TYPE_NAME = "custom_device"
DEVICE_REF_LIST = ["type"]
def load_device(configuration, is_synthetic=False):
""" Load the Custom Device
Parameters
----------
configuration : dict
The configuration for the Custom Device
is_synthetic : bool
Whether the device is synthetic or not. Default is False.
Returns
-------
object
The Custom Device object
"""
return DummyDeviceConnection()
def start_device(microscope_name, device_connection, configuration, is_synthetic=False):
""" Start the Custom Device
Parameters
----------
microscope_name : str
The name of the microscope
device_connection : object
The device connection object
configuration : dict
The configuration for the Custom Device
is_synthetic : bool
Whether the device is synthetic or not. Default is False.
Returns
-------
object
The Custom Device object
"""
if is_synthetic:
device_type = "synthetic"
else:
device_type = configuration["configuration"]["microscopes"][microscope_name][
"custom_device"
]["hardware"]["type"]
if device_type == "CustomDevice":
custom_device = load_module_from_file(
"custom_device",
os.path.join(Path(__file__).resolve().parent, "custom_device.py"),
)
return custom_device.CustomDevice(
microscope_name, device_connection, configuration
)
elif device_type == "synthetic":
synthetic_device = load_module_from_file(
"custom_synthetic_device",
os.path.join(Path(__file__).resolve().parent, "synthetic_device.py"),
)
return synthetic_device.SyntheticCustomDevice(
microscope_name, device_connection, configuration
)
else:
device_not_found(microscope_name, device_type)
View Code
To add a GUI control window, go to the view folder, rename plugin_name_frame.py to custom_device_frame.py, and edit the code as follows.
# Standard library imports
import tkinter as tk
from tkinter import ttk
# Third party imports
# Local application imports
from navigate.view.custom_widgets.LabelInputWidgetFactory import LabelInput
class CustomDeviceFrame(ttk.Frame):
""" The Custom Device Frame """
def __init__(self, root, *args, **kwargs):
""" Initialize the Custom Device Frame
Parameters
----------
root : object
The root Tk object
args : list
The arguments for the Custom Device Frame
kwargs : dict
The keyword arguments for the Custom Device Frame
"""
ttk.Frame.__init__(self, root, *args, **kwargs)
# Formatting
tk.Grid.columnconfigure(self, "all", weight=1)
tk.Grid.rowconfigure(self, "all", weight=1)
# Dictionary for widgets and buttons
#: dict: Dictionary of the widgets in the frame
self.inputs = {}
self.inputs["step_size"] = LabelInput(
parent=self,
label="Step Size",
label_args={"padding": (0, 0, 10, 0)},
input_class=ttk.Entry,
input_var=tk.DoubleVar(),
)
self.inputs["step_size"].grid(row=0, column=0, sticky="N", padx=6)
self.inputs["step_size"].label.grid(sticky="N")
self.inputs["angle"] = LabelInput(
parent=self,
label="Angle",
label_args={"padding": (0, 5, 25, 0)},
input_class=ttk.Entry,
input_var=tk.DoubleVar(),
)
self.inputs["angle"].grid(row=1, column=0, sticky="N", padx=6)
self.inputs["angle"].label.grid(sticky="N")
self.buttons = {}
self.buttons["move"] = ttk.Button(self, text="MOVE")
self.buttons["rotate"] = ttk.Button(self, text="ROTATE")
self.buttons["stop"] = ttk.Button(self, text="STOP")
self.buttons["move"].grid(row=0, column=1, sticky="N", padx=6)
self.buttons["rotate"].grid(row=1, column=1, sticky="N", padx=6)
self.buttons["stop"].grid(row=2, column=1, sticky="N", padx=6)
# Getters
def get_variables(self):
variables = {}
for key, widget in self.inputs.items():
variables[key] = widget.get_variable()
return variables
def get_widgets(self):
return self.inputs
Tip
navigate provides validated widgets that reduce user-input errors and runtime failures. Recommended options include:
LabelInput, which combines a label and an input widget (used above forstep_sizeandangle).Standard Tk widgets such as
ttk.Entryviainput_class.Additional validated widgets such as
ValidatedSpinbox,ValidatedEntry,ValidatedCombobox, andValidatedMixin.
See navigate.view.custom_widgets for details.
Controller Code
Next, build the controller. Open the controller folder, rename plugin_name_controller.py to custom_device_controller.py, and edit the code as follows.
# Standard library imports
import tkinter as tk
# Third party imports
# Local application imports
from navigate.controller.sub_controllers.gui_controller import GUIController
class CustomDeviceController(GUIController):
""" The Custom Device Controller """
def __init__(self, view, parent_controller=None):
""" Initialize the Custom Device Controller
Parameters
----------
view : object
The Custom Device View object
parent_controller : object
The parent (e.g., main) controller object
"""
super().__init__(view, parent_controller)
# Get the variables and buttons from the view
self.variables = self.view.get_variables()
self.buttons = self.view.buttons
# Set the trace commands for the variables associated with the widgets in the View.
self.buttons["move"].configure(command=self.move_device)
self.buttons["rotate"].configure(command=self.rotate_device)
self.buttons["stop"].configure(command=self.stop_device)
def move_device(self, *args):
""" Listen to the move button and move the Custom Device upon clicking.
Parameters
----------
args : list
The arguments for the move_device function. Should be included as the tkinter event
is passed to this function.
"""
self.parent_controller.execute(
"move_custom_device", self.variables["step_size"].get()
)
def rotate_device(self, *args):
""" Listen to the rotate button and rotate the Custom Device upon clicking.
Parameters
----------
args : list
The arguments for the rotate_device function. Should be included as the tkinter event
is passed to this function.
"""
self.parent_controller.execute(
"rotate_custom_device", self.variables["angle"].get()
)
def stop_device(self, *args):
""" Listen to the stop button and stop the Custom Device upon clicking.
Parameters
----------
args : list
The arguments for the stop_device function. Should be included as the tkinter event
is passed to this function.
"""
self.parent_controller.execute("stop_custom_device")
In this example, the custom_device sub-controller maps button clicks to move_device, rotate_device, and stop_device. This triggers the following sequence:
The sub-controller passes the command to the parent controller, which is the main controller for the software.
The parent controller passes the command to the model, which runs in its own subprocess, using an event queue. This avoids tight coupling and reduces race conditions.
The model executes the command, and model-to-controller updates are relayed through another event queue.
Plugin Configuration
Next, update the plugin_config.yml file as follows:
name: Custom Device
view: Popup
Remove the folder ./model/features, the file feature_list.py, and the file plugin_acquisition_mode.py. The plugin folder structure is as follows.
custom_device/
├── controller/
│ └── custom_device_controller.py
|
├── model/
| └── devices/
│ └── custom_device/
│ ├── device_startup_functions.py
│ ├── custom_device.py
│ └── synthetic_device.py
├── view/
| └── custom_device_frame.py
│
└── plugin_config.yml
- Install the plugin using one of two methods:
Install by placing the entire plugin folder directly in
navigate/plugins/. In this example, place thecustom_devicefolder and all contents undernavigate/plugins/.Alternatively, install this plugin through the menu by selecting the plugin folder.
The plugin is now ready to use. You can reference CustomDevice in configuration.yaml as follows.
microscopes:
microscope_1:
daq:
hardware:
name: daq
type: NI
...
custom_device:
hardware:
type: CustomDevice
The custom_device will be loaded when navigate is launched, and it can be controlled through the GUI.