diff --git a/bin/pocs_shell b/bin/pocs_shell index 854dba3fb..f5c0b9651 100755 --- a/bin/pocs_shell +++ b/bin/pocs_shell @@ -17,6 +17,7 @@ from astropy.utils import console from pocs import hardware from pocs.core import POCS from pocs.observatory import Observatory +from pocs.camera import create_cameras_from_config from pocs.scheduler.field import Field from pocs.scheduler.observation import Observation from pocs.utils import current_time @@ -152,7 +153,8 @@ class PocsShell(Cmd): simulator = [] try: - observatory = Observatory(simulator=simulator) + cameras = create_cameras_from_config() + observatory = Observatory(simulator=simulator, cameras=cameras) self.pocs = POCS(observatory, messaging=True) self.pocs.initialize() except error.PanError: diff --git a/pocs/camera/__init__.py b/pocs/camera/__init__.py index 3db8ab298..587183e5d 100644 --- a/pocs/camera/__init__.py +++ b/pocs/camera/__init__.py @@ -1,2 +1,185 @@ +from collections import OrderedDict +import re +import shutil +import subprocess + +from pocs.utils import error +from pocs.utils import load_module +from pocs.utils.config import load_config + from pocs.camera.camera import AbstractCamera # pragma: no flakes from pocs.camera.camera import AbstractGPhotoCamera # pragma: no flakes + +from pocs.utils import logger as logger_module + + +def list_connected_cameras(): + """Detect connected cameras. + + Uses gphoto2 to try and detect which cameras are connected. Cameras should + be known and placed in config but this is a useful utility. + + Returns: + list: A list of the ports with detected cameras. + """ + + gphoto2 = shutil.which('gphoto2') + command = [gphoto2, '--auto-detect'] + result = subprocess.check_output(command) + lines = result.decode('utf-8').split('\n') + + ports = [] + + for line in lines: + camera_match = re.match(r'([\w\d\s_\.]{30})\s(usb:\d{3},\d{3})', line) + if camera_match: + # camera_name = camera_match.group(1).strip() + port = camera_match.group(2).strip() + ports.append(port) + + return ports + + +def create_cameras_from_config(config=None, logger=None, **kwargs): + """Create camera object(s) based on the config. + + Creates a camera for each camera item listed in the config. Ensures the + appropriate camera module is loaded. + + Args: + **kwargs (dict): Can pass a `cameras` object that overrides the info in + the configuration file. Can also pass `auto_detect`(bool) to try and + automatically discover the ports. + + Returns: + OrderedDict: An ordered dictionary of created camera objects, with the + camera name as key and camera instance as value. Returns an empty + OrderedDict if there is no camera configuration items. + + Raises: + error.CameraNotFound: Raised if camera cannot be found at specified port or if + auto_detect=True and no cameras are found. + error.PanError: Description + """ + if not logger: + logger = logger_module.get_root_logger() + + if not config: + config = load_config(**kwargs) + + # Helper method to first check kwargs then config + def kwargs_or_config(item, default=None): + return kwargs.get(item, config.get(item, default)) + + cameras = OrderedDict() + camera_info = kwargs_or_config('cameras') + if not camera_info: + logger.info('No camera information in config.') + return cameras + + logger.debug("Camera config: {}".format(camera_info)) + + a_simulator = 'camera' in kwargs_or_config('simulator', default=list()) + auto_detect = kwargs_or_config('auto_detect', default=False) + + ports = list() + + # Lookup the connected ports if not using a simulator + if not a_simulator and auto_detect: + logger.debug("Auto-detecting ports for cameras") + try: + ports = list_connected_cameras() + except Exception as e: + logger.warning(e) + + if len(ports) == 0: + raise error.PanError( + msg="No cameras detected. Use --simulator=camera for simulator.") + else: + logger.debug("Detected Ports: {}".format(ports)) + + primary_camera = None + + device_info = camera_info['devices'] + for cam_num, device_config in enumerate(device_info): + cam_name = 'Cam{:02d}'.format(cam_num) + + if not a_simulator: + camera_model = device_config.get('model') + + # Assign an auto-detected port. If none are left, skip + if auto_detect: + try: + camera_port = ports.pop() + except IndexError: + logger.warning( + "No ports left for {}, skipping.".format(cam_name)) + continue + else: + try: + camera_port = device_config['port'] + except KeyError: + raise error.CameraNotFound( + msg="No port specified and auto_detect=False") + + camera_focuser = device_config.get('focuser', None) + camera_readout = device_config.get('readout_time', 6.0) + + else: + logger.debug('Using camera simulator.') + # Set up a simulated camera with fully configured simulated + # focuser + camera_model = 'simulator' + camera_port = '/dev/camera/simulator' + camera_focuser = {'model': 'simulator', + 'focus_port': '/dev/ttyFAKE', + 'initial_position': 20000, + 'autofocus_range': (40, 80), + 'autofocus_step': (10, 20), + 'autofocus_seconds': 0.1, + 'autofocus_size': 500} + camera_readout = 0.5 + + camera_set_point = device_config.get('set_point', None) + camera_filter = device_config.get('filter_type', None) + + logger.debug('Creating camera: {}'.format(camera_model)) + + try: + module = load_module('pocs.camera.{}'.format(camera_model)) + logger.debug('Camera module: {}'.format(module)) + except ImportError: + raise error.CameraNotFound(msg=camera_model) + else: + # Create the camera object + cam = module.Camera(name=cam_name, + model=camera_model, + port=camera_port, + set_point=camera_set_point, + filter_type=camera_filter, + focuser=camera_focuser, + readout_time=camera_readout) + + is_primary = '' + if camera_info.get('primary', '') == cam.uid: + primary_camera = cam + is_primary = ' [Primary]' + + logger.debug("Camera created: {} {} {}".format( + cam.name, cam.uid, is_primary)) + + cameras[cam_name] = cam + + if len(cameras) == 0: + raise error.CameraNotFound( + msg="No cameras available. Exiting.", exit=True) + + # If no camera was specified as primary use the first + if primary_camera is None: + primary_camera = cameras['Cam00'] + primary_camera.is_primary = True + + logger.debug("Primary camera: {}", primary_camera) + logger.debug("{} cameras created", len(cameras)) + + return cameras diff --git a/pocs/observatory.py b/pocs/observatory.py index 4deb95dda..14cec0066 100644 --- a/pocs/observatory.py +++ b/pocs/observatory.py @@ -21,13 +21,13 @@ from pocs.utils import error from pocs.utils import images as img_utils from pocs.utils import horizon as horizon_utils -from pocs.utils import list_connected_cameras from pocs.utils import load_module +from pocs.camera import AbstractCamera class Observatory(PanBase): - def __init__(self, *args, **kwargs): + def __init__(self, cameras=None, *args, **kwargs): """Main Observatory class Starts up the observatory. Reads config file, sets up location, @@ -47,10 +47,15 @@ def __init__(self, *args, **kwargs): self.mount = None self._create_mount() - self.logger.info('\tSetting up cameras') - self.cameras = OrderedDict() - self._primary_camera = None - self._create_cameras(**kwargs) + if not cameras: + cameras = OrderedDict() + + if cameras: + self.logger.info('Adding the cameras to the observatory') + self._primary_camera = None + self.cameras = cameras + for cam_name, camera in cameras.items(): + self.add_camera(cam_name, camera) # TODO(jamessynge): Discuss with Wilfred the serial port validation behavior # here compared to that for the mount. @@ -86,8 +91,24 @@ def is_dark(self): def sidereal_time(self): return self.observer.local_sidereal_time(current_time()) + @property + def has_cameras(self): + return len(self.cameras) > 0 + @property def primary_camera(self): + """Return primary camera. + + Note: + If no camera has been marked as primary this will set and return + the first camera in the OrderedDict as primary. + + Returns: + `pocs.camera.Camera`: The primary camera. + """ + if not self._primary_camera and self.has_cameras: + self._primary_camera = self.cameras[list(self.cameras.keys())[0]] + return self._primary_camera @primary_camera.setter @@ -107,6 +128,42 @@ def current_observation(self, new_observation): def has_dome(self): return self.dome is not None + +########################################################################## +# Device Getters/Setters +########################################################################## + + def add_camera(self, cam_name, camera): + """Add camera to list of cameras as cam_name. + + Args: + cam_name (str): The name to use for the camera, e.g. `Cam00`. + camera (`pocs.camera.camera.Camera`): An instance of the `~Camera` class. + """ + assert isinstance(camera, AbstractCamera) + self.logger.debug('Adding {}: {}'.format(cam_name, camera)) + if cam_name in self.cameras: + self.logger.debug('{} already exists, replacing existing camera under that name.') + + self.cameras[cam_name] = camera + if camera.is_primary: + self.primary_camera = camera + + def remove_camera(self, cam_name): + """Remove cam_name from list of attached cameras. + + Note: + If you remove and then add a camera you will change the index order + of the camera. If you prefer to keep the same order then use `add_camera` + with the same name as an existing camera to to update the list and preserve + the order. + + Args: + cam_name (str): Name of camera to remove. + """ + self.logger.debug('Removing {}'.format(cam_name)) + del self.cameras[cam_name] + ########################################################################## # Methods ########################################################################## @@ -601,139 +658,6 @@ def _create_mount(self, mount_info=None): self.logger.debug('Mount created') - def _create_cameras(self, **kwargs): - """Creates a camera object(s) - - Loads the cameras via the configuration. - - Creates a camera for each camera item listed in the config. Ensures the - appropriate camera module is loaded. - - Note: We are currently only operating with one camera and the `take_pic.sh` - script automatically discovers the ports. - - Note: - This does not actually make a usb connection to the camera. To do so, - call the 'camear.connect()' explicitly. - - Args: - **kwargs (dict): Can pass a camera_config object that overrides the info in - the configuration file. Can also pass `auto_detect`(bool) to try and - automatically discover the ports. - - Returns: - list: A list of created camera objects. - - Raises: - error.CameraNotFound: Description - error.PanError: Description - """ - if kwargs.get('camera_info') is None: - camera_info = self.config.get('cameras') - - self.logger.debug("Camera config: \n {}".format(camera_info)) - - a_simulator = 'camera' in self.config.get('simulator', []) - if a_simulator: - self.logger.debug("Using simulator for camera") - - ports = list() - - # Lookup the connected ports if not using a simulator - auto_detect = kwargs.get( - 'auto_detect', camera_info.get('auto_detect', False)) - if not a_simulator and auto_detect: - self.logger.debug("Auto-detecting ports for cameras") - try: - ports = list_connected_cameras() - except Exception as e: - self.logger.warning(e) - - if len(ports) == 0: - raise error.PanError( - msg="No cameras detected. Use --simulator=camera for simulator.") - else: - self.logger.debug("Detected Ports: {}".format(ports)) - - for cam_num, camera_config in enumerate(camera_info.get('devices', [])): - cam_name = 'Cam{:02d}'.format(cam_num) - - if not a_simulator: - camera_model = camera_config.get('model') - - # Assign an auto-detected port. If none are left, skip - if auto_detect: - try: - camera_port = ports.pop() - except IndexError: - self.logger.warning( - "No ports left for {}, skipping.".format(cam_name)) - continue - else: - try: - camera_port = camera_config['port'] - except KeyError: - raise error.CameraNotFound( - msg="No port specified and auto_detect=False") - - camera_focuser = camera_config.get('focuser', None) - camera_readout = camera_config.get('readout_time', 6.0) - - else: - # Set up a simulated camera with fully configured simulated - # focuser - camera_model = 'simulator' - camera_port = '/dev/camera/simulator' - camera_focuser = {'model': 'simulator', - 'focus_port': '/dev/ttyFAKE', - 'initial_position': 20000, - 'autofocus_range': (40, 80), - 'autofocus_step': (10, 20), - 'autofocus_seconds': 0.1, - 'autofocus_size': 500} - camera_readout = 0.5 - - camera_set_point = camera_config.get('set_point', None) - camera_filter = camera_config.get('filter_type', None) - - self.logger.debug('Creating camera: {}'.format(camera_model)) - - try: - module = load_module('pocs.camera.{}'.format(camera_model)) - self.logger.debug('Camera module: {}'.format(module)) - except ImportError: - raise error.CameraNotFound(msg=camera_model) - else: - # Create the camera object - cam = module.Camera(name=cam_name, - model=camera_model, - port=camera_port, - set_point=camera_set_point, - filter_type=camera_filter, - focuser=camera_focuser, - readout_time=camera_readout) - - is_primary = '' - if camera_info.get('primary', '') == cam.uid: - self.primary_camera = cam - is_primary = ' [Primary]' - - self.logger.debug("Camera created: {} {} {}".format( - cam.name, cam.uid, is_primary)) - - self.cameras[cam_name] = cam - - # If no camera was specified as primary use the first - if self.primary_camera is None: - self.primary_camera = self.cameras['Cam00'] - - if len(self.cameras) == 0: - raise error.CameraNotFound( - msg="No cameras available. Exiting.", exit=True) - - self.logger.debug("Primary camera: {}", self.primary_camera) - self.logger.debug("{} cameras created", len(self.cameras)) - def _create_scheduler(self): """ Sets up the scheduler that will be used by the observatory """ diff --git a/pocs/tests/test_observatory.py b/pocs/tests/test_observatory.py index 1cf95e35e..a8c4a107c 100644 --- a/pocs/tests/test_observatory.py +++ b/pocs/tests/test_observatory.py @@ -9,6 +9,7 @@ from pocs.observatory import Observatory from pocs.scheduler.dispatch import Scheduler from pocs.scheduler.observation import Observation +from pocs.camera import create_cameras_from_config from pocs.utils import error @@ -25,7 +26,11 @@ def simulator(): @pytest.fixture def observatory(config, simulator): """Return a valid Observatory instance with a specific config.""" - obs = Observatory(config=config, simulator=simulator, ignore_local_config=True) + cameras = create_cameras_from_config(config) + obs = Observatory(config=config, + simulator=simulator, + cameras=cameras, + ignore_local_config=True) return obs @@ -83,37 +88,41 @@ def test_bad_scheduler_fields_file(config): Observatory(simulator=simulator, config=conf, ignore_local_config=True) -@pytest.mark.without_camera -def test_bad_camera(config): +def test_camera_wrong_type(config): conf = config.copy() simulator = hardware.get_all_names(without=['camera']) - with pytest.raises(error.PanError): - Observatory(simulator=simulator, config=conf, auto_detect=True, ignore_local_config=True) + with pytest.raises(AttributeError): + Observatory(simulator=simulator, + cameras=[Time.now()], + config=conf, + auto_detect=False, + ignore_local_config=True + ) -@pytest.mark.without_camera -def test_camera_not_found(config): - conf = config.copy() - simulator = hardware.get_all_names(without=['camera']) - with pytest.raises(error.PanError): - Observatory(simulator=simulator, config=conf, ignore_local_config=True) + with pytest.raises(AssertionError): + Observatory(simulator=simulator, + cameras={'Cam00': Time.now()}, + config=conf, + auto_detect=False, + ignore_local_config=True + ) -def test_camera_port_error(config): +def test_camera(config): conf = config.copy() - conf['cameras']['devices'][0]['model'] = 'foobar' - simulator = hardware.get_all_names(without=['camera']) - with pytest.raises(error.CameraNotFound): - Observatory(simulator=simulator, config=conf, auto_detect=False, ignore_local_config=True) + cameras = create_cameras_from_config(conf) + obs = Observatory( + cameras=cameras, + config=conf, + auto_detect=False, + ignore_local_config=True + ) + assert obs.has_cameras -def test_camera_import_error(config): - conf = config.copy() - conf['cameras']['devices'][0]['model'] = 'foobar' - conf['cameras']['devices'][0]['port'] = 'usb:001,002' - simulator = hardware.get_all_names(without=['camera']) - with pytest.raises(error.NotFound): - Observatory(simulator=simulator, config=conf, auto_detect=False, ignore_local_config=True) +def test_primary_camera(observatory): + assert observatory.primary_camera is not None def test_status(observatory): @@ -201,10 +210,6 @@ def test_sidereal_time(observatory): assert abs(st.value - 9.145547849536634) < 1e-4 -def test_primary_camera(observatory): - assert observatory.primary_camera is not None - - def test_get_observation(observatory): observation = observatory.get_observation() assert isinstance(observation, Observation) diff --git a/pocs/tests/utils/test_utils.py b/pocs/tests/utils/test_utils.py index 2194138e0..8da652cf4 100644 --- a/pocs/tests/utils/test_utils.py +++ b/pocs/tests/utils/test_utils.py @@ -5,10 +5,10 @@ from pocs.utils import current_time -from pocs.utils import list_connected_cameras from pocs.utils import listify from pocs.utils import load_module from pocs.utils.error import NotFound +from pocs.camera import list_connected_cameras def test_bad_load_module(): diff --git a/pocs/utils/__init__.py b/pocs/utils/__init__.py index a6c7f39e1..02f737248 100644 --- a/pocs/utils/__init__.py +++ b/pocs/utils/__init__.py @@ -1,7 +1,5 @@ import os -import re import shutil -import subprocess import time from astropy import units as u @@ -119,29 +117,6 @@ def get_free_space(dir=None): return free_space -def list_connected_cameras(): - """ - Uses gphoto2 to try and detect which cameras are connected. - Cameras should be known and placed in config but this is a useful utility. - """ - - gphoto2 = shutil.which('gphoto2') - command = [gphoto2, '--auto-detect'] - result = subprocess.check_output(command) - lines = result.decode('utf-8').split('\n') - - ports = [] - - for line in lines: - camera_match = re.match('([\w\d\s_\.]{30})\s(usb:\d{3},\d{3})', line) - if camera_match: - # camera_name = camera_match.group(1).strip() - port = camera_match.group(2).strip() - ports.append(port) - - return ports - - def load_module(module_name): """ Dynamically load a module diff --git a/pocs/version.py b/pocs/version.py index 4e8c80784..8bc556255 100644 --- a/pocs/version.py +++ b/pocs/version.py @@ -1,5 +1,5 @@ major = 0 minor = 6 -patch = 1 +patch = 2 __version__ = '{}.{}.{}'.format(major, minor, patch)