Merge branch 'api-v2' into 'master'

Api v2

See merge request openflexure/openflexure-microscope-server!35
This commit is contained in:
Joel Collins 2019-11-21 14:44:45 +00:00
commit 7cc595d92f
47 changed files with 1903 additions and 223 deletions

View file

@ -142,4 +142,4 @@ class TimelapseAPI(MicroscopeViewPlugin):
self.timelapse_task = taskify(self.plugin.timelapse)(n_images) 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 the state of the task (will show ID, start time, and status before the task has finished)
return jsonify(self.timelapse_task.state), 202 return jsonify(self.timelapse_task.state), 201

View file

@ -44,7 +44,7 @@ An example of a long running task may look like:
def post(self): def post(self):
# Attach the long-running method as a microscope task # Attach the long-running method as a microscope task
self.my_task = taskify(self.plugin.long_running_function)() self.my_task = taskify(self.plugin.long_running_function)()
return jsonify(self.my_task.state), 202 return jsonify(self.my_task.state), 201
After some time, once the task has completed, it could be retreived using: After some time, once the task has completed, it could be retreived using:
@ -132,7 +132,7 @@ For example, a timelapse plugin may look like:
self.timelapse_task = taskify(self.plugin.timelapse)(n_images) 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 the state of the task (will show ID, start time, and status before the task has finished)
return jsonify(self.timelapse_task.state), 202 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, Notice that even though we never use the stage here, we still acquire the lock. This means that during the timelapse,

View file

@ -21,6 +21,7 @@ from openflexure_microscope import Microscope
from openflexure_microscope.camera.capture import build_captures_from_exif from openflexure_microscope.camera.capture import build_captures_from_exif
from openflexure_microscope.config import settings_file_path, JSONEncoder from openflexure_microscope.config import settings_file_path, JSONEncoder
from openflexure_microscope.api.v1 import blueprints from openflexure_microscope.api.v1 import blueprints
from openflexure_microscope.api import v2
# Import device modules # Import device modules
# NB this will eventually be handled by the RC file, so you can choose what device # NB this will eventually be handled by the RC file, so you can choose what device
@ -43,7 +44,8 @@ DEFAULT_LOGFILE = settings_file_path("openflexure_microscope.log")
if (__name__ == "__main__") or (not is_gunicorn): if (__name__ == "__main__") or (not is_gunicorn):
# If imported, but not by gunicorn # If imported, but not by gunicorn
print("Letting sys handle logs") print("Letting sys handle logs")
logging.basicConfig(level=logging.DEBUG) logger = logging.getLogger()
logger.setLevel(logging.DEBUG)
else: else:
# Direct standard Python logging to file and console # Direct standard Python logging to file and console
root = logging.getLogger() root = logging.getLogger()
@ -141,6 +143,38 @@ app.register_blueprint(plugin_blueprint, url_prefix=uri("/plugin", "v1"))
task_blueprint = blueprints.task.construct_blueprint(api_microscope) task_blueprint = blueprints.task.construct_blueprint(api_microscope)
app.register_blueprint(task_blueprint, url_prefix=uri("/task", "v1")) app.register_blueprint(task_blueprint, url_prefix=uri("/task", "v1"))
### V2
# Root routes
v2_root_blueprint = v2.blueprints.root.construct_blueprint(api_microscope)
app.register_blueprint(v2_root_blueprint, url_prefix=uri("/", "v2"))
v2_streams_blueprint = v2.blueprints.streams.construct_blueprint(api_microscope)
app.register_blueprint(v2_streams_blueprint, url_prefix=uri("/streams", "v2"))
# Captures routes
v2_captures_blueprint = v2.blueprints.captures.construct_blueprint(api_microscope)
app.register_blueprint(v2_captures_blueprint, url_prefix=uri("/captures", "v2"))
# Settings routes
v2_settings_blueprint = v2.blueprints.settings.construct_blueprint(api_microscope)
app.register_blueprint(v2_settings_blueprint, url_prefix=uri("/settings", "v2"))
# Status routes
v2_status_blueprint = v2.blueprints.status.construct_blueprint(api_microscope)
app.register_blueprint(v2_status_blueprint, url_prefix=uri("/status", "v2"))
# Plugins routes
v2_plugin_blueprint = v2.blueprints.plugins.construct_blueprint(api_microscope)
app.register_blueprint(v2_plugin_blueprint, url_prefix=uri("/plugins", "v2"))
# Tasks routes
v2_tasks_blueprint = v2.blueprints.tasks.construct_blueprint(api_microscope)
app.register_blueprint(v2_tasks_blueprint, url_prefix=uri("/tasks", "v2"))
# Actions routes
v2_actions_blueprint = v2.blueprints.actions.construct_blueprint(api_microscope)
app.register_blueprint(v2_actions_blueprint, url_prefix=uri("/actions", "v2"))
@app.route("/routes") @app.route("/routes")
def routes(): def routes():
@ -184,7 +218,7 @@ def cleanup():
# Save config # Save config
logging.debug("Saving config for teardown...") logging.debug("Saving config for teardown...")
api_microscope.save_config(backup=True) api_microscope.save_settings()
logging.debug("Settling...") logging.debug("Settling...")
time.sleep(0.5) time.sleep(0.5)

View file

@ -1,6 +1,18 @@
import logging import logging
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from flask import url_for from flask import url_for, Blueprint
def blueprint_for_module(module_name, api_ver=2, suffix=""):
return Blueprint(
blueprint_name_for_module(module_name, api_ver=api_ver, suffix=suffix),
module_name,
)
def blueprint_name_for_module(module_name, api_ver=2, suffix=""):
bp_name = module_name.split(".")[-1]
return f"v{api_ver}_{bp_name}_blueprint{suffix}"
class JsonResponse: class JsonResponse:

View file

@ -152,7 +152,7 @@ class ConfigAPI(MicroscopeView):
:>header Content-Type: application/json :>header Content-Type: application/json
:status 200: state available :status 200: state available
""" """
return jsonify(self.microscope.read_config(json_safe=True)) return jsonify(self.microscope.read_settings(json_safe=True))
def post(self): def post(self):
""" """
@ -223,10 +223,10 @@ class ConfigAPI(MicroscopeView):
logging.debug("Updating settings from POST request:") logging.debug("Updating settings from POST request:")
logging.debug(payload.json) logging.debug(payload.json)
self.microscope.apply_config(payload.json) self.microscope.apply_settings(payload.json)
self.microscope.save_config() self.microscope.save_settings()
return jsonify(self.microscope.read_config(json_safe=True)) return jsonify(self.microscope.read_settings(json_safe=True))
def construct_blueprint(microscope_obj): def construct_blueprint(microscope_obj):

View file

@ -130,16 +130,19 @@ class ListAPI(MicroscopeView):
output.file, use_video_port=use_video_port, resize=resize, bayer=bayer output.file, use_video_port=use_video_port, resize=resize, bayer=bayer
) )
metadata.update( # Inject system metadata
{ system_metadata = {
"microscope_settings": self.microscope.read_config(), "microscope_settings": self.microscope.read_settings(),
"microscope_state": self.microscope.state, "microscope_state": self.microscope.state,
"microscope_id": self.microscope.id, "microscope_id": self.microscope.id,
"microscope_name": self.microscope.name, "microscope_name": self.microscope.name,
} }
) output.system_metadata.update(system_metadata)
# Insert custom metadata
output.put_metadata(metadata) output.put_metadata(metadata)
# Insert custom tags
output.put_tags(tags) output.put_tags(tags)
return jsonify(output.state) return jsonify(output.state)

View file

@ -16,7 +16,7 @@ class ZoomAPI(MicroscopeView):
:<header Content-Type: application/json :<header Content-Type: application/json
:status 200: preview started/stopped :status 200: preview started/stopped
""" """
zoom_value = self.microscope.camera.state["zoom_value"] zoom_value = self.microscope.camera.status["zoom_value"]
return jsonify({"zoom_value": zoom_value}) return jsonify({"zoom_value": zoom_value})
@ -47,7 +47,7 @@ class ZoomAPI(MicroscopeView):
self.microscope.camera.set_zoom(zoom_value) self.microscope.camera.set_zoom(zoom_value)
return jsonify(self.microscope.camera.state) return jsonify(self.microscope.camera.status)
class OverlayAPI(MicroscopeView): class OverlayAPI(MicroscopeView):

View file

