diff --git a/.gitmodules b/.gitmodules index 4744efc..e02fa22 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,55 +1,55 @@ -[submodule "etc/PIPython"] - path = etc/PIPython - url = https://github.com/PI-PhysikInstrumente/PIPython -[submodule "etc/camera-interface"] - path = etc/camera-interface - url = https://github.com/CaltechOpticalObservatories/camera-interface - branch = main -[submodule "hispec/util/lakeshore"] - path = hispec/util/lakeshore +[submodule "src/hispec/util/lakeshore"] + path = src/hispec/util/lakeshore url = https://github.com/COO-Utilities/lakeshore - branch = main -[submodule "hispec/util/inficon"] - path = hispec/util/inficon + branch = main +[submodule "src/hispec/util/inficon"] + path = src/hispec/util/inficon url = https://github.com/COO-Utilities/inficon - branch = main -[submodule "hispec/util/gammavac"] - path = hispec/util/gammavac + branch = main +[submodule "src/hispec/util/gammavac"] + path = src/hispec/util/gammavac url = https://github.com/COO-Utilities/gammavac - branch = main -[submodule "hispec/util/standa"] - path = hispec/util/standa + branch = main +[submodule "src/hispec/util/standa"] + path = src/hispec/util/standa url = https://github.com/COO-Utilities/standa - branch = main -[submodule "hispec/util/thorlabs"] - path = hispec/util/thorlabs + branch = main +[submodule "src/hispec/util/thorlabs"] + path = src/hispec/util/thorlabs url = https://github.com/COO-Utilities/thorlabs - branch = main -[submodule "hispec/util/sunpower"] - path = hispec/util/sunpower + branch = main +[submodule "src/hispec/util/sunpower"] + path = src/hispec/util/sunpower url = https://github.com/COO-Utilities/sunpower - branch = main -[submodule "hispec/util/onewire"] - path = hispec/util/onewire + branch = main +[submodule "src/hispec/util/onewire"] + path = src/hispec/util/onewire url = https://github.com/COO-Utilities/onewire - branch = main -[submodule "hispec/util/xeryon"] - path = hispec/util/xeryon + branch = main +[submodule "src/hispec/util/xeryon"] + path = src/hispec/util/xeryon url = https://github.com/COO-Utilities/xeryon - branch = main -[submodule "hispec/util/pi"] - path = hispec/util/pi + branch = main +[submodule "src/hispec/util/pi"] + path = src/hispec/util/pi url = https://github.com/COO-Utilities/pi - branch = main -[submodule "hispec/util/srs"] - path = hispec/util/srs + branch = main +[submodule "src/hispec/util/srs"] + path = src/hispec/util/srs url = https://github.com/COO-Utilities/srs - branch = main -[submodule "hispec/util/ozoptics"] - path = hispec/util/ozoptics + branch = main +[submodule "src/hispec/util/ozoptics"] + path = src/hispec/util/ozoptics url = https://github.com/COO-Utilities/ozoptics - branch = main -[submodule "hispec/util/newport"] - path = hispec/util/newport - url = https://github.com/COO-Utilities/newport.git - branch = main + branch = main +[submodule "src/hispec/util/newport"] + path = src/hispec/util/newport + url = https://github.com/COO-Utilities/newport + branch = main +[submodule "etc/PIPython"] + path = etc/PIPython + url = https://github.com/PI-PhysikInstrumente/PIPython +[submodule "etc/camera-interface"] + path = etc/camera-interface + url = https://github.com/CaltechOpticalObservatories/camera-interface + branch = main diff --git a/hispec/util/__init__.py b/hispec/util/__init__.py deleted file mode 100644 index 2470d22..0000000 --- a/hispec/util/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from .sunpower import SunpowerCryocooler -from .pi import PIControllerBase -from .xeryon.xeryon_controller import XeryonController -from .xeryon.stage import Stage as XeryonStage -from .newport import smc100pp -from .inficon.inficonvgc502 import InficonVGC502 -from .thorlabs import fw102c -from .lakeshore import lakeshore -from .srs import PTC10 - -__all__ = [ - "SunpowerCryocooler", - "PIControllerBase", - "smc100pp", - "XeryonController", - "XeryonStage", - "InficonVGC502", - "fw102c", - "lakeshore", - "PTC10", -] diff --git a/hispec/util/config/pi_named_positions.json b/hispec/util/config/pi_named_positions.json deleted file mode 100644 index e225df7..0000000 --- a/hispec/util/config/pi_named_positions.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "024551188": { - "test": [ - "1", - 0.0 - ] - } -} diff --git a/hispec/util/config/xeryon_default_settings.txt b/hispec/util/config/xeryon_default_settings.txt deleted file mode 100644 index b9126b1..0000000 --- a/hispec/util/config/xeryon_default_settings.txt +++ /dev/null @@ -1,13 +0,0 @@ -% Default settings for Xeryon motion stages -% Axis-specific settings use the format X:= -% Master settings use = - -X:LLIM=10 -X:HLIM=200 -X:SSPD=5000 -X:PTO2=100 -X:PTOL=100 -X:POLI=5 - -POLI=7 -SAVE=0 diff --git a/hispec/util/helper/__init__.py b/hispec/util/helper/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/hispec/util/helper/logger_utils.py b/hispec/util/helper/logger_utils.py deleted file mode 100644 index a14d936..0000000 --- a/hispec/util/helper/logger_utils.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Set up logging helper module.""" -import logging -import sys - - -def setup_logger(name: str, log_file: str = None, level=logging.DEBUG, - quiet: bool = False) -> logging.Logger: - """Setup logger with given name.""" - logger = logging.getLogger(name) - logger.setLevel(level) - - # Prevent adding multiple handlers (important for reimporting) - if logger.handlers: - return logger - - formatter = logging.Formatter( - "%(asctime)s--%(name)s--%(levelname)s--%(message)s" - ) - - # File handler (optional) - if log_file: - file_handler = logging.FileHandler(log_file) - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - - # Console handler (unless quiet) - if not quiet: - console_formatter = logging.Formatter("%(asctime)s--%(message)s") - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setFormatter(console_formatter) - logger.addHandler(console_handler) - - return logger diff --git a/hispec/util/xeryon b/hispec/util/xeryon deleted file mode 160000 index 9d7ae37..0000000 --- a/hispec/util/xeryon +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9d7ae378c8ef684d8fb8e1d21829544606ddf6d0 diff --git a/pyproject.toml b/pyproject.toml index d59fb06..f346556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ -[tool.setuptools] -packages = ["hispec.util"] +[tool.setuptools.packages.find] +where = ["src"] +include = ["hispec", "hispec.*"] [build-system] requires = ["setuptools>=61.0"] @@ -20,5 +21,7 @@ dependencies = [ "keyring", "pipython", "pyserial", - "libximc" + "libximc", + "libby@git+https://github.com/CaltechOpticalObservatories/libby.git", + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base" ] diff --git a/hispec/__init__.py b/src/hispec/__init__.py similarity index 100% rename from hispec/__init__.py rename to src/hispec/__init__.py diff --git a/src/hispec/daemon.py b/src/hispec/daemon.py new file mode 100644 index 0000000..4fb9296 --- /dev/null +++ b/src/hispec/daemon.py @@ -0,0 +1,223 @@ +from __future__ import annotations +import json +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 libby import Libby + +Payload = Dict[str, Any] +RPCHandler = Callable[[Payload], Dict[str, Any]] +EvtHandler = Callable[[Payload], None] + +class HispecDaemon: + """ + Base daemon class for Libby peers with support for multiple transports. + + ZMQ Usage: + class MyPeer(HispecDaemon): + peer_id = "my-peer" + bind = "tcp://*:5555" + address_book = {"other-peer": "tcp://localhost:5556"} + + services = {"echo": lambda payload: {"echo": payload}} + topics = {"alerts": lambda payload: print(payload)} + + RabbitMQ Usage: + class MyPeer(HispecDaemon): + transport = "rabbitmq" + peer_id = "my-peer" + rabbitmq_url = "amqp://localhost" # optional, defaults to this + + services = {"echo": lambda payload: {"echo": payload}} + topics = {"alerts": lambda payload: print(payload)} + + Note: RabbitMQ doesn't need bind or address_book since routing is + handled automatically by the broker. + """ + # simple attributes users set + peer_id: Optional[str] = None + bind: Optional[str] = None + address_book: Optional[Dict[str, str]] = None + discovery_enabled: bool = True + discovery_interval_s: float = 5.0 + + # transport selection: "zmq" or "rabbitmq (default)" + transport: str = "rabbitmq" + rabbitmq_url: Optional[str] = None + group_id: Optional[str] = None + # internal config + _config: Dict[str, Any] = {} + + # payload-only handlers + services: Dict[str, RPCHandler] = {} + topics: Dict[str, EvtHandler] = {} + + def __init__(self) -> None: + # Ensure per-instance handler tables + if type(self).services is self.services: + self.services = {} + if type(self).topics is self.topics: + self.topics = {} + + # Config ingestion + @classmethod + def from_config_file(cls, path: str, *, env_prefix: str = "LIBBY_") -> "HispecDaemon": + """ + Build a daemon from a JSON or YAML file and then apply environment + overrides whose keys start with env_prefix (default: LIBBY_). + """ + pass + + @classmethod + def from_config(cls, cfg: Dict[str, Any]) -> "HispecDaemon": + """ + 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. + """ + pass + + # optional hooks + def on_start(self, libby: Libby) -> None: ... + def on_stop(self, libby: Optional[Libby] = None) -> None: ... + def on_hello(self, libby: Libby) -> None: ... + def on_event(self, topic: str, msg) -> None: + print(f"[{self.__class__.__name__}] {topic}: {msg.env.payload}") + + # config getters + def config_peer_id(self) -> str: return self.peer_id or self._must("peer_id") + def config_bind(self) -> str: return self.bind or self._must("bind") + def config_rabbitmq_url(self) -> str: return self.rabbitmq_url or "amqp://localhost" + def config_group_id(self) -> Optional[str]: return self.group_id + def config_address_book(self) -> Dict[str, str]: return self.address_book if self.address_book is not None else {} + def config_discovery_enabled(self) -> bool: return bool(self.discovery_enabled) + def config_discovery_interval_s(self) -> float: return float(self.discovery_interval_s) + def config_rpc_keys(self) -> List[str]: return list(self.services.keys()) + def config_subscriptions(self) -> List[str]: return list(self.topics.keys()) + + # user-facing helpers + def add_service(self, key: str, fn: RPCHandler) -> None: + self.services[key] = fn + if hasattr(self, "libby"): self._register_services({key: fn}) + + def add_services(self, mapping: Dict[str, RPCHandler]) -> None: + self.services.update(mapping) + if hasattr(self, "libby"): self._register_services(mapping) + + def add_topic(self, topic: str, fn: EvtHandler) -> None: + self.topics[topic] = fn + if hasattr(self, "libby"): + self.libby.listen(topic, lambda msg, _h=fn: _h(msg.env.payload)) + self.libby.subscribe(topic) + + def add_topics(self, mapping: Dict[str, EvtHandler]) -> None: + self.topics.update(mapping) + if hasattr(self, "libby"): + for topic, fn in mapping.items(): + self.libby.listen(topic, lambda msg, _h=fn: _h(msg.env.payload)) + self.libby.subscribe(*mapping.keys()) + + # internals + def _must(self, name: str): + raise NotImplementedError(f"Set `{name}` or override config_{name}()") + + def _service_adapter(self, fn): + def adapter(user_payload: dict, _ctx: dict) -> dict: + try: + result = fn(user_payload) # user returns ANYTHING + return self.payload(result) # we "shove it into payload" for them + except Exception as ex: + return {"ok": False, "error": str(ex)} + return adapter + + def _register_services(self, mapping: Dict[str, RPCHandler]) -> None: + for key, fn in mapping.items(): + self.libby.serve_keys([key], self._service_adapter(fn)) + + def build_libby(self) -> Libby: + """Build Libby instance with selected transport.""" + if self.transport == "rabbitmq": + return Libby.rabbitmq( + self_id=self.config_peer_id(), + rabbitmq_url=self.config_rabbitmq_url(), + keys=[], + callback=None, + group_id=self.config_group_id(), + ) + else: + # Default to ZMQ + return Libby.zmq( + self_id=self.config_peer_id(), + bind=self.config_bind(), + address_book=self.config_address_book(), + keys=[], callback=None, # register per-key + discover=self.config_discovery_enabled(), + discover_interval_s=self.config_discovery_interval_s(), + hello_on_start=True, + group_id=self.config_group_id(), + ) + + def serve(self) -> None: + stop_evt = threading.Event() + def _sig(_s, _f): stop_evt.set() + signal.signal(signal.SIGINT, _sig) + signal.signal(signal.SIGTERM, _sig) + + try: + self.libby = self.build_libby() + except Exception as ex: + print(f"[{self.__class__.__name__}] failed to start: {ex}", file=sys.stderr) + raise + + if self.services: + self._register_services(self.services) + if self.topics: + for topic, fn in self.topics.items(): + self.libby.listen(topic, lambda msg, _h=fn: _h(msg.env.payload)) + self.libby.subscribe(*self.topics.keys()) + + # discovery hello + hooks + try: + if self.config_discovery_enabled(): + self.libby.hello() + self.on_hello(self.libby) + except Exception: + pass + + try: + self.on_start(self.libby) + except Exception as ex: + print(f"[{self.__class__.__name__}] on_start error: {ex}", file=sys.stderr) + + if self.transport == "rabbitmq": + print(f"[{self.__class__.__name__}] up: id={self.config_peer_id()} transport=rabbitmq url={self.rabbitmq_url}") + else: + print(f"[{self.__class__.__name__}] up: id={self.config_peer_id()} bind={self.config_bind()}") + try: + while not stop_evt.is_set(): time.sleep(0.5) + finally: + try: self.on_stop() + except Exception: pass + self.libby.stop() + print(f"[{self.__class__.__name__}] stopped") + + def payload(self, value=None, /, **extra) -> dict: + if value is None: + out = {} + elif is_dataclass(value): + out = asdict(value) + elif isinstance(value, cabc.Mapping): + out = dict(value) + else: + out = {"data": value} + + if extra: + out.update(extra) + + try: + json.dumps(out) + except TypeError as e: + raise ValueError(f"Payload not JSON-serializable: {e}") from e + + return out \ No newline at end of file diff --git a/hispec/util/gammavac b/src/hispec/util/gammavac similarity index 100% rename from hispec/util/gammavac rename to src/hispec/util/gammavac diff --git a/hispec/util/inficon b/src/hispec/util/inficon similarity index 100% rename from hispec/util/inficon rename to src/hispec/util/inficon diff --git a/hispec/util/lakeshore b/src/hispec/util/lakeshore similarity index 100% rename from hispec/util/lakeshore rename to src/hispec/util/lakeshore diff --git a/hispec/util/newport b/src/hispec/util/newport similarity index 100% rename from hispec/util/newport rename to src/hispec/util/newport diff --git a/hispec/util/onewire b/src/hispec/util/onewire similarity index 100% rename from hispec/util/onewire rename to src/hispec/util/onewire diff --git a/hispec/util/ozoptics b/src/hispec/util/ozoptics similarity index 100% rename from hispec/util/ozoptics rename to src/hispec/util/ozoptics diff --git a/hispec/util/pi b/src/hispec/util/pi similarity index 100% rename from hispec/util/pi rename to src/hispec/util/pi diff --git a/hispec/util/srs b/src/hispec/util/srs similarity index 100% rename from hispec/util/srs rename to src/hispec/util/srs diff --git a/hispec/util/standa b/src/hispec/util/standa similarity index 100% rename from hispec/util/standa rename to src/hispec/util/standa diff --git a/hispec/util/sunpower b/src/hispec/util/sunpower similarity index 100% rename from hispec/util/sunpower rename to src/hispec/util/sunpower diff --git a/hispec/util/thorlabs b/src/hispec/util/thorlabs similarity index 100% rename from hispec/util/thorlabs rename to src/hispec/util/thorlabs diff --git a/src/hispec/util/xeryon b/src/hispec/util/xeryon new file mode 160000 index 0000000..33b6823 --- /dev/null +++ b/src/hispec/util/xeryon @@ -0,0 +1 @@ +Subproject commit 33b68232fd08450f2f312ed629ef4a6664dbf738