From 6581612312c17de3ebb2a1c2345773d35fc1d687 Mon Sep 17 00:00:00 2001 From: jtc42 Date: Thu, 14 Nov 2019 16:33:00 +0000 Subject: [PATCH] Implemented root API v2 routes --- openflexure_microscope/api/app.py | 28 +- .../api/v1/blueprints/base.py | 8 +- .../api/v1/blueprints/camera/capture.py | 11 +- .../api/v1/blueprints/camera/function.py | 4 +- .../api/v1/blueprints/plugins.py | 14 +- openflexure_microscope/api/v2/__init__.py | 1 + .../api/v2/blueprints/__init__.py | 1 + .../api/v2/blueprints/captures.py | 422 ++++++++++++++++++ .../api/v2/blueprints/plugins.py | 151 +++++++ .../api/v2/blueprints/settings.py | 153 +++++++ .../api/v2/blueprints/status.py | 64 +++ .../api/v2/blueprints/stream.py | 57 +++ .../api/v2/blueprints/tasks.py | 160 +++++++ openflexure_microscope/camera/base.py | 22 +- openflexure_microscope/camera/capture.py | 18 +- openflexure_microscope/camera/mock.py | 10 +- openflexure_microscope/camera/pi.py | 44 +- openflexure_microscope/config.py | 6 +- openflexure_microscope/microscope.py | 73 +-- openflexure_microscope/plugins/__init__.py | 4 +- .../default/camera_calibration/plugin.py | 6 +- .../plugins/default/scan/plugin.py | 21 +- openflexure_microscope/plugins/loader.py | 35 +- openflexure_microscope/stage/base.py | 8 +- openflexure_microscope/stage/mock.py | 12 +- openflexure_microscope/stage/sanga.py | 12 +- tests/test_plugins.py | 8 +- 27 files changed, 1205 insertions(+), 148 deletions(-) create mode 100644 openflexure_microscope/api/v2/blueprints/captures.py create mode 100644 openflexure_microscope/api/v2/blueprints/plugins.py create mode 100644 openflexure_microscope/api/v2/blueprints/settings.py create mode 100644 openflexure_microscope/api/v2/blueprints/status.py create mode 100644 openflexure_microscope/api/v2/blueprints/stream.py create mode 100644 openflexure_microscope/api/v2/blueprints/tasks.py diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 0d5e342e..8d9c8ec1 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -21,6 +21,7 @@ from openflexure_microscope import Microscope from openflexure_microscope.camera.capture import build_captures_from_exif from openflexure_microscope.config import settings_file_path, JSONEncoder from openflexure_microscope.api.v1 import blueprints +from openflexure_microscope.api import v2 # Import device modules # NB this will eventually be handled by the RC file, so you can choose what device @@ -141,6 +142,31 @@ app.register_blueprint(plugin_blueprint, url_prefix=uri("/plugin", "v1")) task_blueprint = blueprints.task.construct_blueprint(api_microscope) app.register_blueprint(task_blueprint, url_prefix=uri("/task", "v1")) +### V2 +# Tasks routes +v2_stream_blueprint = v2.blueprints.stream.construct_blueprint(api_microscope) +app.register_blueprint(v2_stream_blueprint, url_prefix=uri("/", "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")) + @app.route("/routes") def routes(): @@ -184,7 +210,7 @@ def cleanup(): # Save config logging.debug("Saving config for teardown...") - api_microscope.save_config(backup=True) + api_microscope.save_settings() logging.debug("Settling...") time.sleep(0.5) diff --git a/openflexure_microscope/api/v1/blueprints/base.py b/openflexure_microscope/api/v1/blueprints/base.py index 35c6ad89..cf3b014b 100644 --- a/openflexure_microscope/api/v1/blueprints/base.py +++ b/openflexure_microscope/api/v1/blueprints/base.py @@ -152,7 +152,7 @@ class ConfigAPI(MicroscopeView): :>header Content-Type: application/json :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): """ @@ -223,10 +223,10 @@ class ConfigAPI(MicroscopeView): logging.debug("Updating settings from POST request:") logging.debug(payload.json) - self.microscope.apply_config(payload.json) - self.microscope.save_config() + self.microscope.apply_settings(payload.json) + 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): diff --git a/openflexure_microscope/api/v1/blueprints/camera/capture.py b/openflexure_microscope/api/v1/blueprints/camera/capture.py index 615ea009..8afe5443 100644 --- a/openflexure_microscope/api/v1/blueprints/camera/capture.py +++ b/openflexure_microscope/api/v1/blueprints/camera/capture.py @@ -130,16 +130,19 @@ class ListAPI(MicroscopeView): output.file, use_video_port=use_video_port, resize=resize, bayer=bayer ) - metadata.update( - { - "microscope_settings": self.microscope.read_config(), + # 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) diff --git a/openflexure_microscope/api/v1/blueprints/camera/function.py b/openflexure_microscope/api/v1/blueprints/camera/function.py index f9e8f35f..fb365483 100644 --- a/openflexure_microscope/api/v1/blueprints/camera/function.py +++ b/openflexure_microscope/api/v1/blueprints/camera/function.py @@ -16,7 +16,7 @@ class ZoomAPI(MicroscopeView): :
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 + """ + include_unavailable = get_bool(request.args.get("include_unavailable")) + + representation = captures_representation(self.microscope.camera.images, include_unavailable=include_unavailable) + + 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 + + """ + capture_obj = self.microscope.camera.image_from_id(capture_id) + + if not capture_obj: + return abort(404) # 404 Not Found + + # Get capture state + capture_metadata = capture_obj.state + + # Add API routes to returned state + uri_dict = { + "uri": { + "state": "{}".format(url_for(".capture", capture_id=capture_obj.id)) + } + } + + # If available, also add download link + if capture_metadata["available"]: + uri_dict["uri"]["download"] = "{}download/{}".format( + url_for(".capture", capture_id=capture_obj.id), capture_obj.filename + ) + + capture_metadata.update(uri_dict) + + return jsonify(capture_metadata) + + 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 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 Accept: application/json + + :
/tags", + view_func=TagsAPI.as_view("capture_tags", microscope=microscope_obj), + ) + + # Capture routes + blueprint.add_url_rule( + "//download/", + view_func=DownloadAPI.as_view( + "capture_download", microscope=microscope_obj + ), + ) + + blueprint.add_url_rule( + "//download", + view_func=DownloadRedirectAPI.as_view( + "capture_download_redirect", microscope=microscope_obj + ), + ) + + blueprint.add_url_rule( + "//", + view_func=CaptureAPI.as_view("capture", microscope=microscope_obj), + ) + + blueprint.add_url_rule( + "/", + view_func=ListAPI.as_view("capture_list", microscope=microscope_obj), + ) + return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/plugins.py b/openflexure_microscope/api/v2/blueprints/plugins.py new file mode 100644 index 00000000..9d9a6868 --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/plugins.py @@ -0,0 +1,151 @@ +from openflexure_microscope.plugins import PluginLoader, MicroscopePlugin +from openflexure_microscope.api.views import MicroscopeViewPlugin + +from flask import Blueprint, jsonify +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: + d = { + "name": plugin["name"], + "plugin": str(plugin["plugin"]), + "routes": plugin["routes"], + "form": plugin["form"] + } + plugins.append(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("plugins_blueprint", __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), + ) + + all_routes = [] + + # For each plugin attached to the microscope object + for plugin_representation in microscope_obj.plugins.active: + + plugin_obj = plugin_representation["plugin"] + plugin_name = plugin_representation["name"] + + # If plugin contains valid endpoints + if hasattr(plugin_obj, "api_views") and isinstance(plugin_obj.api_views, dict): + + # We'll keep a record of how each route was expanded + expanded_routes = {} + + # For each defined endpoint + for view_route, view_class in plugin_obj.api_views.items(): + + # Remove all leading slashes from view route + cleaned_route = view_route + while cleaned_route[0] == "/": + cleaned_route = cleaned_route[1:] + + # Construct a full view route from the plugin name + full_view_route = "/{}/{}".format(plugin_name, cleaned_route) + logging.debug(full_view_route) + + # Record how the view_route got expanded + expanded_routes[view_route] = full_view_route + + # Check if endpoint name clashes + if full_view_route not in all_routes and issubclass( + view_class, MicroscopeViewPlugin + ): + # Add route to main route dictionary + all_routes.append(full_view_route) + + # Create a Python-safe name for the route + plugin_route_id = "plugin{}".format(full_view_route).replace( + "/", "_" + ) + + # Add route to the plugins blueprint + blueprint.add_url_rule( + full_view_route, + view_func=view_class.as_view( + plugin_route_id, + microscope=microscope_obj, + plugin=plugin_obj, + ), + ) + + # Add route to the plugin representation dictionary + plugin_representation["routes"].append(full_view_route) + + else: + warnings.warn( + "An endpoint /{} has already been loaded. Skipping {}.".format( + full_view_route, view_class + ) + ) + + # If plugin includes an API form + if hasattr(plugin_obj, "api_form") and isinstance( + plugin_obj.api_form, dict + ): + # TODO: We deep copy this to avoid clashing between API versions. Can be removed when v1 is removed. + api_form_info = copy.deepcopy(plugin_obj.api_form) + api_form_info["id"] = plugin_name + if "forms" in api_form_info and isinstance( + api_form_info["forms"], list + ): + for form in api_form_info["forms"]: + if "route" in form and form["route"] in expanded_routes.keys(): + form["route"] = expanded_routes[form["route"]] + else: + logging.warn( + "No valid expandable route found for {}".format( + form["route"] + ) + ) + + # Store the complete form in Microscope().plugin.form + plugin_representation["form"] = api_form_info + print(microscope_obj.plugins.forms) + + else: + warnings.warn( + "No valid 'api_views' dictionary found in {}".format(plugin_obj) + ) + return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/settings.py b/openflexure_microscope/api/v2/blueprints/settings.py new file mode 100644 index 00000000..972785d3 --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/settings.py @@ -0,0 +1,153 @@ +from openflexure_microscope.api.utilities import gen, JsonResponse +from openflexure_microscope.api.views import MicroscopeView + +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 + + :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("v2_status_blueprint", __name__) + + blueprint.add_url_rule( + "/", view_func=StatusAPI.as_view("status", microscope=microscope_obj) + ) + + return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/stream.py b/openflexure_microscope/api/v2/blueprints/stream.py new file mode 100644 index 00000000..a6f40a42 --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/stream.py @@ -0,0 +1,57 @@ +from openflexure_microscope.api.utilities import gen, JsonResponse +from openflexure_microscope.api.views import MicroscopeView + +from flask import Response, Blueprint, jsonify, request + + +class StreamAPI(MicroscopeView): + 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): + 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") + + +def construct_blueprint(microscope_obj): + + blueprint = Blueprint("v2_stream_blueprint", __name__) + + blueprint.add_url_rule( + "/stream", view_func=StreamAPI.as_view("stream", microscope=microscope_obj) + ) + + blueprint.add_url_rule( + "/snapshot", + view_func=SnapshotAPI.as_view("snapshot", microscope=microscope_obj), + ) + + return blueprint diff --git a/openflexure_microscope/api/v2/blueprints/tasks.py b/openflexure_microscope/api/v2/blueprints/tasks.py new file mode 100644 index 00000000..ffeb9785 --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/tasks.py @@ -0,0 +1,160 @@ +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": { + "properties": "{}".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.tasks()[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.tasks()[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("//", view_func=TaskAPI.as_view("task")) + + return blueprint diff --git a/openflexure_microscope/camera/base.py b/openflexure_microscope/camera/base.py index ae8c35c2..ce7c9143 100644 --- a/openflexure_microscope/camera/base.py +++ b/openflexure_microscope/camera/base.py @@ -100,7 +100,7 @@ class BaseCamera(metaclass=ABCMeta): 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_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 images (list): List of image capture objects videos (list): List of video capture objects @@ -120,7 +120,7 @@ class BaseCamera(metaclass=ABCMeta): self.stream_timeout = 20 self.stream_timeout_enabled = False - self.state = {"board": None} + self.status = {"board": None} # TODO: Load/save these to config self.paths = {"default": BASE_CAPTURE_PATH, "temp": TEMP_CAPTURE_PATH} @@ -130,7 +130,7 @@ class BaseCamera(metaclass=ABCMeta): self.videos = [] @abstractmethod - def apply_config(self, config: dict): + def apply_settings(self, config: dict): """Update settings from a config dictionary""" with self.lock: # Apply valid config params to camera object @@ -139,11 +139,11 @@ class BaseCamera(metaclass=ABCMeta): setattr(self, key, value) # Set to the target value @abstractmethod - def read_config(self) -> dict: + def read_settings(self) -> dict: """Return the current settings as a dictionary""" return {"paths": self.paths} - def save_config(self): + def save_settings(self): """(Optional) Save any settings to disk that need to be stored""" return @@ -187,7 +187,7 @@ class BaseCamera(metaclass=ABCMeta): self.last_access = time.time() self.stop = False - if not self.state["stream_active"]: + if not self.status["stream_active"]: # start background frame thread self.thread = threading.Thread(target=self._thread) self.thread.daemon = True @@ -207,12 +207,12 @@ class BaseCamera(metaclass=ABCMeta): logging.debug("Stopping worker thread") timeout_time = time.time() + timeout - if self.state["stream_active"]: + if self.status["stream_active"]: self.stop = True self.thread.join() # Wait 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: logging.debug("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() logging.debug("Entering worker thread.") - self.state["stream_active"] = True + self.status["stream_active"] = True for frame in self.frames_iterator: self.frame = frame @@ -365,7 +365,7 @@ class BaseCamera(metaclass=ABCMeta): and ( # If using 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" ] # And GPU preview is not active ): @@ -383,4 +383,4 @@ class BaseCamera(metaclass=ABCMeta): logging.debug("BaseCamera worker thread exiting...") # Set stream_activate state - self.state["stream_active"] = False + self.status["stream_active"] = False diff --git a/openflexure_microscope/camera/capture.py b/openflexure_microscope/camera/capture.py index 410c5560..9a66bb79 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -97,7 +97,7 @@ def capture_from_exif(path, exif_dict): capture.timestring = exif_dict["time"] capture.format = exif_dict["format"] - capture._metadata = exif_dict["custom"] + capture.custom_metadata = exif_dict["custom"] capture.tags = exif_dict["tags"] return capture @@ -110,7 +110,7 @@ class CaptureObject(object): Attributes: 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 filefolder (str): Folder in which the capture file will be stored filename (str): Full name of the capture file @@ -132,7 +132,9 @@ class CaptureObject(object): self.split_file_path(self.file) # 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 self.tags = [] @@ -203,7 +205,7 @@ class CaptureObject(object): Args: data (dict): Dictionary of metadata to be added """ - self._metadata.update(data) + self.custom_metadata.update(data) self.save_metadata() def save_metadata(self) -> None: @@ -225,6 +227,7 @@ class CaptureObject(object): # Insert exif into file piexif.insert(exif_bytes, self.file) + @property def metadata(self) -> dict: """ @@ -233,13 +236,14 @@ class CaptureObject(object): """ d = { "id": self.id, - "filename": self.filename, "time": self.timestring, "format": self.format, "tags": self.tags, - "custom": self._metadata, + "custom": self.custom_metadata, } + d.update(self.system_metadata) + # Add custom metadata to dictionary return d @@ -250,7 +254,7 @@ class CaptureObject(object): """ # 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 if self.exists: diff --git a/openflexure_microscope/camera/mock.py b/openflexure_microscope/camera/mock.py index 0b779e88..1d252cdf 100644 --- a/openflexure_microscope/camera/mock.py +++ b/openflexure_microscope/camera/mock.py @@ -26,7 +26,7 @@ class MockStreamer(BaseCamera): BaseCamera.__init__(self) # Store state of PiCameraStreamer - self.state.update( + self.status.update( {"stream_active": False, "record_active": False, "board": None} ) @@ -74,13 +74,13 @@ class MockStreamer(BaseCamera): BaseCamera.close(self) # HANDLE SETTINGS - def read_config(self) -> dict: + def read_settings(self) -> dict: """ Return config dictionary of the PiCameraStreamer. """ # Get config items from the base class - conf_dict = BaseCamera.read_config(self) + conf_dict = BaseCamera.read_settings(self) # Include device-specific config items conf_dict.update( @@ -94,7 +94,7 @@ class MockStreamer(BaseCamera): return conf_dict - def apply_config(self, config: dict): + def apply_settings(self, config: dict): """ Write a config dictionary to the PiCameraStreamer config. @@ -110,7 +110,7 @@ class MockStreamer(BaseCamera): with self.lock: # 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 if hasattr(self, key): diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 68f4d59b..8a75b817 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -75,8 +75,8 @@ class PiCameraStreamer(BaseCamera): picamera.PiCamera() ) #: :py:class:`picamera.PiCamera`: Picamera object - # Store state of PiCameraStreamer - self.state.update( + # Store status of PiCameraStreamer + self.status.update( { "stream_active": False, "record_active": False, @@ -97,7 +97,7 @@ class PiCameraStreamer(BaseCamera): self.picamera_lst_path = settings_file_path("picamera_lst.npy") #: str: Path of .npy lens shading table file # Update board identifier - self.state.update({}) + self.status.update({}) # Create an empty stream self.stream = io.BytesIO() @@ -118,13 +118,13 @@ class PiCameraStreamer(BaseCamera): self.camera.close() # HANDLE SETTINGS - def read_config(self) -> dict: + def read_settings(self) -> dict: """ Return config dictionary of the PiCameraStreamer. """ # Get config items from the base class - conf_dict = BaseCamera.read_config(self) + conf_dict = BaseCamera.read_settings(self) # Include device-specific config items conf_dict.update( @@ -149,12 +149,12 @@ class PiCameraStreamer(BaseCamera): return conf_dict - def save_config(self): + def save_settings(self): """Save lens-shading table to disk""" logging.info("Saving picamera_lst to {}".format(self.picamera_lst_path)) 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. @@ -172,10 +172,10 @@ class PiCameraStreamer(BaseCamera): with self.lock: # 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 - 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.") self.stop_stream_recording() # Pause stream paused_stream = True # Remember to unpause stream when done @@ -299,13 +299,13 @@ class PiCameraStreamer(BaseCamera): Change the camera zoom, handling re-centering and scaling. """ with self.lock: - self.state["zoom_value"] = float(zoom_value) - if self.state["zoom_value"] < 1: - self.state["zoom_value"] = 1 + self.status["zoom_value"] = float(zoom_value) + if self.status["zoom_value"] < 1: + self.status["zoom_value"] = 1 # Richard's code for zooming ! fov = self.camera.zoom 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 # 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) @@ -332,7 +332,7 @@ class PiCameraStreamer(BaseCamera): self.camera.preview.window = window if fullscreen: self.camera.preview.fullscreen = fullscreen - self.state["preview_active"] = True + self.status["preview_active"] = True except picamera.exc.PiCameraMMALError as e: logging.error( "Suppressed a MMALError in start_preview. Exception: {}".format(e) @@ -347,7 +347,7 @@ class PiCameraStreamer(BaseCamera): def stop_preview(self): """Stop the on board GPU camera 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): """Start recording. @@ -365,7 +365,7 @@ class PiCameraStreamer(BaseCamera): """ with self.lock: # 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 logging.info("Recording to {}".format(output)) @@ -378,8 +378,8 @@ class PiCameraStreamer(BaseCamera): quality=quality, ) - # Update state dictionary - self.state["record_active"] = True + # Update status dictionary + self.status["record_active"] = True return output @@ -398,8 +398,8 @@ class PiCameraStreamer(BaseCamera): self.camera.stop_recording(splitter_port=2) logging.info("Recording stopped") - # Update state dictionary - self.state["record_active"] = False + # Update status dictionary + self.status["record_active"] = False def stop_stream_recording( self, splitter_port: int = 1, resolution: Tuple[int, int] = None @@ -467,7 +467,7 @@ class PiCameraStreamer(BaseCamera): self.camera.resolution = resolution # If the stream should be active - if self.state["stream_active"]: + if self.status["stream_active"]: try: # Start recording on stream port self.camera.start_recording( @@ -633,7 +633,7 @@ class PiCameraStreamer(BaseCamera): # Start stream recording (and set resolution) self.start_stream_recording() - # Update state + # Update status logging.debug("STREAM ACTIVE") # While the iterator is not closed diff --git a/openflexure_microscope/config.py b/openflexure_microscope/config.py index 5b6b2c1c..77ab3c48 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -85,16 +85,16 @@ class OpenflexureSettingsFile: # If the loaded config was in contracted format if self.expand: # Contract self._config into self.raw_config - save_config = self.contract_config(config) + save_settings = self.contract_config(config) else: - save_config = config + save_settings = config if backup: if os.path.isfile(self.config_path): shutil.copyfile(self.config_path, self.config_path + ".bk") 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): logging.debug("Merging settings with file on disk") diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index d52efdf7..820d5986 100644 --- a/openflexure_microscope/microscope.py +++ b/openflexure_microscope/microscope.py @@ -11,7 +11,7 @@ from openflexure_microscope.stage.mock import MockStage from openflexure_microscope.camera.base import BaseCamera 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.common.lock import CompositeLock from openflexure_microscope.config import user_settings @@ -30,7 +30,7 @@ class Microscope: stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): Stage object task: (:py:class:`openflexure_microscope.task.TaskOrchestrator`): Threaded ask orchestrator for managing 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): @@ -49,10 +49,10 @@ class Microscope: self.task = TaskOrchestrator() # 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 - self.plugin = PluginMount(self) + self.plugins = PluginLoader(self) self.attach_plugins(self.plugin_maps) def __enter__(self): @@ -84,7 +84,7 @@ class Microscope: stage (:py:class:`openflexure_microscope.stage.base.BaseStage`): stage object """ - settings_full = self.read_config() + settings_full = self.read_settings() logging.debug("Attaching camera...") self.camera = ( @@ -117,7 +117,7 @@ class Microscope: self.lock.locks.append(self.stage.lock) 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): """ @@ -125,7 +125,7 @@ class Microscope: """ if plugin_maps: for plugin_map in plugin_maps: - self.plugin.attach(plugin_map) + self.plugins.attach(plugin_map) else: logging.warning("No plugins specified. Skipping.") @@ -134,7 +134,7 @@ class Microscope: Empty the plugin mount and re-attach from config. """ logging.info("Tearing down existing PluginMount...") - self.plugin = PluginMount(self) + self.plugins = PluginLoader(self) logging.info("Repopulating PluginMount...") self.attach_plugins(self.plugin_maps) @@ -157,17 +157,34 @@ class Microscope: Return: 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 = { - "camera": self.camera.state, - "stage": self.stage.state, - "plugin": self.plugin.state, + "camera": self.camera.status, + "stage": self.stage.status, + "plugin": self.plugins.state, "version": pkg_resources.get_distribution("openflexure_microscope").version, } 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. """ @@ -175,11 +192,11 @@ class Microscope: # If attached to a 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 ("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 if "id" in config: @@ -191,7 +208,7 @@ class Microscope: if "plugins" in config: self.plugin_maps = config["plugins"] - def read_config(self, json_safe=False): + def read_settings(self, json_safe=False): """ Get an updated settings dictionary. @@ -211,47 +228,43 @@ class Microscope: # If attached to a 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 # If attached to a 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_full = user_settings.merge(settings_current) return settings_full - def save_config(self): + def save_settings(self): """ Merges the current settings back to disk """ # Read curent config - current_config = self.read_config() - # Merge in server version responsible for saving the config file - current_config["server_version"] = pkg_resources.get_distribution( - "openflexure_microscope" - ).version + current_config = self.read_settings() # Save config to file if self.camera: - self.camera.save_config() + self.camera.save_settings() if self.stage: - self.stage.save_config() + self.stage.save_settings() user_settings.save(current_config, backup=True) @property def config(self) -> dict: logging.warning( "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 def config(self, config: dict) -> None: logging.warning( "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) diff --git a/openflexure_microscope/plugins/__init__.py b/openflexure_microscope/plugins/__init__.py index df920221..c22207f5 100644 --- a/openflexure_microscope/plugins/__init__.py +++ b/openflexure_microscope/plugins/__init__.py @@ -3,7 +3,7 @@ __all__ = [ "load_plugin_class", "load_plugin_module", "class_from_map", - "PluginMount", + "PluginLoader", "MicroscopePlugin", ] @@ -12,6 +12,6 @@ from .loader import ( load_plugin_class, load_plugin_module, class_from_map, - PluginMount, + PluginLoader, MicroscopePlugin, ) diff --git a/openflexure_microscope/plugins/default/camera_calibration/plugin.py b/openflexure_microscope/plugins/default/camera_calibration/plugin.py index 5b942ab8..6708cbba 100644 --- a/openflexure_microscope/plugins/default/camera_calibration/plugin.py +++ b/openflexure_microscope/plugins/default/camera_calibration/plugin.py @@ -37,10 +37,10 @@ class Plugin(MicroscopePlugin): """ scamera = self.microscope.camera with scamera.lock: - assert not scamera.state[ + assert not scamera.status[ "record_active" ], "Can't recalibrate while recording!" - streaming = scamera.state["stream_active"] + streaming = scamera.status["stream_active"] if streaming: logging.info("Stopping stream before recalibration") scamera.stop_stream_recording(resolution=(640, 480)) @@ -51,7 +51,7 @@ class Plugin(MicroscopePlugin): recalibrate_camera(scamera.camera) finally: scamera.camera.resolution = old_resolution - self.microscope.save_config() + self.microscope.save_settings() if streaming: logging.info("Restarting stream after recalibration") scamera.start_stream_recording() diff --git a/openflexure_microscope/plugins/default/scan/plugin.py b/openflexure_microscope/plugins/default/scan/plugin.py index 46889e99..35c29e9c 100644 --- a/openflexure_microscope/plugins/default/scan/plugin.py +++ b/openflexure_microscope/plugins/default/scan/plugin.py @@ -97,18 +97,19 @@ class ScanPlugin(MicroscopePlugin): if "scan" not in tags: tags.append("scan") - metadata.update( - { - "scan_id": scan_id, - "basename": basename, - "microscope_settings": self.microscope.read_config(), - "microscope_state": self.microscope.state, - "microscope_id": self.microscope.id, - "microscope_name": self.microscope.name, - } - ) + # 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) def tile( diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py index 691af293..120c36ef 100644 --- a/openflexure_microscope/plugins/loader.py +++ b/openflexure_microscope/plugins/loader.py @@ -137,7 +137,7 @@ def class_from_map(plugin_map): return load_plugin_class(*plugin_arr) -class PluginMount(object): +class PluginLoader(object): """ A mount-point for all loaded plugins. Attaches to a Microscope object. @@ -147,30 +147,19 @@ class PluginMount(object): def __init__(self, parent): self.parent = parent - self.plugins = [] # List of plugin objects + self._plugins = [] # List of plugin objects self.forms = [] # List of plugin forms logging.info("Creating plugin mount") @property 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 [m["python_name"] for m in self._plugins] @property - def members(self): - ignores = ["state", "members", "attach"] - 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 active(self): + return self._plugins def attach(self, plugin_map): """ @@ -204,7 +193,15 @@ class PluginMount(object): ): # If plugin_object is an instance of MicroscopePlugin # Attach plugin_object to the plugin mount setattr(self, pythonsafe_plugin_name, plugin_object) - self.plugins.append((plugin_name, plugin_object)) + + # Store the plugin object, and it's properties + self._plugins.append({ + "name": plugin_name, + "python_name": pythonsafe_plugin_name, + "plugin": plugin_object, + "routes": [], + "form": None + }) # Grant plugin access to the hardware plugin_object.microscope = self.parent diff --git a/openflexure_microscope/stage/base.py b/openflexure_microscope/stage/base.py index 5ae45563..4eca632e 100644 --- a/openflexure_microscope/stage/base.py +++ b/openflexure_microscope/stage/base.py @@ -14,22 +14,22 @@ class BaseStage(metaclass=ABCMeta): self.lock = StrictLock(timeout=5) @abstractmethod - def apply_config(self, config: dict): + def apply_settings(self, config: dict): """Update settings from a config dictionary""" pass @abstractmethod - def read_config(self): + def read_settings(self): """Return the current settings as a dictionary""" pass - def save_config(self): + def save_settings(self): """(Optional) Save any settings to disk that need to be stored""" return @property @abstractmethod - def state(self): + def status(self): """The general state dictionary of the board. Should at least contain 'position', and 'board' keys. Note: A None/Null value for 'board' will disable stage diff --git a/openflexure_microscope/stage/mock.py b/openflexure_microscope/stage/mock.py index e37a7271..7e3be54b 100644 --- a/openflexure_microscope/stage/mock.py +++ b/openflexure_microscope/stage/mock.py @@ -16,9 +16,9 @@ class MockStage(BaseStage): self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis @property - def state(self): - """The general state dictionary of the board.""" - state = { + def status(self): + """The general status dictionary of the board.""" + status = { "position": { "x": self.position[0], "y": self.position[1], @@ -27,9 +27,9 @@ class MockStage(BaseStage): "board": None, "version": "0", } - return state + return status - def apply_config(self, config: dict): + def apply_settings(self, config: dict): """Update settings from a config dictionary""" # 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]) self.backlash = backlash - def read_config(self) -> dict: + def read_settings(self) -> dict: """Return the current settings as a dictionary""" blsh = self.backlash.tolist() config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}} diff --git a/openflexure_microscope/stage/sanga.py b/openflexure_microscope/stage/sanga.py index d25d5691..5588a929 100644 --- a/openflexure_microscope/stage/sanga.py +++ b/openflexure_microscope/stage/sanga.py @@ -31,9 +31,9 @@ class SangaStage(BaseStage): self.axis_names = ["x", "y", "z"] # Assume all sangaboards are 3 axis @property - def state(self): - """The general state dictionary of the board.""" - state = { + def status(self): + """The general status dictionary of the board.""" + status = { "position": { "x": self.position[0], "y": self.position[1], @@ -42,7 +42,7 @@ class SangaStage(BaseStage): "board": self.board.board, "firmware": self.board.firmware, } - return state + return status @property def n_axes(self): @@ -85,7 +85,7 @@ class SangaStage(BaseStage): else: 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""" # 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]) self.backlash = backlash - def read_config(self) -> dict: + def read_settings(self) -> dict: """Return the current settings as a dictionary""" blsh = self.backlash.tolist() config = {"backlash": {"x": blsh[0], "y": blsh[1], "z": blsh[2]}} diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 4a215c66..a6c9895a 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -14,18 +14,18 @@ logging.basicConfig(stream=sys.stderr, level=logging.DEBUG) class TestPluginMethods(unittest.TestCase): def test_plugin_load(self): - plugin_arr = microscope.plugin.plugins + plugin_arr = microscope.plugins.plugins plugin_names = [plugin[0] for plugin in plugin_arr] self.assertTrue("testing" in plugin_names) def test_camera_access(self): - identify = microscope.plugin.testing.identify() + identify = microscope.plugins.testing.identify() self.assertTrue(identify[0] is microscope.camera) def test_stage_access(self): - identify = microscope.plugin.testing.identify() + identify = microscope.plugins.testing.identify() self.assertTrue(identify[1] is microscope.stage) @@ -35,7 +35,7 @@ if __name__ == "__main__": 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)]