@ -3,6 +3,7 @@ from openflexure_microscope.api.views import MicroscopeViewPlugin
from flask import Blueprint, jsonify from flask import Blueprint, jsonify
from openflexure_microscope.api.views import MicroscopeView from openflexure_microscope.api.views import MicroscopeView
import copy
import logging import logging
import warnings import warnings
@ -21,7 +22,7 @@ class PluginFormAPI(MicroscopeView):
A complete list of enabled plugins can be found in the microscope state. A complete list of enabled plugins can be found in the microscope state.
""" """
out = self.microscope.plugin.forms out = self.microscope.plugins.forms
return jsonify(out) return jsonify(out)
@ -38,7 +39,7 @@ def construct_blueprint(microscope_obj):
all_routes = [] all_routes = []
# For each plugin attached to the microscope object # For each plugin attached to the microscope object
for plugin_name, plugin_obj in microscope_obj.plugin.plugins: for plugin_name, plugin_obj in microscope_obj.plugins._legacy_plugins.items():
# If plugin contains valid endpoints # If plugin contains valid endpoints
if hasattr(plugin_obj, "api_views") and isinstance(plugin_obj.api_views, dict): if hasattr(plugin_obj, "api_views") and isinstance(plugin_obj.api_views, dict):
@ -94,7 +95,7 @@ def construct_blueprint(microscope_obj):
if hasattr(plugin_obj, "api_form") and isinstance( if hasattr(plugin_obj, "api_form") and isinstance(
plugin_obj.api_form, dict plugin_obj.api_form, dict
): ):
api_form_info = plugin_obj.api_form api_form_info = copy.deepcopy(plugin_obj.api_form)
api_form_info["id"] = plugin_name api_form_info["id"] = plugin_name
if "forms" in api_form_info and isinstance( if "forms" in api_form_info and isinstance(
api_form_info["forms"], list api_form_info["forms"], list
@ -110,8 +111,7 @@ def construct_blueprint(microscope_obj):
) )
# Store the complete form in Microscope().plugin.form # Store the complete form in Microscope().plugin.form
microscope_obj.plugin.forms.append(api_form_info) microscope_obj.plugins.forms.append(api_form_info)
print(microscope_obj.plugin.forms)
else: else:
warnings.warn( warnings.warn(

View file

@ -0,0 +1 @@
from . import blueprints

View file

@ -0,0 +1 @@
from . import root, captures, settings, status, tasks, streams, plugins, actions

View file

@ -0,0 +1,94 @@
"""
Top-level representation of enabled actions
"""
from flask import Blueprint, url_for, jsonify
from sys import platform
from openflexure_microscope.api.utilities import blueprint_for_module
from openflexure_microscope.utilities import get_docstring, description_from_view
from openflexure_microscope.api.views import MicroscopeView
from . import camera, stage, system
class ActionsAPI(MicroscopeView):
def get(self):
return jsonify(actions_representation())
_actions = {
"capture": {
"rule": "/camera/capture/",
"view_class": camera.CaptureAPI,
"conditions": True,
},
"previewStart": {
"rule": "/camera/preview/start",
"view_class": camera.GPUPreviewStartAPI,
"conditions": True,
},
"previewStop": {
"rule": "/camera/preview/stop",
"view_class": camera.GPUPreviewStopAPI,
"conditions": True,
},
"move": {
"rule": "/stage/move/",
"view_class": stage.MoveStageAPI,
"conditions": True,
},
"shutdown": {
"rule": "/system/shutdown/",
"view_class": system.ShutdownAPI,
"conditions": (platform == "linux"),
},
"reboot": {
"rule": "/system/reboot/",
"view_class": system.RebootAPI,
"conditions": (platform == "linux"),
},
}
def enabled_actions():
global _actions
return {k: v for k, v in _actions.items() if v["conditions"]}
def actions_representation():
global _actions
actions = {}
for name, action in enabled_actions().items():
d = {
"links": {"self": url_for(f".{name}")},
"rule": action["rule"],
"view_class": str(action["view_class"]),
}
d.update(description_from_view(action["view_class"]))
actions[name] = d
return actions
def construct_blueprint(microscope_obj):
global _actions
blueprint = blueprint_for_module(__name__)
# For each enabled action route defined in our dictionary above
for name, action in enabled_actions().items():
# Add the action to our blueprint
blueprint.add_url_rule(
action["rule"],
view_func=action["view_class"].as_view(name, microscope=microscope_obj),
)
blueprint.add_url_rule(
"/", view_func=ActionsAPI.as_view("actions", microscope=microscope_obj)
)
return blueprint

View file

@ -0,0 +1,178 @@
from openflexure_microscope.api.utilities import get_bool, JsonResponse
from openflexure_microscope.api.views import MicroscopeView
from openflexure_microscope.utilities import filter_dict
import logging
from flask import jsonify, request, abort, url_for, redirect, send_file
class CaptureAPI(MicroscopeView):
"""
Create a new image capture.
"""
def post(self):
"""
Create a new image capture.
.. :quickref: Actions; New capture
**Example request**:
.. sourcecode:: http
POST /actions/camera/capture HTTP/1.1
Accept: application/json
{
"filename": "myfirstcapture",
"temporary": false,
"use_video_port": true,
"bayer": true,
"size": {
"width": 640,
"height": 480
}
}
:>header Accept: application/json
:<json string filename: filename of stored capture
:<json boolean temporary: delete the capture file on microscope after closing
:<json boolean use_video_port: capture still image from the video port
:<json boolean bayer: keep raw capture data in the image file
:<json json size: - **x** *(int)*: x-axis resize
- **y** *(int)*: y-axis resize
:>json boolean available: availability of capture data
:>json string filename: filename of capture
:>json string id: unique id of the capture object
:>json boolean temporary: delete the capture file on microscope after closing
:>json boolean locked: file locked for modifications (mostly used for video recording)
:>json string path: path on pi storage to the capture file, if available
:>json boolean stream: capture stored in-memory as a BytesIO stream
:>json json uri: - **download** *(string)*: api uri to the capture file download
- **state** *(string)*: api uri to the capture json representation
:<header Content-Type: application/json
:status 200: capture created
"""
payload = JsonResponse(request)
filename = payload.param("filename")
temporary = payload.param("temporary", default=False, convert=bool)
use_video_port = payload.param("use_video_port", default=False, convert=bool)
bayer = payload.param("bayer", default=True, convert=bool)
metadata = payload.param("metadata", default={}, convert=dict)
tags = payload.param("tags", default=[], convert=list)
resize = payload.param("size", default=None)
if resize:
if ("width" in resize) and ("height" in resize):
resize = (
int(resize["width"]),
int(resize["height"]),
) # Convert dict to tuple
else:
abort(404)
# Explicitally acquire lock (prevents empty files being created if lock is unavailable)
with self.microscope.camera.lock:
output = self.microscope.camera.new_image(
temporary=temporary, filename=filename
)
self.microscope.camera.capture(
output.file, use_video_port=use_video_port, resize=resize, bayer=bayer
)
# Inject system metadata
system_metadata = {
"microscope_settings": self.microscope.read_settings(),
"microscope_state": self.microscope.state,
"microscope_id": self.microscope.id,
"microscope_name": self.microscope.name,
}
output.system_metadata.update(system_metadata)
# Insert custom metadata
output.put_metadata(metadata)
# Insert custom tags
output.put_tags(tags)
return jsonify(output.state)
class GPUPreviewStartAPI(MicroscopeView):
"""
Start the onboard GPU preview.
"""
def post(self, operation):
"""
Start the onboard GPU preview.
Optional "window" parameter can be passed to control the position and size of the preview window,
in the format ``[x, y, width, height]``.
.. :quickref: Actions; Start on-board preview
**Example requests**:
.. sourcecode:: http
POST /actions/camera/preview/start HTTP/1.1
Accept: application/json
{
"window": [0, 0, 480, 320],
}
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
payload = JsonResponse(request)
window = payload.param("window", default=[])
logging.debug(window)
if len(window) != 4:
fullscreen = True
window = None
else:
fullscreen = False
window = [int(w) for w in window]
self.microscope.camera.start_preview(fullscreen=fullscreen, window=window)
return jsonify(self.microscope.state)
class GPUPreviewStopAPI(MicroscopeView):
"""
Stop the onboard GPU preview.
"""
def post(self, operation):
"""
Stop the onboard GPU preview.
.. :quickref: Actions; Stop on-board preview
**Example requests**:
.. sourcecode:: http
POST /actions/camera/preview/stop HTTP/1.1
Accept: application/json
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: preview started/stopped
"""
self.microscope.camera.stop_preview()
return jsonify(self.microscope.state)

View file

@ -0,0 +1,58 @@
from openflexure_microscope.api.utilities import JsonResponse
from openflexure_microscope.api.views import MicroscopeView
from openflexure_microscope.utilities import axes_to_array, filter_dict
from flask import Blueprint, jsonify, request
import logging
class MoveStageAPI(MicroscopeView):
"""
Handle stage movements.
"""
def post(self):
"""
Set x, y and z positions of the stage.
.. :quickref: Position; Update current position
:reqheader Accept: application/json
:<json boolean absolute: (true) move to absolute position, (false) move by relative amount
:<json boolean force: allow moving by more than programmed limit
:<json int x: x steps
:<json int y: y steps
:<json int z: z steps
"""
# Create response object
payload = JsonResponse(request)
logging.debug(payload.json)
# Handle absolute positioning (calculate a relative move from current position and target)
if (payload.param("absolute") is True) and (
self.microscope.stage
): # Only if stage exists
target_position = axes_to_array(payload.json, ["x", "y", "z"])
logging.debug("TARGET: {}".format(target_position))
position = [
target_position[i] - self.microscope.stage.position[i] for i in range(3)
]
logging.debug("DELTA: {}".format(position))
else:
# Get coordinates from payload
position = axes_to_array(payload.json, ["x", "y", "z"], [0, 0, 0])
logging.debug(position)
# Move if stage exists
if self.microscope.stage:
# Explicitally acquire lock
with self.microscope.stage.lock:
self.microscope.stage.move_rel(position)
out = filter_dict(self.microscope.state, ["stage", "position"])
return jsonify(out)

View file

@ -0,0 +1,37 @@
from openflexure_microscope.api.views import MicroscopeView
import subprocess
class ShutdownAPI(MicroscopeView):
"""
Attempt to shutdown the device
"""
def post(self):
"""
Attempt to shutdown the device
.. :quickref: Actions; Shutdown
"""
subprocess.Popen(["shutdown", "-h", "now"])
return "{}", 201
class RebootAPI(MicroscopeView):
"""
Attempt to reboot the device
"""
def post(self):
"""
Attempt to shutdown the device
.. :quickref: Actions; Shutdown
"""
subprocess.Popen(["sudo", "shutdown", "-r", "now"])
return "{}", 201

View file

@ -0,0 +1,416 @@
"""
Top-level representation of all acquired captures
"""
from openflexure_microscope.api.utilities import get_bool, JsonResponse
from openflexure_microscope.api.views import MicroscopeView
from openflexure_microscope.utilities import filter_dict
from openflexure_microscope.api.utilities import blueprint_for_module
from flask import jsonify, request, abort, url_for, redirect, send_file, Blueprint
def captures_representation(capture_list: list, include_unavailable: bool = False):
"""
Generate a dictionary representation of all captures, including Flask route URLs
Args:
capture_list (list): List of capture objects
include_unavailable (bool): Include unavailable captures in response?
Returns:
dict: Dictionary representation of all captures
"""
if include_unavailable:
captures = {image.id: image.state for image in capture_list}
else:
captures = {
image.id: image.state for image in capture_list if image.state["available"]
}
for capture_key, capture_repr in captures.items():
# Add API routes to returned representations
extra_state = {
"links": {
"self": "{}".format(url_for(".capture", capture_id=capture_key)),
"download": "{}".format(
url_for(
".capture_download",
capture_id=capture_key,
filename=capture_repr["filename"],
)
),
"tags": "{}".format(url_for(".capture_tags", capture_id=capture_key)),
}
}
captures[capture_key].update(extra_state)
return captures
class ListAPI(MicroscopeView):
def get(self):
"""
Get list of image captures.
.. :quickref: Captures; Get collection of captures
:>header Accept: application/json
:query include_unavailable: return json representations of captures that have been completely deleted
:>jsonarr boolean available: availability of capture data
:>jsonarr string filename: filename of capture
:>jsonarr string id: unique id of the capture object
:>jsonarr boolean keep_on_disk: keep the capture file on microscope after closing
:>jsonarr boolean locked: file locked for modifications (mostly used for video recording)
:>jsonarr string path: path on pi storage to the capture file, if available
:>jsonarr boolean stream: capture stored in-memory as a BytesIO stream
:>jsonarr json uri: - **download** *(string)*: api uri to the capture file download
- **state** *(string)*: api uri to the capture json representation
:>header Content-Type: application/json
:status 200: capture found
:status 404: no capture found with that id
"""
representation = captures_representation(self.microscope.camera.images)
return jsonify(representation)
def delete(self):
"""
Delete all captures
.. :quickref: Captures; Delete all captures
"""
for image in self.microscope.camera.images:
image.delete()
captures = [image.state for image in self.microscope.camera.images]
return jsonify(captures)
class CaptureAPI(MicroscopeView):
def get(self, capture_id):
"""
Get JSON representation of a capture
.. :quickref: Capture; Get capture
**Example request**:
.. sourcecode:: http
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"available": true,
"bytestream": false,
"keep_on_disk": false,
"locked": false,
"metadata": {
"filename": "2018-11-16_10-21-53.jpeg",
"id": "d0b2067abbb946f19351e075c5e7cd5b",
"path": "capture/2018-11-16_10-21-53.jpeg",
"time": "2018-11-16_10-21-53"
}
"uri": {
"download": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/download",
"state": "/api/v1/capture/d0b2067abbb946f19351e075c5e7cd5b/"
}
}
:>json boolean available: availability of capture data
:>json string filename: filename of capture
:>json string id: unique id of the capture object
:>json boolean keep_on_disk: keep the capture file on microscope after closing
:>json boolean locked: file locked for modifications (mostly used for video recording)
:>json string path: path on pi storage to the capture file, if available
:>json boolean stream: capture stored in-memory as a BytesIO stream
:>json json uri: - **download** *(string)*: api uri to the capture file download
- **state** *(string)*: api uri to the capture json representation
"""
try:
representation = captures_representation(self.microscope.camera.images)[
capture_id
]
except KeyError:
return abort(404) # 404 Not Found
return jsonify(representation)
def delete(self, capture_id):
"""
Delete all capture data from the Pi, even if `keep_on_disk=true;`.
.. :quickref: Capture; Delete capture.
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj:
return abort(404) # 404 Not Found
capture_obj.delete()
return jsonify({"return": capture_id})
def put(self, capture_id):
"""
Add arbitrary metadata to the capture
.. :quickref: Capture; Update capture metadata
**Example request**:
.. sourcecode:: http
PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b HTTP/1.1
Accept: application/json
{
"user_id": "ofm_1234",
"patient_number": 1452,
}
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: metadata updated
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj:
return abort(404) # 404 Not Found
data_dict = JsonResponse(request).json
capture_obj.put_metadata(data_dict)
capture_metadata = capture_obj.state
return jsonify(capture_metadata)
class DownloadRedirectAPI(MicroscopeView):
def get(self, capture_id):
"""
Return image data for a capture.
Return capture data as an image file with the requested filename.
I.e., `/(capture_id)/download/foo.jpeg` will download the image as
`foo.jpeg`, regardless of the capture's initially set filename.
Route automatically redirects to download the capture under it's currently set filename.
I.e., `/(capture_id)/download` will
redirect to `/(capture_id)/download/(filename)`.
.. :quickref: Capture; Download capture image
**Example request**:
.. sourcecode:: http
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/download/2018-11-20_16-04-17.jpeg HTTP/1.1
Accept: image/jpeg
:>header Accept: image/jpeg
:query thumbnail: return an image thumbnail e.g. ?thumbnail=true
:>header Content-Type: image/jpeg
:status 200: capture data found
:status 404: no capture found with that id
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state["available"]:
return abort(404) # 404 Not Found
thumbnail = get_bool(request.args.get("thumbnail"))
return redirect(
url_for(
".capture_download",
capture_id=capture_id,
filename=capture_obj.filename,
thumbnail=thumbnail,
),
code=307,
)
class DownloadAPI(MicroscopeView):
def get(self, capture_id, filename):
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state["available"]:
return abort(404) # 404 Not Found
thumbnail = get_bool(request.args.get("thumbnail"))
# If no filename is specified, redirect to the capture's currently set filename
if not filename:
return redirect(
url_for(
"capture_download",
capture_id=capture_id,
filename=capture_obj.filename,
thumbnail=thumbnail,
),
code=307,
)
# Download the image data using the requested filename
if thumbnail:
img = capture_obj.thumbnail
else:
img = capture_obj.data
return send_file(img, mimetype="image/jpeg")
class TagsAPI(MicroscopeView):
def get(self, capture_id):
"""
Return tag list for a capture.
.. :quickref: Capture; Show capture tags
**Example request**:
.. sourcecode:: http
GET /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
Accept: text/json
:>header Accept: text/json
:>header Content-Type: text/json
:status 200: capture data found
:status 404: no capture found with that id
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state["available"]:
return abort(404) # 404 Not Found
return jsonify(capture_obj.tags)
def put(self, capture_id):
"""
Add tags to the capture
.. :quickref: Capture; Update capture tags
**Example request**:
.. sourcecode:: http
PUT /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
Accept: application/json
["tests", "mytag", "someothertag"]
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: metadata updated
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj or not capture_obj.state["available"]:
return abort(404) # 404 Not Found
data_dict = JsonResponse(request).json
if type(data_dict) != list:
return abort(400)
capture_obj.put_tags(data_dict)
return jsonify(capture_obj.tags)
def delete(self, capture_id):
"""
Delete tags from the capture
.. :quickref: Capture; Delete tags.
**Example request**:
.. sourcecode:: http
DELETE /camera/capture/d0b2067abbb946f19351e075c5e7cd5b/tags HTTP/1.1
Accept: application/json
["tests", "mytag"]
:>header Accept: application/json
:<header Content-Type: application/json
:status 200: metadata updated
"""
capture_obj = self.microscope.camera.image_from_id(capture_id)
if not capture_obj:
return abort(404) # 404 Not Found
data_dict = JsonResponse(request).json
if type(data_dict) != list:
return abort(400)
for tag in data_dict:
capture_obj.delete_tag(str(tag))
return jsonify(capture_obj.tags)
def construct_blueprint(microscope_obj):
blueprint = blueprint_for_module(__name__)
# Tag routes
blueprint.add_url_rule(
"/<capture_id>/tags",
view_func=TagsAPI.as_view("capture_tags", microscope=microscope_obj),
)
# Capture routes
blueprint.add_url_rule(
"/<capture_id>/download/<filename>",
view_func=DownloadAPI.as_view("capture_download", microscope=microscope_obj),
)
blueprint.add_url_rule(
"/<capture_id>/download",
view_func=DownloadRedirectAPI.as_view(
"capture_download_redirect", microscope=microscope_obj
),
)
blueprint.add_url_rule(
"/<capture_id>/",
view_func=CaptureAPI.as_view("capture", microscope=microscope_obj),
)
blueprint.add_url_rule(
"/", view_func=ListAPI.as_view("captures", microscope=microscope_obj)
)
return blueprint

View file

@ -0,0 +1,93 @@
"""
Top-level representation of attached and enabled plugins
"""
from openflexure_microscope.plugins import PluginLoader, MicroscopePlugin
from openflexure_microscope.api.views import MicroscopeViewPlugin
from openflexure_microscope.utilities import get_docstring, description_from_view
from openflexure_microscope.api.utilities import blueprint_for_module
from flask import Blueprint, jsonify, url_for
from openflexure_microscope.api.views import MicroscopeView
import copy
import logging
import warnings
def plugins_representation(plugin_loader_object: PluginLoader):
"""
Generate a dictionary representation of all plugins, including Flask route URLs
Args:
plugin_loader_object (:py:class:`openflexure_microscope.plugins.PluginLoader`): Microscope plugin loader
Returns:
dict: Dictionary representation of all plugins
"""
plugins = {}
for plugin in plugin_loader_object.active:
logging.debug(f"Representing plugin {plugin._name}")
d = {
"python_name": plugin._name_python_safe,
"plugin": str(plugin),
"views": {},
"gui": plugin.gui,
"description": get_docstring(plugin),
}
for view_id, view_data in plugin.views.items():
logging.debug(f"Representing view {view_id}")
uri = url_for(f"v2_plugins_blueprint.{view_id}")
# Make links dictionary if it doesn't yet exist
view_d = {"links": {"self": uri}}
view_d.update(description_from_view(view_data["view"]))
d["views"][view_id] = view_d
plugins[plugin._name] = d
return plugins
class PluginFormAPI(MicroscopeView):
def get(self):
"""
Return the current plugin forms
.. :quickref: Plugin; Get forms
Returns an array of present plugin forms (describing plugin user interfaces.)
Please note, this is *not* a list of all enabled plugins, only those with associated
user interface forms.
A complete list of enabled plugins can be found in the microscope state.
"""
return jsonify(plugins_representation(self.microscope.plugins))
def construct_blueprint(microscope_obj):
blueprint = blueprint_for_module(__name__)
# Create a base route to return plugin API forms, if any exist
blueprint.add_url_rule(
"/", view_func=PluginFormAPI.as_view("plugins", microscope=microscope_obj)
)
# For each plugin attached to the microscope object
for plugin in microscope_obj.plugins.active:
for plugin_view_id, plugin_view in plugin.views.items():
# Add route to the plugins blueprint
blueprint.add_url_rule(
plugin_view["rule"],
view_func=plugin_view["view"].as_view(
plugin_view_id, microscope=microscope_obj, plugin=plugin
),
)
return blueprint

View file

@ -0,0 +1,52 @@
from flask import Blueprint, jsonify, url_for
from openflexure_microscope import Microscope
from openflexure_microscope.api.views import MicroscopeView
from openflexure_microscope.utilities import get_docstring, bottom_level_name
from openflexure_microscope.api.utilities import blueprint_name_for_module
from openflexure_microscope.api.v2.blueprints import (
settings,
status,
plugins,
captures,
actions,
streams,
)
# List of submodules containing create_blueprint methods using standard blueprint_for_module naming
_root_blueprint_modules = [settings, status, plugins, captures, actions, streams]
def root_representation():
"""
Generate a dictionar representation of all top-level blueprint rules
"""
global _root_blueprint_modules
d = {}
for blueprint_module in _root_blueprint_modules:
module_short_name = bottom_level_name(blueprint_module)
blueprint_name = blueprint_name_for_module(blueprint_module.__name__)
d[module_short_name] = {
"name": blueprint_module.__name__,
"description": get_docstring(blueprint_module),
"links": {"self": url_for(f"{blueprint_name}.{module_short_name}")},
}
return d
class RootAPI(MicroscopeView):
def get(self):
return jsonify(root_representation())
def construct_blueprint(microscope_obj):
blueprint = Blueprint("root_blueprint", __name__)
blueprint.add_url_rule(
"/", view_func=RootAPI.as_view("root_repr", microscope=microscope_obj)
)
return blueprint

View file

@ -0,0 +1,158 @@
"""
Writeable settings for the microscope, and attached hardware
"""
from openflexure_microscope.api.utilities import gen, JsonResponse
from openflexure_microscope.api.views import MicroscopeView
from openflexure_microscope.api.utilities import blueprint_for_module
from flask import Blueprint, jsonify, request
import logging
class SettingsAPI(MicroscopeView):
def get(self):
"""
JSON representation of the microscope settings.
.. :quickref: Settings; Get microscope settings
**Example request**:
.. sourcecode:: http
GET /settings/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": "0e2c6fac5421429aac67c7903107bdd8",
"name": "docuscope-2000",
"fov": [4100, 3146],
"camera_settings": {
"image_resolution": [2592, 1944],
"numpy_resolution": [1312, 976],
"video_resolution": [832, 624],
"jpeg_quality": 75,
"picamera_settings": {
"analog_gain": 1.0,
"digital_gain": 1.0,
"awb_gains": [0.92578125, 2.94921875],
"awb_mode": "off",
"exposure_mode": "off",
"framerate": 24.0,
"saturation": 0,
"shutter_speed": 5378
},
},
"stage_settings": {
"backlash": {
"x": 256,
"y": 256,
"z": 0
}
}
"plugins": [
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
"openflexure_microscope.plugins.default.scan:ScanPlugin",
"openflexure_microscope.plugins.default.camera_calibration:Plugin"
]
}
:>header Accept: application/json
:>header Content-Type: application/json
:status 200: state available
"""
return jsonify(self.microscope.read_settings())
def put(self):
"""
Modify microscope configuration
.. :quickref: Config; Set microscope config
**Example request**:
.. sourcecode:: http
PUT /config HTTP/1.1
Accept: application/json
{
"id": "0e2c6fac5421429aac67c7903107bdd8",
"name": "docuscope-2000",
"fov": [4100, 3146],
"camera_settings": {
"image_resolution": [2592, 1944],
"numpy_resolution": [1312, 976],
"video_resolution": [832, 624],
"jpeg_quality": 75,
"picamera_settings": {
"analog_gain": 1.0,
"digital_gain": 1.0,
"awb_gains": [0.92578125, 2.94921875],
"awb_mode": "off",
"exposure_mode": "off",
"framerate": 24.0,
"saturation": 0,
"shutter_speed": 5378
},
},
"stage_settings": {
"backlash": {
"x": 256,
"y": 256,
"z": 0
}
}
"plugins": [
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
"openflexure_microscope.plugins.default.scan:ScanPlugin",
"openflexure_microscope.plugins.default.camera_calibration:Plugin"
]
}
:>header Accept: application/json
:<json string id: Unique string identifier of the microscope.
:<json string name: Friendly name for the microscope
:<json array fov: Field of view (motor steps per full width and height of frame)
:<json json camera_settings: - **image_resolution** *(array)*: Resolution of full image captures
- **numpy_resolution** *(array)*: Resolution of full numpy array captures
- **video_resolution** *(array)*: Resolution of video recordings, low res image captures, and the preview stream
- **jpeg_quality** *(int)*: Quality in which to store JPEG capture data
- **picamera_settings** *(json)*: Key-value pairs to apply directly to any attached PiCamera object
:<json json stage_settings: - **backlash** *(json)*: x, y, and z backlash compensation, in motor steps
:<json array plugins: Array of plugin paths to load. Requires reloading the microscope object after applying
:<header Content-Type: application/json
:status 200: capture created
"""
payload = JsonResponse(request)
logging.debug("Updating settings from PUT request:")
logging.debug(payload.json)
self.microscope.apply_settings(payload.json)
self.microscope.save_settings()
return jsonify(self.microscope.read_settings(json_safe=True))
def construct_blueprint(microscope_obj):
blueprint = blueprint_for_module(__name__)
blueprint.add_url_rule(
"/", view_func=SettingsAPI.as_view("settings", microscope=microscope_obj)
)
return blueprint

View file

@ -0,0 +1,69 @@
"""
Read-only status of the microscope, and attached hardware info
"""
from openflexure_microscope.api.views import MicroscopeView
from openflexure_microscope.api.utilities import blueprint_for_module
from flask import Blueprint, jsonify
class StatusAPI(MicroscopeView):
def get(self):
"""
JSON representation of the microscope status (read-only properties, modifiable with actions)
.. :quickref: Status; Microscope status
**Example request**:
.. sourcecode:: http
GET /status/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"camera": {
"preview_active": false,
"record_active": false,
"stream_active": true
},
"plugin": {},
"stage": {
"backlash": {
"x": 128,
"y": 128,
"z": 128
},
"position": {
"x": -8080,
"y": 5665,
"z": -12600
}
}
}
:>header Accept: application/json
:>header Content-Type: application/json
:status 200: state available
"""
return jsonify(self.microscope.status)
def construct_blueprint(microscope_obj):
blueprint = blueprint_for_module(__name__)
blueprint.add_url_rule(
"/", view_func=StatusAPI.as_view("status", microscope=microscope_obj)
)
return blueprint

View file

@ -0,0 +1,107 @@
"""
Top-level description of routes related to live camera stream data
"""
from openflexure_microscope.api.utilities import gen, JsonResponse
from openflexure_microscope.api.views import MicroscopeView
from openflexure_microscope.api.utilities import (
blueprint_for_module,
blueprint_name_for_module,
)
from openflexure_microscope.utilities import description_from_view
from flask import Response, Blueprint, jsonify, request, url_for
class StreamAPI(MicroscopeView):
def get(self):
return jsonify(streams_representation())
class MjpegAPI(MicroscopeView):
"""
Real-time MJPEG stream from the microscope camera
"""
def get(self):
"""
Real-time MJPEG stream from the microscope camera
.. :quickref: Stream; Camera MJPEG stream
:>header Accept: image/jpeg
:>header Content-Type: image/jpeg
:status 200: stream active
"""
# Restart stream worker thread
self.microscope.camera.start_worker()
return Response(
gen(self.microscope.camera),
mimetype="multipart/x-mixed-replace; boundary=frame",
)
class SnapshotAPI(MicroscopeView):
"""
Single JPEG snapshot from the camera stream
"""
def get(self):
"""
Single snapshot from the camera stream
.. :quickref: Stream; Camera snapshot
:>header Accept: image/jpeg
:>header Content-Type: image/jpeg
:status 200: stream active
"""
# Restart stream worker thread
self.microscope.camera.start_worker()
return Response(self.microscope.camera.get_frame(), mimetype="image/jpeg")
_streams = {
"mjpeg": {"rule": "/mjpeg", "view_class": MjpegAPI, "conditions": True},
"snapshot": {"rule": "/snapshot", "view_class": SnapshotAPI, "conditions": True},
}
def enabled_streams():
global _streams
return {k: v for k, v in _streams.items() if v["conditions"]}
def streams_representation():
global _streams
streams = {}
for name, stream in enabled_streams().items():
d = {"links": {"self": url_for(f".{name}")}}
d.update(description_from_view(stream["view_class"]))
streams[name] = d
return streams
def construct_blueprint(microscope_obj):
blueprint = blueprint_for_module(__name__)
# For each enabled stream route defined in our dictionary above
for name, stream in enabled_streams().items():
# Add the action to our blueprint
blueprint.add_url_rule(
stream["rule"],
view_func=stream["view_class"].as_view(name, microscope=microscope_obj),
)
blueprint.add_url_rule(
"/", view_func=StreamAPI.as_view("streams", microscope=microscope_obj)
)
return blueprint

View file

@ -0,0 +1,158 @@
from openflexure_microscope.api.views import MethodView
from flask import jsonify, abort, Blueprint, url_for
from openflexure_microscope.common import tasks
def tasks_representation():
"""
Generate a dictionary representation of all tasks, including Flask route URLs
Returns:
dict: Dictionary representation of all tasks
"""
tasks_dict = tasks.states()
for task_key, task_repr in tasks_dict.items():
# Add API routes to returned representations
extra_state = {
"links": {"self": "{}".format(url_for(".task", task_id=task_key))}
}
tasks_dict[task_key].update(extra_state)
return tasks_dict
class TaskListAPI(MethodView):
def get(self):
"""
Get list of long-running tasks.
.. :quickref: Tasks; Get collection of tasks
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
[
{
"end_time": "2019-01-23 16-33-33",
"id": "db13a66787e1419bb06b1504e4d80b0c",
"return": [
0.848622546386467,
0.6106785018091292,
],
"start_time": "2019-01-23 16-33-13",
"status": "success"
},
{
"end_time": null,
"id": "df46558cc8844924821bd0181881871e",
"return": null,
"start_time": "2019-01-23 16-34-54",
"status": "running"
}
]
:>header Accept: application/json
:query include_unavailable: return json representations of captures that have been completely deleted
:>header Content-Type: application/json
"""
return jsonify(tasks_representation())
def delete(self):
"""
Clean list of long-running tasks (running tasks persist).
.. :quickref: Tasks; Clean collection of tasks
:>header Accept: application/json
:>header Content-Type: application/json
"""
tasks.cleanup_tasks()
return jsonify(tasks_representation())
class TaskAPI(MethodView):
def get(self, task_id):
"""
Get JSON representation of a task
.. :quickref: Tasks; Get task
**Example request**:
.. sourcecode:: http
GET /task/db13a66787e1419bb06b1504e4d80b0c/ HTTP/1.1
Accept: application/json
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"end_time": "2019-01-23 16-33-33",
"id": "db13a66787e1419bb06b1504e4d80b0c",
"return": [
0.848622546386467,
0.6106785018091292,
],
"start_time": "2019-01-23 16-33-13",
"status": "success"
}
"""
try:
task = tasks_representation()[task_id]
except KeyError:
return abort(404) # 404 Not Found
# Return task state
return jsonify(task.state)
def delete(self, task_id):
"""
Terminate a particular task.
.. :quickref: Tasks; Terminate a task
:>header Accept: application/json
:>header Content-Type: application/json
"""
try:
task = tasks_representation()[task_id]
except KeyError:
return abort(404) # 404 Not Found
task.terminate()
return jsonify(task.state)
def construct_blueprint(microscope_obj):
blueprint = Blueprint("v2_tasks_blueprint", __name__)
blueprint.add_url_rule("/", view_func=TaskListAPI.as_view("task_list"))
blueprint.add_url_rule("/<task_id>/", view_func=TaskAPI.as_view("task"))
return blueprint

View file

@ -100,7 +100,7 @@ class BaseCamera(metaclass=ABCMeta):
last_access (time): Time of last client access to the camera last_access (time): Time of last client access to the camera
stream_timeout (int): Number of inactive seconds before timing out the stream stream_timeout (int): Number of inactive seconds before timing out the stream
stream_timeout_enabled (bool): Enable or disable timing out the stream stream_timeout_enabled (bool): Enable or disable timing out the stream
state (dict): Dictionary for capture state status (dict): Dictionary for capture state
paths (dict): Dictionary of capture paths paths (dict): Dictionary of capture paths
images (list): List of image capture objects images (list): List of image capture objects
videos (list): List of video capture objects videos (list): List of video capture objects
@ -120,7 +120,7 @@ class BaseCamera(metaclass=ABCMeta):
self.stream_timeout = 20 self.stream_timeout = 20
self.stream_timeout_enabled = False self.stream_timeout_enabled = False
self.state = {"board": None} self.status = {"board": None}
# TODO: Load/save these to config # TODO: Load/save these to config
self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH} self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH}
@ -130,7 +130,7 @@ class BaseCamera(metaclass=ABCMeta):
self.videos = [] self.videos = []
@abstractmethod @abstractmethod
def apply_config(self, config: dict): def apply_settings(self, config: dict):
"""Update settings from a config dictionary""" """Update settings from a config dictionary"""
with self.lock: with self.lock:
# Apply valid config params to camera object # Apply valid config params to camera object
@ -139,11 +139,11 @@ class BaseCamera(metaclass=ABCMeta):
setattr(self, key, value) # Set to the target value setattr(self, key, value) # Set to the target value
@abstractmethod @abstractmethod
def read_config(self) -> dict: def read_settings(self) -> dict:
"""Return the current settings as a dictionary""" """Return the current settings as a dictionary"""
return {"paths": self.paths} return {"paths": self.paths}
def save_config(self): def save_settings(self):
"""(Optional) Save any settings to disk that need to be stored""" """(Optional) Save any settings to disk that need to be stored"""
return return
@ -187,7 +187,7 @@ class BaseCamera(metaclass=ABCMeta):
self.last_access = time.time() self.last_access = time.time()
self.stop = False self.stop = False
if not self.state["stream_active"]: if not self.status["stream_active"]:
# start background frame thread # start background frame thread
self.thread = threading.Thread(target=self._thread) self.thread = threading.Thread(target=self._thread)
self.thread.daemon = True self.thread.daemon = True
@ -207,12 +207,12 @@ class BaseCamera(metaclass=ABCMeta):
logging.debug("Stopping worker thread") logging.debug("Stopping worker thread")
timeout_time = time.time() + timeout timeout_time = time.time() + timeout
if self.state["stream_active"]: if self.status["stream_active"]:
self.stop = True self.stop = True
self.thread.join() # Wait for stream thread to exit self.thread.join() # Wait for stream thread to exit
logging.debug("Waiting for stream thread to exit.") logging.debug("Waiting for stream thread to exit.")
while self.state["stream_active"]: while self.status["stream_active"]:
if time.time() > timeout_time: if time.time() > timeout_time:
logging.debug("Timeout waiting for worker thread close.") logging.debug("Timeout waiting for worker thread close.")
raise TimeoutError("Timeout waiting for worker thread close.") raise TimeoutError("Timeout waiting for worker thread close.")
@ -352,7 +352,7 @@ class BaseCamera(metaclass=ABCMeta):
self.frames_iterator = self.frames() self.frames_iterator = self.frames()
logging.debug("Entering worker thread.") logging.debug("Entering worker thread.")
self.state["stream_active"] = True self.status["stream_active"] = True
for frame in self.frames_iterator: for frame in self.frames_iterator:
self.frame = frame self.frame = frame
@ -365,7 +365,7 @@ class BaseCamera(metaclass=ABCMeta):
and ( # If using timeout and ( # If using timeout
time.time() - self.last_access > self.stream_timeout time.time() - self.last_access > self.stream_timeout
) )
and not self.state[ # And timeout time and not self.status[ # And timeout time
"preview_active" "preview_active"
] # And GPU preview is not active ] # And GPU preview is not active
): ):
@ -383,4 +383,4 @@ class BaseCamera(metaclass=ABCMeta):
logging.debug("BaseCamera worker thread exiting...") logging.debug("BaseCamera worker thread exiting...")
# Set stream_activate state # Set stream_activate state
self.state["stream_active"] = False self.status["stream_active"] = False

