diff --git a/config/hsfei/hsfei_atcpickoff.yaml b/config/hsfei/hsfei_atcpickoff.yaml new file mode 100644 index 0000000..436f1c8 --- /dev/null +++ b/config/hsfei/hsfei_atcpickoff.yaml @@ -0,0 +1,23 @@ +# ATC Pickoff Dichroic Selector — C-663 stepper instance +# +# Usage: +# daemons/hsfei/pi-daemon -c config/hsfei/hsfei_atcpickoff.yaml + +peer_id: hsfei_atcpickoff + +hardware: + ip_address: 192.168.29.100 + tcp_port: 10008 + axis: "1" + units: mm + timeout_s: 30.0 + retry_count: 3 + + +named_positions: + open: 0.0 + dichroic_1: 0.0 + dichroic_2: 0.0 + +logging: + level: INFO diff --git a/config/hsfei/hsfei_focpupsel.yaml b/config/hsfei/hsfei_focpupsel.yaml new file mode 100644 index 0000000..268c93b --- /dev/null +++ b/config/hsfei/hsfei_focpupsel.yaml @@ -0,0 +1,21 @@ +# Focal/Pupil Plane Selector — C-663 stepper instance +# +# Usage: +# daemons/hsfei/pi-daemon -c config/hsfei/hsfei_focpupsel.yaml + +peer_id: hsfei_focpupsel + +hardware: + ip_address: 192.168.29.100 + tcp_port: 10009 + axis: "1" + units: mm + timeout_s: 30.0 + retry_count: 3 + +named_positions: + focal: 0.0 + pupil: 0.0 + +logging: + level: INFO diff --git a/config/hsfei/hsfei_msel.yaml b/config/hsfei/hsfei_msel.yaml new file mode 100644 index 0000000..979a34f --- /dev/null +++ b/config/hsfei/hsfei_msel.yaml @@ -0,0 +1,29 @@ +# FEI Mask Selector — H C-863 servo + V C-663 stepper, daisy-chained. +# +# Usage: +# daemons/hsfei/pi-daemon -c config/hsfei/hsfei_msel.yaml + +peer_id: hsfei_msel + +hardware: + ip_address: 192.168.29.100 + tcp_port: 10005 + +stages: + - name: v + device_id: 1 + axis: "1" + units: mm + named_positions: + slot_1: 0.0 + slot_2: 0.0 + - name: h + device_id: 2 + axis: "1" + units: mm + named_positions: + slot_1: 0.0 + slot_2: 0.0 + +logging: + level: INFO diff --git a/config/hsfei/hsfei_pickoff.yaml b/config/hsfei/hsfei_pickoff.yaml index b1c4b22..190fe89 100644 --- a/config/hsfei/hsfei_pickoff.yaml +++ b/config/hsfei/hsfei_pickoff.yaml @@ -1,19 +1,15 @@ -# Single Daemon Configuration Example -# For standalone daemon deployment or development/testing +# FEI Pickoff Mirror — C-663 stepper instance # # Usage: -# daemon = HsfeiPickoffDaemon.from_config_file("config/pickoff_single.yaml") +# daemons/hsfei/pi-daemon -c config/hsfei/hsfei_pickoff.yaml -peer_id: pickoff -group_id: hsfei -transport: rabbitmq -rabbitmq_url: amqp://localhost -discovery_enabled: false +peer_id: hsfei_pickoff hardware: ip_address: 192.168.29.100 tcp_port: 10001 axis: "1" + units: mm timeout_s: 30.0 retry_count: 3 @@ -25,4 +21,3 @@ named_positions: logging: level: INFO - # file: /var/log/hispec/pickoff.log diff --git a/config/hsfei/hsfei_rlight.yaml b/config/hsfei/hsfei_rlight.yaml new file mode 100644 index 0000000..8ad5795 --- /dev/null +++ b/config/hsfei/hsfei_rlight.yaml @@ -0,0 +1,29 @@ +# FEI Retractable Light Source Module — H + V C-863 servo, daisy-chained. +# +# Usage: +# daemons/hsfei/pi-daemon -c config/hsfei/hsfei_rlight.yaml + +peer_id: hsfei_rlight + +hardware: + ip_address: 192.168.29.100 + tcp_port: 10003 + +stages: + - name: h + device_id: 1 + axis: "1" + units: mm + named_positions: + in: 0.0 + out: 0.0 + - name: v + device_id: 2 + axis: "1" + units: mm + named_positions: + in: 0.0 + out: 0.0 + +logging: + level: INFO diff --git a/daemons/hsfei/pi-daemon b/daemons/hsfei/pi-daemon new file mode 100755 index 0000000..dd07ad6 --- /dev/null +++ b/daemons/hsfei/pi-daemon @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +""" +HSFEI PI Daemon +""" + +import argparse +import sys +import threading +from typing import Any, Dict, List, Optional + +from hispec import HispecDaemon +from hispec.driver.pi import PIControllerBase +from libby import KeywordRegistry + + +def _as_float(v) -> Optional[float]: + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +class _Stage: + """One PI axis and the keywords it exposes.""" + + def __init__(self, daemon, spec: Dict[str, Any], is_only: bool): + self.daemon = daemon + self.name = spec.get("name", "") + self.device_id = int(spec.get("device_id", 1)) + self.axis = str(spec.get("axis", "1")) + self.units = spec.get("units") + self.named_positions: Dict[str, float] = spec.get("named_positions", {}) or {} + self._softmin = _as_float(spec.get("softmin")) + self._softmax = _as_float(spec.get("softmax")) + self._move_lock = threading.Lock() + self.suffix = "" if is_only else self.name + + @property + def device_key(self): + """Convenience property to get the (ip, port, device_id) tuple for this stage.""" + return (self.daemon.ip_address, self.daemon.tcp_port, self.device_id) + + @property + def controller(self): + """Convenience property to access the daemon's controller.""" + return self.daemon.controller + + def register_keywords(self, registry: KeywordRegistry) -> None: + """Add this stage's keywords to the given KeywordRegistry.""" + s = self.suffix + registry.bool(f"isloopclosed{s}", + getter=lambda: self.controller.is_loop_closed(self.device_key, self.axis), + description="Servo control loop is closed.") + registry.bool(f"isreferenced{s}", + getter=lambda: self.controller.is_homed(self.device_key, self.axis), + description="Stage has been referenced (homed).") + registry.bool(f"ismoving{s}", + getter=lambda: self.controller.is_moving(self.device_key, self.axis), + description="Stage is currently moving.") + registry.float(f"positionvalue{s}", + getter=lambda: self.controller.get_pos(self.device_key, self.axis), + setter=self._set_position, + validator=self._check_soft_limits, + units=self.units, + description="Stage position in engineering units.") + registry.string(f"positionnamed{s}", + setter=self._set_named, + validator=self._check_named, + description="Move to a named preset position from the config.") + registry.float(f"softmin{s}", + getter=lambda: self._softmin, + setter=lambda v: setattr(self, "_softmin", v), + units=self.units, + nullable=True, + description="Software lower limit; null to clear.") + registry.float(f"softmax{s}", + getter=lambda: self._softmax, + setter=lambda v: setattr(self, "_softmax", v), + units=self.units, + nullable=True, + description="Software upper limit; null to clear.") + registry.float(f"hardmin{s}", + getter=lambda: self._hard_limits()[0], + units=self.units, + description="Hardware travel minimum (from the controller).") + registry.float(f"hardmax{s}", + getter=lambda: self._hard_limits()[1], + units=self.units, + description="Hardware travel maximum (from the controller).") + + def halt(self): + """Halt motion on this stage.""" + try: + self.controller.halt_motion(self.device_key) + except Exception as e: + self.daemon.logger.error("halt on %r failed: %s", self.name, e) + + def check_loop_closed(self): + """Ensure the control loop is closed for this stage, required for motion commands.""" + try: + if not self.controller.is_loop_closed(self.device_key, self.axis): + self.controller.close_loop(self.device_key, self.axis, enable=True) + except Exception as e: + self.daemon.logger.error("close-loop on %r failed: %s", self.name, e) + + def _hard_limits(self): + limits = self.controller.get_limits(self.device_key, self.axis) + if limits is None: + raise RuntimeError("failed to read hardware limits") + return limits[self.axis] + + def _check_soft_limits(self, v: float) -> Optional[str]: + if self._softmin is not None and v < self._softmin: + return f"target {v} below softmin {self._softmin}" + if self._softmax is not None and v > self._softmax: + return f"target {v} above softmax {self._softmax}" + return None + + def _check_named(self, name: str) -> Optional[str]: + if not name: + return "value must be a non-empty string" + if name not in self.named_positions: + return (f"unknown named position '{name}'; " + f"available: {list(self.named_positions.keys())}") + return None + + def _set_position(self, v: float) -> None: + with self._move_lock: + try: + moving = self.controller.is_moving(self.device_key, self.axis) + except Exception as e: + raise RuntimeError(f"could not check motion state: {e}") from e + if moving: + raise RuntimeError("stage is already moving; halt or wait for completion") + if not self.controller.set_pos(v, self.device_key, self.axis, blocking=False): + raise RuntimeError("controller rejected MOV command") + + def _set_named(self, name: str) -> None: + pos = float(self.named_positions[name]) + err = self._check_soft_limits(pos) + if err: + raise RuntimeError(err) + self._set_position(pos) + + +class PiDaemon(HispecDaemon): + """Daemon for controlling PI motion hardware via keywords.""" + + group_id = "hsfei" + + def __init__(self): + super().__init__() + + self.ip_address = None + self.tcp_port = None + self.stages: List[_Stage] = [] + self.controller = PIControllerBase(log=True) + + def on_start(self, _libby): + """Called when daemon starts - initialize hardware.""" + # from_config populates _config after __init__, so read it here + self.ip_address = self.get_config("hardware.ip_address") + self.tcp_port = self.get_config("hardware.tcp_port") + self.stages = self._build_stages() + + self.logger.info("Starting %s daemon with %d stage(s)", self.peer_id, len(self.stages)) + + for stage in self.stages: + stage.register_keywords(self.keyword_registry) + self.keyword_registry.bool("isconnected", + getter=self._controller_responsive, + description="Check if daemon can talk to the PI controller.") + self.keyword_registry.int("error", + getter=self._error_code, + description="Latest PI controller error code (0 = no error). Reading clears the register.") + self.keyword_registry.trigger("halt", + action=self._halt_all, + description="Halt motion on all stages.") + + if not (self.ip_address and self.tcp_port): + self.logger.error("No IP address or port specified") + return + + try: + self._connect_hardware() + self.logger.info("Daemon started successfully and connected to hardware") + except Exception as e: + self.logger.error("Failed to connect to hardware: %s", e) + self.logger.warning("Daemon will start but hardware is not available") + + def _controller_responsive(self) -> bool: + """Return True if the controller answers IsControllerReady?.""" + if not self.stages: + return False + try: + return bool(self.controller.is_controller_ready(self.stages[0].device_key)) + except Exception: + return False + + def _error_code(self) -> int: + """Return the controller's latest qERR? code (0 = no error, -1 = lookup failed).""" + if not self.stages: + return -1 + try: + code = self.controller.get_error_code(self.stages[0].device_key) + return int(code) if code is not None else -1 + except Exception: + return -1 + + def _build_stages(self) -> List[_Stage]: + stages_cfg = self._config.get("stages") + if stages_cfg: + return [_Stage(self, s, is_only=False) for s in stages_cfg] + return [_Stage(self, { + "name": "", + "device_id": 1, + "axis": self.get_config("hardware.axis", "1"), + "units": self.get_config("hardware.units"), + "named_positions": self._config.get("named_positions", {}), + "softmin": self.get_config("hardware.softmin", None), + "softmax": self.get_config("hardware.softmax", None), + }, is_only=True)] + + def _halt_all(self) -> None: + for stage in self.stages: + stage.halt() + + def _connect_hardware(self): + """Connect to the PI controller hardware.""" + self.logger.info("Connecting to PI at %s:%s", self.ip_address, self.tcp_port) + + if len(self.stages) > 1: + self.controller.connect_tcpip_daisy_chain(self.ip_address, self.tcp_port) + else: + self.controller.connect_tcp(self.ip_address, self.tcp_port) + + for stage in self.stages: + try: + idn = self.controller.get_idn(stage.device_key) + self.logger.info("Stage %r: %s", stage.name or "(default)", idn) + stage.check_loop_closed() + except Exception as e: + self.logger.error("Error preparing stage %r: %s", stage.name or "(default)", e) + + def on_stop(self, _libby): + """Cleanup when daemon shuts down.""" + self.logger.info("Shutting down %s daemon", self.peer_id) + + if self.controller is not None: + try: + self.controller.disconnect_all() + self.logger.info("Disconnected from PI controller") + except Exception as e: + self.logger.error("Error disconnecting: %s", e) + + +def main(): + """Main entry point for the daemon.""" + parser = argparse.ArgumentParser( + description='HSFEI PI Motion Daemon' + ) + 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)') + + args = parser.parse_args() + + if not args.config: + print("--config is required", file=sys.stderr) + sys.exit(2) + + try: + daemon = PiDaemon.from_config_file(args.config, daemon_id=args.daemon_id) + daemon.serve() + except KeyboardInterrupt: + print("\nDaemon interrupted by user") + sys.exit(0) + except Exception as e: + print(f"Error running daemon: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/daemons/hsfei/pickoff b/daemons/hsfei/pickoff deleted file mode 100755 index 011e987..0000000 --- a/daemons/hsfei/pickoff +++ /dev/null @@ -1,442 +0,0 @@ -#!/usr/bin/env python3 -""" -HSFEI Pickoff MirrorDaemon - -This daemon provides control and monitoring of the FEI pickoff mirror using Libby. -""" - -import argparse -import sys -from typing import Dict, Any - -from hispec import HispecDaemon -from hispec.util.pi import PIControllerBase - - -class HsfeiPickoffDaemon(HispecDaemon): - """Daemon for controlling the FEI pickoff mirror position.""" - - # Defaults - peer_id = "pickoff" - group_id = "hsfei" - transport = "rabbitmq" - discovery_enabled = False - - def __init__(self): - """Initialize the pickoff daemon.""" - super().__init__() - - # 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 - self.controller = PIControllerBase(log=True) - - # Daemon state - self.state = { - 'connected': False, - 'error': '', - } - - def get_named_positions(self): - """Get named positions from config (e.g., home, deployed, science).""" - return self._config.get("named_positions", {}) - - 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.""" - self.logger.info("Starting pickoff daemon") - - # Register RPC services - services = { - # Status queries - "status.get": self._service_get_status, - "position.get": self._service_get_position, - "target.get": self._service_get_target, - "limits.get": self._service_get_limits, - "idn.get": self._service_get_idn, - - # Control commands - "position.set": self._service_set_position, - "home": self._service_home, - "stop": self._service_stop, - "servo.set": self._service_set_servo, - - # Connection management - "connect": self._service_connect, - "disconnect": self._service_disconnect, - } - - self.add_services(services) - self.logger.info("Registered %d RPC services", len(services)) - - # Initialize hardware connection - if self.ip_address is None or self.tcp_port is None: - self.logger.error("No IP address or port specified for PI C-663 controller") - self.state['error'] = 'No IP address or port specified' - else: - try: - self._connect_hardware() - self.logger.info("Daemon started successfully and connected to hardware") - except Exception as e: - self.logger.error("Failed to connect to hardware: %s", e) - self.logger.warning("Daemon will start but hardware is not available") - self.state['error'] = str(e) - self.state['connected'] = False - - # Publish initial status - libby.publish("pickoff.status", self.state) - - # ========== RPC Service Handlers ========== - - def _service_get_status(self, _: Dict[str, Any]) -> Dict[str, Any]: - """Get complete status of the daemon (queries hardware).""" - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - try: - position = self.controller.get_pos(self.device_key, self.axis) - moving = self.controller.is_moving(self.device_key, self.axis) - servo_on = self.controller.is_loop_closed(self.device_key, self.axis) - referenced = self.controller.is_homed(self.device_key, self.axis) - limits = self.controller.get_limits(self.device_key, self.axis) - limit_min, limit_max = limits[self.axis] if limits else (None, None) - idn = self.controller.get_idn(self.device_key) - - # Get target position - target = None - try: - device = self.controller.devices[self.device_key] - target = device.qMOV(self.axis)[self.axis] - except Exception: - # If qMOV fails, target is unknown - pass - - status = { - 'connected': True, - 'position': position, - 'target': target, - 'moving': moving, - 'servo_on': servo_on, - 'referenced': referenced, - 'limit_min': limit_min, - 'limit_max': limit_max, - 'idn': idn, - } - - return {"ok": True, "status": status} - except Exception as e: - self.logger.error("Error reading status: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_get_position(self, _: Dict[str, Any]) -> Dict[str, Any]: - """Get current position from hardware.""" - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - try: - position = self.controller.get_pos(self.device_key, self.axis) - return {"ok": True, "position": position, "units": "mm"} - except Exception as e: - self.logger.error("Error reading position: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_get_target(self, _: Dict[str, Any]) -> Dict[str, Any]: - """Get target position from hardware.""" - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - try: - device = self.controller.devices[self.device_key] - target = device.qMOV(self.axis)[self.axis] - return {"ok": True, "target": target, "units": "mm"} - except Exception as e: - self.logger.error("Error reading target: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_get_limits(self, _: Dict[str, Any]) -> Dict[str, Any]: - """Get travel limits from hardware.""" - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - try: - limits = self.controller.get_limits(self.device_key, self.axis) - if limits is None: - return {"ok": False, "error": "Failed to read limits"} - limit_min, limit_max = limits[self.axis] - return { - "ok": True, - "limit_min": limit_min, - "limit_max": limit_max, - "units": "mm" - } - except Exception as e: - self.logger.error("Error reading limits: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_get_idn(self, _: Dict[str, Any]) -> Dict[str, Any]: - """Get controller identification from hardware.""" - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - try: - idn = self.controller.get_idn(self.device_key) - return {"ok": True, "idn": idn} - except Exception as e: - self.logger.error("Error reading IDN: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_set_position(self, p: Dict[str, Any]) -> Dict[str, Any]: - """Set/move to target position. - - Args: - p: {"position": } - """ - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - position = p.get("position") - if position is None: - return {"ok": False, "error": "Missing 'position' parameter"} - - if not isinstance(position, (int, float)): - return {"ok": False, "error": "'position' must be a number"} - - position = float(position) - self.logger.info("Setting position to: %f mm", position) - - try: - self.controller.set_pos(position, self.device_key, self.axis, blocking=False) - return {"ok": True, "position": position, "units": "mm"} - - except Exception as e: - self.logger.error("Error setting position: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_home(self, _: Dict[str, Any]) -> Dict[str, Any]: - """Home/reference the pickoff mirror.""" - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - self.logger.info("Homing pickoff mirror (FRF)") - - try: - # Execute reference move (home) - success = self.controller.home( - self.device_key, - self.axis, - method="FRF", - blocking=False - ) - - if success: - return {"ok": True, "status": "homing"} - else: - return {"ok": False, "error": "Homing failed to start"} - - except Exception as e: - self.logger.error("Error during homing: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_stop(self, _p: Dict[str, Any]) -> Dict[str, Any]: - """Stop any ongoing motion.""" - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - self.logger.info("Stopping motion") - - try: - # Halt all motion on the controller - self.controller.halt_motion(self.device_key) - return {"ok": True, "status": "stopped"} - - except Exception as e: - self.logger.error("Error stopping motion: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_set_servo(self, p: Dict[str, Any]) -> Dict[str, Any]: - """Enable or disable the servo. - - Args: - p: {"enable": true/false} - """ - if not self.state['connected']: - return {"ok": False, "error": "Not connected to hardware"} - - enable = p.get("enable") - if enable is None: - return {"ok": False, "error": "Missing 'enable' parameter"} - - if not isinstance(enable, bool): - return {"ok": False, "error": "'enable' must be boolean"} - - try: - self.controller.close_loop(self.device_key, self.axis, enable=enable) - self.logger.info(f"Servo {'enabled' if enable else 'disabled'}") - return {"ok": True, "servo_on": enable} - - except Exception as e: - self.logger.error("Error setting servo: %s", e) - self.state['error'] = str(e) - return {"ok": False, "error": str(e)} - - def _service_connect(self, _: Dict[str, Any]) -> Dict[str, Any]: - """Connect to the hardware.""" - if self.state['connected']: - return {"ok": True, "message": "Already connected"} - - if self.ip_address is None or self.tcp_port is None: - return {"ok": False, "error": "No IP address or port specified"} - - try: - self._connect_hardware() - return {"ok": True, "message": "Connected to hardware"} - except Exception as e: - self.logger.error("Failed to connect to hardware: %s", e) - return {"ok": False, "error": str(e)} - - def _service_disconnect(self, _: Dict[str, Any]) -> Dict[str, Any]: - """Disconnect from the hardware.""" - if not self.state['connected']: - return {"ok": True, "message": "Already disconnected"} - - try: - self.controller.disconnect_all() - self.state['connected'] = False - self.logger.info("Disconnected from hardware") - return {"ok": True, "message": "Disconnected from hardware"} - except Exception as e: - self.logger.error("Error disconnecting: %s", e) - return {"ok": False, "error": str(e)} - - # ========== Hardware Connection ========== - - def _connect_hardware(self): - """Connect to the PI C-663 Mercury Stepper Controller hardware.""" - self.logger.info("Connecting to PI C-663 at %s:%s", self.ip_address, self.tcp_port) - - try: - # Connect to the PI C-663 Mercury Stepper Controller via TCP - self.controller.connect_tcp(self.ip_address, self.tcp_port) - # PI controller stores device with 3-tuple key: (ip, port, device_id) - # For single non-daisy-chain connection, device_id is always 1 - self.device_key = (self.ip_address, self.tcp_port, 1) - self.state['connected'] = True - - # Get controller information - idn = self.controller.get_idn(self.device_key) - self.logger.info(f"Connected to: {idn}") - - # Get axis information - axes = self.controller.get_axes(self.device_key) - self.logger.info("Available axes: %s", axes) - - # Read initial position for logging - position = self.controller.get_pos(self.device_key, self.axis) - self.logger.info("Current position: %f mm", position) - - self.logger.info("Connected to PI C-663 Mercury Stepper Controller") - - except Exception as e: - self.logger.error("Failed to connect to PI C-663: %s",e) - self.state['connected'] = False - # Write error to state - self.state['error'] = str(e) - - def on_stop(self, _libby): - """Cleanup when daemon shuts down.""" - self.logger.info("Shutting down pickoff daemon") - - # Disconnect from PI C-663 controller - if self.state['connected'] and self.controller: - try: - self.controller.disconnect_all() - self.logger.info("Disconnected from PI C-663") - except Exception as e: - self.logger.error("Error disconnecting: %s", e) - - self.state['connected'] = False - - -def main(): - """Main entry point for the daemon.""" - 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, - default='192.168.29.100', - help='IP address of the PI C-663 controller (default: 192.168.29.100)' - ) - parser.add_argument( - '--tcp-port', - type=int, - default=10001, - help='TCP port for PI controller communication (default: 10001)' - ) - parser.add_argument( - '-a', '--axis', - type=str, - default='1', - help='Axis identifier (default: 1)' - ) - - args = parser.parse_args() - - # Create and run daemon - try: - 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") - sys.exit(0) - except Exception as e: - print(f"Error running daemon: {e}", file=sys.stderr) - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/src/hispec/daemon.py b/src/hispec/daemon.py index 08ecfcb..dbf666c 100644 --- a/src/hispec/daemon.py +++ b/src/hispec/daemon.py @@ -5,9 +5,9 @@ from dataclasses import is_dataclass, asdict import collections.abc as cabc import signal, sys, threading, time -from typing import Any, Callable, Dict, List, Optional, Type +from typing import Any, Callable, Dict, Iterable, List, Optional, Type -from libby import Libby +from libby import Keyword, Libby from . import config as cfg Payload = Dict[str, Any] @@ -43,7 +43,7 @@ class MyPeer(HispecDaemon): peer_id: Optional[str] = None bind: Optional[str] = None address_book: Optional[Dict[str, str]] = None - discovery_enabled: bool = True + discovery_enabled: bool = False discovery_interval_s: float = 5.0 # transport selection: "zmq" or "rabbitmq (default)" @@ -189,6 +189,19 @@ def add_services(self, mapping: Dict[str, RPCHandler]) -> None: self.services.update(mapping) if hasattr(self, "libby"): self._register_services(mapping) + def register_keyword(self, keyword: Keyword) -> None: + """Register a Keyword; delegates to the underlying Libby instance.""" + self.libby.register_keyword(keyword) + + def register_keywords(self, keywords: Iterable[Keyword]) -> None: + """Register many keywords in one libby call; call from on_start or later.""" + self.libby.register_keywords(keywords) + + @property + def keyword_registry(self): + """KeywordRegistry on the underlying Libby instance.""" + return self.libby.keyword_registry + def add_topic(self, topic: str, fn: EvtHandler) -> None: self.topics[topic] = fn if hasattr(self, "libby"): @@ -274,6 +287,10 @@ def _sig(_s, _f): stop_evt.set() except Exception as ex: print(f"[{self.__class__.__name__}] on_start error: {ex}", file=sys.stderr) + keywords_to_register = self.libby.keyword_registry.drain() + if keywords_to_register: + self.libby.register_keywords(keywords_to_register) + if self.transport == "rabbitmq": print(f"[{self.__class__.__name__}] up: id={self.config_peer_id()} transport=rabbitmq url={self.rabbitmq_url}") else: diff --git a/src/hispec/util/__init__.py b/src/hispec/driver/__init__.py similarity index 100% rename from src/hispec/util/__init__.py rename to src/hispec/driver/__init__.py