diff --git a/config/example/hsfei.yaml b/config/example/hsfei.yaml new file mode 100644 index 0000000..b81a9d5 --- /dev/null +++ b/config/example/hsfei.yaml @@ -0,0 +1,48 @@ +# HSFEI Subsystem Configuration +group_id: hsfei +transport: rabbitmq +rabbitmq_url: amqp://hispec-rabbitmq +discovery_enabled: false + +daemons: + pickoff1: + hardware: + ip_address: 192.168.29.100 + tcp_port: 10001 + axis: "1" + named_positions: + home: 0.0 + science: 12.5 + calibration: 25.0 + engineering: 37.5 + + pickoff2: + hardware: + ip_address: 192.168.29.101 + tcp_port: 10001 + axis: "1" + named_positions: + home: 0.0 + flat: 10.0 + arc: 20.0 + dark: 30.0 + + pickoff3: + hardware: + ip_address: 192.168.29.102 + tcp_port: 10001 + axis: "1" + named_positions: + home: 0.0 + science: 12.5 + calibration: 25.0 + engineering: 37.5 + + pickoff4: + hardware: + ip_address: 192.168.29.103 + tcp_port: 10001 + axis: "1" + named_positions: + home: 0.0 + test_pos_1: 10.0 diff --git a/config/hsfei/hsfei_pickoff.yaml b/config/hsfei/hsfei_pickoff.yaml new file mode 100644 index 0000000..b1c4b22 --- /dev/null +++ b/config/hsfei/hsfei_pickoff.yaml @@ -0,0 +1,28 @@ +# Single Daemon Configuration Example +# For standalone daemon deployment or development/testing +# +# Usage: +# daemon = HsfeiPickoffDaemon.from_config_file("config/pickoff_single.yaml") + +peer_id: pickoff +group_id: hsfei +transport: rabbitmq +rabbitmq_url: amqp://localhost +discovery_enabled: false + +hardware: + ip_address: 192.168.29.100 + tcp_port: 10001 + axis: "1" + timeout_s: 30.0 + retry_count: 3 + +named_positions: + home: 0.0 + deployed: 25.0 + science: 12.5 + calibration: 37.5 + +logging: + level: INFO + # file: /var/log/hispec/pickoff.log diff --git a/daemons/hsfei/pickoff b/daemons/hsfei/pickoff index 6b3992d..011e987 100755 --- a/daemons/hsfei/pickoff +++ b/daemons/hsfei/pickoff @@ -2,43 +2,34 @@ """ HSFEI Pickoff MirrorDaemon -This daemon provides control and monitoring of the FEI pickoff mirrorusing Libby. +This daemon provides control and monitoring of the FEI pickoff mirror using Libby. """ import argparse -import logging import sys from typing import Dict, Any -from libby.daemon import LibbyDaemon +from hispec import HispecDaemon from hispec.util.pi import PIControllerBase -class HsfeiPickoffDaemon(LibbyDaemon): +class HsfeiPickoffDaemon(HispecDaemon): """Daemon for controlling the FEI pickoff mirror position.""" + # Defaults peer_id = "pickoff" group_id = "hsfei" transport = "rabbitmq" - rabbitmq_url = "amqp://localhost" # RabbitMQ on hispec discovery_enabled = False - discovery_interval_s = 5.0 - # pub/sub topics - topics = {} - - def __init__(self, ip_address='192.168.29.100', tcp_port=10001, axis='1'): - """Initialize the pickoff daemon. + def __init__(self): + """Initialize the pickoff daemon.""" + super().__init__() - Args: - ip_address: IP address of the PI controller (default: 192.168.29.100) - tcp_port: TCP port for the PI controller (default: 10001) - axis: Axis identifier (default: '1') - """ - # PI controller configuration - self.ip_address = ip_address - self.tcp_port = tcp_port - self.axis = axis + # PI controller configuration from config file + self.ip_address = self.get_config("hardware.ip_address", "192.168.29.100") + self.tcp_port = self.get_config("hardware.tcp_port", 10001) + self.axis = self.get_config("hardware.axis", "1") self.device_key = None # PI controller instance @@ -50,11 +41,13 @@ class HsfeiPickoffDaemon(LibbyDaemon): 'error': '', } - # Setup logging - self.logger = logging.getLogger(self.peer_id) + def get_named_positions(self): + """Get named positions from config (e.g., home, deployed, science).""" + return self._config.get("named_positions", {}) - # Call parent __init__ first - super().__init__() + def get_named_position(self, name: str): + """Get a specific named position value, or None if not found.""" + return self.get_named_positions().get(name) def on_start(self, libby): """Called when daemon starts - initialize hardware.""" @@ -384,6 +377,16 @@ def main(): parser = argparse.ArgumentParser( description='HSFEI Pickoff Mirror Daemon (PI C-663 Stepper)' ) + parser.add_argument( + '-c', '--config', + type=str, + help='Path to config file (YAML or JSON)' + ) + parser.add_argument( + '-d', '--daemon-id', + type=str, + help='Daemon ID (required for subsystem configs with multiple daemons)' + ) parser.add_argument( '-i', '--ip', type=str, @@ -405,33 +408,27 @@ def main(): args = parser.parse_args() - # Setup logging to file and console - log_level = logging.INFO - log_format = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - - # Create logger - logger = logging.getLogger() - logger.setLevel(log_level) - - # Console handler - console_handler = logging.StreamHandler() - console_handler.setLevel(log_level) - console_handler.setFormatter(logging.Formatter(log_format)) - logger.addHandler(console_handler) - - # File handler - file_handler = logging.FileHandler('hsfei_pickoff_daemon.log') - file_handler.setLevel(log_level) - file_handler.setFormatter(logging.Formatter(log_format)) - logger.addHandler(file_handler) - # Create and run daemon try: - daemon = HsfeiPickoffDaemon( - ip_address=args.ip, - tcp_port=args.tcp_port, - axis=args.axis, - ) + if args.config: + # Load from config file + daemon = HsfeiPickoffDaemon.from_config_file( + args.config, + daemon_id=args.daemon_id, + ) + else: + # Use CLI args - build config dict and use from_config + config = { + "peer_id": "pickoff", + "group_id": "hsfei", + "transport": "rabbitmq", + "hardware": { + "ip_address": args.ip, + "tcp_port": args.tcp_port, + "axis": args.axis, + } + } + daemon = HsfeiPickoffDaemon.from_config(config) daemon.serve() except KeyboardInterrupt: print("\nDaemon interrupted by user") diff --git a/pyproject.toml b/pyproject.toml index f346556..c87d5a5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "pipython", "pyserial", "libximc", + "pyyaml", "libby@git+https://github.com/CaltechOpticalObservatories/libby.git", "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base" ] diff --git a/src/hispec/__init__.py b/src/hispec/__init__.py index e69de29..577dc1e 100644 --- a/src/hispec/__init__.py +++ b/src/hispec/__init__.py @@ -0,0 +1,17 @@ +from .daemon import HispecDaemon +from .config import ( + ConfigError, + DaemonConfigLoader, + load_file, + extract_daemon_config, + list_daemons, +) + +__all__ = [ + "HispecDaemon", + "ConfigError", + "DaemonConfigLoader", + "load_file", + "extract_daemon_config", + "list_daemons", +] diff --git a/src/hispec/config.py b/src/hispec/config.py new file mode 100644 index 0000000..efb87f1 --- /dev/null +++ b/src/hispec/config.py @@ -0,0 +1,202 @@ +""" +Configuration ingestion daemons. +""" + +from __future__ import annotations # for Python 3.9 compatibility +from pathlib import Path +from typing import Any, Dict, List, Optional +import yaml + + +class ConfigError(Exception): + """Raised when configuration loading or validation fails.""" + pass + + +# Known HispecDaemon attributes that map directly from config +DAEMON_ATTRS = frozenset({ + "peer_id", + "bind", + "address_book", + "discovery_enabled", + "discovery_interval_s", + "transport", + "rabbitmq_url", + "group_id", +}) + + +def load_file(path: str | Path) -> Dict[str, Any]: + """ + Load a configuration file (YAML). + + Args: + path: Path to the config file + + Returns: + Parsed configuration dictionary + + Raises: + ConfigError: If the file cannot be loaded or parsed + """ + path = Path(path) + + if not path.exists(): + raise ConfigError(f"Config file not found: {path}") + + try: + with open(path, "r") as f: + return yaml.safe_load(f) or {} + except Exception as e: + raise ConfigError(f"Failed to load config from {path}: {e}") + + +def extract_daemon_config( + full_config: Dict[str, Any], + daemon_id: str, +) -> Dict[str, Any]: + """ + Extract configuration for a specific daemon from a subsystem config. + + Merges subsystem-level defaults with daemon-specific overrides. + + Args: + full_config: Full subsystem configuration + daemon_id: Identifier of the daemon to extract + + Returns: + Merged configuration for the specific daemon + + Raises: + ConfigError: If the daemon is not found in the config + """ + daemons = full_config.get("daemons", {}) + + if daemon_id not in daemons: + available = list(daemons.keys()) if daemons else [] + raise ConfigError( + f"Daemon '{daemon_id}' not found in config. " + f"Available daemons: {available}" + ) + + # Start with subsystem-level defaults (excluding 'daemons' key) + result = {k: v for k, v in full_config.items() if k != "daemons"} + + # Merge daemon-specific config (overrides subsystem defaults) + daemon_config = daemons[daemon_id] + if daemon_config: + _deep_merge(result, daemon_config) + + # Ensure peer_id is set + if "peer_id" not in result: + result["peer_id"] = daemon_id + + return result + + +def _deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> None: + """Deep merge override into base (modifies base in place).""" + for key, value in override.items(): + if ( + key in base + and isinstance(base[key], dict) + and isinstance(value, dict) + ): + _deep_merge(base[key], value) + else: + base[key] = value + + +def list_daemons(config: Dict[str, Any]) -> List[str]: + """ + List all daemon IDs defined in a subsystem config. + + Args: + config: Subsystem configuration dictionary + + Returns: + List of daemon identifiers + """ + return list(config.get("daemons", {}).keys()) + + +def is_subsystem_config(config: Dict[str, Any]) -> bool: + """Check if a config dict is a subsystem config (has 'daemons' key).""" + return "daemons" in config + + +class DaemonConfigLoader: + """ + Helper class for loading daemon configurations. + + Usage: + loader = DaemonConfigLoader("config/hsfei.yaml") + + # For subsystem configs with multiple daemons + for daemon_id in loader.daemon_ids: + config = loader.get_daemon_config(daemon_id) + daemon = MyDaemon.from_config(config) + + # Or load a single daemon directly + config = loader.get_daemon_config("pickoff1", env_prefix="HISPEC_") + """ + + def __init__(self, path: str | Path): + """ + Initialize loader with a config file path. + + Args: + path: Path to the config file (YAML or JSON) + """ + self.path = Path(path) + self._config: Optional[Dict[str, Any]] = None + + @property + def config(self) -> Dict[str, Any]: + """Lazily load and return the raw configuration.""" + if self._config is None: + self._config = load_file(self.path) + return self._config + + @property + def is_subsystem(self) -> bool: + """Check if this is a subsystem config with multiple daemons.""" + return is_subsystem_config(self.config) + + @property + def subsystem(self) -> Optional[str]: + """Get the subsystem name, if defined.""" + return self.config.get("subsystem") + + @property + def daemon_ids(self) -> List[str]: + """List all daemon IDs in this config.""" + if self.is_subsystem: + return list_daemons(self.config) + # Single daemon config - use peer_id or filename + peer_id = self.config.get("peer_id", self.path.stem) + return [peer_id] + + def get_daemon_config( + self, + daemon_id: Optional[str] = None, + ) -> Dict[str, Any]: + """ + Get configuration for a specific daemon. + + Args: + daemon_id: Daemon identifier (required for subsystem configs, + optional for single-daemon configs) + + Returns: + Merged configuration dictionary + """ + if self.is_subsystem: + if daemon_id is None: + raise ConfigError( + "daemon_id required for subsystem configs. " + f"Available: {self.daemon_ids}" + ) + return extract_daemon_config(self.config, daemon_id) + else: + return self.config.copy() diff --git a/src/hispec/daemon.py b/src/hispec/daemon.py index 4fb9296..08ecfcb 100644 --- a/src/hispec/daemon.py +++ b/src/hispec/daemon.py @@ -1,11 +1,14 @@ -from __future__ import annotations +""" Hispec Daemon template """ +from __future__ import annotations # for Python 3.9 compatibility import json +import logging from dataclasses import is_dataclass, asdict import collections.abc as cabc import signal, sys, threading, time -from typing import Any, Callable, Dict, List, Optional +from typing import Any, Callable, Dict, List, Optional, Type from libby import Libby +from . import config as cfg Payload = Dict[str, Any] RPCHandler = Callable[[Payload], Dict[str, Any]] @@ -63,20 +66,101 @@ def __init__(self) -> None: # Config ingestion @classmethod - def from_config_file(cls, path: str, *, env_prefix: str = "LIBBY_") -> "HispecDaemon": + def from_config_file( + cls: Type["HispecDaemon"], + path: str, + daemon_id: Optional[str] = None, + ) -> "HispecDaemon": """ - Build a daemon from a JSON or YAML file and then apply environment - overrides whose keys start with env_prefix (default: LIBBY_). + Build a daemon from a YAML config file. + + Args: + path: Path to yaml config file + daemon_id: For subsystem configs, which daemon to instantiate. + If None and config has multiple daemons, raises error. + + Returns: + Configured daemon instance """ - pass + loader = cfg.DaemonConfigLoader(path) + + # For subsystem configs, daemon_id is required unless only one daemon + if loader.is_subsystem: + if daemon_id is None: + if len(loader.daemon_ids) == 1: + daemon_id = loader.daemon_ids[0] + else: + raise cfg.ConfigError( + f"Subsystem config has multiple daemons: {loader.daemon_ids}. " + f"Specify daemon_id parameter." + ) + + config_dict = loader.get_daemon_config(daemon_id) + return cls.from_config(config_dict) @classmethod - def from_config(cls, cfg: Dict[str, Any]) -> "HispecDaemon": + def from_config(cls: Type["HispecDaemon"], config: Dict[str, Any]) -> "HispecDaemon": + """ + Build a daemon from a configuration dictionary. + + Known daemon attributes are mapped directly to instance attributes. + + Args: + config: Configuration dictionary + + Returns: + Configured daemon instance + """ + instance = cls() + + # Map known daemon attributes directly + for attr in cfg.DAEMON_ATTRS: + if attr in config: + setattr(instance, attr, config[attr]) + + # Store full config for subclass access + instance._config = config + + # Setup logging + instance._setup_logging() + + return instance + + def _setup_logging(self) -> None: + """Configure logging from config or defaults.""" + log_config = self._config.get("logging", {}) + level_str = log_config.get("level", "INFO").upper() + level = getattr(logging, level_str, logging.INFO) + log_file = log_config.get("file") + + logging.basicConfig( + level=level, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + filename=log_file, + ) + + self.logger = logging.getLogger(self.peer_id or self.__class__.__name__) + + def get_config(self, key: str, default: Any = None) -> Any: """ - Build a daemon from a pre-loaded dict. Only the known public attributes - are mapped; anything else stays in _config for user code to read. + Get a value from the daemon's configuration. + + Args: + key: Config key (supports dot notation for nested keys, e.g., "hardware.ip_address") + default: Default value if key not found + + Returns: + Config value or default """ - pass + keys = key.split(".") + value = self._config + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + return value + # optional hooks def on_start(self, libby: Libby) -> None: ...