View file

@ -35,7 +35,9 @@ def pull_usercomment_dict(filepath):
return json.loads(exif_dict["Exif"][37510].decode()) return json.loads(exif_dict["Exif"][37510].decode())
except json.decoder.JSONDecodeError: except json.decoder.JSONDecodeError:
# TODO: Remove YAML support in a later version # TODO: Remove YAML support in a later version
logging.warning(f"Capture {filepath} has metadata stored in YAML format. This is now deprecated in favour of JSON.") logging.warning(
f"Capture {filepath} has metadata stored in YAML format. This is now deprecated in favour of JSON."
)
return yaml.load(exif_dict["Exif"][37510].decode()) return yaml.load(exif_dict["Exif"][37510].decode())
else: else:
return None return None
@ -97,7 +99,7 @@ def capture_from_exif(path, exif_dict):
capture.timestring = exif_dict["time"] capture.timestring = exif_dict["time"]
capture.format = exif_dict["format"] capture.format = exif_dict["format"]
capture._metadata = exif_dict["custom"] capture.custom_metadata = exif_dict["custom"]
capture.tags = exif_dict["tags"] capture.tags = exif_dict["tags"]
return capture return capture
@ -110,7 +112,7 @@ class CaptureObject(object):
Attributes: Attributes:
timestring (str): Timestring of capture creation time timestring (str): Timestring of capture creation time
_metadata (dict): Dictionary of custom metadata to be included in metadata file custom_metadata (dict): Dictionary of custom metadata to be included in metadata file
tags (list): List of tags. Essentially just as extra custom metadata field, but useful for quick organisation tags (list): List of tags. Essentially just as extra custom metadata field, but useful for quick organisation
filefolder (str): Folder in which the capture file will be stored filefolder (str): Folder in which the capture file will be stored
filename (str): Full name of the capture file filename (str): Full name of the capture file
@ -132,7 +134,9 @@ class CaptureObject(object):
self.split_file_path(self.file) self.split_file_path(self.file)
# Dictionary for storing custom metadata # Dictionary for storing custom metadata
self._metadata = {} self.custom_metadata = {}
# Dictionary for adding top-level metadata (cannmot be accessed through web API)
self.system_metadata = {}
# List for storing tags # List for storing tags
self.tags = [] self.tags = []
@ -203,7 +207,7 @@ class CaptureObject(object):
Args: Args:
data (dict): Dictionary of metadata to be added data (dict): Dictionary of metadata to be added
""" """
self._metadata.update(data) self.custom_metadata.update(data)
self.save_metadata() self.save_metadata()
def save_metadata(self) -> None: def save_metadata(self) -> None:
@ -233,13 +237,14 @@ class CaptureObject(object):
""" """
d = { d = {
"id": self.id, "id": self.id,
"filename": self.filename,
"time": self.timestring, "time": self.timestring,
"format": self.format, "format": self.format,
"tags": self.tags, "tags": self.tags,
"custom": self._metadata, "custom": self.custom_metadata,
} }
d.update(self.system_metadata)
# Add custom metadata to dictionary # Add custom metadata to dictionary
return d return d
@ -250,7 +255,7 @@ class CaptureObject(object):
""" """
# Create basic state dictionary # Create basic state dictionary
d = {"path": self.file, "metadata": self.metadata} d = {"path": self.file, "filename": self.filename, "metadata": self.metadata}
# Combined availability of data # Combined availability of data
if self.exists: if self.exists:

View file

@ -19,6 +19,13 @@ from typing import Tuple
from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.camera.base import BaseCamera
"""
PIL spams the logger with debug-level information. This is a pain when debugging api.app.
We override the logging settings in api.app by setting a level for PIL here.
"""
pil_logger = logging.getLogger("PIL")
pil_logger.setLevel(logging.INFO)
# MAIN CLASS # MAIN CLASS
class MockStreamer(BaseCamera): class MockStreamer(BaseCamera):
def __init__(self): def __init__(self):
@ -26,7 +33,7 @@ class MockStreamer(BaseCamera):
BaseCamera.__init__(self) BaseCamera.__init__(self)
# Store state of PiCameraStreamer # Store state of PiCameraStreamer
self.state.update( self.status.update(
{"stream_active": False, "record_active": False, "board": None} {"stream_active": False, "record_active": False, "board": None}
) )
@ -74,13 +81,13 @@ class MockStreamer(BaseCamera):
BaseCamera.close(self) BaseCamera.close(self)
# HANDLE SETTINGS # HANDLE SETTINGS
def read_config(self) -> dict: def read_settings(self) -> dict:
""" """
Return config dictionary of the PiCameraStreamer. Return config dictionary of the PiCameraStreamer.
""" """
# Get config items from the base class # Get config items from the base class
conf_dict = BaseCamera.read_config(self) conf_dict = BaseCamera.read_settings(self)
# Include device-specific config items # Include device-specific config items
conf_dict.update( conf_dict.update(
@ -94,7 +101,7 @@ class MockStreamer(BaseCamera):
return conf_dict return conf_dict
def apply_config(self, config: dict): def apply_settings(self, config: dict):
""" """
Write a config dictionary to the PiCameraStreamer config. Write a config dictionary to the PiCameraStreamer config.
@ -110,7 +117,7 @@ class MockStreamer(BaseCamera):
with self.lock: with self.lock:
# Apply valid config params to camera object # Apply valid config params to camera object
if not self.state["record_active"]: # If not recording a video if not self.status["record_active"]: # If not recording a video
for key, value in config.items(): # For each provided setting for key, value in config.items(): # For each provided setting
if hasattr(self, key): if hasattr(self, key):

View file

@ -43,6 +43,7 @@ from .set_picamera_gain import set_analog_gain, set_digital_gain
from openflexure_microscope.config import settings_file_path from openflexure_microscope.config import settings_file_path
# MAIN CLASS # MAIN CLASS
class PiCameraStreamer(BaseCamera): class PiCameraStreamer(BaseCamera):
"""Raspberry Pi camera implementation of PiCameraStreamer.""" """Raspberry Pi camera implementation of PiCameraStreamer."""
@ -64,7 +65,10 @@ class PiCameraStreamer(BaseCamera):
"exposure_compensation", "exposure_compensation",
"image_effect", "image_effect",
"meter_mode", "meter_mode",
"sharpness" "sharpness",
"annotate_text",
"annotate_text_size",
"zoom",
] ]
def __init__(self): def __init__(self):
@ -75,8 +79,8 @@ class PiCameraStreamer(BaseCamera):
picamera.PiCamera() picamera.PiCamera()
) #: :py:class:`picamera.PiCamera`: Picamera object ) #: :py:class:`picamera.PiCamera`: Picamera object
# Store state of PiCameraStreamer # Store status of PiCameraStreamer
self.state.update( self.status.update(
{ {
"stream_active": False, "stream_active": False,
"record_active": False, "record_active": False,
@ -89,15 +93,26 @@ class PiCameraStreamer(BaseCamera):
self.set_zoom(1.0) self.set_zoom(1.0)
# Set default settings # Set default settings
self.image_resolution = tuple(self.camera.MAX_RESOLUTION) #: tuple: Resolution for image captures self.image_resolution = tuple(
self.stream_resolution = (832, 624) #: tuple: Resolution for stream and video captures self.camera.MAX_RESOLUTION
self.numpy_resolution = (1312, 976) #: tuple: Resolution for numpy array captures ) #: tuple: Resolution for image captures
self.stream_resolution = (
832,
624,
) #: tuple: Resolution for stream and video captures
self.numpy_resolution = (
1312,
976,
) #: tuple: Resolution for numpy array captures
self.jpeg_quality = 75 #: int: JPEG quality self.jpeg_quality = 75 #: int: JPEG quality
# Set default lens shading table path # Set default lens shading table path
self.picamera_lst_path = settings_file_path("picamera_lst.npy") #: str: Path of .npy lens shading table file self.picamera_lst_path = settings_file_path(
"picamera_lst.npy"
) #: str: Path of .npy lens shading table file
# Update board identifier # Update board identifier
self.state.update({}) self.status.update({})
# Create an empty stream # Create an empty stream
self.stream = io.BytesIO() self.stream = io.BytesIO()
@ -118,13 +133,13 @@ class PiCameraStreamer(BaseCamera):
self.camera.close() self.camera.close()
# HANDLE SETTINGS # HANDLE SETTINGS
def read_config(self) -> dict: def read_settings(self) -> dict:
""" """
Return config dictionary of the PiCameraStreamer. Return config dictionary of the PiCameraStreamer.
""" """
# Get config items from the base class # Get config items from the base class
conf_dict = BaseCamera.read_config(self) conf_dict = BaseCamera.read_settings(self)
# Include device-specific config items # Include device-specific config items
conf_dict.update( conf_dict.update(
@ -133,7 +148,9 @@ class PiCameraStreamer(BaseCamera):
"image_resolution": self.image_resolution, "image_resolution": self.image_resolution,
"numpy_resolution": self.numpy_resolution, "numpy_resolution": self.numpy_resolution,
"jpeg_quality": self.jpeg_quality, "jpeg_quality": self.jpeg_quality,
"picamera_lst_path": self.picamera_lst_path if os.path.isfile(self.picamera_lst_path) else None, "picamera_lst_path": self.picamera_lst_path
if os.path.isfile(self.picamera_lst_path)
else None,
"picamera_settings": {}, "picamera_settings": {},
} }
) )
@ -149,12 +166,12 @@ class PiCameraStreamer(BaseCamera):
return conf_dict return conf_dict
def save_config(self): def save_settings(self):
"""Save lens-shading table to disk""" """Save lens-shading table to disk"""
logging.info("Saving picamera_lst to {}".format(self.picamera_lst_path)) logging.info("Saving picamera_lst to {}".format(self.picamera_lst_path))
self.save_lens_shading_table() self.save_lens_shading_table()
def apply_config(self, config: dict): def apply_settings(self, config: dict):
""" """
Write a config dictionary to the PiCameraStreamer config. Write a config dictionary to the PiCameraStreamer config.
@ -172,10 +189,10 @@ class PiCameraStreamer(BaseCamera):
with self.lock: with self.lock:
# Apply valid config params to Picamera object # Apply valid config params to Picamera object
if not self.state["record_active"]: # If not recording a video if not self.status["record_active"]: # If not recording a video
# Pause stream while changing settings # Pause stream while changing settings
if self.state["stream_active"]: # If stream is active if self.status["stream_active"]: # If stream is active
logging.info("Pausing stream to update config.") logging.info("Pausing stream to update config.")
self.stop_stream_recording() # Pause stream self.stop_stream_recording() # Pause stream
paused_stream = True # Remember to unpause stream when done paused_stream = True # Remember to unpause stream when done
@ -268,7 +285,7 @@ class PiCameraStreamer(BaseCamera):
""" """
Read the current lens shading table as a numpy array, if it exists. Return None otherwise. Read the current lens shading table as a numpy array, if it exists. Return None otherwise.
""" """
if hasattr(self.camera, 'lens_shading_table'): if hasattr(self.camera, "lens_shading_table"):
return self.camera.lens_shading_table return self.camera.lens_shading_table
else: else:
return None return None
@ -292,20 +309,25 @@ class PiCameraStreamer(BaseCamera):
elif (type(lst_array_or_path) == str) and os.path.isfile(lst_array_or_path): elif (type(lst_array_or_path) == str) and os.path.isfile(lst_array_or_path):
self.camera.lens_shading_table = np.load(lst_array_or_path) self.camera.lens_shading_table = np.load(lst_array_or_path)
else: else:
logging.error("Unsupported or missing data for camera lens_shading_table. Must be numpy ndarray, or .npy file path string. Skipping.") logging.error(
"Unsupported or missing data for camera lens_shading_table. Must be numpy ndarray, or .npy file path string. Skipping."
)
def set_zoom(self, zoom_value: float = 1.0) -> None: def set_zoom(self, zoom_value: float = 1.0) -> None:
""" """
Change the camera zoom, handling re-centering and scaling. Change the camera zoom, handling re-centering and scaling.
""" """
logging.warning(
"set_zoom is deprecated. Please use the 'zoom' property in picamera_settings."
)
with self.lock: with self.lock:
self.state["zoom_value"] = float(zoom_value) self.status["zoom_value"] = float(zoom_value)
if self.state["zoom_value"] < 1: if self.status["zoom_value"] < 1:
self.state["zoom_value"] = 1 self.status["zoom_value"] = 1
# Richard's code for zooming ! # Richard's code for zooming !
fov = self.camera.zoom fov = self.camera.zoom
centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0]) centre = np.array([fov[0] + fov[2] / 2.0, fov[1] + fov[3] / 2.0])
size = 1.0 / self.state["zoom_value"] size = 1.0 / self.status["zoom_value"]
# If the new zoom value would be invalid, move the centre to # If the new zoom value would be invalid, move the centre to
# keep it within the camera's sensor (this is only relevant # keep it within the camera's sensor (this is only relevant
# when zooming out, if the FoV is not centred on (0.5, 0.5) # when zooming out, if the FoV is not centred on (0.5, 0.5)
@ -332,7 +354,7 @@ class PiCameraStreamer(BaseCamera):
self.camera.preview.window = window self.camera.preview.window = window
if fullscreen: if fullscreen:
self.camera.preview.fullscreen = fullscreen self.camera.preview.fullscreen = fullscreen
self.state["preview_active"] = True self.status["preview_active"] = True
except picamera.exc.PiCameraMMALError as e: except picamera.exc.PiCameraMMALError as e:
logging.error( logging.error(
"Suppressed a MMALError in start_preview. Exception: {}".format(e) "Suppressed a MMALError in start_preview. Exception: {}".format(e)
@ -347,7 +369,7 @@ class PiCameraStreamer(BaseCamera):
def stop_preview(self): def stop_preview(self):
"""Stop the on board GPU camera preview.""" """Stop the on board GPU camera preview."""
self.camera.stop_preview() self.camera.stop_preview()
self.state["preview_active"] = False self.status["preview_active"] = False
def start_recording(self, output, fmt: str = "h264", quality: int = 15): def start_recording(self, output, fmt: str = "h264", quality: int = 15):
"""Start recording. """Start recording.
@ -365,7 +387,7 @@ class PiCameraStreamer(BaseCamera):
""" """
with self.lock: with self.lock:
# Start recording method only if a current recording is not running # Start recording method only if a current recording is not running
if not self.state["record_active"]: if not self.status["record_active"]:
# Start the camera video recording on port 2 # Start the camera video recording on port 2
logging.info("Recording to {}".format(output)) logging.info("Recording to {}".format(output))
@ -378,8 +400,8 @@ class PiCameraStreamer(BaseCamera):
quality=quality, quality=quality,
) )
# Update state dictionary # Update status dictionary
self.state["record_active"] = True self.status["record_active"] = True
return output return output
@ -398,8 +420,8 @@ class PiCameraStreamer(BaseCamera):
self.camera.stop_recording(splitter_port=2) self.camera.stop_recording(splitter_port=2)
logging.info("Recording stopped") logging.info("Recording stopped")
# Update state dictionary # Update status dictionary
self.state["record_active"] = False self.status["record_active"] = False
def stop_stream_recording( def stop_stream_recording(
self, splitter_port: int = 1, resolution: Tuple[int, int] = None self, splitter_port: int = 1, resolution: Tuple[int, int] = None
@ -467,7 +489,7 @@ class PiCameraStreamer(BaseCamera):
self.camera.resolution = resolution self.camera.resolution = resolution
# If the stream should be active # If the stream should be active
if self.state["stream_active"]: if self.status["stream_active"]:
try: try:
# Start recording on stream port # Start recording on stream port
self.camera.start_recording( self.camera.start_recording(
@ -633,7 +655,7 @@ class PiCameraStreamer(BaseCamera):
# Start stream recording (and set resolution) # Start stream recording (and set resolution)
self.start_stream_recording() self.start_stream_recording()
# Update state # Update status
logging.debug("STREAM ACTIVE") logging.debug("STREAM ACTIVE")
# While the iterator is not closed # While the iterator is not closed

View file

@ -21,6 +21,7 @@ class JSONEncoder(json.JSONEncoder):
""" """
A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays
""" """
def default(self, o, markers=None): def default(self, o, markers=None):
# PiCamera fractions # PiCamera fractions
if isinstance(o, Fraction): if isinstance(o, Fraction):
@ -39,6 +40,7 @@ class JSONEncoder(json.JSONEncoder):
# MAIN CONFIG CLASS # MAIN CONFIG CLASS
class OpenflexureSettingsFile: class OpenflexureSettingsFile:
""" """
An object to handle expansion, conversion, and saving of the microscope configuration. An object to handle expansion, conversion, and saving of the microscope configuration.
@ -85,16 +87,16 @@ class OpenflexureSettingsFile:
# If the loaded config was in contracted format # If the loaded config was in contracted format
if self.expand: if self.expand:
# Contract self._config into self.raw_config # Contract self._config into self.raw_config
save_config = self.contract_config(config) save_settings = self.contract_config(config)
else: else:
save_config = config save_settings = config
if backup: if backup:
if os.path.isfile(self.config_path): if os.path.isfile(self.config_path):
shutil.copyfile(self.config_path, self.config_path + ".bk") shutil.copyfile(self.config_path, self.config_path + ".bk")
logging.debug("Saving settings dictionary to disk") logging.debug("Saving settings dictionary to disk")
save_json_file(self.config_path, save_config) save_json_file(self.config_path, save_settings)
def merge(self, config: dict, backup: bool = True): def merge(self, config: dict, backup: bool = True):
logging.debug("Merging settings with file on disk") logging.debug("Merging settings with file on disk")
@ -164,6 +166,7 @@ class OpenflexureSettingsFile:
# HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES # HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES
def load_json_file(config_path) -> dict: def load_json_file(config_path) -> dict:
""" """
Open a .json config file Open a .json config file

View file

@ -11,7 +11,7 @@ from openflexure_microscope.stage.mock import MockStage
from openflexure_microscope.camera.base import BaseCamera from openflexure_microscope.camera.base import BaseCamera
from openflexure_microscope.camera.mock import MockStreamer from openflexure_microscope.camera.mock import MockStreamer
from openflexure_microscope.plugins import PluginMount from openflexure_microscope.plugins import PluginLoader
from openflexure_microscope.task import TaskOrchestrator from openflexure_microscope.task import TaskOrchestrator
from openflexure_microscope.common.lock import CompositeLock from openflexure_microscope.common.lock import CompositeLock
from openflexure_microscope.config import user_settings from openflexure_microscope.config import user_settings
@ -30,7 +30,7 @@ class Microscope:
stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object
task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing
background tasks using microscope hardware background tasks using microscope hardware
plugin (:py:class:`openflexure_microscope.plugins.PluginMount`): Mounting point for all microscope plugins plugins (:py:class:`openflexure_microscope.plugins.PluginLoader`): Mounting point for all microscope plugins
""" """
def __init__(self): def __init__(self):
@ -49,10 +49,10 @@ class Microscope:
self.task = TaskOrchestrator() self.task = TaskOrchestrator()
# Apply settings loaded from file # Apply settings loaded from file
self.apply_config(user_settings.load()) self.apply_settings(user_settings.load())
# Create plugin mount-point and attach plugins from maps # Create plugin mount-point and attach plugins from maps
self.plugin = PluginMount(self) self.plugins = PluginLoader(self)
self.attach_plugins(self.plugin_maps) self.attach_plugins(self.plugin_maps)
def __enter__(self): def __enter__(self):
@ -84,7 +84,7 @@ class Microscope:
stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): stage object stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): stage object
""" """
settings_full = self.read_config() settings_full = self.read_settings()
logging.debug("Attaching camera...") logging.debug("Attaching camera...")
self.camera = ( self.camera = (
@ -117,7 +117,7 @@ class Microscope:
self.lock.locks.append(self.stage.lock) self.lock.locks.append(self.stage.lock)
logging.info("Reapplying settings to newly attached devices") logging.info("Reapplying settings to newly attached devices")
self.apply_config(settings_full) self.apply_settings(settings_full)
def attach_plugins(self, plugin_maps: list): def attach_plugins(self, plugin_maps: list):
""" """
@ -125,7 +125,7 @@ class Microscope:
""" """
if plugin_maps: if plugin_maps:
for plugin_map in plugin_maps: for plugin_map in plugin_maps:
self.plugin.attach(plugin_map) self.plugins.attach(plugin_map)
else: else:
logging.warning("No plugins specified. Skipping.") logging.warning("No plugins specified. Skipping.")
@ -134,7 +134,7 @@ class Microscope:
Empty the plugin mount and re-attach from config. Empty the plugin mount and re-attach from config.
""" """
logging.info("Tearing down existing PluginMount...") logging.info("Tearing down existing PluginMount...")
self.plugin = PluginMount(self) self.plugins = PluginLoader(self)
logging.info("Repopulating PluginMount...") logging.info("Repopulating PluginMount...")
self.attach_plugins(self.plugin_maps) self.attach_plugins(self.plugin_maps)
@ -157,17 +157,36 @@ class Microscope:
Return: Return:
dict: Dictionary containing position data, dict: Dictionary containing position data,
and :py:attr:`openflexure_microscope.camera.base.BaseCamera.state` and :py:attr:`openflexure_microscope.camera.base.BaseCamera.status`
""" """
# DEPRECATED
logging.warning(
"Microscope.state is deprecated. Use Microscope.status instead. State will be removed in a future version."
)
state = { state = {
"camera": self.camera.state, "camera": self.camera.status,
"stage": self.stage.state, "stage": self.stage.status,
"plugin": self.plugin.state, "plugin": self.plugins.state,
"version": pkg_resources.get_distribution("openflexure_microscope").version, "version": pkg_resources.get_distribution("openflexure_microscope").version,
} }
return state return state
def apply_config(self, config: dict): # Create unified status
@property
def status(self):
"""Dictionary of the basic microscope status.
Return:
dict: Dictionary containing complete microscope status
"""
state = {
"camera": self.camera.status,
"stage": self.stage.status,
"version": pkg_resources.get_distribution("openflexure_microscope").version,
}
return state
def apply_settings(self, config: dict):
""" """
Applies a config dictionary. Missing parameters will be left untouched. Applies a config dictionary. Missing parameters will be left untouched.
""" """
@ -175,11 +194,11 @@ class Microscope:
# If attached to a camera # If attached to a camera
if ("camera_settings" in config) and self.camera: if ("camera_settings" in config) and self.camera:
self.camera.apply_config(config["camera_settings"]) self.camera.apply_settings(config["camera_settings"])
# If attached to a stage # If attached to a stage
if ("stage_settings" in config) and self.stage: if ("stage_settings" in config) and self.stage:
self.stage.apply_config(config["stage_settings"]) self.stage.apply_settings(config["stage_settings"])
# Todo: tidy up with some loopy goodness # Todo: tidy up with some loopy goodness
if "id" in config: if "id" in config:
@ -191,7 +210,7 @@ class Microscope:
if "plugins" in config: if "plugins" in config:
self.plugin_maps = config["plugins"] self.plugin_maps = config["plugins"]
def read_config(self, json_safe=False): def read_settings(self, json_safe=False):
""" """
Get an updated settings dictionary. Get an updated settings dictionary.
@ -211,47 +230,43 @@ class Microscope:
# If attached to a camera # If attached to a camera
if self.camera: if self.camera:
settings_current_camera = self.camera.read_config() settings_current_camera = self.camera.read_settings()
settings_current["camera_settings"] = settings_current_camera settings_current["camera_settings"] = settings_current_camera
# If attached to a stage # If attached to a stage
if self.stage: if self.stage:
settings_current_stage = self.stage.read_config() settings_current_stage = self.stage.read_settings()
settings_current["stage_settings"] = settings_current_stage settings_current["stage_settings"] = settings_current_stage
settings_full = user_settings.merge(settings_current) settings_full = user_settings.merge(settings_current)
return settings_full return settings_full
def save_config(self): def save_settings(self):
""" """
Merges the current settings back to disk Merges the current settings back to disk
""" """
# Read curent config # Read curent config
current_config = self.read_config() current_config = self.read_settings()
# Merge in server version responsible for saving the config file
current_config["server_version"] = pkg_resources.get_distribution(
"openflexure_microscope"
).version
# Save config to file # Save config to file
if self.camera: if self.camera:
self.camera.save_config() self.camera.save_settings()
if self.stage: if self.stage:
self.stage.save_config() self.stage.save_settings()
user_settings.save(current_config, backup=True) user_settings.save(current_config, backup=True)
@property @property
def config(self) -> dict: def config(self) -> dict:
logging.warning( logging.warning(
"Reading microscope through config property is deprecated.\ "Reading microscope through config property is deprecated.\
Please use read_config method instead." Please use read_settings method instead."
) )
return self.read_config() return self.read_settings()
@config.setter @config.setter
def config(self, config: dict) -> None: def config(self, config: dict) -> None:
logging.warning( logging.warning(
"Setting microscope through config property is deprecated.\ "Setting microscope through config property is deprecated.\
Please use apply_config method instead." Please use apply_settings method instead."
) )
self.apply_config(config) self.apply_settings(config)

View file

@ -9,6 +9,6 @@
"plugins": [ "plugins": [
"openflexure_microscope.plugins.default.autofocus:AutofocusPlugin", "openflexure_microscope.plugins.default.autofocus:AutofocusPlugin",
"openflexure_microscope.plugins.default.scan:ScanPlugin", "openflexure_microscope.plugins.default.scan:ScanPlugin",
"openflexure_microscope.plugins.default.camera_calibration:Plugin" "openflexure_microscope.plugins.default.camera_calibration:AutocalibrationPlugin"
] ]
} }

View file

@ -3,7 +3,7 @@ __all__ = [
"load_plugin_class", "load_plugin_class",
"load_plugin_module", "load_plugin_module",
"class_from_map", "class_from_map",
"PluginMount", "PluginLoader",
"MicroscopePlugin", "MicroscopePlugin",
] ]
@ -12,6 +12,6 @@ from .loader import (
load_plugin_class, load_plugin_class,
load_plugin_module, load_plugin_module,
class_from_map, class_from_map,
PluginMount, PluginLoader,
MicroscopePlugin, MicroscopePlugin,
) )

