From ead2b6fc635c80bba8c5904a92072eeb5a136c0c Mon Sep 17 00:00:00 2001 From: Don Neill Date: Thu, 5 Nov 2026 13:18:32 -0800 Subject: [PATCH 1/3] Initial Revision of ozopt_attenuator: a copy of the adc daemon. --- daemons/hscal/ozopt_attenuator | 297 +++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 daemons/hscal/ozopt_attenuator diff --git a/daemons/hscal/ozopt_attenuator b/daemons/hscal/ozopt_attenuator new file mode 100644 index 0000000..7200a7f --- /dev/null +++ b/daemons/hscal/ozopt_attenuator @@ -0,0 +1,297 @@ +#!/usr/bin/env python3 +""" +HSFEI ADC Rotator Daemon + +Exposes the two Newport SMC100PP rotator stages of the ADC (prism 1 & 2) as libby keywords. +""" + +import argparse +import sys +import threading +from typing import Any, Dict, List, Optional + +from libby import KeywordRegistry + +from hispec import HispecDriverDaemon +from hispec.driver.ozoptics.dd100mc import OZController + + +# StageController.home() polls to completion, so the RPC waits out the move +HOME_TIMEOUT_S = 60.0 + + +def _as_float(v) -> Optional[float]: + if v is None: + return None + try: + return float(v) + except (TypeError, ValueError): + return None + + +class _Stage: + """One SMC100PP rotator axis and the keywords it exposes.""" + + def __init__(self, daemon: "AdcDaemon", spec: Dict[str, Any]): + self.daemon = daemon + self.stage_id = int(spec.get("stage_id", 1)) + self.units = spec.get("units", "deg") + self._softmin = _as_float(spec.get("softmin")) + self._softmax = _as_float(spec.get("softmax")) + self._move_lock = threading.Lock() + # Per the spec the suffix is the stage number (positionvalue1, ismoving2, ...) + self.suffix = str(self.stage_id) + + @property + def controller(self) -> OZController: + """Convenience accessor for the daemon's shared 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"isreferenced{s}", + getter=self.is_referenced, + setter=self._set_referenced, + timeout_s=HOME_TIMEOUT_S, + description="ADC rotator stage is referenced (homed); write true to home.") + registry.bool(f"ismoving{s}", + getter=self.is_moving, + description="ADC rotator stage is moving.") + registry.float(f"positionvalue{s}", + getter=self.get_position, + setter=self._set_position, + validator=self._check_soft_limits, + units=self.units, + description="ADC rotator stage position in degrees.") + registry.float(f"softmin{s}", + getter=lambda: self._softmin, + units=self.units, + nullable=True, + description="ADC rotator stage software minimum position.") + registry.float(f"softmax{s}", + getter=lambda: self._softmax, + units=self.units, + nullable=True, + description="ADC rotator stage software maximum position.") + + def is_referenced(self) -> bool: + """Return True if the stage has been referenced (homed).""" + return bool(self.controller.is_homed()) + + def get_position(self) -> Optional[float]: + """Return the current stage position in degrees.""" + return self.controller.get_pos() + + def _set_position(self, v: float) -> None: + with self._move_lock: + if self.is_moving(): + raise RuntimeError("stage is already moving; halt or wait for completion") + if not self.controller.move_abs(position=v, stage_id=self.stage_id, blocking=False): + raise RuntimeError("controller rejected move command") + + def _set_referenced(self, value: bool) -> None: + """Home the stage; reject false, the driver cannot clear the flag.""" + if not value: + raise RuntimeError("cannot clear referenced state; write true to home") + with self._move_lock: + if self.is_moving(): + raise RuntimeError("stage is already moving; halt or wait for completion") + if not self.controller.home(stage_id=self.stage_id): + raise RuntimeError("controller rejected reference move") + + +class OZDaemon(HispecDriverDaemon): # pylint: disable=W0223 + """Daemon for controlling the OZ Optics attenuator via keywords.""" + + def __init__(self): + super().__init__() + + self.ip_address = None + self.tcp_port = None + self.position_tolerance = 0.01 + self.stages: List[_Stage] = [] + self.named_positions: Dict[str, List[float]] = {} + # Two daisy-chained rotators; stage count is fixed by the ADC design. + self.controller = OZController(log=True) + + def on_start(self): + """Called when the daemon starts - register keywords and connect.""" + # 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.position_tolerance = _as_float( + self.get_config("hardware.position_tolerance", 0.01)) or 0.01 + self.named_positions = self._config.get("named_positions", {}) or {} + self.controller.move_rate = _as_float( + self.get_config("hardware.move_rate")) or self.controller.move_rate + 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.is_connected, + setter=self._set_connected, + description="ADC rotator controller is connected.") + self.keyword_registry.string("error", + getter=self._error_string, + description="ADC error string for either rotator stage.") + self.keyword_registry.string("status", + getter=self._status, + description="Status of the device driver as a " + "(code, message) string.") + self.keyword_registry.trigger("halt", + action=self._halt_all, + description="Halt all ADC rotator motion.") + self.keyword_registry.trigger("shutdown", + action=self._shutdown, + description="Gracefully stop this daemon; " + "refused while a stage is moving.") + self.keyword_registry.bool("isreferenced", + getter=lambda: all(s.is_referenced() for s in self.stages), + setter=self._set_all_referenced, + timeout_s=HOME_TIMEOUT_S, + description="Both ADC rotator stages are referenced; " + "write true to home both.") + self.keyword_registry.bool("ismoving", + getter=lambda: any(s.is_moving() for s in self.stages), + description="Either ADC rotator stage is moving.") + self.keyword_registry.string("positionnamed", + getter=self._current_named, + setter=self._goto_named, + validator=self._check_named, + description="ADC named position (e.g. crossed, " + "maxdisp); 'custom' when unmatched.") + + 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: # pylint: disable=W0718 + self.logger.error("Failed to connect to hardware: %s", e) + self.logger.warning("Daemon will start but hardware is not available") + + def on_stop(self): + """Cleanup when the daemon shuts down.""" + self.logger.info("Shutting down %s daemon", self.peer_id) + try: + self.controller.disconnect() + self.logger.info("Disconnected from ADC controller") + except Exception as e: # pylint: disable=W0718 + self.logger.error("Error disconnecting: %s", e) + + def _build_stages(self) -> List[_Stage]: + stages_cfg = self._config.get("stages") + if stages_cfg: + return [_Stage(self, s) for s in stages_cfg] + # Fall back to the two-stage ADC default if not enumerated in config. + return [_Stage(self, {"stage_id": n, "units": "deg"}) for n in (1, 2)] + + def _connect_hardware(self): + self.logger.info("Connecting to ADC controller at %s:%s", + self.ip_address, self.tcp_port) + self.controller.connect(host=self.ip_address, port=int(self.tcp_port)) + # The drivers signal failure by staying disconnected rather than raising + if not self.controller.is_connected(): + raise RuntimeError( + f"no connection to {self.ip_address}:{self.tcp_port}") + self.controller.initialize() + + def _set_connected(self, value: bool) -> None: + if value: + self._connect_hardware() + else: + self.controller.disconnect() + + def _error_string(self) -> str: + """Combine the last-error strings from both stages.""" + try: + parts = [f"{s.stage_id}: {self.controller.get_last_error(s.stage_id)}" + for s in self.stages] + return " | ".join(parts) + except Exception as e: # pylint: disable=W0718 + return f"error read failed: {e}" + + def _halt_all(self) -> None: + for stage in self.stages: + stage.stop() + + def _shutdown(self) -> None: + """Stop the daemon, refusing while a stage is still moving.""" + try: + moving = any(s.is_moving() for s in self.stages) + except Exception: # pylint: disable=W0718 + # An unreachable controller must not leave the daemon unstoppable + moving = False + if moving: + raise RuntimeError("a stage is moving; halt before shutting down") + self.logger.info("Shutting down on keyword request") + self.request_stop() + + def _set_all_referenced(self, value: bool) -> None: + """Setter for the consolidated isreferenced: home every stage.""" + for stage in self.stages: + stage._set_referenced(value) # pylint: disable=protected-access + + 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 _current_named(self) -> str: + """Return the named position both stages currently match, else 'custom'.""" + positions = [s.get_position() for s in self.stages] + if any(p is None for p in positions): + return "custom" + for name, targets in self.named_positions.items(): + if len(targets) == len(positions) and all( + abs(p - float(t)) <= self.position_tolerance + for p, t in zip(positions, targets)): + return name + return "custom" + + def _goto_named(self, name: str) -> None: + """Move both stages to the angles defined by the given named position.""" + targets = self.named_positions[name] + if len(targets) != len(self.stages): + raise RuntimeError( + f"named position '{name}' has {len(targets)} values, " + f"expected {len(self.stages)}") + for stage, target in zip(self.stages, targets): + stage._set_position(float(target)) # pylint: disable=W0212 + + +def main(): + """Main entry point for the daemon.""" + parser = argparse.ArgumentParser(description='HSFEI ADC Rotator Daemon') + parser.add_argument('-c', '--config', type=str, + help='Path to config file (YAML or JSON)') + + args = parser.parse_args() + + if not args.config: + print("--config is required", file=sys.stderr) + sys.exit(2) + + try: + daemon = OZDaemon.from_config_file(args.config) + 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() From 51bae9c9b521303d88d2e507840c46b78d64537f Mon Sep 17 00:00:00 2001 From: Don Neill Date: Mon, 9 Nov 2026 13:31:25 -0800 Subject: [PATCH 2/3] Version of ozopt_attenuator to test with hardware: added hscal_yjetatten.env and hscal_yjetatten.yaml --- config/hscal/hscal_hketatten.yaml | 2 +- config/hscal/hscal_yjetatten.yaml | 15 ++ daemons/hscal/ozopt_attenuator | 216 ++++---------------------- systemd/instances/hscal_yjetatten.env | 9 ++ 4 files changed, 52 insertions(+), 190 deletions(-) create mode 100644 config/hscal/hscal_yjetatten.yaml create mode 100644 systemd/instances/hscal_yjetatten.env diff --git a/config/hscal/hscal_hketatten.yaml b/config/hscal/hscal_hketatten.yaml index c5f0a0f..a654666 100644 --- a/config/hscal/hscal_hketatten.yaml +++ b/config/hscal/hscal_hketatten.yaml @@ -1,4 +1,4 @@ -# HISPEC RED CAL Etalon Attenuator — Standa SMC8 contoller instance +# HISPEC RED CAL Etalon Attenuator — Standa SMC8 controller instance # # Usage: # daemons/hscal/smc8_attenuator -c config/hscal/hscal_hketatten.yaml diff --git a/config/hscal/hscal_yjetatten.yaml b/config/hscal/hscal_yjetatten.yaml new file mode 100644 index 0000000..6af690b --- /dev/null +++ b/config/hscal/hscal_yjetatten.yaml @@ -0,0 +1,15 @@ +# HISPEC BLUE CAL Etalon Attenuator — OZOptics DD100MC controller instance +# +# Usage: +# daemons/hscal/ozopt_attenuator -c config/hscal/hscal_yjetatten.yaml + +peer_id: yjetatten +group_id: hscal + +hardware: + ip_address: 192.168.29.153 + tcp_port: 10003 + +logging: + level: INFO + file: /tmp/hscal_hketatten.log \ No newline at end of file diff --git a/daemons/hscal/ozopt_attenuator b/daemons/hscal/ozopt_attenuator index 7200a7f..bbd03be 100644 --- a/daemons/hscal/ozopt_attenuator +++ b/daemons/hscal/ozopt_attenuator @@ -1,106 +1,20 @@ #!/usr/bin/env python3 """ -HSFEI ADC Rotator Daemon +OZOptics Attenuator Daemon -Exposes the two Newport SMC100PP rotator stages of the ADC (prism 1 & 2) as libby keywords. +Exposes the OZOptics attenuator functions as libby keywords. """ import argparse import sys -import threading -from typing import Any, Dict, List, Optional - -from libby import KeywordRegistry from hispec import HispecDriverDaemon from hispec.driver.ozoptics.dd100mc import OZController - # StageController.home() polls to completion, so the RPC waits out the move HOME_TIMEOUT_S = 60.0 -def _as_float(v) -> Optional[float]: - if v is None: - return None - try: - return float(v) - except (TypeError, ValueError): - return None - - -class _Stage: - """One SMC100PP rotator axis and the keywords it exposes.""" - - def __init__(self, daemon: "AdcDaemon", spec: Dict[str, Any]): - self.daemon = daemon - self.stage_id = int(spec.get("stage_id", 1)) - self.units = spec.get("units", "deg") - self._softmin = _as_float(spec.get("softmin")) - self._softmax = _as_float(spec.get("softmax")) - self._move_lock = threading.Lock() - # Per the spec the suffix is the stage number (positionvalue1, ismoving2, ...) - self.suffix = str(self.stage_id) - - @property - def controller(self) -> OZController: - """Convenience accessor for the daemon's shared 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"isreferenced{s}", - getter=self.is_referenced, - setter=self._set_referenced, - timeout_s=HOME_TIMEOUT_S, - description="ADC rotator stage is referenced (homed); write true to home.") - registry.bool(f"ismoving{s}", - getter=self.is_moving, - description="ADC rotator stage is moving.") - registry.float(f"positionvalue{s}", - getter=self.get_position, - setter=self._set_position, - validator=self._check_soft_limits, - units=self.units, - description="ADC rotator stage position in degrees.") - registry.float(f"softmin{s}", - getter=lambda: self._softmin, - units=self.units, - nullable=True, - description="ADC rotator stage software minimum position.") - registry.float(f"softmax{s}", - getter=lambda: self._softmax, - units=self.units, - nullable=True, - description="ADC rotator stage software maximum position.") - - def is_referenced(self) -> bool: - """Return True if the stage has been referenced (homed).""" - return bool(self.controller.is_homed()) - - def get_position(self) -> Optional[float]: - """Return the current stage position in degrees.""" - return self.controller.get_pos() - - def _set_position(self, v: float) -> None: - with self._move_lock: - if self.is_moving(): - raise RuntimeError("stage is already moving; halt or wait for completion") - if not self.controller.move_abs(position=v, stage_id=self.stage_id, blocking=False): - raise RuntimeError("controller rejected move command") - - def _set_referenced(self, value: bool) -> None: - """Home the stage; reject false, the driver cannot clear the flag.""" - if not value: - raise RuntimeError("cannot clear referenced state; write true to home") - with self._move_lock: - if self.is_moving(): - raise RuntimeError("stage is already moving; halt or wait for completion") - if not self.controller.home(stage_id=self.stage_id): - raise RuntimeError("controller rejected reference move") - - class OZDaemon(HispecDriverDaemon): # pylint: disable=W0223 """Daemon for controlling the OZ Optics attenuator via keywords.""" @@ -109,62 +23,49 @@ class OZDaemon(HispecDriverDaemon): # pylint: disable=W0223 self.ip_address = None self.tcp_port = None - self.position_tolerance = 0.01 - self.stages: List[_Stage] = [] - self.named_positions: Dict[str, List[float]] = {} - # Two daisy-chained rotators; stage count is fixed by the ADC design. self.controller = OZController(log=True) + self.units = "steps" # units of the position keyword + self.atten_units = "dB" # fixed by the driver's conversion coefficients def on_start(self): """Called when the daemon starts - register keywords and connect.""" # 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.position_tolerance = _as_float( - self.get_config("hardware.position_tolerance", 0.01)) or 0.01 - self.named_positions = self._config.get("named_positions", {}) or {} - self.controller.move_rate = _as_float( - self.get_config("hardware.move_rate")) or self.controller.move_rate - 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.logger.info("Starting %s daemon", self.peer_id) self.keyword_registry.bool("isconnected", getter=self.controller.is_connected, setter=self._set_connected, description="ADC rotator controller is connected.") - self.keyword_registry.string("error", - getter=self._error_string, - description="ADC error string for either rotator stage.") self.keyword_registry.string("status", getter=self._status, description="Status of the device driver as a " "(code, message) string.") - self.keyword_registry.trigger("halt", - action=self._halt_all, - description="Halt all ADC rotator motion.") self.keyword_registry.trigger("shutdown", action=self._shutdown, description="Gracefully stop this daemon; " "refused while a stage is moving.") self.keyword_registry.bool("isreferenced", - getter=lambda: all(s.is_referenced() for s in self.stages), - setter=self._set_all_referenced, + getter=self.is_referenced, + setter=self.controller.home(), timeout_s=HOME_TIMEOUT_S, - description="Both ADC rotator stages are referenced; " - "write true to home both.") - self.keyword_registry.bool("ismoving", - getter=lambda: any(s.is_moving() for s in self.stages), - description="Either ADC rotator stage is moving.") - self.keyword_registry.string("positionnamed", - getter=self._current_named, - setter=self._goto_named, - validator=self._check_named, - description="ADC named position (e.g. crossed, " - "maxdisp); 'custom' when unmatched.") + description="OZOptic attenuator stage is referenced; " + "write true to home.") + self.keyword_registry.float(f"position", + getter=self.controller.get_pos, + setter=self.controller.set_pos, + units=self.units, + description="OZOptics stage position in steps.") + self.keyword_registry.float(f"attenuation", + getter=self.controller.get_attenuation(), + setter=self.controller.set_attenuation, + units=self.atten_units, + description="OZOptics attenuation in dB.") + self.keyword_registry.string("configuration", + getter=self.controller.get_params, + description="OZOptics attenuator configuration parameters.") if not (self.ip_address and self.tcp_port): self.logger.error("No IP address or port specified") @@ -186,15 +87,8 @@ class OZDaemon(HispecDriverDaemon): # pylint: disable=W0223 except Exception as e: # pylint: disable=W0718 self.logger.error("Error disconnecting: %s", e) - def _build_stages(self) -> List[_Stage]: - stages_cfg = self._config.get("stages") - if stages_cfg: - return [_Stage(self, s) for s in stages_cfg] - # Fall back to the two-stage ADC default if not enumerated in config. - return [_Stage(self, {"stage_id": n, "units": "deg"}) for n in (1, 2)] - def _connect_hardware(self): - self.logger.info("Connecting to ADC controller at %s:%s", + self.logger.info("Connecting to OZOptics controller at %s:%s", self.ip_address, self.tcp_port) self.controller.connect(host=self.ip_address, port=int(self.tcp_port)) # The drivers signal failure by staying disconnected rather than raising @@ -209,70 +103,14 @@ class OZDaemon(HispecDriverDaemon): # pylint: disable=W0223 else: self.controller.disconnect() - def _error_string(self) -> str: - """Combine the last-error strings from both stages.""" - try: - parts = [f"{s.stage_id}: {self.controller.get_last_error(s.stage_id)}" - for s in self.stages] - return " | ".join(parts) - except Exception as e: # pylint: disable=W0718 - return f"error read failed: {e}" - - def _halt_all(self) -> None: - for stage in self.stages: - stage.stop() - - def _shutdown(self) -> None: - """Stop the daemon, refusing while a stage is still moving.""" - try: - moving = any(s.is_moving() for s in self.stages) - except Exception: # pylint: disable=W0718 - # An unreachable controller must not leave the daemon unstoppable - moving = False - if moving: - raise RuntimeError("a stage is moving; halt before shutting down") - self.logger.info("Shutting down on keyword request") - self.request_stop() - - def _set_all_referenced(self, value: bool) -> None: - """Setter for the consolidated isreferenced: home every stage.""" - for stage in self.stages: - stage._set_referenced(value) # pylint: disable=protected-access - - 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 _current_named(self) -> str: - """Return the named position both stages currently match, else 'custom'.""" - positions = [s.get_position() for s in self.stages] - if any(p is None for p in positions): - return "custom" - for name, targets in self.named_positions.items(): - if len(targets) == len(positions) and all( - abs(p - float(t)) <= self.position_tolerance - for p, t in zip(positions, targets)): - return name - return "custom" - - def _goto_named(self, name: str) -> None: - """Move both stages to the angles defined by the given named position.""" - targets = self.named_positions[name] - if len(targets) != len(self.stages): - raise RuntimeError( - f"named position '{name}' has {len(targets)} values, " - f"expected {len(self.stages)}") - for stage, target in zip(self.stages, targets): - stage._set_position(float(target)) # pylint: disable=W0212 + def is_referenced(self) -> bool: + """Return True if the stage has been referenced (homed).""" + return bool(self.controller.is_homed()) def main(): """Main entry point for the daemon.""" - parser = argparse.ArgumentParser(description='HSFEI ADC Rotator Daemon') + parser = argparse.ArgumentParser(description='OZOptics Attenuator Daemon') parser.add_argument('-c', '--config', type=str, help='Path to config file (YAML or JSON)') diff --git a/systemd/instances/hscal_yjetatten.env b/systemd/instances/hscal_yjetatten.env new file mode 100644 index 0000000..8d04d12 --- /dev/null +++ b/systemd/instances/hscal_yjetatten.env @@ -0,0 +1,9 @@ +# HISPEC BLUE CAL Etalon Attenuator — OZOptics DD100MC controller instance (hscal) +# +# hispec deploy hscal_yjetatten +# +# copies this file to /etc/hispec/instances/hscal_yjetatten.env and +# config/hscal/hscal_yjetatten.yaml to /etc/hispec/hscal_yjetatten.yaml, then +# enables and starts the daemon. +HISPEC_DAEMON=hscal/ozopt_attenuator +HISPEC_HOST=hispecserver From b71c8d062bb1d50fd0878e98b22fee2dce1b9ede Mon Sep 17 00:00:00 2001 From: Don Neill Date: Mon, 9 Nov 2026 13:43:26 -0800 Subject: [PATCH 3/3] Bug fix for attenuation keyword --- daemons/hscal/ozopt_attenuator | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/daemons/hscal/ozopt_attenuator b/daemons/hscal/ozopt_attenuator index bbd03be..f975549 100644 --- a/daemons/hscal/ozopt_attenuator +++ b/daemons/hscal/ozopt_attenuator @@ -59,7 +59,7 @@ class OZDaemon(HispecDriverDaemon): # pylint: disable=W0223 units=self.units, description="OZOptics stage position in steps.") self.keyword_registry.float(f"attenuation", - getter=self.controller.get_attenuation(), + getter=self.controller.get_attenuation, setter=self.controller.set_attenuation, units=self.atten_units, description="OZOptics attenuation in dB.")