diff --git a/openflexure_microscope/api/utilities.py b/openflexure_microscope/api/utilities.py
index b3f919e0..cdeca9d0 100644
--- a/openflexure_microscope/api/utilities.py
+++ b/openflexure_microscope/api/utilities.py
@@ -44,7 +44,7 @@ class JsonPayload:
else:
val = default
- if convert:
+ if convert and (val is not None):
val = convert(val)
return val
diff --git a/openflexure_microscope/api/v1/blueprints/plugins.py b/openflexure_microscope/api/v1/blueprints/plugins.py
index 9aca0f80..9c32dacd 100644
--- a/openflexure_microscope/api/v1/blueprints/plugins.py
+++ b/openflexure_microscope/api/v1/blueprints/plugins.py
@@ -1,15 +1,33 @@
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
-from flask import Blueprint
+from flask import Blueprint, jsonify
+from openflexure_microscope.api.v1.views import MicroscopeView
import logging
import warnings
+class PluginSchemaAPI(MicroscopeView):
+ def get(self):
+ """
+ Return the current plugin schemas
+
+ .. :quickref: Plugin; Get schemas
+
+ """
+ out = self.microscope.plugin.schemas
+ return jsonify(out)
+
def construct_blueprint(microscope_obj):
blueprint = Blueprint('plugin_blueprint', __name__)
+ # Create a base route to return plugin API schemas, if any exist
+ blueprint.add_url_rule(
+ '/',
+ view_func=PluginSchemaAPI.as_view('plugin_api_schema', microscope=microscope_obj)
+ )
+
all_routes = []
# For each plugin attached to the microscope object
@@ -18,27 +36,37 @@ def construct_blueprint(microscope_obj):
# If plugin contains valid endpoints
if hasattr(plugin_obj, 'api_views') and isinstance(plugin_obj.api_views, dict):
+ # We'll keep a record of how each route was expanded
+ expanded_routes = {}
+
# For each defined endpoint
for view_route, view_class in plugin_obj.api_views.items():
# Remove all leading slashes from view route
- while view_route[0] == '/':
- view_route = view_route[1:]
+ cleaned_route = view_route
+ while cleaned_route[0] == '/':
+ cleaned_route = cleaned_route[1:]
# Construct a full view route from the plugin name
- full_view_route = "/{}/{}".format(plugin_name, view_route)
+ full_view_route = "/{}/{}".format(plugin_name, cleaned_route)
logging.debug(full_view_route)
+ # Record how the view_route got expanded
+ expanded_routes[view_route] = full_view_route
+
# Check if endpoint name clashes
if full_view_route not in all_routes and issubclass(view_class, MicroscopeViewPlugin):
# Add route to main route dictionary
all_routes.append(full_view_route)
+ # Create a Python-safe name for the route
+ plugin_route_id = 'plugin{}'.format(full_view_route).replace('/', '_')
+
# Add route to the plugins blueprint
blueprint.add_url_rule(
full_view_route,
view_func=view_class.as_view(
- 'plugin_{}'.format(full_view_route).replace('/', '_'),
+ plugin_route_id,
microscope=microscope_obj,
plugin=plugin_obj
)
@@ -52,9 +80,23 @@ def construct_blueprint(microscope_obj):
)
)
+ # If plugin includes an API schema
+ if hasattr(plugin_obj, 'api_schema') and isinstance(plugin_obj.api_schema, dict):
+ schema = plugin_obj.api_schema
+ # TODO: Validate schema? We need to make sure no single plugin can break all plugins.
+ schema['id'] = plugin_name
+ if 'forms' in schema and isinstance(schema['forms'], list):
+ for form in schema['forms']:
+ if 'route' in form and form['route'] in expanded_routes.keys():
+ form['route'] = expanded_routes[form['route']]
+ else:
+ logging.warn("No valid expandable route found for {}".format(form['route']))
+
+ # Store the complete schema in Microscope().plugin.schema
+ microscope_obj.plugin.schemas.append(schema)
+
else:
warnings.warn(
"No valid 'api_views' dictionary found in {}".format(plugin_obj)
)
-
return blueprint
diff --git a/openflexure_microscope/plugins/default/autofocus/api.py b/openflexure_microscope/plugins/default/autofocus/api.py
index 9670540f..a737e7fb 100644
--- a/openflexure_microscope/plugins/default/autofocus/api.py
+++ b/openflexure_microscope/plugins/default/autofocus/api.py
@@ -32,9 +32,9 @@ class FastAutofocusAPI(MicroscopeViewPlugin):
# Figure out the parameters to use
dz = payload.param("dz", default=2000, convert=int)
- backlash = payload.param("backlash", default=None, convert=int)
+ backlash = payload.param("backlash", default=0, convert=int)
if backlash < 0:
- backlash = None
+ backlash = 0
logging.info("Running autofocus...")
task = self.microscope.task.start(self.plugin.fast_autofocus, dz, backlash=backlash)
diff --git a/openflexure_microscope/plugins/default/camera_calibration/plugin.py b/openflexure_microscope/plugins/default/camera_calibration/plugin.py
index ccd0cd2c..9096a91e 100644
--- a/openflexure_microscope/plugins/default/camera_calibration/plugin.py
+++ b/openflexure_microscope/plugins/default/camera_calibration/plugin.py
@@ -7,27 +7,9 @@ import logging
from .recalibrate_utils import recalibrate_camera, auto_expose_and_freeze_settings
-API_SCHEMA = {
- 'icon': 'lifesaver',
- 'requireConnection': True,
- 'forms': [
- {
- 'route': '/recalibrate',
- 'schema': [
- {
- 'fieldType': "htmlBlock",
- 'name': "heading",
- 'content': "This is different form in a plugin!"
- }
- ]
- }
- ]
-}
class RecalibrateAPIView(MicroscopeViewPlugin):
def post(self):
- payload = JsonPayload(request)
-
logging.info("Starting microscope recalibration...")
task = self.microscope.task.start(self.plugin.recalibrate)
@@ -44,8 +26,6 @@ class Plugin(MicroscopePlugin):
'/recalibrate': RecalibrateAPIView,
}
- api_schema = API_SCHEMA
-
def recalibrate(self):
"""Reset the camera's settings.
diff --git a/openflexure_microscope/plugins/default/scan/plugin.py b/openflexure_microscope/plugins/default/scan/plugin.py
index 06bb87a7..dbf37b0c 100644
--- a/openflexure_microscope/plugins/default/scan/plugin.py
+++ b/openflexure_microscope/plugins/default/scan/plugin.py
@@ -2,6 +2,7 @@ import time
import numpy as np
from typing import Tuple
import uuid
+import itertools
import logging
from openflexure_microscope.camera.base import generate_basename
diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py
index 35bb00f9..74b40a70 100644
--- a/openflexure_microscope/plugins/loader.py
+++ b/openflexure_microscope/plugins/loader.py
@@ -126,7 +126,8 @@ class PluginMount(object):
"""
def __init__(self, parent):
self.parent = parent
- self.plugins = []
+ self.plugins = [] # List of plugin objects
+ self.schemas = [] # List of plugin schemas
logging.info("Creating plugin mount")
@property
diff --git a/openflexure_microscope/plugins/testing/__init__.py b/openflexure_microscope/plugins/testing/__init__.py
index f42b093e..e69de29b 100644
--- a/openflexure_microscope/plugins/testing/__init__.py
+++ b/openflexure_microscope/plugins/testing/__init__.py
@@ -1 +0,0 @@
-from .plugin import Plugin
\ No newline at end of file
diff --git a/openflexure_microscope/plugins/testing/plugin.py b/openflexure_microscope/plugins/testing/plugin.py
deleted file mode 100644
index 719eb62b..00000000
--- a/openflexure_microscope/plugins/testing/plugin.py
+++ /dev/null
@@ -1,13 +0,0 @@
-from openflexure_microscope.plugins import MicroscopePlugin
-
-
-class Plugin(MicroscopePlugin):
- """
- A set of default plugins
- """
-
- def identify(self):
- """
- Tests for access to Microscope.camera, and Microscope.stage
- """
- return self.microscope.camera, self.microscope.stage
diff --git a/openflexure_microscope/plugins/testing/schema_example/__init__.py b/openflexure_microscope/plugins/testing/schema_example/__init__.py
new file mode 100644
index 00000000..3e9a8c4d
--- /dev/null
+++ b/openflexure_microscope/plugins/testing/schema_example/__init__.py
@@ -0,0 +1,2 @@
+from .plugin import ExamplePlugin
+from . import api
\ No newline at end of file
diff --git a/openflexure_microscope/plugins/testing/schema_example/api.py b/openflexure_microscope/plugins/testing/schema_example/api.py
new file mode 100644
index 00000000..67f92b62
--- /dev/null
+++ b/openflexure_microscope/plugins/testing/schema_example/api.py
@@ -0,0 +1,64 @@
+from openflexure_microscope.api.utilities import JsonPayload
+from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
+from openflexure_microscope.exceptions import TaskDeniedException
+
+from flask import request, Response, escape, jsonify
+import logging
+
+class DoAPI(MicroscopeViewPlugin):
+ """
+ A simple example API plugin
+ """
+
+ def get(self):
+ return jsonify(self.plugin.get_values_dict())
+
+ def post(self):
+ # Get payload JSON
+ payload = JsonPayload(request)
+
+ # Extract a values from the JSON payload.
+ val_int = payload.param('val_int', default=None, convert=int)
+ val_str = payload.param('val_str', default=None, convert=str)
+ val_radio = payload.param('val_radio', default=None)
+ val_check = payload.param('val_check', default=None)
+ val_select = payload.param('val_select', default=None)
+ val_disposable = payload.param('val_disposable', default=None)
+
+ self.plugin.set_values(
+ val_int,
+ val_str,
+ val_radio,
+ val_check,
+ val_select,
+ val_disposable
+ )
+
+ print(self.plugin.get_values_dict())
+
+ return jsonify({
+ 'response': 'completed'
+ })
+
+class TaskAPI(MicroscopeViewPlugin):
+ """
+ A task example API plugin
+ """
+
+ def get(self):
+ return jsonify({
+ 'run_time': self.plugin.run_time
+ })
+
+ def post(self):
+ # Get payload JSON
+ payload = JsonPayload(request)
+
+ # Extract a values from the JSON payload.
+ val_int = payload.param('run_time', default=5, convert=int)
+
+ logging.info("Running task...")
+ task = self.microscope.task.start(self.plugin.generate_random_numbers_for_a_while, val_int)
+
+ # return a handle on the autofocus task
+ return jsonify(task.state), 202
\ No newline at end of file
diff --git a/openflexure_microscope/plugins/testing/schema_example/plugin.py b/openflexure_microscope/plugins/testing/schema_example/plugin.py
new file mode 100644
index 00000000..da2264c9
--- /dev/null
+++ b/openflexure_microscope/plugins/testing/schema_example/plugin.py
@@ -0,0 +1,72 @@
+import random
+import time
+import os
+import json
+from openflexure_microscope.plugins import MicroscopePlugin
+
+from .api import DoAPI, TaskAPI
+
+HERE = os.path.dirname(os.path.realpath(__file__))
+SCHEMA_PATH = os.path.join(HERE, "schema.json")
+
+class ExamplePlugin(MicroscopePlugin):
+ """
+ An example plugin using a comprehensive schema
+ """
+ global SCHEMA_PATH
+
+ with open(SCHEMA_PATH, 'r') as sc:
+ api_schema = json.load(sc)
+
+ api_views = {
+ '/do': DoAPI,
+ '/task': TaskAPI
+ }
+
+ def __init__(self):
+ self.val_int = 10
+ self.val_str = "Hello"
+ self.val_radio = "First"
+ self.val_check = ["Foo", "Bar"]
+ self.val_select = "Most"
+ self.val_unused = "I'm an unused string, here to confuse the form parsing"
+
+ self.run_time = 5
+
+ def set_values(self, val_int, val_str, val_radio, val_check, val_select, val_disposable):
+ """
+ Demonstrate a plugin with schema
+ """
+ if val_int:
+ self.val_int = int(val_int)
+ if val_str:
+ self.val_str = str(val_str)
+ if val_radio:
+ self.val_radio = val_radio
+ if val_check is not None:
+ print(val_check)
+ self.val_check = val_check if (type(val_check) is list) else [val_check]
+ if val_select:
+ self.val_select = val_select
+
+ if val_disposable:
+ print("DISPOSABLE VALUE: {}".format(val_disposable))
+
+ def get_values_dict(self):
+ return {
+ 'val_int': self.val_int,
+ 'val_str': self.val_str,
+ 'val_radio': self.val_radio,
+ 'val_check': self.val_check,
+ 'val_select': self.val_select,
+ 'val_unused': self.val_unused
+ }
+
+ def generate_random_numbers_for_a_while(self, run_time: int):
+ self.run_time = run_time
+ vals = []
+ for _ in range(run_time):
+ vals.append(random.random())
+ time.sleep(1)
+
+ return vals
\ No newline at end of file
diff --git a/openflexure_microscope/plugins/testing/schema_example/schema.json b/openflexure_microscope/plugins/testing/schema_example/schema.json
new file mode 100644
index 00000000..4aee4dcd
--- /dev/null
+++ b/openflexure_microscope/plugins/testing/schema_example/schema.json
@@ -0,0 +1,80 @@
+{
+ "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",
+ "minValue": 0
+ },
+ {
+ "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": "This is a block of HTML in a plugin!
I can do paragraph breaks and stuff."
+ },
+ {
+ "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"
+ }
+ ]
+ },
+ {
+ "name": "Task form",
+ "isTask": true,
+ "selfUpdate": true,
+ "route": "/task",
+ "submitLabel": "Start task",
+ "schema": [
+ {
+ "fieldType": "numberInput",
+ "placeholder": "",
+ "name": "run_time",
+ "label": "Run time (seconds)",
+ "minValue": 1
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/pyproject.toml b/pyproject.toml
index 44c01cfd..f861631b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api"
[tool.poetry]
name = "openflexure_microscope"
-version = "1.1.0-beta.1"
+version = "1.1.0-beta.2"
description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope."
authors = [