View file

@ -18,6 +18,10 @@ class MeasureSharpnessAPI(MicroscopeViewPlugin):
class AutofocusAPI(MicroscopeViewPlugin): class AutofocusAPI(MicroscopeViewPlugin):
"""
Run a standard autofocus
"""
def post(self): def post(self):
payload = JsonResponse(request) payload = JsonResponse(request)
@ -29,12 +33,17 @@ class AutofocusAPI(MicroscopeViewPlugin):
task = taskify(self.plugin.autofocus)(dz) task = taskify(self.plugin.autofocus)(dz)
# return a handle on the autofocus task # return a handle on the autofocus task
return jsonify(task.state), 202 return jsonify(task.state), 201
else: else:
abort(503, 'No stage connected. Unable to autofocus.') abort(503, "No stage connected. Unable to autofocus.")
class FastAutofocusAPI(MicroscopeViewPlugin): class FastAutofocusAPI(MicroscopeViewPlugin):
"""
Run a fast autofocus
"""
def post(self): def post(self):
payload = JsonResponse(request) payload = JsonResponse(request)
@ -49,7 +58,7 @@ class FastAutofocusAPI(MicroscopeViewPlugin):
task = taskify(self.plugin.fast_autofocus)(dz, backlash=backlash) task = taskify(self.plugin.fast_autofocus)(dz, backlash=backlash)
# return a handle on the autofocus task # return a handle on the autofocus task
return jsonify(task.state), 202 return jsonify(task.state), 201
else: else:
abort(503, 'No stage connected. Unable to autofocus.') abort(503, "No stage connected. Unable to autofocus.")

