diff --git a/config/lris2_cryocooler.yaml b/config/lris2_cryocooler.yaml new file mode 100644 index 0000000..51fdd0d --- /dev/null +++ b/config/lris2_cryocooler.yaml @@ -0,0 +1,28 @@ +# +# Usage: +# daemons/cryocooler -c config/lris2_cryocooler.yaml + +peer_id: lris2_cryocooler +group_id: lris2 + +hardware: + # TODO: set to the real Sunpower cryocooler endpoint. + # Placeholder points at a closed local port so the daemon exercises its + # graceful-offline path (connection refused) during offline testing. + ip_address: 127.0.0.1 + tcp_port: 9 + con_type: tcp + units: K + +# Software limits enforced by the daemon on writes (the controller exposes no +# hard limits). TODO: set to the real safe operating envelope. +limits: + temp: + soft_min: 40.0 + soft_max: 330.0 + power: + soft_min: 0.0 + soft_max: 250.0 + +logging: + level: INFO diff --git a/config/lris2_vacuum.yaml b/config/lris2_vacuum.yaml new file mode 100644 index 0000000..3cb52cd --- /dev/null +++ b/config/lris2_vacuum.yaml @@ -0,0 +1,15 @@ +# +# Usage: +# daemons/vacuum-gauge -c config/lris2_vacuum.yaml + +peer_id: lris2_vacuum +group_id: lris2 + +hardware: + # Serial->TCP bridge endpoint for the VGC502 on lris2-137 (instrument LAN). + ip_address: 192.168.30.100 + tcp_port: 10001 + n_gauges: 2 # VGC502 has 2 channels + +logging: + level: INFO diff --git a/daemons/cryocooler b/daemons/cryocooler new file mode 100755 index 0000000..164e791 --- /dev/null +++ b/daemons/cryocooler @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +'''LRIS2 Sunpower cryocooler daemon''' +import argparse +import sys +from typing import Any, Callable, Optional # pylint: disable=W0611 + +from lris2.daemon import Lris2Daemon +from lris2.driver.sunpower import SunpowerCryocooler + + +class Cryocooler(Lris2Daemon): # pylint: disable=W0223 + '''Daemon for the LRIS2 Sunpower cryocooler''' + + # Defaults + group_id = "lris2" + + # pub/sub topics + topics = {} + + def __init__(self): + """Initialize the cryocooler daemon.""" + super().__init__() + + # Defaults + self.host = None + self.port = None + self.con_type = "tcp" + self.units = "K" + self.dev = SunpowerCryocooler(log=True) + self.daemon_desc = None + self._temp_soft_min = None + self._temp_soft_max = None + self._power_soft_min = None + self._power_soft_max = None + + # Daemon state. The Sunpower controller has no query for control mode or + # cooler on/off, so both are tracked here from the commands we issue. + self.state = { + 'error': '', + 'connected': False, + 'cooler_on': False, + 'ctrlmode': 'temp', + } + + def on_start(self, libby): + '''Start the daemon, register keywords, connect to the controller''' + self.host = self.get_config("hardware.ip_address") + self.port = self.get_config("hardware.tcp_port") + self.con_type = self.get_config("hardware.con_type", "tcp") + self.units = self.get_config("hardware.units", "K") + self.daemon_desc = self.get_config("peer_id") + self._temp_soft_min = self.get_config("limits.temp.soft_min") + self._temp_soft_max = self.get_config("limits.temp.soft_max") + self._power_soft_min = self.get_config("limits.power.soft_min") + self._power_soft_max = self.get_config("limits.power.soft_max") + + if not (self.host and self.port): + self.logger.error("No IP address or port specified for cryocooler") + self.state['error'] = 'No IP address or port specified' + return + + self._register_keywords() + + try: + connection = self.connect(True) + if not connection.get("is_connected"): + self.logger.warning("Cryocooler not reachable at %s:%s; daemon up, " + "hardware offline", self.host, self.port) + except (ConnectionRefusedError, OSError) as e: + self.logger.warning("Failed to connect to hardware: %s", e) + self.state['error'] = str(e) + + self.logger.info("Starting %s Daemon", self.daemon_desc) + + def _register_keywords(self): + """Register keywords for the daemon.""" + self.keyword_registry.bool("is_connected", + getter=self.dev.is_connected, + setter=self.keyword_wrapper(self.connect, key="is_connected"), + description="Check if daemon can talk to the cryocooler.") + self.keyword_registry.float("targettemp", + getter=self._reader(self.dev.get_target_temp, "targettemp"), + setter=self.keyword_wrapper(self.set_targettemp, key="targettemp"), + validator=self._check_temp_limits, + units=self.units, + description="Cold-head temperature setpoint.") + self.keyword_registry.float("coldtemp", + getter=self._reader(self.dev.get_cold_head_temp, "coldtemp"), + units=self.units, + description="Cold-head temperature.") + self.keyword_registry.float("rejecttemp", + getter=self._reader(self.dev.get_reject_temp, "rejecttemp"), + units=self.units, + description="Reject (warm end) temperature.") + self.keyword_registry.float("measpower", + getter=self._reader(self.dev.get_measured_power, "measpower"), + units="W", + description="Measured cooler power.") + self.keyword_registry.float("cmdpower", + getter=self._reader(self.dev.get_commanded_power, "cmdpower"), + setter=self.keyword_wrapper(self.set_cmdpower, key="cmdpower"), + validator=self._check_power_limits, + units="W", + description="Commanded (steady) cooler power.") + self.keyword_registry.float("curcmdpower", + getter=self._reader(self.dev.get_current_commanded_power, "curcmdpower"), + units="W", + description="Current commanded power.") + self.keyword_registry.bool("cooler", + getter=lambda: self.state['cooler_on'], + setter=self.keyword_wrapper(self.set_cooler, key="cooler"), + description="Cooler on/off (commanded state).") + self.keyword_registry.string("ctrlmode", + getter=lambda: self.state['ctrlmode'], + setter=self.keyword_wrapper(self.set_ctrlmode, key="ctrlmode"), + validator=self._check_ctrlmode, + description="Control mode: 'temp' (setpoint) or 'power' (fixed power).") + self.keyword_registry.float("tempsoftmin", + getter=lambda: self._temp_soft_min, + setter=lambda v: setattr(self, "_temp_soft_min", float(v)), + units=self.units, + description="Software lower limit for the temperature setpoint.") + self.keyword_registry.float("tempsoftmax", + getter=lambda: self._temp_soft_max, + setter=lambda v: setattr(self, "_temp_soft_max", float(v)), + units=self.units, + description="Software upper limit for the temperature setpoint.") + self.keyword_registry.float("powersoftmin", + getter=lambda: self._power_soft_min, + setter=lambda v: setattr(self, "_power_soft_min", float(v)), + units="W", + description="Software lower limit for commanded power.") + self.keyword_registry.float("powersoftmax", + getter=lambda: self._power_soft_max, + setter=lambda v: setattr(self, "_power_soft_max", float(v)), + units="W", + description="Software upper limit for commanded power.") + self.keyword_registry.string("status", + getter=self._reader(lambda: " | ".join(self.dev.get_status()), "status"), + description="Raw controller status block.") + self.keyword_registry.string("error", + getter=self._reader(lambda: str(self.dev.get_error()), "error"), + description="Last controller error.") + self.keyword_registry.string("version", + getter=self._reader(lambda: str(self.dev.get_version()), "version"), + description="Controller firmware version.") + + def on_stop(self, libby=None) -> None: + '''Stop the daemon and disconnect from the hardware device''' + try: + self.connect(False) + self.logger.info("Disconnected %s", self.daemon_desc) + except (OSError, ValueError) as e: + self.logger.error("Disconnect %s failed: %s", self.daemon_desc, e) + + def connect(self, connect): + """Connect to or disconnect from the controller.""" + try: + if connect: + self.dev.connect(self.host, self.port, con_type=self.con_type) + else: + self.dev.disconnect() + result = self.dev.is_connected() + self.state['connected'] = result + except (OSError, ValueError) as e: + self.logger.error("Failed to connect/disconnect with hardware: %s", e) + return {"ok": False, "error": str(e)} + return {"ok": True, "is_connected": result} + + def set_targettemp(self, temp: float): + """Set the temperature setpoint and switch to temperature-control mode.""" + result = self._write(lambda: self.dev.set_target_temp(float(temp)), "targettemp", temp) + if result.get("ok"): + self.state['ctrlmode'] = 'temp' + return result + + def set_cmdpower(self, watts: float): + """Set the commanded power and switch to fixed-power mode.""" + result = self._write(lambda: self.dev.set_commanded_power(float(watts)), "cmdpower", watts) + if result.get("ok"): + self.state['ctrlmode'] = 'power' + return result + + def set_cooler(self, on: bool): + """Turn the cooler on or off.""" + action = self.dev.turn_on_cooler if on else self.dev.turn_off_cooler + result = self._write(action, "cooler", bool(on)) + if result.get("ok"): + self.state['cooler_on'] = bool(on) + return result + + def set_ctrlmode(self, mode: str): + """Record the intended control mode. + + The controller has no explicit mode command; the effective mode is + whichever setpoint (targettemp or cmdpower) was last written. This + keyword tracks that intent and is updated by those setters. + """ + self.state['ctrlmode'] = mode + return {"ok": True, "ctrlmode": mode} + + def _reader(self, fn: Callable[[], Any], key: str): + """Build a keyword getter that reads one value from the device.""" + def read(): + if not self.dev.is_connected(): + return {"ok": False, "error": "Not connected to hardware"} + try: + value = fn() + except (OSError, ValueError, IndexError) as e: + self.logger.error("error reading %s: %s", key, e) + return {"ok": False, "error": str(e)} + return {"ok": True, key: value} + return self.keyword_wrapper(read, key=key) + + def _write(self, fn: Callable[[], Any], key: str, value: Any): + """Run a device write, guarding on connection and mapping errors.""" + if not self.dev.is_connected(): + return {"ok": False, "error": "Not connected to hardware"} + try: + fn() + except (OSError, ValueError) as e: + self.logger.error("error writing %s: %s", key, e) + return {"ok": False, "error": str(e)} + return {"ok": True, key: value} + + def _check_temp_limits(self, value: float) -> Optional[str]: + """Validate a temperature setpoint against the software limits.""" + return self._check_limits(value, self._temp_soft_min, self._temp_soft_max, "temperature") + + def _check_power_limits(self, value: float) -> Optional[str]: + """Validate a commanded power against the software limits.""" + return self._check_limits(value, self._power_soft_min, self._power_soft_max, "power") + + @staticmethod + def _check_limits(value: float, low: Optional[float], high: Optional[float], + label: str) -> Optional[str]: + """Return an error string if value is outside [low, high], else None.""" + if low is not None and value < low: + return f"{label} {value} below soft min {low}" + if high is not None and value > high: + return f"{label} {value} above soft max {high}" + return None + + @staticmethod + def _check_ctrlmode(mode: str) -> Optional[str]: + """Validate a control-mode value.""" + if mode not in ("temp", "power"): + return f"ctrlmode must be 'temp' or 'power', got '{mode}'" + return None + + @staticmethod + def keyword_wrapper(func, key=None): + """Wrap a daemon method for use as a keyword getter/setter.""" + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + if not result.get("ok"): + raise RuntimeError(result.get("error", f"Unknown error in {func.__name__}")) + if key: + return result[key] + return {k: v for k, v in result.items() if k != "ok"} + return wrapper + + +def main(): + """Main entry point for the daemon.""" + parser = argparse.ArgumentParser(description='LRIS2 Sunpower cryocooler daemon') + parser.add_argument('-c', '--config', type=str, + help='Path to config file (YAML)') + 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 = Cryocooler.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: # pylint: disable=W0718 + print(f"Error running daemon: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/daemons/vacuum-gauge b/daemons/vacuum-gauge new file mode 100755 index 0000000..6e2b685 --- /dev/null +++ b/daemons/vacuum-gauge @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +'''LRIS2 Inficon vacuum gauge daemon''' +import argparse +import sys +from typing import Optional # pylint: disable=W0611 + +from lris2.daemon import Lris2Daemon +from lris2.driver.inficon.inficonvgc502 import InficonVGC502 + + +class VacuumGauge(Lris2Daemon): # pylint: disable=W0223 + '''Daemon for the LRIS2 Inficon VGC50x vacuum gauge controller''' + + # Defaults + group_id = "lris2" + + # pub/sub topics + topics = {} + + def __init__(self): + """Initialize the vacuum gauge daemon.""" + super().__init__() + + # Defaults + self.host = None + self.port = None + self.n_gauges = 1 + self.dev = InficonVGC502(log=True) + self.daemon_desc = None + + # Daemon state + self.state = { + 'error': '', + 'connected': False, + } + + def on_start(self, libby): + '''Start the daemon, register keywords, connect to the controller''' + self.host = self.get_config("hardware.ip_address") + self.port = self.get_config("hardware.tcp_port") + self.n_gauges = int(self.get_config("hardware.n_gauges", 1)) + self.daemon_desc = self.get_config("peer_id") + + if not (self.host and self.port): + self.logger.error("No IP address or port specified for vacuum gauge controller") + self.state['error'] = 'No IP address or port specified' + return + + # Keywords register regardless of hardware availability so the daemon + # is usable (and inspectable) even while the gauge is unreachable. + self._register_keywords() + + try: + connection = self.connect(True) + if not connection.get("is_connected"): + self.logger.warning("Gauge not reachable at %s:%s; daemon up, hardware offline", + self.host, self.port) + else: + self.initialize() + self.logger.info("Initialized %s", self.daemon_desc) + except (ConnectionRefusedError, OSError) as e: + self.logger.warning("Failed to connect to hardware: %s", e) + self.state['error'] = str(e) + + self.logger.info("Starting %s Daemon", self.daemon_desc) + + def _register_keywords(self): + """Register keywords for the daemon.""" + self.keyword_registry.bool("is_connected", + getter=self.dev.is_connected, + setter=self.keyword_wrapper(self.connect, key="is_connected"), + description="Check if daemon can talk to the vacuum gauge controller.") + for gauge in range(1, self.n_gauges + 1): + self.keyword_registry.float(f"pressure{gauge}", + getter=self.keyword_wrapper( + lambda g=gauge: self.get_pressure(g), key="pressure"), + units=self.dev.pressure_units or None, + description=f"Pressure reading from gauge {gauge}.") + self.keyword_registry.float("temperature", + getter=self.keyword_wrapper(self.get_temperature, key="temperature"), + units="C", + description="Controller temperature.") + self.keyword_registry.string("units", + getter=self.keyword_wrapper(self.get_units, key="units"), + setter=self.keyword_wrapper(self.set_units, key="units"), + validator=self._check_units, + description="Pressure units (mbar/Torr/Pascal/Micron/hPascal/Volt).") + self.keyword_registry.string("model", + getter=lambda: self.dev.model or "", + description="Controller model.") + self.keyword_registry.string("serial", + getter=lambda: str(self.dev.serial_number or ""), + description="Controller serial number.") + self.keyword_registry.string("firmware", + getter=lambda: self.dev.firmware_version or "", + description="Controller firmware version.") + + def initialize(self): + """Query controller identity, gauge count, and units.""" + if not self.dev.is_connected(): + return {"ok": False, "error": "Not connected to hardware"} + try: + if not self.dev.initialize(): + return {"ok": False, "error": "Controller initialization failed"} + self.logger.debug("Initialized %s", self.daemon_desc) + except (OSError, ValueError) as e: + self.logger.error("error: %s", e) + return {"ok": False, "error": str(e)} + return {"ok": True} + + def on_stop(self, libby=None) -> None: + '''Stop the daemon and disconnect from the hardware device''' + try: + self.connect(False) + self.logger.info("Disconnected %s", self.daemon_desc) + except (OSError, ValueError) as e: + self.logger.error("Disconnect %s failed: %s", self.daemon_desc, e) + + def connect(self, connect): + """Connect to or disconnect from the controller.""" + try: + if connect: + self.dev.connect(host=self.host, port=self.port) + else: + self.dev.disconnect() + result = self.dev.is_connected() + self.state['connected'] = result + except (OSError, ValueError) as e: + self.logger.error("Failed to connect/disconnect with hardware: %s", e) + return {"ok": False, "error": str(e)} + return {"ok": True, "is_connected": result} + + def get_pressure(self, gauge: int): + """Read pressure from a gauge.""" + if not self.dev.is_connected(): + return {"ok": False, "error": "Not connected to hardware"} + try: + pressure = float(self.dev.read_pressure(gauge=gauge)) + self.logger.debug("pressure%d: %s", gauge, pressure) + except (OSError, ValueError) as e: + self.logger.error("error: %s", e) + return {"ok": False, "error": str(e)} + return {"ok": True, "pressure": pressure} + + def get_temperature(self): + """Read controller temperature.""" + if not self.dev.is_connected(): + return {"ok": False, "error": "Not connected to hardware"} + try: + temperature = float(self.dev.read_temperature()) + self.logger.debug("temperature: %s", temperature) + except (OSError, ValueError) as e: + self.logger.error("error: %s", e) + return {"ok": False, "error": str(e)} + return {"ok": True, "temperature": temperature} + + def get_units(self): + """Read the current pressure units.""" + if not self.dev.is_connected(): + return {"ok": False, "error": "Not connected to hardware"} + try: + self.dev.get_pressure_unit() + units = self.dev.pressure_units + except (OSError, ValueError) as e: + self.logger.error("error: %s", e) + return {"ok": False, "error": str(e)} + return {"ok": True, "units": units} + + def set_units(self, units: str): + """Set the pressure units by name.""" + if not self.dev.is_connected(): + return {"ok": False, "error": "Not connected to hardware"} + try: + code = InficonVGC502.UNIT_CODES.index(units) + if not self.dev.set_pressure_unit(code): + return {"ok": False, "error": f"Failed to set units to {units}"} + except (OSError, ValueError) as e: + self.logger.error("error: %s", e) + return {"ok": False, "error": str(e)} + return {"ok": True, "units": self.dev.pressure_units} + + def _check_units(self, units: str) -> Optional[str]: + """Validate a requested unit name against the controller's supported set.""" + if units not in InficonVGC502.UNIT_CODES: + return (f"unknown units '{units}'; " + f"available: {list(InficonVGC502.UNIT_CODES)}") + return None + + @staticmethod + def keyword_wrapper(func, key=None): + """Wrap a daemon method for use as a keyword getter/setter.""" + def wrapper(*args, **kwargs): + result = func(*args, **kwargs) + if not result.get("ok"): + raise RuntimeError(result.get("error", f"Unknown error in {func.__name__}")) + if key: + return result[key] + return {k: v for k, v in result.items() if k != "ok"} + return wrapper + + +def main(): + """Main entry point for the daemon.""" + parser = argparse.ArgumentParser(description='LRIS2 Inficon vacuum gauge daemon') + parser.add_argument('-c', '--config', type=str, + help='Path to config file (YAML)') + 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 = VacuumGauge.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: # pylint: disable=W0718 + print(f"Error running daemon: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/pyproject.toml b/pyproject.toml index c9dd99e..ca4efbd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,6 +12,10 @@ authors = [ ] license = {text = "MIT"} readme = "README.md" +dependencies = [ + "pyserial", + "pyyaml", +] [project.optional-dependencies] dev = [ diff --git a/src/daemon.py b/src/daemon.py new file mode 100644 index 0000000..f530b3e --- /dev/null +++ b/src/daemon.py @@ -0,0 +1,12 @@ +""" LRIS2 daemon base class """ +from libby.daemon import LibbyDaemon + + +class Lris2Daemon(LibbyDaemon): + """Instantiates the LRIS2 daemon base using LibbyDaemon. + + Transport is rabbitmq and discovery is false since rabbitmq includes + its own discovery. + """ + transport = "rabbitmq" + discovery_enabled = False \ No newline at end of file