diff --git a/docs/source/plugins/example/plugin.py b/docs/source/plugins/example/plugin.py index 2c29c39c..e99f9e85 100644 --- a/docs/source/plugins/example/plugin.py +++ b/docs/source/plugins/example/plugin.py @@ -142,4 +142,4 @@ class TimelapseAPI(MicroscopeViewPlugin): self.timelapse_task = taskify(self.plugin.timelapse)(n_images) # Return the state of the task (will show ID, start time, and status before the task has finished) - return jsonify(self.timelapse_task.state), 202 + return jsonify(self.timelapse_task.state), 201 diff --git a/docs/source/plugins/hardware.rst b/docs/source/plugins/hardware.rst index 3b4aa956..cd2f0a70 100644 --- a/docs/source/plugins/hardware.rst +++ b/docs/source/plugins/hardware.rst @@ -44,7 +44,7 @@ An example of a long running task may look like: def post(self): # Attach the long-running method as a microscope task self.my_task = taskify(self.plugin.long_running_function)() - return jsonify(self.my_task.state), 202 + return jsonify(self.my_task.state), 201 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) # 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, diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index 0d5e342e..c3db5f89 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 @@ -43,7 +44,8 @@ DEFAULT_LOGFILE = settings_file_path("openflexure_microscope.log") if (__name__ == "__main__") or (not is_gunicorn): # If imported, but not by gunicorn print("Letting sys handle logs") - logging.basicConfig(level=logging.DEBUG) + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) else: # Direct standard Python logging to file and console 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) 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") def routes(): @@ -184,7 +218,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/utilities.py b/openflexure_microscope/api/utilities.py index 60d490db..32a854e5 100644 --- a/openflexure_microscope/api/utilities.py +++ b/openflexure_microscope/api/utilities.py @@ -1,6 +1,18 @@ import logging 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: 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..d5e66dc5 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(), - "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) 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 + + :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 Accept: application/json + + :
header Accept: application/json + + :
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 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("captures", 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..644a33aa --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/plugins.py @@ -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 diff --git a/openflexure_microscope/api/v2/blueprints/root.py b/openflexure_microscope/api/v2/blueprints/root.py new file mode 100644 index 00000000..bdaf348b --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/root.py @@ -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 diff --git a/openflexure_microscope/api/v2/blueprints/settings.py b/openflexure_microscope/api/v2/blueprints/settings.py new file mode 100644 index 00000000..4d14fd13 --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/settings.py @@ -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 + + :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 diff --git a/openflexure_microscope/api/v2/blueprints/streams.py b/openflexure_microscope/api/v2/blueprints/streams.py new file mode 100644 index 00000000..e11bb3df --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/streams.py @@ -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 diff --git a/openflexure_microscope/api/v2/blueprints/tasks.py b/openflexure_microscope/api/v2/blueprints/tasks.py new file mode 100644 index 00000000..fe6c2202 --- /dev/null +++ b/openflexure_microscope/api/v2/blueprints/tasks.py @@ -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("//", 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..17e244d1 100644 --- a/openflexure_microscope/camera/capture.py +++ b/openflexure_microscope/camera/capture.py @@ -35,7 +35,9 @@ def pull_usercomment_dict(filepath): return json.loads(exif_dict["Exif"][37510].decode()) except json.decoder.JSONDecodeError: # 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()) else: return None @@ -97,7 +99,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 +112,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 +134,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 +207,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: @@ -233,13 +237,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 +255,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..7aca48d8 100644 --- a/openflexure_microscope/camera/mock.py +++ b/openflexure_microscope/camera/mock.py @@ -19,6 +19,13 @@ from typing import Tuple 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 class MockStreamer(BaseCamera): def __init__(self): @@ -26,7 +33,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 +81,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 +101,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 +117,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..a3e1fc47 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -43,6 +43,7 @@ from .set_picamera_gain import set_analog_gain, set_digital_gain from openflexure_microscope.config import settings_file_path + # MAIN CLASS class PiCameraStreamer(BaseCamera): """Raspberry Pi camera implementation of PiCameraStreamer.""" @@ -64,7 +65,10 @@ class PiCameraStreamer(BaseCamera): "exposure_compensation", "image_effect", "meter_mode", - "sharpness" + "sharpness", + "annotate_text", + "annotate_text_size", + "zoom", ] def __init__(self): @@ -75,8 +79,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, @@ -89,15 +93,26 @@ class PiCameraStreamer(BaseCamera): self.set_zoom(1.0) # Set default settings - self.image_resolution = tuple(self.camera.MAX_RESOLUTION) #: 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.image_resolution = tuple( + self.camera.MAX_RESOLUTION + ) #: 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 + # 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 - self.state.update({}) + self.status.update({}) # Create an empty stream self.stream = io.BytesIO() @@ -118,13 +133,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( @@ -133,7 +148,9 @@ class PiCameraStreamer(BaseCamera): "image_resolution": self.image_resolution, "numpy_resolution": self.numpy_resolution, "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": {}, } ) @@ -149,12 +166,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 +189,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 @@ -268,7 +285,7 @@ class PiCameraStreamer(BaseCamera): """ 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 else: return None @@ -292,20 +309,25 @@ class PiCameraStreamer(BaseCamera): 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) 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: """ 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: - 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 +354,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 +369,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 +387,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 +400,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 +420,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 +489,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 +655,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..4d81f98f 100644 --- a/openflexure_microscope/config.py +++ b/openflexure_microscope/config.py @@ -21,6 +21,7 @@ class JSONEncoder(json.JSONEncoder): """ A custom JSON encoder, with type conversions for PiCamera fractions, Numpy integers, and Numpy arrays """ + def default(self, o, markers=None): # PiCamera fractions if isinstance(o, Fraction): @@ -39,6 +40,7 @@ class JSONEncoder(json.JSONEncoder): # MAIN CONFIG CLASS + class OpenflexureSettingsFile: """ 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 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") @@ -164,6 +166,7 @@ class OpenflexureSettingsFile: # HANDLE BASIC LOADING AND SAVING OF SETTINGS FILES + def load_json_file(config_path) -> dict: """ Open a .json config file diff --git a/openflexure_microscope/microscope.py b/openflexure_microscope/microscope.py index d52efdf7..6be22766 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,36 @@ 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 +194,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 +210,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 +230,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/microscope_settings.default.json b/openflexure_microscope/microscope_settings.default.json index 64d0bc0d..40ea86e8 100644 --- a/openflexure_microscope/microscope_settings.default.json +++ b/openflexure_microscope/microscope_settings.default.json @@ -9,6 +9,6 @@ "plugins": [ "openflexure_microscope.plugins.default.autofocus:AutofocusPlugin", "openflexure_microscope.plugins.default.scan:ScanPlugin", - "openflexure_microscope.plugins.default.camera_calibration:Plugin" + "openflexure_microscope.plugins.default.camera_calibration:AutocalibrationPlugin" ] } \ No newline at end of file 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/autofocus/api.py b/openflexure_microscope/plugins/default/autofocus/api.py index 100f5f13..e04bd529 100644 --- a/openflexure_microscope/plugins/default/autofocus/api.py +++ b/openflexure_microscope/plugins/default/autofocus/api.py @@ -18,6 +18,10 @@ class MeasureSharpnessAPI(MicroscopeViewPlugin): class AutofocusAPI(MicroscopeViewPlugin): + """ + Run a standard autofocus + """ + def post(self): payload = JsonResponse(request) @@ -29,12 +33,17 @@ class AutofocusAPI(MicroscopeViewPlugin): task = taskify(self.plugin.autofocus)(dz) # return a handle on the autofocus task - return jsonify(task.state), 202 + return jsonify(task.state), 201 else: - abort(503, 'No stage connected. Unable to autofocus.') + abort(503, "No stage connected. Unable to autofocus.") + class FastAutofocusAPI(MicroscopeViewPlugin): + """ + Run a fast autofocus + """ + def post(self): payload = JsonResponse(request) @@ -49,7 +58,7 @@ class FastAutofocusAPI(MicroscopeViewPlugin): task = taskify(self.plugin.fast_autofocus)(dz, backlash=backlash) # return a handle on the autofocus task - return jsonify(task.state), 202 + return jsonify(task.state), 201 else: - abort(503, 'No stage connected. Unable to autofocus.') + abort(503, "No stage connected. Unable to autofocus.") diff --git a/openflexure_microscope/plugins/default/camera_calibration/__init__.py b/openflexure_microscope/plugins/default/camera_calibration/__init__.py index 0a7ba613..46032671 100644 --- a/openflexure_microscope/plugins/default/camera_calibration/__init__.py +++ b/openflexure_microscope/plugins/default/camera_calibration/__init__.py @@ -1,2 +1,2 @@ __all__ = ["Plugin"] -from .plugin import Plugin +from .plugin import AutocalibrationPlugin diff --git a/openflexure_microscope/plugins/default/camera_calibration/plugin.py b/openflexure_microscope/plugins/default/camera_calibration/plugin.py index 5b942ab8..b682be02 100644 --- a/openflexure_microscope/plugins/default/camera_calibration/plugin.py +++ b/openflexure_microscope/plugins/default/camera_calibration/plugin.py @@ -18,12 +18,12 @@ class RecalibrateAPIView(MicroscopeViewPlugin): task = taskify(self.plugin.recalibrate)() # 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} @@ -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/api.py b/openflexure_microscope/plugins/default/scan/api.py index d61ab01d..13a9a8f2 100644 --- a/openflexure_microscope/plugins/default/scan/api.py +++ b/openflexure_microscope/plugins/default/scan/api.py @@ -60,4 +60,4 @@ class TileScanAPI(MicroscopeViewPlugin): ) # return a handle on the scan task - return jsonify(task.state), 202 + return jsonify(task.state), 201 diff --git a/openflexure_microscope/plugins/default/scan/plugin.py b/openflexure_microscope/plugins/default/scan/plugin.py index 46889e99..0815ecae 100644 --- a/openflexure_microscope/plugins/default/scan/plugin.py +++ b/openflexure_microscope/plugins/default/scan/plugin.py @@ -58,6 +58,8 @@ class ScanPlugin(MicroscopePlugin): api_views = {"/tile": TileScanAPI} def __init__(self): + MicroscopePlugin.__init__(self) + self.images_to_be_captured: int = 1 update_task_data({"images_to_be_captured": self.images_to_be_captured}) @@ -97,18 +99,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( @@ -146,14 +149,18 @@ class ScanPlugin(MicroscopePlugin): if "time" not in metadata: metadata["time"] = generate_basename() - metadata.update({ - "scan_parameters": { - "step_size": step_size, - "grid": grid, - "style": style, - "autofocus_dz": autofocus_dz + metadata.update( + { + "scan_id": scan_id, + "basename": basename, + "scan_parameters": { + "step_size": step_size, + "grid": grid, + "style": style, + "autofocus_dz": autofocus_dz, + }, } - }) + ) # Check if autofocus is enabled if ( diff --git a/openflexure_microscope/plugins/example/api.py b/openflexure_microscope/plugins/example/api.py index 1417359a..ac818184 100644 --- a/openflexure_microscope/plugins/example/api.py +++ b/openflexure_microscope/plugins/example/api.py @@ -73,7 +73,7 @@ class LongRunningAPI(MicroscopeViewPlugin): # Attach the long-running method as a microscope task try: task = self.microscope.task.start(self.plugin.long_running, time_to_run) - return jsonify(task.state), 202 + return jsonify(task.state), 201 except TaskDeniedException: return abort(409) @@ -94,7 +94,7 @@ class SomeExceptionAPI(MicroscopeViewPlugin): # Attach the long-running method as a microscope task try: task = self.microscope.task.start(self.plugin.some_exception) - return jsonify(task.state), 202 + return jsonify(task.state), 201 except TaskDeniedException: return abort(409) diff --git a/openflexure_microscope/plugins/loader.py b/openflexure_microscope/plugins/loader.py index 691af293..503bb2d0 100644 --- a/openflexure_microscope/plugins/loader.py +++ b/openflexure_microscope/plugins/loader.py @@ -1,8 +1,12 @@ import importlib +import copy import os import inspect import logging +from openflexure_microscope.utilities import camel_to_snake, camel_to_spine +from openflexure_microscope.api.views import MicroscopeViewPlugin + class ConColors: HEADER = "\033[95m" @@ -80,7 +84,6 @@ def name_from_module(plugin_path): def load_plugin_module(plugin_path): - # If the loader was found (i.e. plugin probably exists) if check_module(plugin_path): try: @@ -137,7 +140,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 +150,22 @@ class PluginMount(object): def __init__(self, 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 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 list(self._legacy_plugins.keys()) @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): """ @@ -183,7 +178,9 @@ class PluginMount(object): 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: plugin_object = plugin_class() @@ -200,18 +197,24 @@ class PluginMount(object): ) elif isinstance( - plugin_object, MicroscopePlugin + plugin_object, BasePlugin ): # 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)) + setattr(self, plugin_name_python_safe, 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 - plugin_object.microscope = self.parent + if isinstance(plugin_object, MicroscopePlugin): + plugin_object.microscope = self.parent logging.info( ConColors.OKGREEN - + "Plugin {} loaded as {}.".format(plugin_map, plugin_name) + + "Plugin {} loaded as {}.".format( + plugin_map, plugin_object._name + ) + ConColors.ENDC ) @@ -219,7 +222,102 @@ class PluginMount(object): 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. @@ -230,6 +328,7 @@ class MicroscopePlugin: api_views = {} # Initially empty dictionary of API views associated with the plugin def __init__(self): + BasePlugin.__init__(self) self.microscope = ( None ) #: :py:class:`openflexure_microscope.microscope.Microscope`: Microscope object diff --git a/openflexure_microscope/plugins/testing/form_example/api.py b/openflexure_microscope/plugins/testing/form_example/api.py index f41ad4f5..d4fc40f6 100644 --- a/openflexure_microscope/plugins/testing/form_example/api.py +++ b/openflexure_microscope/plugins/testing/form_example/api.py @@ -57,4 +57,4 @@ class TaskAPI(MicroscopeViewPlugin): task = taskify(self.plugin.generate_random_numbers_for_a_while)(val_int) # return a handle on the autofocus task - return jsonify(task.state), 202 + return jsonify(task.state), 201 diff --git a/openflexure_microscope/plugins/testing/form_example/forms.json b/openflexure_microscope/plugins/testing/form_example/forms.json index 4aee4dcd..6bc16dc0 100644 --- a/openflexure_microscope/plugins/testing/form_example/forms.json +++ b/openflexure_microscope/plugins/testing/form_example/forms.json @@ -1,8 +1,7 @@ { "id": "test-plugin", "icon": "pets", - "forms": [ - { + "forms": [{ "name": "Simple request", "isCollapsible": false, "isTask": false, @@ -10,10 +9,8 @@ "route": "/do", "submitLabel": "Do things", "schema": [ - [ - { + [{ "fieldType": "numberInput", - "placeholder": "Some integer", "name": "val_int", "label": "Number value", "minValue": 0 @@ -25,8 +22,7 @@ "name": "val_str" } ], - [ - { + [{ "fieldType": "radioList", "name": "val_radio", "label": "Radio value", @@ -54,8 +50,8 @@ { "fieldType": "textInput", - "placeholder": "Some string", "label": "Non-persistent string", + "default": "A default value", "name": "val_disposable" } ] @@ -66,15 +62,12 @@ "selfUpdate": true, "route": "/task", "submitLabel": "Start task", - "schema": [ - { - "fieldType": "numberInput", - "placeholder": "", - "name": "run_time", - "label": "Run time (seconds)", - "minValue": 1 - } - ] + "schema": [{ + "fieldType": "numberInput", + "name": "run_time", + "label": "Run time (seconds)", + "minValue": 1 + }] } - ] + ] } \ No newline at end of file diff --git a/openflexure_microscope/plugins/testing/form_example/plugin.py b/openflexure_microscope/plugins/testing/form_example/plugin.py index 42acb932..aabf6880 100644 --- a/openflexure_microscope/plugins/testing/form_example/plugin.py +++ b/openflexure_microscope/plugins/testing/form_example/plugin.py @@ -23,6 +23,8 @@ class ExamplePlugin(MicroscopePlugin): api_views = {"/do": DoAPI, "/task": TaskAPI} def __init__(self): + MicroscopePlugin.__init__(self) + self.val_int = 10 self.val_str = "Hello" self.val_radio = "First" 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/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py b/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py index efbf3f6c..91c53a7b 100644 --- a/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py +++ b/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py @@ -65,6 +65,7 @@ class ExtensibleSerialInstrument(object): Set up the serial port and so on. """ logging.info("Updating ESI port settings") + logging.debug(kwargs) self.port_settings.update(kwargs) logging.info("Opening ESI connection to port {}".format(port)) self.open(port, False) # Eventually this shouldn't rely on init... @@ -86,7 +87,9 @@ class ExtensibleSerialInstrument(object): assert ( port is not None ), "We don't have a serial port to open, meaning you didn't specify a valid port. Are you sure the instrument is connected?" + logging.info("Creating serial.Serial instance...") 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 # this allows us to read/write in neat lines. NB the buffer size must # be set to 1 byte for maximum responsiveness. @@ -130,11 +133,6 @@ class ExtensibleSerialInstrument(object): assert ( self._ser.isOpen() ), "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 = data.encode() self._ser.write(data) @@ -193,10 +191,15 @@ class ExtensibleSerialInstrument(object): will keep reading until a termination phrase is reached. """ with self.communications_lock: + logging.debug("Flushing input buffer...") self.flush_input_buffer() + logging.debug(f"Writing query: {queryString}") self.write(queryString) + logging.debug("Query written") if self.ignore_echo == True: # Needs Implementing for a multiline read! + logging.debug("Reading first line...") first_line = self.readline(timeout).strip() + logging.debug(f"Read finished. Got {first_line}") if first_line == queryString: return self.readline(timeout).strip() else: @@ -206,11 +209,15 @@ class ExtensibleSerialInstrument(object): if termination_line is not None: multiline = True if multiline: + logging.debug("Reading multiline...") return self.read_multiline(termination_line) else: - return self.readline( - timeout - ).strip() # question: should we strip the final newline? + logging.debug("Reading response...") + line = ( + self.readline(timeout).strip() + ) # question: should we strip the final newline? + logging.debug(f"Read finished. Got {line}") + return line def parsed_query( self, @@ -218,7 +225,7 @@ class ExtensibleSerialInstrument(object): response_string=r"%d", re_flags=0, parse_function=None, - **kwargs + **kwargs, ): """ Perform a query, returning a parsed form of the response. @@ -345,6 +352,7 @@ class ExtensibleSerialInstrument(object): def find_port(self): """Iterate through the available serial ports and query them to see if our instrument is there.""" + print("Auto-scanning ports") with self.communications_lock: success = False for ( diff --git a/openflexure_microscope/stage/sangaboard/sangaboard.py b/openflexure_microscope/stage/sangaboard/sangaboard.py index 5a0004e6..bd97adbb 100644 --- a/openflexure_microscope/stage/sangaboard/sangaboard.py +++ b/openflexure_microscope/stage/sangaboard/sangaboard.py @@ -90,7 +90,7 @@ class Sangaboard(ExtensibleSerialInstrument): # Once initialised, `firmware` is a string that identifies the firmware version firmware = None - def __init__(self, port=None, **kwargs): + def __init__(self, port=None, timeout: int = 2, **kwargs): """Create a sangaboard object. Arguments are passed to the constructor of @@ -101,13 +101,16 @@ class Sangaboard(ExtensibleSerialInstrument): """ # Initialise basic serial instrument with specified + logging.info(f"Initialising ExtensibleSerialInstrument on port {port}") ExtensibleSerialInstrument.__init__(self, port, **kwargs) try: # Make absolutely sure that whatever port we're on is valid + logging.info("Checking valid firmware...") self.check_valid_firmware() # 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) for module in self.list_modules(): @@ -145,10 +148,11 @@ class Sangaboard(ExtensibleSerialInstrument): """ Overrides superclass, used in self.open(), and port scanning """ + logging.info("Testing communication to SangaBoard") return self.check_valid_firmware() def check_valid_firmware(self): - logging.debug("Running firmware checks") + logging.info("Running firmware checks...") # Request firmware version from the board self.firmware = self.query("version", timeout=2).rstrip() diff --git a/openflexure_microscope/task.py b/openflexure_microscope/task.py index e9dd7e3d..c195e3fc 100644 --- a/openflexure_microscope/task.py +++ b/openflexure_microscope/task.py @@ -7,7 +7,7 @@ from openflexure_microscope.common.tasks import ( remove_task, ) - +# DEPRECATED class TaskOrchestrator: """ DEPRECATED: Class responsible for spawning threaded tasks, and storing their returns. diff --git a/openflexure_microscope/utilities.py b/openflexure_microscope/utilities.py index 69396fc5..27e5734d 100644 --- a/openflexure_microscope/utilities.py +++ b/openflexure_microscope/utilities.py @@ -1,3 +1,4 @@ +import re import copy import operator from collections import abc @@ -5,6 +6,40 @@ from functools import reduce 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 def set_properties(obj, **kwargs): """A context manager to set, then reset, certain properties of an object. 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)]