From 0817623d271807717b98e15c804127277ec6a5a5 Mon Sep 17 00:00:00 2001 From: Michael Langmayr Date: Fri, 24 Apr 2026 17:20:16 -0700 Subject: [PATCH 1/2] update pi-daemon so it use daisy chained pi divices, add yaml configs for the remaining FEI pi controllers, make use of libby keyword API, update HispecDaemon template to use libby keyword API --- config/hsfei/hsfei_atcpickoff.yaml | 23 ++ config/hsfei/hsfei_focpupsel.yaml | 21 ++ config/hsfei/hsfei_msel.yaml | 29 ++ config/hsfei/hsfei_pickoff.yaml | 13 +- config/hsfei/hsfei_rlight.yaml | 29 ++ daemons/hsfei/pi-daemon | 302 ++++++++++++++++ daemons/hsfei/pickoff | 442 ------------------------ src/hispec/daemon.py | 14 +- src/hispec/{util => driver}/__init__.py | 0 9 files changed, 419 insertions(+), 454 deletions(-) create mode 100644 config/hsfei/hsfei_atcpickoff.yaml create mode 100644 config/hsfei/hsfei_focpupsel.yaml create mode 100644 config/hsfei/hsfei_msel.yaml create mode 100644 config/hsfei/hsfei_rlight.yaml create mode 100755 daemons/hsfei/pi-daemon delete mode 100755 daemons/hsfei/pickoff rename src/hispec/{util => driver}/__init__.py (100%) 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..135694f --- /dev/null +++ b/daemons/hsfei/pi-daemon @@ -0,0 +1,302 @@ +#!/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 ( + BoolKeyword, + FloatKeyword, + IntKeyword, + Keyword, + StringKeyword, + TriggerKeyword, +) + + +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 keywords(self) -> List[Keyword]: + """Build the list of Keyword instances this stage exposes.""" + s = self.suffix + return [ + BoolKeyword(f"isloopclosed{s}", + getter=lambda: self.controller.is_loop_closed(self.device_key, self.axis), + description="Servo control loop is closed."), + BoolKeyword(f"isreferenced{s}", + getter=lambda: self.controller.is_homed(self.device_key, self.axis), + description="Stage has been referenced (homed)."), + BoolKeyword(f"ismoving{s}", + getter=lambda: self.controller.is_moving(self.device_key, self.axis), + description="Stage is currently moving."), + FloatKeyword(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."), + StringKeyword(f"positionnamed{s}", + setter=self._set_named, + validator=self._check_named, + description="Move to a named preset position from the config."), + FloatKeyword(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."), + FloatKeyword(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."), + FloatKeyword(f"hardmin{s}", + getter=lambda: self._hard_limits()[0], + units=self.units, + description="Hardware travel minimum (from the controller)."), + FloatKeyword(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)) + + all_keywords: List[Keyword] = [] + for stage in self.stages: + all_keywords.extend(stage.keywords()) + all_keywords += [ + BoolKeyword("isconnected", + getter=self._controller_responsive, + description="Check if daemon can talk to the PI controller."), + IntKeyword("error", + getter=self._error_code, + description="Latest PI controller error code (0 = no error). Reading clears the register."), + TriggerKeyword("halt", + action=self._halt_all, + description="Halt motion on all stages."), + ] + self.register_keywords(all_keywords) + + self.logger.info("Registered %d keywords", len(all_keywords)) + + 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..d3cf1c7 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,14 @@ 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) + def add_topic(self, topic: str, fn: EvtHandler) -> None: self.topics[topic] = fn if hasattr(self, "libby"): 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 From ca548f71f87141f5a3bbace0f266c05adb28f72b Mon Sep 17 00:00:00 2001 From: Michael Langmayr Date: Mon, 27 Apr 2026 15:06:48 -0700 Subject: [PATCH 2/2] use libby keyword registry --- daemons/hsfei/pi-daemon | 119 ++++++++++++++++++---------------------- src/hispec/daemon.py | 9 +++ 2 files changed, 61 insertions(+), 67 deletions(-) diff --git a/daemons/hsfei/pi-daemon b/daemons/hsfei/pi-daemon index 135694f..dd07ad6 100755 --- a/daemons/hsfei/pi-daemon +++ b/daemons/hsfei/pi-daemon @@ -10,14 +10,7 @@ from typing import Any, Dict, List, Optional from hispec import HispecDaemon from hispec.driver.pi import PIControllerBase -from libby import ( - BoolKeyword, - FloatKeyword, - IntKeyword, - Keyword, - StringKeyword, - TriggerKeyword, -) +from libby import KeywordRegistry def _as_float(v) -> Optional[float]: @@ -54,50 +47,48 @@ class _Stage: """Convenience property to access the daemon's controller.""" return self.daemon.controller - def keywords(self) -> List[Keyword]: - """Build the list of Keyword instances this stage exposes.""" + def register_keywords(self, registry: KeywordRegistry) -> None: + """Add this stage's keywords to the given KeywordRegistry.""" s = self.suffix - return [ - BoolKeyword(f"isloopclosed{s}", - getter=lambda: self.controller.is_loop_closed(self.device_key, self.axis), - description="Servo control loop is closed."), - BoolKeyword(f"isreferenced{s}", - getter=lambda: self.controller.is_homed(self.device_key, self.axis), - description="Stage has been referenced (homed)."), - BoolKeyword(f"ismoving{s}", - getter=lambda: self.controller.is_moving(self.device_key, self.axis), - description="Stage is currently moving."), - FloatKeyword(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."), - StringKeyword(f"positionnamed{s}", - setter=self._set_named, - validator=self._check_named, - description="Move to a named preset position from the config."), - FloatKeyword(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."), - FloatKeyword(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."), - FloatKeyword(f"hardmin{s}", - getter=lambda: self._hard_limits()[0], - units=self.units, - description="Hardware travel minimum (from the controller)."), - FloatKeyword(f"hardmax{s}", - getter=lambda: self._hard_limits()[1], - units=self.units, - description="Hardware travel maximum (from the controller)."), - ] + 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.""" @@ -176,23 +167,17 @@ class PiDaemon(HispecDaemon): self.logger.info("Starting %s daemon with %d stage(s)", self.peer_id, len(self.stages)) - all_keywords: List[Keyword] = [] for stage in self.stages: - all_keywords.extend(stage.keywords()) - all_keywords += [ - BoolKeyword("isconnected", - getter=self._controller_responsive, - description="Check if daemon can talk to the PI controller."), - IntKeyword("error", - getter=self._error_code, - description="Latest PI controller error code (0 = no error). Reading clears the register."), - TriggerKeyword("halt", - action=self._halt_all, - description="Halt motion on all stages."), - ] - self.register_keywords(all_keywords) - - self.logger.info("Registered %d keywords", len(all_keywords)) + 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") diff --git a/src/hispec/daemon.py b/src/hispec/daemon.py index d3cf1c7..dbf666c 100644 --- a/src/hispec/daemon.py +++ b/src/hispec/daemon.py @@ -197,6 +197,11 @@ 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"): @@ -282,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: