diff --git a/openflexure_microscope/api/app.py b/openflexure_microscope/api/app.py index f61ca0e5..cbdfeb71 100644 --- a/openflexure_microscope/api/app.py +++ b/openflexure_microscope/api/app.py @@ -1,5 +1,11 @@ #!/usr/bin/env python +import time +import atexit +import logging +import sys +import os + from flask import ( Flask, jsonify, send_file) @@ -13,23 +19,22 @@ from openflexure_microscope.api.utilities import list_routes from openflexure_microscope import Microscope -try: - from openflexure_microscope.camera.pi import StreamingCamera -except ImportError: - from openflexure_microscope.camera.mock import StreamingCamera - -from openflexure_microscope.stage.sanga import SangaStage -from openflexure_microscope.stage.mock import MockStage - from openflexure_microscope.camera.capture import build_captures_from_exif from openflexure_microscope.config import USER_CONFIG_DIR from openflexure_microscope.api.v1 import blueprints -import time -import atexit -import logging -import sys -import os +# Import device modules +# NB this will eventually be handled by the RC file, so you can choose what device +# class should be attached. +try: + from openflexure_microscope.camera.pi import PiCameraStreamer +except ImportError: + logging.warning("Unable to import PiCameraStreamer") +from openflexure_microscope.camera.mock import MockStreamer + +from openflexure_microscope.stage.sanga import SangaStage +from openflexure_microscope.stage.mock import MockStage + # Handle logging is_gunicorn = "gunicorn" in os.environ.get("SERVER_SOFTWARE", "") @@ -96,30 +101,37 @@ def attach_microscope(): global api_microscope, stored_image_list logging.debug("First request made. Populating microscope with hardware...") + # Initialise camera logging.debug("Creating camera object...") - # TODO: Try except finally, like with stage - api_camera = StreamingCamera() + try: + api_camera = PiCameraStreamer() + except Exception as e: + logging.error(e) + logging.warning("No valid camera hardware found. Falling back to mock camera!") + api_camera = MockStreamer() + # Initialise stage logging.debug("Creating stage object...") - # TODO: Tidy this up. api_stage may be referenced before assignment. Use some form of Maybe monad? try: api_stage = SangaStage() except Exception as e: logging.error(e) logging.warning("No valid stage hardware found. Falling back to mock stage!") api_stage = MockStage() - finally: - logging.debug("Attaching devices to microscope...") - api_microscope.attach( - api_camera, - api_stage - ) - logging.debug("Restoring captures...") - if stored_image_list: - api_microscope.camera.images = stored_image_list + # Attach devices to microscope + logging.debug("Attaching devices to microscope...") + api_microscope.attach( + api_camera, + api_stage + ) - logging.debug("Microscope successfully attached!") + # Restore loaded capture array to camera object + logging.debug("Restoring captures...") + if stored_image_list: + api_microscope.camera.images = stored_image_list + + logging.debug("Microscope successfully attached!") # WEBAPP ROUTES diff --git a/openflexure_microscope/camera/mock.py b/openflexure_microscope/camera/mock.py index 963e50ae..9b6103af 100644 --- a/openflexure_microscope/camera/mock.py +++ b/openflexure_microscope/camera/mock.py @@ -19,13 +19,13 @@ from .base import BaseCamera, CaptureObject # MAIN CLASS -class StreamingCamera(BaseCamera): +class MockStreamer(BaseCamera): def __init__(self): # Run BaseCamera init BaseCamera.__init__(self) - # Store state of StreamingCamera + # Store state of PiCameraStreamer self.state.update({ 'stream_active': False, 'record_active': False @@ -49,14 +49,14 @@ class StreamingCamera(BaseCamera): pass def close(self): - """Close the Raspberry Pi StreamingCamera.""" + """Close the Raspberry Pi PiCameraStreamer.""" # Run BaseCamera close method BaseCamera.close(self) # HANDLE SETTINGS def read_config(self) -> dict: """ - Return config dictionary of the StreamingCamera. + Return config dictionary of the PiCameraStreamer. """ conf_dict = { @@ -70,7 +70,7 @@ class StreamingCamera(BaseCamera): def apply_config(self, config: dict): """ - Write a config dictionary to the StreamingCamera config. + Write a config dictionary to the PiCameraStreamer config. The passed dictionary may contain other parameters not relevant to camera config. Eg. Passing a general config file will work fine. @@ -81,7 +81,7 @@ class StreamingCamera(BaseCamera): # TODO: Include timing and batching logic when applying PiCamera settings paused_stream = False - logging.debug("StreamingCamera: Applying config:") + logging.debug("PiCameraStreamer: Applying config:") logging.debug(config) with self.lock: @@ -89,7 +89,7 @@ class StreamingCamera(BaseCamera): # Apply valid config params to Picamera object if not self.state['record_active']: # If not recording a video - # StreamingCamera parameters + # PiCameraStreamer parameters for key, value in config.items(): # For each provided setting if hasattr(self, key): setattr(self, key, value) diff --git a/openflexure_microscope/camera/pi.py b/openflexure_microscope/camera/pi.py index 18186236..ee4d53ee 100644 --- a/openflexure_microscope/camera/pi.py +++ b/openflexure_microscope/camera/pi.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- """ -Raspberry Pi camera implementation of the StreamingCamera class. +Raspberry Pi camera implementation of the PiCameraStreamer class. NOTES: Still port used for image capture @@ -12,7 +12,7 @@ Video port: Splitter port 2: Video capture Splitter port 3: [Currently unused] -StreamingCamera streams at video_resolution +PiCameraStreamer streams at video_resolution Camera capture resolution set to stream_resolution in frames() Video port uses that resolution for everything. If a different resolution is specified for video capture, this is handled by the resizer. @@ -41,8 +41,8 @@ from .set_picamera_gain import set_analog_gain, set_digital_gain # MAIN CLASS -class StreamingCamera(BaseCamera): - """Raspberry Pi camera implementation of StreamingCamera.""" +class PiCameraStreamer(BaseCamera): + """Raspberry Pi camera implementation of PiCameraStreamer.""" picamera_settings_keys = [ 'exposure_mode', 'analog_gain', @@ -61,7 +61,7 @@ class StreamingCamera(BaseCamera): # Attach to Pi camera self.camera = picamera.PiCamera() #: :py:class:`picamera.PiCamera`: Picamera object - # Store state of StreamingCamera + # Store state of PiCameraStreamer self.state.update({ 'stream_active': False, 'record_active': False, @@ -87,7 +87,7 @@ class StreamingCamera(BaseCamera): pass def close(self): - """Close the Raspberry Pi StreamingCamera.""" + """Close the Raspberry Pi PiCameraStreamer.""" # Run BaseCamera close method BaseCamera.close(self) # Detach Pi camera @@ -97,7 +97,7 @@ class StreamingCamera(BaseCamera): # HANDLE SETTINGS def read_config(self) -> dict: """ - Return config dictionary of the StreamingCamera. + Return config dictionary of the PiCameraStreamer. """ conf_dict = { @@ -109,7 +109,7 @@ class StreamingCamera(BaseCamera): } # PiCamera parameters - for key in StreamingCamera.picamera_settings_keys: + for key in PiCameraStreamer.picamera_settings_keys: try: value = getattr(self.camera, key) logging.debug("Reading PiCamera().{}: {}".format(key, value)) @@ -121,7 +121,7 @@ class StreamingCamera(BaseCamera): def apply_config(self, config: dict): """ - Write a config dictionary to the StreamingCamera config. + Write a config dictionary to the PiCameraStreamer config. The passed dictionary may contain other parameters not relevant to camera config. Eg. Passing a general config file will work fine. @@ -132,7 +132,7 @@ class StreamingCamera(BaseCamera): # TODO: Include timing and batching logic when applying PiCamera settings paused_stream = False - logging.debug("StreamingCamera: Applying config:") + logging.debug("PiCameraStreamer: Applying config:") logging.debug(config) with self.lock: @@ -150,7 +150,7 @@ class StreamingCamera(BaseCamera): if 'picamera_settings' in config: # If new settings are given self.apply_picamera_settings(config['picamera_settings'], pause_for_effect=True) - # StreamingCamera parameters + # PiCameraStreamer parameters for key, value in config.items(): # For each provided setting if (key != 'picamera_settings') and hasattr(self, key): setattr(self, key, value) diff --git a/openflexure_microscope/stage/sanga.py b/openflexure_microscope/stage/sanga.py index 6ee4c8e8..9e4c25ec 100644 --- a/openflexure_microscope/stage/sanga.py +++ b/openflexure_microscope/stage/sanga.py @@ -194,13 +194,6 @@ class SangaStage(BaseStage): """De-energise the stepper motor coils""" self.board.release_motors() - def __del__(self): - """Close the port when the object is deleted - - NB if the object is created in a with statement, this will cause - the port to be closed at the end of the with block.""" - self.close() - def __enter__(self): """When we use this in a with statement, remember where we started.""" self._position_on_enter = self.position diff --git a/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py b/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py index ed749b69..c4fc45ce 100644 --- a/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py +++ b/openflexure_microscope/stage/sangaboard/extensible_serial_instrument.py @@ -26,8 +26,8 @@ from serial import PARITY_NONE, PARITY_EVEN, PARITY_ODD, PARITY_MARK, PARITY_SPA from serial import STOPBITS_ONE, STOPBITS_ONE_POINT_FIVE, STOPBITS_TWO import io import time -import warnings import logging +import warnings class ExtensibleSerialInstrument(object): """ @@ -54,7 +54,7 @@ class ExtensibleSerialInstrument(object): ignore_echo = False port_settings = {} - def __init__(self, port, **kwargs): + def __init__(self, port=None, **kwargs): """ Set up the serial port and so on. """ @@ -64,7 +64,7 @@ class ExtensibleSerialInstrument(object): self.open(port, False) # Eventually this shouldn't rely on init... logging.info("Opened ESI connection to port {}".format(port)) - def open(self, port, quiet=True): + def open(self, port=None, quiet=True): """Open communications with the serial port. If no port is specified, it will attempt to autodetect. If quiet=True @@ -74,6 +74,8 @@ class ExtensibleSerialInstrument(object): if hasattr(self,'_ser') and self._ser.isOpen(): if not quiet: logging.warning("Attempted to open an already-open port!") return + if port is None: + port=self.find_port() assert port is not None, "We don't have a serial port to open, meaning you didn't specify a valid port. Are you sure the instrument is connected?" self._ser = serial.Serial(port,**self.port_settings) #the block above wraps the serial IO layer with a text IO layer @@ -82,28 +84,33 @@ class ExtensibleSerialInstrument(object): assert self.test_communications(), "The instrument doesn't seem to be responding. Did you specify the right port?" def close(self): - """Release the serial port""" + """Cleanly close the device. Includes proper logging statements.""" logging.debug("Closing serial connection") with self.communications_lock: try: self._ser.close() except Exception as e: - logging.warning("The serial port didn't close cleanly:", e) - logging.debug("Connection closed") + logging.warning("The serial port didn't close cleanly: {}".format(e)) + logging.debug("Connection closed") def __del__(self): - """Close the port when the object is deleted - - NB if the object is created in a with statement, this will cause - the port to be closed at the end of the with block.""" - self.close() + """Emergency close the device. Try to avoid having to use this.""" + if hasattr(self, '_ser') and self._ser.isOpen(): + with self.communications_lock: + print( + "Closing an open serial communication has been triggered by garbage collection!\n"\ + "Please close this device more sensibly in future.") + try: + self._ser.close() + except Exception as e: + print("The serial port didn't close cleanly: {}".format(e)) def __enter__(self): return self def __exit__(self, type, value, traceback): - """Close down the instrument. This happens in __del__ though.""" - pass + """Cleanly close down the instrument at end of a with block.""" + self.close() def write(self,query_string): """Write a string to the serial port""" @@ -232,7 +239,7 @@ class ExtensibleSerialInstrument(object): reply = self.readline().strip() res=re.search(response_regex, reply, flags=re_flags) if res is not None: - warnings.warn("Query suceeded after initially receieving unmatched response ('%s') to '%s'. Match pattern /%s/ (generated regex /%s/)"%(original_reply, query_string, response_string, response_regex),RuntimeWarning) + warnings.warn("Query suceeded after initially receieving unmatched response ('%s') to '%s'. Match pattern /%s/ (generated regex /%s/)"%(original_reply, query_string, response_string, response_regex), RuntimeWarning) else: raise ValueError("Stage response to '%s' ('%s') wasn't matched by /%s/ (generated regex /%s/)" % (query_string, original_reply, response_string, response_regex)) try: @@ -242,9 +249,8 @@ class ExtensibleSerialInstrument(object): else: return parsed_result except ValueError: - logging.error("Parsing Error") - logging.info("Matched Groups:", res.groups()) - logging.info("Parsing Functions:", parse_function) + logging.info("Matched Groups: {}".format(res.groups())) + logging.info("Parsing Functions {}:".format(parse_function)) raise ValueError("Stage response to %s ('%s') couldn't be parsed by the supplied function" % (query_string, reply)) def int_query(self, query_string, **kwargs): """Perform a query and return the result(s) as integer(s) (see parsedQuery)""" @@ -261,7 +267,32 @@ class ExtensibleSerialInstrument(object): Usually this function sends a command and checks for a known reply.""" with self.communications_lock: return True - + + def find_port(self): + """Iterate through the available serial ports and query them to see + if our instrument is there.""" + with self.communications_lock: + success = False + for port_name, _, _ in serial.tools.list_ports.comports(): #loop through serial ports, apparently 256 is the limit?! + try: + logging.info("Trying port {}".format(port_name)) + self.open(port_name) + success = True + logging.info("Success!") + except: + pass + finally: + try: + self.close() + except: + pass #we don't care if there's an error closing the port... + if success: + break #again, make sure this happens *after* closing the port + if success: + return port_name + else: + return None + class OptionalModule(object): """This allows a `ExtensibleSerialInstrument` to have optional features. diff --git a/openflexure_microscope/stage/sangaboard/sangaboard.py b/openflexure_microscope/stage/sangaboard/sangaboard.py index e7d19142..b755d44b 100644 --- a/openflexure_microscope/stage/sangaboard/sangaboard.py +++ b/openflexure_microscope/stage/sangaboard/sangaboard.py @@ -81,13 +81,8 @@ class Sangaboard(ExtensibleSerialInstrument): it doesn't need to be named. """ - # If no port is specified - if not port: - # Scan all available ports, and check for valid firmware - scanned_port = self.scan_ports() - # Initialise basic serial instrument with specified - ExtensibleSerialInstrument.__init__(self, scanned_port, **kwargs) + ExtensibleSerialInstrument.__init__(self, port, **kwargs) try: # Make absolutely sure that whatever port we're on is valid @@ -119,37 +114,11 @@ class Sangaboard(ExtensibleSerialInstrument): logging.error("You may need to update the firmware running on the Sangaboard.") raise e - def scan_ports(self): - """Iterate through the available serial ports and query them to see - if our instrument is there.""" - logging.debug("Running Sangaboard port scanner") - with self.communications_lock: - success = False - for port_name, _, _ in serial.tools.list_ports.comports(): #loop through serial ports, apparently 256 is the limit?! - try: - logging.info("Trying port {}".format(port_name)) - self.open(port_name) - success = True - - logging.info("Checking firmware") - fw = self.check_valid_firmware() - if not fw: - success = False - except Exception as e: - logging.warning("Error on port {}".format(port_name)) - logging.warning(e) - pass - finally: - try: - self.close() - except: - pass #we don't care if there's an error closing the port... - if success: - break #again, make sure this happens *after* closing the port - if success: - return port_name - else: - return None + def test_communications(self): + """ + Overrides superclass, used in self.open(), and port scanning + """ + return self.check_valid_firmware() def check_valid_firmware(self): logging.debug("Running firmware checks") diff --git a/poetry.lock b/poetry.lock index 7926a8db..2c9d78f1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -180,6 +180,7 @@ version = "1.13.1b0" reference = "b39c2b6e42f5f7f57bb46eafcb5c9e2bbdb5d0cb" type = "git" url = "https://github.com/rwb27/picamera.git" + [[package]] category = "main" description = "Python Imaging Library (Fork)" @@ -242,6 +243,14 @@ chardet = ">=3.0.2,<3.1.0" idna = ">=2.5,<2.9" urllib3 = ">=1.21.1,<1.25.0 || >1.25.0,<1.25.1 || >1.25.1,<1.26" +[[package]] +category = "dev" +description = "a python refactoring library..." +name = "rope" +optional = false +python-versions = "*" +version = "0.14.0" + [[package]] category = "main" description = "A module to control Raspberry Pi GPIO channels" @@ -381,7 +390,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" version = "0.15.4" [metadata] -content-hash = "33585d30bda3df47511879542dd7c778bb1922947b650a5554c231a0be7c8b4a" +content-hash = "c2fee81479442877ae94095d15f05018dc5290eee42edeb7d79d067fdf0ffab6" python-versions = "^3.5" [metadata.hashes] @@ -411,6 +420,7 @@ pyserial = ["6e2d401fdee0eab996cf734e67773a0143b932772ca8b42451440cfed942c627", pytz = ["303879e36b721603cc54604edcac9d20401bdbe31e1e4fdee5b9f98d5d31dfda", "d747dd3d23d77ef44c6a3526e274af6efeb0a6f1afd5a69ba4d5be4098c8e141"] pyyaml = ["3d7da3009c0f3e783b2c873687652d83b1bbfd5c88e9813fb7e5b03c0dd3108b", "3ef3092145e9b70e3ddd2c7ad59bdd0252a94dfe3949721633e41344de00a6bf", "40c71b8e076d0550b2e6380bada1f1cd1017b882f7e16f09a65be98e017f211a", "558dd60b890ba8fd982e05941927a3911dc409a63dcb8b634feaa0cda69330d3", "a7c28b45d9f99102fa092bb213aa12e0aaf9a6a1f5e395d36166639c1f96c3a1", "aa7dd4a6a427aed7df6fb7f08a580d68d9b118d90310374716ae90b710280af1", "bc558586e6045763782014934bfaf39d48b8ae85a2713117d16c39864085c613", "d46d7982b62e0729ad0175a9bc7e10a566fc07b224d2c79fafb5e032727eaa04", "d5eef459e30b09f5a098b9cea68bebfeb268697f78d647bd255a085371ac7f3f", "e01d3203230e1786cd91ccfdc8f8454c8069c91bee3962ad93b87a4b2860f537", "e170a9e6fcfd19021dd29845af83bb79236068bf5fd4df3327c1be18182b2531"] requests = ["11e007a8a2aa0323f5a921e9e6a2d7e4e67d9877e85773fba9ba6419025cbeb4", "9cf5292fcd0f598c671cfc1e0d7d1a7f13bb8085e9a590f48c010551dc6c4b31"] +rope = ["6b728fdc3e98a83446c27a91fc5d56808a004f8beab7a31ab1d7224cecc7d969", "c5c5a6a87f7b1a2095fb311135e2a3d1f194f5ecb96900fdd0a9100881f48aaf", "f0dcf719b63200d492b85535ebe5ea9b29e0d0b8aebeb87fe03fc1a65924fdaf"] "rpi.gpio" = ["a4210ad63bfe844e43995286de0d3950dfacfa0f3799bb9392770ac54a7d2e47"] scipy = ["03b1e0775edbe6a4c64effb05fff2ce1429b76d29d754aa5ee2d848b60033351", "09d008237baabf52a5d4f5a6fcf9b3c03408f3f61a69c404472a16861a73917e", "10325f0ffac2400b1ec09537b7e403419dcd25d9fee602a44e8a32119af9079e", "1db9f964ed9c52dc5bd6127f0dd90ac89791daa690a5665cc01eae185912e1ba", "409846be9d6bdcbd78b9e5afe2f64b2da5a923dd7c1cd0615ce589489533fdbb", "4907040f62b91c2e170359c3d36c000af783f0fa1516a83d6c1517cde0af5340", "6c0543f2fdd38dee631fb023c0f31c284a532d205590b393d72009c14847f5b1", "826b9f5fbb7f908a13aa1efd4b7321e36992f5868d5d8311c7b40cf9b11ca0e7", "a7695a378c2ce402405ea37b12c7a338a8755e081869bd6b95858893ceb617ae", "a84c31e8409b420c3ca57fd30c7589378d6fdc8d155d866a7f8e6e80dec6fd06", "adadeeae5500de0da2b9e8dd478520d0a9945b577b2198f2462555e68f58e7ef", "b283a76a83fe463c9587a2c88003f800e08c3929dfbeba833b78260f9c209785", "c19a7389ab3cd712058a8c3c9ffd8d27a57f3d84b9c91a931f542682bb3d269d", "c3bb4bd2aca82fb498247deeac12265921fe231502a6bc6edea3ee7fe6c40a7a", "c5ea60ece0c0c1c849025bfc541b60a6751b491b6f11dd9ef37ab5b8c9041921", "db61a640ca20f237317d27bc658c1fc54c7581ff7f6502d112922dc285bdabee"] six = ["3350809f0555b11f552448330d0b52d5f24c91a322ea4a15ef22629740f3761c", "d16a0141ec1a18405cd4ce8b4613101da75da0e9a7aec5bdd4fa804d0e0eba73"] diff --git a/pyproject.toml b/pyproject.toml index 1c857cd4..a343d425 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api" [tool.poetry] name = "openflexure_microscope" -version = "1.1.0" +version = "1.1.1" description = "Python module, and Flask-based web API, to run the OpenFlexure Microscope." authors = [ @@ -37,3 +37,4 @@ picamera = { git = "https://github.com/rwb27/picamera.git", branch = "lens-shadi [tool.poetry.dev-dependencies] sphinxcontrib-httpdomain = "^1.7" +rope = "^0.14.0" diff --git a/tests/test_camera.py b/tests/test_camera.py index a1271592..0188fa2f 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -from openflexure_microscope.camera.pi import StreamingCamera, CaptureObject +from openflexure_microscope.camera.pi import PiCameraStreamer, CaptureObject import os import io @@ -279,7 +279,7 @@ class TestThreadStarting(unittest.TestCase): if __name__ == '__main__': - with StreamingCamera() as camera: + with PiCameraStreamer() as camera: suites = [ unittest.TestLoader().loadTestsFromTestCase(TestCaptureMethods), diff --git a/tests/test_plugins.py b/tests/test_plugins.py index 4eb3d61c..ebf9c449 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -from openflexure_microscope.camera.pi import StreamingCamera +from openflexure_microscope.camera.pi import PiCameraStreamer from openflexure_stage import OpenFlexureStage from openflexure_microscope import Microscope, config @@ -31,7 +31,9 @@ class TestPluginMethods(unittest.TestCase): if __name__ == '__main__': - with Microscope(StreamingCamera(), OpenFlexureStage("/dev/ttyUSB0")) as microscope: + with Microscope() as microscope: + + microscope.attach(PiCameraStreamer(), OpenFlexureStage()) microscope.plugin.attach("openflexure_microscope.plugins.testing:Plugin")