From 417373554ee9ab1543b6433d1fa5b23db65ddbf9 Mon Sep 17 00:00:00 2001 From: Mike Langmayr <1809691+mikelangmayr@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:29:01 -0700 Subject: [PATCH] Move the generic camera keywords into the shared camera_daemon service --- daemons/generic/camera | 216 +++-------------------------------------- pyproject.toml | 4 +- 2 files changed, 15 insertions(+), 205 deletions(-) diff --git a/daemons/generic/camera b/daemons/generic/camera index 904ed20..bcce40c 100755 --- a/daemons/generic/camera +++ b/daemons/generic/camera @@ -2,44 +2,18 @@ """ Camera daemon for camerad instruments. -Serves the keywords every camerad camera has. Anything instrument-specific -comes from an instrument object named in config, which registers its own -keywords, so one script runs every camera. +The generic camera keywords come from camera_daemon.CameraService. Anything +instrument-specific comes from an instrument object named in config, which +registers its own keywords, so one script runs every camera. """ import argparse import importlib import sys -import threading -import time -from dataclasses import dataclass -from typing import Any, List, Optional - -from hispec.daemon import HispecDaemon # pylint: disable=E0611 -from hispec.driver.pycamerad import Camerad, ModuleNotAvailable # pylint: disable=E0611 - -# initialize talks to the controller, so it needs longer than an RPC default -INITIALIZE_TIMEOUT_S = 300.0 - -# The writer is asynchronous, so the path appears shortly after the run ends -LASTFRAME_WAIT_S = 5.0 -LASTFRAME_POLL_S = 0.05 - -STATE_NOCAMERA = "nocamera" -STATE_IDLE = "idle" -STATE_EXPOSING = "exposing" -STATE_ABORTING = "aborting" -STATE_ERROR = "error" +from camera_daemon import CameraService -@dataclass -class _Run: - """What the current or most recent exposure run is doing.""" - - state: str = STATE_NOCAMERA - worker: Optional[threading.Thread] = None - lastframe: str = "" - aborting: bool = False +from hispec.daemon import HispecDaemon # pylint: disable=E0611 class CameraDaemon(HispecDaemon): # pylint: disable=W0223 @@ -48,49 +22,25 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 def __init__(self): super().__init__() - self.camera: Optional[Camerad] = None + self.camera = None self.instrument = None - self.config_file = None - self.nframes = 1 - self._isopen = False - self._command_lock = threading.Lock() - self._run = _Run() - - ### lifecycle + self.service = CameraService(self) def on_start(self, _libby): """Register keywords, then try to reach the camera.""" - self.config_file = self.get_config("camera.config_file") - self.nframes = int(self.get_config("camera.nframes", 1)) - self.instrument = self._load_instrument() - self._register_keywords() + + self.service.register_keywords(self.keyword_registry) if self.instrument is not None: self.instrument.register_keywords(self.keyword_registry) self.logger.info("Starting %s", self.peer_id) - - if not self.config_file: - self.logger.error("camera.config_file is not set") - return - - try: - self._build_camera() - self.logger.info("Camera built from %s", self.config_file) - except (ModuleNotAvailable, RuntimeError, OSError) as exc: - self.logger.error("Camera unavailable: %s", exc) + self.service.connect() def on_stop(self, _libby=None): - """Abort anything running, then drop the connection.""" + """Release the camera.""" self.logger.info("Shutting down %s", self.peer_id) - if self.camera is None: - return - try: - if self._run.state == STATE_EXPOSING: - self.camera.abort() - self.camera.close() - except (RuntimeError, OSError) as exc: - self.logger.error("Error releasing the camera: %s", exc) + self.service.on_stop() def _load_instrument(self): """Import the instrument object named in config, if there is one.""" @@ -106,148 +56,6 @@ class CameraDaemon(HispecDaemon): # pylint: disable=W0223 self.logger.error("Instrument object %s unusable: %s", path, exc) return None - def _build_camera(self) -> None: - camera_class = getattr(self.instrument, "camera_class", Camerad) - self.camera = camera_class.from_config(self.config_file) - self._run.state = STATE_IDLE - - ### generic keywords - - def _register_keywords(self) -> None: - self.keyword_registry.string("state", - getter=lambda: self._run.state, - description="nocamera, idle, exposing, aborting or error.") - self.keyword_registry.bool("isconnected", - getter=lambda: self.camera is not None, - description="Camera object exists for this config.") - self.keyword_registry.bool("isopen", - getter=lambda: self._isopen, - description="Controller connection has been opened.") - self.keyword_registry.bool("ispowered", - getter=self._is_powered, - setter=self._set_powered, - description="Detector power is on.") - self.keyword_registry.trigger("initialize", - action=self._initialize, - timeout_s=INITIALIZE_TIMEOUT_S, - description="Open, load firmware and power on.") - self.keyword_registry.float("exptime", - getter=self._get_exptime, - setter=self._set_exptime, - units="s", - description="Exposure time.") - self.keyword_registry.int("nframes", - getter=lambda: self.nframes, - setter=self._set_nframes, - validator=self._check_nframes, - description="Frame count used by expose.") - self.keyword_registry.bool("datacube", - getter=self._get_datacube, - setter=self._set_datacube, - description="Write a frame sequence as one FITS cube.") - self.keyword_registry.trigger("expose", - action=self._expose, - description="Start nframes exposures; watch state.") - self.keyword_registry.trigger("abort", - action=self._abort, - description="Abort the running exposure.") - self.keyword_registry.string("lastframe", - getter=lambda: self._run.lastframe, - description="Path of the last file written.") - - def _require_camera(self) -> Camerad: - if self.camera is None: - raise RuntimeError("no camera; check camera.config_file and the installed module") - return self.camera - - def _is_powered(self) -> bool: - return bool(self._require_camera().power()) - - def _set_powered(self, value: bool) -> None: - self._require_camera().power(bool(value)) - - def _initialize(self) -> None: - """Run the camera's startup sequence, then the instrument's own.""" - camera = self._require_camera() - with self._command_lock: - camera.initialize() - self._isopen = True - self._run.state = STATE_IDLE - - def _get_exptime(self) -> float: - return float(self._require_camera().exptime()) - - def _set_exptime(self, value: float) -> None: - self._require_camera().exptime(float(value)) - - def _check_nframes(self, value: Any) -> Optional[str]: - try: - if int(value) < 1: - return "nframes must be at least 1" - except (TypeError, ValueError): - return "nframes must be an integer" - return None - - def _set_nframes(self, value: int) -> None: - self.nframes = int(value) - - def _get_datacube(self) -> bool: - return bool(self._require_camera().datacube()) - - def _set_datacube(self, value: bool) -> None: - self._require_camera().datacube(bool(value)) - - def _expose(self) -> None: - """Start a counted run and return; progress shows in state.""" - self._require_camera() - with self._command_lock: - if self._run.worker is not None and self._run.worker.is_alive(): - raise RuntimeError(f"already {self._run.state}; abort first") - self._run.aborting = False - self._run.state = STATE_EXPOSING - self._run.worker = threading.Thread(target=self._run_exposure, - args=(self.nframes,), - name="expose", daemon=True) - self._run.worker.start() - - def _run_exposure(self, count: int) -> None: - try: - self.camera.expose(count) - except RuntimeError as exc: - # An aborted run fails too, but on request, so it is not an error - if self._run.aborting: - self.logger.info("Exposure aborted") - self._run.state = STATE_IDLE - return - self.logger.error("Exposure failed: %s", exc) - self._run.state = STATE_ERROR - return - self._run.lastframe = self._await_written(self._run.lastframe) - self._run.state = STATE_IDLE - - def _await_written(self, previous: str) -> str: - """Wait briefly for the writer to report a new path.""" - deadline = time.monotonic() + LASTFRAME_WAIT_S - while time.monotonic() < deadline: - current = self._written_path() - if current and current != previous: - return current - time.sleep(LASTFRAME_POLL_S) - return previous - - def _written_path(self) -> str: - written: List[str] = [output.last_written - for output in self.camera.output_status() - if output.last_written] - return written[-1] if written else "" - - def _abort(self) -> None: - camera = self._require_camera() - if self._run.state == STATE_EXPOSING: - self._run.aborting = True - self._run.state = STATE_ABORTING - camera.abort() - def main(): """Main entry point for the daemon.""" diff --git a/pyproject.toml b/pyproject.toml index 3d32e93..68f1ae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,8 @@ dependencies = [ "pyyaml", # influxdb extra: needed by daemons/generic/keygrabber "libby[influxdb]@git+https://github.com/CaltechOpticalObservatories/libby.git", - "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base" + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base", + "camera_daemon@git+https://github.com/COO-Utilities/camera-daemon.git" ] [tool.pylint.main] @@ -37,6 +38,7 @@ dependencies = [ ignored-modules = [ # Third-party dependencies, not installed in the lint environment "astropy", + "camera_daemon", "libby", "matplotlib", "numpy",