View file

@ -1,2 +1,2 @@
__all__ = ["Plugin"] __all__ = ["Plugin"]
from .plugin import Plugin from .plugin import AutocalibrationPlugin

View file

@ -18,12 +18,12 @@ class RecalibrateAPIView(MicroscopeViewPlugin):
task = taskify(self.plugin.recalibrate)() task = taskify(self.plugin.recalibrate)()
# Return a handle on the autofocus task # Return a handle on the autofocus task
return jsonify(task.state), 202 return jsonify(task.state), 201
class Plugin(MicroscopePlugin): class AutocalibrationPlugin(MicroscopePlugin):
""" """
A set of default plugins Auto-calibration plugin
""" """
api_views = {"/recalibrate": RecalibrateAPIView} api_views = {"/recalibrate": RecalibrateAPIView}
@ -37,10 +37,10 @@ class Plugin(MicroscopePlugin):
""" """
scamera = self.microscope.camera scamera = self.microscope.camera
with scamera.lock: with scamera.lock:
assert not scamera.state[ assert not scamera.status[
"record_active" "record_active"
], "Can't recalibrate while recording!" ], "Can't recalibrate while recording!"
streaming = scamera.state["stream_active"] streaming = scamera.status["stream_active"]
if streaming: if streaming:
logging.info("Stopping stream before recalibration") logging.info("Stopping stream before recalibration")
scamera.stop_stream_recording(resolution=(640, 480)) scamera.stop_stream_recording(resolution=(640, 480))
@ -51,7 +51,7 @@ class Plugin(MicroscopePlugin):
recalibrate_camera(scamera.camera) recalibrate_camera(scamera.camera)
finally: finally:
scamera.camera.resolution = old_resolution scamera.camera.resolution = old_resolution
self.microscope.save_config() self.microscope.save_settings()
if streaming: if streaming:
logging.info("Restarting stream after recalibration") logging.info("Restarting stream after recalibration")
scamera.start_stream_recording() scamera.start_stream_recording()

