Merge branch 'master' of https://gitlab.com/openflexure/openflexure-microscope-server
ESI and schema docs update
This commit is contained in:
commit
5424883a38
11 changed files with 708 additions and 176 deletions
|
|
@ -64,7 +64,7 @@ extensions = [
|
||||||
'sphinx.ext.ifconfig',
|
'sphinx.ext.ifconfig',
|
||||||
'sphinxcontrib.httpdomain',
|
'sphinxcontrib.httpdomain',
|
||||||
'sphinxcontrib.autohttp.flask',
|
'sphinxcontrib.autohttp.flask',
|
||||||
'sphinxcontrib.autohttp.flaskqref',
|
'sphinxcontrib.autohttp.flaskqref'
|
||||||
]
|
]
|
||||||
|
|
||||||
# Override ordering
|
# Override ordering
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,6 @@ Developing Plugins
|
||||||
./plugins/structure.rst
|
./plugins/structure.rst
|
||||||
./plugins/routes.rst
|
./plugins/routes.rst
|
||||||
./plugins/hardware.rst
|
./plugins/hardware.rst
|
||||||
|
./plugins/schema.rst
|
||||||
./plugins/example.rst
|
./plugins/example.rst
|
||||||
./plugins/class.rst
|
./plugins/class.rst
|
||||||
|
|
@ -1,135 +1,20 @@
|
||||||
Example plugin
|
Example plugin
|
||||||
--------------
|
--------------
|
||||||
|
|
||||||
.. code-block:: python
|
plugin.py
|
||||||
|
+++++++++
|
||||||
|
|
||||||
from openflexure_microscope.plugins import MicroscopePlugin
|
.. literalinclude:: example/plugin.py
|
||||||
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
|
:language: python
|
||||||
from openflexure_microscope.api.utilities import JsonPayload
|
|
||||||
|
|
||||||
from flask import request, Response, escape
|
schema.json
|
||||||
|
+++++++++++
|
||||||
|
|
||||||
|
.. literalinclude:: example/schema.json
|
||||||
|
:language: JSON
|
||||||
|
|
||||||
### MICROSCOPE PLUGIN ###
|
Notes
|
||||||
|
+++++
|
||||||
class MyPluginClass(MicroscopePlugin):
|
|
||||||
"""
|
|
||||||
A set of default plugins
|
|
||||||
"""
|
|
||||||
|
|
||||||
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
|
|
||||||
capture_data = self.microscope.camera.new_image(
|
|
||||||
write_to_file=True,
|
|
||||||
temporary=False)
|
|
||||||
|
|
||||||
# Capture a still image from the Pi camera, into the data stream
|
|
||||||
self.microscope.camera.capture(
|
|
||||||
capture_data,
|
|
||||||
use_video_port=True)
|
|
||||||
|
|
||||||
# Append the capture data to our list
|
|
||||||
capture_array.append(capture_data)
|
|
||||||
|
|
||||||
# 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()
|
|
||||||
|
|
||||||
return Response(self.microscope.plugin_string)
|
|
||||||
|
|
||||||
def post(self):
|
|
||||||
"""
|
|
||||||
Method to call when an HTTP POST request is made.
|
|
||||||
Assumes request will include a JSON payload.
|
|
||||||
"""
|
|
||||||
# Get payload JSON
|
|
||||||
payload = JsonPayload(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
|
|
||||||
|
|
||||||
return Response(self.microscope.plugin_string)
|
|
||||||
|
|
||||||
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 = self.microscope.task.start(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), 202
|
|
||||||
|
|
||||||
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()``.
|
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()``.
|
||||||
|
|
||||||
|
|
|
||||||
146
docs/source/plugins/example/plugin.py
Normal file
146
docs/source/plugins/example/plugin.py
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
from openflexure_microscope.plugins import MicroscopePlugin
|
||||||
|
from openflexure_microscope.api.v1.views import MicroscopeViewPlugin
|
||||||
|
from openflexure_microscope.api.utilities import JsonPayload
|
||||||
|
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
|
from flask import request, Response, escape, jsonify
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.realpath(__file__))
|
||||||
|
SCHEMA_PATH = os.path.join(HERE, "schema.json")
|
||||||
|
|
||||||
|
### MICROSCOPE PLUGIN ###
|
||||||
|
|
||||||
|
class MyPluginClass(MicroscopePlugin):
|
||||||
|
"""
|
||||||
|
A set of default plugins
|
||||||
|
"""
|
||||||
|
|
||||||
|
global SCHEMA_PATH
|
||||||
|
|
||||||
|
with open(SCHEMA_PATH, 'r') as sc:
|
||||||
|
api_schema = 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
|
||||||
|
capture_data = self.microscope.camera.new_image(
|
||||||
|
write_to_file=True,
|
||||||
|
temporary=False)
|
||||||
|
|
||||||
|
# Capture a still image from the Pi camera, into the data stream
|
||||||
|
self.microscope.camera.capture(
|
||||||
|
capture_data,
|
||||||
|
use_video_port=True)
|
||||||
|
|
||||||
|
# Append the capture data to our list
|
||||||
|
capture_array.append(capture_data)
|
||||||
|
|
||||||
|
# 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 = JsonPayload(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 = 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 = self.microscope.task.start(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), 202
|
||||||
33
docs/source/plugins/example/schema.json
Normal file
33
docs/source/plugins/example/schema.json
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
192
docs/source/plugins/schema.rst
Normal file
192
docs/source/plugins/schema.rst
Normal file
|
|
@ -0,0 +1,192 @@
|
||||||
|
Adding a GUI
|
||||||
|
=====================
|
||||||
|
|
||||||
|
Introduction
|
||||||
|
------------
|
||||||
|
|
||||||
|
In order to bind user-interface elements to your plugin, an ``api_schema`` variable must be included in your microscope plugin, similar to your ``api_views``.
|
||||||
|
|
||||||
|
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 schema as a JSON file, and load that file as a dictionary into your plugin. For example:
|
||||||
|
|
||||||
|
.. code-block:: python
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
HERE = os.path.dirname(os.path.realpath(__file__)) # Find the full path of your plugin Python file
|
||||||
|
SCHEMA_PATH = os.path.join(HERE, "schema.json") # Find the full path of the adjascent JSON file
|
||||||
|
|
||||||
|
class MyPluginClass(MicroscopePlugin):
|
||||||
|
|
||||||
|
with open(SCHEMA_PATH, 'r') as sc: # Open the JSON file
|
||||||
|
api_schema = json.load(sc) # Load the JSON file into the api_schema dictionary
|
||||||
|
|
||||||
|
Throughout this documentation, all example of ``api_schema`` sections will be in JSON format. Keep in mind however that it is possible to directly define your ``api_schema`` as a dictionary, without loading an external file.
|
||||||
|
|
||||||
|
|
||||||
|
Forms from ``api_schema``
|
||||||
|
-------------------------
|
||||||
|
|
||||||
|
The ``api_schema`` 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*.
|
||||||
|
|
||||||
|
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_schema``
|
||||||
|
---------------------------
|
||||||
|
|
||||||
|
Root level
|
||||||
|
++++++++++
|
||||||
|
|
||||||
|
The root of your ``api_schema`` expects 3 properties:
|
||||||
|
|
||||||
|
``id`` - A unique ID to give your client-side plugin
|
||||||
|
|
||||||
|
``icon`` - The name of a Material Design icon to use for your plugin
|
||||||
|
|
||||||
|
``forms`` - An array of forms as described below
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Each form is described by a JSON object, with the following properties:
|
||||||
|
|
||||||
|
``name`` - A human-readable name for the form
|
||||||
|
|
||||||
|
``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
|
||||||
|
|
||||||
|
``submitLabel`` *(optional)* - String to place inside of the form's submit button
|
||||||
|
|
||||||
|
``schema`` - An array of form components as described below
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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
|
||||||
|
^^^^^^^^^^^^^^^^^^^^
|
||||||
|
|
||||||
|
You can request that the client render several components in a horizontal grid by placing them in an array. You cannot nest arrays however. Each component in the array will be rendered with equal width as far as possible.
|
||||||
|
|
||||||
|
Overview of components
|
||||||
|
----------------------
|
||||||
|
|
||||||
|
.. list-table::
|
||||||
|
:widths: 10 10 40 20
|
||||||
|
:header-rows: 1
|
||||||
|
:stub-columns: 1
|
||||||
|
|
||||||
|
* - fieldType
|
||||||
|
- Data type
|
||||||
|
- Properties
|
||||||
|
- Example
|
||||||
|
* - checkList
|
||||||
|
- [str, str,...]
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**label** (str) Friendly label for the component
|
||||||
|
|
||||||
|
**value** ([str, str,...]) List of selected options
|
||||||
|
|
||||||
|
**options** ([str, str,...]) List of all options
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/checkList.png
|
||||||
|
* - htmlBlock
|
||||||
|
- N/A
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**label** (str) Friendly label for the component
|
||||||
|
|
||||||
|
**content** (str) HTML string to be rendered
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/htmlBlock.png
|
||||||
|
* - keyvalList
|
||||||
|
- dict
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**value** (dict) Dictionary of key-value pairs
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/keyvalList.png
|
||||||
|
* - labelInput
|
||||||
|
- str
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**label** (str) Friendly label for the component
|
||||||
|
|
||||||
|
**value** (str) Value of the editable label text
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/labelInput.png
|
||||||
|
* - numberInput
|
||||||
|
- int
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**label** (str) Friendly label for the component
|
||||||
|
|
||||||
|
**value** (int) Value of the input
|
||||||
|
|
||||||
|
**placeholder** (int) Placeholder value
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/numberInput.png
|
||||||
|
* - radioList
|
||||||
|
- String
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**label** (str) Friendly label for the component
|
||||||
|
|
||||||
|
**value** (str) Currently selected option
|
||||||
|
|
||||||
|
**options** ([str, str,...]) List of all options
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/radioList.png
|
||||||
|
* - selectList
|
||||||
|
- str
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**label** (str) Friendly label for the component
|
||||||
|
|
||||||
|
**value** (str) Currently selected option
|
||||||
|
|
||||||
|
**options** ([str, str,...]) List of all options
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/selectList.png
|
||||||
|
* - tagList
|
||||||
|
- [str, str,...]
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**value** ([str, str,...]) List of tag strings
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/tagList.png
|
||||||
|
* - textInput
|
||||||
|
- str
|
||||||
|
- **name** (str) Unique name of the component
|
||||||
|
|
||||||
|
**label** (str) Friendly label for the component
|
||||||
|
|
||||||
|
**value** (int) Value of the input
|
||||||
|
|
||||||
|
**placeholder** (str) Placeholder value
|
||||||
|
|
||||||
|
- .. figure:: https://openflexure.gitlab.io/assets/plugin-form-components/textInput.png
|
||||||
|
|
||||||
|
JSON form example
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
.. literalinclude:: schema_example.json
|
||||||
|
:language: JSON
|
||||||
|
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
|
./schema/json_schema.rst
|
||||||
5
docs/source/plugins/schema/json_schema.rst
Normal file
5
docs/source/plugins/schema/json_schema.rst
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
Full JSON Schema
|
||||||
|
================
|
||||||
|
|
||||||
|
.. literalinclude:: schema.json
|
||||||
|
:language: JSON
|
||||||
187
docs/source/plugins/schema/schema.json
Normal file
187
docs/source/plugins/schema/schema.json
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
{
|
||||||
|
"definitions": {},
|
||||||
|
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||||
|
"$id": "http://example.com/root.json",
|
||||||
|
"type": "object",
|
||||||
|
"title": "The Root Schema",
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"icon",
|
||||||
|
"forms"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$id": "#/properties/id",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Unique ID",
|
||||||
|
"description": "A unique name for the plugin UI",
|
||||||
|
"default": "",
|
||||||
|
"examples": [
|
||||||
|
"test-plugin"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"icon": {
|
||||||
|
"$id": "#/properties/icon",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Icon name",
|
||||||
|
"description": "Name of the Material Design icon to use for the plugin tab",
|
||||||
|
"default": "",
|
||||||
|
"examples": [
|
||||||
|
"pets"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"forms": {
|
||||||
|
"$id": "#/properties/forms",
|
||||||
|
"type": "array",
|
||||||
|
"title": "Forms array",
|
||||||
|
"description": "Array of form descriptions to be displayed",
|
||||||
|
"default": null,
|
||||||
|
"items": {
|
||||||
|
"$id": "#/properties/forms/items",
|
||||||
|
"type": "object",
|
||||||
|
"title": "The Items Schema",
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"route",
|
||||||
|
"schema"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"$id": "#/properties/forms/items/properties/name",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Form name",
|
||||||
|
"description": "Human-readable form name",
|
||||||
|
"default": "",
|
||||||
|
"examples": [
|
||||||
|
"Simple request"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"isCollapsible": {
|
||||||
|
"$id": "#/properties/forms/items/properties/isCollapsible",
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "The isCollapsible Schema",
|
||||||
|
"description": "Should the form be rendered as a collapsible item",
|
||||||
|
"default": false,
|
||||||
|
"examples": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"isTask": {
|
||||||
|
"$id": "#/properties/forms/items/properties/isTask",
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "The isTask Schema",
|
||||||
|
"description": "Should the form's submit function be treated as a long-running task",
|
||||||
|
"default": false,
|
||||||
|
"examples": [
|
||||||
|
false
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"selfUpdate": {
|
||||||
|
"$id": "#/properties/forms/items/properties/selfUpdate",
|
||||||
|
"type": "boolean",
|
||||||
|
"title": "The selfUpdate Schema",
|
||||||
|
"description": "Should the form's component values be automatically updated with a GET request",
|
||||||
|
"default": false,
|
||||||
|
"examples": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"route": {
|
||||||
|
"$id": "#/properties/forms/items/properties/route",
|
||||||
|
"type": "string",
|
||||||
|
"title": "The Route Schema",
|
||||||
|
"description": "Form submit POST request's corresponding api_views route",
|
||||||
|
"default": "",
|
||||||
|
"examples": [
|
||||||
|
"/do"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"submitLabel": {
|
||||||
|
"$id": "#/properties/forms/items/properties/submitLabel",
|
||||||
|
"type": "string",
|
||||||
|
"title": "The submitLabel Schema",
|
||||||
|
"description": "String to place in submit button",
|
||||||
|
"default": "Submit",
|
||||||
|
"examples": [
|
||||||
|
"Do my function"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"schema": {
|
||||||
|
"$id": "#/properties/forms/items/properties/schema",
|
||||||
|
"type": "array",
|
||||||
|
"title": "Form component array",
|
||||||
|
"items": {
|
||||||
|
"$id": "#/properties/forms/items/properties/schema/items",
|
||||||
|
"type": "object",
|
||||||
|
"title": "A form component",
|
||||||
|
"required": [
|
||||||
|
"fieldType",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"fieldType": {
|
||||||
|
"$id": "#/properties/forms/items/properties/schema/items/properties/fieldType",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Component type",
|
||||||
|
"default": "",
|
||||||
|
"examples": [
|
||||||
|
"numberInput",
|
||||||
|
"textInput",
|
||||||
|
"radioList",
|
||||||
|
"checkList",
|
||||||
|
"htmlBlock",
|
||||||
|
"selectList",
|
||||||
|
"textInput",
|
||||||
|
"keyvalList",
|
||||||
|
"taglList"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"placeholder": {
|
||||||
|
"$id": "#/properties/forms/items/properties/schema/items/properties/placeholder",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Component placeholder value",
|
||||||
|
"default": "",
|
||||||
|
"examples": [
|
||||||
|
"Enter your name"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"$id": "#/properties/forms/items/properties/schema/items/properties/name",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Unique component name",
|
||||||
|
"examples": [
|
||||||
|
"my_form_component_1"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"$id": "#/properties/forms/items/properties/schema/items/properties/label",
|
||||||
|
"type": "string",
|
||||||
|
"title": "Component label (if applicable)",
|
||||||
|
"default": "",
|
||||||
|
"examples": [
|
||||||
|
"My form component"
|
||||||
|
],
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"$id": "#/properties/forms/items/properties/schema/items/properties/value",
|
||||||
|
"type": ["array", "boolean", "integer", "number", "object", "string"],
|
||||||
|
"title": "Component value",
|
||||||
|
"pattern": "^(.*)$"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
87
docs/source/plugins/schema_example.json
Normal file
87
docs/source/plugins/schema_example.json
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
{
|
||||||
|
"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)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -54,7 +54,7 @@ class ExtensibleSerialInstrument(object):
|
||||||
ignore_echo = False
|
ignore_echo = False
|
||||||
port_settings = {}
|
port_settings = {}
|
||||||
|
|
||||||
def __init__(self, port, **kwargs):
|
def __init__(self, port=None, **kwargs):
|
||||||
"""
|
"""
|
||||||
Set up the serial port and so on.
|
Set up the serial port and so on.
|
||||||
"""
|
"""
|
||||||
|
|
@ -64,7 +64,7 @@ class ExtensibleSerialInstrument(object):
|
||||||
self.open(port, False) # Eventually this shouldn't rely on init...
|
self.open(port, False) # Eventually this shouldn't rely on init...
|
||||||
logging.info("Opened ESI connection to port {}".format(port))
|
logging.info("Opened ESI connection to port {}".format(port))
|
||||||
|
|
||||||
def open(self, port, quiet=True):
|
def open(self, port=None, quiet=True):
|
||||||
"""Open communications with the serial port.
|
"""Open communications with the serial port.
|
||||||
|
|
||||||
If no port is specified, it will attempt to autodetect. If quiet=True
|
If no port is specified, it will attempt to autodetect. If quiet=True
|
||||||
|
|
@ -74,6 +74,8 @@ class ExtensibleSerialInstrument(object):
|
||||||
if hasattr(self,'_ser') and self._ser.isOpen():
|
if hasattr(self,'_ser') and self._ser.isOpen():
|
||||||
if not quiet: logging.warning("Attempted to open an already-open port!")
|
if not quiet: logging.warning("Attempted to open an already-open port!")
|
||||||
return
|
return
|
||||||
|
if port is None:
|
||||||
|
port=self.find_port()
|
||||||
assert port is not None, "We don't have a serial port to open, meaning you didn't specify a valid port. Are you sure the instrument is connected?"
|
assert port is not None, "We don't have a serial port to open, meaning you didn't specify a valid port. Are you sure the instrument is connected?"
|
||||||
self._ser = serial.Serial(port,**self.port_settings)
|
self._ser = serial.Serial(port,**self.port_settings)
|
||||||
#the block above wraps the serial IO layer with a text IO layer
|
#the block above wraps the serial IO layer with a text IO layer
|
||||||
|
|
@ -199,15 +201,15 @@ class ExtensibleSerialInstrument(object):
|
||||||
response_regex = response_string
|
response_regex = response_string
|
||||||
noop = lambda x: x #placeholder null parse function
|
noop = lambda x: x #placeholder null parse function
|
||||||
placeholders = [ #tuples of (regex matching placeholder, regex to replace it with, parse function)
|
placeholders = [ #tuples of (regex matching placeholder, regex to replace it with, parse function)
|
||||||
(r"%c",r".", noop),
|
(r"%c", r".", noop),
|
||||||
(r"%(\d+)c",r".{\1}", noop), #TODO support %cn where n is a number of chars
|
(r"%(\d+)c", r".{\1}", noop), #TODO support %cn where n is a number of chars
|
||||||
(r"%d",r"[-+]?\d+", int),
|
(r"%d", r"[-+]?\\d+", int),
|
||||||
(r"%[eEfg]",r"[-+]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][-+]?\d+)?", float),
|
(r"%[eEfg]", r"[-+]?(?:\\d+(?:\.\\d*)?|\.\\d+)(?:[eE][-+]?\\d+)?", float),
|
||||||
(r"%i",r"[-+]?(?:0[xX][\dA-Fa-f]+|0[0-7]*|\d+)", lambda x: int(x, 0)), #0=autodetect base
|
(r"%i", r"[-+]?(?:0[xX][\\dA-Fa-f]+|0[0-7]*|\\d+)", lambda x: int(x, 0)), #0=autodetect base
|
||||||
(r"%o",r"[-+]?[0-7]+", lambda x: int(x, 8)), #8 means octal
|
(r"%o", r"[-+]?[0-7]+", lambda x: int(x, 8)), #8 means octal
|
||||||
(r"%s",r"\S+",noop),
|
(r"%s", r"\\s+", noop),
|
||||||
(r"%u",r"\d+",int),
|
(r"%u", r"\\d+", int),
|
||||||
(r"%[xX]",r"[-+]?(?:0[xX])?[\dA-Fa-f]+",lambda x: int(x, 16)), #16 forces hexadecimal
|
(r"%[xX]", r"[-+]?(?:0[xX])?[\\dA-Fa-f]+", lambda x: int(x, 16)), #16 forces hexadecimal
|
||||||
]
|
]
|
||||||
matched_placeholders = []
|
matched_placeholders = []
|
||||||
for placeholder, regex, parse_fun in placeholders:
|
for placeholder, regex, parse_fun in placeholders:
|
||||||
|
|
@ -262,6 +264,31 @@ class ExtensibleSerialInstrument(object):
|
||||||
with self.communications_lock:
|
with self.communications_lock:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
def find_port(self):
|
||||||
|
"""Iterate through the available serial ports and query them to see
|
||||||
|
if our instrument is there."""
|
||||||
|
with self.communications_lock:
|
||||||
|
success = False
|
||||||
|
for port_name, _, _ in serial.tools.list_ports.comports(): #loop through serial ports, apparently 256 is the limit?!
|
||||||
|
try:
|
||||||
|
logging.info("Trying port {}".format(port_name))
|
||||||
|
self.open(port_name)
|
||||||
|
success = True
|
||||||
|
logging.info("Success!")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
self.close()
|
||||||
|
except:
|
||||||
|
pass #we don't care if there's an error closing the port...
|
||||||
|
if success:
|
||||||
|
break #again, make sure this happens *after* closing the port
|
||||||
|
if success:
|
||||||
|
return port_name
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
class OptionalModule(object):
|
class OptionalModule(object):
|
||||||
"""This allows a `ExtensibleSerialInstrument` to have optional features.
|
"""This allows a `ExtensibleSerialInstrument` to have optional features.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -81,13 +81,8 @@ class Sangaboard(ExtensibleSerialInstrument):
|
||||||
it doesn't need to be named.
|
it doesn't need to be named.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
# If no port is specified
|
|
||||||
if not port:
|
|
||||||
# Scan all available ports, and check for valid firmware
|
|
||||||
scanned_port = self.scan_ports()
|
|
||||||
|
|
||||||
# Initialise basic serial instrument with specified
|
# Initialise basic serial instrument with specified
|
||||||
ExtensibleSerialInstrument.__init__(self, scanned_port, **kwargs)
|
ExtensibleSerialInstrument.__init__(self, port, **kwargs)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Make absolutely sure that whatever port we're on is valid
|
# Make absolutely sure that whatever port we're on is valid
|
||||||
|
|
@ -119,44 +114,18 @@ class Sangaboard(ExtensibleSerialInstrument):
|
||||||
logging.error("You may need to update the firmware running on the Sangaboard.")
|
logging.error("You may need to update the firmware running on the Sangaboard.")
|
||||||
raise e
|
raise e
|
||||||
|
|
||||||
def scan_ports(self):
|
def test_communications(self):
|
||||||
"""Iterate through the available serial ports and query them to see
|
"""
|
||||||
if our instrument is there."""
|
Overrides superclass, used in self.open(), and port scanning
|
||||||
logging.debug("Running Sangaboard port scanner")
|
"""
|
||||||
with self.communications_lock:
|
return self.check_valid_firmware()
|
||||||
success = False
|
|
||||||
for port_name, _, _ in serial.tools.list_ports.comports(): #loop through serial ports, apparently 256 is the limit?!
|
|
||||||
try:
|
|
||||||
logging.info("Trying port {}".format(port_name))
|
|
||||||
self.open(port_name)
|
|
||||||
success = True
|
|
||||||
|
|
||||||
logging.info("Checking firmware")
|
|
||||||
fw = self.check_valid_firmware()
|
|
||||||
if not fw:
|
|
||||||
success = False
|
|
||||||
except Exception as e:
|
|
||||||
logging.warning("Error on port {}".format(port_name))
|
|
||||||
logging.warning(e)
|
|
||||||
pass
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
self.close()
|
|
||||||
except:
|
|
||||||
pass #we don't care if there's an error closing the port...
|
|
||||||
if success:
|
|
||||||
break #again, make sure this happens *after* closing the port
|
|
||||||
if success:
|
|
||||||
return port_name
|
|
||||||
else:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def check_valid_firmware(self):
|
def check_valid_firmware(self):
|
||||||
logging.debug("Running firmware checks")
|
logging.debug("Running firmware checks")
|
||||||
|
|
||||||
# Request firmware version from the board
|
# Request firmware version from the board
|
||||||
self.firmware = self.query("version",timeout=2).rstrip()
|
self.firmware = self.query("version",timeout=2).rstrip()
|
||||||
|
logging.info("Firmware response: {}".format(self.firmware))
|
||||||
# Check for valid firmware string
|
# Check for valid firmware string
|
||||||
if self.firmware:
|
if self.firmware:
|
||||||
match = re.match(r"Sangaboard Firmware v(([\d]+)(?:\.([\d]+))+)", self.firmware)
|
match = re.match(r"Sangaboard Firmware v(([\d]+)(?:\.([\d]+))+)", self.firmware)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue