Removed old plugin docs
This commit is contained in:
parent
58123a76c2
commit
1eda70045c
12 changed files with 70 additions and 702 deletions
|
|
@ -1,44 +1,34 @@
|
|||
Adding a GUI
|
||||
=====================
|
||||
OpenFlexure eV GUI
|
||||
==================
|
||||
|
||||
Introduction
|
||||
------------
|
||||
|
||||
In order to bind user-interface elements to your plugin, an ``api_form`` variable must be included in your microscope plugin, similar to your ``api_views``.
|
||||
The main client application for the OpenFlexure Microscope, OpenFlexure eV, can render simple GUIs (graphical user interfaces) for extensions.
|
||||
|
||||
This variable must be in the form of a dictionary, with all data able to be parsed into JSON. Because of this requirement, it is suggested that you create your API form as a JSON file, and load that file as a dictionary into your plugin. For example:
|
||||
We define our user interface by making use of the extensions general metadata, added using the ``add_meta`` function. This function adds arbitrary additional data to your extensions web API description, for example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
import json
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
HERE = os.path.dirname(os.path.realpath(__file__)) # Find the full path of your plugin Python file
|
||||
FORM_PATH = os.path.join(HERE, "form.json") # Find the full path of the adjascent JSON file
|
||||
...
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
my_extension.add_meta("myKey", "My metadata value")
|
||||
|
||||
with open(FORM_PATH, 'r') as sc: # Open the JSON file
|
||||
api_form = json.load(sc) # Load the JSON file into the api_form dictionary
|
||||
|
||||
Throughout this documentation, all example of ``api_form`` sections will be in JSON format. Keep in mind however that it is possible to directly define your ``api_form`` as a dictionary, without loading an external file.
|
||||
|
||||
|
||||
Forms from ``api_form``
|
||||
-------------------------
|
||||
|
||||
The ``api_form`` object essentially describes HTML forms, which it is up to the client to render. The form is constructed by specifying a set of components, and their values. A form can update it's values by sending a GET request to the API route bound to that form, and can send it's current values via a POST request to *this same API route*.
|
||||
OpenFlexure eV will recognise the ``gui`` metadata key, and render properly structured descriptions of a GUI in the format described below. The ``gui`` data essentially describes HTML forms, which it is up to the client to render. The form is constructed by specifying a set of components, and their values.
|
||||
|
||||
Each component in the form has a ``name`` property, which must match up to a property your API route expects in JSON POST requests, and returns in JSON GET requests.
|
||||
|
||||
Structure of ``api_form``
|
||||
|
||||
Structure of ``gui``
|
||||
---------------------------
|
||||
|
||||
Root level
|
||||
++++++++++
|
||||
|
||||
The root of your ``api_form`` expects 3 properties:
|
||||
|
||||
``id`` - A unique ID to give your client-side plugin
|
||||
The root of your ``gui`` dictionary expects 2 properties:
|
||||
|
||||
``icon`` - The name of a Material Design icon to use for your plugin
|
||||
|
||||
|
|
@ -47,7 +37,7 @@ The root of your ``api_form`` expects 3 properties:
|
|||
Form level
|
||||
++++++++++
|
||||
|
||||
Your plugin can contain multiple forms. For example, if your plugin creates several API routes, you will need a separate form for each route.
|
||||
Your extension can contain multiple forms. For example, if your extension creates several API routes, you will need a separate form for each route.
|
||||
|
||||
Each form is described by a JSON object, with the following properties:
|
||||
|
||||
|
|
@ -55,23 +45,21 @@ Each form is described by a JSON object, with the following properties:
|
|||
|
||||
``route`` - String of the corresponding API route. *Must* match a route defined in your ``api_views`` dictionary
|
||||
|
||||
``selfUpdate`` *(optional)* - Whether the client should automatically update form component values with a GET request to your API route
|
||||
|
||||
``isTask`` *(optional)* - Whether the client should treat your API route as a long-running task
|
||||
|
||||
``isCollapsible`` *(optional)* - Whether the form can be collapsed into an accordion
|
||||
|
||||
``submitLabel`` *(optional)* - String to place inside of the form's submit button
|
||||
|
||||
``form`` - An array of form components as described below
|
||||
``schema`` - List of dictionaries. Each dictionary element describes a form component.
|
||||
|
||||
Component level
|
||||
+++++++++++++++
|
||||
|
||||
Each form can (and probably should) contain multiple components. For example, if your API route expects several properties in POST requests, each property can be bound to a form component.
|
||||
Each form can (and probably should) contain multiple components. For example, if your API route expects several parameters in a POST requests, each parameter can be bound to a form component.
|
||||
|
||||
Upon form submission, the form data will be converted into a JSON object of key-value pairs, where the key is the components ``name``, and the value is it's current value.
|
||||
|
||||
On load, the form will make a GET request to it's API route, an expects a JSON object of key-value pairs in the same format (keys will be matched to component names).
|
||||
|
||||
An overview of available components, and their properties, can be found below.
|
||||
|
||||
Arranging components
|
||||
|
|
@ -179,8 +167,53 @@ Overview of components
|
|||
|
||||
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/textInput.png
|
||||
|
||||
JSON form example
|
||||
-----------------
|
||||
**Note:** Basic input types (``textInput``, ``numberInput``) can also include additional attributes for HTML input elements inputs (e.g. ``placeholder``, ``required``, ``min``, ``max``). These additional attributes will be forwarded to the rendered HTML elements.
|
||||
|
||||
.. literalinclude:: forms_example.json
|
||||
:language: JSON
|
||||
Building the GUI
|
||||
----------------
|
||||
|
||||
Once you have a dictionary describing your GUI, use the :py:meth:`openflexure_microscope.api.utilities.gui.build_gui` function to fill in and expand any information required to have it properly function. This function expands your ``route`` values to include your extensions full URI, and handles returning dynamic GUIs.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
my_gui = {...}
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
...
|
||||
|
||||
my_extension.add_meta("gui", build_gui(my_gui, my_extension))
|
||||
|
||||
Dynamic GUIs
|
||||
------------
|
||||
|
||||
Instead of passing a static dictionary to :py:meth:`openflexure_microscope.api.utilities.gui.build_gui`, you can instead pass a callable function which returns a dictionary. This function is then called every time a client requests a description of active extensions.
|
||||
|
||||
Using a callable has the advantage of allowing your extensions GUI to be updated as it is used. This could be as simple as changing ``value`` parameters of components (to show up-to-date default form values), but could be used to entirely change the GUI form as it is used, for example dynamically changing options in select boxes.
|
||||
|
||||
For example, this could take the form:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
def create_dynamic_form():
|
||||
...
|
||||
generated_form_dict = {...}
|
||||
return generated_form_dict
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
...
|
||||
|
||||
my_extension.add_meta("gui", build_gui(create_dynamic_form, my_extension))
|
||||
|
||||
|
||||
Complete example
|
||||
----------------
|
||||
|
||||
Adding a GUI to our previous timelapse example extension becomes:
|
||||
|
||||
.. literalinclude:: ./example_extension/07_ev_gui.py
|
||||
|
|
@ -9,11 +9,11 @@ This also allows access to the :py:class:`picamera.PiCamera` object.
|
|||
Extensions can either be loaded from a single Python file, or as a Python package installed to the environment being used.
|
||||
|
||||
Single-file extensions
|
||||
----------------------------
|
||||
----------------------
|
||||
For adding simple functionality, such as a few basic functions and API routes, a single Python file can be loaded as a extension. This Python file must contain all of your extension objects, and be located in the applications extensions directory (by default ``~/.openflexure/microscope_extensions``).
|
||||
|
||||
Package extensions
|
||||
---------------
|
||||
------------------
|
||||
Generally, for adding anything other than very simple functionality, extensions should be written as `package distributions <https://packaging.python.org/tutorials/packaging-projects/>`_. This has the advantage of allowing relative imports, so functionality can be easily split over several files. For example, class definitions associated with API routes can be separated from class definitions associated with the microscope extension.
|
||||
|
||||
The main restriction is that the extension package must be importable using an absolute import from within the Python environment being used to load your microscope.
|
||||
|
|
|
|||
|
|
@ -10,4 +10,5 @@ Developing API Extensions
|
|||
./extensions/marshaling.rst
|
||||
./extensions/properties.rst
|
||||
./extensions/actions.rst
|
||||
./extensions/tasks_locks.rst
|
||||
./extensions/tasks_locks.rst
|
||||
./extensions/ev_gui.rst
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
Classes and Modules
|
||||
===================
|
||||
|
||||
Extension class
|
||||
---------------
|
||||
.. autoclass:: labthings.server.extensions.BaseExtension
|
||||
:members:
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
Example plugin
|
||||
--------------
|
||||
|
||||
plugin.py
|
||||
+++++++++
|
||||
|
||||
.. literalinclude:: example/plugin.py
|
||||
:language: python
|
||||
|
||||
form.json
|
||||
+++++++++++
|
||||
|
||||
.. literalinclude:: example/forms.json
|
||||
:language: JSON
|
||||
|
||||
Notes
|
||||
+++++
|
||||
|
||||
In this example, if the package or file were named ``my_plugin``, the three microscope plugin methods would be accessible from ``<microscope_object>.plugin.my_plugin.identify()``, ``<microscope_object>.plugin.my_plugin.timelapse()``, and ``<microscope_object>.plugin.my_plugin.hello_world()``.
|
||||
|
||||
Web API routes would automatically be set up at ``/api/v1/plugin/my_plugin/identify`` (GET), ``/api/v1/plugin/my_plugin/timelapse`` (POST), and ``/api/v1/plugin/my_plugin/hello`` (GET, POST).
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
{
|
||||
"id": "example-plugin",
|
||||
"icon": "pets",
|
||||
"forms": [
|
||||
{
|
||||
"name": "Hello world",
|
||||
"isTask": false,
|
||||
"selfUpdate": true,
|
||||
"route": "/hello",
|
||||
"schema": [
|
||||
{
|
||||
"fieldType": "textInput",
|
||||
"placeholder": "Enter a string",
|
||||
"label": "Plugin string",
|
||||
"name": "plugin_string"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Timelapse",
|
||||
"isTask": true,
|
||||
"route": "/timelapse",
|
||||
"submitLabel": "Start timelapse",
|
||||
"schema": [
|
||||
{
|
||||
"fieldType": "numberInput",
|
||||
"name": "n_images",
|
||||
"label": "Number of images"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,145 +0,0 @@
|
|||
from openflexure_microscope.plugins import MicroscopePlugin
|
||||
from openflexure_microscope.api.views import MicroscopeViewPlugin
|
||||
from openflexure_microscope.api.utilities import JsonResponse
|
||||
|
||||
from labthings.core.tasks import taskify
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
|
||||
from flask import request, Response, escape, jsonify
|
||||
|
||||
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||
FORM_PATH = os.path.join(HERE, "forms.json")
|
||||
|
||||
### MICROSCOPE PLUGIN ###
|
||||
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
"""
|
||||
A set of default plugins
|
||||
"""
|
||||
|
||||
global FORM_PATH
|
||||
|
||||
with open(FORM_PATH, "r") as sc:
|
||||
api_form = json.load(sc)
|
||||
|
||||
api_views = {
|
||||
"/identify": IdentifyAPI,
|
||||
"/hello": HelloWorldAPI,
|
||||
"/timelapse": TimelapseAPI,
|
||||
}
|
||||
|
||||
def identify(self):
|
||||
"""
|
||||
Demonstrate access to Microscope.camera, and Microscope.stage
|
||||
"""
|
||||
|
||||
response = "My parent camera is {}, and my parent stage is {}.".format(
|
||||
self.microscope.camera, self.microscope.stage
|
||||
)
|
||||
return response
|
||||
|
||||
def hello_world(self):
|
||||
"""
|
||||
Demonstrate passive method
|
||||
"""
|
||||
|
||||
return "Hello world!"
|
||||
|
||||
def timelapse(self, n_images):
|
||||
"""
|
||||
Demonstrate a long-running method that requires microscope hardware
|
||||
"""
|
||||
print("Starting timelapse...")
|
||||
capture_array = [] # Empty list to store captures in
|
||||
|
||||
# Acquire locks. Exception is raised if lock is in use by another thread.
|
||||
with self.microscope.camera.lock, self.microscope.stage.lock:
|
||||
for _ in range(n_images):
|
||||
|
||||
# Create a data stream to capture to
|
||||
output = self.microscope.camera.new_image(temporary=False)
|
||||
|
||||
# Capture a still image from the Pi camera, into the data stream
|
||||
self.microscope.camera.capture(output.file, use_video_port=True)
|
||||
|
||||
# Append the capture data to our list
|
||||
capture_array.append(output)
|
||||
|
||||
# Wait for 1 minute
|
||||
time.sleep(60)
|
||||
|
||||
|
||||
### API VIEWS ###
|
||||
|
||||
|
||||
class IdentifyAPI(MicroscopeViewPlugin):
|
||||
"""
|
||||
A simple example API plugin, attached through the main microscope plugin.
|
||||
"""
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Method to call when an HTTP GET request is made.
|
||||
"""
|
||||
# Call a method from our plugin, using the MicroscopeViewPlugin.plugin shortcut
|
||||
data = self.plugin.identify()
|
||||
return Response(escape(data))
|
||||
|
||||
|
||||
class HelloWorldAPI(MicroscopeViewPlugin):
|
||||
"""
|
||||
A method to create, set, and return a new microscope parameter.
|
||||
"""
|
||||
|
||||
def get(self):
|
||||
"""
|
||||
Method to call when an HTTP GET request is made.
|
||||
"""
|
||||
|
||||
# If the microscope does not already contain our plugin_string attribute
|
||||
if not hasattr(self.microscope, "plugin_string"):
|
||||
# Make a string, using the MicroscopeViewPlugin.plugin shortcut
|
||||
self.microscope.plugin_string = self.plugin.hello_world()
|
||||
|
||||
json_response = jsonify({"plugin_string": self.microscope.plugin_string})
|
||||
|
||||
return Response(json_response)
|
||||
|
||||
def post(self):
|
||||
"""
|
||||
Method to call when an HTTP POST request is made.
|
||||
Assumes request will include a JSON payload.
|
||||
"""
|
||||
# Get payload JSON
|
||||
payload = JsonResponse(request)
|
||||
|
||||
# Extract a value from the JSON key 'plugin_string', and convert to a string. If no value is given, default to empty.
|
||||
new_plugin_string = payload.param("plugin_string", default="", convert=str)
|
||||
|
||||
if new_plugin_string: # If not None or empty
|
||||
# Set microscope attribute to the specified string
|
||||
self.microscope.plugin_string = new_plugin_string
|
||||
|
||||
json_response = jsonify({"plugin_string": self.microscope.plugin_string})
|
||||
|
||||
return Response(json_response)
|
||||
|
||||
|
||||
class TimelapseAPI(MicroscopeViewPlugin):
|
||||
def post(self):
|
||||
|
||||
# Get any JSON data in the body of the POST request
|
||||
payload = JsonResponse(request)
|
||||
|
||||
# Extract the "n_images" parameter if it was passed. Otherwise, default to 10.
|
||||
n_images = payload.param("n_images", default=10, convert=int)
|
||||
|
||||
# Attach the long-running method as a microscope task
|
||||
self.timelapse_task = taskify(self.plugin.timelapse)(n_images)
|
||||
|
||||
# Return the state of the task (will show ID, start time, and status before the task has finished)
|
||||
return jsonify(self.timelapse_task.state), 201
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
{
|
||||
"id": "test-plugin",
|
||||
"icon": "pets",
|
||||
"forms": [{
|
||||
"name": "Simple request",
|
||||
"isCollapsible": false,
|
||||
"isTask": false,
|
||||
"selfUpdate": true,
|
||||
"route": "/do",
|
||||
"submitLabel": "Do things",
|
||||
"schema": [{
|
||||
"fieldType": "numberInput",
|
||||
"placeholder": "Some integer",
|
||||
"name": "val_int",
|
||||
"label": "Number value"
|
||||
},
|
||||
{
|
||||
"fieldType": "textInput",
|
||||
"placeholder": "Some string",
|
||||
"label": "String value",
|
||||
"name": "val_str"
|
||||
},
|
||||
{
|
||||
"fieldType": "radioList",
|
||||
"name": "val_radio",
|
||||
"label": "Radio value",
|
||||
"options": ["First", "Second", "Third"]
|
||||
},
|
||||
{
|
||||
"fieldType": "checkList",
|
||||
"name": "val_check",
|
||||
"label": "Checklist values",
|
||||
"options": ["Foo", "Bar", "Baz"]
|
||||
},
|
||||
{
|
||||
"fieldType": "htmlBlock",
|
||||
"name": "html_block",
|
||||
"content": "<i>This is a block of HTML in a plugin!</i>"
|
||||
},
|
||||
{
|
||||
"fieldType": "selectList",
|
||||
"name": "val_select",
|
||||
"multi": false,
|
||||
"label": "Some selection",
|
||||
"options": ["Most", "Average", "Least"]
|
||||
},
|
||||
{
|
||||
"fieldType": "textInput",
|
||||
"placeholder": "Some string",
|
||||
"label": "Non-persistent string",
|
||||
"name": "val_disposable"
|
||||
},
|
||||
{
|
||||
"fieldType": "keyvalList",
|
||||
"label": "A key-val object",
|
||||
"name": "val_keyval",
|
||||
"value": {
|
||||
"key1": "foo",
|
||||
"key2": "bar"
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldType": "tagList",
|
||||
"label": "A tag list",
|
||||
"name": "val_tags",
|
||||
"value": ["tag2", "tag2", "squidward"]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Task form",
|
||||
"isTask": true,
|
||||
"selfUpdate": true,
|
||||
"route": "/task",
|
||||
"submitLabel": "Start task",
|
||||
"schema": [{
|
||||
"fieldType": "numberInput",
|
||||
"name": "run_time",
|
||||
"label": "Run time (seconds)"
|
||||
}]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
Accessing the microscope
|
||||
========================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
Introduction
|
||||
------------
|
||||
All instances of :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewPlugin` must be attached via a microscope plugin. As a result of this, the ``MicroscopeViewPlugin`` class has a ``self.microscope`` attribute, allowing direct access to the :py:class:`openflexure_microscope.Microscope` object. This means that web API plugins can simply chain together basic microscope functions and expose new API routes. No additional microscope functionality is required. However, in most cases web API plugins will serve to provide API routes to new microscope functionality defined in a microscope plugin.
|
||||
|
||||
|
||||
Tasks and Locks
|
||||
---------------
|
||||
Two principles have been implemented to regulate and synchronise access to the microscope hardware: tasks, and locks.
|
||||
Especially when writing plugins with API routes, careful use of both tasks and locks should be implemented.
|
||||
|
||||
Tasks
|
||||
+++++
|
||||
Tasks are introduced to manage long-running functions in a way that does not block HTTP requests. Without
|
||||
the use of tasks, long-running functions would block an HTTP response until the function returns, often
|
||||
resulting in a timeout.
|
||||
|
||||
To prevent this, any function can be offloaded to a background task. This is done through the :py:meth:`labthings.tasks.taskify`
|
||||
function, by calling ``taskify(<long_running_function>)(*args, **kwargs)``. Internally, the ``tasks`` submodule stores a list
|
||||
of all requested tasks, which themselves contain a ``state`` dictionary. This dictionary stores the status of the task (if it
|
||||
is idle, running, error, or success), information about the start and end times, a unique task ID, and the return value of
|
||||
the long-running function.
|
||||
|
||||
By using tasks, a function can be started in the background, and it's return value fetched at a later time once it has reported
|
||||
success. If a long-running task is started by some client, it should note the ID returned in the task state JSON, and use this to
|
||||
periodically check on the status of that particular task. API routes have been created to allow checking the state of all tasks
|
||||
(GET `/task`), a particular task by ID (GET `/task/<task_id>`), removing individual tasks (DELETE `/task/<task_id>`), and pruning
|
||||
the task list of any no-longer-running tasks (DELETE `/task`).
|
||||
|
||||
An example of a long running task may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from openflexure_microscope.plugins import MicroscopeViewPlugin
|
||||
from labthings.tasks import taskify
|
||||
|
||||
class MyPlugin(MicroscopeViewPlugin):
|
||||
def post(self):
|
||||
# Attach the long-running method as a microscope task
|
||||
self.my_task = taskify(self.plugin.long_running_function)()
|
||||
return jsonify(self.my_task.state), 201
|
||||
|
||||
After some time, once the task has completed, it could be retreived using:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
...
|
||||
def get_result(self):
|
||||
self.my_task.state['status'] == "success":
|
||||
return self.my_task.state['return']
|
||||
|
||||
Locks
|
||||
+++++
|
||||
Locks have been implemented to solve a distinct issue, most obvious when considering long-running tasks. During
|
||||
a long task such as a tile-scan or autofocus, it is absolutely necesarry to block any completing interaction with
|
||||
the microscope hardware. For example, even if the stage is not actively moving (for example during a capture phase
|
||||
within a tile scan), another user should not be able to move the microscope, interrupting the task. Thread locks act
|
||||
to prevent this.
|
||||
|
||||
The camera and stage both contain an instance of :py:class:`labthings.lock.StrictLock`, named ``lock``.
|
||||
Built-in functions such as capture and move will always acquire this lock for the duration of the function. This ensures
|
||||
that, for example, simultaneous attemps to move do not occur.
|
||||
|
||||
More importantly, however, tasks can hold on to these locks for longer periods of time, blocking any other calls to the hardware.
|
||||
|
||||
For example, a timelapse plugin may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from openflexure_microscope.plugins import MicroscopePlugin
|
||||
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
|
||||
from openflexure_microscope.api.utilities import JsonResponse
|
||||
|
||||
from labthings.tasks import taskify
|
||||
|
||||
|
||||
### MICROSCOPE PLUGIN ###
|
||||
|
||||
class MyPluginClass(MicroscopePlugin):
|
||||
|
||||
api_views = {
|
||||
'/timelapse': TimelapseAPI,
|
||||
}
|
||||
|
||||
def timelapse(self, n_images):
|
||||
"""
|
||||
Demonstrate a long-running method that requires microscope hardware
|
||||
"""
|
||||
print("Starting timelapse...")
|
||||
capture_array = [] # Empty list to store captures in
|
||||
|
||||
# Acquire locks. Exception is raised if lock is in use by another thread.
|
||||
with self.microscope.camera.lock, self.microscope.stage.lock:
|
||||
for _ in range(n_images):
|
||||
|
||||
# Create a data stream to capture to
|
||||
output = self.microscope.camera.new_image(
|
||||
temporary=False)
|
||||
|
||||
# Capture a still image from the Pi camera, into the data stream
|
||||
self.microscope.camera.capture(
|
||||
output.file,
|
||||
use_video_port=True)
|
||||
|
||||
# Append the capture data to our list
|
||||
capture_array.append(output)
|
||||
|
||||
# Wait for 1 minute
|
||||
time.sleep(60)
|
||||
|
||||
return capture_array
|
||||
|
||||
|
||||
### API ROUTES ###
|
||||
|
||||
class TimelapseAPI(MicroscopeViewPlugin):
|
||||
def post(self):
|
||||
|
||||
# Get any JSON data in the body of the POST request
|
||||
payload = JsonPayload(request)
|
||||
|
||||
# Extract the "n_images" parameter if it was passed. Otherwise, default to 10.
|
||||
n_images = payload.param('n_images', default=10, convert=int)
|
||||
|
||||
# Attach the long-running method as a microscope task
|
||||
self.timelapse_task = taskify(self.plugin.timelapse)(n_images)
|
||||
|
||||
# Return the state of the task (will show ID, start time, and status before the task has finished)
|
||||
return jsonify(self.timelapse_task.state), 201
|
||||
|
||||
|
||||
Notice that even though we never use the stage here, we still acquire the lock. This means that during the timelapse,
|
||||
no other user is able to move the stage, or take separate captures. Control of the microscope is handed exclusively
|
||||
to the thread that obtains the lock, which in this case is the thread spawned when handling a POST request:
|
||||
``self.microscope.task.start(self.plugin.timelapse, n_images)``.
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
Introduction
|
||||
============
|
||||
|
||||
Extensions allow functionality to be added to the OpenFlexure Microscope web API without having to modify the base code.
|
||||
They have full access to the :py:class:`openflexure_microscope.Microscope` object,
|
||||
including direct access to any attached :py:class:`openflexure_microscope.camera.base.BaseCamera` and :py:class:`openflexure_stage.stage.OpenFlexureStage` objects.
|
||||
This also allows access to the :py:class:`picamera.PiCamera` object.
|
||||
|
||||
Extensions can either be loaded from a single Python file, or as a Python package installed to the environment being used.
|
||||
|
||||
Single-file extensions
|
||||
----------------------------
|
||||
For adding simple functionality, such as a few basic functions and API routes, a single Python file can be loaded as a extension. This Python file must contain all of your extension objects, and be located in the applications extensions directory (by default ``~/.openflexure/microscope_extensions``).
|
||||
|
||||
Package extensions
|
||||
---------------
|
||||
Generally, for adding anything other than very simple functionality, extensions should be written as `package distributions <https://packaging.python.org/tutorials/packaging-projects/>`_. This has the advantage of allowing relative imports, so functionality can be easily split over several files. For example, class definitions associated with API routes can be separated from class definitions associated with the microscope extension.
|
||||
|
||||
The main restriction is that the extension package must be importable using an absolute import from within the Python environment being used to load your microscope.
|
||||
|
||||
In order to enable your packaged extension, create a file in the applications extensions directory (by default ``~/.openflexure/microscope_extensions``) which imports your extension object(s) from your module.
|
||||
|
|
@ -1,152 +0,0 @@
|
|||
Adding web API views
|
||||
====================
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
Introduction
|
||||
------------
|
||||
Extensions can create views to expose extension functionality via the web API. Creating API views for your extension is strongly recommended, as this is the primary way we encourage interaction with the microscope device.
|
||||
|
||||
To create API views, add a dictionary to your extension class, named ``api_views``. Within this dictionary, each key should be a string defining the route URL, whose value is a class, subclassing :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewExtension`.
|
||||
|
||||
For example, your ``api_views`` dictionary may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyExtensionClass(MicroscopeExtension):
|
||||
|
||||
api_views = {
|
||||
'/myextension': MyRouteAPI,
|
||||
}
|
||||
|
||||
Here, ``MyRouteAPI`` is a web API extension class, subclassing :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewExtension`. If your extension package were named ``myextensions.package``, an API route would be automatically added at ``/api/v1/extension/myextensions/package/myextension``.
|
||||
|
||||
The full URL that your extension will attach to is essentially identical to it's full module path. That is, if your extension is loaded from ``my_microscope_extensions.myextensionpackage:MyExtensionClass``, then your extension views will appear at ``<microscope_url>/extension/my_microscope_extensions/myextensionpackage/<route>``. While this means that extension views can get long very quickly, they will generally only ever be accessed by client applications, and so this generally should not be a problem.
|
||||
|
||||
|
||||
The MicroscopeViewExtension class
|
||||
------------------------------
|
||||
|
||||
All API extension classes must subclass :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewExtension`, which is itself a subclass of `Flask's MethodView <http://flask.pocoo.org/docs/1.0/api/#flask.views.MethodView>`_. This greatly simplifies defining different functionality associated with different HTTP methods at a single URL route.
|
||||
It is best practice to clearly separate out types of functionality by HTTP method. For example, a GET request should never change the state of the microscope. For this, POST or PUT requests are acceptable. Parameters should be passed to POST and PUT requests as JSON payloads, and your methods should include fallback code for cases where parameters are not passed, or are passed in an invalid format. The DELETE method should only be used in situations where your extension creates additional URL views for newly created objects, and should serve only to delete these objects and views.
|
||||
|
||||
Each HTTP method maps to a function with the same name, in lowercase. For example, your :py:class:`openflexure_microscope.api.v1.views.MicroscopeViewExtension` may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from flask import jsonify
|
||||
...
|
||||
class MyRouteAPI(MicroscopeViewExtension):
|
||||
|
||||
def get(self):
|
||||
# Retrieve some information, without changing the state of the microscope
|
||||
...
|
||||
|
||||
def post(self):
|
||||
# Change the state of the microscope based on passed parameters
|
||||
...
|
||||
|
||||
Sometimes you will need to create variable API views. For example, the built-in views for managing capture data use capture IDs in the request URL to specify which capture data should be returned. Extensions can also access this functionality. This is done using `Flask variable rules <http://flask.pocoo.org/docs/1.0/quickstart/#variable-rules>`_. Here, variables are added to the URL route string by marking them with ``<variable_name>``, which then passes ``variable_name`` to your request function as a keyword argument.
|
||||
|
||||
For example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
class MyExtensionClass(MicroscopeExtension):
|
||||
|
||||
api_views = {
|
||||
'/myextension/<object_id>': MyRouteAPI,
|
||||
}
|
||||
...
|
||||
|
||||
class MyRouteAPI(MicroscopeViewExtension):
|
||||
|
||||
def get(self, object_id):
|
||||
# Retrieve some information about object_id
|
||||
this_object = object_dictionary[object_id]
|
||||
...
|
||||
|
||||
Calling extension methods from views
|
||||
++++++++++++++++++++++++++++++++++
|
||||
|
||||
Instances of MicroscopeViewExtension have direct access to their associated microscope extension methods, without needing to know the extension namespace in advance. As described earlier in this section, all extensions get attached to the microscope in their own namespace, based on the extensions name. This means there are two equivalent ways to access your extension methods from a web API extension:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
...
|
||||
# Call a method from our extension, using the MicroscopeViewExtension.extension shortcut
|
||||
self.extension.my_extension_method()
|
||||
|
||||
# Call a method from our extension, using the full route
|
||||
self.microscope.my_extension_name.my_extension_method()
|
||||
...
|
||||
|
||||
Building responses
|
||||
++++++++++++++++++
|
||||
|
||||
Since we are using Flask as our web framework, you're able to return any type of `Flask response <http://flask.pocoo.org/docs/1.0/quickstart/#about-responses>`_ to be passed back to the client. However, since the server is designed to be accessed by clients as a simple API, it is **strongly** recommended that responses are JSON formatted. Both GET and POST methods should return a JSON object describing the current state of the microscope relevant to the action performed.
|
||||
|
||||
Fortunately, Flask includes a ``jsonify`` method to convert any Python dictionary into a valid JSON response. See the `Flask documentation <http://flask.pocoo.org/docs/1.0/api/#flask.json.jsonify>`_ for more information.
|
||||
|
||||
It is also possible to create HTTP errors using the `Flask abort method <http://flask.pocoo.org/docs/1.0/api/#flask.abort>`_. This is useful particularly if your route handles multiple resources such as captures. In the case that a client tries to modify or get the state of a nonexistent resource, you can return (for example) ``abort(404)``, or some other `more relevant HTTP error code <https://en.wikipedia.org/wiki/List_of_HTTP_status_codes>`_.
|
||||
|
||||
An example web route with simple responses may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from flask import jsonify
|
||||
...
|
||||
|
||||
class MyExtensionClass(MicroscopeExtension):
|
||||
|
||||
api_views = {
|
||||
'/myextension/<object_id>': MyRouteAPI,
|
||||
}
|
||||
...
|
||||
|
||||
class MyRouteAPI(MicroscopeViewExtension):
|
||||
|
||||
def get(self, object_id):
|
||||
|
||||
# If the requested object doesn't exist
|
||||
if not object_id in object_dictionary:
|
||||
# Abort with error 404. This prevents raising a nonspecific exception later
|
||||
abort(404)
|
||||
|
||||
# Retrieve some information, now we're sure the reqested object exists
|
||||
this_object = object_dictionary[object_id]
|
||||
|
||||
# Make a dictionary of the data to return
|
||||
data = {
|
||||
'id': this_object.id,
|
||||
'name': this_object.name
|
||||
'object_value', this_object.value
|
||||
}
|
||||
|
||||
# Return the JSON representation of our data dictionary
|
||||
return jsonify(data)
|
||||
...
|
||||
|
||||
Parsing JSON from HTTP POST requests
|
||||
------------------------------------
|
||||
To ease obtaining values from a JSON payload attached to an HTTP POST request, you can use the :py:class:`openflexure_microscope.api.utilities.JsonResponse` class. This allows parameters to be extracted by their key, with a default value supplied to avoid errors associated with nonexistent keys. Additionally, a converter function may be passed, used in the following examples to convert the type of a value. In principle, however, you can pass any single-argument function and it will apply that function to the value, if it exists.
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
...
|
||||
class MyRouteAPI(MicroscopeViewExtension):
|
||||
|
||||
def post(self):
|
||||
# Get payload JSON from request
|
||||
payload = JsonResponse(request)
|
||||
|
||||
# Try to find value associated with 'my_string' key.
|
||||
# If that key doesn't exist in the payload, return '' instead.
|
||||
# If a value does exist, convert it to a string, regardless of its original type.
|
||||
new_extension_string = payload.param('my_string', default='', convert=str)
|
||||
...
|
||||
|
||||
.. autoclass:: openflexure_microscope.api.utilities.JsonResponse
|
||||
:members:
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
Basic extension structure
|
||||
=========================
|
||||
|
||||
An extension starts as a simple instance of :py:class:`labthings.server.extensions.BaseExtension`.
|
||||
Each extension is described by a single ``BaseExtension`` instance, containing any number of methods, API views, and additional hardware components.
|
||||
|
||||
In order to access the currently running microscope object, use the :py:func:`labthings.server.find` function, with the argument ``"org.openflexure.microscope"``. Likewise, any new components attached by other extensions can be found using their full name, as above.
|
||||
|
||||
A simple extension file, with no API views but application-available methods may look like:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from labthings.server.extensions import BaseExtension
|
||||
from labthings.server.find import find_component
|
||||
|
||||
|
||||
def identify():
|
||||
"""
|
||||
Demonstrate access to Microscope.camera, and Microscope.stage
|
||||
"""
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
parent_camera = microscope.camera
|
||||
parent_stage = microscope.stage
|
||||
|
||||
response = "My parent camera is {}, and my parent stage is {}.".format(
|
||||
parent_camera, parent_stage
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def rename(new_name):
|
||||
"""
|
||||
Rename the microscope
|
||||
"""
|
||||
|
||||
microscope = find_component("org.openflexure.microscope")
|
||||
|
||||
microscope.name = new_name
|
||||
microscope.save_settings()
|
||||
|
||||
|
||||
# Create your extension object
|
||||
my_extension = BaseExtension("com.myname.myextension", version="0.0.0")
|
||||
|
||||
# Add methods to your extension
|
||||
my_extension.add_method(identify, "identify")
|
||||
my_extension.add_method(rename, "rename")
|
||||
|
||||
|
||||
Once this extension is loaded, any other extensions will have access to your methods:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from labthings.server.find import find_extension
|
||||
|
||||
def test_extension_method():
|
||||
# Find your extension. Returns None if it hasn't been found.
|
||||
my_found_extension = find_extension("com.myname.myextension")
|
||||
|
||||
# Call a function from your extension
|
||||
if my_found_extension:
|
||||
my_found_extension.identify()
|
||||
Loading…
Add table
Add a link
Reference in a new issue