View file

@ -60,4 +60,4 @@ class TileScanAPI(MicroscopeViewPlugin):
) )
# return a handle on the scan task # return a handle on the scan task
return jsonify(task.state), 202 return jsonify(task.state), 201

View file

@ -58,6 +58,8 @@ class ScanPlugin(MicroscopePlugin):
api_views = {"/tile": TileScanAPI} api_views = {"/tile": TileScanAPI}
def __init__(self): def __init__(self):
MicroscopePlugin.__init__(self)
self.images_to_be_captured: int = 1 self.images_to_be_captured: int = 1
update_task_data({"images_to_be_captured": self.images_to_be_captured}) update_task_data({"images_to_be_captured": self.images_to_be_captured})
@ -97,18 +99,19 @@ class ScanPlugin(MicroscopePlugin):
if "scan" not in tags: if "scan" not in tags:
tags.append("scan") tags.append("scan")
metadata.update( # Inject system metadata
{ system_metadata = {
"scan_id": scan_id, "microscope_settings": self.microscope.read_settings(),
"basename": basename, "microscope_state": self.microscope.state,
"microscope_settings": self.microscope.read_config(), "microscope_id": self.microscope.id,
"microscope_state": self.microscope.state, "microscope_name": self.microscope.name,
"microscope_id": self.microscope.id, }
"microscope_name": self.microscope.name, output.system_metadata.update(system_metadata)
}
)
# Insert custom metadata
output.put_metadata(metadata) output.put_metadata(metadata)
# Insert custom tags
output.put_tags(tags) output.put_tags(tags)
def tile( def tile(
@ -146,14 +149,18 @@ class ScanPlugin(MicroscopePlugin):
if "time" not in metadata: if "time" not in metadata:
metadata["time"] = generate_basename() metadata["time"] = generate_basename()
metadata.update({ metadata.update(
"scan_parameters": { {
"step_size": step_size, "scan_id": scan_id,
"grid": grid, "basename": basename,
"style": style, "scan_parameters": {
"autofocus_dz": autofocus_dz "step_size": step_size,
"grid": grid,
"style": style,
"autofocus_dz": autofocus_dz,
},
} }
}) )
# Check if autofocus is enabled # Check if autofocus is enabled
if ( if (

View file

@ -73,7 +73,7 @@ class LongRunningAPI(MicroscopeViewPlugin):
# Attach the long-running method as a microscope task # Attach the long-running method as a microscope task
try: try:
task = self.microscope.task.start(self.plugin.long_running, time_to_run) task = self.microscope.task.start(self.plugin.long_running, time_to_run)
return jsonify(task.state), 202 return jsonify(task.state), 201
except TaskDeniedException: except TaskDeniedException:
return abort(409) return abort(409)
@ -94,7 +94,7 @@ class SomeExceptionAPI(MicroscopeViewPlugin):
# Attach the long-running method as a microscope task # Attach the long-running method as a microscope task
try: try:
task = self.microscope.task.start(self.plugin.some_exception) task = self.microscope.task.start(self.plugin.some_exception)
return jsonify(task.state), 202 return jsonify(task.state), 201
except TaskDeniedException: except TaskDeniedException:
return abort(409) return abort(409)

View file

@ -1,8 +1,12 @@
import importlib import importlib
import copy
import os import os
import inspect import inspect
import logging import logging
from openflexure_microscope.utilities import camel_to_snake, camel_to_spine
from openflexure_microscope.api.views import MicroscopeViewPlugin
class ConColors: class ConColors:
HEADER = "\033[95m" HEADER = "\033[95m"
@ -80,7 +84,6 @@ def name_from_module(plugin_path):
def load_plugin_module(plugin_path): def load_plugin_module(plugin_path):
# If the loader was found (i.e. plugin probably exists) # If the loader was found (i.e. plugin probably exists)
if check_module(plugin_path): if check_module(plugin_path):
try: try:
@ -137,7 +140,7 @@ def class_from_map(plugin_map):
return load_plugin_class(*plugin_arr) return load_plugin_class(*plugin_arr)
class PluginMount(object): class PluginLoader(object):
""" """
A mount-point for all loaded plugins. Attaches to a Microscope object. A mount-point for all loaded plugins. Attaches to a Microscope object.
@ -147,30 +150,22 @@ class PluginMount(object):
def __init__(self, parent): def __init__(self, parent):
self.parent = parent self.parent = parent
self.plugins = [] # List of plugin objects self._plugins = [] # List of plugin objects
self._legacy_plugins = {} # DEPRECATED: Dictionary of plugins with old names
self.forms = [] # List of plugin forms self.forms = [] # List of plugin forms
logging.info("Creating plugin mount") logging.info("Creating plugin mount")
@property @property
def state(self): def state(self):
return [m[0] for m in self.members] # DEPRECATED
logging.warning(
"PluginMount.state is deprecated. Use PluginMount.active instead. State will be removed in a future version."
)
return list(self._legacy_plugins.keys())
@property @property
def members(self): def active(self):
ignores = ["state", "members", "attach"] return self._plugins
plugin_array = []
for obj_name in dir(self):
if not obj_name in ignores and not obj_name[:2] == "__":
obj = getattr(self, obj_name)
if isinstance(obj, MicroscopePlugin):
plugin_members = [
member
for member in inspect.getmembers(obj)
if not member[0][:2] == "__"
]
plugin_info = (obj_name, plugin_members)
plugin_array.append(plugin_info)
return plugin_array
def attach(self, plugin_map): def attach(self, plugin_map):
""" """
@ -183,7 +178,9 @@ class PluginMount(object):
if plugin_class is not None: if plugin_class is not None:
pythonsafe_plugin_name = plugin_name.replace("/", "_") logging.debug(f"Loading plugin class {plugin_class}, {plugin_name}")
plugin_name_python_safe = plugin_name.replace("/", "_")
if plugin_class and plugin_name: if plugin_class and plugin_name:
plugin_object = plugin_class() plugin_object = plugin_class()
@ -200,18 +197,24 @@ class PluginMount(object):
) )
elif isinstance( elif isinstance(
plugin_object, MicroscopePlugin plugin_object, BasePlugin
): # If plugin_object is an instance of MicroscopePlugin ): # If plugin_object is an instance of MicroscopePlugin
# Attach plugin_object to the plugin mount # Attach plugin_object to the plugin mount
setattr(self, pythonsafe_plugin_name, plugin_object) setattr(self, plugin_name_python_safe, plugin_object)
self.plugins.append((plugin_name, plugin_object)) # Store the plugin object, and it's properties
self._plugins.append(plugin_object)
# DEPRECATED: Store the plugin with it's old name
self._legacy_plugins[plugin_name] = plugin_object
# Grant plugin access to the hardware # Grant plugin access to the hardware
plugin_object.microscope = self.parent if isinstance(plugin_object, MicroscopePlugin):
plugin_object.microscope = self.parent
logging.info( logging.info(
ConColors.OKGREEN ConColors.OKGREEN
+ "Plugin {} loaded as {}.".format(plugin_map, plugin_name) + "Plugin {} loaded as {}.".format(
plugin_map, plugin_object._name
)
+ ConColors.ENDC + ConColors.ENDC
) )
@ -219,7 +222,102 @@ class PluginMount(object):
logging.warning(f"Error loading plugin {plugin_map}. Moving on.") logging.warning(f"Error loading plugin {plugin_map}. Moving on.")
class MicroscopePlugin: class BasePlugin:
"""
Parent class for all plugins.
Handles binding route views and forms.
"""
def __init__(self):
self._views = (
{}
) # Key: Full, Python-safe ID. Val: Original rule, and view class
self._rules = {} # Key: Original rule. Val: View class
self._gui = None
# If old api_views dictionary is found
if hasattr(self, "api_views"):
# Convert to new format
self._convert_old_api_views()
# If old api_form dictionary is found
if hasattr(self, "api_form"):
# Convert to new format
self._convert_old_api_form()
@property
def views(self):
return self._views
def add_view(self, rule, view_class):
# Remove all leading slashes from view route
cleaned_rule = rule
while cleaned_rule[0] == "/":
cleaned_rule = cleaned_rule[1:]
# Expand the rule to include plugin name
full_rule = "/{}/{}".format(self._name_uri_safe, cleaned_rule)
# Create a Python-safe route ID
view_id = cleaned_rule.replace("/", "_")
# Store route information in a dictionary
d = {"rule": full_rule, "view": view_class}
# Add view to private views dictionary
self._views[view_id] = d
# Store the rule expansion information
self._rules[rule] = self._views[view_id]
@property
def gui(self):
if not self._gui:
return None
api_gui = copy.deepcopy(self._gui)
api_gui["id"] = self._name
if "forms" in api_gui and isinstance(api_gui["forms"], list):
for form in api_gui["forms"]:
if "route" in form and form["route"] in self._rules.keys():
form["route"] = self._rules[form["route"]]["rule"]
else:
logging.warn(
"No valid expandable route found for {}".format(form["route"])
)
return api_gui
def set_gui(self, form_dictionary: dict):
self._gui = form_dictionary
def _convert_old_api_views(self):
for view_route, view_class in self.api_views.items():
self.add_view(view_route, view_class)
def _convert_old_api_form(self):
self.set_gui(self.api_form)
@property
def _name(self):
return self.__class__.__name__
@property
def _name_python_safe(self):
return camel_to_snake(self._name)
@property
def _name_uri_safe(self):
return camel_to_spine(self._name)
def _full_name(self):
module = self.__class__.__module__
if module is None or module == str.__class__.__module__:
return self.__class__.__name__ # Avoid reporting __builtin__
else:
return module + "." + self.__class__.__name__
class MicroscopePlugin(BasePlugin):
""" """
Parent class for all microscope plugins. Parent class for all microscope plugins.
@ -230,6 +328,7 @@ class MicroscopePlugin:
api_views = {} # Initially empty dictionary of API views associated with the plugin api_views = {} # Initially empty dictionary of API views associated with the plugin
def __init__(self): def __init__(self):
BasePlugin.__init__(self)
self.microscope = ( self.microscope = (
None None
) #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object ) #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object

View file

@ -57,4 +57,4 @@ class TaskAPI(MicroscopeViewPlugin):
task = taskify(self.plugin.generate_random_numbers_for_a_while)(val_int) task = taskify(self.plugin.generate_random_numbers_for_a_while)(val_int)
# return a handle on the autofocus task # return a handle on the autofocus task
return jsonify(task.state), 202 return jsonify(task.state), 201

View file

@ -1,8 +1,7 @@
{ {
"id": "test-plugin", "id": "test-plugin",
"icon": "pets", "icon": "pets",
"forms": [ "forms": [{
{
"name": "Simple request", "name": "Simple request",
"isCollapsible": false, "isCollapsible": false,
"isTask": false, "isTask": false,
@ -10,10 +9,8 @@
"route": "/do", "route": "/do",
"submitLabel": "Do things", "submitLabel": "Do things",
"schema": [ "schema": [
[ [{
{
"fieldType": "numberInput", "fieldType": "numberInput",
"placeholder": "Some integer",
"name": "val_int", "name": "val_int",
"label": "Number value", "label": "Number value",
"minValue": 0 "minValue": 0
@ -25,8 +22,7 @@
"name": "val_str" "name": "val_str"
} }
], ],
[ [{
{
"fieldType": "radioList", "fieldType": "radioList",
"name": "val_radio", "name": "val_radio",
"label": "Radio value", "label": "Radio value",
@ -54,8 +50,8 @@
{ {
"fieldType": "textInput", "fieldType": "textInput",
"placeholder": "Some string",
"label": "Non-persistent string", "label": "Non-persistent string",
"default": "A default value",
"name": "val_disposable" "name": "val_disposable"
} }
] ]
@ -66,15 +62,12 @@
"selfUpdate": true, "selfUpdate": true,
"route": "/task", "route": "/task",
"submitLabel": "Start task", "submitLabel": "Start task",
"schema": [ "schema": [{
{ "fieldType": "numberInput",
"fieldType": "numberInput", "name": "run_time",
"placeholder": "", "label": "Run time (seconds)",
"name": "run_time", "minValue": 1
"label": "Run time (seconds)", }]
"minValue": 1
}
]
} }
] ]
} }

View file

@ -23,6 +23,8 @@ class ExamplePlugin(MicroscopePlugin):
api_views = {"/do": DoAPI, "/task": TaskAPI} api_views = {"/do": DoAPI, "/task": TaskAPI}
def __init__(self): def __init__(self):
MicroscopePlugin.__init__(self)
self.val_int = 10 self.val_int = 10
self.val_str = "Hello" self.val_str = "Hello"
self.val_radio = "First" self.val_radio = "First"

View file

@ -14,22 +14,22 @@ class BaseStage(metaclass=ABCMeta):
self.lock = StrictLock(timeout=5) self.lock = StrictLock(timeout=5)
@abstractmethod @abstractmethod
def apply_config(self, config: dict): def apply_settings(self, config: dict):
"""Update settings from a config dictionary""" """Update settings from a config dictionary"""
pass pass
@abstractmethod @abstractmethod
def read_config(self): def read_settings(self):
"""Return the current settings as a dictionary""" """Return the current settings as a dictionary"""
pass pass
def save_config(self): def save_settings(self):
"""(Optional) Save any settings to disk that need to be stored""" """(Optional) Save any settings to disk that need to be stored"""
return return
@property @property
@abstractmethod @abstractmethod
def state(self): def status(self):
"""The general state dictionary of the board. """The general state dictionary of the board.
Should at least contain 'position', and 'board' keys. Should at least contain 'position', and 'board' keys.
Note: A None/Null value for 'board' will disable stage Note: A None/Null value for 'board' will disable stage

View file

@ -16,9 +16,9 @@ class MockStage(BaseStage):
self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis
@property @property
def state(self): def status(self):
"""The general state dictionary of the board.""" """The general status dictionary of the board."""
state = { status = {
"position": { "position": {
"x": self.position[0], "x": self.position[0],
"y": self.position[1], "y": self.position[1],
@ -27,9 +27,9 @@ class MockStage(BaseStage):
"board": None, "board": None,
"version": "0", "version": "0",
} }
return state return status
def apply_config(self, config: dict): def apply_settings(self, config: dict):
"""Update settings from a config dictionary""" """Update settings from a config dictionary"""
# Set backlash. Expects a dictionary with axis labels # Set backlash. Expects a dictionary with axis labels
@ -38,7 +38,7 @@ class MockStage(BaseStage):
backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0]) backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0])
self.backlash = backlash self.backlash = backlash
def read_config(self) -> dict: def read_settings(self) -> dict:
"""Return the current settings as a dictionary""" """Return the current settings as a dictionary"""
blsh = self.backlash.tolist() blsh = self.backlash.tolist()
config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}} config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}}

View file

@ -31,9 +31,9 @@ class SangaStage(BaseStage):
self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis
@property @property
def state(self): def status(self):
"""The general state dictionary of the board.""" """The general status dictionary of the board."""
state = { status = {
"position": { "position": {
"x": self.position[0], "x": self.position[0],
"y": self.position[1], "y": self.position[1],
@ -42,7 +42,7 @@ class SangaStage(BaseStage):
"board": self.board.board, "board": self.board.board,
"firmware": self.board.firmware, "firmware": self.board.firmware,
} }
return state return status
@property @property
def n_axes(self): def n_axes(self):
@ -85,7 +85,7 @@ class SangaStage(BaseStage):
else: else:
self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int) self._backlash = np.array([int(blsh)] * self.n_axes, dtype=np.int)
def apply_config(self, config: dict): def apply_settings(self, config: dict):
"""Update settings from a config dictionary""" """Update settings from a config dictionary"""
# Set backlash. Expects a dictionary with axis labels # Set backlash. Expects a dictionary with axis labels
@ -94,7 +94,7 @@ class SangaStage(BaseStage):
backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0]) backlash = axes_to_array(config["backlash"], ["x", "y", "z"], [0, 0, 0])
self.backlash = backlash self.backlash = backlash
def read_config(self) -> dict: def read_settings(self) -> dict:
"""Return the current settings as a dictionary""" """Return the current settings as a dictionary"""
blsh = self.backlash.tolist() blsh = self.backlash.tolist()
config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}} config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}}

View file

@ -65,6 +65,7 @@ class ExtensibleSerialInstrument(object):
Set up the serial port and so on. Set up the serial port and so on.
""" """
logging.info("Updating ESI port settings") logging.info("Updating ESI port settings")
logging.debug(kwargs)
self.port_settings.update(kwargs) self.port_settings.update(kwargs)
logging.info("Opening ESI connection to port {}".format(port)) logging.info("Opening ESI connection to port {}".format(port))
self.open(port, False) # Eventually this shouldn't rely on init... self.open(port, False) # Eventually this shouldn't rely on init...
@ -86,7 +87,9 @@ class ExtensibleSerialInstrument(object):
assert ( assert (
port is not None 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?" ), "We don't have a serial port to open, meaning you didn't specify a valid port. Are you sure the instrument is connected?"
logging.info("Creating serial.Serial instance...")
self._ser = serial.Serial(port, **self.port_settings) self._ser = serial.Serial(port, **self.port_settings)
logging.info(f"Created {self._ser}")
# 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
# this allows us to read/write in neat lines. NB the buffer size must # this allows us to read/write in neat lines. NB the buffer size must
# be set to 1 byte for maximum responsiveness. # be set to 1 byte for maximum responsiveness.
@ -130,11 +133,6 @@ class ExtensibleSerialInstrument(object):
assert ( assert (
self._ser.isOpen() self._ser.isOpen()
), "Attempted to write to the serial port before it was opened. Perhaps you need to call the 'open' method first?" ), "Attempted to write to the serial port before it was opened. Perhaps you need to call the 'open' method first?"
# TODO: Check if this code is needed and if not kill it
# try:
# if self._ser.outWaiting()>0: self._ser.flushOutput() #ensure there's nothing waiting
# except AttributeError:
# if self._ser.out_waiting>0: self._ser.flushOutput() #ensure there's nothing waiting
data = query_string + self.termination_character data = query_string + self.termination_character
data = data.encode() data = data.encode()
self._ser.write(data) self._ser.write(data)
@ -193,10 +191,15 @@ class ExtensibleSerialInstrument(object):
will keep reading until a termination phrase is reached. will keep reading until a termination phrase is reached.
""" """
with self.communications_lock: with self.communications_lock:
logging.debug("Flushing input buffer...")
self.flush_input_buffer() self.flush_input_buffer()
logging.debug(f"Writing query: {queryString}")
self.write(queryString) self.write(queryString)
logging.debug("Query written")
if self.ignore_echo == True: # Needs Implementing for a multiline read! if self.ignore_echo == True: # Needs Implementing for a multiline read!
logging.debug("Reading first line...")
first_line = self.readline(timeout).strip() first_line = self.readline(timeout).strip()
logging.debug(f"Read finished. Got {first_line}")
if first_line == queryString: if first_line == queryString:
return self.readline(timeout).strip() return self.readline(timeout).strip()
else: else:
@ -206,11 +209,15 @@ class ExtensibleSerialInstrument(object):
if termination_line is not None: if termination_line is not None:
multiline = True multiline = True
if multiline: if multiline:
logging.debug("Reading multiline...")
return self.read_multiline(termination_line) return self.read_multiline(termination_line)
else: else:
return self.readline( logging.debug("Reading response...")
timeout line = (
).strip() # question: should we strip the final newline? self.readline(timeout).strip()
) # question: should we strip the final newline?
logging.debug(f"Read finished. Got {line}")
return line
def parsed_query( def parsed_query(
self, self,
@ -218,7 +225,7 @@ class ExtensibleSerialInstrument(object):
response_string=r"%d", response_string=r"%d",
re_flags=0, re_flags=0,
parse_function=None, parse_function=None,
**kwargs **kwargs,
): ):
""" """
Perform a query, returning a parsed form of the response. Perform a query, returning a parsed form of the response.
@ -345,6 +352,7 @@ class ExtensibleSerialInstrument(object):
def find_port(self): def find_port(self):
"""Iterate through the available serial ports and query them to see """Iterate through the available serial ports and query them to see
if our instrument is there.""" if our instrument is there."""
print("Auto-scanning ports")
with self.communications_lock: with self.communications_lock:
success = False success = False
for ( for (

View file

@ -90,7 +90,7 @@ class Sangaboard(ExtensibleSerialInstrument):
# Once initialised, `firmware` is a string that identifies the firmware version # Once initialised, `firmware` is a string that identifies the firmware version
firmware = None firmware = None
def __init__(self, port=None, **kwargs): def __init__(self, port=None, timeout: int = 2, **kwargs):
"""Create a sangaboard object. """Create a sangaboard object.
Arguments are passed to the constructor of Arguments are passed to the constructor of
@ -101,13 +101,16 @@ class Sangaboard(ExtensibleSerialInstrument):
""" """
# Initialise basic serial instrument with specified # Initialise basic serial instrument with specified
logging.info(f"Initialising ExtensibleSerialInstrument on port {port}")
ExtensibleSerialInstrument.__init__(self, 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
logging.info("Checking valid firmware...")
self.check_valid_firmware() self.check_valid_firmware()
# Bit messy: Defining all valid modules as not available, then overwriting with available information if available. # Bit messy: Defining all valid modules as not available, then overwriting with available information if available.
logging.info("Loading modules...")
self.light_sensor = LightSensor(False) self.light_sensor = LightSensor(False)
for module in self.list_modules(): for module in self.list_modules():
@ -145,10 +148,11 @@ class Sangaboard(ExtensibleSerialInstrument):
""" """
Overrides superclass, used in self.open(), and port scanning Overrides superclass, used in self.open(), and port scanning
""" """
logging.info("Testing communication to SangaBoard")
return self.check_valid_firmware() return self.check_valid_firmware()
def check_valid_firmware(self): def check_valid_firmware(self):
logging.debug("Running firmware checks") logging.info("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()

View file

@ -7,7 +7,7 @@ from openflexure_microscope.common.tasks import (
remove_task, remove_task,
) )
# DEPRECATED
class TaskOrchestrator: class TaskOrchestrator:
""" """
DEPRECATED: Class responsible for spawning threaded tasks, and storing their returns. DEPRECATED: Class responsible for spawning threaded tasks, and storing their returns.

View file

@ -1,3 +1,4 @@
import re
import copy import copy
import operator import operator
from collections import abc from collections import abc
@ -5,6 +6,40 @@ from functools import reduce
from contextlib import contextmanager from contextlib import contextmanager
def bottom_level_name(obj):
return obj.__name__.split(".")[-1]
def description_from_view(view_class):
methods = []
for method_key in ["get", "post", "put", "delete"]:
if hasattr(view_class, method_key):
methods.append(method_key.upper())
brief_description = get_docstring(view_class).partition("\n")[0].strip()
d = {"methods": methods, "description": brief_description}
return d
def get_docstring(obj):
ds = obj.__doc__
if ds:
return ds.strip()
else:
return ""
def camel_to_snake(name):
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s1).lower()
def camel_to_spine(name):
s1 = re.sub("(.)([A-Z][a-z]+)", r"\1-\2", name)
return re.sub("([a-z0-9])([A-Z])", r"\1-\2", s1).lower()
@contextmanager @contextmanager
def set_properties(obj, **kwargs): def set_properties(obj, **kwargs):
"""A context manager to set, then reset, certain properties of an object. """A context manager to set, then reset, certain properties of an object.

View file

@ -14,18 +14,18 @@ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG)
class TestPluginMethods(unittest.TestCase): class TestPluginMethods(unittest.TestCase):
def test_plugin_load(self): def test_plugin_load(self):
plugin_arr = microscope.plugin.plugins plugin_arr = microscope.plugins.plugins
plugin_names = [plugin[0] for plugin in plugin_arr] plugin_names = [plugin[0] for plugin in plugin_arr]
self.assertTrue("testing" in plugin_names) self.assertTrue("testing" in plugin_names)
def test_camera_access(self): def test_camera_access(self):
identify = microscope.plugin.testing.identify() identify = microscope.plugins.testing.identify()
self.assertTrue(identify[0] is microscope.camera) self.assertTrue(identify[0] is microscope.camera)
def test_stage_access(self): def test_stage_access(self):
identify = microscope.plugin.testing.identify() identify = microscope.plugins.testing.identify()
self.assertTrue(identify[1] is microscope.stage) self.assertTrue(identify[1] is microscope.stage)
@ -35,7 +35,7 @@ if __name__ == "__main__":
microscope.attach(PiCameraStreamer(), OpenFlexureStage()) microscope.attach(PiCameraStreamer(), OpenFlexureStage())
microscope.plugin.attach("openflexure_microscope.plugins.testing:Plugin") microscope.plugins.attach("openflexure_microscope.plugins.testing:Plugin")
suites = [unittest.TestLoader().loadTestsFromTestCase(TestPluginMethods)] suites = [unittest.TestLoader().loadTestsFromTestCase(TestPluginMethods)]