From 127e0b526374c269e575cd98407663c23423dc4f Mon Sep 17 00:00:00 2001 From: Prakriti Gupta Date: Fri, 12 Dec 2025 13:18:33 -0800 Subject: [PATCH 1/4] Add Hispec Daemon template. Update structure & toml build --- hispec/util/gammavac | 1 - hispec/util/inficon | 1 - hispec/util/lakeshore | 1 - hispec/util/newport | 1 - hispec/util/onewire | 1 - hispec/util/ozoptics | 1 - hispec/util/pi | 1 - hispec/util/srs | 1 - hispec/util/standa | 1 - hispec/util/sunpower | 1 - hispec/util/thorlabs | 1 - hispec/util/xeryon | 1 - pyproject.toml | 9 +- {hispec => src/hispec}/__init__.py | 0 src/hispec/daemon.py | 223 ++ {hispec => src/hispec}/util/__init__.py | 0 .../util/config/pi_named_positions.json | 0 .../util/config/xeryon_default_settings.txt | 0 src/hispec/util/gammavac/.gitignore | 207 ++ src/hispec/util/gammavac/LICENSE | 674 ++++++ src/hispec/util/gammavac/README.md | 55 + src/hispec/util/gammavac/SPCe.c | 2150 +++++++++++++++++ src/hispec/util/gammavac/SPCe.h | 248 ++ src/hispec/util/gammavac/SPCe.py | 503 ++++ .../hispec/util/gammavac}/__init__.py | 0 src/hispec/util/gammavac/pyproject.toml | 22 + src/hispec/util/gammavac/tests/test_basic.py | 14 + src/hispec/util/helper/__init__.py | 0 .../hispec}/util/helper/logger_utils.py | 0 src/hispec/util/inficon/.gitignore | 165 ++ src/hispec/util/inficon/README.md | 44 + src/hispec/util/inficon/__init__.py | 0 src/hispec/util/inficon/inficonvgc502.py | 336 +++ src/hispec/util/inficon/pyproject.toml | 25 + src/hispec/util/inficon/tests/__init__.py | 0 .../util/inficon/tests/test_inficonvgc502.py | 53 + src/hispec/util/lakeshore/.gitignore | 207 ++ src/hispec/util/lakeshore/README.md | 55 + src/hispec/util/lakeshore/__init__.py | 0 src/hispec/util/lakeshore/lakeshore.py | 424 ++++ src/hispec/util/lakeshore/pyproject.toml | 22 + .../lakeshore/tests/test_lakeshore_basic.py | 19 + src/hispec/util/newport/.gitignore | 171 ++ src/hispec/util/newport/LICENSE | 22 + src/hispec/util/newport/README.md | 45 + src/hispec/util/newport/pyproject.toml | 50 + src/hispec/util/newport/smc100pp.py | 878 +++++++ src/hispec/util/newport/tests/test_basic.py | 15 + src/hispec/util/onewire/.gitignore | 207 ++ src/hispec/util/onewire/README.md | 29 + src/hispec/util/onewire/__init__.py | 4 + src/hispec/util/onewire/onewire.py | 371 +++ src/hispec/util/onewire/pyproject.toml | 22 + .../util/onewire/scripts/influxdb_log.json | 29 + .../util/onewire/scripts/influxdb_log.py | 108 + .../util/onewire/tests/test_onewire_basic.py | 16 + src/hispec/util/ozoptics/.gitignore | 171 ++ src/hispec/util/ozoptics/LICENSE | 22 + src/hispec/util/ozoptics/README.md | 48 + src/hispec/util/ozoptics/dd100mc.py | 621 +++++ src/hispec/util/ozoptics/pyproject.toml | 48 + src/hispec/util/ozoptics/tests/test_basic.py | 13 + src/hispec/util/pi/.gitignore | 171 ++ src/hispec/util/pi/LICENSE | 22 + src/hispec/util/pi/README.md | 99 + src/hispec/util/pi/__init__.py | 4 + src/hispec/util/pi/pi_controller.py | 355 +++ src/hispec/util/pi/pyproject.toml | 50 + src/hispec/util/pi/tests/__init__.py | 0 src/hispec/util/pi/tests/test_pi_basic.py | 18 + src/hispec/util/pi/tests/test_pi_mock.py | 207 ++ src/hispec/util/srs/README.md | 89 + src/hispec/util/srs/__init__.py | 4 + src/hispec/util/srs/ptc10.py | 207 ++ src/hispec/util/srs/pyproject.toml | 23 + src/hispec/util/srs/tests/__init__.py | 0 src/hispec/util/srs/tests/test_ptc10.py | 16 + src/hispec/util/standa/.gitignore | 207 ++ src/hispec/util/standa/README.md | 70 + src/hispec/util/standa/__init__.py | 3 + src/hispec/util/standa/pyproject.toml | 43 + src/hispec/util/standa/smc8.py | 379 +++ .../util/standa/tests/default_smc8_test.py | 79 + .../util/standa/tests/mock_smc8_test.py | 87 + .../util/standa/tests/physical_smc8_test.py | 152 ++ src/hispec/util/sunpower/.gitignore | 15 + src/hispec/util/sunpower/README.md | 66 + src/hispec/util/sunpower/__init__.py | 6 + src/hispec/util/sunpower/pyproject.toml | 39 + .../util/sunpower/sunpower_cryocooler.py | 215 ++ src/hispec/util/sunpower/tests/__init__.py | 0 .../util/sunpower/tests/test_sunpower.py | 76 + src/hispec/util/thorlabs/.gitignore | 165 ++ src/hispec/util/thorlabs/README.md | 89 + src/hispec/util/thorlabs/__init__.py | 4 + src/hispec/util/thorlabs/fw102c.py | 333 +++ src/hispec/util/thorlabs/ppc102.py | 1662 +++++++++++++ src/hispec/util/thorlabs/pyproject.toml | 43 + .../thorlabs/tests/default_fw102c_test.py | 90 + .../thorlabs/tests/default_ppc102_test.py | 160 ++ .../util/thorlabs/tests/mock_fw102c_test.py | 46 + .../util/thorlabs/tests/mock_ppc102_test.py | 60 + .../thorlabs/tests/physical_fw102c_test.py | 104 + .../thorlabs/tests/physical_ppc102_test.py | 149 ++ src/hispec/util/xeryon/.gitignore | 171 ++ src/hispec/util/xeryon/LICENSE | 22 + src/hispec/util/xeryon/README.md | 106 + src/hispec/util/xeryon/__init__.py | 11 + src/hispec/util/xeryon/axis.py | 807 +++++++ src/hispec/util/xeryon/communication.py | 229 ++ src/hispec/util/xeryon/config.py | 38 + .../xeryon/config/xeryon_default_settings.txt | 13 + src/hispec/util/xeryon/pyproject.toml | 48 + src/hispec/util/xeryon/stage.py | 228 ++ src/hispec/util/xeryon/tests/__init__.py | 0 .../util/xeryon/tests/test_xeryon_axis.py | 77 + .../xeryon/tests/test_xeryon_communication.py | 108 + .../xeryon/tests/test_xeryon_controller.py | 240 ++ .../util/xeryon/tests/test_xeryon_utils.py | 16 + src/hispec/util/xeryon/units.py | 35 + src/hispec/util/xeryon/utils.py | 24 + src/hispec/util/xeryon/xeryon_controller.py | 334 +++ 122 files changed, 16456 insertions(+), 15 deletions(-) delete mode 160000 hispec/util/gammavac delete mode 160000 hispec/util/inficon delete mode 160000 hispec/util/lakeshore delete mode 160000 hispec/util/newport delete mode 160000 hispec/util/onewire delete mode 160000 hispec/util/ozoptics delete mode 160000 hispec/util/pi delete mode 160000 hispec/util/srs delete mode 160000 hispec/util/standa delete mode 160000 hispec/util/sunpower delete mode 160000 hispec/util/thorlabs delete mode 160000 hispec/util/xeryon rename {hispec => src/hispec}/__init__.py (100%) create mode 100644 src/hispec/daemon.py rename {hispec => src/hispec}/util/__init__.py (100%) rename {hispec => src/hispec}/util/config/pi_named_positions.json (100%) rename {hispec => src/hispec}/util/config/xeryon_default_settings.txt (100%) create mode 100644 src/hispec/util/gammavac/.gitignore create mode 100644 src/hispec/util/gammavac/LICENSE create mode 100644 src/hispec/util/gammavac/README.md create mode 100644 src/hispec/util/gammavac/SPCe.c create mode 100644 src/hispec/util/gammavac/SPCe.h create mode 100644 src/hispec/util/gammavac/SPCe.py rename {hispec/util/helper => src/hispec/util/gammavac}/__init__.py (100%) create mode 100644 src/hispec/util/gammavac/pyproject.toml create mode 100644 src/hispec/util/gammavac/tests/test_basic.py create mode 100644 src/hispec/util/helper/__init__.py rename {hispec => src/hispec}/util/helper/logger_utils.py (100%) create mode 100644 src/hispec/util/inficon/.gitignore create mode 100644 src/hispec/util/inficon/README.md create mode 100644 src/hispec/util/inficon/__init__.py create mode 100644 src/hispec/util/inficon/inficonvgc502.py create mode 100644 src/hispec/util/inficon/pyproject.toml create mode 100644 src/hispec/util/inficon/tests/__init__.py create mode 100644 src/hispec/util/inficon/tests/test_inficonvgc502.py create mode 100644 src/hispec/util/lakeshore/.gitignore create mode 100644 src/hispec/util/lakeshore/README.md create mode 100644 src/hispec/util/lakeshore/__init__.py create mode 100755 src/hispec/util/lakeshore/lakeshore.py create mode 100644 src/hispec/util/lakeshore/pyproject.toml create mode 100644 src/hispec/util/lakeshore/tests/test_lakeshore_basic.py create mode 100644 src/hispec/util/newport/.gitignore create mode 100644 src/hispec/util/newport/LICENSE create mode 100644 src/hispec/util/newport/README.md create mode 100644 src/hispec/util/newport/pyproject.toml create mode 100644 src/hispec/util/newport/smc100pp.py create mode 100644 src/hispec/util/newport/tests/test_basic.py create mode 100644 src/hispec/util/onewire/.gitignore create mode 100644 src/hispec/util/onewire/README.md create mode 100644 src/hispec/util/onewire/__init__.py create mode 100644 src/hispec/util/onewire/onewire.py create mode 100644 src/hispec/util/onewire/pyproject.toml create mode 100644 src/hispec/util/onewire/scripts/influxdb_log.json create mode 100644 src/hispec/util/onewire/scripts/influxdb_log.py create mode 100644 src/hispec/util/onewire/tests/test_onewire_basic.py create mode 100644 src/hispec/util/ozoptics/.gitignore create mode 100644 src/hispec/util/ozoptics/LICENSE create mode 100644 src/hispec/util/ozoptics/README.md create mode 100644 src/hispec/util/ozoptics/dd100mc.py create mode 100644 src/hispec/util/ozoptics/pyproject.toml create mode 100644 src/hispec/util/ozoptics/tests/test_basic.py create mode 100644 src/hispec/util/pi/.gitignore create mode 100644 src/hispec/util/pi/LICENSE create mode 100644 src/hispec/util/pi/README.md create mode 100644 src/hispec/util/pi/__init__.py create mode 100644 src/hispec/util/pi/pi_controller.py create mode 100644 src/hispec/util/pi/pyproject.toml create mode 100644 src/hispec/util/pi/tests/__init__.py create mode 100644 src/hispec/util/pi/tests/test_pi_basic.py create mode 100644 src/hispec/util/pi/tests/test_pi_mock.py create mode 100644 src/hispec/util/srs/README.md create mode 100644 src/hispec/util/srs/__init__.py create mode 100644 src/hispec/util/srs/ptc10.py create mode 100644 src/hispec/util/srs/pyproject.toml create mode 100644 src/hispec/util/srs/tests/__init__.py create mode 100644 src/hispec/util/srs/tests/test_ptc10.py create mode 100644 src/hispec/util/standa/.gitignore create mode 100644 src/hispec/util/standa/README.md create mode 100644 src/hispec/util/standa/__init__.py create mode 100644 src/hispec/util/standa/pyproject.toml create mode 100644 src/hispec/util/standa/smc8.py create mode 100644 src/hispec/util/standa/tests/default_smc8_test.py create mode 100644 src/hispec/util/standa/tests/mock_smc8_test.py create mode 100644 src/hispec/util/standa/tests/physical_smc8_test.py create mode 100644 src/hispec/util/sunpower/.gitignore create mode 100644 src/hispec/util/sunpower/README.md create mode 100644 src/hispec/util/sunpower/__init__.py create mode 100644 src/hispec/util/sunpower/pyproject.toml create mode 100644 src/hispec/util/sunpower/sunpower_cryocooler.py create mode 100644 src/hispec/util/sunpower/tests/__init__.py create mode 100644 src/hispec/util/sunpower/tests/test_sunpower.py create mode 100644 src/hispec/util/thorlabs/.gitignore create mode 100644 src/hispec/util/thorlabs/README.md create mode 100644 src/hispec/util/thorlabs/__init__.py create mode 100755 src/hispec/util/thorlabs/fw102c.py create mode 100644 src/hispec/util/thorlabs/ppc102.py create mode 100644 src/hispec/util/thorlabs/pyproject.toml create mode 100644 src/hispec/util/thorlabs/tests/default_fw102c_test.py create mode 100644 src/hispec/util/thorlabs/tests/default_ppc102_test.py create mode 100644 src/hispec/util/thorlabs/tests/mock_fw102c_test.py create mode 100644 src/hispec/util/thorlabs/tests/mock_ppc102_test.py create mode 100644 src/hispec/util/thorlabs/tests/physical_fw102c_test.py create mode 100644 src/hispec/util/thorlabs/tests/physical_ppc102_test.py create mode 100644 src/hispec/util/xeryon/.gitignore create mode 100644 src/hispec/util/xeryon/LICENSE create mode 100644 src/hispec/util/xeryon/README.md create mode 100644 src/hispec/util/xeryon/__init__.py create mode 100644 src/hispec/util/xeryon/axis.py create mode 100644 src/hispec/util/xeryon/communication.py create mode 100644 src/hispec/util/xeryon/config.py create mode 100644 src/hispec/util/xeryon/config/xeryon_default_settings.txt create mode 100644 src/hispec/util/xeryon/pyproject.toml create mode 100644 src/hispec/util/xeryon/stage.py create mode 100644 src/hispec/util/xeryon/tests/__init__.py create mode 100644 src/hispec/util/xeryon/tests/test_xeryon_axis.py create mode 100644 src/hispec/util/xeryon/tests/test_xeryon_communication.py create mode 100644 src/hispec/util/xeryon/tests/test_xeryon_controller.py create mode 100644 src/hispec/util/xeryon/tests/test_xeryon_utils.py create mode 100644 src/hispec/util/xeryon/units.py create mode 100644 src/hispec/util/xeryon/utils.py create mode 100644 src/hispec/util/xeryon/xeryon_controller.py diff --git a/hispec/util/gammavac b/hispec/util/gammavac deleted file mode 160000 index add84ce..0000000 --- a/hispec/util/gammavac +++ /dev/null @@ -1 +0,0 @@ -Subproject commit add84ce3a7139b1627cadcf270769941cbe2648d diff --git a/hispec/util/inficon b/hispec/util/inficon deleted file mode 160000 index 204da34..0000000 --- a/hispec/util/inficon +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 204da34c58d1096ce637a9f9f2af6d171bd861ad diff --git a/hispec/util/lakeshore b/hispec/util/lakeshore deleted file mode 160000 index 4f234e4..0000000 --- a/hispec/util/lakeshore +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 4f234e4bdd64ffd94b6558709adfacde86533da5 diff --git a/hispec/util/newport b/hispec/util/newport deleted file mode 160000 index 32c371a..0000000 --- a/hispec/util/newport +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 32c371af331fa20753b45fade7b5aba98c28522d diff --git a/hispec/util/onewire b/hispec/util/onewire deleted file mode 160000 index f07915a..0000000 --- a/hispec/util/onewire +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f07915a1420c8083e8fd51df81746bfdf6106265 diff --git a/hispec/util/ozoptics b/hispec/util/ozoptics deleted file mode 160000 index f6a5dd2..0000000 --- a/hispec/util/ozoptics +++ /dev/null @@ -1 +0,0 @@ -Subproject commit f6a5dd213be4a5bd47150c81e45e9a104f6c32ed diff --git a/hispec/util/pi b/hispec/util/pi deleted file mode 160000 index 3142e64..0000000 --- a/hispec/util/pi +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3142e6481d10ea99a8e326a8485639061dcbbb93 diff --git a/hispec/util/srs b/hispec/util/srs deleted file mode 160000 index 74068fb..0000000 --- a/hispec/util/srs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 74068fb2430f390bbe967c76da3eb6ea60cd06d6 diff --git a/hispec/util/standa b/hispec/util/standa deleted file mode 160000 index e0bf68b..0000000 --- a/hispec/util/standa +++ /dev/null @@ -1 +0,0 @@ -Subproject commit e0bf68b664958d9ab53bb6ed1829361c780df738 diff --git a/hispec/util/sunpower b/hispec/util/sunpower deleted file mode 160000 index a0d007e..0000000 --- a/hispec/util/sunpower +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a0d007e84cb1de8b82fbe903858c3726c2a904a5 diff --git a/hispec/util/thorlabs b/hispec/util/thorlabs deleted file mode 160000 index 6ab0638..0000000 --- a/hispec/util/thorlabs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 6ab06380dc1c0289028fd2f3e7ca461ae6771218 diff --git a/hispec/util/xeryon b/hispec/util/xeryon deleted file mode 160000 index 9d7ae37..0000000 --- a/hispec/util/xeryon +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9d7ae378c8ef684d8fb8e1d21829544606ddf6d0 diff --git a/pyproject.toml b/pyproject.toml index d59fb06..f346556 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,6 @@ -[tool.setuptools] -packages = ["hispec.util"] +[tool.setuptools.packages.find] +where = ["src"] +include = ["hispec", "hispec.*"] [build-system] requires = ["setuptools>=61.0"] @@ -20,5 +21,7 @@ dependencies = [ "keyring", "pipython", "pyserial", - "libximc" + "libximc", + "libby@git+https://github.com/CaltechOpticalObservatories/libby.git", + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base" ] diff --git a/hispec/__init__.py b/src/hispec/__init__.py similarity index 100% rename from hispec/__init__.py rename to src/hispec/__init__.py diff --git a/src/hispec/daemon.py b/src/hispec/daemon.py new file mode 100644 index 0000000..4fb9296 --- /dev/null +++ b/src/hispec/daemon.py @@ -0,0 +1,223 @@ +from __future__ import annotations +import json +from dataclasses import is_dataclass, asdict +import collections.abc as cabc +import signal, sys, threading, time +from typing import Any, Callable, Dict, List, Optional + +from libby import Libby + +Payload = Dict[str, Any] +RPCHandler = Callable[[Payload], Dict[str, Any]] +EvtHandler = Callable[[Payload], None] + +class HispecDaemon: + """ + Base daemon class for Libby peers with support for multiple transports. + + ZMQ Usage: + class MyPeer(HispecDaemon): + peer_id = "my-peer" + bind = "tcp://*:5555" + address_book = {"other-peer": "tcp://localhost:5556"} + + services = {"echo": lambda payload: {"echo": payload}} + topics = {"alerts": lambda payload: print(payload)} + + RabbitMQ Usage: + class MyPeer(HispecDaemon): + transport = "rabbitmq" + peer_id = "my-peer" + rabbitmq_url = "amqp://localhost" # optional, defaults to this + + services = {"echo": lambda payload: {"echo": payload}} + topics = {"alerts": lambda payload: print(payload)} + + Note: RabbitMQ doesn't need bind or address_book since routing is + handled automatically by the broker. + """ + # simple attributes users set + peer_id: Optional[str] = None + bind: Optional[str] = None + address_book: Optional[Dict[str, str]] = None + discovery_enabled: bool = True + discovery_interval_s: float = 5.0 + + # transport selection: "zmq" or "rabbitmq (default)" + transport: str = "rabbitmq" + rabbitmq_url: Optional[str] = None + group_id: Optional[str] = None + # internal config + _config: Dict[str, Any] = {} + + # payload-only handlers + services: Dict[str, RPCHandler] = {} + topics: Dict[str, EvtHandler] = {} + + def __init__(self) -> None: + # Ensure per-instance handler tables + if type(self).services is self.services: + self.services = {} + if type(self).topics is self.topics: + self.topics = {} + + # Config ingestion + @classmethod + def from_config_file(cls, path: str, *, env_prefix: str = "LIBBY_") -> "HispecDaemon": + """ + Build a daemon from a JSON or YAML file and then apply environment + overrides whose keys start with env_prefix (default: LIBBY_). + """ + pass + + @classmethod + def from_config(cls, cfg: Dict[str, Any]) -> "HispecDaemon": + """ + Build a daemon from a pre-loaded dict. Only the known public attributes + are mapped; anything else stays in _config for user code to read. + """ + pass + + # optional hooks + def on_start(self, libby: Libby) -> None: ... + def on_stop(self, libby: Optional[Libby] = None) -> None: ... + def on_hello(self, libby: Libby) -> None: ... + def on_event(self, topic: str, msg) -> None: + print(f"[{self.__class__.__name__}] {topic}: {msg.env.payload}") + + # config getters + def config_peer_id(self) -> str: return self.peer_id or self._must("peer_id") + def config_bind(self) -> str: return self.bind or self._must("bind") + def config_rabbitmq_url(self) -> str: return self.rabbitmq_url or "amqp://localhost" + def config_group_id(self) -> Optional[str]: return self.group_id + def config_address_book(self) -> Dict[str, str]: return self.address_book if self.address_book is not None else {} + def config_discovery_enabled(self) -> bool: return bool(self.discovery_enabled) + def config_discovery_interval_s(self) -> float: return float(self.discovery_interval_s) + def config_rpc_keys(self) -> List[str]: return list(self.services.keys()) + def config_subscriptions(self) -> List[str]: return list(self.topics.keys()) + + # user-facing helpers + def add_service(self, key: str, fn: RPCHandler) -> None: + self.services[key] = fn + if hasattr(self, "libby"): self._register_services({key: fn}) + + def add_services(self, mapping: Dict[str, RPCHandler]) -> None: + self.services.update(mapping) + if hasattr(self, "libby"): self._register_services(mapping) + + def add_topic(self, topic: str, fn: EvtHandler) -> None: + self.topics[topic] = fn + if hasattr(self, "libby"): + self.libby.listen(topic, lambda msg, _h=fn: _h(msg.env.payload)) + self.libby.subscribe(topic) + + def add_topics(self, mapping: Dict[str, EvtHandler]) -> None: + self.topics.update(mapping) + if hasattr(self, "libby"): + for topic, fn in mapping.items(): + self.libby.listen(topic, lambda msg, _h=fn: _h(msg.env.payload)) + self.libby.subscribe(*mapping.keys()) + + # internals + def _must(self, name: str): + raise NotImplementedError(f"Set `{name}` or override config_{name}()") + + def _service_adapter(self, fn): + def adapter(user_payload: dict, _ctx: dict) -> dict: + try: + result = fn(user_payload) # user returns ANYTHING + return self.payload(result) # we "shove it into payload" for them + except Exception as ex: + return {"ok": False, "error": str(ex)} + return adapter + + def _register_services(self, mapping: Dict[str, RPCHandler]) -> None: + for key, fn in mapping.items(): + self.libby.serve_keys([key], self._service_adapter(fn)) + + def build_libby(self) -> Libby: + """Build Libby instance with selected transport.""" + if self.transport == "rabbitmq": + return Libby.rabbitmq( + self_id=self.config_peer_id(), + rabbitmq_url=self.config_rabbitmq_url(), + keys=[], + callback=None, + group_id=self.config_group_id(), + ) + else: + # Default to ZMQ + return Libby.zmq( + self_id=self.config_peer_id(), + bind=self.config_bind(), + address_book=self.config_address_book(), + keys=[], callback=None, # register per-key + discover=self.config_discovery_enabled(), + discover_interval_s=self.config_discovery_interval_s(), + hello_on_start=True, + group_id=self.config_group_id(), + ) + + def serve(self) -> None: + stop_evt = threading.Event() + def _sig(_s, _f): stop_evt.set() + signal.signal(signal.SIGINT, _sig) + signal.signal(signal.SIGTERM, _sig) + + try: + self.libby = self.build_libby() + except Exception as ex: + print(f"[{self.__class__.__name__}] failed to start: {ex}", file=sys.stderr) + raise + + if self.services: + self._register_services(self.services) + if self.topics: + for topic, fn in self.topics.items(): + self.libby.listen(topic, lambda msg, _h=fn: _h(msg.env.payload)) + self.libby.subscribe(*self.topics.keys()) + + # discovery hello + hooks + try: + if self.config_discovery_enabled(): + self.libby.hello() + self.on_hello(self.libby) + except Exception: + pass + + try: + self.on_start(self.libby) + except Exception as ex: + print(f"[{self.__class__.__name__}] on_start error: {ex}", file=sys.stderr) + + if self.transport == "rabbitmq": + print(f"[{self.__class__.__name__}] up: id={self.config_peer_id()} transport=rabbitmq url={self.rabbitmq_url}") + else: + print(f"[{self.__class__.__name__}] up: id={self.config_peer_id()} bind={self.config_bind()}") + try: + while not stop_evt.is_set(): time.sleep(0.5) + finally: + try: self.on_stop() + except Exception: pass + self.libby.stop() + print(f"[{self.__class__.__name__}] stopped") + + def payload(self, value=None, /, **extra) -> dict: + if value is None: + out = {} + elif is_dataclass(value): + out = asdict(value) + elif isinstance(value, cabc.Mapping): + out = dict(value) + else: + out = {"data": value} + + if extra: + out.update(extra) + + try: + json.dumps(out) + except TypeError as e: + raise ValueError(f"Payload not JSON-serializable: {e}") from e + + return out \ No newline at end of file diff --git a/hispec/util/__init__.py b/src/hispec/util/__init__.py similarity index 100% rename from hispec/util/__init__.py rename to src/hispec/util/__init__.py diff --git a/hispec/util/config/pi_named_positions.json b/src/hispec/util/config/pi_named_positions.json similarity index 100% rename from hispec/util/config/pi_named_positions.json rename to src/hispec/util/config/pi_named_positions.json diff --git a/hispec/util/config/xeryon_default_settings.txt b/src/hispec/util/config/xeryon_default_settings.txt similarity index 100% rename from hispec/util/config/xeryon_default_settings.txt rename to src/hispec/util/config/xeryon_default_settings.txt diff --git a/src/hispec/util/gammavac/.gitignore b/src/hispec/util/gammavac/.gitignore new file mode 100644 index 0000000..b7faf40 --- /dev/null +++ b/src/hispec/util/gammavac/.gitignore @@ -0,0 +1,207 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/src/hispec/util/gammavac/LICENSE b/src/hispec/util/gammavac/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/src/hispec/util/gammavac/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/src/hispec/util/gammavac/README.md b/src/hispec/util/gammavac/README.md new file mode 100644 index 0000000..b92c384 --- /dev/null +++ b/src/hispec/util/gammavac/README.md @@ -0,0 +1,55 @@ +# gammavac_controller + +Low-level library for communicating with a Gamma Vacuum SPCe controller + +## Currently Supported Models +- SPCe - SPCe.py, SPCe.c, SPCe.h + +## Features +- Connect to Gamma Vacuum controllers over serial through a terminal server +- Query state and parameters +- Set individual parameters + +## Requirements + +- Install base class from https://github.com/COO-Utilities/hardware_device_base + +## Installation + +```bash +pip install . +``` + +## Usage + +```python +import SPCe + +controller = SPCe.SpceController(bus_address=5) +controller.connect(host='192.168.29.100', port=10015) + +# Print pressure +print(controller.read_pressure()) + +# Print pump size +print(controller.get_pump_size()) + +# Get voltage +controller.read_voltage() + +# Get Controller Version +controller.read_version() + +# For a comprehensive list of classes and methods, use the help function +help(SPCe) + +``` + +## 🧪 Testing +Unit tests are located in `tests/` directory. + +To run all tests from the project root: + +```bash +pytest +``` \ No newline at end of file diff --git a/src/hispec/util/gammavac/SPCe.c b/src/hispec/util/gammavac/SPCe.c new file mode 100644 index 0000000..5b68501 --- /dev/null +++ b/src/hispec/util/gammavac/SPCe.c @@ -0,0 +1,2150 @@ +/*+*************************************************************************** + + * File: SPCe.c + + * Purpose: The functions herein are specific to the Lesker + SPCe Gauge SPCe Controller (PGC). + + * Modification history: + * 2011/12/02 Stephen Kaye - Initial + * 2025/07/25 Don Neill - Modify for COO Utils + * + *-**************************************************************************/ +static char sccsid[] = "%W% %E% %U%"; + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include "SPCe.h" +#include "kprs.h" + +/* + * Global variables + */ + +extern int Simulate; + +pthread_mutex_t socket_mutex = PTHREAD_MUTEX_INITIALIZER; + +int bus_address=SPCE_BUS_ADDRESS; /* bus address, 1 for RS-232 */ + +/************************************************************************* + *+ + * Function name: spce_read_version + + * Description: Reads the software version and prints it to the log + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: Returns SPCe error code. + char *version + + * Modification History: + 2012/01/10 SK -- Initial. + 2014/02/04 DN adapted to SPCe controller + *- +*************************************************************************/ +int spce_read_version(char *port, char *version) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", + __FILE__, __LINE__); + + /* create command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_READ_VERSION, NULL, 1)) < 0) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_READ_VERSION)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting version string from response.", + __FILE__, __LINE__); + + /* extract pressure from string */ + if ( (err=getStringFromSpceResponse(response, version)) < 0 ) + return err; + + log_msg(SINFO, LOGLVL_USER1, "%s %d : SPCe Firmware version: %s", + __FILE__, __LINE__, version); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_read_version */ + + +/************************************************************************* + *+ + * Function name: spce_reset + + * Description: resets gamma pump + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: Returns SPCe error code. + + * Modification History: + 2012/01/10 SK -- Initial. + 2014/02/04 DN adapted to SPCe controller + *- +*************************************************************************/ +int spce_reset(char *port) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", + __FILE__, __LINE__); + + /* create command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_RESET, NULL, 1)) < 0) + return err; + + if ( (err=spce_send_command(port, command)) < 0 ) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_reset */ + + +/************************************************************************* + *+ + * Function name: spce_set_arc_detect + + * Description: sets arc detect based on value in YESNO + + * Inputs: + char *port -- socket port attached to pump + int yesno -- 0 - no, 1 - yes + + * Outputs: Returns SPCe error code. + + * Modification History: + 2012/01/10 SK -- Initial. + 2014/02/04 DN adapted to SPCe controller + *- +*************************************************************************/ +int spce_set_arc_detect(char *port, int yesno) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", + __FILE__, __LINE__); + + /* create command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_ARC_DETECT, + ((yesno == 1) ? "YES" : "NO"), 1)) < 0) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_ARC_DETECT)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_arc_detect */ + + +/************************************************************************* + *+ + * Function name: spce_get_arc_detect + + * Description: gets arc detect setting, puts it in yesno + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: Returns SPCe error code. + int *yesno -- 1 = "YES" or 0 = "NO" + + * Modification History: + 2012/01/10 SK -- Initial. + 2014/02/04 DN adapted to SPCe controller + *- +*************************************************************************/ +int spce_get_arc_detect(char *port, int *yesno) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char stryesno[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", + __FILE__, __LINE__); + + /* create command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_GET_ARC_DETECT, NULL, 1)) < 0) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_GET_ARC_DETECT)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting arc detect state from response.", + __FILE__, __LINE__); + + /* extract pressure from string */ + if ( (err=getStringFromSpceResponse(response, stryesno)) < 0 ) + return err; + + if ( strcmp(stryesno,"YES") == 0 ) + *yesno = 1; + else *yesno = 0; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_get_arc_detect */ + + +/************************************************************************* + *+ + * Function name: spce_read_current + + * Description: reads the current of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + float *outcurrent -- variable to hold retrieved current value + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_read_current(char *port, float *outcurrent) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_READ_CURRENT, NULL, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_READ_CURRENT)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting current from response.", + __FILE__, __LINE__); + + /* extract current from string */ + *outcurrent = getFloatFromSpceResponse(response); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_read_current */ + + +/************************************************************************* + *+ + * Function name: spce_read_pressure + + * Description: reads the pressure of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + float *outpressure -- variable to hold retrieved pressure value + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_read_pressure(char *port, float *outpressure) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_READ_PRESSURE, NULL, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_READ_PRESSURE)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting pressure from response.", + __FILE__, __LINE__); + + /* extract pressure from string */ + *outpressure = getFloatFromSpceResponse(response); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_read_pressure */ + + +/************************************************************************* + *+ + * Function name: spce_read_voltage + + * Description: reads the voltage of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + int *outvoltage -- variable to hold retrieved voltage value + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_read_voltage(char *port, int *outvoltage) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_READ_VOLTAGE, NULL, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_READ_VOLTAGE)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting voltage from response.", + __FILE__, __LINE__); + + /* extract voltage from string */ + *outvoltage = getIntFromSpceResponse(response); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_read_voltage */ + + +/************************************************************************* + *+ + * Function name: spce_set_units + + * Description: sets pressure units to Torr, Mbar, or Pascals + + * Inputs: + char *port -- socket port attached to pump + char *units-- "Torr", "Mbar", or "Pascals" (only checks first char) + + * Outputs: Returns SPCe error code. + + * Modification History: + 2012/01/10 SK -- Initial. + 2014/02/04 DN adapted to SPCe controller + *- +*************************************************************************/ +int spce_set_units(char *port, int units) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char spce_units[2]; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", + __FILE__, __LINE__); + + /* check input units */ + if (units == 'M' || units == 'm') { + spce_units[0] = SPCE_UNITS_MBAR; + } else if (units == 'P' || units == 'p') { + spce_units[0] = SPCE_UNITS_PASCAL; + } else spce_units[0] = SPCE_UNITS_TORR; + spce_units[1] = '\0'; + + /* create command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_PRESS_UNITS, spce_units, 1)) < 0) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_ARC_DETECT)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_units */ + + +/************************************************************************* + *+ + * Function name: spce_get_pump_size + + * Description: reads the pump size in L/s of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + int *outsize -- variable to hold retrieved pump size value + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_get_pump_size(char *port, int *outsize) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_GET_PUMP_SIZE, NULL, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_GET_PUMP_SIZE)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting pump size from response.", + __FILE__, __LINE__); + + /* extract pump size from string */ + *outsize = getIntFromSpceResponse(response); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_get_pump_size */ + + +/************************************************************************* + *+ + * Function name: spce_set_pump_size + + * Description: reads the pump size in L/s of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + int size -- pump size in L/s + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_set_pump_size(char *port, int size) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char strsize[5]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* create size string */ + if (size >= 0 && size <= 9999) { + sprintf(strsize,"%04d",size); + } else { + return SPCE_ERROR_VALUE_OUT_OF_RANGE; + } + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_PUMP_SIZE, strsize, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_PUMP_SIZE)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_pump_size */ + + +/************************************************************************* + *+ + * Function name: spce_get_cal_factor + + * Description: reads the calibration factor (0-9.99) of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + float *outcalfact -- variable to hold retrieved calibration factor + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_get_cal_factor(char *port, float *outcalfact) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_GET_CAL_FACTOR, NULL, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_GET_CAL_FACTOR)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting cal factor from response.", + __FILE__, __LINE__); + + /* extract cal factor from string */ + *outcalfact = getFloatFromSpceResponse(response); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_get_cal_factor */ + + +/************************************************************************* + *+ + * Function name: spce_set_cal_factor + + * Description: sets the gamma pump calibration factor (0-9.99). + + * Inputs: + char *port -- socket port attached to pump + float calfact -- calibration factor (0.00 - 9.99) + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_set_cal_factor(char *port, float calfact) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char strcalfact[5]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* create size string */ + if (calfact >= 0.00 && calfact <= 9.99) { + sprintf(strcalfact,"%4.2f",calfact); + } else { + return SPCE_ERROR_VALUE_OUT_OF_RANGE; + } + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_CAL_FACTOR, strcalfact, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_CAL_FACTOR)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_cal_factor */ + + +/************************************************************************* + *+ + * Function name: spce_set_auto_restart + + * Description: sets auto restart for the gamma pump to yes or no. + + * Inputs: + char *port -- socket port attached to pump + int yesno -- 0 - no, 1 - yes + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_set_auto_restart(char *port, int yesno) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char stryesno[4]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* create set string */ + if (yesno == 1) { + sprintf(stryesno,"YES"); + } else { + sprintf(stryesno,"NO"); + } + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_AUTO_RESTART, stryesno, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_AUTO_RESTART)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_auto_restart */ + + +/************************************************************************* + *+ + * Function name: spce_get_auto_restart + + * Description: gets the auto restart setting for the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: Returns SPCe error code. + int *yesno -- 1 = "YES" or 0 = "NO" + + * Modification History: + 2012/01/10 SK -- Initial. + 2014/02/04 DN adapted to SPCe controller + *- +*************************************************************************/ +int spce_get_auto_restart(char *port, int *yesno) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char stryesno[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", + __FILE__, __LINE__); + + /* create command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_GET_AUTO_RESTART, NULL, 1)) < 0) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_GET_AUTO_RESTART)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting auto restart state from response.", + __FILE__, __LINE__); + + /* extract status from string */ + if ( (err=getStringFromSpceResponse(response, stryesno)) < 0 ) + return err; + + if ( strcmp(stryesno,"YES") == 0 ) + *yesno = 1; + else *yesno = 0; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_get_auto_restart */ + + +/************************************************************************* + *+ + * Function name: spce_pump_start + + * Description: starts the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_pump_start(char *port) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_START_PUMP, NULL, 1)) < 0 ) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_START_PUMP)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_pump_start */ + + +/************************************************************************* + *+ + * Function name: spce_pump_stop + + * Description: stops the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_pump_stop(char *port) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_STOP_PUMP, NULL, 1)) < 0 ) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_STOP_PUMP)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_pump_stop */ + + +/************************************************************************* + *+ + * Function name: spce_lock_keypad + + * Description: Locks/Unlocks the keypad according to value in LOCK + + * Inputs: + char *port -- socket port attached to pump + int lock -- 0 - unlock, 1 - lock + + * Outputs: Returns SPCe error code. + + * Modification History: + 2012/01/10 SK -- Initial. + 2014/02/04 DN adapted to SPCe controller + *- +*************************************************************************/ +int spce_lock_keypad(char *port, int lock) { + + int err=0; + int command_code = ((lock == 1) ? SPCE_COMMAND_LOCK_KEYPAD : + SPCE_COMMAND_UNLOCK_KEYPAD); + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", + __FILE__, __LINE__); + + /* create command string */ + if ( (err=spce_create_command_string(command, bus_address, + command_code, NULL, 1)) < 0) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, command_code)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_lock_keypad */ + + +/************************************************************************* + *+ + * Function name: spce_get_analog_mode + + * Description: reads the analog mode of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + int *outmode -- variable to hold retrieved analog mode value + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_get_analog_mode(char *port, int *outmode) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_GET_ANALOG_MODE, NULL, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_GET_ANALOG_MODE)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting analog mode from response.", + __FILE__, __LINE__); + + /* extract analog mode from string */ + *outmode = getIntFromSpceResponse(response); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_get_analog_mode */ + + +/************************************************************************* + *+ + * Function name: spce_set_analog_mode + + * Description: sets the gamma pump analog_mode (0-6,8-10) + + * Inputs: + char *port -- socket port attached to pump + int mode -- analog mode value (0-6,8-10) + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_set_analog_mode(char *port, int mode) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char strmode[3]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* create mode string */ + if ( mode >= 0 && mode <= 10 && mode != 7 ) { + sprintf(strmode,"%d",mode); + } else { + return SPCE_ERROR_VALUE_OUT_OF_RANGE; + } + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_ANALOG_MODE, strmode, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_ANALOG_MODE)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_analog_mode */ + + +/************************************************************************* + *+ + * Function name: spce_high_voltage_on + + * Description: gets the high voltage setting for the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: Returns SPCe error code. + int *yesno -- 1 = "YES" or 0 = "NO" + + * Modification History: + 2012/01/10 SK -- Initial. + 2014/02/04 DN adapted to SPCe controller + *- +*************************************************************************/ +int spce_high_voltage_on(char *port, int *yesno) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char stryesno[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", + __FILE__, __LINE__); + + /* create command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_IS_HIGH_VOLTAGE_ON, NULL, 1)) < 0) + return err; + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_IS_HIGH_VOLTAGE_ON)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting high voltage state from response.", + __FILE__, __LINE__); + + /* extract status from string */ + if ( (err=getStringFromSpceResponse(response, stryesno)) < 0 ) + return err; + + if ( strcmp(stryesno,"YES") == 0 ) + *yesno = 1; + else *yesno = 0; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_high_voltage_on */ + + +/************************************************************************* + *+ + * Function name: spce_set_hv_autorecovery + + * Description: sets the gamma pump HV autorecovery mode (0-2) + + * Inputs: + char *port -- socket port attached to pump + int mode -- HV autorecovery mode value (0-3): + 0 - disabled + 1 - enable auto HV start + 2 - enable auto power start (no HV) + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_set_hv_autorecovery(char *port, int mode) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char strmode[3]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* create mode string */ + if ( mode >= 0 && mode <= 2 ) { + sprintf(strmode,"%d",mode); + } else { + return SPCE_ERROR_VALUE_OUT_OF_RANGE; + } + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_HV_AUTORECOVERY, strmode, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_HV_AUTORECOVERY)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_hv_autorecovery */ + + +/************************************************************************* + *+ + * Function name: spce_get_hv_autorecovery + + * Description: reads the HV autorecovery mode of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + int *outmode -- variable to hold retrieved HV auto recovery mode value + (see spce_set_hv_autorecovery) + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_get_hv_autorecovery(char *port, int *outmode) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_GET_HV_AUTORECOVERY, NULL, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_GET_HV_AUTORECOVERY)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting HV autorecovery mode from response.", + __FILE__, __LINE__); + + /* extract HV autorecovery mode from string */ + *outmode = getIntFromSpceResponse(response); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_get_hv_autorecovery */ + + +/************************************************************************* + *+ + * Function name: spce_set_comm_mode + + * Description: sets the gamma pump comm mode (0-2) + + * Inputs: + char *port -- socket port attached to pump + int mode -- comm mode value (0-3): + 0 - Local + 1 - Remote + 2 - Full + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_set_comm_mode(char *port, int mode) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char strmode[3]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* create mode string */ + if ( mode >= 0 && mode <= 2 ) { + sprintf(strmode,"%d",mode); + } else { + return SPCE_ERROR_VALUE_OUT_OF_RANGE; + } + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_COMM_MODE, strmode, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_COMM_MODE)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_comm_mode */ + + +/************************************************************************* + *+ + * Function name: spce_get_comm_mode + + * Description: reads the comm mode of the gamma pump. + + * Inputs: + char *port -- socket port attached to pump + + * Outputs: + int *outmode -- variable to hold retrieved commy mode value + (see spce_set_comm_mode) + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_get_comm_mode(char *port, int *outmode) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_GET_COMM_MODE, NULL, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_GET_COMM_MODE)) < 0) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: extracting comm mode from response.", + __FILE__, __LINE__); + + /* extract comm mode from string */ + *outmode = getIntFromSpceResponse(response); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_get_comm_mode */ + + +/************************************************************************* + *+ + * Function name: spce_set_comm_interface + + * Description: sets the gamma pump comm interface (0-2) + + * Inputs: + char *port -- socket port attached to pump + int interface -- comm interface value (0-3): + 0 - RS232 + 1 - RS422 + 2 - RS485 + 3 - RS485 (full duplex) + 4 - Ethernet + 5 - USB + + * Outputs: + + * Returns: 0 or gamma error code. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/04 DN Modified for SPCe controller + *- +*************************************************************************/ +int spce_set_comm_interface(char *port, int interface) { + + int err=0; + char command[MAX_COMMAND_LENGTH]; + char response[MAX_RESPONSE_LENGTH]; + char strinterface[3]; + + log_msg(SINFO, LOGLVL_USER3, + "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); + + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string.", __FILE__, __LINE__); + + /* create interface string */ + if ( interface >= 0 && interface <= 5 ) { + sprintf(strinterface,"%d",interface); + } else { + return SPCE_ERROR_VALUE_OUT_OF_RANGE; + } + + /* generate command string */ + if ( (err=spce_create_command_string(command, bus_address, + SPCE_COMMAND_SET_COMM_INTERFACE, strinterface, 1)) < 0 ) + return err; + + + if ( (err=spce_send_request(port, command, response)) < 0 ) + return err; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", + __FILE__, __LINE__); + + /* check for errors */ + if ( (err=spce_validate_response(response, + SPCE_COMMAND_SET_COMM_INTERFACE)) < 0) + return err; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_set_comm_interface */ + + +/************************************************************************* + *+ + * Function name: spce_send_command + + * Description: send a command to the socket port + + * Inputs: + char *port -- socket port + int cmd -- command code (see kprs_gamma.h) + + * Returns: 0 or gamma error code. + + * Modification History: + 2014/02/04 DN Initial + *- +*************************************************************************/ +int spce_send_command(char *port, char *cmd) { + + int err=0; + int cerr=0; + int socket_fd; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + if ( !Simulate ) { + + /* lock socket port */ + pthread_mutex_lock(&socket_mutex); + + /* open port */ + log_msg(SINFO, LOGLVL_USER4, + "%s %d, %s: Calling setupSocketInterface", + __FILE__, __LINE__, __FUNCTION__); + + if ( (socket_fd=setupSocketInterface(port, 0)) < 0) { + pthread_mutex_unlock(&socket_mutex); + return SPCE_ERROR_OPEN_PORT; + } + log_msg(SINFO, LOGLVL_USER4, + "%s %d, %s: setupSocketInterface=%d, success", + __FILE__, __LINE__, __FUNCTION__, socket_fd); + + /* write command to port */ + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Writing command.", + __FILE__, __LINE__); + + if ( (err=socketport_write(socket_fd, cmd, strlen(cmd))) < 0) { + if ((cerr = socketport_close(socket_fd)) < 0) { + err = SPCE_ERROR_CLOSE_PORT; + errlog(SERROR, "%s %d: %s", __FILE__, __LINE__, + SpceErrMsg[SPCE_ERROR_CODE0-err]); + } + pthread_mutex_unlock(&socket_mutex); + return SPCE_ERROR_WRITE_COMMAND; + } + log_msg(SINFO, LOGLVL_USER4, + "%s %d, %s: socketport_write ret=%d, success", + __FILE__, __LINE__, __FUNCTION__, err); + + /* close port */ + if ((err=socketport_close(socket_fd)) < 0) + err=SPCE_ERROR_CLOSE_PORT; + + /* unlock socket port */ + pthread_mutex_unlock(&socket_mutex); + + } /* end if ( !Simulate ) */ + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_send_command */ + + +/************************************************************************* + *+ + * Function name: spce_send_request + + * Description: send a command to the socket port and return the response + + * Inputs: + char *port -- socket port + int cmd -- command code (see kprs_gamma.h) + char *response -- response to command + + * Returns: 0 or gamma error code. + + * Modification History: + 2014/02/04 DN Initial + *- +*************************************************************************/ +int spce_send_request(char *port, char *cmd, char *response) { + + int err=0; + int cerr=0; + int charsread=0; + int socket_fd; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + if ( !Simulate ) { + + /* lock socket port */ + pthread_mutex_lock(&socket_mutex); + + /* open port */ + log_msg(SINFO, LOGLVL_USER4, + "%s %d, %s: Calling setupSocketInterface", + __FILE__, __LINE__, __FUNCTION__); + + if ( (socket_fd=setupSocketInterface(port, 0)) < 0) { + pthread_mutex_unlock(&socket_mutex); + return SPCE_ERROR_OPEN_PORT; + } + log_msg(SINFO, LOGLVL_USER4, + "%s %d, %s: setupSocketInterface=%d, success", + __FILE__, __LINE__, __FUNCTION__, socket_fd); + + /* write command to port */ + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Writing command.", + __FILE__, __LINE__); + + if ( (err=socketport_write(socket_fd, cmd, strlen(cmd))) < 0) { + if ((cerr = socketport_close(socket_fd)) < 0) { + err = SPCE_ERROR_CLOSE_PORT; + errlog(SERROR, "%s %d: %s", __FILE__, __LINE__, + SpceErrMsg[SPCE_ERROR_CODE0-err]); + } + pthread_mutex_unlock(&socket_mutex); + return SPCE_ERROR_WRITE_COMMAND; + } + log_msg(SINFO, LOGLVL_USER4, + "%s %d, %s: socketport_write ret=%d, success", + __FILE__, __LINE__, __FUNCTION__, err); + + /* wait for response */ + usleep(SPCE_TIME_BETWEEN_COMMANDS); + + /* read response */ + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Reading response.", + __FILE__, __LINE__); + + memset( response, (char)'\0', sizeof(response) ); + + if ( (charsread=socketport_read(socket_fd, MAX_RESPONSE_LENGTH, + response )) < 0) { + if ((cerr = socketport_close(socket_fd)) < 0) { + err = SPCE_ERROR_CLOSE_PORT; + errlog(SERROR, "%s %d: %s", __FILE__, __LINE__, + SpceErrMsg[SPCE_ERROR_CODE0-err]); + } + pthread_mutex_unlock(&socket_mutex); + return SPCE_ERROR_READ_COMMAND; + } + log_msg(SINFO, LOGLVL_USER4, + "%s %d, %s: socketport_read ret=%d, success", + __FILE__, __LINE__, __FUNCTION__, charsread ); + + /* close port */ + if ((err=socketport_close(socket_fd)) < 0) + err=SPCE_ERROR_CLOSE_PORT; + + /* unlock socket port */ + pthread_mutex_unlock(&socket_mutex); + + /* remove all non-printable chars from response? */ + + } /* end if ( !Simulate ) */ + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_send_request */ + + +/************************************************************************* + *+ + * Function name: spce_create_command_string + + * Description: creates the proper command string to be sent to the gamma + pump based on the input variables. + + * Inputs: + char *outstring -- string to receive command + int bus_address -- bus address (1 = RS232) + int command_code -- gamma mgc command code + int nchar -- number of characters in command_code (2 or 3) + int val -- parameter int value, if necessary + float fval -- parameter float value, if necessary + + * Outputs: Returns length of command or SPCe error code if there is an error. + + * Modification History: + 2011/12/02 SK -- Initial. + 2014/02/03 DN Modified for GAMMA + *- +*************************************************************************/ +int spce_create_command_string( + char *outstring, /* string to receive command */ + int bus_address, /* bus address (1 = RS232) */ + int command_code, /* gamma command code */ + char *command_data, /* data for command or NULL */ + int do_checksum /* 0 - no, other - yes */ + ) +{ + + /* + * This function creates a command string to be passed to + * the SPCe vacuum controller. + * See SPCe vacuum SPCe controller user manual + * from gammavacuum.com for details + * + * commands use this format: + * {attention char} {bus_address} {command code} {data} {termination} + * ~ ba cc data \r + * + * with + * ba = address value between 01 and FF. + * cc = character string representing command (2 bytes) + * data = optional value for command (e.g. baud rate, adress setting, etc.) + * + */ + + char out_command[MAX_COMMAND_LENGTH]; + char temp_command[MAX_COMMAND_LENGTH]; + char temp_code[MAX_CODE_LENGTH]; + char *p; + int err=0; + int cksm=0; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + log_msg(SDEBUG, LOGLVL_USER4, + "%s %d: Creating command string for SPCe pump.", + __FILE__, __LINE__); + + /* create command code string */ + sprintf(temp_code, "%.2X", command_code); + + /* determine which command was called, and then construct + command based on parameters if err == 0 */ + if (err == 0) { + switch (command_code) { + /* just the command code */ + case SPCE_COMMAND_READ_MODEL: + case SPCE_COMMAND_READ_VERSION: + case SPCE_COMMAND_RESET: + case SPCE_COMMAND_GET_ARC_DETECT: + case SPCE_COMMAND_READ_CURRENT: + case SPCE_COMMAND_READ_PRESSURE: + case SPCE_COMMAND_READ_VOLTAGE: + case SPCE_COMMAND_GET_SUPPLY_STATUS: + case SPCE_COMMAND_GET_PUMP_SIZE: + case SPCE_COMMAND_GET_CAL_FACTOR: + case SPCE_COMMAND_GET_AUTO_RESTART: + case SPCE_COMMAND_START_PUMP: + case SPCE_COMMAND_STOP_PUMP: + case SPCE_COMMAND_GET_SETPOINT: + case SPCE_COMMAND_LOCK_KEYPAD: + case SPCE_COMMAND_UNLOCK_KEYPAD: + case SPCE_COMMAND_GET_ANALOG_MODE: + case SPCE_COMMAND_IS_HIGH_VOLTAGE_ON: + case SPCE_COMMAND_GET_HV_AUTORECOVERY: + case SPCE_COMMAND_SET_FIRMWARE_UPDATE: + case SPCE_COMMAND_GET_COMM_MODE: + case SPCE_COMMAND_GET_ETHERNET_MAC: + case SPCE_COMMAND_INITIATE_FEA: + case SPCE_COMMAND_INITIATE_HIPOT: + /* need trailing space for checksum */ + sprintf(temp_command, " %.2X %s ", + bus_address, temp_code); + break; + + /* GET/SET command codes */ + case SPCE_COMMAND_GETSET_SERIAL_COMM: + case SPCE_COMMAND_GETSET_ETHERNET_IP: + case SPCE_COMMAND_GETSET_ETHERNET_MASK: + case SPCE_COMMAND_GETSET_ETHERNET_GTWY: + case SPCE_COMMAND_GETSET_HIPOT_TARGET: + case SPCE_COMMAND_GETSET_FOLDBACK_VOLTS: + case SPCE_COMMAND_GETSET_FOLDBACK_PRES: + /* need trailing space for checksum */ + if (command_data == NULL) { + sprintf(temp_command, " %.2X %s ", + bus_address, temp_code); + } else { + sprintf(temp_command, " %.2X %s %s ", + bus_address, temp_code, + command_data); + } + break; + + /* command plus data */ + case SPCE_COMMAND_SET_ARC_DETECT: + case SPCE_COMMAND_SET_PRESS_UNITS: + case SPCE_COMMAND_SET_PUMP_SIZE: + case SPCE_COMMAND_SET_CAL_FACTOR: + case SPCE_COMMAND_SET_AUTO_RESTART: + case SPCE_COMMAND_SET_SETPOINT: + case SPCE_COMMAND_SET_ANALOG_MODE: + case SPCE_COMMAND_SET_SERIAL_ADDRESS: + case SPCE_COMMAND_SET_HV_AUTORECOVERY: + case SPCE_COMMAND_SET_COMM_MODE: + case SPCE_COMMAND_SET_COMM_INTERFACE: + case SPCE_COMMAND_GET_FEA_DATA: + /* need trailing space for checksum */ + sprintf(temp_command, " %.2X %s %s ", + bus_address, temp_code, command_data); + break; + + /* invalid command */ + default: + err = SPCE_ERROR_BAD_COMMAND_CODE; + + } /* end switch(command_code) */ + + /* make sure we still have a valid command */ + if (err == 0) { + + /* do we need the checksum? */ + if (do_checksum != 0) { + p = temp_command; + while (*p) cksm = cksm + (int)(*p++); + cksm = cksm % 256; + } + + /* final output command */ + sprintf(out_command, "~%s%.2X\r", temp_command, cksm); + + /* set err to length of command */ + err = strlen(out_command); + + /* copy the new command string from temp + * string into output string */ + strcpy(outstring, out_command); + } + } /* end if (err == 0) */ + + log_msg(SINFO, LOGLVL_USER4, "%s %d: command string = {%s}", + __FILE__, __LINE__, outstring); + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: spce_create_command_string */ + + +/************************************************************************* + *+ + * Function name: spce_validate_response + + * Description: Processes response read from serial port to determine + if there was an error. + + * Inputs: + char *response -- string received from controller, read from serialport + int command_code -- command code of command that generated response + + * Outputs: Returns SPCe error code. + + * Modification History: + 2002/12/17 JLW -- Initial. + 2006/06/27 CRO -- Superficial mods for MOSFIRE. + 2014/02/06 JDN -- Major re-write for GAMMA + *- +*************************************************************************/ +int spce_validate_response(char *response, int command_code) { + + const char del[2] = " "; + int err=0; + char *substr, *p; + int bus, i, offset, rcksm, cksm=0; + + log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", + __FILE__, __LINE__, __FUNCTION__); + + /* all responses must start with the bus address */ + bus = atoi(response); + if (bus != bus_address) { + + /* bad response string */ + err = SPCE_ERROR_INVALID_RESPONSE; + + /* bus address OK */ + } else { + + /* now get status mnemonic */ + substr = response + 3*sizeof(char); + + /* check for error condition */ + if ( strncmp(substr,"ER",2) == 0 ) { + + /* get error code */ + substr += 3*sizeof(char); + err = SPCE_ERROR_CODE0 - atoi(substr); + + /* status OK */ + } else { + + /* offset to beginning of checksum in response */ + offset = strlen(response) - 3*sizeof(char); + + /* extract response checksum (Hex) */ + rcksm = strtol(response+offset,NULL,16); + + /* calculate response checksum */ + p = response; + for ( i=0 ; i<=offset-1 ; i++ ) + cksm = cksm + *p++; + cksm = cksm % 256; + + /* make sure they match */ + if (rcksm != cksm) + err = SPCE_ERROR_BAD_RESPONSE_CHECKSUM; + + } /* status OK */ + + } /* bus address OK */ + + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; + +} /* end * Function name: spce_validate_response */ + +/************************************************************************* + *+ + * Function name: getFloatFromSpceResponse + + * Description: Convert response to floating-point number. + + * Inputs: + char *spce_response -- string read from serialport + + * Outputs: Returns float value or -1. for bad response + + * Modification History: + 2012/12/17 SK -- Initial. + 2014/02/06 DN -- Added more error checking. + *- +*************************************************************************/ +float getFloatFromSpceResponse(char *response) { + char *substr; + float num; + int fstat; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: entering %s.", + __FILE__, __LINE__, __FUNCTION__); + + /* offset to beginning of data */ + substr = strstr(response, "OK") + 6*sizeof(char); + + fstat = sscanf(substr,"%g", &num); + + if (fstat != 1) { + num = -1.; + errlog(SERROR, "%s %d (%s): Invalid float value", + __FILE__, __LINE__, __FUNCTION__); + } else { + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: %s, value = %e", + __FILE__, __LINE__, __FUNCTION__, num); + } + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return num; +} /* end * Function name: getFloatFromSpceResponse */ + + +/************************************************************************* + *+ + * Function name: getStringFromSpceResponse + + * Description: Convert response to string. + + * Inputs: + char *spce_response -- string read from serialport + + * Outputs: Returns SPCe error code. + char *outstring -- data string from response + + * Modification History: + 2014/02/27 DN -- Initial. + *- +*************************************************************************/ +int getStringFromSpceResponse(char *response, char *outstring) { + char *substr; + int data_size; + int err=0; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: entering %s.", + __FILE__, __LINE__, __FUNCTION__); + + /* validate OK response */ + if ( (substr=strstr(response, "OK")) == NULL ) { + + err = SPCE_ERROR_INVALID_RESPONSE; + errlog(SERROR, "%s %d (%s): Invalid string", + __FILE__, __LINE__, __FUNCTION__); + + /* response OK */ + } else { + + /* offset from "OK" to beginning of data */ + substr += 6*sizeof(char); + + /* find size of data */ + data_size = strlen(substr) - 4*sizeof(char); + + /* verify the size of the data string */ + if (data_size <= 0 || data_size >= MAX_RESPONSE_LENGTH) { + + err = SPCE_ERROR_INVALID_RESPONSE; + errlog(SERROR, "%s %d (%s): Invalid string", + __FILE__, __LINE__, __FUNCTION__); + + /* data string size OK */ + } else { + + strncpy(outstring, substr, data_size); + outstring[data_size] = '\0'; /* null terminate */ + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: %s, string = %s", + __FILE__, __LINE__, __FUNCTION__, outstring); + } + + } /* response OK */ + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return err; +} /* end * Function name: getStringFromSpceResponse */ + + +/************************************************************************* + *+ + * Function name: getIntFromSpceResponse + + * Description: Convert response to floating-point number. + + * Inputs: + char *spce_response -- string read from serialport + + * Outputs: integer value or -1 for bad response + + * Modification History: + 2012/12/17 SK -- Initial. + 2014/02/06 DN -- Added more error checking. + *- +*************************************************************************/ +float getIntFromSpceResponse(char *response) { + char *substr; + int num; + int fstat; + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: entering %s.", + __FILE__, __LINE__, __FUNCTION__); + + /* offset to beginning of data */ + substr = strstr(response, "OK") + 6*sizeof(char); + + fstat = sscanf(substr,"%d", &num); + + if (fstat != 1) { + num = -1; + errlog(SERROR, "%s %d (%s): Invalid int value", + __FILE__, __LINE__, __FUNCTION__); + } else { + + log_msg(SDEBUG, LOGLVL_USER4, "%s %d: %s, value = %d", + __FILE__, __LINE__, __FUNCTION__, num); + } + + log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", + __FILE__, __LINE__, __FUNCTION__); + + return num; +} /* end * Function name: getIntFromSpceResponse */ + diff --git a/src/hispec/util/gammavac/SPCe.h b/src/hispec/util/gammavac/SPCe.h new file mode 100644 index 0000000..1443af1 --- /dev/null +++ b/src/hispec/util/gammavac/SPCe.h @@ -0,0 +1,248 @@ +#ifndef _SPCE_H +#define _SPCE_H + + +#define MAX_COMMAND_LENGTH 64 +#define MAX_CODE_LENGTH 3 +#define MAX_RESPONSE_LENGTH 100 +#define SPCE_BUS_ADDRESS 1 +#define SPCE_COMM_INTERFACE 0 /* RS232 */ +#define SPCE_PUMP_SIZE 3 /* Pump Size in L/s */ +#define SPCE_ARC_DETECT 1 /* Arc Detect ON */ +#define SPCE_HV_AUTO_RECOVERY 0 /* HV auto recover OFF */ +#define SPCE_AUTO_RESTART 1 /* Pump auto restart ON */ +#define SPCE_COMM_MODE 2 /* Comm mode is FULL (for now)*/ +#define SPCE_TIME_BETWEEN_COMMANDS 120000 /* microsecs */ +#define SPCE_START_CHAR '~' +#define SPCE_UNITS_TORR 'T' +#define SPCE_UNITS_MBAR 'M' +#define SPCE_UNITS_PASCAL 'P' +#define SPCE_KEYPAD_UNLOCK 0 +#define SPCE_KEYPAD_LOCK 1 + +/* Spce baud rate and parity */ +#define SPCE_BAUD_RATE 9600 +#define SPCE_PARITY (int)'N' + +#define SPCE_QUERY 1 +#define SPCE_COMMAND 0 +#define SPCE_TURNS_OFF_ABOVE 0 +#define SPCE_TURNS_ON_BELOW 1 + +/* Spce command codes */ +#define SPCE_COMMAND_READ_MODEL 0x01 /* not implemented */ +#define SPCE_COMMAND_READ_VERSION 0x02 +#define SPCE_COMMAND_RESET 0x07 +#define SPCE_COMMAND_SET_ARC_DETECT 0x91 +#define SPCE_COMMAND_GET_ARC_DETECT 0x92 +#define SPCE_COMMAND_READ_CURRENT 0x0a +#define SPCE_COMMAND_READ_PRESSURE 0x0b +#define SPCE_COMMAND_READ_VOLTAGE 0x0c +#define SPCE_COMMAND_GET_SUPPLY_STATUS 0x0d /* not implemented */ +#define SPCE_COMMAND_SET_PRESS_UNITS 0x0e +#define SPCE_COMMAND_GET_PUMP_SIZE 0x11 +#define SPCE_COMMAND_SET_PUMP_SIZE 0x12 +#define SPCE_COMMAND_GET_CAL_FACTOR 0x1d +#define SPCE_COMMAND_SET_CAL_FACTOR 0x1e +#define SPCE_COMMAND_SET_AUTO_RESTART 0x33 +#define SPCE_COMMAND_GET_AUTO_RESTART 0x34 +#define SPCE_COMMAND_START_PUMP 0x37 +#define SPCE_COMMAND_STOP_PUMP 0x38 +#define SPCE_COMMAND_GET_SETPOINT 0x3c /* not implemented */ +#define SPCE_COMMAND_SET_SETPOINT 0x3d /* not implemented */ +#define SPCE_COMMAND_LOCK_KEYPAD 0x44 +#define SPCE_COMMAND_UNLOCK_KEYPAD 0x45 +#define SPCE_COMMAND_GET_ANALOG_MODE 0x50 +#define SPCE_COMMAND_SET_ANALOG_MODE 0x51 +#define SPCE_COMMAND_IS_HIGH_VOLTAGE_ON 0x61 +#define SPCE_COMMAND_SET_SERIAL_ADDRESS 0x62 /* not implemented */ +#define SPCE_COMMAND_SET_HV_AUTORECOVERY 0x68 +#define SPCE_COMMAND_GET_HV_AUTORECOVERY 0x69 +#define SPCE_COMMAND_SET_FIRMWARE_UPDATE 0x8f /* not implemented */ +#define SPCE_COMMAND_SET_COMM_MODE 0xd3 +#define SPCE_COMMAND_GET_COMM_MODE 0xd4 +#define SPCE_COMMAND_GETSET_SERIAL_COMM 0x46 /* not implemented */ +#define SPCE_COMMAND_GETSET_ETHERNET_IP 0x47 /* not implemented */ +#define SPCE_COMMAND_GETSET_ETHERNET_MASK 0x48 /* not implemented */ +#define SPCE_COMMAND_GETSET_ETHERNET_GTWY 0x49 /* not implemented */ +#define SPCE_COMMAND_GET_ETHERNET_MAC 0x4a /* not implemented */ +#define SPCE_COMMAND_SET_COMM_INTERFACE 0x4b +#define SPCE_COMMAND_INITIATE_FEA 0x4c /* not implemented */ +#define SPCE_COMMAND_GET_FEA_DATA 0x4d /* not implemented */ +#define SPCE_COMMAND_INITIATE_HIPOT 0x52 /* not implemented */ +#define SPCE_COMMAND_GETSET_HIPOT_TARGET 0x53 /* not implemented */ +#define SPCE_COMMAND_GETSET_FOLDBACK_VOLTS 0x54 /* not implemented */ +#define SPCE_COMMAND_GETSET_FOLDBACK_PRES 0x55 /* not implemented */ +#define SPCE_COMMAND_MAX 0x92 + +/* Spce error codes */ +#define SPCE_ERROR_CODE0 -500 +#define SPCE_ERROR_BAD_COMMAND_CODE SPCE_ERROR_CODE0 - 1 +#define SPCE_ERROR_UNKNOWN_COMMAND_CODE SPCE_ERROR_CODE0 - 2 +#define SPCE_ERROR_BAD_CHECKSUM SPCE_ERROR_CODE0 - 3 +#define SPCE_ERROR_TIMEOUT SPCE_ERROR_CODE0 - 4 +#define SPCE_ERROR_UNKNOWN_ERROR SPCE_ERROR_CODE0 - 6 +#define SPCE_ERROR_COMM_ERROR SPCE_ERROR_CODE0 - 7 +#define SPCE_ERROR_OPEN_PORT SPCE_ERROR_CODE0 - 10 +#define SPCE_ERROR_CLOSE_PORT SPCE_ERROR_CODE0 - 11 +#define SPCE_ERROR_CONFIG_PORT SPCE_ERROR_CODE0 - 12 +#define SPCE_ERROR_WRITE_COMMAND SPCE_ERROR_CODE0 - 13 +#define SPCE_ERROR_READ_COMMAND SPCE_ERROR_CODE0 - 14 +#define SPCE_ERROR_INVALID_RESPONSE SPCE_ERROR_CODE0 - 15 +#define SPCE_ERROR_BAD_RESPONSE_CHECKSUM SPCE_ERROR_CODE0 - 16 +#define SPCE_ERROR_VALUE_OUT_OF_RANGE SPCE_ERROR_CODE0 - 17 + +#define SPCE_ERROR_MAX 18 + +/* Spce display codes */ +#define SPCE_DISPLAY_CODE0 -400 +#define SPCE_DISPLAY_COOLDOWN_CYCLES SPCE_DISPLAY_CODE0 - 1 +#define SPCE_DISPLAY_VACUUM_LOSS SPCE_DISPLAY_CODE0 - 2 +#define SPCE_DISPLAY_SHORT_CIRCUIT SPCE_DISPLAY_CODE0 - 3 +#define SPCE_DISPLAY_EXCESS_PRESSURE SPCE_DISPLAY_CODE0 - 4 +#define SPCE_DISPLAY_PUMP_OVERLOAD SPCE_DISPLAY_CODE0 - 5 +#define SPCE_DISPLAY_SUPPLY_POWER SPCE_DISPLAY_CODE0 - 6 +#define SPCE_DISPLAY_START_UNDER_VOLTAGE SPCE_DISPLAY_CODE0 - 7 +#define SPCE_DISPLAY_PUMP_IS_ARCING SPCE_DISPLAY_CODE0 - 10 +#define SPCE_DISPLAY_THERMAL_RUNAWAY SPCE_DISPLAY_CODE0 - 12 +#define SPCE_DISPLAY_UNKNOWN_ERROR SPCE_DISPLAY_CODE0 - 19 +#define SPCE_DISPLAY_SAFE_CONN_INTERLOCK SPCE_DISPLAY_CODE0 - 20 +#define SPCE_DISPLAY_HVE_INTERLOCK SPCE_DISPLAY_CODE0 - 21 +#define SPCE_DISPLAY_SET_PUMP_SIZE SPCE_DISPLAY_CODE0 - 22 +#define SPCE_DISPLAY_CALIBRATION_NEEDED SPCE_DISPLAY_CODE0 - 23 +#define SPCE_DISPLAY_RESET_REQUIRED SPCE_DISPLAY_CODE0 - 24 +#define SPCE_DISPLAY_TEMPERATURE_WARNING SPCE_DISPLAY_CODE0 - 25 +#define SPCE_DISPLAY_SUPPLY_OVERHEAT SPCE_DISPLAY_CODE0 - 26 +#define SPCE_DISPLAY_CURRENT_LIMITED SPCE_DISPLAY_CODE0 - 27 +#define SPCE_DISPLAY_INTERNAL_BUS_ERROR SPCE_DISPLAY_CODE0 - 30 +#define SPCE_DISPLAY_HV_CONTROL_ERROR SPCE_DISPLAY_CODE0 - 31 +#define SPCE_DISPLAY_CURRENT_CONTROL_ERROR SPCE_DISPLAY_CODE0 - 32 +#define SPCE_DISPLAY_CURRENT_MEASURE_ERROR SPCE_DISPLAY_CODE0 - 33 +#define SPCE_DISPLAY_VOLTAGE_CONTROL_ERROR SPCE_DISPLAY_CODE0 - 34 +#define SPCE_DISPLAY_VOLTAGE_MEASURE_ERROR SPCE_DISPLAY_CODE0 - 35 +#define SPCE_DISPLAY_POLARITY_MISMATCH SPCE_DISPLAY_CODE0 - 36 +#define SPCE_DISPLAY_HV_NOT_INSTALLED SPCE_DISPLAY_CODE0 - 37 +#define SPCE_DISPLAY_INPUT_VOLTAGE_ERROR SPCE_DISPLAY_CODE0 - 38 + +#define SPCE_DISPLAY_MAX 48 + +/* Spce data response length */ +#define SPCE_PRESSURE_DATA_SIZE 13 +#define SPCE_RESPONSE_DATA_SIZE 13 + +#ifdef CREATOR +char *SpceErrMsg[] = { + NULL, + "SPCe Error (01): Command code/format is not correct, semantics is wrong.", + "SPCe Error (02): Command code not recognized, does not exist.", + "SPCe Error (03): Bad checksum.", + "SPCe Error (04): Command timeout.", + NULL, + "SPCe Error (06): Firmware encountered an unknown error.", + "SPCe Error (07): Communication error, zero characters recieved.", + NULL, + NULL, + "SPCe Error (10): Socket port open error.", + "SPCe Error (11): Socket port close error.", + "SPCe Error (12): Socket port configuration error.", + "SPCe Error (13): Socket port write error.", + "SPCe Error (14): Socket port read error.", + "SPCe Error (15): Invalid response.", + "SPCe Error (16): Bad response checksum.", + "SPCe Error (17): Value out of range.", +NULL +}; +#else +extern char *SpceErrMsg[]; +#endif + +#ifdef CREATOR +char *SpceDspMsg[] = { + NULL, + "SPCe Error (01): Too many cooldown cycles (>3) occured during pump starting.", + "SPCe Error (02): The voltage dropped below 1200V while pump was running.", + "SPCe Error (03): Short circuit condition has been detected during pump starting.", + "SPCe Error (04): Excessive pressure condition detected. Pressure greater than 1.0e-4 Torr detected.", + "SPCe Error (05): Too much power delivered to the pump for the given pump size.", + "SPCe Error (06): Supply output power detected greater than 50W.", + "SPCe Error (07): The voltage did not reach 2000V within the maximum pump starting time of 5 minutes.", + NULL, + NULL, + "SPCe Error (10): Arcing detected.", + NULL, + "SPCe Error (12): Significant drop in voltage detected during pump starting.", + NULL, + NULL, + NULL, + NULL, + NULL, + NULL, + "SPCe Error (19): Unknown Error.", + "SPCe Error (20): Safety interlock connection is not detected. Check safe-conn connection.", + "SPCe Error (21): HVE interlock set or HVE Signal off.", + "SPCe Error (22): Pump size is not set.", + "SPCe Error (23): Supply calibration has not been performed. Required for accurate current/pressure readings.", + "SPCe Error (24): Supply calibration parameters are outside normal ranges. System reset will clear all paramters to factory defaults.", + "SPCe Error (25): Supply internal temperature is past the threshold.", + "SPCe Error (26): Supply internal temperature too high. HV operation is disabled.", + "SPCe Error (27): Supply current is limited. The limit is set by programming the pump size or manually by the user.", + NULL, + NULL, + "SPCe Error (30): Internal data bus error detected.", + "SPCe Error (31): Supply HV control mechanism malfunctioning. On/Off state is malfunctioning.", + "SPCe Error (32): Supply current control mechanism malfunctioning.", + "SPCe Error (33): Supply current measuring mechanism malfunctioning.", + "SPCe Error (34): Supply HV control mechanism malfunctioning. Voltage output level is malfunctioning.", + "SPCe Error (35): Supply voltage measuring mechanism malfunctioning.", + "SPCe Error (36): Internal boards polarity mismatch.", + "SPCe Error (37): HV module missing.", + "SPCe Error (38): Input power voltage outside 22-26VDC range. HV operation disabled.", + NULL, + "SPCe Error (40): Socket port open error.", + "SPCe Error (41): Socket port close error.", + "SPCe Error (42): Socket port configuration error.", + "SPCe Error (43): Socket port write error.", + "SPCe Error (44): Socket port read error.", +NULL +}; +#else +extern char *SpceDspMsg[]; +#endif + +/* function prototypes */ +int spce_read_version(char *port, char *version); +int spce_reset(char *port); +int spce_set_arc_detect(char *port, int yesno); +int spce_get_arc_detect(char *port, int *yesno); +int spce_read_current(char *port, float *outcurrent); +int spce_read_pressure(char *port, float *outpressure); +int spce_read_voltage(char *port, int *outvoltage); +int spce_set_units(char *port, int units); +int spce_get_pump_size(char *port, int *outsize); +int spce_set_pump_size(char *port, int size); +int spce_get_cal_factor(char *port, float *outcalfact); +int spce_set_cal_factor(char *port, float calfact); +int spce_set_auto_restart(char *port, int yesno); +int spce_get_auto_restart(char *port, int *yesno); +int spce_pump_start(char *port); +int spce_pump_stop(char *port); +int spce_lock_keypad(char *port, int lock); +int spce_get_analog_mode(char *port, int *outmode); +int spce_set_analog_mode(char *port, int mode); +int spce_high_voltage_on(char *port, int *yesno); +int spce_set_hv_autorecovery(char *port, int mode); +int spce_get_hv_autorecovery(char *port, int *outmode); +int spce_set_comm_mode(char *port, int mode); +int spce_get_comm_mode(char *port, int *outmode); +int spce_set_comm_interface(char *port, int interface); +int spce_send_command(char *port, char *cmd); +int spce_send_request(char *port, char *cmd, char *response); +int spce_create_command_string(char *outstring, int bus_address, + int command_code, char *command_data, + int do_checksum); +int spce_validate_response(char *response, int command_code); +float getFloatFromSpceResponse(char *response); +int getStringFromSpceResponse(char *response, char *outstring); +float getIntFromSpceResponse(char *response); + +#endif /* _KPRS_SPCE_H */ diff --git a/src/hispec/util/gammavac/SPCe.py b/src/hispec/util/gammavac/SPCe.py new file mode 100644 index 0000000..00eaef9 --- /dev/null +++ b/src/hispec/util/gammavac/SPCe.py @@ -0,0 +1,503 @@ +"""Gamma Vacuum SPCe model utility functions.""" +import errno +import time +import socket +import re +from typing import Union + +from hardware_device_base import HardwareDeviceBase + +# Constants (partial, extend as needed) +SPCE_TIME_BETWEEN_COMMANDS = 0.12 + +# Command codes (extend as needed) +SPCE_COMMAND_READ_MODEL = 0x01 +SPCE_COMMAND_READ_VERSION = 0x02 +SPCE_COMMAND_RESET = 0x07 +SPCE_COMMAND_SET_ARC_DETECT = 0x91 +SPCE_COMMAND_GET_ARC_DETECT = 0x92 +SPCE_COMMAND_READ_CURRENT = 0x0A +SPCE_COMMAND_READ_PRESSURE = 0x0B +SPCE_COMMAND_READ_VOLTAGE = 0x0C +SPCE_COMMAND_GET_PUMP_STATUS = 0x0D +SPCE_COMMAND_SET_PRESS_UNITS = 0x0E +SPCE_COMMAND_GET_PUMP_SIZE = 0x11 +SPCE_COMMAND_SET_PUMP_SIZE = 0x12 +SPCE_COMMAND_GET_CAL_FACTOR = 0x1D +SPCE_COMMAND_SET_CAL_FACTOR = 0x1E +SPCE_COMMAND_SET_AUTO_RESTART = 0x33 +SPCE_COMMAND_GET_AUTO_RESTART = 0x34 +SPCE_COMMAND_START_PUMP = 0x37 +SPCE_COMMAND_STOP_PUMP = 0x38 +SPCE_COMMAND_LOCK_KEYPAD = 0x44 +SPCE_COMMAND_UNLOCK_KEYPAD = 0x45 +SPCE_COMMAND_GET_ANALOG_MODE = 0x50 +SPCE_COMMAND_SET_ANALOG_MODE = 0x51 +SPCE_COMMAND_IS_HIGH_VOLTAGE_ON = 0x61 +SPCE_COMMAND_SET_HV_AUTORECOVERY = 0x68 +SPCE_COMMAND_GET_HV_AUTORECOVERY = 0x69 +SPCE_COMMAND_SET_COMM_MODE = 0xD3 +SPCE_COMMAND_GET_COMM_MODE = 0xD4 +SPCE_COMMAND_SET_COMM_INTERFACE = 0x4B + +SPCE_UNITS_TORR = 'T' +SPCE_UNITS_MBAR = 'M' +SPCE_UNITS_PASCAL = 'P' + + +class SpceController(HardwareDeviceBase): + """Class to control a Lesker GAMMA gauge SPCe controller over a TCP socket.""" + # pylint: disable=too-many-public-methods + + def __init__(self, bus_address: int =1, simulate: bool =False, + log: bool =True, logfile: str = __name__.rsplit(".", 1)[-1] ) -> None: + """Initialize the SpceController. + + Args: + bus_address (str): bus address of the controller (00 - FF). + simulate (bool): If True, simulate communication. + log (bool): If True, log outputs. + logfile (str): If specified, write logs to this file. + + NOTE; default is INFO level logging, use set_verbose to increase verbosity. + """ + super().__init__(log, logfile) + + # Bus address + self.bus_address = bus_address + + # Set up socket + self.sock = None + + # Simulate mode + if simulate: + self.simulate = True + self.logger.info("Simulate mode enabled.") + else: + self.simulate = False + + def connect(self, *args, con_type="tcp") -> None: + """Connect to the controller. + + :param args: for tcp connection, host and port, for serial, port and baudrate + :param con_type: tcp or serial + """ + if self.validate_connection_params(args): + if self.simulate: + self.connected = True + self.logger.info('Connected to SPCe simulator.') + else: + if con_type == "tcp": + host = args[0] + port = args[1] + if self.sock is None: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.sock.connect((host, port)) + self._set_connected(True) + self.logger.info("Connected to SPCe controller at %s:%d bus %d", + host, port, self.bus_address) + except OSError as e: + if e.errno == errno.EISCONN: + self.logger.debug("Already connected") + self._set_connected(True) + else: + self.logger.error("Connection error: %s", e.strerror) + self._set_connected(False) + if self.connected: + self._clear_socket() + elif con_type == "serial": + self.logger.error("Serial connection not implemented.") + self._set_connected(False) + else: + self.logger.error("Unknown con_type: %s", con_type) + self._set_connected(False) + else: + self.logger.error("Invalid connection args: %s", args) + self._set_connected(False) + + def disconnect(self) -> None: + """Disconnect from the controller.""" + if self.simulate: + self.logger.info('Disconnected from SPCe simulator.') + self.connected = False + else: + try: + self.sock.shutdown(socket.SHUT_RDWR) + self.sock.close() + self._set_connected(False) + self.sock = None + self.logger.info("Disconnected from SPCe controller") + except OSError as e: + self.logger.error("Disconnection error: %s", e.strerror) + self._set_connected(False) + self.sock = None + + def _clear_socket(self): + """ Clear socket buffer. """ + if self.sock is not None: + self.sock.setblocking(False) + while True: + try: + _ = self.sock.recv(1024) + except BlockingIOError: + break + self.sock.setblocking(True) + self.sock.settimeout(2.0) + + def _send_command(self, command: str, *args) -> bool: + """Send a command without expecting a response. + Args: + command (str): command to send. + """ + if not self.is_connected: + self.logger.error("Not connected to SPCe controller.") + return False + + self.logger.debug("Sending command %s", command) + if self.simulate: + print(f"[SIM SEND] {command}") + return True + with self.lock: + self.sock.sendall(command.encode('utf-8')) + time.sleep(SPCE_TIME_BETWEEN_COMMANDS) + return True + + def _read_reply(self) -> Union[str, None]: + """Read a reply from the controller.""" + if not self.is_connected: + self.logger.error("Not connected to SPCe controller.") + return None + try: + reply = self.sock.recv(1024).decode('utf-8').strip() + self.logger.debug("Received reply %s", reply) + return reply + except Exception as ex: + raise IOError(f"Failed to _read_reply message: {ex}") from ex + + def _send_request(self, command: str, response_type: str ="S") -> Union[int, float, str]: + """Send a command and receive a response. + Args: + command (str): Command to send. + response_type (str): Type of response: + 'I' for int, 'S' for str (default), 'F' for float. + """ + if not self.connected: + self.logger.error("Not connected to SPCe controller.") + return "NOT CONNECTED" + + self.logger.debug("Sending request %s", command) + if self.simulate: + print(f"[SIM REQ] {command}") + return "SIM_RESPONSE" + with self.lock: + self.sock.sendall(command.encode('utf-8')) + time.sleep(SPCE_TIME_BETWEEN_COMMANDS) + try: + recv = self.sock.recv(1024) + recv_len = len(recv) + self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) + except socket.timeout: + self.logger.error("Timeout while waiting for response") + return "TIMEOUT" + retval = str(recv.decode('utf-8')).strip() + if self.validate_response(retval): + response_type = response_type.upper() + if response_type == "F": + retval = extract_float_from_response(retval) + elif response_type == "I": + retval = extract_int_from_response(retval) + else: + retval = extract_string_from_response(retval) + return retval + return "NOT VALID" + + def create_command(self, code, data=None): + """Create a properly formatted command string. + + Args: + code (int): Command code. + data (str): Command data. + + This function creates a command string to be passed to + the SPCe vacuum controller. See SPCe vacuum SPCe controller user manual + from gammavacuum.com for details. + + Commands use this format: + {attention char} {bus_address} {command code} {data} {termination} + ~ ba cc data \r + + With + ba = address value between 01 and FF. + cc = character string representing command (2 bytes). + data = optional value for command (e.g. baud rate, adress setting, etc.). + """ + + command = f" {self.bus_address:02X} {code:02X} " + if data: + command += f"{data} " + + chksm = sum(ord(c) for c in command) % 256 + + command = f"~{command}{chksm:02X}\r" + return command + + def validate_response(self, response: str) -> int: + """ + Validate the response string from a serial device. + + Args: + response (str): The raw response string from the device. + + Returns: + int: 0 if valid, or an error code. + """ + # pylint: disable=too-many-branches + + try: + # The First field must be the bus address + bus = int(response.split()[0]) + except (ValueError, IndexError): + self.logger.error("Invalid response from device.") + return False + + if bus != self.bus_address: + self.logger.error("Invalid bus address from device.") + return False + + # Now check for error condition or valid response + substr = response[3:] + + if substr.startswith("ER"): + try: + error_code_str = substr[3:] + self.logger.error(error_code_str) + except ValueError: + pass + return False + + # Calculate and verify checksum + offset = len(response) - 3 + try: + rcksm = int(response[offset:], 16) # Read hex checksum to decimal + except ValueError: + self.logger.error("Unable to read checksum from device.") + return False + + # Calculate checksum (sum of all chars before checksum, mod 256) + cksm = sum(ord(c) for c in response[:offset+1]) % 256 + + if rcksm != cksm: + self.logger.error("Invalid checksum from device.") + return False + + return True + + # --- Command Methods --- + def get_atomic_value(self, item: str ="") -> Union[float, int, str, None]: + """Get an atomic telemetry value.""" + if item == "pressure": + value = self.read_pressure() + elif item == "current": + value = self.read_current() + elif item == "voltage": + value = self.read_voltage() + else: + self.logger.error("Invalid item from device.") + value = None + return value + + def read_model(self): + """Read the model from the controller.""" + return self._send_request(self.create_command(SPCE_COMMAND_READ_MODEL)) + + def read_version(self): + """Read the firmware version from the controller.""" + return self._send_request(self.create_command(SPCE_COMMAND_READ_VERSION)) + + def reset(self): + """Send a reset command to the controller.""" + return self._send_command(self.create_command(SPCE_COMMAND_RESET)) + + def set_arc_detect(self, enable): + """Enable or disable arc detection.""" + val = "YES" if enable else "NO" + return self._send_request(self.create_command(SPCE_COMMAND_SET_ARC_DETECT, val)) + + def get_arc_detect(self): + """Get the current arc detection setting.""" + return self._send_request(self.create_command(SPCE_COMMAND_GET_ARC_DETECT)) + + def read_current(self): + """Read the emission current.""" + return self._send_request( + self.create_command(SPCE_COMMAND_READ_CURRENT), "F") + + def read_pressure(self): + """Read the pressure value.""" + return self._send_request( + self.create_command(SPCE_COMMAND_READ_PRESSURE), "F") + + def read_voltage(self): + """Read the ion gauge voltage.""" + return self._send_request( + self.create_command(SPCE_COMMAND_READ_VOLTAGE), "F") + + def set_units(self, unit_char): + """Set the pressure display units. + + Args: + unit_char (str): One of 'T', 'M', or 'P'. + """ + unit_char = unit_char.upper() + if unit_char not in [SPCE_UNITS_TORR, SPCE_UNITS_MBAR, SPCE_UNITS_PASCAL]: + raise ValueError("Invalid unit. Use 'T', 'M', or 'P'.") + return self._send_request(self.create_command(SPCE_COMMAND_SET_PRESS_UNITS, unit_char)) + + def get_pump_status(self): + """Get the pump status.""" + return self._send_request( + self.create_command(SPCE_COMMAND_GET_PUMP_STATUS)) + + def get_pump_size(self): + """Get the configured pump size.""" + return self._send_request( + self.create_command(SPCE_COMMAND_GET_PUMP_SIZE), "I") + + def set_pump_size(self, size): + """Set the pump size. + + Args: + size (int): Pump size (0-9999). + """ + if not 0 <= size <= 9999: + raise ValueError("Pump size out of range (0-9999).") + return self._send_request(self.create_command(SPCE_COMMAND_SET_PUMP_SIZE, f"{size:04d}")) + + def get_cal_factor(self): + """Get the calibration factor.""" + return self._send_request( + self.create_command(SPCE_COMMAND_GET_CAL_FACTOR), "F") + + def set_cal_factor(self, factor): + """Set the calibration factor. + + Args: + factor (float): Calibration factor (0.00 to 9.99). + """ + if not 0.0 <= factor <= 9.99: + raise ValueError("Calibration factor out of range (0.00 - 9.99).") + return self._send_request(self.create_command( + SPCE_COMMAND_SET_CAL_FACTOR, f"{factor:.2f}")) + + def set_auto_restart(self, enable: bool): + """Enable or disable auto restart.""" + val = "YES" if enable else "NO" + return self._send_request(self.create_command(SPCE_COMMAND_SET_AUTO_RESTART, val)) + + def get_auto_restart(self): + """Get the auto restart setting.""" + return self._send_request(self.create_command(SPCE_COMMAND_GET_AUTO_RESTART)) + + def start_pump(self): + """Start the pump.""" + return self._send_request(self.create_command(SPCE_COMMAND_START_PUMP)) + + def stop_pump(self): + """Stop the pump.""" + return self._send_request(self.create_command(SPCE_COMMAND_STOP_PUMP)) + + def lock_keypad(self, lock): + """Lock or unlock the controller keypad.""" + cmd = SPCE_COMMAND_LOCK_KEYPAD if lock else SPCE_COMMAND_UNLOCK_KEYPAD + return self._send_request(self.create_command(cmd)) + + def get_analog_mode(self): + """Get the analog output mode.""" + return self._send_request( + self.create_command(SPCE_COMMAND_GET_ANALOG_MODE), "I") + + def set_analog_mode(self, mode: int): + """Set the analog output mode. + + Args: + mode (int): Analog mode (0-6, 8-10). + """ + if mode not in list(range(0, 7)) + [8, 9, 10]: + raise ValueError("Invalid analog mode. Must be 0-6 or 8-10.") + return self._send_request(self.create_command(SPCE_COMMAND_SET_ANALOG_MODE, str(mode))) + + def high_voltage_on(self): + """Check if high voltage is on.""" + return self._send_request(self.create_command(SPCE_COMMAND_IS_HIGH_VOLTAGE_ON)) + + def set_hv_autorecovery(self, mode: int): + """Set HV autorecovery mode. + + Args: + mode (int): Mode (0-2). + """ + if mode not in [0, 1, 2]: + raise ValueError("Invalid HV autorecovery mode (0-2).") + return self._send_request(self.create_command(SPCE_COMMAND_SET_HV_AUTORECOVERY, str(mode))) + + def get_hv_autorecovery(self): + """Get the HV autorecovery setting.""" + return self._send_request( + self.create_command(SPCE_COMMAND_GET_HV_AUTORECOVERY)) + + def set_comm_mode(self, mode): + """Set the communication mode. + + Args: + mode (int): Communication mode (0-2). + """ + if mode not in [0, 1, 2]: + raise ValueError("Invalid communication mode (0-2).") + return self._send_request(self.create_command(SPCE_COMMAND_SET_COMM_MODE, str(mode))) + + def get_comm_mode(self): + """Get the communication mode.""" + return self._send_request( + self.create_command(SPCE_COMMAND_GET_COMM_MODE), "I") + + def set_comm_interface(self, interface): + """Set the communication interface. + + Args: + interface (int): Interface index (0-5). + """ + if interface not in range(6): + raise ValueError("Invalid communication interface (0-5).") + return self._send_request(self.create_command( + SPCE_COMMAND_SET_COMM_INTERFACE, str(interface))) + +def extract_float_from_response(response): + """Extract a float value from the response string.""" + response = response.split("OK 00 ")[-1].split()[0] + try: + match = re.search(r"([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)", response) + return float(match.group(1)) if match else None + except ValueError: + return None + +def extract_int_from_response(response): + """Extract an integer value from the response string.""" + response = response.split("OK 00 ")[-1].split()[0] + try: + match = re.search(r"([-+]?[0-9]+)", response) + return int(match.group(1)) if match else None + except ValueError: + return None + +def extract_string_from_response(response): + """Extract a string value from a key=value response.""" + response = " ".join(response.split("OK 00 ")[-1].split()[:-1]) + try: + parts = response.split(',') + for part in parts: + if '=' in part: + return part.split('=')[1].strip() + return response.strip() + except ValueError: + return response.strip() diff --git a/hispec/util/helper/__init__.py b/src/hispec/util/gammavac/__init__.py similarity index 100% rename from hispec/util/helper/__init__.py rename to src/hispec/util/gammavac/__init__.py diff --git a/src/hispec/util/gammavac/pyproject.toml b/src/hispec/util/gammavac/pyproject.toml new file mode 100644 index 0000000..9967cdb --- /dev/null +++ b/src/hispec/util/gammavac/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "gammavac" +version = "0.1.0" +description = "Gamma Vacuum controller software" +authors = [ + { name="Michael Langmayr", email="langmayr@caltech.edu" }, + { name="Don Neill", email="neill@astro.caltech.edu" }, + { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } +] +readme = "README.md" +requires-python = ">=3.7" +dependencies = [ + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" +] +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/src/hispec/util/gammavac/tests/test_basic.py b/src/hispec/util/gammavac/tests/test_basic.py new file mode 100644 index 0000000..5067f5b --- /dev/null +++ b/src/hispec/util/gammavac/tests/test_basic.py @@ -0,0 +1,14 @@ +"""Perform basic tests.""" +import pytest +from SPCe import SpceController + +def test_initialization(): + """Test initialization.""" + controller = SpceController() + assert not controller.connected + +def test_connection_fail(): + """Test connection failure.""" + controller = SpceController() + controller.connect(host="127.0.0.1", port=50000) + assert not controller.connected diff --git a/src/hispec/util/helper/__init__.py b/src/hispec/util/helper/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hispec/util/helper/logger_utils.py b/src/hispec/util/helper/logger_utils.py similarity index 100% rename from hispec/util/helper/logger_utils.py rename to src/hispec/util/helper/logger_utils.py diff --git a/src/hispec/util/inficon/.gitignore b/src/hispec/util/inficon/.gitignore new file mode 100644 index 0000000..1681eb4 --- /dev/null +++ b/src/hispec/util/inficon/.gitignore @@ -0,0 +1,165 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# MacOS +.DS_Store diff --git a/src/hispec/util/inficon/README.md b/src/hispec/util/inficon/README.md new file mode 100644 index 0000000..ba96afc --- /dev/null +++ b/src/hispec/util/inficon/README.md @@ -0,0 +1,44 @@ +# InficonVGC502 + +A Python 3 module to communicate with an INFICONVGC502 controller over TCP. +It supports reading pressure values from one or more gauges. + +## Currently Supported Models +- VGC501, VGC502 + +## Features +- Connect to VGC controller +- Read out the pressure + +## 🛠️ Requirements + +- Python 3.7+ +- Install base class from https://github.com/COO-Utilities/hardware_device_base + +## Installation + +```bash +pip install . +``` + +## 🧪 Running from a Python Terminal + +You can also use the `INFICON` module interactively from a Python terminal or script: + +```python +from inficonvgc502 import InficonVGC502 + +vgc502 = InficonVGC502() +vgc502.initialize() +pressure = vgc502.get_atomic_value("pressure1") # must have gauge number +print(f"Pressure: {pressure} Torr") +``` + +## Testing +Unit tests are in the `tests/` directory. + +To run all tests from the projecgt root: + +```bash +python -m pytest +``` \ No newline at end of file diff --git a/src/hispec/util/inficon/__init__.py b/src/hispec/util/inficon/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hispec/util/inficon/inficonvgc502.py b/src/hispec/util/inficon/inficonvgc502.py new file mode 100644 index 0000000..a323c1e --- /dev/null +++ b/src/hispec/util/inficon/inficonvgc502.py @@ -0,0 +1,336 @@ +""" +Inficon VGC502 Controller Interface +""" +import sys +import socket +from errno import EISCONN +from typing import Union + +from hardware_device_base import HardwareDeviceBase + + +class InficonVGC502(HardwareDeviceBase): + """Class for interfacing with InficonVGC502""" + # pylint: disable=too-many-instance-attributes + + UNIT_CODES = ("mbar", "Torr", "Pascal", "Micron", "hPascal", "Volt") + + def __init__(self, log: bool=True, logfile: str =__name__.rsplit('.', 1)[-1], + timeout: int=1): + """Initialize the InficonVGC502 class. + Args: + log (bool): If True, start logging. + logfile (str, optional): Path to log file. + timeout (int, optional): Timeout in seconds. + """ + super().__init__(log, logfile) + self.timeout = timeout + self.type = "" + self.model = "" + self.serial_number = None + self.firmware_version = "" + self.hardware_version = "" + self.pressure_units = "" + self.n_gauges = 0 + self.sock: socket.socket | None = None + + def connect(self, *args, con_type="tcp") -> None: + """ Connect to the controller. """ + if self.validate_connection_params(args): + if con_type == "tcp": + host = args[0] + port = args[1] + if self.sock is None: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.sock.connect((host, port)) + self.logger.info("Connected to %s:%d", host, port) + self._set_connected(True) + # ensure subsequent ops also use timeout + self.sock.settimeout(self.timeout) + + except OSError as e: + if e.errno == EISCONN: + self.logger.info("Already connected") + self._set_connected(True) + else: + self.logger.error("Connection error: %s", e.strerror) + self._set_connected(False) + if self.is_connected(): + self._clear_socket() + elif con_type == "serial": + self.logger.error("Serial connection not supported") + else: + self.logger.error("Unknown con_type: %s", con_type) + else: + self.logger.error("Invalid connection arguments: %s", args) + + def _clear_socket(self): + """Clear the socket connection.""" + if self.sock: + self.sock.setblocking(False) + while True: + try: + _ = self.sock.recv(1024) + except BlockingIOError: + break + self.sock.setblocking(True) + + def disconnect(self): + """Close the TCP connection.""" + try: + self.logger.debug("Closing connection to controller") + if self.sock: + self.sock.close() + self._set_connected(False) + except Exception as ex: + raise IOError(f"Failed to close connection: {ex}") from ex + + def _send_command(self, command: str, *args) -> bool: + """ + Send a command to the controller. + + :param command: (str) Command to send. + :param args: Arguments to send. + :return: True on success, False on failure. + """ + try: + self.logger.debug("Sending command: %s", command) + command += "\r\n" + with self.lock: + self.sock.sendall(command.encode()) + except Exception as ex: + self.logger.error("Failed to send command: %s", ex) + return False + # raise IOError(f"Failed to send command: {ex}") from ex + return True + + def _send_enq(self): + """Send ENQ to the controller.""" + try: + self.logger.debug("Sending ENQ to controller") + with self.lock: + self.sock.sendall(b"\x05") + except Exception as ex: + self.logger.error("Failed to send ENQ: %s", ex) + return False + # raise IOError(f"Failed to send ENQ: {ex}") from ex + return True + + def _read_until(self, terminator: bytes = b"\r\n", max_bytes: int = 4096) -> bytes: + """Read until 'terminator' or timeout. Returns bytes including the terminator.""" + buf = bytearray() + try: + while True: + chunk = self.sock.recv(1) + if not chunk: + # peer closed + break + buf += chunk + if buf.endswith(terminator): + break + if len(buf) >= max_bytes: + break + # self.logger.debug("Input buffer: %r", buf) + return bytes(buf) + except Exception as ex: + raise IOError(f"Failed to _read_reply: {ex}") from ex + + def _read_reply(self) -> Union[str, None]: + """Read reply from controller.""" + try: + ack = self._read_until(b"\r\n").strip() + self.logger.debug("Reply received: %r", ack) + except socket.timeout: + return None + + # ACK received + if ack == b"\x06": + self.logger.debug("ACK received, sending ENQ") + try: + if self._send_enq(): + response = self._read_until(b"\r\n").decode().strip() + else: + self.logger.error("Error sending ENQ") + return None + except socket.timeout: + return None + except OSError as e: + self.logger.error("IO error while receiving response: %s", e) + return None + + self.logger.debug("Response received: %s", response) + return response + + if ack == b'\x15': + self.logger.error("NAK received, try command again.") + else: + self.logger.error("ACK NOT received") + return None + + def initialize(self): + """Initialize the controller.""" + self.logger.debug("Initializing controller") + if self._send_command("UNI"): + unit_code = int(self._read_reply()) + if 0 <= unit_code <= 5: + self.pressure_units = self.UNIT_CODES[unit_code] + if self._send_command("AYT"): + devinfo = self._read_reply() + dev_items = devinfo.split(",") + if len(dev_items) == 5: + self.type = dev_items[0] + self.model = dev_items[1] + self.serial_number = int(dev_items[2]) + self.firmware_version = dev_items[3] + self.hardware_version = dev_items[4] + try: + self.n_gauges = int(self.type[-1]) + except ValueError: + self.logger.error("Invalid gauge type, unable to parse n_gauges: %s", self.type) + self.n_gauges = 0 + else: + self.logger.error("Error initializing controller: %s", devinfo) + else: + self.logger.error("Failed to initialize controller") + + def set_pressure_unit(self, unit_code: int =1) -> bool: + """ Set the pressure units + :param unit_code: (int) Pressure unit code + :return: True on success, False on failure. + + Codes: 0 - mbar, 1 - Torr, 2 - Pascal, 3 - Micron, 4 - hPascal, 5 - Volt + """ + retval = False + if unit_code < 0 or unit_code > 5: + self.logger.error("Invalid pressure unit code: %s\nMust be between 0 and 5 inclusive", + unit_code) + else: + if self._send_command(f"UNI,{unit_code}"): + received = int(self._read_reply()) + if received != unit_code: + self.logger.error("Requested pressure unit code not achieved: %d", received) + else: + retval = True + if 0 <= received <= 5: + self.pressure_units = self.UNIT_CODES[received] + else: + self.logger.error("Invalid pressure unit received: %d", received) + else: + retval = False + return retval + + def get_pressure_unit(self) -> int: + """ Get the pressure units""" + if self._send_command("UNI"): + received = int(self._read_reply()) + self.pressure_units = self.UNIT_CODES[received] + else: + received = None + return received + + def read_temperature(self) -> float: + """ Read temperature from controller.""" + command = "TMP" + try: + self._send_command(command) + except DeviceConnectionError: + self.logger.error("Connection error: %s", command) + raise + except OSError as e: + self.logger.error("Failed to send command: %s", e) + raise DeviceConnectionError("Write failed") from e + + response = self._read_reply() + self.logger.debug("Temperature response: %s", response) + try: + value = float(response) + return value + except ValueError as e: + self.logger.error("Failed to parse response: %s", e) + return sys.float_info.max + + def read_pressure(self, gauge: int = 1) -> float: + """Read pressure from gauge 1 to n. + Returns float, or sys.float_info.max on timeout/parse error.""" + # pylint: disable=too-many-branches + if self.n_gauges == 0: + self.initialize() + if not isinstance(gauge, int) or gauge < 1 or gauge > self.n_gauges: + self.logger.error("gauge number must be between 1 and %d, inclusive", self.n_gauges) + return sys.float_info.max + + # Command format: PR{gauge} + command = f"PR{gauge}" + try: + self._send_command(command) + except DeviceConnectionError: + self.logger.error("Connection error: %s", command) + raise + except OSError as e: + self.logger.error("Failed to send command: %s", e) + raise DeviceConnectionError("Write failed") from e + + # Read acknowledgment line (controller typically replies with ACK/NAK ending CRLF) + response = self._read_reply() + self.logger.debug("Pressure response: %s", response) + + # Expected like: "PR1," + try: + parts = response.split(",") + value = float(parts[1]) + return value + except (IndexError, ValueError, AttributeError) as e: + self.logger.error("Failed to parse response: %s", e) + return sys.float_info.max + + def get_atomic_value(self, item: str ="") -> float: + """ + Read the latest value of a specific channel. + + Args: + item (str): Channel name (e.g., "3A", "Out1") + + Returns: + float: Current value, or NaN if invalid. + """ + if "pressure" in item: + try: + gauge_num = int(item.split("pressure")[-1]) + value = self.read_pressure(gauge=gauge_num) + except ValueError: + self.logger.error("Invalid item: %s", item) + value = sys.float_info.max + elif "temperature" in item: + value = float(self.read_temperature()) + elif "units" in item: + self.get_pressure_unit() + value = self.pressure_units + else: + self.logger.error("Unknown item received: %r", item) + value = sys.float_info.max + return value + + def run_manually(self): + """Input commands manually.""" + while True: + cmd = input("> ") + if not cmd: + break + + if self._send_command(cmd): + ret = self._read_reply() + print(ret) + + print("End.") + +class WrongCommandError(Exception): + """Exception raised when a wrong command is sent.""" + + +class UnknownResponse(Exception): + """Exception raised when an unknown response is received.""" + + +class DeviceConnectionError(Exception): + """Exception raised when a device connection error occurs.""" diff --git a/src/hispec/util/inficon/pyproject.toml b/src/hispec/util/inficon/pyproject.toml new file mode 100644 index 0000000..7ccf399 --- /dev/null +++ b/src/hispec/util/inficon/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "inficon" +version = "0.1.0" +description = "OneWire controller software" +authors = [ + { name="Michael Langmayr", email="langmayr@caltech.edu" }, + { name="Don Neill", email="neill@astro.caltech.edu" }, + { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } +] +readme = "README.md" +requires-python = ">=3.7" +dependencies = [ + "keyring", + "pipython", + "pyserial", + "libximc" +] +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/src/hispec/util/inficon/tests/__init__.py b/src/hispec/util/inficon/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hispec/util/inficon/tests/test_inficonvgc502.py b/src/hispec/util/inficon/tests/test_inficonvgc502.py new file mode 100644 index 0000000..ede3e5f --- /dev/null +++ b/src/hispec/util/inficon/tests/test_inficonvgc502.py @@ -0,0 +1,53 @@ +# tests/test_inficonvgc502_units_sync.py +import pytest +from unittest.mock import MagicMock + +from inficonvgc502 import InficonVGC502, UnknownResponse + + +@pytest.fixture +def vgc502(): + """Creates an InficonVGC502 instance with mock logger.""" + cont = InficonVGC502(log=False) + # cont.connect("127.0.0.1", 8000) + return cont + +def test_set_pressure_unit_success(vgc502): + # Mock low-level I/O so we don't need a real socket + vgc502._send_command = MagicMock() + # Device replies ACK (\x06\r\n) + vgc502._read_reply = MagicMock(return_value="2") + + # Pascal (example: 2) + result = vgc502.set_pressure_unit(2) + assert result is True + + # Verify the command was sent + # vgc502._send_command.assert_called_with("UNI,2\r\n") + + +def test_set_pressure_unit_invalid_value(vgc502): + + result = vgc502.set_pressure_unit(9) # out of allowed range + assert result is False + + +def test_get_pressure_unit(vgc502): + vgc502._send_command = MagicMock() + # First read: ACK to 'UNI\r\n'; Second read: the unit value line '3\r\n' + vgc502._read_reply = MagicMock(return_value="3") + + result = vgc502.get_pressure_unit() + assert result == 3 + + # Ensure UNI and ENQ were written (order matters) + # vgc502._send_command.assert_has_calls([call("UNI\r\n"), call("\x05")]) + + +def test_get_pressure_unit_invalid_response(vgc502): + vgc502._send_command = MagicMock() + # ACK followed by a non-integer value + vgc502._read_reply = MagicMock(side_effect=[b"\x06\r\n", b"X\r\n"]) + + with pytest.raises(ValueError): + vgc502.get_pressure_unit() diff --git a/src/hispec/util/lakeshore/.gitignore b/src/hispec/util/lakeshore/.gitignore new file mode 100644 index 0000000..b7faf40 --- /dev/null +++ b/src/hispec/util/lakeshore/.gitignore @@ -0,0 +1,207 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/src/hispec/util/lakeshore/README.md b/src/hispec/util/lakeshore/README.md new file mode 100644 index 0000000..bc2b8ee --- /dev/null +++ b/src/hispec/util/lakeshore/README.md @@ -0,0 +1,55 @@ +# lakeshore_controller + +Low-level Python modules to send commands to Lakeshore 224 or 336 controllers. + +## Currently Supported Models +- 224 & 336 - lakeshore.py + +## Features +- Connect to Lakeshore controllers over ethernet +- Query sensor values +- For model 336, query status and parameters of heaters + +## Requirements + +- Install base class from https://github.com/COO-Utilities/hardware_device_base + +## Installation + +```bash +pip install . +``` + +## Usage + +```python +import lakeshore + +controller = lakeshore.LakeshoreController() # defaults to 336 +controller.connect('192.168.29.104', 7777) + +# Initialize controller +controller.initialize(celsius=False) # print temperatures in Kelvin + +# Print heater 1 status +print(controller.get_heater_status('1')) + +# Print sensor A temperature +print(controller.get_temperature('a')) + +# Print heater 2 output +print(controller.get_heater_output('2'), controller.outputs['2']['htr_display']) + +# For a comprehensive list of classes and methods, use the help function +help(lakeshore) + +``` + +## 🧪 Testing +Unit tests are located in `tests/` directory. + +To run all tests from the project root: + +```bash +pytest +``` \ No newline at end of file diff --git a/src/hispec/util/lakeshore/__init__.py b/src/hispec/util/lakeshore/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hispec/util/lakeshore/lakeshore.py b/src/hispec/util/lakeshore/lakeshore.py new file mode 100755 index 0000000..8a53dd0 --- /dev/null +++ b/src/hispec/util/lakeshore/lakeshore.py @@ -0,0 +1,424 @@ +#! @KPYTHON3@ +""" Lakeshore 224/336 controller class """ + +from errno import ETIMEDOUT, EISCONN +import socket +import time +from typing import Union + +from hardware_device_base import HardwareDeviceBase + + +class LakeshoreController(HardwareDeviceBase): + """ Handle all correspondence with the ethernet interface of the + Lakeshore 224/336 controller. + """ + + initialized = False + revision = None + success = False + termchars = '\r\n' + + # Heater dictionaries + resistance = {'1': 25, '2': 50} + max_current = {'0': 0.0, '1': 0.707, '2': 1.0, '3': 1.141, '4': 2.0} + htr_display = {'1': 'current', '2': 'power'} + htr_errors = {'0': 'no error', '1': 'heater open load', '2': 'heater short'} + + def __init__(self, log=True, logfile=__name__.rsplit(".", 1)[-1], + opt3062=False, model336=True, celsius=True): + """ Initialize the Lakeshore controller. + :param log: If True, log to file + :param logfile: name of log file (defaults to lakeshore.log) + :param opt3062: set to True if optional 3062 board installed (defaults to False) + :param model336: set to True if controller is model 336 (default), + if False assumes model 224 + :param celsius: set to True to read temperature in Celsius (default), + """ + # pylint: disable=too-many-positional-arguments, too-many-arguments + super().__init__(log, logfile) + self.socket: socket.socket | None = None + self.host: str | None = None + self.port: int = -1 + + self.celsius = celsius + if self.celsius: + self.set_celsius() + self.logger.info("Using Celsius for temperature") + else: + self.set_kelvin() + self.logger.info("Using Kelvin for temperature") + self.model336 = model336 + + self.status = None + + if model336: + if opt3062: + self.sensors = {'A': 1, 'B': 2, 'C': 3, + 'D1': 4, 'D2': 5, 'D3': 6, 'D4': 7, 'D5': 8} + else: + self.sensors = {'A': 1, 'B': 2, 'C': 3, 'D': 4} + + self.outputs = {'1': + {'resistance': None, 'max_current': 0.0, + 'user_max_current': 0.0, 'htr_display': '', + 'status': '', 'p': 0.0, 'i': 0.0, 'd': 0.0}, + '2': + {'resistance': None, 'max_current': 0.0, + 'user_max_current': 0.0, 'htr_display': '', + 'status': '', 'p': 0.0, 'i': 0.0, 'd': 0.0}, + } + else: + # Model 224 + self.sensors = {'A': 1, 'B': 2, + 'C1': 3, 'C2': 4, 'C3': 5, 'C4': 6, 'C5': 7, + 'D1': 8, 'D2': 9, 'D3': 10, 'D4': 11, 'D5': 12} + self.outputs = None + + def disconnect(self) -> None: + """ Disconnect controller. """ + + try: + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + self.socket = None + self.logger.info("Disconnected controller") + self._set_connected(False) + self.success = True + + except OSError as e: + if self.logger: + self.logger.error("Disconnection error: %s", e.strerror) + self._set_connected(False) + self.socket = None + self.success = False + + self.set_status("disconnected") + + def connect(self, *args, con_type: str ="tcp") -> None: + """ Connect to controller. """ + if self.validate_connection_params(args): + if con_type == "tcp": + self.host = args[0] + self.port = args[1] + if self.socket is None: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.socket.connect((self.host, self.port)) + self.logger.info("Connected to %(host)s:%(port)s", { + 'host': self.host, + 'port': self.port + }) + self._set_connected(True) + self.success = True + self.set_status('ready') + + except OSError as e: + if e.errno == EISCONN: + self.logger.debug("Already connected") + self._set_connected(True) + self.success = True + self.set_status('ready') + else: + self.logger.error("Connection error: %s", e.strerror) + self._set_connected(False) + self.success = False + self.set_status('not connected') + # clear socket + if self.is_connected(): + self._clear_socket() + elif con_type == "serial": + self.logger.error("Serial connection not supported") + self._set_connected(False) + else: + self.logger.error("Invalid connection parameters: %s", args) + self._set_connected(False) + + def _clear_socket(self): + """ Clear socket buffer. """ + if self.socket is not None: + self.socket.setblocking(False) + while True: + try: + _ = self.socket.recv(1024) + except BlockingIOError: + break + self.socket.setblocking(True) + + def check_status(self): + """ Check connection status """ + if not self.is_connected(): + status = 'not connected' + elif not self.success: + status = 'unresponsive' + else: + status = 'ready' + + self.set_status(status) + + def set_status(self, status): + """ Set the status of the filter wheel. + + :param status: String, status of the controller. + + """ + status = status.lower() + + if self.status is None: + current = None + else: + current = self.status + + if current != 'locked' or status == 'unlocked': + self.status = status + + def initialize(self): + """ Initialize the lakeshore status. """ + + self.revision = self.command('*idn?') + + if self.model336: + + for htr_items in self.outputs.items(): + htr = htr_items[0] + htr_settings = self.get_heater_settings(htr) + if htr_settings is None: + self.logger.warning("Unable to get settings for htr %s", htr) + else: + resistance, max_current, user_max_current, htr_display = htr_settings + self.outputs[htr]['resistance'] = resistance + self.outputs[htr]['max_current'] = max_current + self.outputs[htr]['user_max_current'] = user_max_current + self.outputs[htr]['htr_display'] = htr_display + + self.outputs[htr]['status'] = self.get_heater_status(htr) + + pid = self.get_heater_pid(htr) + if pid is None: + self.logger.warning("PID not set for htr %s", htr) + else: + p, i, d = pid + self.outputs[htr]['p'] = p + self.outputs[htr]['i'] = i + self.outputs[htr]['d'] = d + + self.initialized = True + + def command(self, command, params=None): + """ Wrapper to issue_command(), ensuring the command lock is + released if an exception occurs. + + :param command: String, command to issue. + :param params: String, parameters to issue. + + """ + + with self.lock: + try: + self.success= self._send_command(command, params) + result = '' + if '?' in command: + result = self._read_reply() + finally: + # Ensure that status is always checked, even on failure + self.check_status() + + return result + + def _send_command(self, command, *args) -> bool: + """ Wrapper to send/receive with error checking and retries. + + :param command: String, command to issue. + :param args: String, parameters to issue. + + """ + if not self.is_connected(): + self.set_status('connecting') + self.connect(self.host, self.port) + + retries = 3 + if args: + send_command = f"{command} {args[0]}{self.termchars}".encode('utf-8') + else: + send_command = f"{command}{self.termchars}".encode('utf-8') + + while retries > 0: + self.logger.debug("sending command %s", send_command) + try: + self.socket.send(send_command) + + except socket.error: + self.logger.error( + "Failed to send command, re-opening socket, %d retries" + " remaining", retries) + self.disconnect() + try: + self.connect(self.host, self.port) + except OSError: + self.logger.error('Could not reconnect to controller, aborting') + return False + retries -= 1 + continue + break + if retries <= 0: + self.logger.error("Failed to send command.") + raise RuntimeError('unable to successfully issue command: ' + repr(command)) + + self.logger.debug("Sent command: %s", send_command) + return True + + def _read_reply(self) -> Union[str, None]: + # Get a reply, if needed. + timeout = 1 + start = time.time() + reply = self.socket.recv(1024) + while self.termchars not in reply.decode('utf-8') and \ + time.time() - start < timeout: + try: + reply += self.socket.recv(1024) + self.logger.debug("reply: %s", reply) + except OSError as e: + if e.errno == ETIMEDOUT: + reply = '' + time.sleep(0.1) + + if reply == '': + # Don't log here, because it happens a lot when the controller + # is unresponsive. Just try again. + continue + + if isinstance(reply, str): + reply = reply.strip() + else: + reply = reply.decode('utf-8').strip() + return reply + + def set_celsius(self): + """ Set units to Celsius. """ + self.celsius = True + + def set_kelvin(self): + """ Set units to Kelvin. """ + self.celsius = False + + def get_temperature(self, sensor): + """ Get sensor temperature. + + :param sensor: String, name of the sensor: A-D or A-C, D1=D5. + + """ + retval = None + if sensor.upper() not in self.sensors: + self.logger.error("Sensor %s is not available", sensor) + else: + if self.celsius: + reply = self.command('crdg?', sensor) + if len(reply) > 0: + retval = float(reply) + else: + reply = self.command('krdg?', sensor) + if len(reply) > 0: + retval = float(reply) + return retval + + def get_heater_settings(self, output): + """ Get heater settings. + + :param output: String, output number of the sensor (1 or 2). + returns resistance, max current, max user current, display. + """ + retval = None + if self.model336: + if output.upper() not in self.outputs: + self.logger.error("Heater %s is not available", output) + else: + reply = self.command('htrset?', output) + if len(reply) > 0: + ires, imaxcur, strusermaxcur, idisp = reply.split(',') + retval = (self.resistance[ires], self.max_current[imaxcur], + float(strusermaxcur), self.htr_display[idisp]) + else: + self.logger.error("Heater is not available with this model") + return retval + + def get_heater_pid(self, output): + """ Get heater PID values. + + :param output: String, output number of the sensor (1 or 2). + returns p,i,d values + """ + retval = None + if self.model336: + if output.upper() not in self.outputs: + self.logger.error("Heater %s is not available", output) + else: + reply = self.command('pid?', output) + if len(reply) > 0: + p, i, d = reply.split(',') + retval = [float(i), float(d), float(p)] + else: + self.logger.error("Heater is not available with this model") + return retval + + def get_heater_status(self, output): + """ Get heater status. + + :param output: String, output number of the sensor (1 or 2). + returns status string + """ + retval = 'unknown' + if self.model336: + if output.upper() not in self.outputs: + self.logger.error("Heater %s is not available", output) + else: + reply = self.command('htrst?', output) + if len(reply) > 0: + reply = reply.strip() + if reply in self.htr_errors: + retval = self.htr_errors[reply] + else: + self.logger.error("Heater error %s and status is unknown", reply) + else: + self.logger.error("Heater is not available with this model") + return retval + + def get_heater_output(self, output): + """ Get heater output. + + :param output: String, output number of the sensor (1 or 2). + returns heater output. + """ + retval = None + if self.model336: + if output.upper() not in self.outputs: + self.logger.error("Heater %s is not available", output) + else: + reply = self.command('htr?', output) + if len(reply) > 0: + reply = reply.strip() + try: + retval = float(reply) + except ValueError: + self.logger.error("Heater output error: %s", reply) + else: + self.logger.error("Heater output error") + else: + self.logger.error("Heater is not available with this model") + return retval + + def get_atomic_value(self, item: str = "") -> Union[float, None]: + """ + Read the latest value of a specific item + :param item: String, name of the item + returns value of item or None + """ + retval = None + if item.upper() in self.sensors or item in self.outputs: + if item.upper() in self.sensors: + retval = self.get_temperature(item) + else: + retval = self.get_heater_output(item) + else: + self.logger.error("Item %s is not available", item) + return retval +# end of class Controller diff --git a/src/hispec/util/lakeshore/pyproject.toml b/src/hispec/util/lakeshore/pyproject.toml new file mode 100644 index 0000000..70a1b3c --- /dev/null +++ b/src/hispec/util/lakeshore/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "lakeshore" +version = "0.1.0" +description = "Lakeshore temperature controller software" +authors = [ + { name="Michael Langmayr", email="langmayr@caltech.edu" }, + { name="Don Neill", email="neill@astro.caltech.edu" }, + { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } +] +readme = "README.md" +requires-python = ">=3.7" +dependencies = [ + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" +] +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/src/hispec/util/lakeshore/tests/test_lakeshore_basic.py b/src/hispec/util/lakeshore/tests/test_lakeshore_basic.py new file mode 100644 index 0000000..c019036 --- /dev/null +++ b/src/hispec/util/lakeshore/tests/test_lakeshore_basic.py @@ -0,0 +1,19 @@ +"""Perform basic tests.""" +from lakeshore import LakeshoreController + +def test_not_connected(): + """Test not connected.""" + controller = LakeshoreController() + assert not controller.connected + +def test_not_initialized(): + """Test isn't initialized.""" + controller = LakeshoreController() + assert not controller.initialized + +def test_connection_fail(): + """Test connection failure.""" + controller = LakeshoreController() + controller.set_connection(ip="127.0.0.1", port=50000) + controller.connect() + assert not controller.connected diff --git a/src/hispec/util/newport/.gitignore b/src/hispec/util/newport/.gitignore new file mode 100644 index 0000000..15201ac --- /dev/null +++ b/src/hispec/util/newport/.gitignore @@ -0,0 +1,171 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc diff --git a/src/hispec/util/newport/LICENSE b/src/hispec/util/newport/LICENSE new file mode 100644 index 0000000..bce361a --- /dev/null +++ b/src/hispec/util/newport/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/src/hispec/util/newport/README.md b/src/hispec/util/newport/README.md new file mode 100644 index 0000000..e756021 --- /dev/null +++ b/src/hispec/util/newport/README.md @@ -0,0 +1,45 @@ +# smc100pp_controller + +Low-level Python modules to send commands to Newport motion controllers. + +## Currently Supported Models +- SMC100PP - smc100pp.py + +## Features +- Connect to Newport controllers over serial through a terminal server +- Query stage state and parameters +- Move individual axes to absolute or relative positions + +## Usage + +```python +import smc100pp + +controller = smc100pp.StageController() +controller.connect(host='192.168.29.100', port=10006) + +# Print stage 1 parameters +print(controller.get_params(1)) + +# Print stage2 state +print(controller.get_state(2)) + +# Move axis 1 to position 12.0 +controller.move_abs(12.0, 1) + +# Move axis 2 to +12 degrees relative to current position +controller.move_rel(12.0, 2) + +# For a comprehensive list of classes and methods, use the help function +help(smc100pp) + +``` + +## 🧪 Testing +Unit tests are located in `tests/` directory. + +To run all tests from the project root: + +```bash +pytest +``` \ No newline at end of file diff --git a/src/hispec/util/newport/pyproject.toml b/src/hispec/util/newport/pyproject.toml new file mode 100644 index 0000000..2eda790 --- /dev/null +++ b/src/hispec/util/newport/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +# Specifies the build system to use. +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" + +[project] +# Basic information about your project. +name = "newport" +version = "0.1.0" +dependencies = [ + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" +] +requires-python = ">=3.7" +authors = [ + {name = "Don Neill", email = "neill@astro.caltech.edu"} +] +maintainers = [ + {name = "Don Neill", email = "neill@astro.caltech.edu"} +] +description = "Newport controller software" +readme = "README.md" +license = { text = "MIT" } +# keywords = ["example", "package", "keywords"] +classifiers = [ + "Programming Language :: Python" +] + +[project.urls] +# Various URLs related to your project. These links are displayed on PyPI. +# Homepage = "https://example.com" +# Documentation = "https://readthedocs.org" +Repository = "https://github.com/COO-Utils/newport" +# "Bug Tracker" = "https://github.com/yourusername/your-repo/issues" +# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" + +[project.scripts] +# Defines command-line scripts for your package. Replace with your script and function. +# your-command = "your_module:your_function" + +[project.optional-dependencies] +# Optional dependencies that can be installed with extra tags, like "dev". +dev = [ + "pytest", + "black", + "flake8" +] +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/src/hispec/util/newport/smc100pp.py b/src/hispec/util/newport/smc100pp.py new file mode 100644 index 0000000..ab4be41 --- /dev/null +++ b/src/hispec/util/newport/smc100pp.py @@ -0,0 +1,878 @@ +# coding=utf-8 +""" +The following stage controller commands are available. Note that many +are not implemented at the moment. The asterisk indicates the commands +that are implemented. + +AC Set/Get acceleration +BA Set/Get backlash compensation +BH Set/Get hysteresis compensation +DV Set/Get driver voltage Not for PP +FD Set/Get low pass filter for Kd Not for PP +FE Set/Get following error limit Not for PP +FF Set/Get friction compensation Not for PP +FR Set/Get stepper motor configuration Not for CC +HT Set/Get HOME search type +ID Set/Get stage identifier +JD Leave JOGGING state +JM Enable/disable keypad +JR Set/Get jerk time +KD Set/Get derivative gain Not for PP +KI Set/Get integral gain Not for PP +KP Set/Get proportional gain Not for PP +KV Set/Get velocity feed forward Not for PP +MM Enter/Leave DISABLE state +OH Set/Get HOME search velocity +OR * Execute HOME search +OT Set/Get HOME search time-out +PA * Move absolute +PR * Move relative +PT Get motion time for a relative move +PW Enter/Leave CONFIGURATION state +QI Set/Get motor’s current limits +RA Get analog input value +RB Get TTL input value +RS * Reset controller +SA Set/Get controller’s RS-485 address +SB Set/Get TTL output value +SC Set/Get control loop state Not for PP +SE Configure/Execute simultaneous started move +SL * Set/Get negative software limit +SR * Set/Get positive software limit +ST Stop motion +SU Set/Get encoder increment value Not for PP +TB Get command error string +TE * Get last command error +TH Get set-point position +TP * Get current position +TS * Get positioner error and controller state +VA Set/Get velocity +VB Set/Get base velocity Not for CC +VE Get controller revision information +ZT * Get all axis parameters +ZX Set/Get SmartStage configuration + +The values below as of 2025-May-19 + +For stage 1 & 2 current values are: +80 - Acceleration +-3600 - negative software limit, from 1SL? ++3600 - positive software limit, from 1SR? +20 - Microstep factor +0.0200682 - Full step value +0.04 - Jerk time in seconds +8 - deg/s Home velocity +1980 - Home timeout in seconds +0.3 - Peak current limit in Amperes +2 - Controller's RS485 address +8 - deg/s Move velocity +0 - deg/s Base velocity +ESP stage check enabled +Home type: use MZ switch only +Backlash and hysteresis compensations are disabled. +""" + +import errno +import logging +import time +import socket +import threading +import sys + + +class StageController: + """ + Controller class for Newport SMC100PP Stage Controller. + """ + # pylint: disable=too-many-instance-attributes + + controller_commands = ["OR", # Execute HOME search + "PA", # Absolute move + "PR", # Move relative + "RS", # Reset controller + "SL", # Set/Get positive software limit + "SR", # Set/Get negative software limit + "TE", # Get last command error + "TP", # Get current position + "TS", # Get positioner error and controller state + "ZT" # Get all axis parameters + ] + return_value_commands = ["TE", "TP", "TS"] + parameter_commands = ["PA", "PR", "SL", "SR"] + end_code_list = ['32', '33', '34', '35'] + not_ref_list = ['0A', '0B', '0C', '0D', '0F', '10', '11'] + moving_list = ['28'] + msg = { + "0A": "NOT REFERENCED from reset.", + "0B": "NOT REFERENCED from HOMING.", + "0C": "NOT REFERENCED from CONFIGURATION.", + "0D": "NOT REFERENCED from DISABLE.", + "0E": "NOT REFERENCED from READY.", + "0F": "NOT REFERENCED from MOVING.", + "10": "NOT REFERENCED ESP stage error.", + "11": "NOT REFERENCED from JOGGING.", + "14": "CONFIGURATION.", + "1E": "HOMING commanded from RS-232-C.", + "1F": "HOMING commanded by SMC-RC.", + "28": "MOVING.", + "32": "READY from HOMING.", + "33": "READY from MOVING.", + "34": "READY from DISABLE.", + "35": "READY from JOGGING.", + "3C": "DISABLE from READY.", + "3D": "DISABLE from MOVING.", + "3E": "DISABLE from JOGGING.", + "46": "JOGGING from READY.", + "47": "JOGGING from DISABLE." + } + error = { + "@": "No error.", + "A": "Unknown message code or floating point controller address.", + "B": "Controller address not correct", + "C": "Parameter missing or out of range.", + "D": "Command not allowed.", + "E": "Home sequence already started.", + "F": "ESP stage name unknown.", + "G": "Displacement out of limits.", + "H": "Command not allowed in NOT REFERENCED state.", + "I": "Command not allowed in CONFIGURATION state.", + "J": "Command not allowed in DISABLE state.", + "K": "Command not allowed in READY state.", + "L": "Command not allowed in HOMING state.", + "M": "Command not allowed in MOVING state.", + "N": "Current position out of software limit.", + "S": "Communication Time Out.", + "U": "Error during EEPROM access.", + "V": "Error durring command execution.", + "W": "Command not allowed for PP version.", + "X": "Command not allowed for CC version." + } + last_error = "" + + def __init__(self, num_stages=2, move_rate=5.0, log=True, + logfile=None): + + """ + Class to handle communications with the stage controller and any faults + + :param num_stages: Int, number of stages daisey-chained + :param move_rate: Float, move rate in degrees per second + :param log: Boolean, whether to log to file or not + :param logfile: Filename for log + + NOTE: default is INFO level logging, use set_verbose to increase verbosity. + """ + + # thread lock + self.lock = threading.Lock() + + # Set up socket + self.socket = None + self.connected = False + + # number of daisy-chained stages + self.num_stages = num_stages + + # stage rate in degrees per second + self.move_rate = move_rate + + # current values + self.current_position = [0.0] * (num_stages + 1) + self.current_limits = [(0., 0.)] * (num_stages + 1) + + # set up logging + self.verbose = False + if log: + if logfile is None: + logfile = __name__.rsplit('.', 1)[-1] + '.log' + self.logger = logging.getLogger(logfile) + self.logger.setLevel(logging.INFO) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + file_handler = logging.FileHandler(logfile) + file_handler.setFormatter(formatter) + self.logger.addHandler(file_handler) + + console_formatter = logging.Formatter( + '%(asctime)s--%(message)s') + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(console_formatter) + self.logger.addHandler(console_handler) + else: + self.logger = None + + def set_verbose(self, verbose=True): + """ Set verbose mode. + + :param verbose: Boolean, set to True to enable DEBUG level messages, + False to disable DEBUG level messages + """ + self.verbose = verbose + if self.logger: + if self.verbose: + self.logger.setLevel(logging.DEBUG) + else: + self.logger.setLevel(logging.INFO) + + def connect(self, host=None, port=None): + """ Connect to stage controller. + + :param host: String, host ip address + :param port: Int, Port number + """ + start = time.time() + if self.socket is None: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.socket.connect((host, port)) + if self.logger: + self.logger.debug("Connected to %(host)s:%(port)s", { + 'host': host, + 'port': port + }) + self.connected = True + ret = {'elaptime': time.time()-start, 'data': 'connected'} + + except OSError as ex: + if ex.errno == errno.EISCONN: + if self.logger: + self.logger.debug("Already connected") + self.connected = True + ret = {'elaptime': time.time()-start, 'data': 'already connected'} + else: + if self.logger: + self.logger.error("Connection error: %s", ex.strerror) + self.connected = False + ret = {'elaptime': time.time()-start, 'error': ex.strerror} + # clear socket + if self.connected: + self.__clear_socket() + + return ret + + def disconnect(self): + """ Disconnect stage controller. """ + start = time.time() + try: + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + self.socket = None + if self.logger: + self.logger.debug("Disconnected controller") + self.connected = False + ret = {'elaptime': time.time()-start, 'data': 'disconnected'} + except OSError as ex: + if self.logger: + self.logger.error("Disconnection error: %s", ex.strerror) + self.connected = False + self.socket = None + ret = {'elaptime': time.time()-start, 'error': ex.strerror} + + return ret + + def __clear_socket(self): + """ Clear socket buffer. """ + if self.socket is not None: + self.socket.setblocking(False) + while True: + try: + _ = self.socket.recv(1024) + except BlockingIOError: + break + self.socket.setblocking(True) + + def __read_value(self): + """ Read return value from controller """ + # Return value commands + + # Get return value + recv = self.socket.recv(2048) + recv_len = len(recv) + if self.logger: + self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) + + # Are we a valid return value? + if recv_len in [6, 11, 12, 13, 14]: + if self.logger: + self.logger.debug("Return value validated") + return str(recv.decode('utf-8')) + + def __read_params(self): + """ Read stage controller parameters """ + # Get return value + recv = self.socket.recv(2048) + + # Did we get all the params? + tries = 5 + while tries > 0 and b'PW0' not in recv: + recv += self.socket.recv(2048) + tries -= 1 + + if b'PW0' in recv: + recv_len = len(recv) + if self.logger: + self.logger.debug("ZT Return: len = %d", recv_len) + else: + if self.logger: + self.logger.warning("ZT command timed out") + + return str(recv.decode('utf-8')) + + def __read_blocking(self, stage_id=1, timeout=15): + """ Block while reading from the controller. + :param stage_id: Int, stage id + :param timeout: Timeout for blocking read + """ + + start = time.time() + + # Non-return value commands eventually return state output + sleep_time = 0.1 + start_time = time.time() + print_it = 0 + recv = None + while time.time() - start_time < timeout: + # Check state + statecmd = f'{stage_id}TS\r\n' + statecmd = statecmd.encode('utf-8') + self.socket.send(statecmd) + time.sleep(sleep_time) + recv = self.socket.recv(1024) + + # Valid state return + if len(recv) == 11: + # Parse state + recv = recv.rstrip() + code = str(recv[-2:].decode('utf-8')) + + # Valid end code or not referenced code (done) + if code in self.end_code_list or code in self.not_ref_list: + return {'elaptime': time.time()-start, + 'data': self.msg.get(code, 'Unknown state')} + + if print_it >= 10: + msg = (f"{time.time()-start:05.2f} " + f"{self.msg.get(code, 'Unknown state'):s}") + if self.logger: + self.logger.info(msg) + else: + print(msg) + print_it = 0 + + # Invalid state return (done) + else: + if self.logger: + self.logger.warning("Bad %dTS return: %s", stage_id, recv) + return {'elaptime': time.time()-start, + 'error': str(recv.decode('utf-8'))} + + # Increment tries and read state again + print_it += 1 + + # If we get here, we ran out of tries + recv = recv.rstrip() + code = str(recv[-2:].decode('utf-8')) + if self.logger: + self.logger.warning("Command timed out, final state: %s", + self.msg.get(code, "Unknown state")) + return {'elaptime': time.time()-start, + 'error': self.msg.get(code, 'Unknown state')} + + def __send_serial_command(self, stage_id=1, cmd=''): + """ + Send serial command to stage controller + + :param stage_id: Int, stage position in the daisy chain starting with 1 + :param cmd: String, command to send to stage controller + :return: + """ + + start = time.time() + + # Prep command + cmd_send = f"{stage_id}{cmd}\r\n" + if self.logger: + self.logger.debug("Sending command:%s", cmd_send) + cmd_encoded = cmd_send.encode('utf-8') + + # check connection + if not self.connected: + msg_type = 'error' + msg_text = "Not connected to controller!" + if self.logger: + self.logger.error(msg_text) + + try: + self.socket.settimeout(30) + # Send command + self.socket.send(cmd_encoded) + time.sleep(.05) + msg_type = 'data' + msg_text = 'Command sent successfully' + + except socket.error as ex: + msg_type = 'error' + msg_text = f"Command send error: {ex.strerror}" + if self.logger: + self.logger.error(msg_text) + + return {'elaptime': time.time()-start, msg_type: msg_text} + + def __send_command(self, cmd="", parameters=None, stage_id=1, custom_command=False): + """ + Send a command to the stage controller + + :param cmd: String, command to send to the stage controller + :param parameters: List of string parameters associated with cmd + :param stage_id: Int, stage position in the daisy chain starting with 1 + :param custom_command: Boolean, if true, command is custom + :return: + """ + + # verify cmd and stage_id + ret = self.__verify_send_command(cmd, stage_id, custom_command) + if 'error' in ret: + return ret + + # Check if the command should have parameters + if cmd in self.parameter_commands and parameters: + if self.logger: + self.logger.debug("Adding parameters") + parameters = [str(x) for x in parameters] + parameters = " ".join(parameters) + cmd += parameters + + if self.logger: + self.logger.debug("Input command: %s", cmd) + + # Send serial command + with self.lock: + result = self.__send_serial_command(stage_id, cmd) + + return result + + def __verify_send_command(self, cmd, stage_id, custom_command=False): + """ Verify cmd and stage_id + + :param cmd: String, command to send to the stage controller + :param stage_id: Int, stage position in the daisy chain starting with 1 + :param custom_command: Boolean, if true, command is custom + :return: dictionary {'elaptime': time, 'data|error': string_message}""" + + start = time.time() + + # Do we have a connection? + if not self.connected: + msg_type = 'error' + msg_text = 'Not connected to controller' + + # Is stage id valid? + elif not self.__verify_stage_id(stage_id): + msg_type = 'error' + msg_text = f"{stage_id} is not a valid stage" + + else: + # Do we have a legal command? + if cmd.rstrip().upper() in self.controller_commands: + msg_type = 'data' + msg_text = f"{cmd} is a valid or custom command" + else: + if not custom_command: + msg_type = 'error' + msg_text = f"{cmd} is not a valid command" + else: + msg_type = 'data' + msg_text = f"{cmd} is a custom command" + + return {'elaptime': time.time() - start, msg_type: msg_text} + + def __verify_stage_id(self, stage_id): + """ Check that the stage id is legal + + :param stage_id: Int, stage position in the daisy chain starting with 1 + :return: True if stage id is legal + """ + if stage_id > self.num_stages or stage_id < 1: + is_valid = False + else: + is_valid = True + + return is_valid + + def __verify_move_state(self, stage_id, position, move_type='absolute'): + """ Verify that the move is allowed + :param stage_id: Int, stage position in the daisy chain starting with 1 + :param position: String, move position + :param move_type: String, move type: 'absolute' or 'relative' + :return: True if move is allowed""" + + start = time.time() + + msg_type = 'data' + msg_text = 'OK to move' + # Verify inputs + if position is None or stage_id is None: + msg_type = 'error' + msg_text = 'must specify both position and stage_id' + else: + # Verify move state + current_state = self.get_state(stage_id=stage_id) + if 'error' in current_state: + msg_type = 'error' + msg_text = current_state['error'] + elif 'READY' not in current_state['data']: + msg_type = 'error' + msg_text = current_state['data'] + else: + # Verify position + if 'absolute' not in move_type: + position += self.current_position[stage_id] + if position < self.current_limits[stage_id][0] or \ + position > self.current_limits[stage_id][1]: + msg_type = 'error' + msg_text = 'position out of range' + ret = {'elaptime': time.time() - start, msg_type: msg_text} + if self.logger: + self.logger.debug("Move state: %s", msg_text) + return ret + + def __return_parse_state(self, message=""): + """ + Parse the return message from the controller. The message code is + given in the last two string characters + + :param message: String message code from the controller + :return: String message + """ + message = message.rstrip() + code = message[-2:] + return self.msg.get(code, "Unknown state") + + def __return_parse_error(self, error=""): + """ + Parse the return error message from the controller. The message code is + given in the last string character + + :param error: Error code from the controller + :return: String message + """ + error = error.rstrip() + code = error[-1:] + return self.error.get(code, "Unknown error") + + def home(self, stage_id=1): + """ + Home the stage + + :param stage_id: Int, stage position in the daisy chain starting with 1 + :return: return from __send_command + """ + + start = time.time() + + if not self.homed(stage_id): + ret = self.__send_command(cmd='OR', stage_id=stage_id) + + if 'error' not in ret: + while 'READY from HOMING' not in ret['data']: + time.sleep(1.) + ret = self.get_state(stage_id) + if 'error' in ret: + break + if self.logger: + self.logger.info(ret['data']) + ret['elaptime'] = time.time() - start + else: + ret = { 'elaptime': time.time()-start, 'data': 'already homed' } + + return ret + + def homed(self, stage_id=1): + """ Is the stage homed? + :param stage_id: Int, stage position in the daisy chain starting with 1 + :return: Boolean, True if homed else False + """ + + state = self.get_state(stage_id=stage_id) + + if 'error' in state: + if self.logger: + self.logger.error(state['error']) + ret = False + + else: + if 'NOT REFERENCED' in state['data']: + ret = False + else: + ret = True + + if self.logger: + self.logger.debug(state['data']) + + return ret + + def move_abs(self, position=None, stage_id=None, blocking=False): + """ + Move stage to absolute position and return when in position + + :param position: Float, absolute position in degrees + :param stage_id: Int, stage position in the daisy chain starting with 1 + :param blocking: Boolean, block until move complete or not + :return: return from __send_command + """ + + start = time.time() + + # Verify we are ready to move + ret = self.__verify_move_state(stage_id=stage_id, position=position) + if 'error' in ret: + if self.logger: + self.logger.error(ret['error']) + return ret + if 'OK to move' not in ret['data']: + if self.logger: + self.logger.error(ret['data']) + return {'elaptime': time.time()-start, 'error': ret['data']} + + # Send move to controller + ret = self.__send_command(cmd="PA", parameters=[position], + stage_id=stage_id) + + if blocking: + move_len = self.current_position[stage_id] - position + if self.move_rate <= 0: + timeout = 5 + else: + timeout = int(abs(move_len / self.move_rate)) + timeout = max(timeout, 5) + if self.logger: + self.logger.info("Timeout for move to absolute position: %d s", + timeout) + ret = self.__read_blocking(stage_id=stage_id, timeout=timeout) + + if 'error' not in ret: + self.current_position[stage_id] = position + + ret['elaptime'] = time.time() - start + return ret + + def move_rel(self, position=None, stage_id=None, blocking=False): + """ + Move stage to relative position and return when in position + + :param position: Float, relative position in degrees + :param stage_id: Int, stage position in the daisy chain starting with 1 + :param blocking: Boolean, block until move complete or not + :return: return from __send_command + """ + + start = time.time() + + # Verify we are ready to move + ret = self.__verify_move_state(stage_id=stage_id, position=position, + move_type='relative') + if 'error' in ret: + if self.logger: + self.logger.error(ret['error']) + return ret + if 'OK to move' not in ret['data']: + if self.logger: + self.logger.error(ret['data']) + return {'elaptime': time.time()-start, 'error': ret['data']} + + ret = self.__send_command(cmd="PR", parameters=[position], + stage_id=stage_id) + + if blocking: + if self.move_rate <= 0: + timeout = 5 + else: + timeout = int(abs(position / self.move_rate)) + timeout = max(timeout, 5) + if self.logger: + self.logger.info("Timeout for move to relative position: %d s", + timeout) + ret = self.__read_blocking(stage_id=stage_id, timeout=timeout) + + if 'error' not in ret: + self.current_position[stage_id] += position + + ret['elaptime'] = time.time() - start + return ret + + def get_state(self, stage_id=1): + """ Current state of the stage + + :param stage_id: int, stage position in the daisy chain starting with 1 + :return: return from __send_command + """ + + start = time.time() + + ret = self.__send_command(cmd="TS", stage_id=stage_id) + if 'error' not in ret: + state = self.__return_parse_state(self.__read_value()) + ret['data'] = state + ret['elaptime'] = time.time() - start + + return ret + + def get_last_error(self, stage_id=1): + """ Last error + + :param stage_id: int, stage position in the daisy chain starting with 1 + :return: return from __send_command + """ + + start = time.time() + + ret = self.__send_command(cmd="TE", stage_id=stage_id) + if 'error' not in ret: + last_error = self.__return_parse_error(self.__read_value()) + ret['data'] = last_error + ret['elaptime'] = time.time() - start + + return ret + + def get_position(self, stage_id=1): + """ Current position + + :param stage_id: int, stage position in the daisy chain starting with 1 + :return: return from __send_command + """ + + start = time.time() + + ret = self.__send_command(cmd="TP", stage_id=stage_id) + if 'error' not in ret: + position = float(self.__read_value().rstrip()[3:]) + self.current_position[stage_id] = position + ret['data'] = position + ret['elaptime'] = time.time() - start + + return ret + + def get_move_rate(self): + """ Current move rate + + :return: return from __send_command + """ + start = time.time() + return {'elaptime': time.time()-start, 'data': self.move_rate} + + def set_move_rate(self, rate=5.0): + """ Set move rate + + :param rate: Float, move rate in degrees per second + :return: dictionary {'elaptime': time, 'data': move_rate} + """ + start = time.time() + if rate > 0: + self.move_rate = rate + else: + if self.logger: + self.logger.error('set_move_rate input error, not changed') + return {'elaptime': time.time()-start, 'data': self.move_rate} + + def reset(self, stage_id=1): + """ Reset stage + + :param stage_id: int, stage position in the daisy chain starting with 1 + :return: return from __send_command + """ + + start = time.time() + + ret = self.__send_command(cmd="RS", stage_id=stage_id) + time.sleep(2.) + + if 'error' not in ret: + self.read_from_controller() + + ret['elaptime'] = time.time() - start + return ret + + def get_limits(self, stage_id=1): + """ Get stage limits + :param stage_id: int, stage position in the daisy chain starting with 1 + :return: return from __send_command + """ + start = time.time() + ret = self.__send_command(cmd="SL", parameters="?", stage_id=stage_id) + if 'error' not in ret: + lolim = int(self.__read_value().rstrip()[3:]) + ret = self.__send_command(cmd="SR", parameters="?", stage_id=stage_id) + if 'error' not in ret: + uplim = int(self.__read_value().rstrip()[3:]) + self.current_limits[stage_id] = (lolim, uplim) + ret = {'elaptime': time.time()-start, + 'data': self.current_limits[stage_id]} + return ret + + def get_params(self, stage_id=1, quiet=False): + """ Get stage parameters + + :param stage_id: int, stage position in the daisy chain starting with 1 + :param quiet: Boolean, do not print parameters + :return: return from __send_command + """ + + start = time.time() + + ret = self.__send_command(cmd="ZT", stage_id=stage_id) + + if 'error' not in ret: + params = self.__read_params() + if not quiet: + for param in params.split(): + if 'PW' not in param: + print(param) + ret['data'] = params + ret['elaptime'] = time.time() - start + + return ret + + def initialize_controller(self): + """ Initialize stage controller. """ + start = time.time() + for i in range(self.num_stages): + self.get_position(i+1) + self.get_limits(i+1) + return {'elaptime': time.time()-start, 'data': 'initialized'} + + def read_from_controller(self): + """ Read from controller""" + self.socket.setblocking(False) + try: + recv = self.socket.recv(2048) + recv_len = len(recv) + if self.logger: + self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) + except BlockingIOError: + recv = b"" + self.socket.setblocking(True) + return str(recv.decode('utf-8')) + + def run_manually(self, stage_id=1): + """ Input stage commands manually + + :param stage_id: int, stage position in the daisy chain starting with 1 + :return: None + """ + + while True: + + cmd = input("Enter Command") + + if not cmd: + break + + ret = self.__send_command(cmd=cmd, stage_id=stage_id, + custom_command=True) + if 'error' not in ret: + output = self.read_from_controller() + print(output) + + if self.logger: + self.logger.debug("End: %s", ret) diff --git a/src/hispec/util/newport/tests/test_basic.py b/src/hispec/util/newport/tests/test_basic.py new file mode 100644 index 0000000..9dbdc05 --- /dev/null +++ b/src/hispec/util/newport/tests/test_basic.py @@ -0,0 +1,15 @@ +"""Perform basic tests.""" +import pytest +from smc100pp import StageController + +def test_initialization(): + """Test initialization.""" + controller = StageController() + assert not controller.connected + +def test_connection_fail(): + """Test connection failure.""" + with pytest.raises(Exception): + controller = StageController() + controller.connect(host="127.0.0.1", port=50000) + assert not controller.connected diff --git a/src/hispec/util/onewire/.gitignore b/src/hispec/util/onewire/.gitignore new file mode 100644 index 0000000..b7faf40 --- /dev/null +++ b/src/hispec/util/onewire/.gitignore @@ -0,0 +1,207 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/src/hispec/util/onewire/README.md b/src/hispec/util/onewire/README.md new file mode 100644 index 0000000..4d156a7 --- /dev/null +++ b/src/hispec/util/onewire/README.md @@ -0,0 +1,29 @@ +# Onewire Environmental Sensor + +Low-level Python modules to communicate with Embedded Data Systems OW-SERVER device. +It supports reading temperature, humidity, dew point, humidex, heat index, +pressure, and illuminance from OW-ENV sensor (pressure and illuminance are only +available on supported sensors). + +## Requirements + +- Python 3.7+ +- Install base class from https://github.com/COO-Utilities/hardware_device_base + +### Running from a Python Terminal + +```python +from onewire import ONEWIRE + +ow = ONEWIRE() +ow.connect("192.168.29.154", 80) + +ow.get_data() +print(ow.ow_data.read_sensors()) +ow.get_data() +print(ow.ow_data.read_sensors()) +``` +### NOTE +The OneWire disconnects after each call to get_data(), but the host and port +are stored after the first connection, so subsequent calls to get_data() will +reconnect. \ No newline at end of file diff --git a/src/hispec/util/onewire/__init__.py b/src/hispec/util/onewire/__init__.py new file mode 100644 index 0000000..5facccb --- /dev/null +++ b/src/hispec/util/onewire/__init__.py @@ -0,0 +1,4 @@ +"""Initialize the ONEWIRE module.""" +from .onewire import ONEWIRE + +__all__ = ["ONEWIRE"] diff --git a/src/hispec/util/onewire/onewire.py b/src/hispec/util/onewire/onewire.py new file mode 100644 index 0000000..fd0d658 --- /dev/null +++ b/src/hispec/util/onewire/onewire.py @@ -0,0 +1,371 @@ +""" +Onewire Controller Interface +""" +import socket +import xml.etree.ElementTree as ET +from dataclasses import dataclass, field, asdict +import sys +from typing import List, Union + +from hardware_device_base import HardwareDeviceBase + +PARAMETER_QUERY = "GET /details.xml HTTP/1.1\r\n\r\n" + +@dataclass +class EDS0065DATA: + """Class to hold data from EDS0065""" + # pylint: disable=too-many-instance-attributes + rom_id: str = None + device_type: str = None + health: int = None + channel: int = None + raw_data: str = None + relative_humidity: float = None + temperature: float = None + humidity: float = None + dew_point: float = None + humidex: float = None + heat_index: float = None + version: float = None + +@dataclass +class EDS0068DATA: + """Class to hold data from EDS0068""" + # pylint: disable=too-many-instance-attributes + rom_id: str = None + device_type: str = None + health: int = None + channel: int = None + raw_data: str = None + relative_humidity: float = None + temperature: float = None + humidity: float = None + dew_point: float = None + humidex: float = None + heat_index: float = None + pressure_mb: float = None + pressure_hg: float = None + illuminance: int = None + version: float = None + +@dataclass +class ONEWIREDATA: + """Class to hold data from OneWire""" + # pylint: disable=too-many-instance-attributes + poll_count: int = None + total_devices: int = None + loop_time: float = None + ch1_connected: int = None + ch2_connected: int = None + ch3_connected: int = None + ch1_error: int = None + ch2_error: int = None + ch3_error: int = None + ch1_voltage: float = None + ch2_voltage: float = None + ch3_voltage: float = None + voltage_power: float = None + device_name: str = None + hostname: str = None + mac_address: str = None + datetime: str = None + eds0065_data: List[EDS0065DATA] = field(default_factory=list) + eds0068_data: List[EDS0068DATA] = field(default_factory=list) + + def read_sensors(self): + """Method to read sensor data from OneWire""" + sensors = [] + for sensor in self.eds0065_data: + sensors.append(asdict(sensor)) + for sensor in self.eds0068_data: + sensors.append(asdict(sensor)) + + return sensors + + def read_temperature(self): + """Method to read temperature data from OneWire""" + temperatures = [] + for sensor in self.eds0065_data: + if sensor.temperature is not None: + temperature = {"rom_id": sensor.rom_id, "temperature": sensor.temperature} + temperatures.append(temperature) + for sensor in self.eds0068_data: + if sensor.temperature is not None: + temperature = {"rom_id": sensor.rom_id, "temperature": sensor.temperature} + temperatures.append(temperature) + + return temperatures + + def read_humidity(self): + """Method to read humidity data from OneWire""" + humidities = [] + for sensor in self.eds0065_data: + if sensor.humidity is not None: + humidity = {"rom_id": sensor.rom_id, "humidity": sensor.humidity} + humidities.append(humidity) + for sensor in self.eds0068_data: + if sensor.humidity is not None: + humidity = {"rom_id": sensor.rom_id, "humidity": sensor.humidity} + humidities.append(humidity) + + return humidities + +class ONEWIRE(HardwareDeviceBase): + """Class for interfacing with OneWire""" + # pylint: disable=too-many-instance-attributes + def __init__(self, timeout=1, log=True, logfile=__name__.rsplit(".", 1)[-1]): + """Instantiate a OneWire device""" + + super().__init__(log, logfile) + + self.host = None + self.port = 80 + self.timeout = timeout + self.sock: socket.socket | None = None + + self.ow_data = ONEWIREDATA() + + def connect(self, *args, con_type="tcp") -> None: + """Method to connect to OneWire""" + if self.validate_connection_params(args): + if con_type == "tcp": + self.host = args[0] + self.port = args[1] + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.connect((self.host, self.port)) + self.sock.settimeout(self.timeout) + self._set_connected(True) + self.logger.info("Connected to OneWire at %s:%d", self.host, self.port) + except (ConnectionRefusedError, OSError) as err: + raise DeviceConnectionError( + f"Could not connect to {self.host}:{self.port} {err}" + ) from err + else: + self.logger.error() + raise DeviceConnectionError(f"Connection type not supported: {con_type}") + else: + self._set_connected(False) + self.logger.error("Invalid connection arguments: %s", args) + + def disconnect(self): + """ + Close the connection to the controller. + """ + try: + if self.sock: + self.sock.close() + self._set_connected(False) + self.logger.info('Closed connection to controller') + except Exception as ex: + raise IOError(f"Failed to close connection: {ex}") from ex + + def _send_command(self, command: str, *args) -> bool: + """ + Send a message to the controller (adds newline). + + Args: + command (str): The message to send (e.g., '3A?'). + """ + try: + self.logger.debug('Sending: %s', command) + with self.lock: + self.sock.sendall(command.encode("ascii")) + except Exception as ex: + raise IOError(f'Failed to write message: {ex}') from ex + return True + + def _read_reply(self) -> bytes: + """ + Read a response from the controller. + + Returns: + str: The received message, stripped of trailing newline. + """ + try: + retval = self.sock.recv(25000) + self.logger.debug('Received: %s', retval.decode("ascii")) + return retval + except Exception as ex: + raise IOError(f"Failed to _read_reply message: {ex}") from ex + + def get_atomic_value(self, item: str ="") -> Union[float, int, str, None]: + """Get the atomic value from the controller.""" + self.logger.warning("""Not implemented, use: + > controller.get_data() + > data = controller.ow_data.read_sensors() + """) + + def get_data(self): + """Method to get data from OneWire""" + if not self.is_connected(): + self.connect(self.host, self.port) + self._send_command(PARAMETER_QUERY) + + response = self._read_reply() + + http_response = response.decode("ascii").split("\r\n")[0] + try: + self.__http_response_handler(http_response) + except HttpResponseError as err: + print(err) + sys.exit(1) + + while b'' not in response: + response += self.sock.recv(1024) + # at this point the server has dropped the connection, so disconnect + self.disconnect() + + response = response.decode("ascii") + xml_data = response.split("?>\r\n")[1] + self.__xml_data_handler(xml_data) + + def __http_response_handler(self, response): + response_code = int(response.split(' ')[1]) + + if response_code != 200: + raise HttpResponseError(f"Http response error: {response_code}") + + def __xml_data_handler(self, xml_data): + root = ET.fromstring(xml_data) + + for elem in root.iter(): + tag_elements = elem.tag.split("}") + elem.tag = tag_elements[1] + + if self.logger: + self.logger.debug("XML data received: %s", ET.tostring(root, encoding='unicode')) + # ET.dump(root) + # for elem in root.iter(): + # print(elem.tag, elem.attrib, elem.text) + + for elem in root.iter(): + self.__device_data_handler(elem) + + def __device_data_handler(self, element): + # pylint: disable=too-many-branches + if element.tag == "PollCount": + self.ow_data.poll_count = int(element.text) + elif element.tag == "DevicesConnected": + self.ow_data.total_devices = int(element.text) + elif element.tag == "LoopTime": + self.ow_data.loop_time = float(element.text) + elif element.tag == "DevicesConnectedChannel1": + self.ow_data.ch1_connected = int(element.text) + elif element.tag == "DevicesConnectedChannel2": + self.ow_data.ch2_connected = int(element.text) + elif element.tag == "DevicesConnectedChannel3": + self.ow_data.ch3_connected = int(element.text) + elif element.tag == "DataErrorsChannel1": + self.ow_data.ch1_error = int(element.text) + elif element.tag == "DataErrorsChannel2": + self.ow_data.ch2_error = int(element.text) + elif element.tag == "DataErrorsChannel3": + self.ow_data.ch3_error = int(element.text) + elif element.tag == "VoltageChannel1": + self.ow_data.ch1_voltage = float(element.text) + elif element.tag == "VoltageChannel2": + self.ow_data.ch2_voltage = float(element.text) + elif element.tag == "VoltageChannel3": + self.ow_data.ch3_voltage = float(element.text) + elif element.tag == "VoltagePower": + self.ow_data.voltage_power = float(element.text) + elif element.tag == "DeviceName": + self.ow_data.device_name = str(element.text) + elif element.tag == "HostName": + self.ow_data.hostname = str(element.text) + elif element.tag == "MACAddress": + self.ow_data.mac_address = str(element.text) + elif element.tag == "DateTime": + self.ow_data.datetime = str(element.text) + elif element.tag == "owd_EDS0065": + self.__sensor_data_handler(element, sensor_type="EDS0065") + elif element.tag == "owd_EDS0068": + self.__sensor_data_handler(element, sensor_type="EDS0068") + + def __sensor_data_handler(self, element, sensor_type): + # pylint: disable=too-many-branches,too-many-statements + if sensor_type == "EDS0065": + eds0065_data = EDS0065DATA() + for sensor in element: + if sensor.tag == "ROMId": + eds0065_data.rom_id = str(sensor.text) + elif sensor.tag == "Name": + eds0065_data.device_type = str(sensor.text) + elif sensor.tag == "Health": + eds0065_data.health = int(sensor.text) + elif sensor.tag == "Channel": + eds0065_data.channel = int(sensor.text) + elif sensor.tag == "RawData": + eds0065_data.raw_data = str(sensor.text) + elif sensor.tag == "PrimaryValue": + data = sensor.text.split(" ")[0] + eds0065_data.relative_humidity = float(data) + elif sensor.tag == "Temperature": + eds0065_data.temperature = float(sensor.text) + elif sensor.tag == "Humidity": + eds0065_data.humidity = float(sensor.text) + elif sensor.tag == "DewPoint": + eds0065_data.dew_point = float(sensor.text) + elif sensor.tag == "Humidex": + eds0065_data.humidex = float(sensor.text) + elif sensor.tag == "HeatIndex": + eds0065_data.heat_index = float(sensor.text) + elif sensor.tag == "Version": + eds0065_data.version = float(sensor.text) + + self.ow_data.eds0065_data.append(eds0065_data) + elif sensor_type == "EDS0068": + eds0068_data = EDS0068DATA() + for sensor in element: + # print(sensor.tag, sensor.attrib, sensor.text) + if sensor.tag == "ROMId": + eds0068_data.rom_id = str(sensor.text) + elif sensor.tag == "Name": + eds0068_data.device_type = str(sensor.text) + elif sensor.tag == "Health": + eds0068_data.health = int(sensor.text) + elif sensor.tag == "Channel": + eds0068_data.channel = int(sensor.text) + elif sensor.tag == "RawData": + eds0068_data.raw_data = str(sensor.text) + elif sensor.tag == "PrimaryValue": + data = sensor.text.split(" ")[0] + eds0068_data.relative_humidity = float(data) + elif sensor.tag == "Temperature": + eds0068_data.temperature = float(sensor.text) + elif sensor.tag == "Humidity": + eds0068_data.humidity = float(sensor.text) + elif sensor.tag == "DewPoint": + eds0068_data.dew_point = float(sensor.text) + elif sensor.tag == "Humidex": + eds0068_data.humidex = float(sensor.text) + elif sensor.tag == "HeatIndex": + eds0068_data.heat_index = float(sensor.text) + elif sensor.tag == "BarometricPressureMb": + eds0068_data.pressure_mb = float(sensor.text) + elif sensor.tag == "BarometricPressureHg": + eds0068_data.pressure_hg = float(sensor.text) + elif sensor.tag == "Light": + eds0068_data.illuminance = int(sensor.text) + elif sensor.tag == "Version": + eds0068_data.version = float(sensor.text) + self.ow_data.eds0068_data.append(eds0068_data) + +class HttpResponseError(Exception): + """Response Error from OneWire""" + # pass + +class DeviceConnectionError(Exception): + """Device Connection Error from OneWire""" + # pass + + +if __name__ == "__main__": + OW_ADDRESS = "hs1wireblue" + OW_PORT = 80 + ow = ONEWIRE() + ow.connect(OW_ADDRESS, OW_PORT) + ow.get_data() + ow_sensors = ow.ow_data.read_sensors() + print(ow_sensors) diff --git a/src/hispec/util/onewire/pyproject.toml b/src/hispec/util/onewire/pyproject.toml new file mode 100644 index 0000000..46ee8f3 --- /dev/null +++ b/src/hispec/util/onewire/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "onewire" +version = "0.1.0" +description = "OneWire controller software" +authors = [ + { name="Michael Langmayr", email="langmayr@caltech.edu" }, + { name="Don Neill", email="neill@astro.caltech.edu" }, + { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } +] +readme = "README.md" +requires-python = ">=3.7" +dependencies = [ + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" +] +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/src/hispec/util/onewire/scripts/influxdb_log.json b/src/hispec/util/onewire/scripts/influxdb_log.json new file mode 100644 index 0000000..eb0c271 --- /dev/null +++ b/src/hispec/util/onewire/scripts/influxdb_log.json @@ -0,0 +1,29 @@ +{ + "db_url": "localhost:8086", + "db_token": "", + "db_org": "Organization", + "db_bucket": "bucket", + "db_channel": "Temperature", + "log_channels": { + "temperature": {"field": "temperature", + "units": "degC"}, + "humidity": {"field": "humidity", + "units": "Perc"}, + "dew_point": {"field": "dewpoint", + "units": "degC"} + }, + "log_locations": { + "1": "Rack loc1", + "2": "Rack loc2", + "3": "Rack loc3", + "4": "Rack loc4", + "5": "Rack loc5", + "6": "Rack loc6", + "7": "Rack loc7" + }, + "device_host": "onewire", + "device_port": 80, + "interval_secs": 30, + "verbose": 0, + "logfile": "" +} \ No newline at end of file diff --git a/src/hispec/util/onewire/scripts/influxdb_log.py b/src/hispec/util/onewire/scripts/influxdb_log.py new file mode 100644 index 0000000..e64d53a --- /dev/null +++ b/src/hispec/util/onewire/scripts/influxdb_log.py @@ -0,0 +1,108 @@ +"""Script for logging to InfluxDB.""" +import time +import sys +import json +import logging +from influxdb_client import InfluxDBClient, Point +from influxdb_client.client.write_api import SYNCHRONOUS +from urllib3.exceptions import ReadTimeoutError +import onewire + + +def main(config_file): + """Query user for setup info and start logging to InfluxDB.""" + # pylint: disable=too-many-statements,too-many-locals + + # read the config file + with open(config_file, encoding='utf-8') as cfg_file: + cfg = json.load(cfg_file) + + verbose = cfg['verbose'] == 1 + + # set up logging + logfile = cfg['logfile'] + if logfile is None: + logfile = __name__.rsplit('.', 1)[-1] + logger = logging.getLogger(logfile) + if verbose: + logger.setLevel(logging.DEBUG) + else: + logger.setLevel(logging.INFO) + # log to console by default + console_formatter = logging.Formatter( + '%(asctime)s - %(levelname)s - %(message)s') + console_handler = logging.StreamHandler() + console_handler.setFormatter(console_formatter) + logger.addHandler(console_handler) + # Do we have a logfile? + if cfg['logfile'] is not None: + # log to a file + formatter = logging.Formatter( + '%(asctime)s - %(levelname)s - %(funcName)s() - %(message)s') + file_handler = logging.FileHandler(logfile if ".log" in logfile else logfile + '.log') + file_handler.setFormatter(formatter) + logger.addHandler(file_handler) + + # get channels to log + channels = cfg['log_channels'] + locations = cfg['log_locations'] + + # Try/except to catch exceptions + db_client = None + try: + # Loop until ctrl-C + while True: + try: + # Connect to onewire + ow = onewire.ONEWIRE() + logger.info('Connecting to OneWire controller...') + ow.connect(cfg['device_host'], cfg['device_port']) + ow.get_data() + ow_data = ow.ow_data.read_sensors() + + # Connect to InfluxDB + logger.info('Connecting to InfluxDB...') + db_client = InfluxDBClient(url=cfg['db_url'], token=cfg['db_token'], + org=cfg['db_org']) + write_api = db_client.write_api(write_options=SYNCHRONOUS) + + for sens_no, sensor in enumerate(ow_data): + location = locations[str(sens_no+1)] + for chan in channels: + value = sensor[chan] + point = ( + Point("onewire") + .field(channels[chan]['field']+str(sens_no+1), value) + .tag("location", location) + .tag("units", channels[chan]['units']) + .tag("channel", f"{cfg['db_channel']}") + ) + write_api.write(bucket=cfg['db_bucket'], org=cfg['db_org'], record=point) + logger.debug(point) + + # Close db connection + logger.info('Closing connection to InfluxDB...') + db_client.close() + db_client = None + + # Handle exceptions + except ReadTimeoutError as e: + logger.critical("ReadTimeoutError: %s, will retry.", e) + except Exception as e: + logger.critical("Unexpected error: %s, will retry.", e) + + # Sleep for interval_secs + logger.info("Waiting %d seconds...", cfg['interval_secs']) + time.sleep(cfg['interval_secs']) + + except KeyboardInterrupt: + logger.critical("Shutting down InfluxDB logging...") + if db_client: + db_client.close() + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: python influxdb_log.py ") + sys.exit(0) + main(sys.argv[1]) diff --git a/src/hispec/util/onewire/tests/test_onewire_basic.py b/src/hispec/util/onewire/tests/test_onewire_basic.py new file mode 100644 index 0000000..28e1d7d --- /dev/null +++ b/src/hispec/util/onewire/tests/test_onewire_basic.py @@ -0,0 +1,16 @@ +"""Perform basic tests.""" +import pytest + +import onewire +from onewire import ONEWIRE + +def test_not_connected(): + """Test not connected.""" + controller = ONEWIRE() + assert not controller.connected + +def test_connection_fail(): + """Test connection failure.""" + controller = ONEWIRE() + with pytest.raises(onewire.DeviceConnectionError): + controller.connect("127.0.0.1", 9999) diff --git a/src/hispec/util/ozoptics/.gitignore b/src/hispec/util/ozoptics/.gitignore new file mode 100644 index 0000000..15201ac --- /dev/null +++ b/src/hispec/util/ozoptics/.gitignore @@ -0,0 +1,171 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc diff --git a/src/hispec/util/ozoptics/LICENSE b/src/hispec/util/ozoptics/LICENSE new file mode 100644 index 0000000..bce361a --- /dev/null +++ b/src/hispec/util/ozoptics/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/src/hispec/util/ozoptics/README.md b/src/hispec/util/ozoptics/README.md new file mode 100644 index 0000000..577aba2 --- /dev/null +++ b/src/hispec/util/ozoptics/README.md @@ -0,0 +1,48 @@ +# ozoptics_software + +Low-level python modules for operating OZ Optics attenuators + +## Currently Supported Models +- DD-100-MC (RS232) +- DD-600-MC (RS232) + +## Features +- Connect to OZ Optics attenuators over serial through a terminal server +- Query attenuator state and parameters +- Command full range of attenuation values + +## Requirements + +- Install base class from https://github.com/COO-Utilities/hardware_device_base + +## Installation + +```bash +pip install . +``` + +## Usage + +```python +import dd100mc + +controller = dd100mc.OZController() +controller.connect('192.168.29.153', 10001) + +controller.set_attenuation(36.5) +print(controller.get_attenuation()) +controller.set_position(5750) +print(controller.get_position()) + +# For a comprehensive list of classes and methods, use the help function +help(dd100mc) +``` + +## 🧪 Testing +Unit tests are located in `tests/` directory. + +To run all tests from the project root: + +```bash +python -m pytest +``` \ No newline at end of file diff --git a/src/hispec/util/ozoptics/dd100mc.py b/src/hispec/util/ozoptics/dd100mc.py new file mode 100644 index 0000000..62d04c1 --- /dev/null +++ b/src/hispec/util/ozoptics/dd100mc.py @@ -0,0 +1,621 @@ +# coding=utf-8 +""" +The following controller commands are available. Not that many are not implemented at the moment. +The asterisk indicates the commands that are implemented. + +A * - Sets attenuation to . + Two digits to the right of the decimal point are allowed, but not required. +A? * - Gets attenuation. +B * - Steps the attenuator one step backward. + Returns the final position after the command is completed. +CD - Sends the current unit configuration only to the RS-232 communications port. +CH - Sets the I2C address to the hexadecimal address + when the address is a valid I2C address between 0x00 and 0x7F. +CI - Sets the I2C address to the decimal address + when the address is a valid I2C address between 0 and 127. +CS - Sets the SPI parameters to and , + where is clock polarity and is data position. +D * - Gets current attenuation and step position. +E0 * - In RS-232 mode, sets echo to OFF. + The unit does not echo any characters received through the RS-232 interface. +E1 * - In RS-232 mode, sets echo to ON. + The unit echoes all characters received through the RS-232 interface. +EVA8 - Set the unit in EVA8 mode +EVA9 - Set the unit in EVA9 mode +EVA? - Requests the current EVA configuration mode +F * - Steps the attenuator one step forward. +H * - Re-homes the unit. +I2C? - Gets I2C/SPI bus Voltage +I2C3 - Sets I2C/SPI bus voltage to 3.3V +I2C5 - Sets I2C/SPI bus voltage to 5.0V +L - Sets the unit’s insertion loss to . + Two digits following the decimal point are allowed, but not required. +RES? * - Read previous command response +RST * - Restarts in response to a hardware or software reset (RST) command + and is in self-test mode. +S? * - Requests the current position of the attenuator. + Returns the current number of steps from the home position. +S * - Sets the position of the attenuator to steps from the home position. +S+ * - Sets the step position of the attenuator to steps + numerically greater than the current position. +S- * - Sets the step position of the attenuator to steps + numerically less than the current position. +W - Selects the wavelength using . + This command is valid only when the unit is calibrated for more than one wavelength. + +""" +import dataclasses +import enum +import errno +import time +import socket +from typing import Union + +from hardware_device_base import HardwareDeviceBase + +class ResponseType(enum.Enum): + """Controller response types.""" + ATTEN = "attenuation" + POS = "steps" + DIFF = "diff" + BOTH = "attenuation and steps" + STRING = "string" + ERROR = "error" + + +@dataclasses.dataclass +class OzResponse: + """Oz controller response data.""" + type: ResponseType + value: Union[float, int, str, dict, None] + + +class OZController(HardwareDeviceBase): + """ + Controller class for OZ Optics DD-100-MC Attenuator Controller. + """ + # pylint: disable=too-many-instance-attributes + + controller_commands = ["A", # Set attenuation + "A?", # Get attenuation + "B", # Move attenuator one step backward + "CD", # Configuration Display + "D", # Gets current attenuation and step position + "E0", # In RS232 mode, sets echo to OFF + "E1", # In RS232 mode, sets echo to ON + "F", # Move attenuator one step forward + "H", # Re-homes the unit + "L", # Insertion loss + "RES?", # Read previous command response + "RST", # Restarts in self-test mode + "S?", # Requests current position of the attenuator + "S", # Sets the position of the attenuator to steps from home + "S+", # Adds steps to current position + "S-" # Subtracts steps from current position + ] + return_value_commands = ["A", "A?", "B", "CD", "D", "F", "H", "L", + "RES?", "RST", "S?", "S", "S+", "S-" ] + parameter_commands = ["A", "L", "S", "S+", "S-"] + error = { + "Done": "No error.", + "Error-2": "Bad command. The command is ignored.", + "Error-5": "Home sensor error. Return unit to factory for repair.", + "Error-6": "Overflow. The command is ignored.", + "Error-7": "Motor voltage exceeds safe limits" + } + + def __init__(self, log: bool =True, logfile: str =__name__.rsplit(".", 1)[-1]): + + """ + Class to handle communications with the stage controller and any faults + + :param log: Boolean, whether to log to file or not + :param logfile: Filename for log + + NOTE: default is INFO level logging, use set_verbose to increase verbosity. + """ + super().__init__(log, logfile) + + # Set up socket + self.socket = None + + self.current_attenuation = None + self.current_position = None + self.current_diff = None + self.configuration = "" + self.homed = False + self.last_error = "" + + def _clear_socket(self): + """ Clear socket buffer. """ + if self.socket is not None: + self.socket.setblocking(False) + while True: + try: + _ = self.socket.recv(1024) + except BlockingIOError: + break + self.socket.setblocking(True) + + def _read_reply(self) -> dict: + """Read the return message from stage controller.""" + # Get return value + recv = self.socket.recv(2048) + + # Did we get the entire return? + tries = 5 + while tries > 0 and b'Done' not in recv: + recv += self.socket.recv(2048) + if b'Error' in recv: + self.logger.error(recv) + return {'error': self._return_parse_error(str(recv.decode('utf-8')))} + tries -= 1 + + recv_len = len(recv) + self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) + + if b'Done' not in recv: + self.logger.warning("Read from controller timed out") + msg_type = 'error' + msg_data = str(recv.decode('utf-8')) + else: + resp = self._parse_response(str(recv.decode('utf-8'))) + msg_data = resp.value + if resp.type == ResponseType.ERROR: + msg_type = 'error' + else: + msg_type = 'data' + + return {msg_type: msg_data} + + def _parse_response(self, raw: str) -> OzResponse: + """Parse the response from stage controller.""" + # pylint: disable=too-many-branches + raw = raw.strip() + + if 'Pos:' in raw: + try: + pos = int(raw.split('Pos:')[1].split()[0]) + self.current_position = pos + pos_read = True + except ValueError: + self.logger.error("Error parsing position") + pos = None + pos_read = False + else: + pos = None + pos_read = False + + if 'Atten:' in raw: + try: + if 'unknown' in raw: + atten = None + else: + atten = float(raw.split('Atten:')[1].split('(')[0]) + self.current_attenuation = atten + atten_read = True + except ValueError: + self.logger.error("Error parsing attenuation") + atten = None + atten_read = False + else: + atten = None + atten_read = False + + # Diff (after homing) + if 'Diff=' in raw: + try: + diff = float(raw.split('Diff=')[1].split()[0]) + self.current_diff = diff + self.current_position = 0 + diff_read = True + except ValueError: + self.logger.error("Error parsing diff") + diff = None + diff_read = False + else: + diff = None + diff_read = False + + # Error case + if 'Error' in raw: + return OzResponse(ResponseType.ERROR, raw) + + # Both Attenuation and Steps + if pos_read and atten_read: + return OzResponse(ResponseType.BOTH, {"pos": pos, "atten": atten}) + + # Attenuation + if atten_read: + return OzResponse(ResponseType.ATTEN, atten) + + # Pos + if pos_read: + return OzResponse(ResponseType.POS, pos) + + # Diff (after homing) + if diff_read: + return OzResponse(ResponseType.DIFF, diff) + + # Default to string + return OzResponse(ResponseType.STRING, raw) + + def _send_serial_command(self, cmd=''): + """ + Send serial command to stage controller + + :param cmd: String, command to send to stage controller + :return: dictionary {'data|error': string_message} + """ + + # check connection + if not self.connected: + msg_text = "Not connected to controller!" + self.logger.error(msg_text) + + # Prep command + cmd_send = f"{cmd}\r\n" + self.logger.debug("Sending command: %s", cmd_send) + cmd_encoded = cmd_send.encode('utf-8') + + try: + self.socket.settimeout(30) + # Send command + self.socket.send(cmd_encoded) + time.sleep(.05) + msg_type = 'data' + msg_text = 'Command sent successfully' + + except socket.error as ex: + msg_type = 'error' + msg_text = f"Command send error: {ex.strerror}" + self.logger.error(msg_text) + + return {msg_type: msg_text} + + def _send_command(self, command: str, *args, custom_command=False) -> dict: + """ + Send a command to the stage controller + + :param command: String, command to send to the stage controller + :param *args: List of string parameters associated with cmd + :param custom_command: Boolean, if true, command is custom + :return: dictionary {'data|error': string_message} + """ + + # verify cmd and stage_id + ret = self._verify_send_command(command, custom_command) + if 'error' in ret: + return ret + + # Check if the command should have parameters + if command in self.parameter_commands and args: + self.logger.debug("Adding parameters") + parameters = [str(x) for x in args] + parameters = "".join(parameters) + command += parameters + + self.logger.debug("Input command: %s", command) + + # Send serial command + with self.lock: + result = self._send_serial_command(command) + + return result + + def _verify_send_command(self, cmd, custom_command=False): + """ Verify cmd and stage_id + + :param cmd: String, command to send to the stage controller + :param custom_command: Boolean, if true, command is custom + :return: dictionary {'data|error': string_message}""" + + # Do we have a connection? + if not self.connected: + msg_type = 'error' + msg_text = 'Not connected to controller' + + else: + # Do we have a legal command? + if cmd.rstrip().upper() in self.controller_commands: + msg_type = 'data' + msg_text = f"{cmd} is a valid or custom command" + else: + if not custom_command: + msg_type = 'error' + msg_text = f"{cmd} is not a valid command" + else: + msg_type = 'data' + msg_text = f"{cmd} is a custom command" + + return {msg_type: msg_text} + + def _return_parse_error(self, error=""): + """ + Parse the return error message from the controller. The message code is + given in the last string character + + :param error: Error code from the controller + :return: String message + """ + error = error.rstrip() + return self.error.get(error, "Unknown error") + + # --- User-Facing Methods + def connect(self, *args, con_type: str="tcp") -> None: + """ Connect to stage controller. + + :param args: for tcp connection, host and port, for serial, port and baudrate + :param con_type: tcp or serial + """ + if self.validate_connection_params(args): + if con_type == "tcp": + host = args[0] + port = args[1] + if self.socket is None: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.socket.connect((host, port)) + self.logger.info("Connected to %s:%d", host, port) + self._set_connected(True) + + except OSError as ex: + if ex.errno == errno.EISCONN: + self.logger.debug("Already connected") + self._set_connected(True) + else: + self.logger.error("Connection error: %s", ex.strerror) + self._set_connected(False) + # clear socket + if self.is_connected(): + self._clear_socket() + elif con_type == "serial": + self.logger.error("Serial connection not implemented") + self._set_connected(False) + else: + self.logger.error("Unknown con_type: %s", con_type) + self._set_connected(False) + else: + self.logger.error("Invalid connection args: %s", args) + self._set_connected(False) + + def disconnect(self): + """ Disconnect stage controller. """ + try: + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + self.socket = None + self.logger.debug("Disconnected controller") + self._set_connected(False) + except OSError as ex: + self.logger.error("Disconnection error: %s", ex.strerror) + self._set_connected(False) + self.socket = None + + def home(self): + """ + Home the stage + + :return: return from __send_command + """ + + if not self.homed: + ret = self._send_command('H') + self.current_attenuation = None + self.current_position = None + + if 'data' in ret: + ret = self._read_reply() + if 'error' in ret: + self.logger.error(ret['error']) + else: + self.logger.debug(ret['data']) + self.homed = True + else: + self.logger.error(ret['error']) + else: + ret = {'data': 'already homed' } + + return ret + + def get_atomic_value(self, item: str ="") -> Union[float, int, str, None]: + """Return single value for item""" + if "pos" in item: + result = self.get_position() + if 'error' in result: + self.logger.error(result['error']) + value = None + else: + value = int(result['data']) + elif "atten" in item: + value = self.current_attenuation + else: + self.logger.error("Unknown item: %s, choose pos or atten", item) + value = None + return value + + def set_attenuation(self, atten: float=None): + """ + Move stage to input attenuation and return when in position + + :param atten: Float, absolute attenuation in dB (0. - 60.) + :return: dictionary {'data|error': current_attenuation|string_message} + """ + # check attenuation limits + if atten is None or atten < 0.0 or atten > 60.0: + self.logger.error("Invalid attenuation: %s, cannot be < 0. or > 60.", atten) + return {'error': 'Invalid attenuation'} + + # Send move to controller + ret = self._send_command("A", atten) + + if 'data' in ret: + ret = self._read_reply() + if 'error' in ret: + self.logger.error(ret['error']) + else: + time.sleep(0.5) + cur_atten = self.get_attenuation()['data'] + self.logger.debug(cur_atten) + if cur_atten != atten: + self.logger.error("Attenuation setting not achieved!") + return {'data': cur_atten} + + return ret + + def set_position(self, pos=None): + """ + Move stage to absolute position and return when in position + + :param pos: Int, absolute position in steps + :return: dictionary {'data|error': current_attenuation|string_message} + """ + + # Send move to controller + ret = self._send_command("S", pos) + + if 'data' in ret: + ret = self._read_reply() + if 'error' in ret: + self.logger.error(ret['error']) + else: + time.sleep(0.5) + cur_pos = ret['data'] + self.logger.debug(cur_pos) + if cur_pos != pos: + self.logger.error("Position not achieved!") + self.get_attenuation() + return {'data': cur_pos} + + return ret + + def step(self, direction:str = 'F'): + """ + Move stage to relative position and return when in position + :param direction: String, 'F' - forward or 'B' - backward + :return: dictionary {'data|error': current_position|string_message} + """ + direc = direction.upper() + # check inputs + if direc not in ['F', 'B']: + self.logger.error("Invalid direction: use F or B") + return {'error': 'Invalid direction'} + + ret = self._send_command(direc) + if 'data' in ret: + ret = self._read_reply() + if 'error' in ret: + self.logger.error(ret['error']) + else: + self.logger.debug(ret['data']) + cur_pos = ret['data'] + if cur_pos != self.current_position: + self.logger.error("Position setting not achieved!") + self.current_position = cur_pos + return {'data': cur_pos} + return ret + + def get_position(self): + """ Current position + + :return: dictionary {'data|error': current_position|string_message} + """ + + ret = self._send_command("S?") + if 'data' in ret: + ret = self._read_reply() + if 'error' in ret: + self.logger.error(ret['error']) + else: + self.logger.debug(ret['data']) + return ret + + def get_attenuation(self): + """ Current attenuation + + :return: dictionary {'data|error': current_attenuation|string_message} + """ + + ret = self._send_command("A?") + if 'data' in ret: + ret = self._read_reply() + if 'error' in ret: + self.logger.error(ret['error']) + else: + self.logger.debug(ret['data']) + return ret + + def reset(self): + """ Reset stage + + :return: return from __send_command + """ + + ret = self._send_command("RS") + time.sleep(2.) + + if 'data' in ret: + ret = self._read_reply() + if 'error' in ret: + self.logger.error(ret['error']) + else: + self.logger.debug(ret['data']) + + return ret + + def get_params(self): + """ Get stage parameters + + :return: return from __send_command + """ + + ret = self._send_command("CD") + + if 'data' in ret: + ret = self._read_reply() + if 'error' in ret: + self.logger.error(ret['error']) + else: + self.logger.debug(ret['data']) + self.configuration = ret['data'] + + return ret + + def initialize_controller(self): + """ Initialize stage controller. """ + ret = self.home() + if 'error' in ret: + self.logger.error(ret['error']) + return ret + + def read_from_controller(self): + """ Read from controller""" + self.socket.setblocking(False) + try: + recv = self.socket.recv(2048) + recv_len = len(recv) + self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) + except BlockingIOError: + recv = b"" + self.socket.setblocking(True) + return str(recv.decode('utf-8')) + + def run_manually(self): + """ Input stage commands manually + + :return: None + """ + + while True: + + cmd = input("Enter Command") + + if not cmd: + break + + ret = self._send_command(cmd, custom_command=True) + if 'error' not in ret: + output = self.read_from_controller() + self.logger.info(output) + + self.logger.info("End: %s", ret) diff --git a/src/hispec/util/ozoptics/pyproject.toml b/src/hispec/util/ozoptics/pyproject.toml new file mode 100644 index 0000000..a3380a0 --- /dev/null +++ b/src/hispec/util/ozoptics/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +# Specifies the build system to use. +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" + +[project] +# Basic information about your project. +name = "ozoptics" +version = "0.1.0" +dependencies = [ + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" +] +requires-python = ">=3.7" +authors = [ + {name = "Don Neill", email = "neill@astro.caltech.edu"} +] +maintainers = [ + {name = "Don Neill", email = "neill@astro.caltech.edu"} +] +description = "OZ Optics attenuator software" +readme = "README.md" +license = { text = "MIT" } +# keywords = ["example", "package", "keywords"] +classifiers = [ + "Programming Language :: Python" +] + +[project.urls] +# Various URLs related to your project. These links are displayed on PyPI. +# Homepage = "https://example.com" +# Documentation = "https://readthedocs.org" +Repository = "https://github.com/COO-Utils/ozoptics" +# "Bug Tracker" = "https://github.com/yourusername/your-repo/issues" +# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" + +[project.scripts] +# Defines command-line scripts for your package. Replace with your script and function. +# your-command = "your_module:your_function" + +[project.optional-dependencies] +# Optional dependencies that can be installed with extra tags, like "dev". +dev = [ + "pytest" +] +[tool.pytest.ini_options] + pythonpath = [ + "." + ] diff --git a/src/hispec/util/ozoptics/tests/test_basic.py b/src/hispec/util/ozoptics/tests/test_basic.py new file mode 100644 index 0000000..7241664 --- /dev/null +++ b/src/hispec/util/ozoptics/tests/test_basic.py @@ -0,0 +1,13 @@ +"""Perform basic tests.""" +from dd100mc import OZController + +def test_initialization(): + """Test initialization.""" + controller = OZController() + assert not controller.connected + +def test_connection_fail(): + """Test connection failure.""" + controller = OZController() + controller.connect("127.0.0.1", 50000) + assert not controller.connected diff --git a/src/hispec/util/pi/.gitignore b/src/hispec/util/pi/.gitignore new file mode 100644 index 0000000..15201ac --- /dev/null +++ b/src/hispec/util/pi/.gitignore @@ -0,0 +1,171 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc diff --git a/src/hispec/util/pi/LICENSE b/src/hispec/util/pi/LICENSE new file mode 100644 index 0000000..bce361a --- /dev/null +++ b/src/hispec/util/pi/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/src/hispec/util/pi/README.md b/src/hispec/util/pi/README.md new file mode 100644 index 0000000..3431485 --- /dev/null +++ b/src/hispec/util/pi/README.md @@ -0,0 +1,99 @@ +# pi_controller + +Low-level Python library to control PI 863 and PI 663 motion controllers using the [pipython](https://pypi.org/project/pipython/) library. + +## Features +- Connect to a single PI controller over TCP/IP, including through terminal server +- Connect to a TCP/IP-based daisy chain via a terminal server +- Automatically detect and select connected devices in a daisy chain +- Manage multiple controllers in the same daisy chain +- Log controller actions and errors (with optional quiet mode) +- Check the reference (home) state and execute reference moves (homing) +- Store and recall named positions per controller +- Query and set servo status (open/close firmware loops) +- Query motion status +- Emergency halt of motion +- Structured error handling via `pipython.GCSError` + +## Example Usage +```python +from pi import PIControllerBase + +# Connect to a Single Controller +controller = PIControllerBase() +controller.connect_tcp('192.168.0.100') +device_key = ('192.168.0.100', 50000, 1) + +print(controller.get_idn(device_key)) + +# Connect to a Daisy Chain +controller = PIControllerBase() +controller.connect_tcpip_daisy_chain("192.168.29.100", 10005) + +# List available devices +devices = controller.list_devices_on_chain("192.168.29.100", 10005) +for device_id, desc in devices: + print(f"Device {device_id}: {desc}") + +# Use a device_key for further operations +device_key = ("192.168.29.100", 10005, 2) +print("Now on device 2:", controller.get_idn(device_key)) + +# Check if axis '1' is referenced +is_referenced = controller.is_controller_referenced(device_key, '1') +print("Axis 1 referenced:", is_referenced) + +# Perform a reference move (home) on axis '1' +success = controller.reference_move(device_key, '1', method="FRF", blocking=True, timeout=30) +if success: + print("Reference move completed successfully.") +else: + print("Reference move failed or timed out.") + +# Move axis 1 to position 12.0 +controller.set_position(device_key, '1', 12.0) + +# Save current position as "home" +controller.set_named_position(device_key, '1', 'home') + +# Later on, move back to "home" position +controller.go_to_named_position(device_key, 'home') + +controller.disconnect_device(device_key) +controller.disconnect_all() +``` + +## API Summary +| Method | Description | +|----------------------------------------------|----------------------------------------------| +| `connect_tcp(ip, port=50000)` | Connect to a single PI controller | +| `connect_tcpip_daisy_chain(ip, port)` | Open a TCP-based daisy chain | +| `list_devices_on_chain(ip, port)` | Return list of connected devices for a chain | +| `get_idn(device_key)` | Get the controller identification string | +| `get_serial_number(device_key)` | Get the serial number from the IDN | +| `get_axes(device_key)` | Return available axes | +| `get_position(device_key, axis_index)` | Get current position of axis by index | +| `servo_status(device_key, axis)` | Check if the servo on an axis is enabled | +| `get_error_code(device_key)` | Get the controller's last error code | +| `halt_motion(device_key)` | Stop all motion on the controller | +| `set_position(device_key, axis, position)` | Move an axis to a position | +| `set_named_position(device_key, axis, name)` | Save a position under a named label | +| `go_to_named_position(name)` | Move to a previously saved named position | +| `disconnect_device(device_key)` | Disconnect a single device | +| `disconnect_all()` | Disconnect all devices | + + +## Logging +By default, the controller logs info and error messages to the console. You can suppress logs (except warnings/errors) by passing quiet=True: +```python +controller = PIControllerBase(quiet=True) +``` + +## 🧪 Testing +Unit tests are located in `tests/` directory and use `pytest` with `unittest.mock` to simulate hardware behavior — no physical PI controller is required. + +To run all tests from the project root: + +```bash +pytest tests/ +``` \ No newline at end of file diff --git a/src/hispec/util/pi/__init__.py b/src/hispec/util/pi/__init__.py new file mode 100644 index 0000000..072b75e --- /dev/null +++ b/src/hispec/util/pi/__init__.py @@ -0,0 +1,4 @@ +"""This module provides a base class for PI controllers.""" +from .pi_controller import PIControllerBase + +__all__ = ["PIControllerBase"] diff --git a/src/hispec/util/pi/pi_controller.py b/src/hispec/util/pi/pi_controller.py new file mode 100644 index 0000000..0815a9e --- /dev/null +++ b/src/hispec/util/pi/pi_controller.py @@ -0,0 +1,355 @@ +""" +This module provides a base class for communicating with PI (Physik Instrumente) motion controllers +""" +import json +import os +import time +import logging +from pipython import GCSDevice, GCSError + + +class PIControllerBase: # pylint: disable=too-many-public-methods + """ + Base class for communicating with PI (Physik Instrumente) motion controllers daisy-chained + over TCP/IP via a terminal server. + """ + + def __init__(self, quiet=False): + """ + Initialize the controller, set up logging, and prepare device storage. + """ + self.devices = {} # {(ip, port, device_id): GCSDevice instance} + self.daisy_chains = {} # {(ip, port): [(device_id, desc)]} + self.connected = False + self.named_position_file = "config/pi_named_positions.json" + + # Logging + logfile = __name__.rsplit('.', 1)[-1] + '.log' + self.logger = logging.getLogger(logfile) + self.logger.setLevel(logging.INFO) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + if not quiet: + file_handler = logging.FileHandler(logfile) + file_handler.setFormatter(formatter) + self.logger.addHandler(file_handler) + + def _require_connection(self): + """ + Raise an error if not connected to any device. + """ + if not self.connected: + raise RuntimeError("Controller is not connected") + + def connect_tcp(self, ip_address, port=50000): + """ + Connect to a single PI controller via TCP/IP (non-daisy-chain). + """ + device = GCSDevice() + device.ConnectTCPIP(ip_address, port) + self.devices[(ip_address, port, 1)] = device + self.connected = True + self.logger.info("Connected to single PI controller at %s:%s", ip_address, port) + + def connect_tcpip_daisy_chain(self, ip_address, port, blocking=True): + """ + Connect to all available devices on a daisy-chained set of PI controllers via TCP/IP. + Each device is a separate GCSDevice instance. + """ + main_device = GCSDevice() + devices = main_device.OpenTCPIPDaisyChain(ip_address, port) + dcid = main_device.dcid + + available = [] + for index, desc in enumerate(devices, start=1): + if "not connected" not in desc.lower(): + available.append((index, desc)) + + if not available: + raise RuntimeError(f"No connected devices found at {ip_address}:{port}") + + self.daisy_chains[(ip_address, port)] = available + + for device_id, desc in available: + if device_id == 1: + dev = main_device + else: + dev = GCSDevice() + + dev.ConnectDaisyChainDevice(device_id, dcid) + self.devices[(ip_address, port, device_id)] = dev + self.logger.info("[{ip}:{port}] Connected to device %s: %s", device_id, desc) + + self.connected = True + + if blocking: + # Wait until all devices are ready + while not all(dev.IsControllerReady() for dev in self.devices.values()): + time.sleep(0.1) + + def disconnect_device(self, device_key): + """ + Disconnect from a single device specified by device_key. + """ + if device_key in self.devices: + self.devices[device_key].CloseConnection() + del self.devices[device_key] + self.logger.info("Disconnected device %s", device_key) + if not self.devices: + self.connected = False + + def disconnect_all(self): + """ + Disconnect from all devices (e.g., the whole daisychain). + """ + for device_key in list(self.devices.keys()): + self.devices[device_key].CloseConnection() + self.logger.info("Disconnected device %s", device_key) + self.devices.clear() + self.connected = False + self.logger.info("Disconnected from all PI controllers") + + def list_devices_on_chain(self, ip_address, port): + """ + Return the list of available (device_id, description) tuples for the given daisy chain. + """ + if (ip_address, port) not in self.daisy_chains: + raise ValueError(f"No daisy chain found at {ip_address}:{port}") + return self.daisy_chains[(ip_address, port)] + + def is_connected(self) -> bool: + """ + Check if the controller is connected to any device. + """ + return self.connected + + def get_idn(self, device_key) -> str: + """ + Return the identification string for the specified device. + """ + self._require_connection() + return self.devices[device_key].qIDN() + + def get_serial_number(self, device_key) -> str: + """ + Return the serial number for the specified device. + """ + idn = self.get_idn(device_key) + return idn.split(",")[-2].strip() + + def get_axes(self, device_key): + """ + Return the list of axes for the specified device. + """ + self._require_connection() + return self.devices[device_key].axes + + def get_position(self, device_key, axis): + """ + Return the position of the specified axis for the given device. + """ + self._require_connection() + device = self.devices[device_key] + try: + return device.qPOS(axis)[axis] + except (GCSError, IndexError) as ex: + self.logger.error("Error getting position: %s", ex) + return None + + def servo_status(self, device_key, axis): + """ + Return True if the servo for the given axis is enabled, False otherwise. + """ + self._require_connection() + try: + return bool(self.devices[device_key].qSVO(axis)[axis]) + except GCSError as ex: + self.logger.error("Error checking servo status: %s", ex) + return False + + def get_error_code(self, device_key): + """ + Return the error code for the specified device, or None if an error occurs. + """ + self._require_connection() + try: + return self.devices[device_key].qERR() + except GCSError as ex: + self.logger.error("Error getting error code: %s", ex) + return None + + def halt_motion(self, device_key): + """ + Halt all motion for the specified device. + """ + self._require_connection() + try: + self.devices[device_key].HLT() + except GCSError as ex: + self.logger.error("Error halting motion: %s", ex) + + def set_position(self, device_key, axis, position, blocking=True): + """ + Move the specified axis to the given position for the specified device. + If blocking=True, wait until move is complete. + """ + self._require_connection() + try: + self.devices[device_key].MOV(axis, position) + if blocking: + while self.is_moving(device_key, axis): + time.sleep(0.1) + + except GCSError as ex: + self.logger.error("Error setting position: %s", ex) + + def set_named_position(self, device_key, axis, name): + """ + Save the current position of the axis under a named label, scoped to + the controller serial number. + """ + device = self.devices[device_key] + try: + pos = device.qMOV(axis)[axis] + except (GCSError, OSError, ValueError): + pos = self.get_position(device_key, axis) + + if pos is None: + self.logger.warning("Could not get position for axis %s", axis) + return + + serial = self.get_serial_number(device_key) + positions = {} + + if os.path.exists(self.named_position_file): + with open(self.named_position_file, "r") as file: + try: + positions = json.load(file) + except json.JSONDecodeError: + self.logger.warning( + "Could not parse JSON from %s", self.named_position_file + ) + + if serial not in positions: + positions[serial] = {} + + positions[serial][name] = [axis, pos] + + with open(self.named_position_file, "w") as file: + json.dump(positions, file, indent=2) + + self.logger.info( + "Saved position '%s' for controller %s, axis %s: %s", name, serial, axis, pos + ) + + def go_to_named_position(self, device_key, name, blocking=True): + """ + Move the specified device's axis to a previously saved named position. + """ + serial = self.get_serial_number(device_key) + + if not os.path.exists(self.named_position_file): + self.logger.warning( + "Named positions file not found: %s", self.named_position_file + ) + return + + try: + with open(self.named_position_file, "r") as file: + positions = json.load(file) + except json.JSONDecodeError: + self.logger.warning( + "Failed to read positions from %s", self.named_position_file + ) + return + + if serial not in positions: + self.logger.warning("No named positions found for controller %s", serial) + return + + if name not in positions[serial]: + self.logger.warning( + "Named position '%s' not found for controller %s", name, serial + ) + return + + axis, pos = positions[serial][name] + self.set_position(device_key, axis, pos, blocking) + self.logger.info( + "Moved axis %s to named position '%s' for controller %s: %s", axis, name, serial, pos + ) + + def is_moving(self, device_key, axis): + """Check if stage/axis is moving.""" + self._require_connection() + return self.devices[device_key].IsMoving(axis)[axis] + + def set_servo(self, device_key, axis, enable=True): + """Open (enable) or close (disable) servo loop.""" + self._require_connection() + return self.devices[device_key].SVO(axis, int(enable)) + + def get_limit_min(self, device_key, axis): + """Query stage minimum limit.""" + self._require_connection() + return self.devices[device_key].qTMN(axis)[axis] + + def get_limit_max(self, device_key, axis): + """Query stage maximum limit.""" + self._require_connection() + return self.devices[device_key].qTMX(axis)[axis] + + def is_controller_ready(self, device_key): + """Check if stage/controller is ready.""" + self._require_connection() + return self.devices[device_key].IsControllerReady() + + def is_controller_referenced(self, device_key, axis): + """Check reference/home state for axis.""" + self._require_connection() + return self.devices[device_key].qFRF(axis)[axis] + + def reference_move(self, device_key, axis, method="FRF", blocking=True, timeout=30): # pylint:disable=too-many-arguments + """ + Execute a reference/home move (FRF, FNL, FPL). + method: which command to use ("FRF", "FNL", "FPL") + blocking: if True, wait until move is complete + Returns True if successful, False otherwise. + """ + self._require_connection() + allowed_methods = {"FRF", "FNL", "FPL"} + if method not in allowed_methods: + self.logger.error( + "Invalid reference method: %s. Must be one of %s", method, allowed_methods + ) + return False + + device = self.devices[device_key] + + # Check if the device supports the specified method + if not getattr(device, "Has%s", method)(): + self.logger.error("Device %s does not support method '%s'", device_key, method) + return False + + try: + getattr(device, method)(axis) + self.logger.info( + "Started reference move '%s' on axis %s (device %s)", method, axis, device_key + ) + if blocking: + start_time = time.time() + while self.is_moving(device_key, axis): + if time.time() - start_time > timeout: + self.logger.error( + "Reference move timed out after %s seconds on axis %s", timeout, axis + ) + return False + time.sleep(0.1) + + return True + except (GCSError, OSError, ValueError) as ex: + self.logger.error( + "Error during reference move '%s' on axis %s: %s", method, axis, ex + ) + return False diff --git a/src/hispec/util/pi/pyproject.toml b/src/hispec/util/pi/pyproject.toml new file mode 100644 index 0000000..b28c9e0 --- /dev/null +++ b/src/hispec/util/pi/pyproject.toml @@ -0,0 +1,50 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +# Basic information about your project. +name = "pi" +version = "0.1.0" +dependencies = [ + "pipython" +] +requires-python = ">=3.9" +authors = [ + {name = "Michael Langmayr", email = "langmayr@caltech.edu"} +] +maintainers = [ + {name = "Michael Langmayr", email = "langmayr@caltech.edu"} +] +description = "Low-level Python library to control PI 863 and PI 663 motion controllers using the [pipython](https://pypi.org/project/pipython/) library." +readme = "README.md" +license = "MIT" +classifiers = [ + "Programming Language :: Python" +] + +[project.urls] +# Various URLs related to your project. These links are displayed on PyPI. +# Homepage = "https://example.com" +# Documentation = "https://readthedocs.org" +Repository = "https://github.com/COO-Utils/pi" +# "Bug Tracker" = "https://github.com/yourusername/your-repo/issues" +# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" + +[project.optional-dependencies] +# Optional dependencies that can be installed with extra tags, like "dev". +dev = [ + "pytest", + "black", + "flake8" +] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.setuptools.packages.find] +where = ["."] + +[tool.setuptools] +py-modules = ["pi"] + diff --git a/src/hispec/util/pi/tests/__init__.py b/src/hispec/util/pi/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hispec/util/pi/tests/test_pi_basic.py b/src/hispec/util/pi/tests/test_pi_basic.py new file mode 100644 index 0000000..a172dcf --- /dev/null +++ b/src/hispec/util/pi/tests/test_pi_basic.py @@ -0,0 +1,18 @@ +""" +# Test for the PIControllerBase class from the hispec.util module +""" +import pytest +# pylint: disable=import-error, no-name-in-module +from pi import PIControllerBase + +def test_initialization(): + """Test that the PIControllerBase initializes correctly.""" + controller = PIControllerBase() + assert not controller.connected + + +def test_connection_fail(): + """Test that connecting to an invalid IP raises an exception.""" + with pytest.raises(Exception): + controller = PIControllerBase() + controller.connect_tcp(ip_address="10.0.0.1", port=50000) diff --git a/src/hispec/util/pi/tests/test_pi_mock.py b/src/hispec/util/pi/tests/test_pi_mock.py new file mode 100644 index 0000000..5146efb --- /dev/null +++ b/src/hispec/util/pi/tests/test_pi_mock.py @@ -0,0 +1,207 @@ +"""Test cases for the PIControllerBase class in pi_controller module.""" +import json +from unittest.mock import MagicMock, patch +# pylint: disable=import-error, no-name-in-module +from pi import PIControllerBase + + +@patch("pi.pi_controller.GCSDevice") +def test_connect_tcpip_daisy_chain(mock_gcs_device_cls): + """Test connecting to a TCP/IP daisy chain.""" + mock_device = MagicMock() + mock_device.OpenTCPIPDaisyChain.return_value = [ + "PI Device 1", + "PI Device 2", + "", + ] + mock_device.dcid = 1 + mock_gcs_device_cls.return_value = mock_device + + controller = PIControllerBase() + controller.connect_tcpip_daisy_chain("192.168.29.100", 10003) + + mock_device.OpenTCPIPDaisyChain.assert_called_once_with("192.168.29.100", 10003) + assert controller.connected + assert controller.daisy_chains[("192.168.29.100", 10003)] == [ + (1, "PI Device 1"), + (2, "PI Device 2"), + ] + assert (1, "PI Device 1") in controller.daisy_chains[("192.168.29.100", 10003)] + assert (2, "PI Device 2") in controller.daisy_chains[("192.168.29.100", 10003)] + assert ("192.168.29.100", 10003, 1) in controller.devices + assert ("192.168.29.100", 10003, 2) in controller.devices + + +def test_list_devices_on_chain(): + """Test listing devices on a daisy chain.""" + controller = PIControllerBase(quiet=True) + ip_port = ("192.168.29.100", 10003) + controller.daisy_chains[ip_port] = [(1, "PI Device 1"), (2, "PI Device 2")] + + devices = controller.list_devices_on_chain(*ip_port) + assert devices == [(1, "PI Device 1"), (2, "PI Device 2")] + + +@patch("pi.pi_controller.GCSDevice") +def test_connect_disconnect_device(mock_gcs_device_cls): + """Test connecting and disconnecting a device.""" + mock_device = MagicMock() + mock_gcs_device_cls.return_value = mock_device + + controller = PIControllerBase(quiet=True) + controller.connect_tcp("127.0.0.1", 50000) + device_key = ("127.0.0.1", 50000, 1) + + mock_device.ConnectTCPIP.assert_called_once_with("127.0.0.1", 50000) + assert controller.connected + + controller.disconnect_device(device_key) + mock_device.CloseConnection.assert_called_once() + assert not controller.connected + + +def test_disconnect_all(): + """Test disconnecting all devices.""" + mock_device1 = MagicMock() + mock_device2 = MagicMock() + controller = PIControllerBase(quiet=True) + controller.devices[("ip", 1, 1)] = mock_device1 + controller.devices[("ip", 1, 2)] = mock_device2 + controller.connected = True + + controller.disconnect_all() + mock_device1.CloseConnection.assert_called_once() + mock_device2.CloseConnection.assert_called_once() + assert not controller.devices + assert not controller.connected + + +def test_get_serial_number(): + """Test getting the serial number of a device.""" + controller = PIControllerBase(quiet=True) + controller.connected = True + device_key = ("ip", 1, 1) + device = MagicMock() + device.qIDN.return_value = "PI,Model,123456,1.0.0" + controller.devices[device_key] = device + + serial = controller.get_serial_number(device_key) + assert serial == "123456" + + +@patch("pi.pi_controller.GCSDevice") +def test_get_position(mock_gcs_device_cls): + """Test getting the position of an axis.""" + mock_device = MagicMock() + mock_gcs_device_cls.return_value = mock_device + + controller = PIControllerBase(quiet=True) + controller.connected = True + device_key = ("ip", 1, 1) + mock_device.axes = ["1", "2"] + mock_device.qPOS.return_value = {"1": 42.0} + controller.devices[device_key] = mock_device + + pos = controller.get_position(device_key, "1") + assert pos == 42.0 + mock_device.qPOS.assert_called_once_with("1") + + +def test_set_named_position(tmp_path): + """Test setting a named position.""" + controller = PIControllerBase(quiet=True) + controller.named_position_file = tmp_path / "positions.json" + controller.connected = True + device_key = ("ip", 1, 1) + device = MagicMock() + device.axes = ["1"] + # Mock qMOV to return a real float value + device.qMOV.return_value = {"1": 10.0} + controller.devices[device_key] = device + controller.get_serial_number = MagicMock(return_value="123456") + + controller.set_named_position(device_key, "1", "home") + + with open(controller.named_position_file) as file: + data = json.load(file) + + assert "123456" in data + assert "home" in data["123456"] + assert data["123456"]["home"][1] == 10.0 + + +def test_go_to_named_position(tmp_path): + """Test going to a named position.""" + controller = PIControllerBase(quiet=True) + controller.named_position_file = tmp_path / "positions.json" + controller.connected = True + device_key = ("ip", 1, 1) + device = MagicMock() + controller.devices[device_key] = device + controller.get_serial_number = MagicMock(return_value="123456") + controller.set_position = MagicMock() + + # Prepare a named position file + named_positions = {"123456": {"home": ["1", 42.0]}} + with open(controller.named_position_file, "w") as file: + json.dump(named_positions, file) + + controller.go_to_named_position(device_key, "home", blocking=False) + controller.set_position.assert_called_once_with(device_key, "1", 42.0, False) + + +@patch("pi.pi_controller.GCSDevice") +def test_reference_move_success(mock_gcs_device_cls): + """Test successful reference move.""" + mock_device = MagicMock() + mock_device.IsMoving.return_value = {"1": False} + mock_gcs_device_cls.return_value = mock_device + + controller = PIControllerBase(quiet=True) + controller.connected = True + device_key = ("ip", 1, 1) + controller.devices[device_key] = mock_device + + # Test allowed method + for method in ["FRF", "FNL", "FPL"]: + getattr(mock_device, method).reset_mock() + result = controller.reference_move( + device_key, "1", method=method, blocking=True, timeout=1 + ) + assert result is True + getattr(mock_device, method).assert_called_once_with("1") + + +@patch("pi.pi_controller.GCSDevice") +def test_reference_move_invalid_method(mock_gcs_device_cls): + """Test reference move with an invalid method.""" + mock_device = MagicMock() + mock_gcs_device_cls.return_value = mock_device + + controller = PIControllerBase(quiet=True) + controller.connected = True + device_key = ("ip", 1, 1) + controller.devices[device_key] = mock_device + + # Test disallowed method + result = controller.reference_move(device_key, "1", method="INVALID", blocking=True) + assert result is False + + +@patch("pi.pi_controller.GCSDevice") +def test_reference_move_timeout(mock_gcs_device_cls): + """Test reference move with a timeout.""" + mock_device = MagicMock() + # Simulate IsMoving always True + mock_device.IsMoving.return_value = {"1": True} + mock_gcs_device_cls.return_value = mock_device + + controller = PIControllerBase(quiet=True) + controller.connected = True + device_key = ("ip", 1, 1) + controller.devices[device_key] = mock_device + + result = controller.reference_move( + device_key, "1", method="FRF", blocking=True, timeout=0.1 + ) + assert result is False diff --git a/src/hispec/util/srs/README.md b/src/hispec/util/srs/README.md new file mode 100644 index 0000000..03662bf --- /dev/null +++ b/src/hispec/util/srs/README.md @@ -0,0 +1,89 @@ +# PTC10 Python Interface + +A low level library for communicating with the **Stanford Research Systems PTC10 Programmable Temperature Controller** via Ethernet. + +## Features + +- Query identification string +- Read the current value of a specific sensor or output channel +- Read all channel values in a single query +- Retrieve names of all active channels +- Return values as a dictionary mapping channel name to current value +- Compatible with both serial and Ethernet connections + +## Requirements + +- Python 3.8+ +- Install base class from https://github.com/COO-Utilities/hardware_device_base + +## Installation + +```bash +pip install . +``` + +### Project Structure + +``` +ptc10/ +├── __init__.py +├── ptc10.py +└── README.md +``` + +### Basic Usage + +```python +import ptc10 + +# Ethernet example +ptc = ptc10.PTC10() +ptc.connect("192.168.29.150", 23) + +# Identify controller +print("Device ID:", ptc.identify()) + +# Read a specific channel +print("Temp at 3A:", ptc.get_channel_value("3A")) + +# Read all values +print("All values:", ptc.get_all_values()) + +# Channel name to value map +print("Named outputs:", ptc.get_named_output_dict()) + +ptc.close() +``` + +## API Reference + +### `PTC10` + +#### `connect()` +Connects to PTC10. + +#### `disconnect()` +Closes the connection to the controller + +#### `identify() -> str` +Returns the device identification string. + +#### `get_channel_value(channel: str) -> float` +Queries the most recent value of a single channel. Example: `"3A"`, `"Out1"`. + +#### `get_all_values() -> List[float]` +Returns a list of values for all available channels. Sensors out of range return `float('nan')`. + +#### `get_channel_names() -> List[str]` +Returns the channel names in the same order as `get_all_values()`. + +#### `get_named_output_dict() -> Dict[str, float]` +Returns a dictionary mapping each channel name to its latest value. + +--- + +## Notes + +- All messages must end in `\n` (linefeed). This is handled automatically by the connection class. +- Commands like `3A?`, `Out1?`, `getOutput?`, and `getOutputNames?` follow the PTC10 manual. +- Invalid channels or disconnected sensors will return `NaN`. diff --git a/src/hispec/util/srs/__init__.py b/src/hispec/util/srs/__init__.py new file mode 100644 index 0000000..842b485 --- /dev/null +++ b/src/hispec/util/srs/__init__.py @@ -0,0 +1,4 @@ +"""Initialize the PTC10 module.""" +from .ptc10 import PTC10 + +__all__ = ["PTC10"] diff --git a/src/hispec/util/srs/ptc10.py b/src/hispec/util/srs/ptc10.py new file mode 100644 index 0000000..0065456 --- /dev/null +++ b/src/hispec/util/srs/ptc10.py @@ -0,0 +1,207 @@ +""" +PTC10 Controller Interface +""" +from typing import List, Dict +from errno import EISCONN +import socket + +from hardware_device_base import HardwareDeviceBase + +class PTC10(HardwareDeviceBase): + """ + Interface for controlling the PTC10 controller. + """ + channel_names = None + + def __init__(self, log: bool = True, logfile: str = __name__.rsplit(".", 1)[-1] ): + """ + Initialize the PTC10 controller interface. + + Args: + log (bool): If True, start logging. + logfile (str, optional): Path to log file. + """ + super().__init__(log, logfile) + self.sock: socket.socket | None = None + + def connect(self, *args, con_type="tcp") -> None: + """ Connect to controller. """ + if self.validate_connection_params(args): + if con_type == "tcp": + host = args[0] + port = args[1] + if self.sock is None: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.sock.connect((host, port)) + self.logger.info("Connected to %(host)s:%(port)s", { + 'host': host, + 'port': port + }) + self._set_connected(True) + + except OSError as e: + if e.errno == EISCONN: + self.logger.info("Already connected") + self._set_connected(True) + else: + self.logger.error("Connection error: %s", e.strerror) + self._set_connected(False) + # clear socket + if self.is_connected(): + self._clear_socket() + elif con_type == "serial": + self.logger.error("Serial connection not yet implemented") + else: + self.logger.error("Unknown con_type: %s", con_type) + else: + self.logger.error("Invalid connection arguments: %s", args) + + def _clear_socket(self): + """ Clear socket buffer. """ + if self.sock is not None: + self.sock.setblocking(False) + while True: + try: + _ = self.sock.recv(1024) + except BlockingIOError: + break + self.sock.setblocking(True) + + def _send_command(self, command: str, *args) -> bool: + """ + Send a message to the controller (adds newline). + + Args: + command (str): The message to send (e.g., '3A?'). + """ + try: + self.logger.debug('Sending: %s', command) + with self.lock: + self.sock.sendall((command + "\n").encode()) + except Exception as ex: + raise IOError(f'Failed to write message: {ex}') from ex + return True + + def _read_reply(self) -> str: + """ + Read a response from the controller. + + Returns: + str: The received message, stripped of trailing newline. + """ + try: + retval = self.sock.recv(4096).decode().strip() + self.logger.debug('Received: %s', retval) + return retval + except Exception as ex: + raise IOError(f"Failed to _read_reply message: {ex}") from ex + + def query(self, msg: str) -> str: + """ + Send a command and _read_reply the immediate response. + + Args: + msg (str): Command string to send. + + Returns: + str: Response from the controller. + """ + self._send_command(msg) + return self._read_reply() + + def disconnect(self): + """ + Close the connection to the controller. + """ + try: + self.logger.info('Closing connection to controller') + if self.sock: + self.sock.close() + self._set_connected(False) + except Exception as ex: + raise IOError(f"Failed to close connection: {ex}") from ex + + def identify(self) -> str: + """ + Query the device identification string. + + Returns: + str: Device identification (e.g. manufacturer, model, serial number, firmware version). + """ + id_str = self.query("*IDN?") + self.logger.info("Device identification: %s", id_str) + return id_str + + def validate_channel_name(self, channel_name: str) -> bool: + """Is channel name valid?""" + if self.channel_names is None: + self.channel_names = self.get_channel_names() + return channel_name in self.channel_names + + def get_atomic_value(self, channel: str ="") -> float: + """ + Read the latest value of a specific channel. + + Args: + channel (str): Channel name (e.g., "3A", "Out1") + + Returns: + float: Current value, or NaN if invalid. + """ + if self.validate_channel_name(channel): + self.logger.debug("Channel name validated: %s", channel) + # Spaces not allowed + query_channel = channel.replace(" ", "") + response = self.query(f"{query_channel}?") + try: + value = float(response) + self.logger.debug("Channel %s value: %f", channel, value) + return value + except ValueError: + self.logger.error( + "Invalid float returned for channel %s: %s", channel, response + ) + return float("nan") + else: + self.logger.error("Invalid channel name: %s", channel) + return float("nan") + + def get_all_values(self) -> List[float]: + """ + Read the latest values of all channels. + + Returns: + List[float]: List of float values, with NaN where applicable. + """ + response = self.query("getOutput?") + values = [ + float(val) if val != "NaN" else float("nan") for val in response.split(",") + ] + self.logger.debug("Output values: %s", values) + return values + + def get_channel_names(self) -> List[str]: + """ + Get the list of channel names corresponding to the getOutput() values. + + Returns: + List[str]: List of channel names. + """ + response = self.query("getOutputNames?") + names = [name.strip() for name in response.split(",")] + self.logger.debug("Channel names: %s", names) + return names + + def get_named_output_dict(self) -> Dict[str, float]: + """ + Get a dictionary mapping channel names to their current values. + + Returns: + Dict[str, float]: Mapping of channel names to values. + """ + names = self.get_channel_names() + values = self.get_all_values() + output_dict = dict(zip(names, values)) + self.logger.debug("Named outputs: %s", output_dict) + return output_dict diff --git a/src/hispec/util/srs/pyproject.toml b/src/hispec/util/srs/pyproject.toml new file mode 100644 index 0000000..7f80059 --- /dev/null +++ b/src/hispec/util/srs/pyproject.toml @@ -0,0 +1,23 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "srs" +version = "0.1.0" +description = "Stanford Research Systems controller software" +authors = [ + { name="Michael Langmayr", email="langmayr@caltech.edu" }, + { name="Don Neill", email="neill@astro.caltech.edu" }, + { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } +] +readme = "README.md" +requires-python = ">=3.7" +dependencies = [ + "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main", + "pytest" +] +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/src/hispec/util/srs/tests/__init__.py b/src/hispec/util/srs/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hispec/util/srs/tests/test_ptc10.py b/src/hispec/util/srs/tests/test_ptc10.py new file mode 100644 index 0000000..8328613 --- /dev/null +++ b/src/hispec/util/srs/tests/test_ptc10.py @@ -0,0 +1,16 @@ +""" +Unit tests for the PTC10 class in the srs.ptc10 module. +""" +# pylint: disable=import-error +from ptc10 import PTC10 + +def test_not_connected(): + """Test not connected.""" + controller = PTC10() + assert not controller.connected + +def test_connection_fail(): + """Test connection failure.""" + controller = PTC10() + controller.connect("127.0.0.1", 50000) + assert not controller.connected diff --git a/src/hispec/util/standa/.gitignore b/src/hispec/util/standa/.gitignore new file mode 100644 index 0000000..b7faf40 --- /dev/null +++ b/src/hispec/util/standa/.gitignore @@ -0,0 +1,207 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[codz] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py.cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock +#poetry.toml + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. +# https://pdm-project.org/en/latest/usage/project/#working-with-version-control +#pdm.lock +#pdm.toml +.pdm-python +.pdm-build/ + +# pixi +# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. +#pixi.lock +# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one +# in the .venv directory. It is recommended not to include this directory in version control. +.pixi + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.envrc +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the entire vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore + +# Marimo +marimo/_static/ +marimo/_lsp/ +__marimo__/ diff --git a/src/hispec/util/standa/README.md b/src/hispec/util/standa/README.md new file mode 100644 index 0000000..3bae9d1 --- /dev/null +++ b/src/hispec/util/standa/README.md @@ -0,0 +1,70 @@ +# Standa Controllers + +Low-level Python or simplified wrapper modules to send commands to Standa controllers. + +## Currently Supported Models +- 8SMC5 - smc8.py + +## Features +- Connect to Standa Controllers +- Query state and parameters +- Move individual axes to absolute or relative positions + +## Usage + +### smc8.py Example +```python + from util.smc8 import SMC + + # Open connection examples + dev = SMC(device_connection = "192.168.31.123/9219", connection_type = "xinet", log = False) + dev = SMC(device_connection="/dev/ximc/00007DF6", connection_type = "serial",log = True) + dev.open_connection() + time.sleep(.25) + #Populates dev with device info + dev.get_info() + + # checks status + status = dev.status() + + # Homes device + dev.home() + time.sleep(5) #Give time for stage to move + + # Query Position + pos = dev.get_position() # Query Position + + # Move Relative to its current position + dev.move_rel(position = 5) #positive ot negative + time.sleep(5) + + # Move to absolute position + dev.move_abs(position = 10) + time.sleep(5) + + pos = dev.get_position() + dev.home() + time.sleep(5) + #Close connection + dev.close_connection() +``` + +## 🧪 Testing +Unit tests are located in `tests/` directory. + +TODO: Make "Mock test" for PPC102 get_position and get_status which threw errors and was removed. + Assumed to be due to the byte and int convertion + +To run tests from the project root based on what you need: +Software check: +```bash +pytest -m unit +``` +Connection Test: +```bash +pytest -m default +``` +Functionality Test: +```bash +pytest -m functional +``` \ No newline at end of file diff --git a/src/hispec/util/standa/__init__.py b/src/hispec/util/standa/__init__.py new file mode 100644 index 0000000..621e564 --- /dev/null +++ b/src/hispec/util/standa/__init__.py @@ -0,0 +1,3 @@ +from .smc8 import SMC + +__all__ = ["SMC"] diff --git a/src/hispec/util/standa/pyproject.toml b/src/hispec/util/standa/pyproject.toml new file mode 100644 index 0000000..0abe938 --- /dev/null +++ b/src/hispec/util/standa/pyproject.toml @@ -0,0 +1,43 @@ +[build-system] +requires = ["setuptools>=75"] +build-backend = "setuptools.build_meta" + +[project] +name = "standa" +version = "0.1.0" +description = "A collection of Python interfaces for communicating with standa controllers" +authors = [ + { name = "Elijah Anakalea-Buckley", email = "elijahab@caltech.edu" } +] +maintainers = [ + { name = "Elijah Anakalea-Buckley", email = "elijahab@caltech.edu" } +] +readme = "README.md" +requires-python = ">=3.8" + +dependencies = [ + "libximc" +] + +[project.urls] +Repository = "https://github.com/COO-Utilities/standa" + +[project.optional-dependencies] +dev = [ + "pytest-mock", + "pytest", + "black", + "flake8" +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "default: marks tests as default run set", + "unit: marks tests as unit tests", + "functional: marks tests as functional tests", +] + +[tool.setuptools.packages.find] +# find packages under the "standa" namespace +include = ["smc8"] \ No newline at end of file diff --git a/src/hispec/util/standa/smc8.py b/src/hispec/util/standa/smc8.py new file mode 100644 index 0000000..4eb9cd9 --- /dev/null +++ b/src/hispec/util/standa/smc8.py @@ -0,0 +1,379 @@ +#NOTE:: Pip install of libximc is needed to use the library imported +# These are not standard python librarys but are on PyPI +# -Elijah Anakalea-Buckley + +import libximc.highlevel as ximc +import logging +import pathlib +import os +import time + + +class SMC(object): + ''' + Class is for utilizing the libximc Library. + Functions from lib.ximc is incorporated into this class + to make it easier to use for common tasks. + - using the more recently developed libximc.highlevel API + - step_size:float = 0.0025 Conversion Coefficient, Example for + converting steps to mm used in API, adjust as needed + - All functions log their actions and errors to a log file + - Required Parameters: + device_connection: str = Connection string for device + - Ex: serial connection: '/COM3', '/dev/ximc/000746D30' or '192.123.123.92' + - NOTE:: For Network you must provide IP/Name and device ID. Device ID is the + serial number tranlslated to hex + EX: SMC(device_connection = "192.168.29.123/9219", connection_type="xinet") + connection_type: str = Type of connection + - Options: 'serial'=USB, 'tcp'=Raw TCP, 'xinet'=Network + log: bool = Enable or disable logging to file + ''' + + def __init__(self, device_connection: str, connection_type: str,log: bool, step_size:float = 0.0025): + ''' + Inicializes the device + parameters: ip string, port integer, logging bool + - full device capabilities will be under "self.device." + ''' + # Logger setup + logname = __name__.rsplit(".", 1)[-1] + self.logger = logging.getLogger(logname) + self.logger.setLevel(logging.DEBUG) + if log: + log_handler = logging.FileHandler(logname + ".log") + formatter = logging.Formatter( + "%(asctime)s--%(name)s--%(levelname)s--%(module)s--" + "%(funcName)s--%(message)s") + log_handler.setFormatter(formatter) + self.logger.addHandler(log_handler) + # Console handler for real-time output + console_handler = logging.StreamHandler() + console_formatter = logging.Formatter("%(asctime)s--%(message)s") + console_handler.setFormatter(console_formatter) + self.logger.addHandler(console_handler) + + + self.logger.info("Logger initialized for SMC8 Stage") + + #Inicialize variables and objects + self._move_cmd_flags = ximc.MvcmdStatus # Default move command flags + self._state_flags = ximc.StateFlags + self.serial_number = None + self.power_setting = None + self.device_information = None + self._engine_settings = None + self.min_limit = None + self.max_limit = None + self._homed_and_happy_bool = False + self._uPOSITION = 0 #Constant is 0 for DC motors and avaries for stepper motors + #look into ximc library for details on uPOSITION + self.device_uri = None + + # Reference for connecting to device + # device_uri = r"xi-emu:///ABS_PATH/virtual_controller.bin" # Virtual device + # device_uri = r"xi-com:\\.\COM111" # Serial port + # device_uri = "xi-tcp://172.16.130.155:1820" # Raw TCP connection + # device_uri = "xi-net://192.168.1.120/abcd" # XiNet connection + connection_type = connection_type.lower().strip() + if connection_type == "serial": + self.device_uri = f"xi-com://{device_connection}" + elif connection_type == "tcp": + self.device_uri = f"xi-tcp://{device_connection}" + elif connection_type == "xinet": + self.device_uri = f"xi-net://{device_connection}" + else: + self.logger.error(f"Unknown connection type: {connection_type}") + raise ValueError(f"Unknown connection type: {connection_type}") + + + self.step_size_coeff = step_size # Example conversion coefficient, adjust as needed(mm) + self.dev_open = False + self._axis = ximc.Axis(self.device_uri) + + def open_connection(self): + ''' + Opens communication to the Device, gathers general information to + store in local variables. + return: Bool for successful or unsuccessful connection + libximc:: open_device() + ''' + #Check if already open + if self.dev_open: + #log that device is already open + self.logger.info("Device already open, skipping open command.") + #return true if already open + return True + + #try to open + try: + #open device + self._axis.open_device() + #get and save engine settings + self._engine_settings = self._axis.get_engine_settings() + #Set calb for user units TODO:: Check if this is correct(SPECIFICALLY THE MICROSTEP MODE) + self._axis.set_calb(self.step_size_coeff, self._engine_settings.MicrostepMode) + #Set limits + self.limits = self._axis.get_edges_settings() + self.min_limit = self.limits.LeftBorder + self.max_limit = self.limits.RightBorder + + self.logger.info("Device opened successfully.") + + #return true if successful + self.dev_open = True + return True + except Exception as e: + #log error + self.logger.error(f"Error opening device: {e}") + + #return false if unsuccessful + self.dev_open = False + return False + + def close_connection(self): + ''' + Closes communication to the Device + return: Bool for successful or unsuccessful termination + libximc:: close_device() + ''' + #Check if already open + if not self.dev_open: + #log that de is closed + self.logger.info("Device already closed, skipping close command.") + + #return true if already closed + return True + + #Try to close + try: + self._axis.close_device() + self.dev_open = False + self.logger.info("Device closed successfully.") + #return true if succesful + return True + except Exception as e: + #catch error + #log error and return false + self.logger.error(f"Error closing device: {e}") + + #return false if unsuccessful + self.dev_open = True + return False + + def get_info(self): + ''' + Gets information about the device, such as serial number, power setting, + command read settings, and device information. That information is stored + in local variables for later use. + - This function is called after opening the device to gather information + return: dict with device information + libximc:: get_serial_number(), get_power_setting(), command_read_settings(), + get_device_information() + ''' + #Check if connection not open + if not self.dev_open: + #log closed connection + self.logger.error("Device not open, cannot get info.") + return False + + #Try to get info + try: + #get serial number + self.serial_number = self._axis.get_serial_number() + #get power settings + self.power_setting = self._axis.get_power_settings() + #get device information + self.device_information = self._axis.get_device_information() + + self.logger.info("Device opened successfully.") + self.logger.info(f"Serial number: {self.serial_number}") + self.logger.info(f"Power setting: {self.power_setting}") + #Log device information + self.logger.info(f"Device information: {self.device_information}") + + #return true if successful + return True + except Exception as e: + #log error and return None + self.logger.error(f"Error getting device information: {e}") + return False + + + def home(self): + ''' + Homes stage into "parked" positon + -Will Home and stay at homed position. + return: bool on successful home + libximc:: command_homezero() + ''' + #Check if connection not open + if not self.dev_open: + #log closed connection + self.logger.error("Device not open, cannot home stage.") + return False + + #Try to home to zero or parked position + try: + self._axis.command_homezero() + #Check position after homing + self.logger.info("Stage sent to homed position which is 0") + #return true if succesful + self.status() + return True + #catch error + except Exception as e: + #log error + self.logger.error(f"Error homing stage: {e}") + #return false if unsuccessful + return False + + def move_abs(self, position:int): + ''' + Move the stage to a ABSOLUTE position. Send stage to any specific + location within the device limits. + - Check min_limit and max_limit for valid inputs + parameters: min_limit < int:"position" < max_limit + return: bool on successful or unsuccessful absolute move + libximc:: command_move() + ''' + #Check if connection not open + if not self.dev_open: + #log closed connection + self.logger.error("Device not open, cannot move stage.") + return False + + #Try move absolute + try: + #check limits/valid inputs + if position < self.min_limit or position > self.max_limit: + self.logger.error(f"Position out of limits: {position}") + return False + #move absolute + self._axis.command_move(position, self._uPOSITION) + #return true if succesful + return True + #catch error + except Exception as e: + #log error and return false + self.logger.error(f"Error moving stage: {e}") + return False + + def move_rel(self, position:int): + ''' + Move the stage to a RELATIVE position. Send stage to a position + relative to its current position. + - Check min_limit and max_limit for range of device + parameters: min_limit < +- int for relative move < max_limit + return: bool on successful or unsuccessful relative move + libximc:: command_movr() + ''' + #Check if connection not open + if not self.dev_open: + #log closed connection + self.logger.error("Device not open, cannot move stage.") + return False + + #Try move relative + try: + #check limits/valid inputs + #get current position + current_position = self.get_position() + #calculate new position + new_position = current_position + position + #check if new position is within limits + if new_position < self.min_limit or new_position > self.max_limit: + self.logger.error(f"Position out of limits: {new_position}") + return False + #move relative + self._axis.command_movr(position, self._uPOSITION) + #return true if succesful + return True + #catch error + except Exception as e: + #log error and return false + self.logger.error(f"Error moving stage: {e}") + return False + + def get_position(self): + ''' + Gets Position of stage + return: position in stage specific units + libximc:: + ''' + #Check if connection not open + if not self.dev_open: + #log closed connection + self.logger.error("Device not open, cannot get position.") + return False + + #Try get_position + try: + #get position + pos = self._axis.get_position() + #return aspects of the position object + return pos.Position + #catch error + except Exception as e: + #log error and return None + self.logger.error(f"Error getting position: {e}") + return None + + def status(self): + ''' + Gathers status and formats it in a usable and readable format. + mostly for logging + return: status string and variables nessesary + libximc:: get_status() + ''' + #Check if connection not open + if not self.dev_open: + #log closed connection + self.logger.error("Device not open, cannot get status.") + return False + + #Try status function + try: + #get status + status = self._axis.get_status() + #parse results + #return status in user friendly way + self.logger.info(f"Position: {status.CurPosition}") + self._homed_and_happy_bool = bool(status.Flags & self._state_flags.STATE_IS_HOMED | + self._state_flags.STATE_EEPROM_CONNECTED) + return status + #catch error + except Exception as e: + #log error and return false + self.logger.error(f"Error getting status: {e}") + return None + + def halt(self): + ''' + IMMITATELY halts the stage, no matter the status or if moving, stage + stops(for safety purposes) + return: status of the stage(log and/or print hald command called) + libximc:: command_stop() + ''' + #Check if connection not open + if not self.dev_open: + #log closed connection + self.logger.error("Device not open, cannot halt stage.") + return False + + #Try imidiate stop of stage + try: + self._axis.command_stop() + #Check status after halting + status = self._axis.get_status() + if status.MvCmdSts != self._move_cmd_flags.MVCMD_STOP: + self.halt() #Recursively call halt if not stopped + + #status.Moving + self.logger.info("Stage halted successfully.") + #return true if succesful + return True + #catch error + except Exception as e: + #log error and return false + self.logger.error(f"Error halting stage: {e}") + return False \ No newline at end of file diff --git a/src/hispec/util/standa/tests/default_smc8_test.py b/src/hispec/util/standa/tests/default_smc8_test.py new file mode 100644 index 0000000..f0e3d41 --- /dev/null +++ b/src/hispec/util/standa/tests/default_smc8_test.py @@ -0,0 +1,79 @@ +################# +#Default Communication test +#Description: Test connection, disconnection and confirming communication with stage +################# + +import pytest +pytestmark = pytest.mark.default +import sys +import os +import unittest +import time +from smc8 import SMC + +########################## +## CONFIG +## connection and Disconnection in all test +########################## + +class Comms_Test(unittest.TestCase): + + #Instances for Test management + #def setUp(self): + dev = None + success = True + device = "" + log = False + error_tolerance = 0.1 + device_connection = "192.168.29.123/9219" + connection_type = "xinet" + + ########################## + ## TestConnection and failure connection + ########################## + def test_connection(self): + # Open connection + self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) + time.sleep(.2) + self.dev.open_connection() + time.sleep(.25) + assert self.dev.get_info() + assert self.dev.serial_number is not None + assert self.dev.power_setting is not None + assert self.dev.device_information is not None + #Close connection + self.dev.close_connection() + time.sleep(.25) + + def test_connection_failure(self): + # Use an unreachable IP (TEST-NET-1 range, reserved for docs/testing) + bad_connection = "dev/ximc/0000" + self.dev = SMC(device_connection = bad_connection, connection_type = self.connection_type, log = self.log) + success = self.dev.open_connection() + self.assertFalse(success, "Expected connection failure with invalid IP/port") + self.dev.close_connection() + + ########################## + ## Status Communication + ########################## + def status_communication(self): + time.sleep(.2) + # Open connection + self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) + time.sleep(.2) + self.dev.open_connection() + time.sleep(.25) + assert self.dev.get_info() + status = self.dev.status() + assert status is not None + + self.dev.close_connection() + time.sleep(.25) + + +if __name__ == '__main__': + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(Comms_Test) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/standa/tests/mock_smc8_test.py b/src/hispec/util/standa/tests/mock_smc8_test.py new file mode 100644 index 0000000..9a9a1c0 --- /dev/null +++ b/src/hispec/util/standa/tests/mock_smc8_test.py @@ -0,0 +1,87 @@ +################# +#Unit test +#Description: Validate software functions are correctly implemented via mocking +################# + +import pytest +pytestmark = pytest.mark.unit +import unittest +from unittest.mock import patch, MagicMock +# pylint: disable=import-error,no-name-in-module +from smc8 import SMC +import time +import ctypes + +class TestSMC8(unittest.TestCase): + """Unit tests for the SunpowerCryocooler class.""" + + def setUp(self): # pylint: disable=arguments-differ + """Set up the test case with a mocked ximc connection.""" + patcher = patch("smc8.ximc.Axis") # <- patch the right function + self.addCleanup(patcher.stop) + mock_open_device = patcher.start() + self.mock_ximc = MagicMock() + mock_open_device.return_value = self.mock_ximc + self.controller = SMC(device_connection = "192.168.29.123/9219", connection_type = "xinet", log = False) + self.controller._axis = self.mock_ximc + self.controller.dev_open = True + self.controller.min_limit = -500 + self.controller.max_limit = 500 + self.mock_ximc.get_serial_number.return_value = 12345678 + self.mock_ximc.get_power_setting.return_value = 1 + self.mock_ximc.get_device_information.return_value = 5000 + self.mock_ximc.command_homezero.return_value = True + self.mock_ximc.get_position_calb.return_value = 0 , "0.0" + self.mock_ximc.Position = 10 + self.mock_ximc.CurPosition = 10 + self.mock_ximc.CurSpeed = 0.12 + + def test_info(self): + """Test getting the info from the attenuator.""" + assert self.controller.get_info() + assert self.controller.serial_number is not None + assert self.controller.power_setting is not None + assert self.controller.device_information is not None + + + def test_abs_move(self): + """Testing sending the correct commands to abs move the SMC.""" + mock_axis = MagicMock() + self.controller._axis = mock_axis # inject the mock axis + + self.controller.move_abs(10) + mock_axis.command_move.assert_called_once_with(10,0) + + def test_rel_move(self): + """Testing sending the correct commands to rel move the SMC.""" + mock_axis = MagicMock() + self.controller._axis = mock_axis # inject the mock axis + self.controller.get_position = MagicMock(return_value=0) + + self.controller.move_rel(10) + mock_axis.command_movr.assert_called_once_with(10,0) + + def test_home(self): + """Test setting the position from the SMC.""" + with patch.object(self.controller._axis, "command_homezero") as mock_home: + self.controller.home() + mock_home.assert_called_once() + + def test_get_position(self): + """Test getting the position from the SMC.""" + self.controller._axis.get_position = MagicMock(return_value=self.mock_ximc) + pos = self.controller.get_position() + assert pos == 10 + + def test_get_status(self): + """Test getting the status from the SMC.""" + self.controller._axis.get_status = MagicMock(return_value=self.mock_ximc) + status = self.controller.status() + Position = status.CurPosition + Moving_speed = status.CurSpeed + assert Position is not None + + +if __name__ == "__main__": + unittest.main() + \ No newline at end of file diff --git a/src/hispec/util/standa/tests/physical_smc8_test.py b/src/hispec/util/standa/tests/physical_smc8_test.py new file mode 100644 index 0000000..0fc4116 --- /dev/null +++ b/src/hispec/util/standa/tests/physical_smc8_test.py @@ -0,0 +1,152 @@ +################# +#Functionality test +#Description: Test connection, disconnection, confirming communication with stage, +# inicialization(or something similar) and movement/position query +# tests are successful and correct +################# + +import pytest +pytestmark = pytest.mark.functional +import sys +import os +import unittest +import time +from smc8 import SMC + +########################## +## CONFIG +## connection and Disconnection in all test +########################## +class Physical_Test(unittest.TestCase): + + #Instances for Test management + def setUp(self): + self.dev = None + self.success = True + self.device = "" + self.log = False + self.error_tolerance = 0.1 + self.device_connection = "192.168.29.123/9219" + self.connection_type = "xinet" + + ########################## + ## TestConnection and failure connection + ########################## + def test_connection(self): + # Open connection + self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) + time.sleep(.2) + self.dev.open_connection() + time.sleep(.25) + assert self.dev.get_info() + assert self.dev.serial_number is not None + assert self.dev.power_setting is not None + assert self.dev.device_information is not None + #Close connection + self.dev.close_connection() + time.sleep(.25) + + def test_connection_failure(self): + # Use an unreachable IP (TEST-NET-1 range, reserved for docs/testing) + bad_connection = "dev/ximc/0000" + self.dev = SMC(device_connection = bad_connection, connection_type = self.connection_type, log = self.log) + success = self.dev.open_connection() + self.assertFalse(success, "Expected connection failure with invalid IP/port") + self.dev.close_connection() + + ########################## + ## Status Communication + ########################## + def status_communication(self): + # Open connection + self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) + time.sleep(.2) + self.dev.open_connection() + time.sleep(.25) + self.dev.get_info() + status = self.dev.status() + assert status is not None + + self.dev.close_connection() + time.sleep(.25) + + ########################## + ## Test Move and Home + ########################## + def test_home(self): + # Open connection + self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) + time.sleep(.2) + self.dev.open_connection() + time.sleep(.25) + assert self.dev.get_info() + status = self.dev.status() + assert status is not None + assert self.dev.home() + time.sleep(.25) + pos = self.dev.get_position() + assert abs(pos - 0) < self.error_tolerance*2 + + #Close connection + self.dev.close_connection() + time.sleep(.25) + + def test_move(self): + # Open connection + self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) + time.sleep(.2) + self.dev.open_connection() + time.sleep(.25) + assert self.dev.get_info() + status = self.dev.status() + assert status is not None + assert self.dev.home() + time.sleep(.25) + pos = self.dev.get_position() + assert abs(pos - 0) < self.error_tolerance*2 + assert self.dev.move_abs(position = 5) + time.sleep(.25) + pos = self.dev.get_position() + assert abs(pos - 5) < self.error_tolerance*2 + assert self.dev.move_rel(position = 5) + time.sleep(.25) + pos = self.dev.get_position() + assert abs(pos - 10) < self.error_tolerance*2 + assert self.dev.home() + time.sleep(.25) + pos = self.dev.get_position() + assert abs(pos - 0) < self.error_tolerance*2 + #Close connection + self.dev.close_connection() + time.sleep(.25) + + def test_halt(self): + # Open connection + self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) + time.sleep(.2) + self.dev.open_connection() + time.sleep(.25) + assert self.dev.get_info() + status = self.dev.status() + assert status is not None + end = self.dev.max_limit - 1 + assert self.dev.move_abs(position = end) + time.sleep(2) + assert self.dev.move_abs(position = (self.dev.min_limit + 1)) + assert self.dev.halt() + time.sleep(.25) + pos = self.dev.get_position() + assert pos != (self.dev.min_limit + 1) + #Close connection + self.dev.home() + time.sleep(.25) + self.dev.close_connection() + time.sleep(.25) + + +if __name__ == '__main__': + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(Robust_Test) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + sys.exit(not result.wasSuccessful()) \ No newline at end of file diff --git a/src/hispec/util/sunpower/.gitignore b/src/hispec/util/sunpower/.gitignore new file mode 100644 index 0000000..3f029b2 --- /dev/null +++ b/src/hispec/util/sunpower/.gitignore @@ -0,0 +1,15 @@ +# Python +__pycache__ +*.pyc +*.pyo +*.pyd +*.pdb +*.egg +*.egg-info +.pytest_cache/ +.venv +venv + +# IDEs +.idea/ +.vscode/ \ No newline at end of file diff --git a/src/hispec/util/sunpower/README.md b/src/hispec/util/sunpower/README.md new file mode 100644 index 0000000..87289f8 --- /dev/null +++ b/src/hispec/util/sunpower/README.md @@ -0,0 +1,66 @@ +# sunpower + +A collection of Python interfaces for communicating with HISPEC FEI components. + +## Features + +- Query device status, error, and firmware version +- Get and set target temperature +- Get and set user commanded power +- Get reject and cold head temperatures +- Turn cooler on or off +- Supports both serial and TCP (socket) connections with error handling + +## Requirements + +- Install base class from https://github.com/COO-Utilities/hardware_device_base + +## Installation + +```bash +pip install . +``` + +## Usage +### Serial Connection +```python +from sunpower_cryocooler import SunpowerCryocooler + +controller = SunpowerCryocooler() +controller.connect('/dev/ttyUSB0', 9600, con_type="serial") + +print("\n".join(controller.get_status())) +controller.set_target_temp(300.0) +controller.turn_on_cooler() +print("\n".join(controller.get_commanded_power())) +print("\n".join(controller.get_measured_power())) +controller.set_commanded_power(10.0) +print("\n".join(controller.get_reject_temp())) +print("\n".join(controller.get_cold_head_temp())) +``` + +### TCP Connection +```python +from sunpower_cryocooler import SunpowerCryocooler + +controller = SunpowerCryocooler() +controller.connect("192.168.29.100", 10016, con_type="tcp") + +print("\n".join(controller.get_status())) +controller.set_target_temp(300.0) +controller.turn_on_cooler() +print("\n".join(controller.get_commanded_power())) +print("\n".join(controller.get_measured_power())) +controller.set_commanded_power(10.0) +print("\n".join(controller.get_reject_temp())) +print("\n".join(controller.get_cold_head_temp())) +``` + +## 🧪 Testing +Unit tests are located in `tests/` directory and use `pytest` with `unittest.mock` to simulate hardware behavior — no physical sunpower controller is required. + +To run all tests from the project root: + +```bash +pytest +``` diff --git a/src/hispec/util/sunpower/__init__.py b/src/hispec/util/sunpower/__init__.py new file mode 100644 index 0000000..dfb869d --- /dev/null +++ b/src/hispec/util/sunpower/__init__.py @@ -0,0 +1,6 @@ +""" +This module provides a controller for the Sunpower Cryocooler. +""" +from .sunpower_cryocooler import SunpowerCryocooler + +__all__ = ["SunpowerCryocooler"] diff --git a/src/hispec/util/sunpower/pyproject.toml b/src/hispec/util/sunpower/pyproject.toml new file mode 100644 index 0000000..caa8a46 --- /dev/null +++ b/src/hispec/util/sunpower/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["setuptools>=42"] +build-backend = "setuptools.build_meta" + +[project] +name = "sunpower" +version = "0.1.0" +dependencies = [ + "pyserial>=3.4", +] +description = "A collection of Python interfaces for communicating with HISPEC FEI components, including Sunpower cryocooler control." +authors = [ + { name = "Michael Langmayr", email = "langmayr@caltech.edu" } +] +maintainers = [ + {name = "Michael Langmayr", email = "langmayr@caltech.edu"} +] +readme = "README.md" +requires-python = ">=3.9" +license = {text = "MIT"} + +[project.urls] +# Various URLs related to your project. These links are displayed on PyPI. +# Homepage = "https://example.com" +# Documentation = "https://readthedocs.org" +Repository = "https://github.com/COO-Utils/sunpower" +# "Bug Tracker" = "https://github.com/COO-Utils/sunpower/issues" +# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" + +[project.optional-dependencies] +dev = [ + "pytest-mock", + "pytest", + "black", + "flake8" +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/src/hispec/util/sunpower/sunpower_cryocooler.py b/src/hispec/util/sunpower/sunpower_cryocooler.py new file mode 100644 index 0000000..e6c51f1 --- /dev/null +++ b/src/hispec/util/sunpower/sunpower_cryocooler.py @@ -0,0 +1,215 @@ +""" +A Python class to control a Sunpower cryocooler via serial or TCP connection. +""" +import socket +import time +from typing import Union +import serial + +from hardware_device_base import HardwareDeviceBase + + +def parse_single_value(reply: list) -> Union[float, int, bool, str]: + """Attempt to parse a single value from the reply list.""" + if not isinstance(reply, list): + raise TypeError("reply must be a list") + + try: + val = reply[1] + except IndexError: + return "No reply" + + # Parse Booleans + if val.lower() in ("true", "yes", "on", "1"): + return True + if val.lower() in ("false", "no", "off", "0"): + return False + + # Parse integers + try: + return int(val) + except ValueError: + pass + + # Parse floats + try: + return float(val) + except ValueError: + pass + + # Fallback: return string + return val.strip() + +class SunpowerCryocooler(HardwareDeviceBase): + """A class to control a Sunpower cryocooler via serial or TCP connection.""" + # pylint: disable=too-many-instance-attributes + def __init__(self, log: bool = True, logfile: str = __name__.rsplit(".", 1)[-1], + read_timeout: float = 1.0): + """ Initialize the SunpowerCryocooler.""" + + super().__init__(log, logfile) + self.con_type = None + self.read_timeout = read_timeout + self.ser = None + self.sock = None + + def connect(self, *args, con_type: str ="tcp"): + """Connect to the Sunpower controller.""" + if self.validate_connection_params(args): + try: + if con_type == "serial": + port = args[0] + baudrate = args[1] + self.ser = serial.Serial( + port=port, + baudrate=baudrate, + timeout=self.read_timeout, + parity=serial.PARITY_NONE, + stopbits=serial.STOPBITS_ONE, + ) + self.logger.info("Serial connection opened: %s",self.ser.is_open) + self._set_connected(True) + self.con_type = con_type + elif con_type == "tcp": + tcp_host = args[0] + tcp_port = args[1] + self.sock = socket.create_connection((tcp_host, tcp_port), timeout=2) + self.sock.settimeout(self.read_timeout) + self.logger.info("TCP connection opened: %s:%d", tcp_host, tcp_port) + self._set_connected(True) + self.con_type = con_type + else: + self._set_connected(False) + raise ValueError("connection_type must be 'serial' or 'tcp'") + except Exception as ex: + self._set_connected(False) + self.logger.error("Failed to establish connection: %s", ex) + raise + + def disconnect(self): + """Close the connection.""" + if self.con_type == "serial": + self.ser.close() + self.logger.info("Serial connection closed.") + elif self.con_type == "tcp": + self.sock.close() + self.logger.info("TCP connection closed.") + self._set_connected(False) + + def _send_command(self, command: str, *args) -> bool: + """Send a command to the Sunpower controller.""" + full_cmd = f"{command}\r" + try: + if self.con_type == "serial": + self.ser.write(full_cmd.encode()) + elif self.con_type == "tcp": + self.sock.sendall(full_cmd.encode()) + self.logger.debug("Sent command: %s", repr(full_cmd)) + except Exception as ex: + self.logger.error("Failed to send command '%s': %s", command, ex) + raise + return True + + def _read_reply(self) -> list: + """Read and return lines from the device.""" + lines_out = [] + try: + raw_data = None + if self.con_type == "serial": + raw_data = self.ser.read(1024) + elif self.con_type == "tcp": + try: + raw_data = self.sock.recv(1024) + except socket.timeout: + self.logger.warning("TCP read timeout.") + return [] + + if not raw_data: + self.logger.warning("No data received.") + return [] + + self.logger.debug("Raw received: %s", repr(raw_data)) + lines = raw_data.decode(errors="replace").splitlines() + for line in lines: + stripped = line.strip() + if stripped: + lines_out.append(stripped) + return lines_out + except (serial.SerialException, socket.error, ValueError) as ex: + self.logger.error("Failed to read reply: %s", ex) + return [] + + def _send_and_read(self, command: str): + """Send a command and read the reply.""" + if self.is_connected(): + self._send_command(command) + time.sleep(0.2) # wait a bit for device to reply + return self._read_reply() + self.logger.error("Failed to send command '%s': Not connected", command) + return [] + + # --- User-Facing Methods (synchronous) --- + def get_atomic_value(self, item: str ="") -> Union[float, int, str, None]: + """Get the atomic value from the Sunpower cryocooler.""" + retval = None + if item == "cold_head_temp": + retval = self.get_cold_head_temp() + elif item == "reject_temp": + retval = self.get_reject_temp() + elif item == "target_temp": + retval = self.get_target_temp() + elif item == "measured_power": + retval = self.get_measured_power() + elif item == "commanded_power": + retval = self.get_commanded_power() + else: + self.logger.error("Unknown item: %s", item) + return retval + + def get_status(self): + """Get the status of the Sunpower cryocooler.""" + return self._send_and_read("STATUS") + + def get_error(self): + """Get the last error message from the Sunpower cryocooler.""" + return parse_single_value(self._send_and_read("ERROR")) + + def get_version(self): + """Get the firmware version of the Sunpower cryocooler.""" + return parse_single_value(self._send_and_read("VERSION")) + + def get_cold_head_temp(self): + """Get the temperature of the cold head.""" + return parse_single_value(self._send_and_read("TC")) + + def get_reject_temp(self): + """Get the temperature of the reject heat.""" + return parse_single_value(self._send_and_read("TEMP RJ")) + + def get_target_temp(self): + """Get the target temperature set for the cryocooler.""" + return parse_single_value(self._send_and_read("TTARGET")) + + def set_target_temp(self, temp_kelvin: float): + """Set the target temperature for the cryocooler in Kelvin.""" + return parse_single_value(self._send_and_read(f"TTARGET={temp_kelvin}")) + + def get_measured_power(self): + """Get the measured power of the cryocooler.""" + return parse_single_value(self._send_and_read("P")) + + def get_commanded_power(self): + """Get the commanded power of the cryocooler.""" + return parse_single_value(self._send_and_read("PWOUT")) + + def set_commanded_power(self, watts: float): + """Set the commanded power for the cryocooler in watts.""" + return parse_single_value(self._send_and_read(f"PWOUT={watts}")) + + def turn_on_cooler(self): + """Turn on the cryocooler.""" + return parse_single_value(self._send_and_read("COOLER=ON")) + + def turn_off_cooler(self): + """Turn off the cryocooler.""" + return parse_single_value(self._send_and_read("COOLER=OFF")) diff --git a/src/hispec/util/sunpower/tests/__init__.py b/src/hispec/util/sunpower/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hispec/util/sunpower/tests/test_sunpower.py b/src/hispec/util/sunpower/tests/test_sunpower.py new file mode 100644 index 0000000..5ac515a --- /dev/null +++ b/src/hispec/util/sunpower/tests/test_sunpower.py @@ -0,0 +1,76 @@ +"""Test suite for the SunpowerCryocooler class in hispec.util module.""" +import unittest +from unittest.mock import patch +# pylint: disable=import-error,no-name-in-module +from sunpower import SunpowerCryocooler + + +class TestSunpowerController(unittest.TestCase): + """Unit tests for the SunpowerCryocooler class.""" + + @patch("serial.Serial") + def setUp(self, mock_serial): # pylint: disable=arguments-differ + """Set up the test case with a mocked serial connection.""" + self.mock_serial = mock_serial.return_value + self.mock_serial.read.return_value = b"" + self.controller = SunpowerCryocooler( + port="COM1", baudrate=19200, quiet=False, connection_type="serial" + ) + + def test_send_command(self): + """Test sending a command to the cryocooler.""" + self.controller._send_command("TEST") # pylint: disable=protected-access + self.mock_serial.write.assert_called_with(b"TEST\r") + + def test_read_reply_parses_float(self): + """Test reading a reply and parsing a float value.""" + self.mock_serial.read.return_value = b"TTARGET= 123.456\r\n" + with patch.object(self.controller.logger, "info") as mock_info: + result = self.controller._read_reply() # pylint: disable=protected-access + assert "TTARGET= 123.456" in result + mock_info.assert_not_called() # we don't log parsed floats directly anymore + + def test_read_reply_handles_non_float(self): + """Test reading a reply that does not contain a float.""" + self.mock_serial.read.return_value = b"ERROR= notanumber\r\n" + with patch.object(self.controller.logger, "warning") as mock_warn: + result = self.controller._read_reply() # pylint: disable=protected-access + assert "ERROR= notanumber" in result + mock_warn.assert_not_called() # no parse attempted anymore + + def test_get_commanded_power(self): + """Test getting the commanded power from the cryocooler.""" + with patch.object(self.controller, "_send_and_read") as mock_send_and_read: + self.controller.get_commanded_power() + mock_send_and_read.assert_called_once_with("PWOUT") + + def test_set_commanded_power(self): + """Test setting the commanded power on the cryocooler.""" + with patch.object(self.controller, "_send_and_read") as mock_send_and_read: + self.controller.set_commanded_power(12.34) + mock_send_and_read.assert_called_once_with("PWOUT=12.34") + + def test_get_reject_temp(self): + """Test getting the reject temperature from the cryocooler.""" + with patch.object(self.controller, "_send_and_read") as mock_send_and_read: + self.controller.get_reject_temp() + mock_send_and_read.assert_called_once_with("TEMP RJ") + + def test_get_cold_head_temp(self): + """Test getting the cold head temperature from the cryocooler.""" + with patch.object(self.controller, "_send_and_read") as mock_send_and_read: + self.controller.get_cold_head_temp() + mock_send_and_read.assert_called_once_with("TC") + + def test_get_measured_power_returns_p_and_value(self): + """Test getting the measured power from the cryocooler.""" + with patch.object( + self.controller, "_send_and_read", return_value=["P", "72"] + ) as mock_send_and_read: + result = self.controller.get_measured_power() + self.assertEqual(result, ["P", "72"]) + mock_send_and_read.assert_called_once_with("P") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/hispec/util/thorlabs/.gitignore b/src/hispec/util/thorlabs/.gitignore new file mode 100644 index 0000000..1681eb4 --- /dev/null +++ b/src/hispec/util/thorlabs/.gitignore @@ -0,0 +1,165 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# MacOS +.DS_Store diff --git a/src/hispec/util/thorlabs/README.md b/src/hispec/util/thorlabs/README.md new file mode 100644 index 0000000..186da4e --- /dev/null +++ b/src/hispec/util/thorlabs/README.md @@ -0,0 +1,89 @@ +# Thorlabs_controllers + +Low-level Python modules to send commands to Thorlabs motion controllers. + +## Currently Supported Models +- FW102C - fw102c.py +- PPC102 - ppc102.py + +## Features +- Connect to Thorlabs controllers over serial through a terminal server +- Query state and parameters +- Move individual axes to absolute or relative positions + +## Usage + +### FW102C Example +```python +from hispec.util import fw102c + +controller = fw102c.FilterWheelController() +controller.set_connection(ip='192.168.29.100', port=10010) +controller.connect() + +# Print filter wheel current position +print(controller.get_position()) + +# Move filter wheel to filter 5 +controller.move(5) + +# For a comprehensive list of classes and methods, use the help function +help(fw102c) + +``` + +### PPC102 Example +```python + from hispec.util.thorlabs.ppc102 import PPC102_Coms + + # log = false will now print to command line + dev = PPC102_Coms(ip="",port="",log=False) + + #Open connection + dev.open() + + # set voltage on channel 1 and get result (open loop control) + dev.set_output_volts(channel=1,volts=100) + res = dev.get_output_volts(channel=1) + + # switch channels to closed loop + dev.set_loop(channel=1,loop=2) + dev.set_loop(channel=2,loop=2) + + # set positions on channel 1 or 2 and get result + dev.set_position(channel=1,pos=5.0) + dev.set_position(channel=2,pos=-5.0) + cur_pos1 = dev.get_position(channel=1) + cur_pos2 = dev.get_position(channel=2) + + # switch channels to open loop + dev.set_loop(channel=1,loop=1) + dev.set_loop(channel=2,loop=1) + + #Set voltages to zero + dev.set_output_volts(channel=1,volts=0) + dev.set_output_volts(channel=2,volts=0) + + # close socket connection + dev.close() +``` + +## 🧪 Testing +Unit tests are located in `tests/` directory. + +TODO: Make "Mock test" for PPC102 get_position and get_status which threw errors and was removed. + Assumed to be due to the byte and int convertion + +To run tests from the project root based on what you need: +Software check: +```bash +pytest -m unit +``` +Connection Test: +```bash +pytest -m default +``` +Functionality Test: +```bash +pytest -m functional +``` \ No newline at end of file diff --git a/src/hispec/util/thorlabs/__init__.py b/src/hispec/util/thorlabs/__init__.py new file mode 100644 index 0000000..7d82257 --- /dev/null +++ b/src/hispec/util/thorlabs/__init__.py @@ -0,0 +1,4 @@ +from .fw102c import FilterWheelController +from .ppc102 import PPC102_Coms + +__all__ = ["FilterWheelController", "PPC102_Coms"] diff --git a/src/hispec/util/thorlabs/fw102c.py b/src/hispec/util/thorlabs/fw102c.py new file mode 100755 index 0000000..ba7406b --- /dev/null +++ b/src/hispec/util/thorlabs/fw102c.py @@ -0,0 +1,333 @@ +#! @KPYTHON3@ +""" Thorlabs FW102C controller class """ + +from errno import ETIMEDOUT, EISCONN +import logging +import socket +import threading +import time + + +class FilterWheelController: + """ Handle all correspondence with the serial interface of the + Thorlabs FW102C filter wheel. + """ + + + connected = False + status = None + ip = '' + port = 0 + + initialized = False + revision = None + success = False + + def __init__(self, log=True, logfile=None, quiet=False): + + self.lock = threading.Lock() + self.socket = None + + # Logger setup + logname = __name__.rsplit(".", 1)[-1] + self.logger = logging.getLogger(logname) + self.logger.setLevel(logging.DEBUG) + if log: + log_handler = logging.FileHandler(logname + ".log") + formatter = logging.Formatter( + "%(asctime)s--%(name)s--%(levelname)s--%(module)s--" + "%(funcName)s--%(message)s") + log_handler.setFormatter(formatter) + self.logger.addHandler(log_handler) + # Console handler for real-time output + console_handler = logging.StreamHandler() + console_formatter = logging.Formatter("%(asctime)s--%(message)s") + console_handler.setFormatter(console_formatter) + self.logger.addHandler(console_handler) + + def set_connection(self, ip=None, port=None): + """ Configure the connection to the controller. + + :param ip: String, IP address of the controller. + :param port: Int, port number of the controller. + + """ + self.ip = ip + self.port = port + + def disconnect(self): + """ Disconnect controller. """ + + try: + self.socket.shutdown(socket.SHUT_RDWR) + self.socket.close() + self.socket = None + if self.logger: + self.logger.debug("Disconnected controller") + self.connected = False + self.success = True + + except OSError as e: + if self.logger: + self.logger.error("Disconnection error: %s", e.strerror) + self.connected = False + self.socket = None + self.success = False + + self.set_status("disconnected") + + def connect(self): + """ Connect to controller. """ + if self.socket is None: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + self.socket.connect((self.ip, self.port)) + if self.logger: + self.logger.debug("Connected to %(host)s:%(port)s", { + 'host': self.ip, + 'port': self.port + }) + self.connected = True + self.success = True + self.set_status('ready') + + except OSError as e: + if e.errno == EISCONN: + if self.logger: + self.logger.debug("Already connected") + self.connected = True + self.success = True + self.set_status('ready') + else: + if self.logger: + self.logger.error("Connection error: %s", e.strerror) + self.connected = False + self.success = False + self.set_status('not connected') + # clear socket + if self.connected: + self.__clear_socket() + + def __clear_socket(self): + """ Clear socket buffer. """ + if self.socket is not None: + self.socket.setblocking(False) + while True: + try: + _ = self.socket.recv(1024) + except BlockingIOError: + break + self.socket.setblocking(True) + + def check_status(self): + """ Check connection status """ + if not self.connected: + status = 'not connected' + elif not self.success: + status = 'unresponsive' + else: + status = 'ready' + + self.set_status(status) + + + def set_status(self, status): + """ Set the status of the filter wheel. + + :param status: String, status of the controller. + + """ + + status = status.lower() + + if self.status is None: + current = None + else: + current = self.status + + if current != 'locked' or status == 'unlocked': + self.status = status + + + def initialize(self): + """ Initialize the filter wheel. """ + + save = False + + # Give it an initial dummy command to flush out the buffer. + self.command('*idn?') + + self.revision = self.command('*idn?') + + # Turn off the position sensors when the wheel is + # idle to mitigate stray light. + + sensors = self.command('sensors?') + + if sensors != '0': + self.command('sensors=0') + save = True + + # Make sure the wheel is set to move at "high" speed, + # which takes ~3 seconds to rotate 180 degrees. + + speed = self.command('speed?') + + if speed != '1': + self.command('speed=1') + save = True + + # Make sure the external trigger is in 'output' mode. + + trigger = self.command('trig?') + + if trigger != '1': + self.command('trig=1') + save = True + + if save is True: + self.command('save') + + self.initialized = True + + + def command(self, command): + """ Wrapper to issue_command(), ensuring the command lock is + released if an exception occurs. + + :param command: String, command to issue. + + """ + + with self.lock: + try: + result = self.issue_command(command) + self.success = True + finally: + # Ensure that status is always checked, even on failure + self.check_status() + + return result + + def issue_command(self, command): + """ Wrapper to send/receive with error checking and retries. + + :param command: String, command to issue. + + """ + + if not self.connected: + self.set_status('connecting') + self.connect() + + retries = 3 + reply = '' + send_command = f"{command}\r".encode('utf-8') + + while retries > 0: + self.logger.debug("sending command %s", send_command) + try: + self.socket.send(send_command) + + except socket.error: + self.logger.error( + "Failed to send command, re-opening socket, %d retries remaining", retries) + self.disconnect() + try: + self.connect() + except OSError: + self.logger.error( + 'Could not reconnect to controller, aborting') + return None + retries -= 1 + continue + + # Wait for a reply. + delimiter = b'>' + + if 'pos=' in command: + # The next response will wait + # until the filter wheel is + # actually in position. + timeout = 5 + else: + timeout = 1 + + start = time.time() + time.sleep(0.1) + reply = self.socket.recv(1024) + while delimiter not in reply and time.time() - start < timeout: + try: + reply += self.socket.recv(1024) + self.logger.debug("reply: %s", reply) + except OSError as e: + if e.errno == ETIMEDOUT: + reply = '' + time.sleep(0.1) + + if reply == '': + # Don't log here, because it happens a lot when the controller + # is unresponsive. Just try again. + retries -= 1 + continue + break + + if isinstance(reply, str): + reply = reply.strip() + else: + reply = reply.decode('utf-8') + + if retries == 0: + raise RuntimeError('unable to successfully issue command: ' + repr(command)) + + # For a command with a reply, the response always looks like: + # + # command\rreply\r> + # + # For commands that do not have a reply, the response is: + # + # command\r> + + if command[-1] == '?': + expected = 3 + else: + expected = 2 + + chunks = reply.split('\r') + + if len(chunks) != expected: + raise ValueError(f"unexpected number of fields in response: {repr(reply)}") + + if expected == 3: + return chunks[1] + + return None + + def get_position(self): + """ Get the current position from the controller.""" + return self.command('pos?') + + def move(self, target): + """ Move the filter wheel to the target position. + + :param target: Int, target position to move. + + """ + if not self.initialized: + self.initialize() + + target = int(target) + command = f"pos={target:d}" + + response = self.command(command) + + if response is not None: + raise RuntimeError(f"error response to command: {response}") + + current = int(self.get_position()) + + if current != target: + raise RuntimeError( + f"wound up at position {current:d} instead of commanded {target:d}") + +# end of class Controller diff --git a/src/hispec/util/thorlabs/ppc102.py b/src/hispec/util/thorlabs/ppc102.py new file mode 100644 index 0000000..1fc30fc --- /dev/null +++ b/src/hispec/util/thorlabs/ppc102.py @@ -0,0 +1,1662 @@ +##### IMPORTANT NOTE:: ##### +# The PPC102 can(EXTREAMELY RARELY) fall into an "unhappy state" where the user +# is unable to command or query the stage in any way. This state is not reflected +# with software or hardware indications. The issue is that an interupt can get +# out of sync with the internal firmware loop, and you are unable to hop back +# into the loop. SOLUTION:: Power Cycle +# -Elijah A-B(Dev of this Library) + +import time +import socket +from enum import IntEnum, IntFlag +import logging +import struct +import contextlib +import io + +# Should Modify: +# Provide a build mode which does not print +# Can use buildFLG to supress prints and take it in as arg +# Can keep printDev() for printing if really needed + +# -- "destination" byte formatting -- +# The destination byte in a given packet is based on which hardware element +# is being commanded and the length of the message. +# Hardware element codes: +# - 0x01 (00000001) = host computer [us] +# - 0x11 (00010001) = motherboard [for controller-general commands] +# - 0x21 (00100001) = motor channel 1 [for commands to channel 1] +# - 0x22 (00100010) = motor channel 2 +# +# When a message is going to be >6 bytes long, the MSB must be set. The manual +# suggests doing that with a bitwise OR against 0x80 (shown in manual as 'd|') +# - 0x80 (10000000) = used to bit flip the MSB, signaling a longer-than-6-byte message +# +# Example: +# In the set_position() function, we're can command channel one, so we use 0x21. +# Since the command carries data (>6 bytes), we need to OR with 0x80. +# ==>> 0x21 | 0x80 = 0xA1 +# (00100001) | (10000000) = (10100001) + +class PPC102_Coms(object): + '''Class for controlling the Throlabs PPC102 + ***Device not setting Keys/Intr bits correctly so some items are omitted + from this code to avoid confusion + - The output of the device depends solely on the 'enable' bit + ''' + + def __init__(self, ip: str, port: int, timeout: float= 2.0, + log: bool = True): + ''' + Create socket connection instance variable + Parameters: Ini file and logger bool + old default ini params + (host: str = '192.168.29.100', port: int = 10013, + timeout: float = 2.0) + ''' + # Logger setup + logname = __name__.rsplit(".", 1)[-1] + self.logger = logging.getLogger(logname) + self.logger.setLevel(logging.DEBUG) + if log: + log_handler = logging.FileHandler(logname + ".log") + formatter = logging.Formatter( + "%(asctime)s--%(name)s--%(levelname)s--%(module)s--" + "%(funcName)s--%(message)s") + log_handler.setFormatter(formatter) + self.logger.addHandler(log_handler) + # Console handler for real-time output + console_handler = logging.StreamHandler() + console_formatter = logging.Formatter("%(asctime)s--%(message)s") + console_handler.setFormatter(console_formatter) + self.logger.addHandler(console_handler) + + self.logger.info("Logger initialized for PPC102_Coms") + + # get coms + self.ip = ip + self.port = port + self.timeout = timeout + self.sock = None + self.buffsize = 1024 + # Other Instance Variables + self.sock = None + self.DELAY = .1 # Number of seconds to wait after writing a message + #Class Constants + self.OPEN_LOOP = 1 + self.CLOSED_LOOP = 2 + self.CHAN_ENABLED = 1 + self.CHAN_DISABLED = 2 + + ########### Socket Communitcations ########### + def open(self): + ''' + Opens connection to device + -Also queries the device to obtain basic information + -This serves to confirm communication + -*Closes Device and reopens if already opens + RETURNS: True/False based on Successful connection + ''' + # if instranticated then close and open a new connection + if self.sock: + self.close() + # Try for error handling + try: + self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.sock.settimeout(self.timeout) + self.sock.connect((self.ip, self.port)) + self.logger.info(f"Connected to {self.ip}:{self.port}") + self.logger.info("Preliminary read_buff to clear buffer: " \ + "Sometimes inicializes with 0x00 in buffer") + # silent this single read buff execution!!! + original_logger_level = None + original_logger_level = self.logger.level + self.logger.setLevel(100) # Temporarily silence logger + #(higher than CRITICAL) + + try: + with contextlib.redirect_stdout(io.StringIO()): + try: + _ = self.read_buff() + except Exception: + pass + except Exception: + pass # Silently ignore + finally: + self.logger.setLevel(original_logger_level) + + return True # Successful Connection to Device + except socket.error as e: + self.logger.error(f"Socket connection failed: {e}") + self.sock = None + return False #Unsuccessful Connection + + def close(self): + ''' + Closes the device connection + ''' + #Socket close in try statements for error handling + if self.sock: + try: + self.sock.close() + self.logger.info("Socket closed.") + except socket.error as e: + self.logger.error(f"Error closing socket: {e}") + finally: + self.sock = None + + def write(self, msg: bytes): + ''' + Sends a message to the device + msg should be bytes(ex. b'05 00 00 00 50 01') + *Data requests using 'write' should be followed by a read + Otherwise unread items in buffer may cause problems + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + + # Send message using Socket + try: + self.sock.sendall(msg) + except socket.error as e: + self.logger.error(f"Error sending data: {e}") + + def read_buff(self): + ''' + This function will read socket(max: self.bufssize). + If buffer had values, it will return those values in hex form for the + calling fucntion to disect(Also clears buffer) + ''' + #Read socket + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + #return array of hex values for other functions to Disect + res = self.sock.recv(self.buffsize) + hex_array = [f'0x{byte:02X}' for byte in res] + #print(hex_array) + return hex_array + except socket.timeout: + self.logger.error("Read timed out.") + return [] + except socket.error as e: + self.logger.error(f"Error receiving data: {e}") + return [] + + def _interpret_bit_flags(self, byte_data): + """ + Helper function to interpret bits + All bit interpretation comes from pg.204 of APT Coms Doc + """ + if len(byte_data) != 4: + raise ValueError("Expected exactly 4 bytes") + + # Convert from little endian bytes to a 32-bit unsigned integer + status = int.from_bytes(byte_data, byteorder='little', signed=False) + + # Define bit meanings + bit_flags = { + 0: "Piezo actuator connected", + 10: "Position control mode (closed loop)", + 29: "Active (unit is active)", + 31: "Channel enabled", + } + + # Extract and report set bits + results = {} + for bit, description in bit_flags.items(): + results[description] = bool(status & (1 << bit)) + + return results + + def _check_for_reboot_(self): + ''' + Checks if an unrecoverable error has occured and the device + needs to be power cycled + NOTE:: Checks for consistent behavior of unhappy state + Returns: N/A, print statement if reboot needed + ''' + self.logger.info("Checking for unrecoverable state") + #Send a set of commands to see if device responds correctly + try: + #Message to query enable state, position and loop state + enableq = bytes([0x11, 0x02, 0x01, 0x00, 0x21, 0x01]) + posq = bytes([0x21, 0x06, 0x01, 0x00, 0x21, 0x01]) + loopq = bytes([0x41, 0x06, 0x01, 0x00, 0x21, 0x01]) + #counter for number of failed responses + message_list = [enableq, posq, loopq] + counter = 0 + for message in message_list: + self.write(message) + time.sleep(self.DELAY) + result = self.read_buff() + if len(result) < 6: + counter += 1 + if counter >= 2: + raise BrokenPipeError("Device in unrecoverable State, Power Cycle Needed") + else: + self.logger.info("Device responding correctly") + return + except Exception as e: + self.logger.error(f"Error: {e}") + return + + + ######## Functions for Complete Stage Control ######## + + def identify(self): + ''' + Makes device flash screen and LED for 3 seconds + Useful for identifying connected device Visually + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + + # Send identify command + try: + self.write(bytes([0x23, 0x02, 0x01, 0x00, 0x11, 0x01])) + time.sleep(3) # Wait until identify is complete + self.write(bytes([0x23, 0x02, 0x02, 0x00, 0x11, 0x01])) + time.sleep(3) + except socket.error as e: + self.logger.error(f"Error: {e}") + return None + + def set_enable(self, channel: int = 0, enable: int = 1): + ''' + Sets enable on PPC102 Controller + channel param:(int) 1 or 2 + NOTE: Default channel is set to 0, This will change both + channels to the desired enable state provided by the user + enable param:(int) Enable=1 or Disable=2 + Returns: True/False based on successful com send + **MGMSG_MOD_SET_CHANENABLESTATE**(10 02 Chan_Ident Enable_State d s) + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + #check for valid params + if enable not in (1, 2): + raise ValueError("Enable state must be 1 (Enable) or 2 (Disable)") + + if channel == 0: + command = bytes([0x10, 0x02, 0x01, enable, 0x21, 0x01]) + self.write(command) + time.sleep(self.DELAY) + command = bytes([0x10, 0x02, 0x01, enable, 0x22, 0x01]) + self.write(command) + time.sleep(self.DELAY) + return True + if channel not in (1, 2): + raise ValueError("Channel must be 0, 1 or 2") + + chan = 0x20 + channel # Channel identifier: 0x21 or 0x22 + set_val = enable # Already an int: 1 or 2 + + command = bytes([0x10, 0x02, 0x01, set_val, chan, 0x01]) + self.write(command) + time.sleep(self.DELAY) + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def get_enable(self, channel: int = 0): + ''' + Gets enable on PPC102 Controller + channel param:(int) 1 or 2 + NOTE: channel=0 will query both channels, returning a + list (channel 1 result, channel 2 result) + Returns: enable state for that channel as int + **MGMSG_MOD_REQ_CHANENABLESTATE**(11 02 Chan_Ident 0 d s) + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + #Check channels + if channel == 0: + # Construct command: [0x11, 0x02, 0x01, 0x00, chan, 0x01] + command = bytes([0x11, 0x02, 0x01, 0x00, 0x21, 0x01]) + self.write(command) + time.sleep(self.DELAY) + ch1 = self.read_buff() + ch1_state = ch1[3] + if len(ch1) != 6: + raise BufferError("Invalid number of bytes received") + + command = bytes([0x11, 0x02, 0x01, 0x00, 0x22, 0x01]) + self.write(command) + time.sleep(self.DELAY) + ch2 = self.read_buff() + ch2_state = ch2[3] + if len(ch2) != 6: + raise BufferError("Invalid number of bytes received") + + # retrun loop state + return int(ch1_state[2:],16), int(ch2_state[2:],16) + if channel not in (1, 2): + raise ValueError("Channel must be 0, 1 or 2") + + # Send Req Enable Command + chan = 0x20 + channel + command = bytes([0x11, 0x02, 0x01, 0x00, chan, 0x01]) + + # REQ + self.write(command) + time.sleep(self.DELAY) # Wait Delay time for write + + # returns self.logger.errored state of Channel and Enable + enable_status = self.read_buff() + if len(enable_status) == 0: + raise BufferError("Buffer empty when expecting response") + + enable_state = enable_status[3] # This should be a single byte + return int(enable_state[2:],16) # Already an int if read_buff + #returns a byte array + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return -1 + + def _set_digital_outputs(self,channel:int = 1, bit=0000): + ''' + Sets Digital Output on PPC102 Controller + (Trigger Fucntionality must be disabled by calling set_trigger first) + channel param:(int) 1 or 2 + bit param:1111 for all on and 0000 for all off + (Only capable of all or nothing setting) + Returns: True/False based on successful com send + **MGMSG_MOD_SET_DIGOUTPUTS**(13 02 Bit 00 d s)** + + NOTE: Only sets all on or all off, must implment more detailed + controls if you need it + + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + chan = 0x20 + channel # '2' + channel, as hex + + # Validate bit + if bit == 0b1111: + set_val = 0x0F + elif bit == 0b0000: + set_val = 0x00 + else: + raise ReferenceError('Bit not valid – must be 0b0000 or 0b1111') + + # Construct command + command = bytes([ + 0x13, # ID + 0x02, # Param 1 + set_val, # Bits + 0x00, # Unused + chan, # Destination (e.g., 0x21 for channel 1) + 0x01 # Source + ]) + + # Send MGMSG_MOD_SET_DIGOUTPUTS command + self.write(command) + time.sleep(self.DELAY) # Wait for execution of set + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def _get_digital_outputs(self,channel:int = 1, bit=0000): + ''' + Gets Digital Output on PPC102 Controller + channel param:(int) 1 or 2 + Returns: Bit + **MGMSG_MOD_REQ_DIGOUTPUTS**(14 02 Bits 00 d s)** + + NOTE:: bit not requred but original logic from maunal includes + + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + chan = 0x20 + channel # '2' + channel, as hex + + # Validate bit (not strictly needed for a "get", + # but preserved from original logic) + if bit == 0b1111: + set_val = 0x0F + elif bit == 0b0000: + set_val = 0x00 + else: + raise ReferenceError('Bit not valid - must be 0b0000 or 0b1111') + + # Construct command + command = bytes([ + 0x14, # ID + 0x02, # Param 1 + set_val, # Bits + 0x00, # Unused + chan, # Destination + 0x01 # Source + ]) + + # Send MGMSG_MOD_REQ_DIGOUTPUTS command + self.write(command) + time.sleep(self.DELAY) # Wait for response + + # Read response + digioutputs_status = self.read_buff() + if len(digioutputs_status) == 0: + raise BufferError("Buffer empty when expecting response") + + # Parse status byte (typically at index 2 or 3 depending on format) + digioutputs_state = digioutputs_status[2] + return int(digioutputs_state[2:],16) + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def _hw_disconnect(self): + ''' + Sent by hardware unit or host to disconnect from Ethernet or USB bus + Returns: True/False based on successful com send + **MGMSG_HW_DISCONNECT**(02 00 00 00 d s)** + + NOTE:: Do not disconnect, this would require a power cycle as there + is noreconnect set of bytes to send based on the thorlabs comms + documentation + + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Send identify command + self.write(bytes([0x02, 0x00, 0x00, 0x00, 0x11, 0x01])) + time.sleep(self.DELAY) # Data Grab + res = self.read_buff() + #Save all info needed into self.variables + self.logger.info("Disconnected from Hardware") + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def _hw_response(self): + ''' + Sent by the controllers to notify Thorlabs Server of some event that + requires user intervention, usually some fault or error condition + that needs to be handled before normal operation can resume. The + message transmits the fault code as a numerical value--see the + Return Codes listed in the Thorlabs Server helpfile for details + on the specific return codes. + Returns: return code + **MGMSG_HW_RESPONSE**(80 00 00 00 d s)** + + NOTE:: According to thor labs technical team, this function and + hw_richresponse are messages that we recieve from the hardware. + Rare occation. + + ''' + raise NotImplementedError("MGMSG_HW_RESPONSE: Has not been fully implemented") + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Send Req response Command + command = bytes([0x80, 0x00, 0x00, 0x00, 0x11, 0x01]) + #REQ + self.write(command) + time.sleep(self.DELAY) # Wait Delay time for write + #returns printed + res = self.read_buff() + if(len(res) == 0): + raise BufferError("Buffer empty when expecting response") + return res # TODO: Optional – parse return code if needed + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def _hw_richresponse(self): #TODO:: Finish + ''' + Similarly, to HW_RESPONSE, this message is sent by the controllers + to notify Thorlabs Server of some event that requires user + intervention, usually some fault or error condition that needs to be + handled before normal operation can resume. However, unlike + HW_RESPONSE, this message also transmits a printable text string. + Upon receiving the message, Thorlabs Server displays both the + numerical value and the text information, which is useful in finding + the cause of the problem. + Returns: + **MGMSG_HW_RICHRESPONSE**(81 00 44 00 d s MsgIdent(x2bytes) code(x2bytes))** + + NOTE:: HW_Response and HW_RichResponse basically do the same thing, + these are usually sent by the controller indicating some sort of fault + that needs to be addressed by the user before continuing. The only + difference being that RichResponse gives you a text string to help + debug the fault. I've never seen these be returned before so they + don't come up very often. + ''' + raise NotImplementedError("MGMSG_HW_RICHRESPONSE: Has not been fully implemented") + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Send Req rich response Command + command = bytes([ + 0x81, 0x00, 0x44, 0x00, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00 + ]) + #REQ + self.write(command) + time.sleep(self.DELAY) # Wait Delay time for write + #returns printed + res = self.read_buff() + if(len(res) == 0): + raise BufferError("Buffer empty when expecting response") + return res # TODO: Optional – parse message content + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def _hw_start_update_msgs(self): + ''' + Sent to start automatic status updates from the embedded + controller. Status update messages contain information about the + position and status of the controller (for example limit switch + status, motion indication, etc). The messages will be sent by + the controller every 100 msec until it receives a STOP STATUS + UPDATE MESSAGES command. In applications where spontaneous + messages (i.e., messages which are not received as a response to + a specific command) must be avoided the same information can + also be obtained by using the relevant GET_STATUTSUPDATES function. + Returns: True/False on successful com send + **MGMSG_HW_START_UPDATEMSGS**(11 00 unused unused d s)** + + NOTE: This function starts the polling loop inside the hardware that is + ''' + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Send start update response Command + command = bytes([0x11, 0x00, 0x00, 0x00, 0x11, 0x01]) + #REQ + self.write(command) + time.sleep(self.DELAY) # Wait Delay time for write + #returns printed state + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def _hw_stop_update_msgs(self): + ''' + Sent to stop automatic status updates from the controller – usually + called by a client application when it is shutting down, to instruct + the controller to turn off status updates to prevent USB buffer + overflows on the PC. + Returns: True/False on successful com send + **MGMSG_HW_STOP_UPDATEMSGS**(12 00 unused unused d s)** + ''' + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Send stop update response Command + command = bytes([0x12, 0x00, 0x00, 0x00, 0x11, 0x01]) + #REQ + self.write(command) + time.sleep(self.DELAY) # Wait Delay time for write + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def _get_info(self):#TODO:: Parse this message + ''' + Sent to request hardware information from the controller. + Returns: True/False on successful com send + **MGMSG_HW_REQ_INFO**(05 00 00 00 d s)** + + NOTE:: Response Data Packet Not parsed yet + - This function is used to get the hardware information from the + controller, such as firmware version, serial number, etc + + ''' + raise NotImplementedError(" MGMSG_HW_REQ_INFO Correctly send and " \ + "recieved but not parsed ") + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Send identify command + self.write(bytes([0x05, 0x00, 0x00, 0x00, 0x11, 0x01])) + time.sleep(self.DELAY) # Data Grab + res = self.read_buff() + if(len(res) != 90): + raise BufferError("Buffer empty when expecting response") + #Save all info needed into self.variables + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def get_rack_bay_used(self, bay:int = 0): + ''' + Sent to determine whether the specified bay in the controller is occupied. + bay param: int + Returns: True=Occupied//False=Empty + **MGMSG_RACK_REQ_BAYUSED**(60 00 Bay_Ident 00 d s)** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + if 0 <= bay < 10: + set_val = bay # already an integer, 0–9 + else: + raise ReferenceError('Bay Out of Range') + + # Send Req digioutput Command + command = bytes([0x60, 0x00, set_val, 0x00, 0x11, 0x01]) + + # REQ + self.write(command) + time.sleep(self.DELAY) # Wait Delay time for write + + # Read and process response + bay_res = self.read_buff() + if len(bay_res) == 0: + raise BufferError("Buffer empty when expecting response") + + bay_state = bay_res[3] # Already an int if read_buff returns bytes/bytearray + return int(bay_state[2:],16) == 1 + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def set_loop(self, channel: int = 0, loop:int = 1): + ''' + Sets the loop to open or closed on each channel + -Must change for each channel to have a completely closed loop + -each channel must be enabled + channel:(int) 1 or 2 + NOTE: Default channel is set to 0, This will change both + loops to the desired state the user is attempting to set it to + loop: Loop state int: 1 Open Loop (no feedback) + 2 Closed Loop (feedback employed) + 3 Open Loop Smooth + 4 Closed Loop Smooth + **MGMSG_PZ_GET_POSCONTROLMODE**(41 06 Chan_Iden + Returns: True or False on successful com send + **MGMSG_PZ_SET_POSCONTROLMODE**(40 06 Chan_Ident Mode d s)** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + #Check valid loop state + if loop in (1,2,3,4): + set_val = loop + else: + raise ReferenceError('Loop mode out of range (must be 1,2,3 or 4)') + + # Validate channel + if channel == 0: + #Check for enable, instruct to set enable if needed + if (self.get_enable(channel = 1) == self.CHAN_DISABLED or + self.get_enable(channel = 2) == self.CHAN_DISABLED): + raise PermissionError( + 'Channel must be enabled.\n' + ' Solution: call set_enable(channel= , enable=1)') + command = bytes([0x40, 0x06, 0x01, set_val, 0x21, 0x01]) + self.write(command) + command = bytes([0x40, 0x06, 0x01, set_val, 0x22, 0x01]) + self.write(command) + time.sleep(self.DELAY) + return True + elif channel not in (1, 2): + raise ValueError("Channel must be 0, 1 or 2") + + chan = 0x20 + channel # '2' + channel, as hex + + # Construct command: [0x40, 0x06, 0x01, set_val, chan, 0x01] + #Check for enable, instruct to set enable if needed + if (self.get_enable(channel) == self.CHAN_DISABLED): + raise PermissionError( + 'Channel must be enabled.\n' + ' Solution: call set_enable(channel= , enable=1)') + command = bytes([0x40, 0x06, 0x01, set_val, chan, 0x01]) + self.write(command) + time.sleep(self.DELAY) + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def get_loop(self, channel: int = 0): + ''' + Gathers the current state of a channels loop + channel:(int) 1 or 2 + NOTE: channel=0 will query both channels, returning a + list (channel 1 result, channel 2 result) + Returns: Loop state int 1 Open Loop (no feedback) + 2 Closed Loop (feedback employed) + 3 Open Loop Smooth + 4 Closed Loop Smooth + **MGMSG_PZ_GET_POSCONTROLMODE**(41 06 Chan_Ident 00 d s)** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel == 0: + # Construct command: [0x41, 0x06, 0x01, 0x00, chan, 0x01] + command = bytes([0x41, 0x06, 0x01, 0x00, 0x21, 0x01]) + self.write(command) + time.sleep(self.DELAY) + ch1 = self.read_buff() + ch1_state = ch1[3] + if len(ch1) != 6: + raise BufferError("Invalid number of bytes received") + + command = bytes([0x41, 0x06, 0x01, 0x00, 0x22, 0x01]) + self.write(command) + time.sleep(self.DELAY) + ch2 = self.read_buff() + ch2_state = ch2[3] + if len(ch2) != 6: + raise BufferError("Invalid number of bytes received") + + # retrun loop state + return int(ch1_state[2:],16), int(ch2_state[2:],16) + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + chan = 0x20 + channel # '2' + channel, as hex + + # Construct command: [0x41, 0x06, 0x01, 0x00, chan, 0x01] + command = bytes([0x41, 0x06, 0x01, 0x00, chan, 0x01]) + self.write(command) + time.sleep(self.DELAY) + + loop_status = self.read_buff() + loop_state = loop_status[3] + if len(loop_status) != 6: + raise BufferError("Invalid number of bytes received") + + # retrun loop state + return int(loop_state[2:],16) + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def are_loops_closed(self, channel: int = 0): + ''' + Uses the get_loop function that returns an int to return a + boolean for the state of the loops + channel: 0=both loops + 1=channel 1 loop + 2=channel 2 loop + returns: Bool True(int returned = 2) False(int returned = 1) + NOTE: ONLY returns true when both channels are in a closed-loop + state. will return true/false if querying for individual + channel + ''' + loop_state = self.get_loop(channel) + if isinstance(loop_state, tuple): + return loop_state[0] == 2 and loop_state[1] == 2 + return loop_state == 2 + + def set_output_volts(self, channel: int = 1, volts:int = 0): + ''' + Sets voltage going to specified channel + -Must be in open loop + -each channel must be enabled + channel:(int) 1 or 2 + volts:(int) -32768 --> 32767 + Returns: True or False on successful com send + **MGMSG_PZ_SET_OUTPUTVOLTS**(43 06 04 00 d s Chan_Ident(x2bytes) + Volts(x2bytes))** + ''' + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + destination = (0x20 + channel) | 0x80 # '2' + channel, as hex + + # Check if channel is enabled + if self.get_enable(channel) == self.CHAN_DISABLED: + raise PermissionError('Channel Must Be enabled\n' + + ' solution: call set_enable(channel= , enable=1)') + + # Check if loop is open + if self.get_loop(channel) == self.CLOSED_LOOP: + raise PermissionError("Loops Must be OPEN") + + # Check voltage range + if -32768 < volts < 32767: + volts_bytes = volts.to_bytes(2, byteorder='little', signed=True) + else: + self.logger.error('Voltage out of Range') + return False + + # Channel identifier (usually 0x01 0x00) + chan_ident = bytes([0x01, 0x00]) + + # Build command + command = bytes([0x43, 0x06, 0x04, 0x00,destination, + 0x01,]) + chan_ident + volts_bytes + + self.write(command) + time.sleep(self.DELAY) + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def get_output_volts(self, channel: int = 1): + ''' + Gathers the current state of a channels voltage + -must be in open loop + channel:(int) 1 or 2 + Returns: Voltage state in int (-32768 --> 32767) + **MGMSG_PZ_GET_OUTPUTVOLTS**(44 6 Chan_Ident 00 d s)** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + destination = (0x20 + channel) + + # Only proceed if open loop and enabled + if (self.get_loop(channel) == self.OPEN_LOOP and + self.get_enable(channel) == self.CHAN_ENABLED): + + # Construct command + command = bytes([0x44, 0x06, 0x01, 0x00,destination,0x01 ]) + self.write(command) + else: + raise PermissionError("Loops Must be OPEN and channel must be " \ + "enabled") + + time.sleep(self.DELAY) + + # Read response + volts = self.read_buff() + if len(volts) != 10: + raise BufferError("Buffer did not return expected response " \ + "length (10 bytes)") + + # Voltage is in bytes 8 and 9 (little endian hex strings like '0xA3', '0x00') + low_byte = int(volts[8], 16) # LSB + high_byte = int(volts[9], 16) # MSB + + # Convert to signed 16-bit int + voltage_raw = (high_byte << 8) | low_byte + if voltage_raw >= 0x8000: + voltage_raw -= 0x10000 + return voltage_raw + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def set_position(self, channel: int = 1, pos:float = 0.00): + ''' + Sets the position of the stage channel + -only settable while in closed loop + pos: (float-10.0 mRad -> +10.0 mRad + Returns: True or False based on successful com send + NOTE:Sending Controller 0 --> 32767 based on the angular range + user provides + **MGMSG_PZ_SET_OUTPUTPOS**(46 06 04 00 d s Chan_Ident(x2bytes) + Pos(x2bytes))** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + destination = (0x20 + channel) | 0x80 + + #Check for enable, set enable if needed + if self.get_enable(channel) == self.CHAN_DISABLED: + raise PermissionError('Channel Must Be enabled\n' + + ' solution: call set_enable(channel= , enable=1)') + + #check for loop state + if self.get_loop(channel) == self.OPEN_LOOP: + raise PermissionError("Loops Must be Closed") + + #Check for valid inputs + converted_pos = int(round((pos + 10)/20*32767)) + #Check Loop State + if 0 <= converted_pos <= 32767: + pos_bytes = converted_pos.to_bytes(2, byteorder='little', signed=False) + else: + self.logger.error('Position out of Range') + return False + + #Write command + command = bytes([0x46, 0x06, 0x04, 0x00,destination, 0x01, + 0x01, 0x00,pos_bytes[0], pos_bytes[1] ]) + self.write(command) + time.sleep(self.DELAY) + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def get_position(self, channel: int = 1): + ''' + Gets Positional Value of an axis of a stage + -can only read positions while in closed loop + channel: (int) 1 0r 2 + Returns: -10.0 mRad -> 10.0 mRad + NOTE:Contoller return 0 --> 32768 and converted is converted + to the angular range + **MGMSG_PZ_REQ_OUTPUTPOS**(47 06 Chan_Ident 00 d s)** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + return None + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + destination = (0x20 + channel) + + # Send Req OUTPUTPOS command if in closed loop + if (self.get_loop(channel) == self.CLOSED_LOOP and + self.get_enable(channel) == self.CHAN_ENABLED): + command = bytes([0x47, 0x06, 0x01, 0x00,destination, 0x01]) + self.write(command) #REQ + else: + raise PermissionError("Loops Must be Closed and channel must be " \ + "enabled") + + time.sleep(self.DELAY) # Wait Delay time for write + + #returns printed state of Channel and Enable + pos = self.read_buff() + if(len(pos) == 0): + raise BufferError("Buffer empty when expecting response") + + #Return Positional Value, 2hex or the positional value in int(bytes 8 and 9) + # Convert hex string to bytes and parse little-endian unsigned int + low_byte = int(pos[8], 16) + high_byte = int(pos[9], 16) + position = (high_byte << 8) | low_byte + + #Convert for user readability + if position > 32767: + position = 32767 - position + mRad_pos = (position / 32767) * 20 - 10 + + return mRad_pos + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def get_max_travel(self, channel: int = 1): + ''' + In the case of actuators with built in position sensing, the + Piezoelectric Control Unit can detect the range of travel of the + actuator since this information is programmed in the electronic + circuit inside the actuator. This function retrieves the maximum + travel for the piezo actuator associated with the channel specified + by the Chan Ident parameter, and returns a value (in microns) in the + Travel parameter. + channel: (int) 1 0r 2 + Returns: travel of a single acuator in microns(Linear Travel) not + Angular travel + (ThorLabs Support states: 10nm of linear travel equates + to about 20 mrad of angular movement in the mount) + **MGMSG_PZ_REQ_MAXTRAVEL**(50 06 Chan_Ident 00 d s)** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + destination = (0x20 + channel) + + # Send Req + command = bytes([0x50, 0x06, 0x01, 0x00,destination, 0x01]) + self.write(command) #REQ + + time.sleep(self.DELAY) # Wait Delay time for write + + #returns printed state of Channel and Enable + trav = self.read_buff() + if(len(trav) == 0): + raise BufferError("Buffer empty when expecting response") + + #Return travitional Value, 2hex or the travitional value in int(bytes 8 and 9) + byte1 = int(trav[8], 16) + byte2 = int(trav[9], 16) + hexVal = byte2 << 8 | byte1 + return hexVal + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def get_status_bits(self, channel: int = 1): + ''' + Returns a number of status flags pertaining to the operation of the + piezo controller channel specified in the Chan Ident parameter. + These flags are returned in a single 32 bit integer parameter and can + provide additional useful status information for client application + development. The individual bits (flags) of the 32 bit integer value + are described in the following tables. + channel: (int) 1 or 2 + Returns: Status Bytes 4 hex values + **MGMSG_PZ_REQ_PZSTATUSBITS**(5B 06 Chan_Ident 00 d s)** + + NOTE::Bit status comes from pg.204 of thor labs APT Coms documentation + + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + destination = (0x20 + channel) + + # Send + command = bytes([0x5B, 0x06, 0x01, 0x00,destination, 0x01]) + self.write(command) #REQ + + time.sleep(self.DELAY) # Wait Delay time for write + #returns printed state of Channel and Enable + status = self.read_buff() + if len(status) < 12: + raise BufferError("Buffer empty when expecting response") + + # Collect status bytes 8 through 11 (LSB to MSB) + status_bytes = bytes(int(b, 16) for b in status[8:12]) + + #deliver to interpret bytes function + results = self._interpret_bit_flags(status_bytes) + + self.logger.info("Status Flags:", results) + return results + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def get_status_update(self, channel: int = 1): + ''' + This function is used in applications where spontaneous status + messages (i.e. messages sent using the START_STATUSUPDATES + command) must be avoided. + Status update messages contain information about the position and + status of the controller (for example position and O/P voltage). The + messages will be sent by the controller each time the function is + called. + channel: (int) 1 or 2 + Returns: OPVoltage, Position,StatusBits + **MGMSG_PZ_REQ_PZSTATUSUPDATE**(60 06 Chan_Ident 00 d s)** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + destination = (0x20 + channel) + + command = bytes([0x60, 0x06, 0x01, 0x00,destination, 0x01]) + self.write(command) #REQ + time.sleep(self.DELAY) # Wait Delay time for write + #returns printed state of Channel and Enable + status = self.read_buff() + if len(status) < 16: + raise BufferError("Buffer empty when expecting response") + + volt_bytes = bytes(int(b, 16) for b in status[8:10]) + pos_bytes = bytes(int(b, 16) for b in status[10:12]) + stat_bytes = bytes(int(b, 16) for b in status[12:16]) + + voltage = int.from_bytes(volt_bytes, byteorder='little') + position = int.from_bytes(pos_bytes, byteorder='little') + #Convert for user readability + if position > 32767: + position = 32767 - position + mRad_pos = (position / 32767) * 20 - 10 + flags = self._interpret_bit_flags(stat_bytes) + #flags = self.interpret_bit_flags(stat_bytes) + + self.logger.info(f"Voltage: {voltage}") + self.logger.info(f"Position: {mRad_pos}") + + return voltage, mRad_pos, flags + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def set_max_output_voltage(self, channel: int = 1, limit:int = 150): + ''' + The piezo actuator connected to the unit has a specific maximum + operating voltage range: 75, 100 or 150 V. This function sets the + maximum voltage for the piezo actuator associated with the + specified channel. + channel: (int) 1 or 2 + Returns: True or False on successful com send + **MGMSG_PZ_SET_OUTPUTMAXVOLTS**(80 06 06 00 d| s Chan_Itent(x2bytes) + Volts(x2bytes) + Flags(x2bytes))** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + #Check for enable, set enable if needed + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + #Convert User friendly volt units to controller expected decavolt units + limit = int(limit * 10) + + destination = (0x20 + channel) | 0x80 + + #Check for valid inputs + if 0 < limit <= 1500: + hex_val = f'{limit:04X}' + #Backwards voltage section according to the manual + #little Edian + volt_lsb = int(hex_val[2:], 16) + volt_msb = int(hex_val[:2], 16) + else: + raise ValueError('Voltage out of Range') + + #Format and write commad + command = bytes([ + 0x80, 0x06, 0x06, 0x00,destination, 0x01, 0x01, 0x00, + volt_lsb, volt_msb,0x00, 0x00]) + self.write(command) + time.sleep(self.DELAY) + return True + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return False + + def get_max_output_voltage(self, channel: int = 1): + ''' + Gets Max voltage for associated channel + channel: (int) 1 or 2 + Returns: Max Volts (0v --> 150v) + **MGMSG_PZ_GET_OUTPUTMAXVOLTS**(81 06 Chan_Ident 00 d s)** + ''' + # Check if socket is open + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + # Validate channel + if channel not in (1, 2): + raise ValueError("Channel must be 1 or 2") + + destination = (0x20 + channel) + + #Send command for reqOUTPUTMAXVOLTS + command = bytes([0x81, 0x06, 0x01, 0x00,destination, 0x01]) + self.write(command) + time.sleep(self.DELAY) + msg = self.read_buff() + if(len(msg) == 0): + raise BufferError("Buffer empty when expecting response") + byte1 = int(msg[8], 16) + byte2 = int(msg[9], 16) + max_volts = byte2 << 8 | byte1 + max_volts = max_volts/10 + return max_volts + except Exception as e: + self.logger.error(f"Error: {e}") + self._check_for_reboot_() + return None + + def _set_ppc_PIDCONSTS(self, channel: int = 1, p_const: float = 900.0, + i_const: float = 800.0, d_const: float = 90.0, + dfc_const: float = 1000.0, derivFilter: bool = True): + ''' + When operating in Closed Loop mode, the proportional, integral and + derivative (PID) constants can be used to fine tune the behaviour of + the feedback loop to changes in the output voltage or position. + While closed loop operation allows more precise control of the + position, feedback loops need to be adjusted to suit the different + types of focus mount assemblies that can be connected to the + system. Due to the wide range of objectives that can be used with + the PFM450 and their different masses, some loop tuning may be + necessary to optimize the response of the system and to avoid + instability. + This message sets values for these PID parameters. The default + values have been optimized to work with the actuator shipped with + the controller and any changes should be made with caution. + channel: (int) 1 or 2 + p_const: float 0-10000 + i_const: float 0-10000 + d_const: float 0-10000 + dfc_const: float 10000 + derivFilter: True=ON False=OFF + Returns: True or False based on successful com send + **MGMSG_PZ_SET_PPC_PIDCONSTS**(90 06 0C 00 d s Chan_Ident(x2bytes) + p_const(x2bytes) i_const(x2bytes) + d_const(x2bytes) dfc_const(x2bytes) + derivFilter(x2bytes))** + + TODO:: tests + ''' + raise NotImplementedError("MGMSG_PZ_SET_PPC_PIDCONSTS: Implemented but " \ + "not tested") + #check Connection + if not self.sock: + raise RuntimeError("Socket is not connected.") + + try: + if 0 < channel < 3: + destination = (0x20 + channel) | 0x80 + + if not all(0 <= val <= 10000 for val in (p_const, i_const, + d_const, dfc_const)): + raise ValueError("PID values must be between 0 and 10000") + + chan_ident = (1).to_bytes(2, byteorder='little') + + # Convert PID values to little-endian 2-byte words + p_bytes = int(p_const).to_bytes(2, byteorder='little') + i_bytes = int(i_const).to_bytes(2, byteorder='little') + d_bytes = int(d_const).to_bytes(2, byteorder='little') + dfc_bytes = int(dfc_const).to_bytes(2, byteorder='little') + filter_flag = (1 if derivFilter else 2).to_bytes(2, byteorder='little') + + # Header: 90 06 0C 00 d s + # Assuming destination is generic device 0xD0 and source is 0x01 + header = bytes([0x90, 0x06, 0x0C, 0x00, destination, 0x01]) + data = chan_ident + p_bytes + i_bytes + d_bytes + dfc_bytes + filter_flag + + packet = header + data + self.write(packet) + time.sleep(self.DELAY) + + self.logger.info(f"PID constants sent: P={p_const}, "\ + "I={i_const}, D={d_const}, DFC={dfc_const}, "\ + "Filter={'ON' if derivFilter else 'OFF'}") + return True + except Exception as e: + self.logger.error(f"Error in set_pid_consts: {e}") + self._check_for_reboot_() + return False + + def _get_ppc_PIDCONSTS(self, channel:int = 1): + ''' + Gets current state values based on description from set + channel:(int) 1 or 2 + Returns: PID constants in the same format as the set + **MGMSG_PZ_GET_PPC_PIDCONSTS**(91 06 Chan_Ident 00 d s )** + + NOTE:: Parsing seems to be incorrect + + ''' + raise NotImplementedError("MGMSG_PZ_GET_PPC_PIDCONSTS: " \ + "Parseing seems to be Incorrect") + # check connection + if not self.sock: + raise RuntimeError("Socket is not connected.") + + try: + if 0 < channel < 3: + destination = (0x20 + channel) + else: + raise ReferenceError("Channel must be 1 or 2.") + + chan_ident = (1).to_bytes(1, byteorder='little') + + # Header for GET command: 91 06 + ChanIdent + 00 + d + s + header = bytes([0x91, 0x06]) + chan_ident + bytes([0x00, destination, 0x01]) + self.write(header) + time.sleep(self.DELAY) + + response = self.read_buff() + #Make into bytes for easier parsing + response_bytes = bytes([int(b, 16) for b in response]) + + # Parse the 12-byte payload from byte 6 onwards + p_const = int.from_bytes(response_bytes[8:10], byteorder='little') + i_const = int.from_bytes(response_bytes[10:12], byteorder='little') + d_const = int.from_bytes(response_bytes[12:14], byteorder='little') + dfc_const = int.from_bytes(response_bytes[14:16], byteorder='little') + deriv_filter_flag = int.from_bytes(response_bytes[16:18], byteorder='little') + deriv_filter = True if deriv_filter_flag == 1 else False + + pid_consts = { + 'p_const': p_const, + 'i_const': i_const, + 'd_const': d_const, + 'dfc_const': dfc_const, + 'derivFilter': deriv_filter + } + + self.logger.info(f"Retrieved PID constants: {pid_consts}") + return pid_consts + + except Exception as e: + self.logger.error(f"Error in get_pid_consts: {e}") + self._check_for_reboot_() + return None + + def _set_ppc_NOTCHPARAMS(self, channel: int, filterNO: int, + filter_1fc: float, filter_1q: float, notch_filter1_on: bool, + filter_2fc: float, filter_2q: float, notch_filter2_on: bool): + ''' + Due to their construction, most actuators are prone to mechanical + resonance at well-defined frequencies. The underlying reason is that + all spring-mass systems are natural harmonic oscillators. This + proneness to resonance can be a problem in closed loop systems + because, coupled with the effect of the feedback, it can result in + oscillations. With some actuators, the resonance peak is either weak + enough or at a high enough frequency for the resonance not to be + troublesome. With other actuators the resonance peak is very + significant and needs to be eliminated for operation in a stable + closed loop system. The notch filter is an adjustable electronic anti + resonance that can be used to counteract the natural resonance of + the mechanical system. + As the resonant frequency of actuators varies with load in addition + to the minor variations from product to product, the notch filter is + tuneable so that its characteristics can be adjusted to match those + of the actuator. In addition to its centre frequency, the bandwidth of + the notch (or the equivalent quality factor, often referred to as the + Q-factor) can also be adjusted. In simple terms, the Q factor is the + centre frequency/bandwidth, and defines how wide the notch is, a + higher Q factor defining a narrower ("higher quality") notch. + Optimizing the Q factor requires some experimentation but in + general a value of 5 to 10 is in most cases a good starting point. + channel: (int) 1 or 2 + filterNO: int 1,2,3 + filter_1fc: float 20-500 + filter_1q: float 0.2 100 + notch_filter1_on: word ON or OFF + filter_2fc: float 20-500 + filter_2q: float 0.2 100 + notch_filter2_on: word ON or OFF + Returns: true or false based on successful com send + **MGMSG_PZ_SET_PPC_NOTCHPARAMS**(93 06 10 00 d s (16 byte data packet))** + + TODO:: Requires testing + ''' + # Check for connection + if not self.sock: + raise RuntimeError("Socket is not connected.") + try: + #Check Channel and Filter values + if channel not in [1, 2]: + raise ValueError("Channel must be 1 or 2") + if filterNO not in [1, 2, 3]: + raise ValueError("Filter number must be 1, 2, or 3") + + #Assign values + destination = (0x20 + channel) | 0x80 + chan_ident = (1).to_bytes(2, byteorder='little') + filter_no_bytes = filterNO.to_bytes(2, byteorder='little') + + #Notch filters based on bools + notch1_on_bytes = (1 if notch_filter1_on else 2).to_bytes(2, 'little') + notch2_on_bytes = (1 if notch_filter2_on else 2).to_bytes(2, 'little') + + #Creation of data packet + package = ( + chan_ident + + filter_no_bytes + + struct.pack('=75"] +build-backend = "setuptools.build_meta" + +[project] +name = "thorlabs" +version = "0.1.0" +description = "A collection of Python interfaces for communicating with HISPEC FEI components, including Filter Wheel and Gimbal Mount Control." +authors = [ + { name = "Elijah Anakalea-Buckley", email = "elijahab@caltech.edu" } +] +maintainers = [ + { name = "Elijah Anakalea-Buckley", email = "elijahab@caltech.edu" } +] +readme = "README.md" +requires-python = ">=3.8" + +dependencies = [ + "pyserial" +] + +[project.urls] +Repository = "https://github.com/COO-Utils/thorlabs" + +[project.optional-dependencies] +dev = [ + "pytest-mock", + "pytest", + "black", + "flake8" +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +markers = [ + "default: marks tests as default run set", + "unit: marks tests as unit tests", + "functional: marks tests as functional tests", +] + +[tool.setuptools.packages.find] +# find packages under the "thorlabs" namespace +include = ["ppc102", "fw102c"] diff --git a/src/hispec/util/thorlabs/tests/default_fw102c_test.py b/src/hispec/util/thorlabs/tests/default_fw102c_test.py new file mode 100644 index 0000000..26145de --- /dev/null +++ b/src/hispec/util/thorlabs/tests/default_fw102c_test.py @@ -0,0 +1,90 @@ +################# +#Default Communication test +#Description: Test connection, disconnection and confirming communication with stage +################# + +import pytest +pytestmark = pytest.mark.default +import sys +import os +import unittest +import time +from fw102c import FilterWheelController + +########################## +## CONFIG +## connection and Disconnection in all test +########################## + +class Default_Test(unittest.TestCase): + + #Instances for Test management + def setUp(self): + self.dev = FilterWheelController() + self.success = True + self.ip = '192.168.29.100' + self.port = 10010 + self.log = False + self.error_tolerance = 0.1 + + ########################## + ## Test Connection + ########################## + def test_connection(self): + time.sleep(.2) + # Open connection + self.dev = FilterWheelController(log = self.log) + self.dev.set_connection(ip=self.ip, port=self.port) + assert self.dev.status is None + self.dev.connect() + time.sleep(.25) + assert self.dev.connected + assert self.dev.success + assert self.dev.status == 'ready' + self.dev.disconnect() + time.sleep(.25) + assert not self.dev.connected + assert self.dev.status == 'disconnected' + time.sleep(.25) + + + ########################## + ## Negative test: failed connect + ########################## + def failed_connect_test(self): + # Use an unreachable ip (TEST-NET-1 range, reserved for docs/testing) + bad_ip = "192.1.2.123" + bad_port = 65535 # usually blocked/unusable + + self.dev = FilterWheelController(log=self.log) + self.dev.set_connection(ip=bad_ip, port=bad_port) + self.dev.connect() + time.sleep(.25) + assert self.dev.success is False, "Expected connection failure with invalid ip/port" + assert self.dev.connected is False, "Expected not connected state with invalid ip/port" + self.assertFalse(dev.connected, "Expected connection failure with invalid ip/port") + self.dev.disconnect() + time.sleep(.25) + + ########################## + ## Inicialize test + ########################## + def inicialize(self): + self.dev = FilterWheelController(log = self.log) + self.dev.set_connection(ip=self.ip, port=self.port) + self.dev.connect() + time.sleep(.25) + self.dev.initialize() + time.sleep(.25) + assert self.dev.initialized + assert self.dev.revision is not None + self.dev.disconnect() + time.sleep(.25) + + +if __name__ == '__main__': + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(Default_Test) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/thorlabs/tests/default_ppc102_test.py b/src/hispec/util/thorlabs/tests/default_ppc102_test.py new file mode 100644 index 0000000..4a74903 --- /dev/null +++ b/src/hispec/util/thorlabs/tests/default_ppc102_test.py @@ -0,0 +1,160 @@ +################# +#Default Communication test +#Description: Test connection, disconnection and confirming communication with stage +################# + +import pytest +pytestmark = pytest.mark.default +import sys +import os +import unittest +import time +from ppc102 import PPC102_Coms + +########################## +## CONFIG +## connection and Disconnection in all test +########################## + +class Comms_Test(unittest.TestCase): + + #Instances for Test management + #def setUp(self): + dev = None + success = True + ip = '192.168.29.100' + port = 10012 + log = False + error_tolerance = 0.1 + + ########################## + ## Servos / Loops [ Not really applicable] + ########################## + def test_loop(self): + time.sleep(.2) + # Open connection + self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) + time.sleep(.2) + self.dev.open() + time.sleep(.25) + for ch in [1,2]:#Check for channels that are applicable + #Close Loop assert Loop states + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.OPEN_LOOP or ret == self.dev.CLOSED_LOOP + assert self.dev.set_loop(channel=ch, loop=2) + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.CLOSED_LOOP + #Open Loops and assert the states + assert self.dev.set_loop(channel=ch, loop=1) + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.OPEN_LOOP + self.assertFalse(self.dev.set_loop(channel=5)) + self.assertFalse(self.dev.set_loop(channel=-1)) + self.assertTrue(self.dev.set_loop(loop = 4)) + ret = self.dev.get_loop(channel = 0) + assert ret[0] == self.dev.CLOSED_LOOP + assert ret[1] == self.dev.CLOSED_LOOP + self.assertTrue(self.dev.set_loop(loop = 1)) + ret = self.dev.get_loop(channel = 0) + assert ret[0] == self.dev.OPEN_LOOP + assert ret[1] == self.dev.OPEN_LOOP + self.dev.close() + time.sleep(.25) + with self.assertRaises(Exception): + self.dev.get_loop() + self.dev.set_loop() + time.sleep(.25) + #Close connection + self.dev.close() + time.sleep(.25) + + ########################## + ## Negative test: failed connect + ########################## + def failed_connect_test(self): + # Use an unreachable ip (TEST-NET-1 range, reserved for docs/testing) + bad_ip = "192.1.2.123" + bad_port = 65535 # usually blocked/unusable + + dev = PPC102_Coms(ip=bad_ip, port=bad_port, log=self.log) + + success = dev.open() + self.assertFalse(success, "Expected connection failure with invalid ip/port") + dev.close() + + ########################## + ## Limit Check + ########################## + def test_limit(self): + # Open connection + self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) + time.sleep(.2) + self.dev.open() + time.sleep(.25) + for ch in [1,2]: # Check for channels that are applicable + # Check limit states and save to variable + original_limit = self.dev.get_max_output_voltage(channel=ch) + print(f"Channel {ch} Max output Voltage: {original_limit}") + # Set limit states and assert + assert self.dev.set_max_output_voltage(channel=ch, limit=75) + ret = self.dev.get_max_output_voltage(channel=ch) + print(f"New Channel {ch} Max output Voltage: {ret}") + # set limits back to default + assert self.dev.set_max_output_voltage(channel=ch, limit=original_limit) + ret = self.dev.get_max_output_voltage(channel=ch) + print(f"Back to Original Channel {ch} Max output Voltage: {ret}") + + #Close connection + self.dev.close() + time.sleep(.25) + + ########################## + ## Position Query and Movement + ########################## + def test_position_query(self): + self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) + self.dev.open() + time.sleep(.25) + for ch in [1,2]: # Check for channels that are applicable + # Close loops and assert + assert self.dev.set_loop(channel=ch, loop=self.dev.CLOSED_LOOP) + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.CLOSED_LOOP + + # Get position and assert + original_position = self.dev.get_position(channel=ch) + #make sure that balue returned is a not none type + assert original_position is not None + #open loops and assert + assert self.dev.set_loop(channel=ch, loop=self.dev.OPEN_LOOP) + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.OPEN_LOOP + + #Close connection + self.dev.close() + time.sleep(.25) + + ########################## + ## Status Communication + ########################## + def status_communication(self): + self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) + self.dev.open() + time.sleep(.25) + for ch in [1,2]: # Check for channels that are applicable + # Get status and assert + ret = self.dev.get_status_update(channel=ch) + assert ret is not None + # Get status bits + ret = self.dev.get_status_bits(channel=ch) + assert ret is not None + self.dev.close() + time.sleep(.25) + + +if __name__ == '__main__': + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(Comms_Test) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/thorlabs/tests/mock_fw102c_test.py b/src/hispec/util/thorlabs/tests/mock_fw102c_test.py new file mode 100644 index 0000000..e4ad4da --- /dev/null +++ b/src/hispec/util/thorlabs/tests/mock_fw102c_test.py @@ -0,0 +1,46 @@ +################# +#Unit test +#Description: Validate software functions are correctly implemented via mocking +################# + +"""Test suite for the FilterWheel class in hispec.util module.""" +import unittest +from unittest.mock import patch, MagicMock +from fw102c import FilterWheelController +import pytest +pytestmark = pytest.mark.unit + + +class TestFilterWheelController(unittest.TestCase): + """Unit tests for the FilterWheelController class.""" + + @patch("socket.socket") + def setUp(self, mock_socket_obj): # pylint: disable=arguments-differ + """Set up the test case with a mocked socket connection.""" + self.mock_socket = MagicMock() + mock_socket_obj.return_value = self.mock_socket + self.mock_socket.read.return_value = b"" + self.controller = FilterWheelController(log=False) + self.controller.set_connection(ip="123.456.789.101", port=1234) + self.controller.connected = True + + def test_get_position(self): + """Test getting the position of the filter wheel.""" + with patch.object(self.controller, "command") as mock_command: + self.controller.get_position() + mock_command.assert_called_once_with("pos?") + + def test_set_position(self): + """Test setting the position of the filter wheel.""" + with patch.object(self.controller, "command") as mock_command: + mock_command.return_value = None + with patch.object(self.controller, "get_position") as mock_getpos: + mock_getpos.return_value = 10 + self.controller.initialized = True + self.controller.move(target = 10) + mock_command.assert_called_once_with("pos=10") + + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/src/hispec/util/thorlabs/tests/mock_ppc102_test.py b/src/hispec/util/thorlabs/tests/mock_ppc102_test.py new file mode 100644 index 0000000..286ad35 --- /dev/null +++ b/src/hispec/util/thorlabs/tests/mock_ppc102_test.py @@ -0,0 +1,60 @@ +################# +#Unit test +#Description: Validate software functions are correctly implemented via mocking +################# + +import unittest +from unittest.mock import patch, MagicMock +# pylint: disable=import-error,no-name-in-module +from ppc102 import PPC102_Coms +import time +import pytest +pytestmark = pytest.mark.unit + + +class TestPPC102_Coms(unittest.TestCase): + """Unit tests for the SunpowerCryocooler class.""" + + @patch("socket.socket", autospec=True) + def setUp(self, mock_socket_obj): # pylint: disable=arguments-differ + """Set up the test case with a mocked socket connection.""" + self.mock_socket = MagicMock() + mock_socket_obj.return_value = self.mock_socket + self.mock_socket.read.return_value = b"" + self.controller = PPC102_Coms(ip="123.456.789.101", port=1234, log=False) + self.controller.sock = self.mock_socket + self.controller.get_loop() + + + def test_send_command(self): + """Test sending _get_infocommand to the controller.""" + with patch.object(self.controller, "write") as mock_write: + with self.assertRaises(NotImplementedError): + self.controller._get_info()#pylint: disable=protected-access + mock_write.assert_called_with(bytes([0x05, 0x00, 0x00, 0x00, 0x11, 0x01])) + + def test_get_loop(self): + """Testing sending the correct bytes to get the loop status from the gimbal.""" + with patch.object(self.controller, "write") as mock_get_loop: + self.controller.read_buff = MagicMock(return_value=bytes([0x41, 0x06, 0x01, 0x00, 0x21, 0x01, 0x02])) + self.controller.get_loop(channel = 1) + mock_get_loop.assert_called_with(bytes([0x41, 0x06, 0x01, 0x00, 0x21, 0x01])) + + + def test_set_position(self): + """Test setting the position from the Gimbal.""" + #make get_loop and get_enable return the correct responses using MagicMock + with patch.object(self.controller, "write") as mock_setpos: + self.controller.get_loop = MagicMock(return_value=2) + self.controller.get_enable = MagicMock(return_value=1) + self.controller.set_position(channel = 1, pos = 5.0) + dest = (0x20 + 1) | 0x80 + converted_pos = int(round((5.0 + 10)/20*32767)) + pos_bytes = converted_pos.to_bytes(2, byteorder='little', signed=False) + mock_setpos.assert_called_with(bytes([0x46, 0x06, 0x04, 0x00,dest, 0x01, + 0x01, 0x00,pos_bytes[0], pos_bytes[1]])) + + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/src/hispec/util/thorlabs/tests/physical_fw102c_test.py b/src/hispec/util/thorlabs/tests/physical_fw102c_test.py new file mode 100644 index 0000000..32ce275 --- /dev/null +++ b/src/hispec/util/thorlabs/tests/physical_fw102c_test.py @@ -0,0 +1,104 @@ +################# +#Functionality test +#Description: Test connection, disconnection, confirming communication with stage, +# inicialization(or something similar) and movement/position query +# tests are successful and correct +################# + + +import pytest +pytestmark = pytest.mark.functional +import sys +import os +import unittest +import time +from fw102c import FilterWheelController + + +########################## +## CONFIG +## connection and Disconnection in all test +########################## +class Physical_Test(unittest.TestCase): + + #Instances for Test management + def setUp(self): + self.dev = FilterWheelController() + self.success = True + self.ip = '192.168.29.100' + self.port = 10010 + self.log = False + self.error_tolerance = 0.1 + + ########################## + ## Test Connection + ########################## + def test_connection(self): + time.sleep(.2) + # Open connection + self.dev = FilterWheelController(log = self.log) + self.dev.set_connection(ip=self.ip, port=self.port) + assert self.dev.status is None + self.dev.connect() + time.sleep(.25) + assert self.dev.connected + assert self.dev.success + assert self.dev.status == 'ready' + self.dev.disconnect() + time.sleep(.25) + assert not self.dev.connected + assert self.dev.status == 'disconnected' + time.sleep(.25) + + ########################## + ## Inicialize test + ########################## + def inicialize(self): + self.dev = FilterWheelController(log = self.log) + self.dev.set_connection(ip=self.ip, port=self.port) + self.dev.connect() + time.sleep(.25) + self.dev.initialize() + time.sleep(.25) + assert self.dev.initialized + assert self.dev.revision is not None + self.dev.disconnect() + time.sleep(.25) + + ########################## + ## Position Query and Movement + ########################## + def test_position_query_and_movement(self): + self.dev = FilterWheelController(log = self.log) + self.dev.set_connection(ip=self.ip, port=self.port) + self.dev.connect() + time.sleep(.25) + self.dev.initialize() + # Set position and assert + self.dev.move(target=1) + time.sleep(.25) + ret = int(self.dev.get_position()) + assert ret == 1 + self.dev.move(target=2) + time.sleep(.25) + ret = int(self.dev.get_position()) + assert ret == 2 + self.dev.move(target=5) + time.sleep(.25) + ret = int(self.dev.get_position()) + assert ret == 5 + self.dev.move(target=1) + time.sleep(.25) + ret = int(self.dev.get_position()) + assert ret == 1 + #Close connection + self.dev.disconnect() + time.sleep(.25) + + +if __name__ == '__main__': + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(Robust_Test) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/thorlabs/tests/physical_ppc102_test.py b/src/hispec/util/thorlabs/tests/physical_ppc102_test.py new file mode 100644 index 0000000..3aca85f --- /dev/null +++ b/src/hispec/util/thorlabs/tests/physical_ppc102_test.py @@ -0,0 +1,149 @@ +################# +#Functionality test +#Description: Test connection, disconnection, confirming communication with stage, +# inicialization(or something similar) and movement/position query +# tests are successful and correct +################# + +import pytest +pytestmark = pytest.mark.functional +import sys +import os +import unittest +import time +from ppc102 import PPC102_Coms + +########################## +## CONFIG +## connection and Disconnection in all test +########################## +class Physical_Test(unittest.TestCase): + + #Instances for Test management + def setUp(self): + self.dev = None + self.success = True + self.ip = '192.168.29.100' + self.port = 10012 + self.log = False + self.error_tolerance = 0.1 + + + ########################## + ## Servos / Loops [ Not really applicable] + ########################## + def test_loop(self): + time.sleep(.2) + # Open connection + self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) + time.sleep(.2) + self.dev.open() + time.sleep(.25) + for ch in [1,2]:#Check for channels that are applicable + #Close Loop assert Loop states + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.OPEN_LOOP or ret == self.dev.CLOSED_LOOP + assert self.dev.set_loop(channel=ch, loop=2) + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.CLOSED_LOOP + #Open Loops and assert the states + assert self.dev.set_loop(channel=ch, loop=1) + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.OPEN_LOOP + self.assertFalse(self.dev.set_loop(channel=5)) + self.assertFalse(self.dev.set_loop(channel=-1)) + self.assertTrue(self.dev.set_loop(loop = 4)) + ret = self.dev.get_loop(channel = 0) + assert ret[0] == self.dev.CLOSED_LOOP + assert ret[1] == self.dev.CLOSED_LOOP + self.assertTrue(self.dev.set_loop(loop = 1)) + ret = self.dev.get_loop(channel = 0) + assert ret[0] == self.dev.OPEN_LOOP + assert ret[1] == self.dev.OPEN_LOOP + self.dev.close() + time.sleep(.25) + with self.assertRaises(Exception): + self.dev.get_loop() + self.dev.set_loop() + time.sleep(.25) + #Close connection + self.dev.close() + time.sleep(.25) + + + ########################## + ## Limit Check + ########################## + def test_limit(self): + # Open connection + self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) + time.sleep(.2) + self.dev.open() + time.sleep(.25) + for ch in [1,2]: # Check for channels that are applicable + # Check limit states and save to variable + original_limit = self.dev.get_max_output_voltage(channel=ch) + print(f"Channel {ch} Max output Voltage: {original_limit}") + # Set limit states and assert + assert self.dev.set_max_output_voltage(channel=ch, limit=75) + ret = self.dev.get_max_output_voltage(channel=ch) + print(f"New Channel {ch} Max output Voltage: {ret}") + # set limits back to default + assert self.dev.set_max_output_voltage(channel=ch, limit=original_limit) + ret = self.dev.get_max_output_voltage(channel=ch) + print(f"Back to Original Channel {ch} Max output Voltage: {ret}") + + #Close connection + self.dev.close() + time.sleep(.25) + + ########################## + ## Position Query and Movement + ########################## + def test_position_query_and_movement(self): + self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) + self.dev.open() + time.sleep(.25) + for ch in [1,2]: # Check for channels that are applicable + # Close loops and assert + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.OPEN_LOOP or ret == self.dev.CLOSED_LOOP + assert self.dev.set_loop(channel=ch, loop=self.dev.CLOSED_LOOP) + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.CLOSED_LOOP + # Set position and assert + assert self.dev.set_position(channel=ch, pos=0) + time.sleep(.2) + # Get position and assert + ret = self.dev.get_position(channel=ch) + assert abs(ret - 0) < self.error_tolerance*2 + original_position = ret + print(f"Channel {ch} Original Position: {original_position}") + # Set position and assert with Error Tolerance x2 + assert self.dev.set_position(channel=ch, pos=1.0) + time.sleep(.2) + ret = self.dev.get_position(channel=ch) + assert abs(ret - 1.0) < self.error_tolerance*2 + print(f"Channel {ch} New Position: {ret}") + # Set position back to default + assert self.dev.set_position(channel=ch, pos=original_position) + time.sleep(.2) + ret = self.dev.get_position(channel=ch) + assert abs(ret - original_position) < self.error_tolerance*2 + print(f"Channel {ch} Back to Original Position: {ret}") + #open loops and assert + assert self.dev.set_loop(channel=ch, loop=self.dev.OPEN_LOOP) + ret = self.dev.get_loop(channel=ch) + assert ret == self.dev.OPEN_LOOP + + #Close connection + self.dev.close() + time.sleep(.25) + + +if __name__ == '__main__': + loader = unittest.TestLoader() + suite = loader.loadTestsFromTestCase(Robust_Test) + runner = unittest.TextTestRunner(verbosity=2) + result = runner.run(suite) + sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/xeryon/.gitignore b/src/hispec/util/xeryon/.gitignore new file mode 100644 index 0000000..15201ac --- /dev/null +++ b/src/hispec/util/xeryon/.gitignore @@ -0,0 +1,171 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# PyPI configuration file +.pypirc diff --git a/src/hispec/util/xeryon/LICENSE b/src/hispec/util/xeryon/LICENSE new file mode 100644 index 0000000..bce361a --- /dev/null +++ b/src/hispec/util/xeryon/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) [year] [fullname] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + diff --git a/src/hispec/util/xeryon/README.md b/src/hispec/util/xeryon/README.md new file mode 100644 index 0000000..82fe377 --- /dev/null +++ b/src/hispec/util/xeryon/README.md @@ -0,0 +1,106 @@ +# Xeryon Motion Controller Library + +This module provides a Python interface to communicate with and control Xeryon precision stages. It supports serial communication, axis movement, settings management, and safe handling of errors and edge cases. + +## Features +- Serial or TCP/IP communication with Xeryon controllers +- Multi-axis system support +- Configurable stage settings from a file +- Blocking/non-blocking movement +- Real-time data logging and error monitoring +- Configurable output via logger + +--- + +## Folder Structure +``` +xeryon/ +├── __init__.py +├── axis.py # Axis class abstraction +├── communication.py # Low-level serial communication logic +├── config.py # Centralized constants and flags +├── controller.py # XeryonController high-level interface +├── stage.py # Stage definitions (e.g. XLS, XLA, XRTU) +├── units.py # Unit definitions and conversion +├── utils.py # Logging, time utilities, formatting helpers +├── settings_default.txt +└── tests/ + ├── test_axis.py + ├── test_communication.py + ├── test_controller.py + └── test_utils.py +``` + +--- + +## Getting Started +### Prerequisites +- Python 3.7+ +- Xeryon controller connected via serial +- `pyserial` library + + +### Example Usage +#### Serial Connection +```python +from xeryon.controller import XeryonController +from xeryon.stage import Stage + +# Initialize controller +controller = XeryonController(COM_port="/dev/ttyUSB0") +controller.addAxis(Stage.XLS_312, "X") +controller.start() + +# Move axis +x_axis = controller.getAxis("X") +x_axis.setDPOS(1000) # Move to position 1000 in current units + +controller.stop() +``` +#### TCP/IP Connection +```python +from xeryon.controller import XeryonController +from xeryon.stage import Stage + +# Initialize controller via TCP/IP (e.g., through a terminal server) +controller = XeryonController( + connection_type="tcp", + tcp_host="192.168.1.100", + tcp_port=12345 +) +controller.add_axis(Stage.XLS_312, "X") +controller.start() + +# Move axis +x_axis = controller.get_axis("X") +x_axis.set_DPOS(1000) + +controller.stop() +``` + +--- + +## Settings File +Place a `settings_default.txt` file in the config directory. Format: +```txt +X:LLIM=0 +X:HLIM=100000 +X:SSPD=2000 +POLI=5 +``` +Each line sets a controller or axis setting. + +--- + +## Logging +The `utils.py` module provides a `output_console` function with logger integration. Messages can be printed to stdout or stderr depending on severity. + +--- + +## 🧪 Testing +Tests are written using `pytest`. Run with: +```bash +pytest +``` + + diff --git a/src/hispec/util/xeryon/__init__.py b/src/hispec/util/xeryon/__init__.py new file mode 100644 index 0000000..b973340 --- /dev/null +++ b/src/hispec/util/xeryon/__init__.py @@ -0,0 +1,11 @@ +""" +Exposes the main public interface for the Xeryon motion control library. + +Includes: +- XeryonController: High-level interface for Xeryon motion controllers. +- Stage: Represents a single controllable motion stage. +""" +from .xeryon_controller import XeryonController +from .stage import Stage + +__all__ = ["XeryonController", "Stage"] diff --git a/src/hispec/util/xeryon/axis.py b/src/hispec/util/xeryon/axis.py new file mode 100644 index 0000000..933a553 --- /dev/null +++ b/src/hispec/util/xeryon/axis.py @@ -0,0 +1,807 @@ +# pylint: skip-file +import time +import math +from .units import Units +from .config import DEFAULT_POLI_VALUE, DISABLE_WAITING, DEBUG_MODE, NOT_SETTING_COMMANDS, AUTO_SEND_ENBL +from .utils import get_dpos_epos_string, get_actual_time + + +class Axis: + axis_letter = None # Stores the axis letter for this specific axis. + xeryon_object = None # Stores the "XeryonController" object. + axis_data = None # Stores all the data the controller sends. + settings = None # Stores all the settings from the settings file + stage = None # Specifies the type of stage used in this axis. + units = Units.mm # Specifies the units this axis is currently working in. + # This number increments each time an update is recieved from the controller. + update_nb = 0 + # if True, the STEP command takes DPOS as the refrence. It's called "targeted_position=1/0" in the Microcontroller + was_valid_DPOS = False + def_poli_value = str(DEFAULT_POLI_VALUE) + + # Stores if this axis is currently "Logging": it's storing its axis_data. + isLogging = False + logs = {} # This stores all the data. It's a dictionary of the form: + + previous_epos = [0, 0] # Two samples to calculate speed + previous_time = [0, 0] + + # { "EPOS": [...,...,...], "DPOS": [...,...,...], "STAT":[...,...,...],...} + + def __init__(self, xeryon_object, axis_letter, stage, logger): + """ + Initialize an Axis object. + :param xeryon_object: This points to the XeryonController object. + :type xeryon_object: Xeryon + :param axis_letter: This specifies a specific letter to this axis. + :type axis_letter: str + :param stage: This specifies the stage used in this axis. + :type stage: Stage + """ + self.logger = logger + self.axis_letter = axis_letter + self.xeryon_object = xeryon_object + self.stage = stage + self.axis_data = dict({"EPOS": 0, "DPOS": 0, "STAT": 0, "SSPD": 0}) + self.settings = dict({}) + if self.stage.isLineair: + self.units = Units.mm + else: + self.units = Units.deg + # self.settings = self.stage.defaultSettings # Load default settings + + def find_index(self, forceWaiting=False, direction=0): + """ + :return: None + This function finds the index, after finding the index it goes to the index position. + It blocks the program until the index is found. + """ + self.__send_command("INDX=" + str(direction)) + self.was_valid_DPOS = False + + if DISABLE_WAITING is False or forceWaiting is True: + # Waits a couple of updates, so the EncoderValid flag is valid and doesn't lagg behind. + self.__wait_for_update() + self.__wait_for_update() + self.logger.info("Searching index for axis " + str(self) + ".") + while not self.is_encoder_valid(): # While index not found, wait. + if not self.is_searching_index(): # Check if searching for index bit is true. + self.logger.info( + "Index is not found, but stopped searching for index.", True) + break + time.sleep(0.2) + + if self.is_encoder_valid(): + self.logger.info("Index of axis " + str(self) + " found.") + + def move(self, value): + value = int(value) + direction = 0 + if value > 0: + direction = 1 + elif value < 0: + direction = -1 + self.send_command("MOVE=" + str(direction)) + + def set_D_POS(self, value, differentUnits=None, outputToConsole=True): + """ + :param value: The new value DPOS has to become. + :param differentUnits: If the value isn't specified in the current units, specify the correct units. + :type differentUnits: Units + :param outputToConsole: Default set to True. If set to False, this function won't output text to the console. + :return: None + Note: This function makes use of the send_command function, which is blocking the program until the position is reached. + """ + unit = self.units # Current units + # If the value given are in different units than the current units: + if differentUnits is not None: + # Then specify the unit in differentUnits argument. + unit = differentUnits + + # Convert into encoder units. + DPOS = int(self.convert_units_to_encoder(value, unit)) + error = False + + self.__send_command("DPOS=" + str(DPOS)) + # And keep it True in order to avoid an accumulating error. + self.was_valid_DPOS = True + + # Block all futher processes until position is reached. + # This check isn't nessecary in DEBUG mode or when DISABLE_WAITING is True + if DEBUG_MODE is False and DISABLE_WAITING is False: + # send_time = get_actual_time() + # distance = abs(int(DPOS) - int(self.get_data("EPOS"))) # For calculating timeout time. + + # Wait some updates. This is so the flags (e.g. left end stop) of the previous command aren't received. + # self.__wait_for_update() + + # Wait until EPOS is within PTO2 AND positionReached status is received. + while not (self.__is_within_tol(DPOS) and self.is_position_reached()): + + # Check if stage is at left end or right end. ==> out of range movement. + if self.is_at_left_end() or self.is_at_right_end(): + self.logger.info("DPOS is out or range. (1) " + + get_dpos_epos_string(value, self.get_EPOS(), unit), True) + error = True + break + + # # Position reached flag is set, but EPOS not within tolerance of DPOS. + # if self.is_position_reached() and not self.__is_within_tol(DPOS): + # # if self.is_position_reached(): + # # Check if it's a lineair stage and DPOS is beyond it's limits. + # if self.stage.isLineair and ( + # int(self.get_setting("LLIM")) > int(DPOS) or int(self.get_setting("HLIM")) < int(DPOS)): + # self.logger.info("DPOS is out or range.(2)" + get_dpos_epos_string(value, self.get_EPOS(), unit), True) + # error = True + # break + + # # EPOS is not within tolerance of DPOS, unknown reason. + # self.logger.info("Position not reached. (3) " + get_dpos_epos_string(value, self.get_EPOS(), unit), True) + # error = True + # break + + if self.is_encoder_error(): + self.logger.info( + "Position not reached. (4). Encoder gave an error.", True) + error = True + break + + if self.is_error_limit(): + self.logger.info( + "Position not reached. (5) ELIM Triggered.", True) + error = True + break + + if self.is_safety_timeout_triggered(): + self.logger.info( + "Position not reached. (6) TOU2 (Timeout 2) triggered.", True) + error = True + break + + if self.is_thermal_protection_1() or self.is_thermal_protection_2(): + self.logger.info( + "Position not reached. (7) amplifier error.", True) + error = True + break + + # # This movement took too long, timeout time is estimated with speed & distance. + # if self.__time_out_reached(send_time, distance): + # self.logger.info( + # "Position not reached, timeout reached. (4) " + get_dpos_epos_string(value, self.get_EPOS(), unit), + # True) + # error = True + # break + # Keep polling ==> if timeout is not done, the computer will poll too fast. The microcontroller can't follow. + + time.sleep(0.01) + + if outputToConsole and error is False and DISABLE_WAITING is False: # Output new DPOS & EPOS if necessary + self.logger.info(get_dpos_epos_string(value, self.get_EPOS(), unit)) + + def set_TRGS(self, value): + """ + Define the start of the trigger pulses. + Expressed in the current units. + :param value: Start position to trigger the pulses. Expressed in the current units. + :return: + """ + value_in_encoder_positions = int(self.convert_units_to_encoder(value)) + self.send_command("TRGS=" + str(value_in_encoder_positions)) + + def set_TRGW(self, value): + """ + Define the width of the trigger pulses. + Expressed in the current units. + :param value: Width of the trigger pulses. Expressed in the current units. + :return: + """ + value_in_encoder_positions = int(self.convert_units_to_encoder(value)) + self.send_command("TRGW=" + str(value_in_encoder_positions)) + + def set_TRGP(self, value): + """ + Define the pitch of the trigger pulses. + Expressed in the current units. + :param value: Pitch of the trigger pulses. Expressed in the current units. + :return: + """ + value_in_encoder_positions = int(self.convert_units_to_encoder(value)) + self.send_command("TRGP=" + str(value_in_encoder_positions)) + + def set_TRGN(self, value): + """ + Define the number of trigger pulses. + :param value: Number of trigger pulses. + :return: + """ + self.send_command("TRGN=" + str(int(value))) + + def get_DPOS(self): + """ + :return: Return the desired position (DPOS) in the current units. + """ + return self.convert_encoder_units_to_units(self.get_data("DPOS"), self.units) + + def get_unit(self): + """ + :return: Return the current units this stage is working in. + """ + return self.units + + def step(self, value): + """ + :param value: The amount it needs to step (specified in the current units) + If this axis has a rotating stage, this function handles the "wrapping". (Going around in a full circle) + This function makes use of send_command, which blocks the program until the desired position is reached. + """ + step = self.convert_units_to_encoder(value, self.units) + if self.was_valid_DPOS: + # If the previous DPOS was valid, DPOS is taken as a refrence. + new_DPOS = int(self.get_data("DPOS")) + step + else: + new_DPOS = int(self.get_data("EPOS")) + step + + if not self.stage.isLineair: # Rotating Stage + # Below is the amount of encoder units in one revolution. + # From -180 => +180 + # -180 *(val // 180 % 2) + (val % 180) + encoderUnitsPerRevolution = self.convert_units_to_encoder( + 360, Units.deg) + new_DPOS = -encoderUnitsPerRevolution/2 * \ + (new_DPOS // (encoderUnitsPerRevolution/2) % + 2) + (new_DPOS % (encoderUnitsPerRevolution/2)) + + # This is used so position is checked in here. + self.set_D_POS(new_DPOS, Units.enc, False) + if DISABLE_WAITING is False: + # Waits a couple of updates, so the EPOS is valid and doesn't lagg behind. + self.__wait_for_update() + self.logger.info("Stepped: " + str(self.convert_encoder_units_to_units(step, self.units)) + " " + str( + self.units) + " " + get_dpos_epos_string(self.getDPOS(), self.get_EPOS(), self.units)) + + def get_EPOS(self): + """ + :return: Returns the EPOS in the correct units this axis is working in. + """ + return self.convert_encoder_units_to_units(self.get_data("EPOS"), self.units) + + def set_units(self, units): + """ + :param units: The units this axis needs to work in. + :type units: Units + """ + self.units = units + + def start_logging(self, increase_poli=True): + """ + This function starts logging all data that the controller sends. + It updates the POLI (Polling Interval) to get more data. + """ + self.isLogging = True + if increase_poli: + self.set_setting("POLI", "1") + self.__wait_for_update() # To make sure the POLI is set. + # DISABLE_WAITING isn't checked here, because it is really necessary. + + def end_logging(self): + """ + This function stops the logging of all the data. + It updates the POLI (Polling Interval) back to the default value. + """ + self.isLogging = False + logs = self.logs # Store logs + self.logs = {} # Reset logs + + # Restore POLI back to default value. + self.set_setting("POLI", str(self.def_poli_value)) + return logs + + def get_frequency(self): + return self.get_data("FREQ") + + def set_setting(self, tag, value, fromSettingsFile=False, doNotSendThrough=False): + """ + :param tag: The tag that needs to be stored + :param value: The value + :return: None + This stores the settings in a list as specified in the settings file. + """ + + if fromSettingsFile: + value = self.apply_setting_multipliers(tag, value) + if "MASS" in tag: + tag = "CFRQ" + if "?" not in str(value): + self.settings.update({tag: value}) + # a change: settings are send when they are set. + if not doNotSendThrough: + self.__send_command(str(tag) + "=" + str(value)) + + def start_scan(self, direction, execTime=None): + """ + :param direction: Positive or negative number. + :param execTime: Specify the execution time in seconds. If no time is specified, it scans until scanStop() is used. + :return: + This function starts a scan. + A scan is a continous movement with fixed speed. The speed is maintained by closed-loop control. + A positive number sends the stage towards increasing encoder values. + A negative number sends the stage towards decreasing encoder values. + If a time is specified, the scan will go on for that amount of seconds + If no time is specified, the scan will go on until scanStop() is ran. + """ + self.__send_command("SCAN=" + str(int(direction))) + self.was_valid_DPOS = False + + if execTime is not None: + time.sleep(execTime) + self.__send_command("SCAN=0") + + def stop_scan(self): + """ + Stop scanning. + """ + self.__send_command("SCAN=0") + self.was_valid_DPOS = False + + def set_speed(self, speed): + """ + :param speed: The new speed this axis needs to operate on. The speed is specified in the current units/second. + :type speed: int + + """ + if self.stage.isLineair: + speed = int(self.convert_encoder_units_to_units(self.convert_units_to_encoder(speed, self.units), + Units.mu)) # Convert to micrometer + else: + speed = self.convert_encoder_units_to_units(self.convert_units_to_encoder(speed, self.units), + Units.deg) # Convert to degrees + speed = int(speed) * 100 # *100 conversion factor. + self.set_setting("SSPD", str(speed)) + + def get_setting(self, tag): + """ + :param tag: The tag that indicates the setting. + :return: The value of the setting with the given tag. + """ + return self.settings.get(tag) + + def set_PTOL(self, value): + """ + :param value: The new value for PTOL (in encoder units!) + """ + self.set_setting("PTOL", value) + + def set_PTO2(self, value): + """ + :param value: The new value for PTO2 (in encoder units!) + """ + self.set_setting("PTO2", value) + + def send_command(self, command): + """ + :param command: the command that needs to be send. + This function is used to let the user send commands. + If one of the 'setting commands' are used, it is detected. + This way the settings are saved in self.settings + """ + + tag = command.split("=")[0] + value = str(command.split("=")[1]) + + if tag in NOT_SETTING_COMMANDS: + self.__send_command(command) # These settings are not stored. + else: + self.set_setting(tag, value) # These settings are stored + + def reset(self): + """ + Reset this axis. + """ + self.send_command("RSET=0") + self.was_valid_DPOS = False + + """ + Here all the status bits are checked. + """ + + def is_thermal_protection_1(self): + """ + :return: True if the "Thermal Protection 1" flag is set to true. + """ + return self.__get_stat_bit_at_index(2) == "1" + + def is_thermal_protection_2(self): + """ + :return: True if the "Thermal Protection 2" flag is set to true. + """ + return self.__get_stat_bit_at_index(3) == "1" + + def is_force_zero(self): + """ + :return: True if the "Force Zero" flag is set to true. + """ + return self.__get_stat_bit_at_index(4) == "1" + + def is_motor_on(self): + """ + :return: True if the "Motor On" flag is set to true. + """ + return self.__get_stat_bit_at_index(5) == "1" + + def is_closed_loop(self): + """ + :return: True if the "Closed Loop" flag is set to true. + """ + return self.__get_stat_bit_at_index(6) == "1" + + def is_encoder_at_index(self): + """ + :return: True if the "Encoder index" flag is set to true. + """ + return self.__get_stat_bit_at_index(7) == "1" + + def is_encoder_valid(self): + """ + :return: True if the "Encoder Valid" flag is set to true. + """ + return self.__get_stat_bit_at_index(8) == "1" + + def is_searching_index(self): + """ + :return: True if the "Searching index" flag is set to true. + """ + return self.__get_stat_bit_at_index(9) == "1" + + def is_position_reached(self): + """ + :return: True if the position reached flag is set to true. + """ + return self.__get_stat_bit_at_index(10) == "1" + + def is_encoder_error(self): + """ + :return: True if the "Encoder Error" flag is set to true. + """ + return self.__get_stat_bit_at_index(12) == "1" + + def is_scanning(self): + """ + :return: True if the "Scanning" flag is set to true. + """ + return self.__get_stat_bit_at_index(13) == "1" + + def is_at_left_end(self): + """ + :return: True if the "Left end stop" flag is set to true. + """ + return self.__get_stat_bit_at_index(14) == "1" + + def is_at_right_end(self): + """ + :return: True if the "Right end stop" flag is set to true. + """ + return self.__get_stat_bit_at_index(15) == "1" + + def is_error_limit(self): + """ + :return: True if the "ErrorLimit" flag is set to true. + """ + return self.__get_stat_bit_at_index(16) == "1" + + def is_searching_optimal_frequency(self): + """ + :return: True if the "Searching Optimal Frequency" flag is set to true. + """ + return self.__get_stat_bit_at_index(17) == "1" + + def is_safety_timeout_triggered(self): + """ + :return: True if the "Searching Optimal Frequency" flag is set to true. + """ + return self.__get_stat_bit_at_index(18) == "1" + + def get_letter(self): + """ + :return: The letter of the axis. If single axis system, it returns "X". + """ + return self.axis_letter + + def apply_setting_multipliers(self, tag, value): + """ + Some settings have to be multiplied before it can be send to the controller. + That's done in this function. + :param tag: The tag of the setting + :param value: The value of the setting + :return: Return an adjusted value for this setting. + """ + # Apply multipliers (different units in settings file and in controller) + if "MAMP" in tag or "OFSA" in tag or "OFSB" in tag or "AMPL" in tag or "MAM2" in tag: + # Use amplitude multiplier. + value = str(int(int(value) * self.stage.amplitudeMultiplier)) + elif "PHAC" in tag or "PHAS" in tag: + value = str(int(int(value) * self.stage.phaseMultiplier)) + # In the settigns file, SSPD is in mm/s ==> gets translated to mu/s + elif "SSPD" in tag or "MSPD" in tag or "ISPD" in tag: + value = str(int(float(value) * self.stage.speedMultiplier)) + elif "LLIM" in tag or "RLIM" in tag or "HLIM" in tag: + # These are given in mm/deg and need to be converted to encoder units + if self.stage.isLineair: + value = str(self.convert_units_to_encoder(value, Units.mm)) + else: + value = str(self.convert_units_to_encoder(value, Units.deg)) + elif "POLI" in tag: + self.def_poli_value = value + elif "MASS" in tag: + value = str(self.__mass_to_CFREQ(value)) + elif "ZON1" in tag or "ZON2" in tag: + if self.stage.isLineair: + value = str(self.convert_units_to_encoder(value, Units.mm)) + else: + value = str(self.convert_units_to_encoder(value, Units.deg)) + return str(value) + + def __mass_to_CFREQ(self, mass): + """ + Conversion table to change the value of the setting "MASS" into a value for the settings "CFRQ". + :return: + """ + mass = int(mass) + if mass <= 50: + return 100000 + if mass <= 100: + return 60000 + if mass <= 250: + return 30000 + if mass <= 500: + return 10000 + if mass <= 1000: + return 5000 + return 3000 + + def __str__(self): + return str(self.axis_letter) + + def __is_within_tol(self, DPOS): + """ + :param DPOS: The desired position + :return: True if EPOS is within PTO2 of DPOS. (PTO2 = Position Tolerance 2) + """ + DPOS = abs(int(DPOS)) + if self.get_setting("PTO2") is not None: + PTO2 = int(self.get_setting("PTO2")) + elif self.get_setting("PTOL") is not None: + PTO2 = int(self.get_setting("PTOL")) + else: + PTO2 = 100 # TODO + EPOS = abs(int(self.get_data("EPOS"))) + + if DPOS - PTO2 <= EPOS <= DPOS + PTO2: + return True + + def __time_out_reached(self, start_time, distance): + """ + :param start_time: The time the command started in ms. + :param distance: The distance the stage needs to travel. + :return: True if the timeout time has been reached. + The timeout time is calculated based on the speed (SSPD) and the distance. + """ + t = get_actual_time() + speed = int(self.get_setting("SSPD")) + # Convert seconds to milliseconds + timeout_t = (distance / speed * 1000) + timeout_t *= 1.25 # 25% safety factor + + # For quick and tiny movements, the method above is not accurate. + # If the timeout_t is smaller than the specified TOUT&TOU2, use TOUT+TOU2 + if self.get_setting("TOUT") is not None: + TOUT = int(self.get_setting("TOUT"))*3 + if TOUT > timeout_t: + timeout_t = TOUT + + return (t - start_time) > timeout_t + + def receive_data(self, data): + """ + :param data: The command that is received. + :return: None + This function processes the commands that are send to this axis. + eg: if "EPOS=5" is send, it stores "EPOS", "5". + If logging is enabled, this function will store the new incoming data. + """ + if "=" in data: + tag = data.split("=")[0] + val = data.split("=")[1].rstrip("\n\r").replace(" ", "") + + # The received command is a setting that's requested. + if tag not in NOT_SETTING_COMMANDS and "EPOS" not in tag and "DPOS" not in tag and not "FREQ" in tag: + self.set_setting(tag, val) + elif "FREQ" in tag: + if self.get_setting("FREQ") is not None and int(self.get_setting("FREQ")) != int(val): + self.set_setting("FREQ", val) + else: + self.axis_data[tag] = val + + if "STAT" in tag: + if self.is_safety_timeout_triggered(): + self.logger.info("The safety timeout was triggered (TOU2 command). " + "This means that the stage kept moving and oscillating around the desired position. " + "A reset is required now OR 'ENBL=1' should be send.", True) + + if self.is_thermal_protection_1() or self.is_thermal_protection_2() or self.is_error_limit() or self.is_safety_timeout_triggered(): + if self.is_error_limit(): + self.logger.info( + "Error limit is reached (status bit 16). A reset is required now OR 'ENBL=1' should be send.", True) + + if self.is_thermal_protection_2() or self.is_thermal_protection_1(): + self.logger.info( + "Thermal protection 1 or 2 is raised (status bit 2 or 3). A reset is required now OR 'ENBL=1' should be send.", True) + + if self.is_safety_timeout_triggered(): + self.logger.info( + "Saftety timeout (TOU2 timeout reached) triggered. A reset is required now OR 'ENBL=1' should be send.", True) + + if AUTO_SEND_ENBL: + self.xeryon_object.set_master_setting("ENBL", "1") + self.logger.info("'ENBL=1' is automatically send.") + + if "EPOS" in tag: # This uses "EPOS" as an indicator that a new round of data is coming in. + + self.previous_epos.remove( + self.previous_epos[0]) # Remove first entry + # Add EPOS: this is like a FIFO list + self.previous_epos.append(int(self.axis_data["EPOS"])) + self.update_nb += 1 # This update_nb is for the function __wait_for_update + + if self.isLogging: # Log all received data if logging is enabled. + # This data is useless. + if tag not in ["SRNO", "XLS ", "XRTU", "XLA ", "XTRA", "SOFT", "SYNC"]: + if self.logs.get(tag) is None: + self.logs[tag] = [] + self.logs[tag].append(int(val)) + + if "TIME" in tag: + # CALCULATE SPEED + if len(self.previous_time) > 0: + self.previous_time.remove(self.previous_time[0]) + if "TIME" in self.axis_data.items(): + self.previous_time.append(int(self.axis_data["TIME"])) + if len(self.previous_time) >= 2: + t1 = self.previous_time[0] + t2 = self.previous_time[1] + if int(t2) < int(t1): + t2 += 2**16 + + if len(self.previous_epos) >= 2: + self.axis_data["SSPD"] = ( + self.previous_epos[1] - self.previous_epos[0])/(t2 - t1) + + pass + + def get_data(self, TAG): + """ + :param TAG: The tag requested. + :return: Returns the value of this tag stored, if no data it returns None. + eg: get("DPOS") returns the value stored for "DPOS". + """ + return self.axis_data.get(TAG) # Returnt zelf None als TAG niet bestaat. + + def send_settings(self): + """ + :return: None + This function sends ALL settings to the controller. + """ + self.__send_command( + str(self.stage.encoderResolutionCommand)) # This sends: XLS =.. || XRTU=.. || XRTA=.. || XLA =.. + for tag in self.settings: + self.__send_command(str(tag) + "=" + str(self.get_setting(tag))) + + def save_settings(self): + """ + :return: None + This function just sends the "AXIS:SAVE" command to store the settings for this axis. + """ + self.send_command("SAVE=0") + + def convert_units_to_encoder(self, value, units=None): + """ + :param value: The value that needs to be converted into encoder units. + :param units: The units the value is in. + :return: The value converted into encoder units. + """ + if units is None: + units = self.units + value = float(value) + if units == Units.mm: + return round(value * 10 ** 6 * 1 / self.stage.encoderResolution) + elif units == Units.mu: + return round(value * 10 ** 3 * 1 / self.stage.encoderResolution) + elif units == Units.nm: + return round(value * 1 / self.stage.encoderResolution) + elif units == Units.inch: + return round(value * 25.4 * 10 ** 6 * 1 / self.stage.encoderResolution) + elif units == Units.minch: + return round(value * 25.4 * 10 ** 3 * 1 / self.stage.encoderResolution) + elif units == Units.enc: + return round(value) + elif units == Units.mrad: + return round(value * 10 ** 3 * 1 / self.stage.encoderResolution) + elif units == Units.rad: + return round(value * 10 ** 6 * 1 / self.stage.encoderResolution) + elif units == Units.deg: + return round(value * (2 * math.pi) / 360 * 10 ** 6 / self.stage.encoderResolution) + else: + self.xeryon_object.stop() + raise ("Unexpected unit") + + def convert_encoder_units_to_units(self, value, units=None): + """ + :param value: The value (in encoder units) that needs to be converted. + :param units: The output unit. + :return: The value converted into the output unit. + """ + if units is None: + units = self.units + value = float(value) + if units == Units.mm: + return value / (10 ** 6 * 1 / self.stage.encoderResolution) + elif units == Units.mu: + return value / (10 ** 3 * 1 / self.stage.encoderResolution) + elif units == Units.nm: + return value / (1 / self.stage.encoderResolution) + elif units == Units.inch: + return value / (25.4 * 10 ** 6 * 1 / self.stage.encoderResolution) + elif units == Units.minch: + return value / (25.4 * 10 ** 3 * 1 / self.stage.encoderResolution) + elif units == Units.enc: + return value + elif units == Units.mrad: + return value / (10 ** 3 * 1 / self.stage.encoderResolution) + elif units == Units.rad: + return value / (10 ** 6 * 1 / self.stage.encoderResolution) + elif units == Units.deg: + return value / ((2 * math.pi) / 360 * 10 ** 6 / self.stage.encoderResolution) + else: + self.xeryon_object.stop() + raise ("Unexpected unit") + + def __send_command(self, command): + """ + :param command: The command that needs to be send. + THIS IS A HIDDEN FUNCTION. Just to make sure that the SETTING commands are send via set_setting() and the other commands via send_command() + This function is used to send a command to the controller. + No "AXIS:" (e.g.: "X:") needs to be specified, just the command. + """ + tag = command.split("=")[0] + value = str(command.split("=")[1]) + + prefix = "" # In a multi axis system, prefix stores the "LETTER:". + if not self.xeryon_object.is_single_axis_system(): + prefix = self.axis_letter + ":" + + # Construct and send the command. + command = tag + "=" + str(value) + self.xeryon_object.get_communication().send_command(prefix + command) + + def __wait_for_update(self): + """ + This function waits a couple of update messages. + :return: + """ + wait_nb = 3 # This number defines how much updates need to be passed. + + # The wait number needs to adjust to POLI. + if self.get_setting("POLI") is not None: + wait_nb = wait_nb / int(self.def_poli_value) * \ + int(self.get_setting("POLI")) + + start_nb = int(self.update_nb) + while (int(self.update_nb) - start_nb) < wait_nb: + time.sleep(0.01) # Wait 10 ms + + def __get_stat_bit_at_index(self, bit_index): + if self.get_data("STAT") is not None: + bits = bin(int(self.get_data("STAT"))).replace("0b", "")[::-1] + # [::-1 mirrors the string so the status bit numbering is the same. + if len(bits) >= bit_index + 1: + return bits[bit_index] + return "0" diff --git a/src/hispec/util/xeryon/communication.py b/src/hispec/util/xeryon/communication.py new file mode 100644 index 0000000..28c10f3 --- /dev/null +++ b/src/hispec/util/xeryon/communication.py @@ -0,0 +1,229 @@ +# pylint: skip-file +import time +import serial +import threading +import socket + + +class Communication: + """ + Manages serial or TCP/IP communication with a Xeryon device. + + Supports automatic COM port detection, background data processing, + and queuing commands for asynchronous communication. + """ + + def __init__(self, xeryon_object, com_port, baud, logger, + connection_type='serial', tcp_host=None, tcp_port=None): + """ + Initializes the Communication object. + + :param xeryon_object: Object that manages Xeryon device and axes. + :param com_port: COM port to use (for serial communication). + :param baud: Baud rate for serial communication. + :param logger: Logger instance for error and status messages. + :param connection_type: 'serial' or 'tcp' (default is 'serial'). + :param tcp_host: Hostname or IP address for TCP connection. + :param tcp_port: Port number for TCP connection. + """ + self.xeryon_object = xeryon_object + self.COM_port = com_port + self.baud = baud + self.readyToSend = [] + self.thread = None + self.ser = None + self.sock = None + self.sio = None + self.stop_thread = False + self.logger = logger + self.connection_type = connection_type + self.tcp_host = tcp_host + self.tcp_port = tcp_port + self.last_heartbeat = None + + def start(self, external_communication_thread=False): + """ + Starts communication with the device and optionally launches a background thread. + + :param external_communication_thread: If True, returns the internal data handler + instead of starting a background thread. + :return: None or a callable for external data handling. + :raises Exception: If required connection parameters are missing or invalid. + """ + if self.connection_type == 'serial': + if self.COM_port is None: + self.xeryon_object.find_COM_port() + if self.COM_port is None: + raise Exception("No COM port found. Please provide one manually.") + self.ser = serial.Serial(self.COM_port, self.baud, timeout=1, xonxoff=True) + self.ser.flush() + time.sleep(0.1) + self.ser.flushInput() + self.ser.flushOutput() + time.sleep(0.1) + + elif self.connection_type == 'tcp': + if not self.tcp_host or not self.tcp_port: + raise Exception("TCP host and port must be specified.") + self.sock = socket.create_connection((self.tcp_host, self.tcp_port), timeout=2) + self.sio = self.sock.makefile('rwb', buffering=0) + + else: + raise Exception(f"Unknown connection_type: {self.connection_type}") + + if not external_communication_thread: + self.thread = threading.Thread(target=self.__process_data) + self.thread.daemon = True + self.thread.start() + else: + return self.__process_data + + def send_command(self, command): + """ + Queues a command to be sent to the device. + + :param command: Command string to send. + """ + self.readyToSend.append(command) + + def set_COM_port(self, com_port): + """ + Sets the COM port manually. + + :param com_port: New COM port string. + """ + self.COM_port = com_port + + def __process_data(self, external_while_loop=False): + """ + Handles sending commands and reading responses in a loop. + + :param external_while_loop: If True, run a single iteration and return (for external loops). + :return: None + """ + while not self.stop_thread: + try: + # Update heartbeat timestamp + self.last_heartbeat = time.time() + + data_to_send = self.readyToSend[:10] + self.readyToSend = self.readyToSend[10:] + + # Send commands + for command in data_to_send: + try: + msg = (command.rstrip("\n\r") + "\n").encode() + if self.connection_type == 'serial': + self.ser.write(msg) + elif self.connection_type == 'tcp': + self.sio.write(msg) + self.sio.flush() + except Exception as e: + self.logger.error(f"Write error: {e}") + continue + + # Read responses + try: + for _ in range(10): + if self.connection_type == 'serial': + if self.ser.in_waiting == 0: + break + reading = self.ser.readline().decode() + elif self.connection_type == 'tcp': + self.sock.settimeout(0.1) + reading = self.sio.readline().decode() + if not reading: + break + else: + break + + if "=" in reading: + if ":" in reading: + key, value = reading.split(":", 1) + axis = self.xeryon_object.get_axis(key) or self.xeryon_object.axis_list[0] + axis.receive_data(value) + else: + axis = self.xeryon_object.axis_list[0] + axis.receive_data(reading) + + except Exception as e: + self.logger.error(f"Read error: {e}") + + if external_while_loop: + return + + # NOTE: (KPIC MOD) we added a delay here so that we don't use as much CPU power on this loop + time.sleep(0.01) + + except Exception as e: + self.logger.error(f"CRITICAL: Communication thread encountered fatal error: {e}", exc_info=True) + self.logger.error("Communication thread is terminating!") + raise + + def is_thread_alive(self): + """ + Check if the communication thread is still running. + + :return: True if thread is alive, False otherwise + """ + return self.thread is not None and self.thread.is_alive() + + def get_thread_health_status(self): + """ + Get detailed health status of the communication thread. + + :return: Dictionary with thread health information + """ + status = { + 'thread_alive': self.is_thread_alive(), + 'last_heartbeat': self.last_heartbeat, + 'time_since_heartbeat': None, + 'heartbeat_stale': False + } + + if self.last_heartbeat is not None: + time_since = time.time() - self.last_heartbeat + status['time_since_heartbeat'] = time_since + # Consider heartbeat stale if more than 1 second old (100x the loop delay) + status['heartbeat_stale'] = time_since > 1.0 + + return status + + def check_thread_health(self, raise_on_dead=False): + """ + Check if communication thread is healthy and optionally raise exception if not. + + :param raise_on_dead: If True, raise an exception when thread is dead + :return: True if healthy, False otherwise + :raises Exception: If raise_on_dead=True and thread is not healthy + """ + status = self.get_thread_health_status() + + is_healthy = status['thread_alive'] and not status['heartbeat_stale'] + + if not is_healthy: + error_msg = "Communication thread is unhealthy: " + if not status['thread_alive']: + error_msg += "Thread is dead. " + if status['heartbeat_stale']: + error_msg += f"Heartbeat is stale ({status['time_since_heartbeat']:.2f}s old). " + + self.logger.warning(error_msg) + + if raise_on_dead: + raise Exception(error_msg) + + return is_healthy + + def close_communication(self): + """ + Closes the communication channel and stops the background thread. + """ + self.stop_thread = True + time.sleep(0.1) + + if self.connection_type == 'serial' and self.ser: + self.ser.close() + elif self.connection_type == 'tcp' and self.sock: + self.sio.close() + self.sock.close() diff --git a/src/hispec/util/xeryon/config.py b/src/hispec/util/xeryon/config.py new file mode 100644 index 0000000..f54683f --- /dev/null +++ b/src/hispec/util/xeryon/config.py @@ -0,0 +1,38 @@ +# pylint: skip-file +# Configuration constants for the Xeryon controller library + +SETTINGS_FILENAME = "config/xeryon_default_settings.txt" +LIBRARY_VERSION = "v1.64" + +# DEBUG MODE +# This variable is set to True if you are in debug mode. +# It ignores some checks, e.g., when sending DPOS without checking EPOS range. +DEBUG_MODE = False + +# OUTPUT TO CONSOLE +# If set to True, debug output will be printed to the console. +OUTPUT_TO_CONSOLE = True + +# DISABLE WAITING +# If set to True, the library won't wait until positions are reached. +DISABLE_WAITING = False # Important: set to False in production! + +# AUTO SEND SETTINGS +# Automatically send settings from the config file to the controller on startup. +AUTO_SEND_SETTINGS = True + +# AUTO SEND ENBL +# Automatically send ENBL=1 when specific errors occur to bypass protection. +AUTO_SEND_ENBL = False + +# Commands whose values are not stored in the library +NOT_SETTING_COMMANDS = [ + "DPOS", "EPOS", "HOME", "ZERO", "RSET", "INDX", "STEP", "MOVE", "STOP", "CONT", + "SAVE", "STAT", "TIME", "SRNO", "SOFT", "XLA3", "XLA1", "XRT1", "XRT3", "XLS1", + "XLS3", "SFRQ", "SYNC" +] + +# Default values for motion calculations +DEFAULT_POLI_VALUE = 200 +AMPLITUDE_MULTIPLIER = 1456.0 +PHASE_MULTIPLIER = 182 diff --git a/src/hispec/util/xeryon/config/xeryon_default_settings.txt b/src/hispec/util/xeryon/config/xeryon_default_settings.txt new file mode 100644 index 0000000..b9126b1 --- /dev/null +++ b/src/hispec/util/xeryon/config/xeryon_default_settings.txt @@ -0,0 +1,13 @@ +% Default settings for Xeryon motion stages +% Axis-specific settings use the format X:= +% Master settings use = + +X:LLIM=10 +X:HLIM=200 +X:SSPD=5000 +X:PTO2=100 +X:PTOL=100 +X:POLI=5 + +POLI=7 +SAVE=0 diff --git a/src/hispec/util/xeryon/pyproject.toml b/src/hispec/util/xeryon/pyproject.toml new file mode 100644 index 0000000..6c606e8 --- /dev/null +++ b/src/hispec/util/xeryon/pyproject.toml @@ -0,0 +1,48 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "xeryon" +version = "0.1.0" +dependencies = [ + "pyserial" +] +description = "This module provides a Python interface to communicate with and control Xeryon precision stages. It supports serial communication, axis movement, settings management, and safe handling of errors and edge cases." +authors = [ + { name = "Michael Langmayr", email = "langmayr@caltech.edu" } +] +maintainers = [ + {name = "Michael Langmayr", email = "langmayr@caltech.edu"} +] +readme = "README.md" +requires-python = ">=3.8" +license = "MIT" +classifiers = [ + "Programming Language :: Python" +] + +[project.urls] +# Various URLs related to your project. These links are displayed on PyPI. +# Homepage = "https://example.com" +# Documentation = "https://readthedocs.org" +Repository = "https://github.com/COO-Utils/xeryon" +# "Bug Tracker" = "https://github.com/COO-Utils/xeryon/issues" +# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" + +[project.optional-dependencies] +dev = [ + "pytest-mock", + "pytest", + "black", + "flake8" +] +[tool.setuptools.packages.find] +include = ["axis", "config", "units", "utils", "communication", "stage", "xeryon_controller"] +where = ["."] + +[tool.pytest.ini_options] +testpaths = ["tests"] + +[tool.setuptools] +py-modules = ["xeryon"] diff --git a/src/hispec/util/xeryon/stage.py b/src/hispec/util/xeryon/stage.py new file mode 100644 index 0000000..f4802b4 --- /dev/null +++ b/src/hispec/util/xeryon/stage.py @@ -0,0 +1,228 @@ +# pylint: skip-file +from enum import Enum +import math +from .config import AMPLITUDE_MULTIPLIER, PHASE_MULTIPLIER + + +class Stage(Enum): + XLS_312 = (True, # isLineair (True/False) + # Encoder Resolution Command (XLS =|XRTU=|XRTA=|XLA =) + "XLS1=312", + 312.5, # Encoder Resolution always in nanometer/microrad + 1000) # Speed multiplier + + XLS_1250 = (True, + "XLS1=1251", + 1250, + 1000) + XLS_1250_OLD = (True, + "XLS1=1250", + 1250, + 1000) + + XLS_1250_OLD_2 = (True, + "XLS1=1250", + 312.5, + 1000) + + XLS_78 = (True, + "XLS1=78", + 78.125, + 1000) + + XLS_5 = (True, + "XLS1=5", + 5, + 1000) + + XLS_1 = (True, + "XLS1=1", + 1, + 1000) + XLS_312_3N = (True, # isLineair (True/False) + # Encoder Resolution Command (XLS =|XRTU=|XRTA=|XLA =) + "XLS3=312", + 312.5, # Encoder Resolution always in nanometer/microrad + 1000) # Speed multiplier + + XLS_1250_3N = (True, + "XLS3=1251", + 1250, + 1000) + + XLS_1250_3N_OLD = (True, + "XLS3=1250", + 312.5, + 1000) + + XLS_78_3N = (True, + "XLS3=78", + 78.125, + 1000) + + XLS_5_3N = (True, + "XLS3=5", + 5, + 1000) + + XLS_1_3N = (True, + "XLS3=1", + 1, + 1000) + + XLA_312 = (True, + "XLA1=312", + 312.5, + 1000) + + XLA_1250 = (True, + "XLA1=1250", + 1250, + 1000) + + XLA_78 = (True, + "XLA1=78", + 78.125, + 1000) + + XLA_OL = (True, + "XLA1=0", + 1, + 1000) + + XLA_OL_3N = (True, + "XLA3=0", + 1, + 1000) + + XLA_312_3N = (True, + "XLA3=312", + 312.5, + 1000) + + XLA_1250_3N = (True, + "XLA3=1250", + 1250, + 1000) + + XLA_78_3N = (True, + "XLA3=78", + 78.125, + 1000) + + XLA_312_OLD = (True, + "XLA=312", + 312.5, + 1000) + + XLA_1250_OLD = (True, + "XLA=1250", + 1250, + 1000) + + XLA_78_OLD = (True, + "XLA=78", + 78.125, + 1000) + + XRTA = (False, + "XRTA=109", # ? + (2 * math.pi * 1e6) / 57600, + 100) + + # TODO: CHECK RES + # XRTU's 1N VERSION + XRTU_40_3 = (False, + "XRT1=2", + (2 * math.pi * 1e6) / 86400, + 100) + + XRTU_40_19 = (False, + "XRT1=18", + (2 * math.pi * 1e6) / 86400, + 100) + XRTU_40_49 = (False, + "XRT1=47", + (2 * math.pi * 1e6) / 86400, + 100) + + XRTU_40_73 = (False, + "XRT1=73", + (2 * math.pi * 1e6) / 86400, # CORRECT ??? + 100) + + XRTU_30_3 = (False, + "XRT1=3", + (2 * math.pi * 1e6) / 1843200, + 100) + + XRTU_30_19 = (False, + "XRT1=19", + (2 * math.pi * 1e6) / 360000, + 100) + + XRTU_30_49 = (False, + "XRT1=49", + (2 * math.pi * 1e6) / 144000, + 100) + + XRTU_30_109 = (False, + "XRT1=109", + (2 * math.pi * 1e6) / 57600, + 100) + + XRTU_60_3 = (False, + "XRT3=3", + (2 * math.pi * 1e6) / 2073600, + 100) + XRTU_60_19 = (False, + "XRT3=19", + (2 * math.pi * 1e6) / 324000, + 100) + XRTU_60_49 = (False, + "XRT3=49", + (2 * math.pi * 1e6) / 129600, + 100) + XRTU_60_109 = (False, + "XRT3=109", + (2 * math.pi * 1e6) / 64800, + 100) + + # For backwards compatibility + + XRTU_30_109_OLD = (False, + "XRTU=109", + (2 * math.pi * 1e6) / 57600, + 100) + XRTU_40_73_OLD = (False, + "XRTU=73", + (2 * math.pi * 1e6) / 86400, + 100) + XRTU_40_3_OLD = (False, + "XRTU=3", # ? + (2 * math.pi * 1e6) / 1800000, + 100) + + def __init__(self, isLineair, encoderResolutionCommand, encoderResolution, + speedMultiplier): + + self.isLineair = isLineair + self.encoderResolutionCommand = encoderResolutionCommand + # ALTIJD IN nm / nanorad !!! ==> Verschillend met windows interface. + self.encoderResolution = encoderResolution + self.speedMultiplier = speedMultiplier # used. + self.amplitudeMultiplier = AMPLITUDE_MULTIPLIER + self.phaseMultiplier = PHASE_MULTIPLIER + + def get_stage(self, stage_command): + """ + Get stagetype by specifying "stage_command". + 'stage_command' is how the stage is specified in the config file. + e.g.: XLS=312 or XRTU=40, .... + :param stage_command: String containing "XLS=.." or "XRTU=..." or ... + :return: Stagetype, or none if invalid stage command. + """ + for stage in Stage: + if stage_command in str(stage.encoderResolutionCommand).replace(" ", ""): + return stage + return None diff --git a/src/hispec/util/xeryon/tests/__init__.py b/src/hispec/util/xeryon/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/hispec/util/xeryon/tests/test_xeryon_axis.py b/src/hispec/util/xeryon/tests/test_xeryon_axis.py new file mode 100644 index 0000000..7c1dc2c --- /dev/null +++ b/src/hispec/util/xeryon/tests/test_xeryon_axis.py @@ -0,0 +1,77 @@ +"""Unit tests for the Axis class in the hispec.util.xeryon.axis module.""" +import unittest +from unittest.mock import MagicMock +from dataclasses import dataclass +# pylint: disable=no-name-in-module,import-error +from ..axis import Axis +from .test_xeryon_controller import MockStage +from .test_xeryon_communication import MockLogger + + +@dataclass +class MockXeryonController: + """A mock Xeryon controller for testing purposes.""" + + # pylint: disable=no-self-use + def is_single_axis_system(self): + """Return True to indicate this is a single-axis system.""" + return True + + # pylint: disable=no-self-use + def get_communication(self): + """Return a mock communication object.""" + return MagicMock(send_command=MagicMock()) + + +class TestAxis(unittest.TestCase): + """Unit tests for the Axis class.""" + + def setUp(self): + """Set up a mock stage and xeryon controller for testing.""" + self.stage = MockStage() + self.xeryon = MockXeryonController() + self.axis = Axis(self.xeryon, "X", self.stage, MockLogger()) + + def test_set_setting_stores_value(self): + """Test that set_setting correctly stores a setting in the internal dictionary.""" + self.axis.set_setting("VEL", "500") + self.assertEqual(self.axis.settings["VEL"], "500") + + def test_get_setting_returns_value(self): + """Test that get_setting returns the correct value for a known tag.""" + self.axis.settings["ACC"] = "100" + self.assertEqual(self.axis.get_setting("ACC"), "100") + + def test_send_command_stores_in_settings(self): + """Test that send_command routes to set_setting for supported tags.""" + self.axis.set_setting = MagicMock() + self.axis.send_command("VEL=200") + self.axis.set_setting.assert_called_with("VEL", "200") + + def test_reset_clears_flag_and_sends(self): + """Test that reset sends the correct command and clears the was_valid_DPOS flag.""" + self.axis.send_command = MagicMock() + self.axis.was_valid_DPOS = True + self.axis.reset() + self.axis.send_command.assert_called_with("RSET=0") + self.assertFalse(self.axis.was_valid_DPOS) + + def test_get_letter_returns_axis_letter(self): + """Test that get_letter returns the correct axis letter.""" + self.assertEqual(self.axis.get_letter(), "X") + + def test_convert_units_to_encoder_mm(self): + """Test conversion from millimeters to encoder units.""" + enc = self.axis.convert_units_to_encoder(1, self.axis.units) + expected = round(1 * 1e6 / self.stage.encoderResolution) + self.assertEqual(enc, expected) + + def test_convert_encoder_to_units_mm(self): + """Test conversion from encoder units to millimeters.""" + val = self.axis.convert_encoder_units_to_units(312500, self.axis.units) + expected = 312500 / (1e6 / self.stage.encoderResolution) + self.assertAlmostEqual(val, expected, places=2) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/hispec/util/xeryon/tests/test_xeryon_communication.py b/src/hispec/util/xeryon/tests/test_xeryon_communication.py new file mode 100644 index 0000000..8cc5b2a --- /dev/null +++ b/src/hispec/util/xeryon/tests/test_xeryon_communication.py @@ -0,0 +1,108 @@ +""" Mock classes to simulate the Xeryon environment for testing. """ +import unittest +from unittest.mock import MagicMock, patch +from dataclasses import dataclass +# pylint: disable=import-error,no-name-in-module +from xeryon.communication import Communication + + +@dataclass +class MockAxis: + """A mock class to simulate an axis in the Xeryon system.""" + def __init__(self): + """Initialize the mock axis with an empty list to store received data.""" + self.received_data = [] + + def receive_data(self, data): + """Simulate receiving data by appending it to the received_data list.""" + self.received_data.append(data) + + +@dataclass +class MockXeryon: + """A mock class to simulate the Xeryon system.""" + def __init__(self): + self.axis_list = [MockAxis()] + + # pylint: disable=unused-argument + def get_axis(self, letter): + """Return the first axis for simplicity.""" + return self.axis_list[0] + +class MockLogger: + """A mock logger to capture log messages.""" + def __init__(self): + """Initialize the mock logger with an empty list to store messages.""" + self.messages = [] + + def info(self, msg): + """Capture info messages.""" + self.messages.append(('info', msg)) + + def debug(self, msg): + """Capture debug messages.""" + self.messages.append(('debug', msg)) + + def warning(self, msg): + """Capture warning messages.""" + self.messages.append(('warning', msg)) + + def error(self, msg): + """Capture error messages.""" + self.messages.append(('error', msg)) + + def critical(self, msg): + """Capture critical messages.""" + self.messages.append(('critical', msg)) + +class TestCommunication(unittest.TestCase): + """Unit tests for the Communication class in the Xeryon system.""" + + @patch('serial.Serial') + # pylint: disable=no-self-use + def test_start_sets_up_serial_connection(self, mock_serial_class): + """Test that the Communication class sets up the serial connection correctly.""" + mock_serial = MagicMock() + mock_serial.in_waiting = 0 + mock_serial_class.return_value = mock_serial + mock_xeryon = MockXeryon() + + comm = Communication(mock_xeryon, 'COM3', 115200, MockLogger()) + comm.start() + + mock_serial_class.assert_called_with( + 'COM3', 115200, timeout=1, xonxoff=True) + mock_serial.flush.assert_called() + mock_serial.flushInput.assert_called() + mock_serial.flushOutput.assert_called() + + def test_send_command_queues_command(self): + """Test that the send_command method queues a command.""" + mock_xeryon = MockXeryon() + comm = Communication(mock_xeryon, 'COM3', 115200, MockLogger()) + comm.send_command("DPOS=100") + + self.assertEqual(comm.readyToSend, ["DPOS=100"]) + + @patch('serial.Serial') + def test_process_data_reads_and_dispatches(self, mock_serial_class): + """Test that the __process_data method reads from serial and dispatches commands.""" + mock_serial = MagicMock() + mock_serial.readline.return_value = b'X:DPOS=1000\n' + mock_serial.in_waiting = 1 + mock_serial_class.return_value = mock_serial + + mock_xeryon = MockXeryon() + comm = Communication(mock_xeryon, 'COM3', 115200, MockLogger()) + comm.ser = mock_serial + comm.readyToSend = ["X:MOVE=1"] + + # pylint: disable=protected-access + comm._Communication__process_data(external_while_loop=True) + + self.assertIn("DPOS=1000\n", mock_xeryon.axis_list[0].received_data) + mock_serial.write.assert_called_with(b"X:MOVE=1\n") + + +if __name__ == '__main__': + unittest.main() diff --git a/src/hispec/util/xeryon/tests/test_xeryon_controller.py b/src/hispec/util/xeryon/tests/test_xeryon_controller.py new file mode 100644 index 0000000..57bb85b --- /dev/null +++ b/src/hispec/util/xeryon/tests/test_xeryon_controller.py @@ -0,0 +1,240 @@ +""" +Unit tests for the XeryonController class in the hispec.util.xeryon module. +""" +import unittest +import os +import tempfile +from unittest.mock import patch, mock_open +from dataclasses import dataclass +# pylint: disable=no-name-in-module,import-error +from xeryon.xeryon_controller import XeryonController +from xeryon.stage import Stage +from xeryon.units import Units + + +def get_letter(): + """ + Return the letter associated with this axis. + """ + return "X" + + +class MockAxis: + """ + Mock class to simulate an axis in the XeryonController. + """ + def __init__(self): + """ + Initialize the mock axis with default values. + """ + self.commands = [] + self.settings = {} + + # pylint: disable=invalid-name + self.was_valid_DPOS = False + + def send_command(self, cmd): + """ + Simulate sending a command to the axis. + """ + self.commands.append(cmd) + + def set_setting(self, tag, value, *_, **__): + """ + Simulate setting a configuration value for the axis. + """ + self.settings[tag] = value + + def reset(self): + """ + Simulate resetting the axis. + """ + self.commands.append("RSET=0") + + def send_settings(self): + """ + Simulate sending the current settings of the axis. + """ + self.commands.append("send_settings") + + +class MockComm: + """ + Mock class to simulate communication with the Xeryon controller. + """ + def __init__(self): + """ + Initialize the mock communication with an empty command list. + """ + self.port = None + self.sent_commands = [] + + def send_command(self, cmd): + """ + Simulate sending a command through the communication interface. + """ + self.sent_commands.append(cmd) + + def close_communication(self): + """ + Simulate closing the communication interface. + """ + self.sent_commands.append("CLOSE") + + # pylint: disable=invalid-name + def set_COM_port(self, port): + """ + Simulate setting the communication port. + """ + self.port = port + + def start(self, *_): + """ + Simulate starting the communication interface. + """ + return self + +@dataclass() +class MockStage: + """ + Mock class to simulate a stage in the XeryonController. + """ + # pylint: disable=invalid-name + isLineair = True + # pylint: disable=invalid-name + encoderResolutionCommand = "XLS1=312" + # pylint: disable=invalid-name + encoderResolution = 312.5 + # pylint: disable=invalid-name + speedMultiplier = 1000 + # pylint: disable=invalid-name + amplitudeMultiplier = 1456.0 + # pylint: disable=invalid-name + phaseMultiplier = 182 + + +class TestXeryonController(unittest.TestCase): + """ + Unit tests for the XeryonController class. + """ + + def setUp(self): + """ + Set up the test environment by initializing a XeryonController instance + """ + self.controller = XeryonController() + self.controller.comm = MockComm() + self.axis = MockAxis() + self.controller.axis_list = [self.axis] + self.controller.axis_letter_list = ["X"] + + def test_add_axis(self): + """ + Test adding a new axis to the controller. + """ + result = self.controller.add_axis(MockStage(), "Y") + self.assertEqual(len(self.controller.axis_list), 2) + self.assertEqual(result.axis_letter, "Y") + + def test_is_single_axis(self): + """ + Test if the controller recognizes a single-axis system. + """ + self.assertTrue(self.controller.is_single_axis_system()) + + def test_stop_sends_commands(self): + """ + Test that the stop method sends the correct commands to the axis and communication + interface. + """ + self.controller.stop() + self.assertIn("ZERO=0", self.axis.commands) + self.assertIn("STOP=0", self.axis.commands) + self.assertIn("CLOSE", self.controller.comm.sent_commands) + + def test_set_master_setting(self): + """ + Test setting a master setting in the controller and ensuring it is sent correctly. + """ + self.controller.set_master_setting("VEL", "100") + self.assertEqual(self.controller.master_settings["VEL"], "100") + self.assertIn("VEL=100", self.controller.comm.sent_commands) + + def test_send_master_settings(self): + """ + Test sending master settings to the communication interface. + """ + self.controller.master_settings = {"VEL": "100", "ACC": "10"} + self.controller.send_master_settings() + self.assertIn("VEL=100", self.controller.comm.sent_commands) + self.assertIn("ACC=10", self.controller.comm.sent_commands) + + @patch("builtins.open", new_callable=mock_open, read_data="X:VEL=100\nACC=10\n") + # pylint: disable=unused-argument + def test_read_settings(self, mock_file): + """ + Test reading settings from a file and applying them to the axis. + """ + self.controller.read_settings() + self.assertEqual(self.axis.settings["VEL"], "100") + + def test_read_settings_applies_axis_values_correctly(self): + """ + Test that reading settings applies axis values correctly. + """ + settings_content = """ + X:LLIM=10 + X:HLIM=200 + X:SSPD=5000 + X:POLI=7 + """ + with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp: + tmp.write(settings_content) + tmp_path = tmp.name + + try: + controller = XeryonController() + + axis = controller.add_axis(Stage.XLS_312, "X") + controller.read_settings(tmp_path) + + expected_llim = str( + axis.convert_units_to_encoder(10, Units.mm)) + self.assertEqual(axis.get_setting("LLIM"), expected_llim) + + expected_hlim = str( + axis.convert_units_to_encoder(200, Units.mm)) + self.assertEqual(axis.get_setting("HLIM"), expected_hlim) + + expected_sspd = str(int(5000 * axis.stage.speedMultiplier)) + self.assertEqual(axis.get_setting("SSPD"), expected_sspd) + + self.assertEqual(axis.get_setting("POLI"), "7") + finally: + os.remove(tmp_path) + + @patch("serial.tools.list_ports.comports") + # pylint: disable=invalid-name + def test_find_COM_port(self, mock_comports): + """ + Test finding the COM port for the Xeryon controller. + """ + @dataclass + class Port: + """ + Mock class to simulate a serial port. + """ + def __init__(self, device, hwid): + """ + Initialize the mock port with device and hardware ID. + """ + self.device = device + self.hwid = hwid + + mock_comports.return_value = [Port("COM3", "USB VID:PID=04D8")] + self.controller.find_com_port() + self.assertEqual(self.controller.comm.port, "COM3") + + +if __name__ == "__main__": + unittest.main() diff --git a/src/hispec/util/xeryon/tests/test_xeryon_utils.py b/src/hispec/util/xeryon/tests/test_xeryon_utils.py new file mode 100644 index 0000000..4460973 --- /dev/null +++ b/src/hispec/util/xeryon/tests/test_xeryon_utils.py @@ -0,0 +1,16 @@ +"""Test suite for Xeryon utility functions.""" +# pylint: disable=import-error,no-name-in-module +from xeryon.utils import get_actual_time, get_dpos_epos_string + + +def test_get_actual_time_returns_int(): + """Test that get_actual_time returns an integer.""" + timestamp = get_actual_time() + assert isinstance(timestamp, int) + + +def test_get_dpos_epos_string_format(): + """Test that get_dpos_epos_string formats the string correctly.""" + dpos, epos, unit = 123, 456, 'mm' + result = get_dpos_epos_string(dpos, epos, unit) + assert result == "DPOS: 123 mm and EPOS: 456 mm" diff --git a/src/hispec/util/xeryon/units.py b/src/hispec/util/xeryon/units.py new file mode 100644 index 0000000..ed976ee --- /dev/null +++ b/src/hispec/util/xeryon/units.py @@ -0,0 +1,35 @@ +# pylint: skip-file +""" +Defines the Units enum for standardized unit representation and improved code readability. + +Each unit has a unique ID and string name. Includes a method to match a unit from a string. +""" +from enum import Enum + + +class Units(Enum): + """ + This class is only made for making the program more readable. + """ + mm = (0, "mm") + mu = (1, "mu") + nm = (2, "nm") + inch = (3, "inches") + minch = (4, "milli inches") + enc = (5, "encoder units") + rad = (6, "radians") + mrad = (7, "mrad") + deg = (8, "degrees") + + def __init__(self, ID, str_name): + self.ID = ID + self.str_name = str_name + + def __str__(self): + return self.str_name + + def get_unit(self, str): + for unit in Units: + if unit.str_name in str: + return unit + return None diff --git a/src/hispec/util/xeryon/utils.py b/src/hispec/util/xeryon/utils.py new file mode 100644 index 0000000..5030fd5 --- /dev/null +++ b/src/hispec/util/xeryon/utils.py @@ -0,0 +1,24 @@ +# pylint: skip-file +""" +Utility functions for time measurement and formatted position string generation. + +This module includes: +- `get_actual_time()`: Returns the current time in milliseconds. +- `get_dpos_epos_string(dpos, epos, unit)`: Returns a formatted string + of dpos and epos values with units. +""" +import time + + +def get_actual_time(): + """ + :return: Returns the actual time in ms. + """ + return int(round(time.time() * 1000)) + + +def get_dpos_epos_string(DPOS, EPOS, Unit): + """ + :return: A string containting the EPOS & DPOS value's and the current units. + """ + return str("DPOS: " + str(DPOS) + " " + str(Unit) + " and EPOS: " + str(EPOS) + " " + str(Unit)) diff --git a/src/hispec/util/xeryon/xeryon_controller.py b/src/hispec/util/xeryon/xeryon_controller.py new file mode 100644 index 0000000..1c1ebbc --- /dev/null +++ b/src/hispec/util/xeryon/xeryon_controller.py @@ -0,0 +1,334 @@ +""" +Defines the XeryonController class, the main interface for communicating with +Xeryon motion controllers. + +Includes setup of communication, management of connected axes, settings handling, +and motion control utilities. +""" +import time +import serial +import os +import logging +from .communication import Communication +from .axis import Axis +from .config import AUTO_SEND_SETTINGS, SETTINGS_FILENAME + + +class XeryonController: + """ + Main controller class for Xeryon motion systems. + + Handles communication setup, axis registration, system initialization, and command execution. + Supports both serial and TCP communication, single or multi-axis setups, + and persistent settings. + + Typical usage: + controller = XeryonController(COM_port="COM3") + controller.add_axis(stage="linear", axis_letter="X") + controller.start() + """ + # pylint: disable=too-many-arguments + def __init__(self, COM_port=None, baudrate=115200, log=True, logfile=None, + settings_filename=SETTINGS_FILENAME, + connection_type='serial', tcp_host=None, tcp_port=None): + """ + :param COM_port: Specify the COM port used. + :type COM_port: string + :param baudrate: Specify the baudrate. + :type baudrate: int + :param quiet: If True, suppresses logger output to stdout. + :type quiet: bool + :param settings_filename: Path to the settings file to use for this controller instance. + :type settings_filename: str + :return: A XeryonController object. + + Main Xeryon Drive Class, onitialize with the COM port, baudrate, and a settings file + for communication with the driver. + """ + + # Logging + logfile = __name__.rsplit('.', 1)[-1] + '.log' + self.logger = logging.getLogger(logfile) + self.logger.setLevel(logging.INFO) + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + + if log: + file_handler = logging.FileHandler(logfile) + file_handler.setFormatter(formatter) + self.logger.addHandler(file_handler) + + self.comm = Communication( + self, COM_port, baudrate, self.logger, + connection_type=connection_type, tcp_host=tcp_host, tcp_port=tcp_port + ) # Startup communication + self.axis_list = [] + self.axis_letter_list = [] + self.master_settings = {} + self.settings_filename = settings_filename + + def is_single_axis_system(self): + """ + :return: Returns True if it's a single axis system, False if its a multiple axis system. + """ + return len(self.get_all_axis()) <= 1 + + def start(self, external_communication_thread=False, do_reset=True, auto_send_settings=AUTO_SEND_SETTINGS): + """ + :return: Nothing. + This functions NEEDS to be ran before any commands are executed. + This function starts the serial communication and configures the settings + with the controller. + + + NOTE: (KPIC MOD) we added the do_reset flag so that we can disconnect and reconnect + to the stage without doing a reset. This allows us to reconnect without having + to re-reference the stage. + """ + if len(self.get_all_axis()) <= 0: + raise Exception( + "Cannot start the system without stages. The stages don't have to be connnected, " + "only initialized in the software.") + + comm = self.get_communication().start( + external_communication_thread) # Start communication + + if do_reset: + for axis in self.get_all_axis(): + axis.reset() + time.sleep(0.2) + + self.read_settings() # Read settings file + if auto_send_settings: + self.send_master_settings() + for axis in self.get_all_axis(): # Loop trough each axis: + axis.send_settings() # Send the settings + # ask for LLIM & HLIM value's + for axis in self.get_all_axis(): + axis.send_command("HLIM=?") + axis.send_command("LLIM=?") + axis.send_command("SSPD=?") + axis.send_command("PTO2=?") + axis.send_command("PTOL=?") + + if external_communication_thread: + return comm + return None + + def stop(self, is_print_end=True): + """ + :return: None + This function sends STOP to the controller and closes the communication. + + NOTE: (KPIC MOD) we added the is_print_end flag to avoid unnecessary prints that + may confuse users + """ + for axis in self.get_all_axis(): # Send STOP to each axis. + axis.send_command("ZERO=0") + axis.send_command("STOP=0") + axis.was_valid_DPOS = False + self.get_communication().close_communication() # Close communication + if is_print_end: + self.logger.info("Program stopped running.") + + def stop_movements(self): + """ + Just stop moving. + """ + for axis in self.get_all_axis(): + axis.send_command("STOP=0") + axis.was_valid_DPOS = False + + def reset(self): + """ + :return: None + This function sends RESET to the controller, and resends all settings. + """ + for axis in self.get_all_axis(): + axis.reset() + time.sleep(0.2) + + self.read_settings() # Read settings file again + + if AUTO_SEND_SETTINGS: + for axis in self.get_all_axis(): + axis.send_settings() # Update settings + + def get_all_axis(self): + """ + :return: A list containing all axis objects belonging to this controller. + """ + return self.axis_list + + def add_axis(self, stage, axis_letter): + """ + :param stage: Specify the type of stage that is connected. + :type stage: Stage + :return: Returns an Axis object + """ + new_axis = Axis(self, axis_letter, stage, self.logger) + self.axis_list.append(new_axis) # Add axis to axis list. + self.axis_letter_list.append(axis_letter) + return new_axis + + # End User Commands + def get_communication(self): + """ + :return: The communication class. + """ + return self.comm + + def get_axis(self, letter): + """ + :param letter: Specify the axis letter + :return: Returns the correct axis object. Or None if the axis does not exist. + """ + if self.axis_letter_list.count(letter) == 1: # Axis letter found + indx = self.axis_letter_list.index(letter) + if len(self.get_all_axis()) > indx: + return self.get_all_axis()[indx] # Return axis + return None + + def read_settings(self, settings_file: str = None): + """ + :param settings_file: Optional path to a settings file. If not provided, + uses self.settings_filename. + :return: None + This function reads the settings.txt file and processes each line. + It first determines for what axis the setting is, then it reads the setting and saves it. + If there are commands for axis that don't exist, it just ignores them. + """ + filepath = settings_file if settings_file is not None else self.settings_filename + + print(filepath) + + try: + with open(filepath, "r") as file: + for line in file.readlines(): # For each line: + # Check if it's a command and not a comment or blank line. + if "=" in line and line.find("%") != 0: + + # Strip spaces and newlines. + line = line.strip("\n\r").replace(" ", "") + # Default select the first axis. + axis = self.get_all_axis()[0] + if ":" in line: # Check if axis is specified + axis = self.get_axis(line.split(":")[0]) + if axis is None: # Check if specified axis exists + # No valid axis? ==> IGNORE and loop further. + continue + line = line.split(":")[1] # Strip "X:" from command + elif not self.is_single_axis_system(): + # This line doesn't contain ":", so it doesn't specify an axis. + # BUT It's a multi-axis system ==> so these settings are for the master. + if "%" in line: # Ignore comments + line = line.split("%")[0] + self.set_master_setting(line.split( + "=")[0], line.split("=")[1], True) + continue + + if "%" in line: # Ignore comments + line = line.split("%")[0] + + tag = line.split("=")[0] + value = line.split("=")[1] + + # Update settings for specified axis. + axis.set_setting(tag, value, True, doNotSendThrough=True) + + except FileNotFoundError as ex: + print("Trying to open:", os.path.abspath(filepath)) + self.logger.info("No settings_default.txt found.") + # self.stop() # Make sure the thread also stops. + # raise Exception( + # "ERROR: settings_default.txt file not found. Place it in the same folder + # as Xeryon.py. \n " + # "The settings_default.txt is delivered in the same folder as the + # Windows Interface. \n " + str(e)) + except Exception as ex: + raise ex + + def set_master_setting(self, tag, value, from_settings_file=False): + """ + In multi-axis systems, commands without an axis specified are for the master. + This function adds a setting (tag, value) to the list of settings for the master. + """ + self.master_settings.update({tag: value}) + if not from_settings_file: + self.comm.send_command(str(tag)+"="+str(value)) + if "COM" in tag: + self.set_com_port(str(value)) + + def send_master_settings(self, axis=False): + """ + In multi-axis systems, commands without an axis specified are for the master. + This function sends the stored settings to the controller; + """ + prefix = "" + if axis is not False: + prefix = str(self.get_all_axis()[0].get_letter()) + ":" + + for tag, value in self.master_settings.items(): + self.comm.send_command(str(prefix) + str(tag) + "="+str(value)) + + def save_master_settings(self, axis=False): + """ + In multi-axis systems, commands without an axis specified are for the master. + This function saves the master settings on the controller. + """ + if axis is None: + self.comm.send_command("SAVE=0") + else: + self.comm.send_command( + str(self.get_all_axis()[0].get_letter()) + ":SAVE=0") + + def set_com_port(self, com_port): + """ + :param com_port: Specify the COM port used. + """ + self.get_communication().set_COM_port(com_port) + + def find_com_port(self): + """ + This function loops through every available COM-port. + It check's if it contains any signature of Xeryon. + :return: + """ + self.logger.info("Automatically searching for COM-Port. If you want to speed things up " + "you should manually provide it inside the controller object.") + ports = list(serial.tools.list_ports.comports()) + for port in ports: + if "04D8" in str(port.hwid): + self.set_com_port(str(port.device)) + break + + def is_communication_thread_alive(self): + """ + Check if the communication thread is still running. + + :return: True if thread is alive, False otherwise + """ + return self.comm.is_thread_alive() + + def get_communication_health_status(self): + """ + Get detailed health status of the communication thread. + + :return: Dictionary with thread health information including: + - thread_alive: bool + - last_heartbeat: timestamp or None + - time_since_heartbeat: float (seconds) or None + - heartbeat_stale: bool + """ + return self.comm.get_thread_health_status() + + def check_communication_health(self, raise_on_dead=False): + """ + Check if communication thread is healthy and optionally raise exception if not. + + :param raise_on_dead: If True, raise an exception when thread is dead + :return: True if healthy, False otherwise + :raises Exception: If raise_on_dead=True and thread is not healthy + """ + return self.comm.check_thread_health(raise_on_dead=raise_on_dead) From 18f2fc7296f5a53c9c7c164c55a347c2999218c1 Mon Sep 17 00:00:00 2001 From: Reed Riddle Date: Mon, 15 Dec 2025 14:00:16 -0800 Subject: [PATCH 2/4] Remove broken util submodules --- .gitmodules | 55 - src/hispec/util/__init__.py | 21 - .../util/config/pi_named_positions.json | 8 - .../util/config/xeryon_default_settings.txt | 13 - src/hispec/util/gammavac/.gitignore | 207 -- src/hispec/util/gammavac/LICENSE | 674 ------ src/hispec/util/gammavac/README.md | 55 - src/hispec/util/gammavac/SPCe.c | 2150 ----------------- src/hispec/util/gammavac/SPCe.h | 248 -- src/hispec/util/gammavac/SPCe.py | 503 ---- src/hispec/util/gammavac/__init__.py | 0 src/hispec/util/gammavac/pyproject.toml | 22 - src/hispec/util/gammavac/tests/test_basic.py | 14 - src/hispec/util/helper/__init__.py | 0 src/hispec/util/helper/logger_utils.py | 33 - src/hispec/util/inficon/.gitignore | 165 -- src/hispec/util/inficon/README.md | 44 - src/hispec/util/inficon/__init__.py | 0 src/hispec/util/inficon/inficonvgc502.py | 336 --- src/hispec/util/inficon/pyproject.toml | 25 - src/hispec/util/inficon/tests/__init__.py | 0 .../util/inficon/tests/test_inficonvgc502.py | 53 - src/hispec/util/lakeshore/.gitignore | 207 -- src/hispec/util/lakeshore/README.md | 55 - src/hispec/util/lakeshore/__init__.py | 0 src/hispec/util/lakeshore/lakeshore.py | 424 ---- src/hispec/util/lakeshore/pyproject.toml | 22 - .../lakeshore/tests/test_lakeshore_basic.py | 19 - src/hispec/util/newport/.gitignore | 171 -- src/hispec/util/newport/LICENSE | 22 - src/hispec/util/newport/README.md | 45 - src/hispec/util/newport/pyproject.toml | 50 - src/hispec/util/newport/smc100pp.py | 878 ------- src/hispec/util/newport/tests/test_basic.py | 15 - src/hispec/util/onewire/.gitignore | 207 -- src/hispec/util/onewire/README.md | 29 - src/hispec/util/onewire/__init__.py | 4 - src/hispec/util/onewire/onewire.py | 371 --- src/hispec/util/onewire/pyproject.toml | 22 - .../util/onewire/scripts/influxdb_log.json | 29 - .../util/onewire/scripts/influxdb_log.py | 108 - .../util/onewire/tests/test_onewire_basic.py | 16 - src/hispec/util/ozoptics/.gitignore | 171 -- src/hispec/util/ozoptics/LICENSE | 22 - src/hispec/util/ozoptics/README.md | 48 - src/hispec/util/ozoptics/dd100mc.py | 621 ----- src/hispec/util/ozoptics/pyproject.toml | 48 - src/hispec/util/ozoptics/tests/test_basic.py | 13 - src/hispec/util/pi/.gitignore | 171 -- src/hispec/util/pi/LICENSE | 22 - src/hispec/util/pi/README.md | 99 - src/hispec/util/pi/__init__.py | 4 - src/hispec/util/pi/pi_controller.py | 355 --- src/hispec/util/pi/pyproject.toml | 50 - src/hispec/util/pi/tests/__init__.py | 0 src/hispec/util/pi/tests/test_pi_basic.py | 18 - src/hispec/util/pi/tests/test_pi_mock.py | 207 -- src/hispec/util/srs/README.md | 89 - src/hispec/util/srs/__init__.py | 4 - src/hispec/util/srs/ptc10.py | 207 -- src/hispec/util/srs/pyproject.toml | 23 - src/hispec/util/srs/tests/__init__.py | 0 src/hispec/util/srs/tests/test_ptc10.py | 16 - src/hispec/util/standa/.gitignore | 207 -- src/hispec/util/standa/README.md | 70 - src/hispec/util/standa/__init__.py | 3 - src/hispec/util/standa/pyproject.toml | 43 - src/hispec/util/standa/smc8.py | 379 --- .../util/standa/tests/default_smc8_test.py | 79 - .../util/standa/tests/mock_smc8_test.py | 87 - .../util/standa/tests/physical_smc8_test.py | 152 -- src/hispec/util/sunpower/.gitignore | 15 - src/hispec/util/sunpower/README.md | 66 - src/hispec/util/sunpower/__init__.py | 6 - src/hispec/util/sunpower/pyproject.toml | 39 - .../util/sunpower/sunpower_cryocooler.py | 215 -- src/hispec/util/sunpower/tests/__init__.py | 0 .../util/sunpower/tests/test_sunpower.py | 76 - src/hispec/util/thorlabs/.gitignore | 165 -- src/hispec/util/thorlabs/README.md | 89 - src/hispec/util/thorlabs/__init__.py | 4 - src/hispec/util/thorlabs/fw102c.py | 333 --- src/hispec/util/thorlabs/ppc102.py | 1662 ------------- src/hispec/util/thorlabs/pyproject.toml | 43 - .../thorlabs/tests/default_fw102c_test.py | 90 - .../thorlabs/tests/default_ppc102_test.py | 160 -- .../util/thorlabs/tests/mock_fw102c_test.py | 46 - .../util/thorlabs/tests/mock_ppc102_test.py | 60 - .../thorlabs/tests/physical_fw102c_test.py | 104 - .../thorlabs/tests/physical_ppc102_test.py | 149 -- src/hispec/util/xeryon/.gitignore | 171 -- src/hispec/util/xeryon/LICENSE | 22 - src/hispec/util/xeryon/README.md | 106 - src/hispec/util/xeryon/__init__.py | 11 - src/hispec/util/xeryon/axis.py | 807 ------- src/hispec/util/xeryon/communication.py | 229 -- src/hispec/util/xeryon/config.py | 38 - .../xeryon/config/xeryon_default_settings.txt | 13 - src/hispec/util/xeryon/pyproject.toml | 48 - src/hispec/util/xeryon/stage.py | 228 -- src/hispec/util/xeryon/tests/__init__.py | 0 .../util/xeryon/tests/test_xeryon_axis.py | 77 - .../xeryon/tests/test_xeryon_communication.py | 108 - .../xeryon/tests/test_xeryon_controller.py | 240 -- .../util/xeryon/tests/test_xeryon_utils.py | 16 - src/hispec/util/xeryon/units.py | 35 - src/hispec/util/xeryon/utils.py | 24 - src/hispec/util/xeryon/xeryon_controller.py | 334 --- 108 files changed, 16357 deletions(-) delete mode 100644 src/hispec/util/__init__.py delete mode 100644 src/hispec/util/config/pi_named_positions.json delete mode 100644 src/hispec/util/config/xeryon_default_settings.txt delete mode 100644 src/hispec/util/gammavac/.gitignore delete mode 100644 src/hispec/util/gammavac/LICENSE delete mode 100644 src/hispec/util/gammavac/README.md delete mode 100644 src/hispec/util/gammavac/SPCe.c delete mode 100644 src/hispec/util/gammavac/SPCe.h delete mode 100644 src/hispec/util/gammavac/SPCe.py delete mode 100644 src/hispec/util/gammavac/__init__.py delete mode 100644 src/hispec/util/gammavac/pyproject.toml delete mode 100644 src/hispec/util/gammavac/tests/test_basic.py delete mode 100644 src/hispec/util/helper/__init__.py delete mode 100644 src/hispec/util/helper/logger_utils.py delete mode 100644 src/hispec/util/inficon/.gitignore delete mode 100644 src/hispec/util/inficon/README.md delete mode 100644 src/hispec/util/inficon/__init__.py delete mode 100644 src/hispec/util/inficon/inficonvgc502.py delete mode 100644 src/hispec/util/inficon/pyproject.toml delete mode 100644 src/hispec/util/inficon/tests/__init__.py delete mode 100644 src/hispec/util/inficon/tests/test_inficonvgc502.py delete mode 100644 src/hispec/util/lakeshore/.gitignore delete mode 100644 src/hispec/util/lakeshore/README.md delete mode 100644 src/hispec/util/lakeshore/__init__.py delete mode 100755 src/hispec/util/lakeshore/lakeshore.py delete mode 100644 src/hispec/util/lakeshore/pyproject.toml delete mode 100644 src/hispec/util/lakeshore/tests/test_lakeshore_basic.py delete mode 100644 src/hispec/util/newport/.gitignore delete mode 100644 src/hispec/util/newport/LICENSE delete mode 100644 src/hispec/util/newport/README.md delete mode 100644 src/hispec/util/newport/pyproject.toml delete mode 100644 src/hispec/util/newport/smc100pp.py delete mode 100644 src/hispec/util/newport/tests/test_basic.py delete mode 100644 src/hispec/util/onewire/.gitignore delete mode 100644 src/hispec/util/onewire/README.md delete mode 100644 src/hispec/util/onewire/__init__.py delete mode 100644 src/hispec/util/onewire/onewire.py delete mode 100644 src/hispec/util/onewire/pyproject.toml delete mode 100644 src/hispec/util/onewire/scripts/influxdb_log.json delete mode 100644 src/hispec/util/onewire/scripts/influxdb_log.py delete mode 100644 src/hispec/util/onewire/tests/test_onewire_basic.py delete mode 100644 src/hispec/util/ozoptics/.gitignore delete mode 100644 src/hispec/util/ozoptics/LICENSE delete mode 100644 src/hispec/util/ozoptics/README.md delete mode 100644 src/hispec/util/ozoptics/dd100mc.py delete mode 100644 src/hispec/util/ozoptics/pyproject.toml delete mode 100644 src/hispec/util/ozoptics/tests/test_basic.py delete mode 100644 src/hispec/util/pi/.gitignore delete mode 100644 src/hispec/util/pi/LICENSE delete mode 100644 src/hispec/util/pi/README.md delete mode 100644 src/hispec/util/pi/__init__.py delete mode 100644 src/hispec/util/pi/pi_controller.py delete mode 100644 src/hispec/util/pi/pyproject.toml delete mode 100644 src/hispec/util/pi/tests/__init__.py delete mode 100644 src/hispec/util/pi/tests/test_pi_basic.py delete mode 100644 src/hispec/util/pi/tests/test_pi_mock.py delete mode 100644 src/hispec/util/srs/README.md delete mode 100644 src/hispec/util/srs/__init__.py delete mode 100644 src/hispec/util/srs/ptc10.py delete mode 100644 src/hispec/util/srs/pyproject.toml delete mode 100644 src/hispec/util/srs/tests/__init__.py delete mode 100644 src/hispec/util/srs/tests/test_ptc10.py delete mode 100644 src/hispec/util/standa/.gitignore delete mode 100644 src/hispec/util/standa/README.md delete mode 100644 src/hispec/util/standa/__init__.py delete mode 100644 src/hispec/util/standa/pyproject.toml delete mode 100644 src/hispec/util/standa/smc8.py delete mode 100644 src/hispec/util/standa/tests/default_smc8_test.py delete mode 100644 src/hispec/util/standa/tests/mock_smc8_test.py delete mode 100644 src/hispec/util/standa/tests/physical_smc8_test.py delete mode 100644 src/hispec/util/sunpower/.gitignore delete mode 100644 src/hispec/util/sunpower/README.md delete mode 100644 src/hispec/util/sunpower/__init__.py delete mode 100644 src/hispec/util/sunpower/pyproject.toml delete mode 100644 src/hispec/util/sunpower/sunpower_cryocooler.py delete mode 100644 src/hispec/util/sunpower/tests/__init__.py delete mode 100644 src/hispec/util/sunpower/tests/test_sunpower.py delete mode 100644 src/hispec/util/thorlabs/.gitignore delete mode 100644 src/hispec/util/thorlabs/README.md delete mode 100644 src/hispec/util/thorlabs/__init__.py delete mode 100755 src/hispec/util/thorlabs/fw102c.py delete mode 100644 src/hispec/util/thorlabs/ppc102.py delete mode 100644 src/hispec/util/thorlabs/pyproject.toml delete mode 100644 src/hispec/util/thorlabs/tests/default_fw102c_test.py delete mode 100644 src/hispec/util/thorlabs/tests/default_ppc102_test.py delete mode 100644 src/hispec/util/thorlabs/tests/mock_fw102c_test.py delete mode 100644 src/hispec/util/thorlabs/tests/mock_ppc102_test.py delete mode 100644 src/hispec/util/thorlabs/tests/physical_fw102c_test.py delete mode 100644 src/hispec/util/thorlabs/tests/physical_ppc102_test.py delete mode 100644 src/hispec/util/xeryon/.gitignore delete mode 100644 src/hispec/util/xeryon/LICENSE delete mode 100644 src/hispec/util/xeryon/README.md delete mode 100644 src/hispec/util/xeryon/__init__.py delete mode 100644 src/hispec/util/xeryon/axis.py delete mode 100644 src/hispec/util/xeryon/communication.py delete mode 100644 src/hispec/util/xeryon/config.py delete mode 100644 src/hispec/util/xeryon/config/xeryon_default_settings.txt delete mode 100644 src/hispec/util/xeryon/pyproject.toml delete mode 100644 src/hispec/util/xeryon/stage.py delete mode 100644 src/hispec/util/xeryon/tests/__init__.py delete mode 100644 src/hispec/util/xeryon/tests/test_xeryon_axis.py delete mode 100644 src/hispec/util/xeryon/tests/test_xeryon_communication.py delete mode 100644 src/hispec/util/xeryon/tests/test_xeryon_controller.py delete mode 100644 src/hispec/util/xeryon/tests/test_xeryon_utils.py delete mode 100644 src/hispec/util/xeryon/units.py delete mode 100644 src/hispec/util/xeryon/utils.py delete mode 100644 src/hispec/util/xeryon/xeryon_controller.py diff --git a/.gitmodules b/.gitmodules index 4744efc..e69de29 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,55 +0,0 @@ -[submodule "etc/PIPython"] - path = etc/PIPython - url = https://github.com/PI-PhysikInstrumente/PIPython -[submodule "etc/camera-interface"] - path = etc/camera-interface - url = https://github.com/CaltechOpticalObservatories/camera-interface - branch = main -[submodule "hispec/util/lakeshore"] - path = hispec/util/lakeshore - url = https://github.com/COO-Utilities/lakeshore - branch = main -[submodule "hispec/util/inficon"] - path = hispec/util/inficon - url = https://github.com/COO-Utilities/inficon - branch = main -[submodule "hispec/util/gammavac"] - path = hispec/util/gammavac - url = https://github.com/COO-Utilities/gammavac - branch = main -[submodule "hispec/util/standa"] - path = hispec/util/standa - url = https://github.com/COO-Utilities/standa - branch = main -[submodule "hispec/util/thorlabs"] - path = hispec/util/thorlabs - url = https://github.com/COO-Utilities/thorlabs - branch = main -[submodule "hispec/util/sunpower"] - path = hispec/util/sunpower - url = https://github.com/COO-Utilities/sunpower - branch = main -[submodule "hispec/util/onewire"] - path = hispec/util/onewire - url = https://github.com/COO-Utilities/onewire - branch = main -[submodule "hispec/util/xeryon"] - path = hispec/util/xeryon - url = https://github.com/COO-Utilities/xeryon - branch = main -[submodule "hispec/util/pi"] - path = hispec/util/pi - url = https://github.com/COO-Utilities/pi - branch = main -[submodule "hispec/util/srs"] - path = hispec/util/srs - url = https://github.com/COO-Utilities/srs - branch = main -[submodule "hispec/util/ozoptics"] - path = hispec/util/ozoptics - url = https://github.com/COO-Utilities/ozoptics - branch = main -[submodule "hispec/util/newport"] - path = hispec/util/newport - url = https://github.com/COO-Utilities/newport.git - branch = main diff --git a/src/hispec/util/__init__.py b/src/hispec/util/__init__.py deleted file mode 100644 index 2470d22..0000000 --- a/src/hispec/util/__init__.py +++ /dev/null @@ -1,21 +0,0 @@ -from .sunpower import SunpowerCryocooler -from .pi import PIControllerBase -from .xeryon.xeryon_controller import XeryonController -from .xeryon.stage import Stage as XeryonStage -from .newport import smc100pp -from .inficon.inficonvgc502 import InficonVGC502 -from .thorlabs import fw102c -from .lakeshore import lakeshore -from .srs import PTC10 - -__all__ = [ - "SunpowerCryocooler", - "PIControllerBase", - "smc100pp", - "XeryonController", - "XeryonStage", - "InficonVGC502", - "fw102c", - "lakeshore", - "PTC10", -] diff --git a/src/hispec/util/config/pi_named_positions.json b/src/hispec/util/config/pi_named_positions.json deleted file mode 100644 index e225df7..0000000 --- a/src/hispec/util/config/pi_named_positions.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "024551188": { - "test": [ - "1", - 0.0 - ] - } -} diff --git a/src/hispec/util/config/xeryon_default_settings.txt b/src/hispec/util/config/xeryon_default_settings.txt deleted file mode 100644 index b9126b1..0000000 --- a/src/hispec/util/config/xeryon_default_settings.txt +++ /dev/null @@ -1,13 +0,0 @@ -% Default settings for Xeryon motion stages -% Axis-specific settings use the format X:= -% Master settings use = - -X:LLIM=10 -X:HLIM=200 -X:SSPD=5000 -X:PTO2=100 -X:PTOL=100 -X:POLI=5 - -POLI=7 -SAVE=0 diff --git a/src/hispec/util/gammavac/.gitignore b/src/hispec/util/gammavac/.gitignore deleted file mode 100644 index b7faf40..0000000 --- a/src/hispec/util/gammavac/.gitignore +++ /dev/null @@ -1,207 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ diff --git a/src/hispec/util/gammavac/LICENSE b/src/hispec/util/gammavac/LICENSE deleted file mode 100644 index f288702..0000000 --- a/src/hispec/util/gammavac/LICENSE +++ /dev/null @@ -1,674 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS - - How to Apply These Terms to Your New Programs - - If you develop a new program, and you want it to be of the greatest -possible use to the public, the best way to achieve this is to make it -free software which everyone can redistribute and change under these terms. - - To do so, attach the following notices to the program. It is safest -to attach them to the start of each source file to most effectively -state the exclusion of warranty; and each file should have at least -the "copyright" line and a pointer to where the full notice is found. - - - Copyright (C) - - This program is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with this program. If not, see . - -Also add information on how to contact you by electronic and paper mail. - - If the program does terminal interaction, make it output a short -notice like this when it starts in an interactive mode: - - Copyright (C) - This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. - This is free software, and you are welcome to redistribute it - under certain conditions; type `show c' for details. - -The hypothetical commands `show w' and `show c' should show the appropriate -parts of the General Public License. Of course, your program's commands -might be different; for a GUI interface, you would use an "about box". - - You should also get your employer (if you work as a programmer) or school, -if any, to sign a "copyright disclaimer" for the program, if necessary. -For more information on this, and how to apply and follow the GNU GPL, see -. - - The GNU General Public License does not permit incorporating your program -into proprietary programs. If your program is a subroutine library, you -may consider it more useful to permit linking proprietary applications with -the library. If this is what you want to do, use the GNU Lesser General -Public License instead of this License. But first, please read -. diff --git a/src/hispec/util/gammavac/README.md b/src/hispec/util/gammavac/README.md deleted file mode 100644 index b92c384..0000000 --- a/src/hispec/util/gammavac/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# gammavac_controller - -Low-level library for communicating with a Gamma Vacuum SPCe controller - -## Currently Supported Models -- SPCe - SPCe.py, SPCe.c, SPCe.h - -## Features -- Connect to Gamma Vacuum controllers over serial through a terminal server -- Query state and parameters -- Set individual parameters - -## Requirements - -- Install base class from https://github.com/COO-Utilities/hardware_device_base - -## Installation - -```bash -pip install . -``` - -## Usage - -```python -import SPCe - -controller = SPCe.SpceController(bus_address=5) -controller.connect(host='192.168.29.100', port=10015) - -# Print pressure -print(controller.read_pressure()) - -# Print pump size -print(controller.get_pump_size()) - -# Get voltage -controller.read_voltage() - -# Get Controller Version -controller.read_version() - -# For a comprehensive list of classes and methods, use the help function -help(SPCe) - -``` - -## 🧪 Testing -Unit tests are located in `tests/` directory. - -To run all tests from the project root: - -```bash -pytest -``` \ No newline at end of file diff --git a/src/hispec/util/gammavac/SPCe.c b/src/hispec/util/gammavac/SPCe.c deleted file mode 100644 index 5b68501..0000000 --- a/src/hispec/util/gammavac/SPCe.c +++ /dev/null @@ -1,2150 +0,0 @@ -/*+*************************************************************************** - - * File: SPCe.c - - * Purpose: The functions herein are specific to the Lesker - SPCe Gauge SPCe Controller (PGC). - - * Modification history: - * 2011/12/02 Stephen Kaye - Initial - * 2025/07/25 Don Neill - Modify for COO Utils - * - *-**************************************************************************/ -static char sccsid[] = "%W% %E% %U%"; - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -#include "SPCe.h" -#include "kprs.h" - -/* - * Global variables - */ - -extern int Simulate; - -pthread_mutex_t socket_mutex = PTHREAD_MUTEX_INITIALIZER; - -int bus_address=SPCE_BUS_ADDRESS; /* bus address, 1 for RS-232 */ - -/************************************************************************* - *+ - * Function name: spce_read_version - - * Description: Reads the software version and prints it to the log - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: Returns SPCe error code. - char *version - - * Modification History: - 2012/01/10 SK -- Initial. - 2014/02/04 DN adapted to SPCe controller - *- -*************************************************************************/ -int spce_read_version(char *port, char *version) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", - __FILE__, __LINE__); - - /* create command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_READ_VERSION, NULL, 1)) < 0) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_READ_VERSION)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting version string from response.", - __FILE__, __LINE__); - - /* extract pressure from string */ - if ( (err=getStringFromSpceResponse(response, version)) < 0 ) - return err; - - log_msg(SINFO, LOGLVL_USER1, "%s %d : SPCe Firmware version: %s", - __FILE__, __LINE__, version); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_read_version */ - - -/************************************************************************* - *+ - * Function name: spce_reset - - * Description: resets gamma pump - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: Returns SPCe error code. - - * Modification History: - 2012/01/10 SK -- Initial. - 2014/02/04 DN adapted to SPCe controller - *- -*************************************************************************/ -int spce_reset(char *port) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", - __FILE__, __LINE__); - - /* create command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_RESET, NULL, 1)) < 0) - return err; - - if ( (err=spce_send_command(port, command)) < 0 ) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_reset */ - - -/************************************************************************* - *+ - * Function name: spce_set_arc_detect - - * Description: sets arc detect based on value in YESNO - - * Inputs: - char *port -- socket port attached to pump - int yesno -- 0 - no, 1 - yes - - * Outputs: Returns SPCe error code. - - * Modification History: - 2012/01/10 SK -- Initial. - 2014/02/04 DN adapted to SPCe controller - *- -*************************************************************************/ -int spce_set_arc_detect(char *port, int yesno) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", - __FILE__, __LINE__); - - /* create command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_ARC_DETECT, - ((yesno == 1) ? "YES" : "NO"), 1)) < 0) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_ARC_DETECT)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_arc_detect */ - - -/************************************************************************* - *+ - * Function name: spce_get_arc_detect - - * Description: gets arc detect setting, puts it in yesno - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: Returns SPCe error code. - int *yesno -- 1 = "YES" or 0 = "NO" - - * Modification History: - 2012/01/10 SK -- Initial. - 2014/02/04 DN adapted to SPCe controller - *- -*************************************************************************/ -int spce_get_arc_detect(char *port, int *yesno) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char stryesno[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", - __FILE__, __LINE__); - - /* create command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_GET_ARC_DETECT, NULL, 1)) < 0) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_GET_ARC_DETECT)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting arc detect state from response.", - __FILE__, __LINE__); - - /* extract pressure from string */ - if ( (err=getStringFromSpceResponse(response, stryesno)) < 0 ) - return err; - - if ( strcmp(stryesno,"YES") == 0 ) - *yesno = 1; - else *yesno = 0; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_get_arc_detect */ - - -/************************************************************************* - *+ - * Function name: spce_read_current - - * Description: reads the current of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - float *outcurrent -- variable to hold retrieved current value - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_read_current(char *port, float *outcurrent) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_READ_CURRENT, NULL, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_READ_CURRENT)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting current from response.", - __FILE__, __LINE__); - - /* extract current from string */ - *outcurrent = getFloatFromSpceResponse(response); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_read_current */ - - -/************************************************************************* - *+ - * Function name: spce_read_pressure - - * Description: reads the pressure of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - float *outpressure -- variable to hold retrieved pressure value - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_read_pressure(char *port, float *outpressure) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_READ_PRESSURE, NULL, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_READ_PRESSURE)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting pressure from response.", - __FILE__, __LINE__); - - /* extract pressure from string */ - *outpressure = getFloatFromSpceResponse(response); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_read_pressure */ - - -/************************************************************************* - *+ - * Function name: spce_read_voltage - - * Description: reads the voltage of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - int *outvoltage -- variable to hold retrieved voltage value - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_read_voltage(char *port, int *outvoltage) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_READ_VOLTAGE, NULL, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_READ_VOLTAGE)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting voltage from response.", - __FILE__, __LINE__); - - /* extract voltage from string */ - *outvoltage = getIntFromSpceResponse(response); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_read_voltage */ - - -/************************************************************************* - *+ - * Function name: spce_set_units - - * Description: sets pressure units to Torr, Mbar, or Pascals - - * Inputs: - char *port -- socket port attached to pump - char *units-- "Torr", "Mbar", or "Pascals" (only checks first char) - - * Outputs: Returns SPCe error code. - - * Modification History: - 2012/01/10 SK -- Initial. - 2014/02/04 DN adapted to SPCe controller - *- -*************************************************************************/ -int spce_set_units(char *port, int units) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char spce_units[2]; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", - __FILE__, __LINE__); - - /* check input units */ - if (units == 'M' || units == 'm') { - spce_units[0] = SPCE_UNITS_MBAR; - } else if (units == 'P' || units == 'p') { - spce_units[0] = SPCE_UNITS_PASCAL; - } else spce_units[0] = SPCE_UNITS_TORR; - spce_units[1] = '\0'; - - /* create command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_PRESS_UNITS, spce_units, 1)) < 0) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_ARC_DETECT)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_units */ - - -/************************************************************************* - *+ - * Function name: spce_get_pump_size - - * Description: reads the pump size in L/s of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - int *outsize -- variable to hold retrieved pump size value - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_get_pump_size(char *port, int *outsize) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_GET_PUMP_SIZE, NULL, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_GET_PUMP_SIZE)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting pump size from response.", - __FILE__, __LINE__); - - /* extract pump size from string */ - *outsize = getIntFromSpceResponse(response); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_get_pump_size */ - - -/************************************************************************* - *+ - * Function name: spce_set_pump_size - - * Description: reads the pump size in L/s of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - int size -- pump size in L/s - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_set_pump_size(char *port, int size) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char strsize[5]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* create size string */ - if (size >= 0 && size <= 9999) { - sprintf(strsize,"%04d",size); - } else { - return SPCE_ERROR_VALUE_OUT_OF_RANGE; - } - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_PUMP_SIZE, strsize, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_PUMP_SIZE)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_pump_size */ - - -/************************************************************************* - *+ - * Function name: spce_get_cal_factor - - * Description: reads the calibration factor (0-9.99) of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - float *outcalfact -- variable to hold retrieved calibration factor - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_get_cal_factor(char *port, float *outcalfact) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_GET_CAL_FACTOR, NULL, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_GET_CAL_FACTOR)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting cal factor from response.", - __FILE__, __LINE__); - - /* extract cal factor from string */ - *outcalfact = getFloatFromSpceResponse(response); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_get_cal_factor */ - - -/************************************************************************* - *+ - * Function name: spce_set_cal_factor - - * Description: sets the gamma pump calibration factor (0-9.99). - - * Inputs: - char *port -- socket port attached to pump - float calfact -- calibration factor (0.00 - 9.99) - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_set_cal_factor(char *port, float calfact) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char strcalfact[5]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* create size string */ - if (calfact >= 0.00 && calfact <= 9.99) { - sprintf(strcalfact,"%4.2f",calfact); - } else { - return SPCE_ERROR_VALUE_OUT_OF_RANGE; - } - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_CAL_FACTOR, strcalfact, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_CAL_FACTOR)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_cal_factor */ - - -/************************************************************************* - *+ - * Function name: spce_set_auto_restart - - * Description: sets auto restart for the gamma pump to yes or no. - - * Inputs: - char *port -- socket port attached to pump - int yesno -- 0 - no, 1 - yes - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_set_auto_restart(char *port, int yesno) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char stryesno[4]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* create set string */ - if (yesno == 1) { - sprintf(stryesno,"YES"); - } else { - sprintf(stryesno,"NO"); - } - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_AUTO_RESTART, stryesno, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_AUTO_RESTART)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_auto_restart */ - - -/************************************************************************* - *+ - * Function name: spce_get_auto_restart - - * Description: gets the auto restart setting for the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: Returns SPCe error code. - int *yesno -- 1 = "YES" or 0 = "NO" - - * Modification History: - 2012/01/10 SK -- Initial. - 2014/02/04 DN adapted to SPCe controller - *- -*************************************************************************/ -int spce_get_auto_restart(char *port, int *yesno) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char stryesno[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", - __FILE__, __LINE__); - - /* create command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_GET_AUTO_RESTART, NULL, 1)) < 0) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_GET_AUTO_RESTART)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting auto restart state from response.", - __FILE__, __LINE__); - - /* extract status from string */ - if ( (err=getStringFromSpceResponse(response, stryesno)) < 0 ) - return err; - - if ( strcmp(stryesno,"YES") == 0 ) - *yesno = 1; - else *yesno = 0; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_get_auto_restart */ - - -/************************************************************************* - *+ - * Function name: spce_pump_start - - * Description: starts the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_pump_start(char *port) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_START_PUMP, NULL, 1)) < 0 ) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_START_PUMP)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_pump_start */ - - -/************************************************************************* - *+ - * Function name: spce_pump_stop - - * Description: stops the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_pump_stop(char *port) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_STOP_PUMP, NULL, 1)) < 0 ) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_STOP_PUMP)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_pump_stop */ - - -/************************************************************************* - *+ - * Function name: spce_lock_keypad - - * Description: Locks/Unlocks the keypad according to value in LOCK - - * Inputs: - char *port -- socket port attached to pump - int lock -- 0 - unlock, 1 - lock - - * Outputs: Returns SPCe error code. - - * Modification History: - 2012/01/10 SK -- Initial. - 2014/02/04 DN adapted to SPCe controller - *- -*************************************************************************/ -int spce_lock_keypad(char *port, int lock) { - - int err=0; - int command_code = ((lock == 1) ? SPCE_COMMAND_LOCK_KEYPAD : - SPCE_COMMAND_UNLOCK_KEYPAD); - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", - __FILE__, __LINE__); - - /* create command string */ - if ( (err=spce_create_command_string(command, bus_address, - command_code, NULL, 1)) < 0) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, command_code)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_lock_keypad */ - - -/************************************************************************* - *+ - * Function name: spce_get_analog_mode - - * Description: reads the analog mode of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - int *outmode -- variable to hold retrieved analog mode value - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_get_analog_mode(char *port, int *outmode) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_GET_ANALOG_MODE, NULL, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_GET_ANALOG_MODE)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting analog mode from response.", - __FILE__, __LINE__); - - /* extract analog mode from string */ - *outmode = getIntFromSpceResponse(response); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_get_analog_mode */ - - -/************************************************************************* - *+ - * Function name: spce_set_analog_mode - - * Description: sets the gamma pump analog_mode (0-6,8-10) - - * Inputs: - char *port -- socket port attached to pump - int mode -- analog mode value (0-6,8-10) - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_set_analog_mode(char *port, int mode) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char strmode[3]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* create mode string */ - if ( mode >= 0 && mode <= 10 && mode != 7 ) { - sprintf(strmode,"%d",mode); - } else { - return SPCE_ERROR_VALUE_OUT_OF_RANGE; - } - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_ANALOG_MODE, strmode, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_ANALOG_MODE)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_analog_mode */ - - -/************************************************************************* - *+ - * Function name: spce_high_voltage_on - - * Description: gets the high voltage setting for the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: Returns SPCe error code. - int *yesno -- 1 = "YES" or 0 = "NO" - - * Modification History: - 2012/01/10 SK -- Initial. - 2014/02/04 DN adapted to SPCe controller - *- -*************************************************************************/ -int spce_high_voltage_on(char *port, int *yesno) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char stryesno[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Creating command string.", - __FILE__, __LINE__); - - /* create command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_IS_HIGH_VOLTAGE_ON, NULL, 1)) < 0) - return err; - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_IS_HIGH_VOLTAGE_ON)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting high voltage state from response.", - __FILE__, __LINE__); - - /* extract status from string */ - if ( (err=getStringFromSpceResponse(response, stryesno)) < 0 ) - return err; - - if ( strcmp(stryesno,"YES") == 0 ) - *yesno = 1; - else *yesno = 0; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_high_voltage_on */ - - -/************************************************************************* - *+ - * Function name: spce_set_hv_autorecovery - - * Description: sets the gamma pump HV autorecovery mode (0-2) - - * Inputs: - char *port -- socket port attached to pump - int mode -- HV autorecovery mode value (0-3): - 0 - disabled - 1 - enable auto HV start - 2 - enable auto power start (no HV) - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_set_hv_autorecovery(char *port, int mode) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char strmode[3]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* create mode string */ - if ( mode >= 0 && mode <= 2 ) { - sprintf(strmode,"%d",mode); - } else { - return SPCE_ERROR_VALUE_OUT_OF_RANGE; - } - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_HV_AUTORECOVERY, strmode, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_HV_AUTORECOVERY)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_hv_autorecovery */ - - -/************************************************************************* - *+ - * Function name: spce_get_hv_autorecovery - - * Description: reads the HV autorecovery mode of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - int *outmode -- variable to hold retrieved HV auto recovery mode value - (see spce_set_hv_autorecovery) - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_get_hv_autorecovery(char *port, int *outmode) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_GET_HV_AUTORECOVERY, NULL, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_GET_HV_AUTORECOVERY)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting HV autorecovery mode from response.", - __FILE__, __LINE__); - - /* extract HV autorecovery mode from string */ - *outmode = getIntFromSpceResponse(response); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_get_hv_autorecovery */ - - -/************************************************************************* - *+ - * Function name: spce_set_comm_mode - - * Description: sets the gamma pump comm mode (0-2) - - * Inputs: - char *port -- socket port attached to pump - int mode -- comm mode value (0-3): - 0 - Local - 1 - Remote - 2 - Full - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_set_comm_mode(char *port, int mode) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char strmode[3]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* create mode string */ - if ( mode >= 0 && mode <= 2 ) { - sprintf(strmode,"%d",mode); - } else { - return SPCE_ERROR_VALUE_OUT_OF_RANGE; - } - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_COMM_MODE, strmode, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_COMM_MODE)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_comm_mode */ - - -/************************************************************************* - *+ - * Function name: spce_get_comm_mode - - * Description: reads the comm mode of the gamma pump. - - * Inputs: - char *port -- socket port attached to pump - - * Outputs: - int *outmode -- variable to hold retrieved commy mode value - (see spce_set_comm_mode) - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_get_comm_mode(char *port, int *outmode) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_GET_COMM_MODE, NULL, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_GET_COMM_MODE)) < 0) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: extracting comm mode from response.", - __FILE__, __LINE__); - - /* extract comm mode from string */ - *outmode = getIntFromSpceResponse(response); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_get_comm_mode */ - - -/************************************************************************* - *+ - * Function name: spce_set_comm_interface - - * Description: sets the gamma pump comm interface (0-2) - - * Inputs: - char *port -- socket port attached to pump - int interface -- comm interface value (0-3): - 0 - RS232 - 1 - RS422 - 2 - RS485 - 3 - RS485 (full duplex) - 4 - Ethernet - 5 - USB - - * Outputs: - - * Returns: 0 or gamma error code. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/04 DN Modified for SPCe controller - *- -*************************************************************************/ -int spce_set_comm_interface(char *port, int interface) { - - int err=0; - char command[MAX_COMMAND_LENGTH]; - char response[MAX_RESPONSE_LENGTH]; - char strinterface[3]; - - log_msg(SINFO, LOGLVL_USER3, - "%s %d: entering %s", __FILE__, __LINE__, __FUNCTION__); - - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string.", __FILE__, __LINE__); - - /* create interface string */ - if ( interface >= 0 && interface <= 5 ) { - sprintf(strinterface,"%d",interface); - } else { - return SPCE_ERROR_VALUE_OUT_OF_RANGE; - } - - /* generate command string */ - if ( (err=spce_create_command_string(command, bus_address, - SPCE_COMMAND_SET_COMM_INTERFACE, strinterface, 1)) < 0 ) - return err; - - - if ( (err=spce_send_request(port, command, response)) < 0 ) - return err; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: checking for errors.", - __FILE__, __LINE__); - - /* check for errors */ - if ( (err=spce_validate_response(response, - SPCE_COMMAND_SET_COMM_INTERFACE)) < 0) - return err; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_set_comm_interface */ - - -/************************************************************************* - *+ - * Function name: spce_send_command - - * Description: send a command to the socket port - - * Inputs: - char *port -- socket port - int cmd -- command code (see kprs_gamma.h) - - * Returns: 0 or gamma error code. - - * Modification History: - 2014/02/04 DN Initial - *- -*************************************************************************/ -int spce_send_command(char *port, char *cmd) { - - int err=0; - int cerr=0; - int socket_fd; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - if ( !Simulate ) { - - /* lock socket port */ - pthread_mutex_lock(&socket_mutex); - - /* open port */ - log_msg(SINFO, LOGLVL_USER4, - "%s %d, %s: Calling setupSocketInterface", - __FILE__, __LINE__, __FUNCTION__); - - if ( (socket_fd=setupSocketInterface(port, 0)) < 0) { - pthread_mutex_unlock(&socket_mutex); - return SPCE_ERROR_OPEN_PORT; - } - log_msg(SINFO, LOGLVL_USER4, - "%s %d, %s: setupSocketInterface=%d, success", - __FILE__, __LINE__, __FUNCTION__, socket_fd); - - /* write command to port */ - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Writing command.", - __FILE__, __LINE__); - - if ( (err=socketport_write(socket_fd, cmd, strlen(cmd))) < 0) { - if ((cerr = socketport_close(socket_fd)) < 0) { - err = SPCE_ERROR_CLOSE_PORT; - errlog(SERROR, "%s %d: %s", __FILE__, __LINE__, - SpceErrMsg[SPCE_ERROR_CODE0-err]); - } - pthread_mutex_unlock(&socket_mutex); - return SPCE_ERROR_WRITE_COMMAND; - } - log_msg(SINFO, LOGLVL_USER4, - "%s %d, %s: socketport_write ret=%d, success", - __FILE__, __LINE__, __FUNCTION__, err); - - /* close port */ - if ((err=socketport_close(socket_fd)) < 0) - err=SPCE_ERROR_CLOSE_PORT; - - /* unlock socket port */ - pthread_mutex_unlock(&socket_mutex); - - } /* end if ( !Simulate ) */ - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_send_command */ - - -/************************************************************************* - *+ - * Function name: spce_send_request - - * Description: send a command to the socket port and return the response - - * Inputs: - char *port -- socket port - int cmd -- command code (see kprs_gamma.h) - char *response -- response to command - - * Returns: 0 or gamma error code. - - * Modification History: - 2014/02/04 DN Initial - *- -*************************************************************************/ -int spce_send_request(char *port, char *cmd, char *response) { - - int err=0; - int cerr=0; - int charsread=0; - int socket_fd; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - if ( !Simulate ) { - - /* lock socket port */ - pthread_mutex_lock(&socket_mutex); - - /* open port */ - log_msg(SINFO, LOGLVL_USER4, - "%s %d, %s: Calling setupSocketInterface", - __FILE__, __LINE__, __FUNCTION__); - - if ( (socket_fd=setupSocketInterface(port, 0)) < 0) { - pthread_mutex_unlock(&socket_mutex); - return SPCE_ERROR_OPEN_PORT; - } - log_msg(SINFO, LOGLVL_USER4, - "%s %d, %s: setupSocketInterface=%d, success", - __FILE__, __LINE__, __FUNCTION__, socket_fd); - - /* write command to port */ - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Writing command.", - __FILE__, __LINE__); - - if ( (err=socketport_write(socket_fd, cmd, strlen(cmd))) < 0) { - if ((cerr = socketport_close(socket_fd)) < 0) { - err = SPCE_ERROR_CLOSE_PORT; - errlog(SERROR, "%s %d: %s", __FILE__, __LINE__, - SpceErrMsg[SPCE_ERROR_CODE0-err]); - } - pthread_mutex_unlock(&socket_mutex); - return SPCE_ERROR_WRITE_COMMAND; - } - log_msg(SINFO, LOGLVL_USER4, - "%s %d, %s: socketport_write ret=%d, success", - __FILE__, __LINE__, __FUNCTION__, err); - - /* wait for response */ - usleep(SPCE_TIME_BETWEEN_COMMANDS); - - /* read response */ - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: Reading response.", - __FILE__, __LINE__); - - memset( response, (char)'\0', sizeof(response) ); - - if ( (charsread=socketport_read(socket_fd, MAX_RESPONSE_LENGTH, - response )) < 0) { - if ((cerr = socketport_close(socket_fd)) < 0) { - err = SPCE_ERROR_CLOSE_PORT; - errlog(SERROR, "%s %d: %s", __FILE__, __LINE__, - SpceErrMsg[SPCE_ERROR_CODE0-err]); - } - pthread_mutex_unlock(&socket_mutex); - return SPCE_ERROR_READ_COMMAND; - } - log_msg(SINFO, LOGLVL_USER4, - "%s %d, %s: socketport_read ret=%d, success", - __FILE__, __LINE__, __FUNCTION__, charsread ); - - /* close port */ - if ((err=socketport_close(socket_fd)) < 0) - err=SPCE_ERROR_CLOSE_PORT; - - /* unlock socket port */ - pthread_mutex_unlock(&socket_mutex); - - /* remove all non-printable chars from response? */ - - } /* end if ( !Simulate ) */ - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_send_request */ - - -/************************************************************************* - *+ - * Function name: spce_create_command_string - - * Description: creates the proper command string to be sent to the gamma - pump based on the input variables. - - * Inputs: - char *outstring -- string to receive command - int bus_address -- bus address (1 = RS232) - int command_code -- gamma mgc command code - int nchar -- number of characters in command_code (2 or 3) - int val -- parameter int value, if necessary - float fval -- parameter float value, if necessary - - * Outputs: Returns length of command or SPCe error code if there is an error. - - * Modification History: - 2011/12/02 SK -- Initial. - 2014/02/03 DN Modified for GAMMA - *- -*************************************************************************/ -int spce_create_command_string( - char *outstring, /* string to receive command */ - int bus_address, /* bus address (1 = RS232) */ - int command_code, /* gamma command code */ - char *command_data, /* data for command or NULL */ - int do_checksum /* 0 - no, other - yes */ - ) -{ - - /* - * This function creates a command string to be passed to - * the SPCe vacuum controller. - * See SPCe vacuum SPCe controller user manual - * from gammavacuum.com for details - * - * commands use this format: - * {attention char} {bus_address} {command code} {data} {termination} - * ~ ba cc data \r - * - * with - * ba = address value between 01 and FF. - * cc = character string representing command (2 bytes) - * data = optional value for command (e.g. baud rate, adress setting, etc.) - * - */ - - char out_command[MAX_COMMAND_LENGTH]; - char temp_command[MAX_COMMAND_LENGTH]; - char temp_code[MAX_CODE_LENGTH]; - char *p; - int err=0; - int cksm=0; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - log_msg(SDEBUG, LOGLVL_USER4, - "%s %d: Creating command string for SPCe pump.", - __FILE__, __LINE__); - - /* create command code string */ - sprintf(temp_code, "%.2X", command_code); - - /* determine which command was called, and then construct - command based on parameters if err == 0 */ - if (err == 0) { - switch (command_code) { - /* just the command code */ - case SPCE_COMMAND_READ_MODEL: - case SPCE_COMMAND_READ_VERSION: - case SPCE_COMMAND_RESET: - case SPCE_COMMAND_GET_ARC_DETECT: - case SPCE_COMMAND_READ_CURRENT: - case SPCE_COMMAND_READ_PRESSURE: - case SPCE_COMMAND_READ_VOLTAGE: - case SPCE_COMMAND_GET_SUPPLY_STATUS: - case SPCE_COMMAND_GET_PUMP_SIZE: - case SPCE_COMMAND_GET_CAL_FACTOR: - case SPCE_COMMAND_GET_AUTO_RESTART: - case SPCE_COMMAND_START_PUMP: - case SPCE_COMMAND_STOP_PUMP: - case SPCE_COMMAND_GET_SETPOINT: - case SPCE_COMMAND_LOCK_KEYPAD: - case SPCE_COMMAND_UNLOCK_KEYPAD: - case SPCE_COMMAND_GET_ANALOG_MODE: - case SPCE_COMMAND_IS_HIGH_VOLTAGE_ON: - case SPCE_COMMAND_GET_HV_AUTORECOVERY: - case SPCE_COMMAND_SET_FIRMWARE_UPDATE: - case SPCE_COMMAND_GET_COMM_MODE: - case SPCE_COMMAND_GET_ETHERNET_MAC: - case SPCE_COMMAND_INITIATE_FEA: - case SPCE_COMMAND_INITIATE_HIPOT: - /* need trailing space for checksum */ - sprintf(temp_command, " %.2X %s ", - bus_address, temp_code); - break; - - /* GET/SET command codes */ - case SPCE_COMMAND_GETSET_SERIAL_COMM: - case SPCE_COMMAND_GETSET_ETHERNET_IP: - case SPCE_COMMAND_GETSET_ETHERNET_MASK: - case SPCE_COMMAND_GETSET_ETHERNET_GTWY: - case SPCE_COMMAND_GETSET_HIPOT_TARGET: - case SPCE_COMMAND_GETSET_FOLDBACK_VOLTS: - case SPCE_COMMAND_GETSET_FOLDBACK_PRES: - /* need trailing space for checksum */ - if (command_data == NULL) { - sprintf(temp_command, " %.2X %s ", - bus_address, temp_code); - } else { - sprintf(temp_command, " %.2X %s %s ", - bus_address, temp_code, - command_data); - } - break; - - /* command plus data */ - case SPCE_COMMAND_SET_ARC_DETECT: - case SPCE_COMMAND_SET_PRESS_UNITS: - case SPCE_COMMAND_SET_PUMP_SIZE: - case SPCE_COMMAND_SET_CAL_FACTOR: - case SPCE_COMMAND_SET_AUTO_RESTART: - case SPCE_COMMAND_SET_SETPOINT: - case SPCE_COMMAND_SET_ANALOG_MODE: - case SPCE_COMMAND_SET_SERIAL_ADDRESS: - case SPCE_COMMAND_SET_HV_AUTORECOVERY: - case SPCE_COMMAND_SET_COMM_MODE: - case SPCE_COMMAND_SET_COMM_INTERFACE: - case SPCE_COMMAND_GET_FEA_DATA: - /* need trailing space for checksum */ - sprintf(temp_command, " %.2X %s %s ", - bus_address, temp_code, command_data); - break; - - /* invalid command */ - default: - err = SPCE_ERROR_BAD_COMMAND_CODE; - - } /* end switch(command_code) */ - - /* make sure we still have a valid command */ - if (err == 0) { - - /* do we need the checksum? */ - if (do_checksum != 0) { - p = temp_command; - while (*p) cksm = cksm + (int)(*p++); - cksm = cksm % 256; - } - - /* final output command */ - sprintf(out_command, "~%s%.2X\r", temp_command, cksm); - - /* set err to length of command */ - err = strlen(out_command); - - /* copy the new command string from temp - * string into output string */ - strcpy(outstring, out_command); - } - } /* end if (err == 0) */ - - log_msg(SINFO, LOGLVL_USER4, "%s %d: command string = {%s}", - __FILE__, __LINE__, outstring); - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: spce_create_command_string */ - - -/************************************************************************* - *+ - * Function name: spce_validate_response - - * Description: Processes response read from serial port to determine - if there was an error. - - * Inputs: - char *response -- string received from controller, read from serialport - int command_code -- command code of command that generated response - - * Outputs: Returns SPCe error code. - - * Modification History: - 2002/12/17 JLW -- Initial. - 2006/06/27 CRO -- Superficial mods for MOSFIRE. - 2014/02/06 JDN -- Major re-write for GAMMA - *- -*************************************************************************/ -int spce_validate_response(char *response, int command_code) { - - const char del[2] = " "; - int err=0; - char *substr, *p; - int bus, i, offset, rcksm, cksm=0; - - log_msg(SINFO, LOGLVL_USER3, "%s %d: entering %s", - __FILE__, __LINE__, __FUNCTION__); - - /* all responses must start with the bus address */ - bus = atoi(response); - if (bus != bus_address) { - - /* bad response string */ - err = SPCE_ERROR_INVALID_RESPONSE; - - /* bus address OK */ - } else { - - /* now get status mnemonic */ - substr = response + 3*sizeof(char); - - /* check for error condition */ - if ( strncmp(substr,"ER",2) == 0 ) { - - /* get error code */ - substr += 3*sizeof(char); - err = SPCE_ERROR_CODE0 - atoi(substr); - - /* status OK */ - } else { - - /* offset to beginning of checksum in response */ - offset = strlen(response) - 3*sizeof(char); - - /* extract response checksum (Hex) */ - rcksm = strtol(response+offset,NULL,16); - - /* calculate response checksum */ - p = response; - for ( i=0 ; i<=offset-1 ; i++ ) - cksm = cksm + *p++; - cksm = cksm % 256; - - /* make sure they match */ - if (rcksm != cksm) - err = SPCE_ERROR_BAD_RESPONSE_CHECKSUM; - - } /* status OK */ - - } /* bus address OK */ - - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; - -} /* end * Function name: spce_validate_response */ - -/************************************************************************* - *+ - * Function name: getFloatFromSpceResponse - - * Description: Convert response to floating-point number. - - * Inputs: - char *spce_response -- string read from serialport - - * Outputs: Returns float value or -1. for bad response - - * Modification History: - 2012/12/17 SK -- Initial. - 2014/02/06 DN -- Added more error checking. - *- -*************************************************************************/ -float getFloatFromSpceResponse(char *response) { - char *substr; - float num; - int fstat; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: entering %s.", - __FILE__, __LINE__, __FUNCTION__); - - /* offset to beginning of data */ - substr = strstr(response, "OK") + 6*sizeof(char); - - fstat = sscanf(substr,"%g", &num); - - if (fstat != 1) { - num = -1.; - errlog(SERROR, "%s %d (%s): Invalid float value", - __FILE__, __LINE__, __FUNCTION__); - } else { - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: %s, value = %e", - __FILE__, __LINE__, __FUNCTION__, num); - } - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return num; -} /* end * Function name: getFloatFromSpceResponse */ - - -/************************************************************************* - *+ - * Function name: getStringFromSpceResponse - - * Description: Convert response to string. - - * Inputs: - char *spce_response -- string read from serialport - - * Outputs: Returns SPCe error code. - char *outstring -- data string from response - - * Modification History: - 2014/02/27 DN -- Initial. - *- -*************************************************************************/ -int getStringFromSpceResponse(char *response, char *outstring) { - char *substr; - int data_size; - int err=0; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: entering %s.", - __FILE__, __LINE__, __FUNCTION__); - - /* validate OK response */ - if ( (substr=strstr(response, "OK")) == NULL ) { - - err = SPCE_ERROR_INVALID_RESPONSE; - errlog(SERROR, "%s %d (%s): Invalid string", - __FILE__, __LINE__, __FUNCTION__); - - /* response OK */ - } else { - - /* offset from "OK" to beginning of data */ - substr += 6*sizeof(char); - - /* find size of data */ - data_size = strlen(substr) - 4*sizeof(char); - - /* verify the size of the data string */ - if (data_size <= 0 || data_size >= MAX_RESPONSE_LENGTH) { - - err = SPCE_ERROR_INVALID_RESPONSE; - errlog(SERROR, "%s %d (%s): Invalid string", - __FILE__, __LINE__, __FUNCTION__); - - /* data string size OK */ - } else { - - strncpy(outstring, substr, data_size); - outstring[data_size] = '\0'; /* null terminate */ - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: %s, string = %s", - __FILE__, __LINE__, __FUNCTION__, outstring); - } - - } /* response OK */ - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return err; -} /* end * Function name: getStringFromSpceResponse */ - - -/************************************************************************* - *+ - * Function name: getIntFromSpceResponse - - * Description: Convert response to floating-point number. - - * Inputs: - char *spce_response -- string read from serialport - - * Outputs: integer value or -1 for bad response - - * Modification History: - 2012/12/17 SK -- Initial. - 2014/02/06 DN -- Added more error checking. - *- -*************************************************************************/ -float getIntFromSpceResponse(char *response) { - char *substr; - int num; - int fstat; - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: entering %s.", - __FILE__, __LINE__, __FUNCTION__); - - /* offset to beginning of data */ - substr = strstr(response, "OK") + 6*sizeof(char); - - fstat = sscanf(substr,"%d", &num); - - if (fstat != 1) { - num = -1; - errlog(SERROR, "%s %d (%s): Invalid int value", - __FILE__, __LINE__, __FUNCTION__); - } else { - - log_msg(SDEBUG, LOGLVL_USER4, "%s %d: %s, value = %d", - __FILE__, __LINE__, __FUNCTION__, num); - } - - log_msg(SINFO, LOGLVL_USER3, "%s %d: exiting %s", - __FILE__, __LINE__, __FUNCTION__); - - return num; -} /* end * Function name: getIntFromSpceResponse */ - diff --git a/src/hispec/util/gammavac/SPCe.h b/src/hispec/util/gammavac/SPCe.h deleted file mode 100644 index 1443af1..0000000 --- a/src/hispec/util/gammavac/SPCe.h +++ /dev/null @@ -1,248 +0,0 @@ -#ifndef _SPCE_H -#define _SPCE_H - - -#define MAX_COMMAND_LENGTH 64 -#define MAX_CODE_LENGTH 3 -#define MAX_RESPONSE_LENGTH 100 -#define SPCE_BUS_ADDRESS 1 -#define SPCE_COMM_INTERFACE 0 /* RS232 */ -#define SPCE_PUMP_SIZE 3 /* Pump Size in L/s */ -#define SPCE_ARC_DETECT 1 /* Arc Detect ON */ -#define SPCE_HV_AUTO_RECOVERY 0 /* HV auto recover OFF */ -#define SPCE_AUTO_RESTART 1 /* Pump auto restart ON */ -#define SPCE_COMM_MODE 2 /* Comm mode is FULL (for now)*/ -#define SPCE_TIME_BETWEEN_COMMANDS 120000 /* microsecs */ -#define SPCE_START_CHAR '~' -#define SPCE_UNITS_TORR 'T' -#define SPCE_UNITS_MBAR 'M' -#define SPCE_UNITS_PASCAL 'P' -#define SPCE_KEYPAD_UNLOCK 0 -#define SPCE_KEYPAD_LOCK 1 - -/* Spce baud rate and parity */ -#define SPCE_BAUD_RATE 9600 -#define SPCE_PARITY (int)'N' - -#define SPCE_QUERY 1 -#define SPCE_COMMAND 0 -#define SPCE_TURNS_OFF_ABOVE 0 -#define SPCE_TURNS_ON_BELOW 1 - -/* Spce command codes */ -#define SPCE_COMMAND_READ_MODEL 0x01 /* not implemented */ -#define SPCE_COMMAND_READ_VERSION 0x02 -#define SPCE_COMMAND_RESET 0x07 -#define SPCE_COMMAND_SET_ARC_DETECT 0x91 -#define SPCE_COMMAND_GET_ARC_DETECT 0x92 -#define SPCE_COMMAND_READ_CURRENT 0x0a -#define SPCE_COMMAND_READ_PRESSURE 0x0b -#define SPCE_COMMAND_READ_VOLTAGE 0x0c -#define SPCE_COMMAND_GET_SUPPLY_STATUS 0x0d /* not implemented */ -#define SPCE_COMMAND_SET_PRESS_UNITS 0x0e -#define SPCE_COMMAND_GET_PUMP_SIZE 0x11 -#define SPCE_COMMAND_SET_PUMP_SIZE 0x12 -#define SPCE_COMMAND_GET_CAL_FACTOR 0x1d -#define SPCE_COMMAND_SET_CAL_FACTOR 0x1e -#define SPCE_COMMAND_SET_AUTO_RESTART 0x33 -#define SPCE_COMMAND_GET_AUTO_RESTART 0x34 -#define SPCE_COMMAND_START_PUMP 0x37 -#define SPCE_COMMAND_STOP_PUMP 0x38 -#define SPCE_COMMAND_GET_SETPOINT 0x3c /* not implemented */ -#define SPCE_COMMAND_SET_SETPOINT 0x3d /* not implemented */ -#define SPCE_COMMAND_LOCK_KEYPAD 0x44 -#define SPCE_COMMAND_UNLOCK_KEYPAD 0x45 -#define SPCE_COMMAND_GET_ANALOG_MODE 0x50 -#define SPCE_COMMAND_SET_ANALOG_MODE 0x51 -#define SPCE_COMMAND_IS_HIGH_VOLTAGE_ON 0x61 -#define SPCE_COMMAND_SET_SERIAL_ADDRESS 0x62 /* not implemented */ -#define SPCE_COMMAND_SET_HV_AUTORECOVERY 0x68 -#define SPCE_COMMAND_GET_HV_AUTORECOVERY 0x69 -#define SPCE_COMMAND_SET_FIRMWARE_UPDATE 0x8f /* not implemented */ -#define SPCE_COMMAND_SET_COMM_MODE 0xd3 -#define SPCE_COMMAND_GET_COMM_MODE 0xd4 -#define SPCE_COMMAND_GETSET_SERIAL_COMM 0x46 /* not implemented */ -#define SPCE_COMMAND_GETSET_ETHERNET_IP 0x47 /* not implemented */ -#define SPCE_COMMAND_GETSET_ETHERNET_MASK 0x48 /* not implemented */ -#define SPCE_COMMAND_GETSET_ETHERNET_GTWY 0x49 /* not implemented */ -#define SPCE_COMMAND_GET_ETHERNET_MAC 0x4a /* not implemented */ -#define SPCE_COMMAND_SET_COMM_INTERFACE 0x4b -#define SPCE_COMMAND_INITIATE_FEA 0x4c /* not implemented */ -#define SPCE_COMMAND_GET_FEA_DATA 0x4d /* not implemented */ -#define SPCE_COMMAND_INITIATE_HIPOT 0x52 /* not implemented */ -#define SPCE_COMMAND_GETSET_HIPOT_TARGET 0x53 /* not implemented */ -#define SPCE_COMMAND_GETSET_FOLDBACK_VOLTS 0x54 /* not implemented */ -#define SPCE_COMMAND_GETSET_FOLDBACK_PRES 0x55 /* not implemented */ -#define SPCE_COMMAND_MAX 0x92 - -/* Spce error codes */ -#define SPCE_ERROR_CODE0 -500 -#define SPCE_ERROR_BAD_COMMAND_CODE SPCE_ERROR_CODE0 - 1 -#define SPCE_ERROR_UNKNOWN_COMMAND_CODE SPCE_ERROR_CODE0 - 2 -#define SPCE_ERROR_BAD_CHECKSUM SPCE_ERROR_CODE0 - 3 -#define SPCE_ERROR_TIMEOUT SPCE_ERROR_CODE0 - 4 -#define SPCE_ERROR_UNKNOWN_ERROR SPCE_ERROR_CODE0 - 6 -#define SPCE_ERROR_COMM_ERROR SPCE_ERROR_CODE0 - 7 -#define SPCE_ERROR_OPEN_PORT SPCE_ERROR_CODE0 - 10 -#define SPCE_ERROR_CLOSE_PORT SPCE_ERROR_CODE0 - 11 -#define SPCE_ERROR_CONFIG_PORT SPCE_ERROR_CODE0 - 12 -#define SPCE_ERROR_WRITE_COMMAND SPCE_ERROR_CODE0 - 13 -#define SPCE_ERROR_READ_COMMAND SPCE_ERROR_CODE0 - 14 -#define SPCE_ERROR_INVALID_RESPONSE SPCE_ERROR_CODE0 - 15 -#define SPCE_ERROR_BAD_RESPONSE_CHECKSUM SPCE_ERROR_CODE0 - 16 -#define SPCE_ERROR_VALUE_OUT_OF_RANGE SPCE_ERROR_CODE0 - 17 - -#define SPCE_ERROR_MAX 18 - -/* Spce display codes */ -#define SPCE_DISPLAY_CODE0 -400 -#define SPCE_DISPLAY_COOLDOWN_CYCLES SPCE_DISPLAY_CODE0 - 1 -#define SPCE_DISPLAY_VACUUM_LOSS SPCE_DISPLAY_CODE0 - 2 -#define SPCE_DISPLAY_SHORT_CIRCUIT SPCE_DISPLAY_CODE0 - 3 -#define SPCE_DISPLAY_EXCESS_PRESSURE SPCE_DISPLAY_CODE0 - 4 -#define SPCE_DISPLAY_PUMP_OVERLOAD SPCE_DISPLAY_CODE0 - 5 -#define SPCE_DISPLAY_SUPPLY_POWER SPCE_DISPLAY_CODE0 - 6 -#define SPCE_DISPLAY_START_UNDER_VOLTAGE SPCE_DISPLAY_CODE0 - 7 -#define SPCE_DISPLAY_PUMP_IS_ARCING SPCE_DISPLAY_CODE0 - 10 -#define SPCE_DISPLAY_THERMAL_RUNAWAY SPCE_DISPLAY_CODE0 - 12 -#define SPCE_DISPLAY_UNKNOWN_ERROR SPCE_DISPLAY_CODE0 - 19 -#define SPCE_DISPLAY_SAFE_CONN_INTERLOCK SPCE_DISPLAY_CODE0 - 20 -#define SPCE_DISPLAY_HVE_INTERLOCK SPCE_DISPLAY_CODE0 - 21 -#define SPCE_DISPLAY_SET_PUMP_SIZE SPCE_DISPLAY_CODE0 - 22 -#define SPCE_DISPLAY_CALIBRATION_NEEDED SPCE_DISPLAY_CODE0 - 23 -#define SPCE_DISPLAY_RESET_REQUIRED SPCE_DISPLAY_CODE0 - 24 -#define SPCE_DISPLAY_TEMPERATURE_WARNING SPCE_DISPLAY_CODE0 - 25 -#define SPCE_DISPLAY_SUPPLY_OVERHEAT SPCE_DISPLAY_CODE0 - 26 -#define SPCE_DISPLAY_CURRENT_LIMITED SPCE_DISPLAY_CODE0 - 27 -#define SPCE_DISPLAY_INTERNAL_BUS_ERROR SPCE_DISPLAY_CODE0 - 30 -#define SPCE_DISPLAY_HV_CONTROL_ERROR SPCE_DISPLAY_CODE0 - 31 -#define SPCE_DISPLAY_CURRENT_CONTROL_ERROR SPCE_DISPLAY_CODE0 - 32 -#define SPCE_DISPLAY_CURRENT_MEASURE_ERROR SPCE_DISPLAY_CODE0 - 33 -#define SPCE_DISPLAY_VOLTAGE_CONTROL_ERROR SPCE_DISPLAY_CODE0 - 34 -#define SPCE_DISPLAY_VOLTAGE_MEASURE_ERROR SPCE_DISPLAY_CODE0 - 35 -#define SPCE_DISPLAY_POLARITY_MISMATCH SPCE_DISPLAY_CODE0 - 36 -#define SPCE_DISPLAY_HV_NOT_INSTALLED SPCE_DISPLAY_CODE0 - 37 -#define SPCE_DISPLAY_INPUT_VOLTAGE_ERROR SPCE_DISPLAY_CODE0 - 38 - -#define SPCE_DISPLAY_MAX 48 - -/* Spce data response length */ -#define SPCE_PRESSURE_DATA_SIZE 13 -#define SPCE_RESPONSE_DATA_SIZE 13 - -#ifdef CREATOR -char *SpceErrMsg[] = { - NULL, - "SPCe Error (01): Command code/format is not correct, semantics is wrong.", - "SPCe Error (02): Command code not recognized, does not exist.", - "SPCe Error (03): Bad checksum.", - "SPCe Error (04): Command timeout.", - NULL, - "SPCe Error (06): Firmware encountered an unknown error.", - "SPCe Error (07): Communication error, zero characters recieved.", - NULL, - NULL, - "SPCe Error (10): Socket port open error.", - "SPCe Error (11): Socket port close error.", - "SPCe Error (12): Socket port configuration error.", - "SPCe Error (13): Socket port write error.", - "SPCe Error (14): Socket port read error.", - "SPCe Error (15): Invalid response.", - "SPCe Error (16): Bad response checksum.", - "SPCe Error (17): Value out of range.", -NULL -}; -#else -extern char *SpceErrMsg[]; -#endif - -#ifdef CREATOR -char *SpceDspMsg[] = { - NULL, - "SPCe Error (01): Too many cooldown cycles (>3) occured during pump starting.", - "SPCe Error (02): The voltage dropped below 1200V while pump was running.", - "SPCe Error (03): Short circuit condition has been detected during pump starting.", - "SPCe Error (04): Excessive pressure condition detected. Pressure greater than 1.0e-4 Torr detected.", - "SPCe Error (05): Too much power delivered to the pump for the given pump size.", - "SPCe Error (06): Supply output power detected greater than 50W.", - "SPCe Error (07): The voltage did not reach 2000V within the maximum pump starting time of 5 minutes.", - NULL, - NULL, - "SPCe Error (10): Arcing detected.", - NULL, - "SPCe Error (12): Significant drop in voltage detected during pump starting.", - NULL, - NULL, - NULL, - NULL, - NULL, - NULL, - "SPCe Error (19): Unknown Error.", - "SPCe Error (20): Safety interlock connection is not detected. Check safe-conn connection.", - "SPCe Error (21): HVE interlock set or HVE Signal off.", - "SPCe Error (22): Pump size is not set.", - "SPCe Error (23): Supply calibration has not been performed. Required for accurate current/pressure readings.", - "SPCe Error (24): Supply calibration parameters are outside normal ranges. System reset will clear all paramters to factory defaults.", - "SPCe Error (25): Supply internal temperature is past the threshold.", - "SPCe Error (26): Supply internal temperature too high. HV operation is disabled.", - "SPCe Error (27): Supply current is limited. The limit is set by programming the pump size or manually by the user.", - NULL, - NULL, - "SPCe Error (30): Internal data bus error detected.", - "SPCe Error (31): Supply HV control mechanism malfunctioning. On/Off state is malfunctioning.", - "SPCe Error (32): Supply current control mechanism malfunctioning.", - "SPCe Error (33): Supply current measuring mechanism malfunctioning.", - "SPCe Error (34): Supply HV control mechanism malfunctioning. Voltage output level is malfunctioning.", - "SPCe Error (35): Supply voltage measuring mechanism malfunctioning.", - "SPCe Error (36): Internal boards polarity mismatch.", - "SPCe Error (37): HV module missing.", - "SPCe Error (38): Input power voltage outside 22-26VDC range. HV operation disabled.", - NULL, - "SPCe Error (40): Socket port open error.", - "SPCe Error (41): Socket port close error.", - "SPCe Error (42): Socket port configuration error.", - "SPCe Error (43): Socket port write error.", - "SPCe Error (44): Socket port read error.", -NULL -}; -#else -extern char *SpceDspMsg[]; -#endif - -/* function prototypes */ -int spce_read_version(char *port, char *version); -int spce_reset(char *port); -int spce_set_arc_detect(char *port, int yesno); -int spce_get_arc_detect(char *port, int *yesno); -int spce_read_current(char *port, float *outcurrent); -int spce_read_pressure(char *port, float *outpressure); -int spce_read_voltage(char *port, int *outvoltage); -int spce_set_units(char *port, int units); -int spce_get_pump_size(char *port, int *outsize); -int spce_set_pump_size(char *port, int size); -int spce_get_cal_factor(char *port, float *outcalfact); -int spce_set_cal_factor(char *port, float calfact); -int spce_set_auto_restart(char *port, int yesno); -int spce_get_auto_restart(char *port, int *yesno); -int spce_pump_start(char *port); -int spce_pump_stop(char *port); -int spce_lock_keypad(char *port, int lock); -int spce_get_analog_mode(char *port, int *outmode); -int spce_set_analog_mode(char *port, int mode); -int spce_high_voltage_on(char *port, int *yesno); -int spce_set_hv_autorecovery(char *port, int mode); -int spce_get_hv_autorecovery(char *port, int *outmode); -int spce_set_comm_mode(char *port, int mode); -int spce_get_comm_mode(char *port, int *outmode); -int spce_set_comm_interface(char *port, int interface); -int spce_send_command(char *port, char *cmd); -int spce_send_request(char *port, char *cmd, char *response); -int spce_create_command_string(char *outstring, int bus_address, - int command_code, char *command_data, - int do_checksum); -int spce_validate_response(char *response, int command_code); -float getFloatFromSpceResponse(char *response); -int getStringFromSpceResponse(char *response, char *outstring); -float getIntFromSpceResponse(char *response); - -#endif /* _KPRS_SPCE_H */ diff --git a/src/hispec/util/gammavac/SPCe.py b/src/hispec/util/gammavac/SPCe.py deleted file mode 100644 index 00eaef9..0000000 --- a/src/hispec/util/gammavac/SPCe.py +++ /dev/null @@ -1,503 +0,0 @@ -"""Gamma Vacuum SPCe model utility functions.""" -import errno -import time -import socket -import re -from typing import Union - -from hardware_device_base import HardwareDeviceBase - -# Constants (partial, extend as needed) -SPCE_TIME_BETWEEN_COMMANDS = 0.12 - -# Command codes (extend as needed) -SPCE_COMMAND_READ_MODEL = 0x01 -SPCE_COMMAND_READ_VERSION = 0x02 -SPCE_COMMAND_RESET = 0x07 -SPCE_COMMAND_SET_ARC_DETECT = 0x91 -SPCE_COMMAND_GET_ARC_DETECT = 0x92 -SPCE_COMMAND_READ_CURRENT = 0x0A -SPCE_COMMAND_READ_PRESSURE = 0x0B -SPCE_COMMAND_READ_VOLTAGE = 0x0C -SPCE_COMMAND_GET_PUMP_STATUS = 0x0D -SPCE_COMMAND_SET_PRESS_UNITS = 0x0E -SPCE_COMMAND_GET_PUMP_SIZE = 0x11 -SPCE_COMMAND_SET_PUMP_SIZE = 0x12 -SPCE_COMMAND_GET_CAL_FACTOR = 0x1D -SPCE_COMMAND_SET_CAL_FACTOR = 0x1E -SPCE_COMMAND_SET_AUTO_RESTART = 0x33 -SPCE_COMMAND_GET_AUTO_RESTART = 0x34 -SPCE_COMMAND_START_PUMP = 0x37 -SPCE_COMMAND_STOP_PUMP = 0x38 -SPCE_COMMAND_LOCK_KEYPAD = 0x44 -SPCE_COMMAND_UNLOCK_KEYPAD = 0x45 -SPCE_COMMAND_GET_ANALOG_MODE = 0x50 -SPCE_COMMAND_SET_ANALOG_MODE = 0x51 -SPCE_COMMAND_IS_HIGH_VOLTAGE_ON = 0x61 -SPCE_COMMAND_SET_HV_AUTORECOVERY = 0x68 -SPCE_COMMAND_GET_HV_AUTORECOVERY = 0x69 -SPCE_COMMAND_SET_COMM_MODE = 0xD3 -SPCE_COMMAND_GET_COMM_MODE = 0xD4 -SPCE_COMMAND_SET_COMM_INTERFACE = 0x4B - -SPCE_UNITS_TORR = 'T' -SPCE_UNITS_MBAR = 'M' -SPCE_UNITS_PASCAL = 'P' - - -class SpceController(HardwareDeviceBase): - """Class to control a Lesker GAMMA gauge SPCe controller over a TCP socket.""" - # pylint: disable=too-many-public-methods - - def __init__(self, bus_address: int =1, simulate: bool =False, - log: bool =True, logfile: str = __name__.rsplit(".", 1)[-1] ) -> None: - """Initialize the SpceController. - - Args: - bus_address (str): bus address of the controller (00 - FF). - simulate (bool): If True, simulate communication. - log (bool): If True, log outputs. - logfile (str): If specified, write logs to this file. - - NOTE; default is INFO level logging, use set_verbose to increase verbosity. - """ - super().__init__(log, logfile) - - # Bus address - self.bus_address = bus_address - - # Set up socket - self.sock = None - - # Simulate mode - if simulate: - self.simulate = True - self.logger.info("Simulate mode enabled.") - else: - self.simulate = False - - def connect(self, *args, con_type="tcp") -> None: - """Connect to the controller. - - :param args: for tcp connection, host and port, for serial, port and baudrate - :param con_type: tcp or serial - """ - if self.validate_connection_params(args): - if self.simulate: - self.connected = True - self.logger.info('Connected to SPCe simulator.') - else: - if con_type == "tcp": - host = args[0] - port = args[1] - if self.sock is None: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.sock.connect((host, port)) - self._set_connected(True) - self.logger.info("Connected to SPCe controller at %s:%d bus %d", - host, port, self.bus_address) - except OSError as e: - if e.errno == errno.EISCONN: - self.logger.debug("Already connected") - self._set_connected(True) - else: - self.logger.error("Connection error: %s", e.strerror) - self._set_connected(False) - if self.connected: - self._clear_socket() - elif con_type == "serial": - self.logger.error("Serial connection not implemented.") - self._set_connected(False) - else: - self.logger.error("Unknown con_type: %s", con_type) - self._set_connected(False) - else: - self.logger.error("Invalid connection args: %s", args) - self._set_connected(False) - - def disconnect(self) -> None: - """Disconnect from the controller.""" - if self.simulate: - self.logger.info('Disconnected from SPCe simulator.') - self.connected = False - else: - try: - self.sock.shutdown(socket.SHUT_RDWR) - self.sock.close() - self._set_connected(False) - self.sock = None - self.logger.info("Disconnected from SPCe controller") - except OSError as e: - self.logger.error("Disconnection error: %s", e.strerror) - self._set_connected(False) - self.sock = None - - def _clear_socket(self): - """ Clear socket buffer. """ - if self.sock is not None: - self.sock.setblocking(False) - while True: - try: - _ = self.sock.recv(1024) - except BlockingIOError: - break - self.sock.setblocking(True) - self.sock.settimeout(2.0) - - def _send_command(self, command: str, *args) -> bool: - """Send a command without expecting a response. - Args: - command (str): command to send. - """ - if not self.is_connected: - self.logger.error("Not connected to SPCe controller.") - return False - - self.logger.debug("Sending command %s", command) - if self.simulate: - print(f"[SIM SEND] {command}") - return True - with self.lock: - self.sock.sendall(command.encode('utf-8')) - time.sleep(SPCE_TIME_BETWEEN_COMMANDS) - return True - - def _read_reply(self) -> Union[str, None]: - """Read a reply from the controller.""" - if not self.is_connected: - self.logger.error("Not connected to SPCe controller.") - return None - try: - reply = self.sock.recv(1024).decode('utf-8').strip() - self.logger.debug("Received reply %s", reply) - return reply - except Exception as ex: - raise IOError(f"Failed to _read_reply message: {ex}") from ex - - def _send_request(self, command: str, response_type: str ="S") -> Union[int, float, str]: - """Send a command and receive a response. - Args: - command (str): Command to send. - response_type (str): Type of response: - 'I' for int, 'S' for str (default), 'F' for float. - """ - if not self.connected: - self.logger.error("Not connected to SPCe controller.") - return "NOT CONNECTED" - - self.logger.debug("Sending request %s", command) - if self.simulate: - print(f"[SIM REQ] {command}") - return "SIM_RESPONSE" - with self.lock: - self.sock.sendall(command.encode('utf-8')) - time.sleep(SPCE_TIME_BETWEEN_COMMANDS) - try: - recv = self.sock.recv(1024) - recv_len = len(recv) - self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) - except socket.timeout: - self.logger.error("Timeout while waiting for response") - return "TIMEOUT" - retval = str(recv.decode('utf-8')).strip() - if self.validate_response(retval): - response_type = response_type.upper() - if response_type == "F": - retval = extract_float_from_response(retval) - elif response_type == "I": - retval = extract_int_from_response(retval) - else: - retval = extract_string_from_response(retval) - return retval - return "NOT VALID" - - def create_command(self, code, data=None): - """Create a properly formatted command string. - - Args: - code (int): Command code. - data (str): Command data. - - This function creates a command string to be passed to - the SPCe vacuum controller. See SPCe vacuum SPCe controller user manual - from gammavacuum.com for details. - - Commands use this format: - {attention char} {bus_address} {command code} {data} {termination} - ~ ba cc data \r - - With - ba = address value between 01 and FF. - cc = character string representing command (2 bytes). - data = optional value for command (e.g. baud rate, adress setting, etc.). - """ - - command = f" {self.bus_address:02X} {code:02X} " - if data: - command += f"{data} " - - chksm = sum(ord(c) for c in command) % 256 - - command = f"~{command}{chksm:02X}\r" - return command - - def validate_response(self, response: str) -> int: - """ - Validate the response string from a serial device. - - Args: - response (str): The raw response string from the device. - - Returns: - int: 0 if valid, or an error code. - """ - # pylint: disable=too-many-branches - - try: - # The First field must be the bus address - bus = int(response.split()[0]) - except (ValueError, IndexError): - self.logger.error("Invalid response from device.") - return False - - if bus != self.bus_address: - self.logger.error("Invalid bus address from device.") - return False - - # Now check for error condition or valid response - substr = response[3:] - - if substr.startswith("ER"): - try: - error_code_str = substr[3:] - self.logger.error(error_code_str) - except ValueError: - pass - return False - - # Calculate and verify checksum - offset = len(response) - 3 - try: - rcksm = int(response[offset:], 16) # Read hex checksum to decimal - except ValueError: - self.logger.error("Unable to read checksum from device.") - return False - - # Calculate checksum (sum of all chars before checksum, mod 256) - cksm = sum(ord(c) for c in response[:offset+1]) % 256 - - if rcksm != cksm: - self.logger.error("Invalid checksum from device.") - return False - - return True - - # --- Command Methods --- - def get_atomic_value(self, item: str ="") -> Union[float, int, str, None]: - """Get an atomic telemetry value.""" - if item == "pressure": - value = self.read_pressure() - elif item == "current": - value = self.read_current() - elif item == "voltage": - value = self.read_voltage() - else: - self.logger.error("Invalid item from device.") - value = None - return value - - def read_model(self): - """Read the model from the controller.""" - return self._send_request(self.create_command(SPCE_COMMAND_READ_MODEL)) - - def read_version(self): - """Read the firmware version from the controller.""" - return self._send_request(self.create_command(SPCE_COMMAND_READ_VERSION)) - - def reset(self): - """Send a reset command to the controller.""" - return self._send_command(self.create_command(SPCE_COMMAND_RESET)) - - def set_arc_detect(self, enable): - """Enable or disable arc detection.""" - val = "YES" if enable else "NO" - return self._send_request(self.create_command(SPCE_COMMAND_SET_ARC_DETECT, val)) - - def get_arc_detect(self): - """Get the current arc detection setting.""" - return self._send_request(self.create_command(SPCE_COMMAND_GET_ARC_DETECT)) - - def read_current(self): - """Read the emission current.""" - return self._send_request( - self.create_command(SPCE_COMMAND_READ_CURRENT), "F") - - def read_pressure(self): - """Read the pressure value.""" - return self._send_request( - self.create_command(SPCE_COMMAND_READ_PRESSURE), "F") - - def read_voltage(self): - """Read the ion gauge voltage.""" - return self._send_request( - self.create_command(SPCE_COMMAND_READ_VOLTAGE), "F") - - def set_units(self, unit_char): - """Set the pressure display units. - - Args: - unit_char (str): One of 'T', 'M', or 'P'. - """ - unit_char = unit_char.upper() - if unit_char not in [SPCE_UNITS_TORR, SPCE_UNITS_MBAR, SPCE_UNITS_PASCAL]: - raise ValueError("Invalid unit. Use 'T', 'M', or 'P'.") - return self._send_request(self.create_command(SPCE_COMMAND_SET_PRESS_UNITS, unit_char)) - - def get_pump_status(self): - """Get the pump status.""" - return self._send_request( - self.create_command(SPCE_COMMAND_GET_PUMP_STATUS)) - - def get_pump_size(self): - """Get the configured pump size.""" - return self._send_request( - self.create_command(SPCE_COMMAND_GET_PUMP_SIZE), "I") - - def set_pump_size(self, size): - """Set the pump size. - - Args: - size (int): Pump size (0-9999). - """ - if not 0 <= size <= 9999: - raise ValueError("Pump size out of range (0-9999).") - return self._send_request(self.create_command(SPCE_COMMAND_SET_PUMP_SIZE, f"{size:04d}")) - - def get_cal_factor(self): - """Get the calibration factor.""" - return self._send_request( - self.create_command(SPCE_COMMAND_GET_CAL_FACTOR), "F") - - def set_cal_factor(self, factor): - """Set the calibration factor. - - Args: - factor (float): Calibration factor (0.00 to 9.99). - """ - if not 0.0 <= factor <= 9.99: - raise ValueError("Calibration factor out of range (0.00 - 9.99).") - return self._send_request(self.create_command( - SPCE_COMMAND_SET_CAL_FACTOR, f"{factor:.2f}")) - - def set_auto_restart(self, enable: bool): - """Enable or disable auto restart.""" - val = "YES" if enable else "NO" - return self._send_request(self.create_command(SPCE_COMMAND_SET_AUTO_RESTART, val)) - - def get_auto_restart(self): - """Get the auto restart setting.""" - return self._send_request(self.create_command(SPCE_COMMAND_GET_AUTO_RESTART)) - - def start_pump(self): - """Start the pump.""" - return self._send_request(self.create_command(SPCE_COMMAND_START_PUMP)) - - def stop_pump(self): - """Stop the pump.""" - return self._send_request(self.create_command(SPCE_COMMAND_STOP_PUMP)) - - def lock_keypad(self, lock): - """Lock or unlock the controller keypad.""" - cmd = SPCE_COMMAND_LOCK_KEYPAD if lock else SPCE_COMMAND_UNLOCK_KEYPAD - return self._send_request(self.create_command(cmd)) - - def get_analog_mode(self): - """Get the analog output mode.""" - return self._send_request( - self.create_command(SPCE_COMMAND_GET_ANALOG_MODE), "I") - - def set_analog_mode(self, mode: int): - """Set the analog output mode. - - Args: - mode (int): Analog mode (0-6, 8-10). - """ - if mode not in list(range(0, 7)) + [8, 9, 10]: - raise ValueError("Invalid analog mode. Must be 0-6 or 8-10.") - return self._send_request(self.create_command(SPCE_COMMAND_SET_ANALOG_MODE, str(mode))) - - def high_voltage_on(self): - """Check if high voltage is on.""" - return self._send_request(self.create_command(SPCE_COMMAND_IS_HIGH_VOLTAGE_ON)) - - def set_hv_autorecovery(self, mode: int): - """Set HV autorecovery mode. - - Args: - mode (int): Mode (0-2). - """ - if mode not in [0, 1, 2]: - raise ValueError("Invalid HV autorecovery mode (0-2).") - return self._send_request(self.create_command(SPCE_COMMAND_SET_HV_AUTORECOVERY, str(mode))) - - def get_hv_autorecovery(self): - """Get the HV autorecovery setting.""" - return self._send_request( - self.create_command(SPCE_COMMAND_GET_HV_AUTORECOVERY)) - - def set_comm_mode(self, mode): - """Set the communication mode. - - Args: - mode (int): Communication mode (0-2). - """ - if mode not in [0, 1, 2]: - raise ValueError("Invalid communication mode (0-2).") - return self._send_request(self.create_command(SPCE_COMMAND_SET_COMM_MODE, str(mode))) - - def get_comm_mode(self): - """Get the communication mode.""" - return self._send_request( - self.create_command(SPCE_COMMAND_GET_COMM_MODE), "I") - - def set_comm_interface(self, interface): - """Set the communication interface. - - Args: - interface (int): Interface index (0-5). - """ - if interface not in range(6): - raise ValueError("Invalid communication interface (0-5).") - return self._send_request(self.create_command( - SPCE_COMMAND_SET_COMM_INTERFACE, str(interface))) - -def extract_float_from_response(response): - """Extract a float value from the response string.""" - response = response.split("OK 00 ")[-1].split()[0] - try: - match = re.search(r"([-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?)", response) - return float(match.group(1)) if match else None - except ValueError: - return None - -def extract_int_from_response(response): - """Extract an integer value from the response string.""" - response = response.split("OK 00 ")[-1].split()[0] - try: - match = re.search(r"([-+]?[0-9]+)", response) - return int(match.group(1)) if match else None - except ValueError: - return None - -def extract_string_from_response(response): - """Extract a string value from a key=value response.""" - response = " ".join(response.split("OK 00 ")[-1].split()[:-1]) - try: - parts = response.split(',') - for part in parts: - if '=' in part: - return part.split('=')[1].strip() - return response.strip() - except ValueError: - return response.strip() diff --git a/src/hispec/util/gammavac/__init__.py b/src/hispec/util/gammavac/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/gammavac/pyproject.toml b/src/hispec/util/gammavac/pyproject.toml deleted file mode 100644 index 9967cdb..0000000 --- a/src/hispec/util/gammavac/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "gammavac" -version = "0.1.0" -description = "Gamma Vacuum controller software" -authors = [ - { name="Michael Langmayr", email="langmayr@caltech.edu" }, - { name="Don Neill", email="neill@astro.caltech.edu" }, - { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } -] -readme = "README.md" -requires-python = ">=3.7" -dependencies = [ - "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" -] -[tool.pytest.ini_options] -pythonpath = [ - "." -] diff --git a/src/hispec/util/gammavac/tests/test_basic.py b/src/hispec/util/gammavac/tests/test_basic.py deleted file mode 100644 index 5067f5b..0000000 --- a/src/hispec/util/gammavac/tests/test_basic.py +++ /dev/null @@ -1,14 +0,0 @@ -"""Perform basic tests.""" -import pytest -from SPCe import SpceController - -def test_initialization(): - """Test initialization.""" - controller = SpceController() - assert not controller.connected - -def test_connection_fail(): - """Test connection failure.""" - controller = SpceController() - controller.connect(host="127.0.0.1", port=50000) - assert not controller.connected diff --git a/src/hispec/util/helper/__init__.py b/src/hispec/util/helper/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/helper/logger_utils.py b/src/hispec/util/helper/logger_utils.py deleted file mode 100644 index a14d936..0000000 --- a/src/hispec/util/helper/logger_utils.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Set up logging helper module.""" -import logging -import sys - - -def setup_logger(name: str, log_file: str = None, level=logging.DEBUG, - quiet: bool = False) -> logging.Logger: - """Setup logger with given name.""" - logger = logging.getLogger(name) - logger.setLevel(level) - - # Prevent adding multiple handlers (important for reimporting) - if logger.handlers: - return logger - - formatter = logging.Formatter( - "%(asctime)s--%(name)s--%(levelname)s--%(message)s" - ) - - # File handler (optional) - if log_file: - file_handler = logging.FileHandler(log_file) - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - - # Console handler (unless quiet) - if not quiet: - console_formatter = logging.Formatter("%(asctime)s--%(message)s") - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setFormatter(console_formatter) - logger.addHandler(console_handler) - - return logger diff --git a/src/hispec/util/inficon/.gitignore b/src/hispec/util/inficon/.gitignore deleted file mode 100644 index 1681eb4..0000000 --- a/src/hispec/util/inficon/.gitignore +++ /dev/null @@ -1,165 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ - -# MacOS -.DS_Store diff --git a/src/hispec/util/inficon/README.md b/src/hispec/util/inficon/README.md deleted file mode 100644 index ba96afc..0000000 --- a/src/hispec/util/inficon/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# InficonVGC502 - -A Python 3 module to communicate with an INFICONVGC502 controller over TCP. -It supports reading pressure values from one or more gauges. - -## Currently Supported Models -- VGC501, VGC502 - -## Features -- Connect to VGC controller -- Read out the pressure - -## 🛠️ Requirements - -- Python 3.7+ -- Install base class from https://github.com/COO-Utilities/hardware_device_base - -## Installation - -```bash -pip install . -``` - -## 🧪 Running from a Python Terminal - -You can also use the `INFICON` module interactively from a Python terminal or script: - -```python -from inficonvgc502 import InficonVGC502 - -vgc502 = InficonVGC502() -vgc502.initialize() -pressure = vgc502.get_atomic_value("pressure1") # must have gauge number -print(f"Pressure: {pressure} Torr") -``` - -## Testing -Unit tests are in the `tests/` directory. - -To run all tests from the projecgt root: - -```bash -python -m pytest -``` \ No newline at end of file diff --git a/src/hispec/util/inficon/__init__.py b/src/hispec/util/inficon/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/inficon/inficonvgc502.py b/src/hispec/util/inficon/inficonvgc502.py deleted file mode 100644 index a323c1e..0000000 --- a/src/hispec/util/inficon/inficonvgc502.py +++ /dev/null @@ -1,336 +0,0 @@ -""" -Inficon VGC502 Controller Interface -""" -import sys -import socket -from errno import EISCONN -from typing import Union - -from hardware_device_base import HardwareDeviceBase - - -class InficonVGC502(HardwareDeviceBase): - """Class for interfacing with InficonVGC502""" - # pylint: disable=too-many-instance-attributes - - UNIT_CODES = ("mbar", "Torr", "Pascal", "Micron", "hPascal", "Volt") - - def __init__(self, log: bool=True, logfile: str =__name__.rsplit('.', 1)[-1], - timeout: int=1): - """Initialize the InficonVGC502 class. - Args: - log (bool): If True, start logging. - logfile (str, optional): Path to log file. - timeout (int, optional): Timeout in seconds. - """ - super().__init__(log, logfile) - self.timeout = timeout - self.type = "" - self.model = "" - self.serial_number = None - self.firmware_version = "" - self.hardware_version = "" - self.pressure_units = "" - self.n_gauges = 0 - self.sock: socket.socket | None = None - - def connect(self, *args, con_type="tcp") -> None: - """ Connect to the controller. """ - if self.validate_connection_params(args): - if con_type == "tcp": - host = args[0] - port = args[1] - if self.sock is None: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.sock.connect((host, port)) - self.logger.info("Connected to %s:%d", host, port) - self._set_connected(True) - # ensure subsequent ops also use timeout - self.sock.settimeout(self.timeout) - - except OSError as e: - if e.errno == EISCONN: - self.logger.info("Already connected") - self._set_connected(True) - else: - self.logger.error("Connection error: %s", e.strerror) - self._set_connected(False) - if self.is_connected(): - self._clear_socket() - elif con_type == "serial": - self.logger.error("Serial connection not supported") - else: - self.logger.error("Unknown con_type: %s", con_type) - else: - self.logger.error("Invalid connection arguments: %s", args) - - def _clear_socket(self): - """Clear the socket connection.""" - if self.sock: - self.sock.setblocking(False) - while True: - try: - _ = self.sock.recv(1024) - except BlockingIOError: - break - self.sock.setblocking(True) - - def disconnect(self): - """Close the TCP connection.""" - try: - self.logger.debug("Closing connection to controller") - if self.sock: - self.sock.close() - self._set_connected(False) - except Exception as ex: - raise IOError(f"Failed to close connection: {ex}") from ex - - def _send_command(self, command: str, *args) -> bool: - """ - Send a command to the controller. - - :param command: (str) Command to send. - :param args: Arguments to send. - :return: True on success, False on failure. - """ - try: - self.logger.debug("Sending command: %s", command) - command += "\r\n" - with self.lock: - self.sock.sendall(command.encode()) - except Exception as ex: - self.logger.error("Failed to send command: %s", ex) - return False - # raise IOError(f"Failed to send command: {ex}") from ex - return True - - def _send_enq(self): - """Send ENQ to the controller.""" - try: - self.logger.debug("Sending ENQ to controller") - with self.lock: - self.sock.sendall(b"\x05") - except Exception as ex: - self.logger.error("Failed to send ENQ: %s", ex) - return False - # raise IOError(f"Failed to send ENQ: {ex}") from ex - return True - - def _read_until(self, terminator: bytes = b"\r\n", max_bytes: int = 4096) -> bytes: - """Read until 'terminator' or timeout. Returns bytes including the terminator.""" - buf = bytearray() - try: - while True: - chunk = self.sock.recv(1) - if not chunk: - # peer closed - break - buf += chunk - if buf.endswith(terminator): - break - if len(buf) >= max_bytes: - break - # self.logger.debug("Input buffer: %r", buf) - return bytes(buf) - except Exception as ex: - raise IOError(f"Failed to _read_reply: {ex}") from ex - - def _read_reply(self) -> Union[str, None]: - """Read reply from controller.""" - try: - ack = self._read_until(b"\r\n").strip() - self.logger.debug("Reply received: %r", ack) - except socket.timeout: - return None - - # ACK received - if ack == b"\x06": - self.logger.debug("ACK received, sending ENQ") - try: - if self._send_enq(): - response = self._read_until(b"\r\n").decode().strip() - else: - self.logger.error("Error sending ENQ") - return None - except socket.timeout: - return None - except OSError as e: - self.logger.error("IO error while receiving response: %s", e) - return None - - self.logger.debug("Response received: %s", response) - return response - - if ack == b'\x15': - self.logger.error("NAK received, try command again.") - else: - self.logger.error("ACK NOT received") - return None - - def initialize(self): - """Initialize the controller.""" - self.logger.debug("Initializing controller") - if self._send_command("UNI"): - unit_code = int(self._read_reply()) - if 0 <= unit_code <= 5: - self.pressure_units = self.UNIT_CODES[unit_code] - if self._send_command("AYT"): - devinfo = self._read_reply() - dev_items = devinfo.split(",") - if len(dev_items) == 5: - self.type = dev_items[0] - self.model = dev_items[1] - self.serial_number = int(dev_items[2]) - self.firmware_version = dev_items[3] - self.hardware_version = dev_items[4] - try: - self.n_gauges = int(self.type[-1]) - except ValueError: - self.logger.error("Invalid gauge type, unable to parse n_gauges: %s", self.type) - self.n_gauges = 0 - else: - self.logger.error("Error initializing controller: %s", devinfo) - else: - self.logger.error("Failed to initialize controller") - - def set_pressure_unit(self, unit_code: int =1) -> bool: - """ Set the pressure units - :param unit_code: (int) Pressure unit code - :return: True on success, False on failure. - - Codes: 0 - mbar, 1 - Torr, 2 - Pascal, 3 - Micron, 4 - hPascal, 5 - Volt - """ - retval = False - if unit_code < 0 or unit_code > 5: - self.logger.error("Invalid pressure unit code: %s\nMust be between 0 and 5 inclusive", - unit_code) - else: - if self._send_command(f"UNI,{unit_code}"): - received = int(self._read_reply()) - if received != unit_code: - self.logger.error("Requested pressure unit code not achieved: %d", received) - else: - retval = True - if 0 <= received <= 5: - self.pressure_units = self.UNIT_CODES[received] - else: - self.logger.error("Invalid pressure unit received: %d", received) - else: - retval = False - return retval - - def get_pressure_unit(self) -> int: - """ Get the pressure units""" - if self._send_command("UNI"): - received = int(self._read_reply()) - self.pressure_units = self.UNIT_CODES[received] - else: - received = None - return received - - def read_temperature(self) -> float: - """ Read temperature from controller.""" - command = "TMP" - try: - self._send_command(command) - except DeviceConnectionError: - self.logger.error("Connection error: %s", command) - raise - except OSError as e: - self.logger.error("Failed to send command: %s", e) - raise DeviceConnectionError("Write failed") from e - - response = self._read_reply() - self.logger.debug("Temperature response: %s", response) - try: - value = float(response) - return value - except ValueError as e: - self.logger.error("Failed to parse response: %s", e) - return sys.float_info.max - - def read_pressure(self, gauge: int = 1) -> float: - """Read pressure from gauge 1 to n. - Returns float, or sys.float_info.max on timeout/parse error.""" - # pylint: disable=too-many-branches - if self.n_gauges == 0: - self.initialize() - if not isinstance(gauge, int) or gauge < 1 or gauge > self.n_gauges: - self.logger.error("gauge number must be between 1 and %d, inclusive", self.n_gauges) - return sys.float_info.max - - # Command format: PR{gauge} - command = f"PR{gauge}" - try: - self._send_command(command) - except DeviceConnectionError: - self.logger.error("Connection error: %s", command) - raise - except OSError as e: - self.logger.error("Failed to send command: %s", e) - raise DeviceConnectionError("Write failed") from e - - # Read acknowledgment line (controller typically replies with ACK/NAK ending CRLF) - response = self._read_reply() - self.logger.debug("Pressure response: %s", response) - - # Expected like: "PR1," - try: - parts = response.split(",") - value = float(parts[1]) - return value - except (IndexError, ValueError, AttributeError) as e: - self.logger.error("Failed to parse response: %s", e) - return sys.float_info.max - - def get_atomic_value(self, item: str ="") -> float: - """ - Read the latest value of a specific channel. - - Args: - item (str): Channel name (e.g., "3A", "Out1") - - Returns: - float: Current value, or NaN if invalid. - """ - if "pressure" in item: - try: - gauge_num = int(item.split("pressure")[-1]) - value = self.read_pressure(gauge=gauge_num) - except ValueError: - self.logger.error("Invalid item: %s", item) - value = sys.float_info.max - elif "temperature" in item: - value = float(self.read_temperature()) - elif "units" in item: - self.get_pressure_unit() - value = self.pressure_units - else: - self.logger.error("Unknown item received: %r", item) - value = sys.float_info.max - return value - - def run_manually(self): - """Input commands manually.""" - while True: - cmd = input("> ") - if not cmd: - break - - if self._send_command(cmd): - ret = self._read_reply() - print(ret) - - print("End.") - -class WrongCommandError(Exception): - """Exception raised when a wrong command is sent.""" - - -class UnknownResponse(Exception): - """Exception raised when an unknown response is received.""" - - -class DeviceConnectionError(Exception): - """Exception raised when a device connection error occurs.""" diff --git a/src/hispec/util/inficon/pyproject.toml b/src/hispec/util/inficon/pyproject.toml deleted file mode 100644 index 7ccf399..0000000 --- a/src/hispec/util/inficon/pyproject.toml +++ /dev/null @@ -1,25 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "inficon" -version = "0.1.0" -description = "OneWire controller software" -authors = [ - { name="Michael Langmayr", email="langmayr@caltech.edu" }, - { name="Don Neill", email="neill@astro.caltech.edu" }, - { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } -] -readme = "README.md" -requires-python = ">=3.7" -dependencies = [ - "keyring", - "pipython", - "pyserial", - "libximc" -] -[tool.pytest.ini_options] -pythonpath = [ - "." -] diff --git a/src/hispec/util/inficon/tests/__init__.py b/src/hispec/util/inficon/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/inficon/tests/test_inficonvgc502.py b/src/hispec/util/inficon/tests/test_inficonvgc502.py deleted file mode 100644 index ede3e5f..0000000 --- a/src/hispec/util/inficon/tests/test_inficonvgc502.py +++ /dev/null @@ -1,53 +0,0 @@ -# tests/test_inficonvgc502_units_sync.py -import pytest -from unittest.mock import MagicMock - -from inficonvgc502 import InficonVGC502, UnknownResponse - - -@pytest.fixture -def vgc502(): - """Creates an InficonVGC502 instance with mock logger.""" - cont = InficonVGC502(log=False) - # cont.connect("127.0.0.1", 8000) - return cont - -def test_set_pressure_unit_success(vgc502): - # Mock low-level I/O so we don't need a real socket - vgc502._send_command = MagicMock() - # Device replies ACK (\x06\r\n) - vgc502._read_reply = MagicMock(return_value="2") - - # Pascal (example: 2) - result = vgc502.set_pressure_unit(2) - assert result is True - - # Verify the command was sent - # vgc502._send_command.assert_called_with("UNI,2\r\n") - - -def test_set_pressure_unit_invalid_value(vgc502): - - result = vgc502.set_pressure_unit(9) # out of allowed range - assert result is False - - -def test_get_pressure_unit(vgc502): - vgc502._send_command = MagicMock() - # First read: ACK to 'UNI\r\n'; Second read: the unit value line '3\r\n' - vgc502._read_reply = MagicMock(return_value="3") - - result = vgc502.get_pressure_unit() - assert result == 3 - - # Ensure UNI and ENQ were written (order matters) - # vgc502._send_command.assert_has_calls([call("UNI\r\n"), call("\x05")]) - - -def test_get_pressure_unit_invalid_response(vgc502): - vgc502._send_command = MagicMock() - # ACK followed by a non-integer value - vgc502._read_reply = MagicMock(side_effect=[b"\x06\r\n", b"X\r\n"]) - - with pytest.raises(ValueError): - vgc502.get_pressure_unit() diff --git a/src/hispec/util/lakeshore/.gitignore b/src/hispec/util/lakeshore/.gitignore deleted file mode 100644 index b7faf40..0000000 --- a/src/hispec/util/lakeshore/.gitignore +++ /dev/null @@ -1,207 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ diff --git a/src/hispec/util/lakeshore/README.md b/src/hispec/util/lakeshore/README.md deleted file mode 100644 index bc2b8ee..0000000 --- a/src/hispec/util/lakeshore/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# lakeshore_controller - -Low-level Python modules to send commands to Lakeshore 224 or 336 controllers. - -## Currently Supported Models -- 224 & 336 - lakeshore.py - -## Features -- Connect to Lakeshore controllers over ethernet -- Query sensor values -- For model 336, query status and parameters of heaters - -## Requirements - -- Install base class from https://github.com/COO-Utilities/hardware_device_base - -## Installation - -```bash -pip install . -``` - -## Usage - -```python -import lakeshore - -controller = lakeshore.LakeshoreController() # defaults to 336 -controller.connect('192.168.29.104', 7777) - -# Initialize controller -controller.initialize(celsius=False) # print temperatures in Kelvin - -# Print heater 1 status -print(controller.get_heater_status('1')) - -# Print sensor A temperature -print(controller.get_temperature('a')) - -# Print heater 2 output -print(controller.get_heater_output('2'), controller.outputs['2']['htr_display']) - -# For a comprehensive list of classes and methods, use the help function -help(lakeshore) - -``` - -## 🧪 Testing -Unit tests are located in `tests/` directory. - -To run all tests from the project root: - -```bash -pytest -``` \ No newline at end of file diff --git a/src/hispec/util/lakeshore/__init__.py b/src/hispec/util/lakeshore/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/lakeshore/lakeshore.py b/src/hispec/util/lakeshore/lakeshore.py deleted file mode 100755 index 8a53dd0..0000000 --- a/src/hispec/util/lakeshore/lakeshore.py +++ /dev/null @@ -1,424 +0,0 @@ -#! @KPYTHON3@ -""" Lakeshore 224/336 controller class """ - -from errno import ETIMEDOUT, EISCONN -import socket -import time -from typing import Union - -from hardware_device_base import HardwareDeviceBase - - -class LakeshoreController(HardwareDeviceBase): - """ Handle all correspondence with the ethernet interface of the - Lakeshore 224/336 controller. - """ - - initialized = False - revision = None - success = False - termchars = '\r\n' - - # Heater dictionaries - resistance = {'1': 25, '2': 50} - max_current = {'0': 0.0, '1': 0.707, '2': 1.0, '3': 1.141, '4': 2.0} - htr_display = {'1': 'current', '2': 'power'} - htr_errors = {'0': 'no error', '1': 'heater open load', '2': 'heater short'} - - def __init__(self, log=True, logfile=__name__.rsplit(".", 1)[-1], - opt3062=False, model336=True, celsius=True): - """ Initialize the Lakeshore controller. - :param log: If True, log to file - :param logfile: name of log file (defaults to lakeshore.log) - :param opt3062: set to True if optional 3062 board installed (defaults to False) - :param model336: set to True if controller is model 336 (default), - if False assumes model 224 - :param celsius: set to True to read temperature in Celsius (default), - """ - # pylint: disable=too-many-positional-arguments, too-many-arguments - super().__init__(log, logfile) - self.socket: socket.socket | None = None - self.host: str | None = None - self.port: int = -1 - - self.celsius = celsius - if self.celsius: - self.set_celsius() - self.logger.info("Using Celsius for temperature") - else: - self.set_kelvin() - self.logger.info("Using Kelvin for temperature") - self.model336 = model336 - - self.status = None - - if model336: - if opt3062: - self.sensors = {'A': 1, 'B': 2, 'C': 3, - 'D1': 4, 'D2': 5, 'D3': 6, 'D4': 7, 'D5': 8} - else: - self.sensors = {'A': 1, 'B': 2, 'C': 3, 'D': 4} - - self.outputs = {'1': - {'resistance': None, 'max_current': 0.0, - 'user_max_current': 0.0, 'htr_display': '', - 'status': '', 'p': 0.0, 'i': 0.0, 'd': 0.0}, - '2': - {'resistance': None, 'max_current': 0.0, - 'user_max_current': 0.0, 'htr_display': '', - 'status': '', 'p': 0.0, 'i': 0.0, 'd': 0.0}, - } - else: - # Model 224 - self.sensors = {'A': 1, 'B': 2, - 'C1': 3, 'C2': 4, 'C3': 5, 'C4': 6, 'C5': 7, - 'D1': 8, 'D2': 9, 'D3': 10, 'D4': 11, 'D5': 12} - self.outputs = None - - def disconnect(self) -> None: - """ Disconnect controller. """ - - try: - self.socket.shutdown(socket.SHUT_RDWR) - self.socket.close() - self.socket = None - self.logger.info("Disconnected controller") - self._set_connected(False) - self.success = True - - except OSError as e: - if self.logger: - self.logger.error("Disconnection error: %s", e.strerror) - self._set_connected(False) - self.socket = None - self.success = False - - self.set_status("disconnected") - - def connect(self, *args, con_type: str ="tcp") -> None: - """ Connect to controller. """ - if self.validate_connection_params(args): - if con_type == "tcp": - self.host = args[0] - self.port = args[1] - if self.socket is None: - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.socket.connect((self.host, self.port)) - self.logger.info("Connected to %(host)s:%(port)s", { - 'host': self.host, - 'port': self.port - }) - self._set_connected(True) - self.success = True - self.set_status('ready') - - except OSError as e: - if e.errno == EISCONN: - self.logger.debug("Already connected") - self._set_connected(True) - self.success = True - self.set_status('ready') - else: - self.logger.error("Connection error: %s", e.strerror) - self._set_connected(False) - self.success = False - self.set_status('not connected') - # clear socket - if self.is_connected(): - self._clear_socket() - elif con_type == "serial": - self.logger.error("Serial connection not supported") - self._set_connected(False) - else: - self.logger.error("Invalid connection parameters: %s", args) - self._set_connected(False) - - def _clear_socket(self): - """ Clear socket buffer. """ - if self.socket is not None: - self.socket.setblocking(False) - while True: - try: - _ = self.socket.recv(1024) - except BlockingIOError: - break - self.socket.setblocking(True) - - def check_status(self): - """ Check connection status """ - if not self.is_connected(): - status = 'not connected' - elif not self.success: - status = 'unresponsive' - else: - status = 'ready' - - self.set_status(status) - - def set_status(self, status): - """ Set the status of the filter wheel. - - :param status: String, status of the controller. - - """ - status = status.lower() - - if self.status is None: - current = None - else: - current = self.status - - if current != 'locked' or status == 'unlocked': - self.status = status - - def initialize(self): - """ Initialize the lakeshore status. """ - - self.revision = self.command('*idn?') - - if self.model336: - - for htr_items in self.outputs.items(): - htr = htr_items[0] - htr_settings = self.get_heater_settings(htr) - if htr_settings is None: - self.logger.warning("Unable to get settings for htr %s", htr) - else: - resistance, max_current, user_max_current, htr_display = htr_settings - self.outputs[htr]['resistance'] = resistance - self.outputs[htr]['max_current'] = max_current - self.outputs[htr]['user_max_current'] = user_max_current - self.outputs[htr]['htr_display'] = htr_display - - self.outputs[htr]['status'] = self.get_heater_status(htr) - - pid = self.get_heater_pid(htr) - if pid is None: - self.logger.warning("PID not set for htr %s", htr) - else: - p, i, d = pid - self.outputs[htr]['p'] = p - self.outputs[htr]['i'] = i - self.outputs[htr]['d'] = d - - self.initialized = True - - def command(self, command, params=None): - """ Wrapper to issue_command(), ensuring the command lock is - released if an exception occurs. - - :param command: String, command to issue. - :param params: String, parameters to issue. - - """ - - with self.lock: - try: - self.success= self._send_command(command, params) - result = '' - if '?' in command: - result = self._read_reply() - finally: - # Ensure that status is always checked, even on failure - self.check_status() - - return result - - def _send_command(self, command, *args) -> bool: - """ Wrapper to send/receive with error checking and retries. - - :param command: String, command to issue. - :param args: String, parameters to issue. - - """ - if not self.is_connected(): - self.set_status('connecting') - self.connect(self.host, self.port) - - retries = 3 - if args: - send_command = f"{command} {args[0]}{self.termchars}".encode('utf-8') - else: - send_command = f"{command}{self.termchars}".encode('utf-8') - - while retries > 0: - self.logger.debug("sending command %s", send_command) - try: - self.socket.send(send_command) - - except socket.error: - self.logger.error( - "Failed to send command, re-opening socket, %d retries" - " remaining", retries) - self.disconnect() - try: - self.connect(self.host, self.port) - except OSError: - self.logger.error('Could not reconnect to controller, aborting') - return False - retries -= 1 - continue - break - if retries <= 0: - self.logger.error("Failed to send command.") - raise RuntimeError('unable to successfully issue command: ' + repr(command)) - - self.logger.debug("Sent command: %s", send_command) - return True - - def _read_reply(self) -> Union[str, None]: - # Get a reply, if needed. - timeout = 1 - start = time.time() - reply = self.socket.recv(1024) - while self.termchars not in reply.decode('utf-8') and \ - time.time() - start < timeout: - try: - reply += self.socket.recv(1024) - self.logger.debug("reply: %s", reply) - except OSError as e: - if e.errno == ETIMEDOUT: - reply = '' - time.sleep(0.1) - - if reply == '': - # Don't log here, because it happens a lot when the controller - # is unresponsive. Just try again. - continue - - if isinstance(reply, str): - reply = reply.strip() - else: - reply = reply.decode('utf-8').strip() - return reply - - def set_celsius(self): - """ Set units to Celsius. """ - self.celsius = True - - def set_kelvin(self): - """ Set units to Kelvin. """ - self.celsius = False - - def get_temperature(self, sensor): - """ Get sensor temperature. - - :param sensor: String, name of the sensor: A-D or A-C, D1=D5. - - """ - retval = None - if sensor.upper() not in self.sensors: - self.logger.error("Sensor %s is not available", sensor) - else: - if self.celsius: - reply = self.command('crdg?', sensor) - if len(reply) > 0: - retval = float(reply) - else: - reply = self.command('krdg?', sensor) - if len(reply) > 0: - retval = float(reply) - return retval - - def get_heater_settings(self, output): - """ Get heater settings. - - :param output: String, output number of the sensor (1 or 2). - returns resistance, max current, max user current, display. - """ - retval = None - if self.model336: - if output.upper() not in self.outputs: - self.logger.error("Heater %s is not available", output) - else: - reply = self.command('htrset?', output) - if len(reply) > 0: - ires, imaxcur, strusermaxcur, idisp = reply.split(',') - retval = (self.resistance[ires], self.max_current[imaxcur], - float(strusermaxcur), self.htr_display[idisp]) - else: - self.logger.error("Heater is not available with this model") - return retval - - def get_heater_pid(self, output): - """ Get heater PID values. - - :param output: String, output number of the sensor (1 or 2). - returns p,i,d values - """ - retval = None - if self.model336: - if output.upper() not in self.outputs: - self.logger.error("Heater %s is not available", output) - else: - reply = self.command('pid?', output) - if len(reply) > 0: - p, i, d = reply.split(',') - retval = [float(i), float(d), float(p)] - else: - self.logger.error("Heater is not available with this model") - return retval - - def get_heater_status(self, output): - """ Get heater status. - - :param output: String, output number of the sensor (1 or 2). - returns status string - """ - retval = 'unknown' - if self.model336: - if output.upper() not in self.outputs: - self.logger.error("Heater %s is not available", output) - else: - reply = self.command('htrst?', output) - if len(reply) > 0: - reply = reply.strip() - if reply in self.htr_errors: - retval = self.htr_errors[reply] - else: - self.logger.error("Heater error %s and status is unknown", reply) - else: - self.logger.error("Heater is not available with this model") - return retval - - def get_heater_output(self, output): - """ Get heater output. - - :param output: String, output number of the sensor (1 or 2). - returns heater output. - """ - retval = None - if self.model336: - if output.upper() not in self.outputs: - self.logger.error("Heater %s is not available", output) - else: - reply = self.command('htr?', output) - if len(reply) > 0: - reply = reply.strip() - try: - retval = float(reply) - except ValueError: - self.logger.error("Heater output error: %s", reply) - else: - self.logger.error("Heater output error") - else: - self.logger.error("Heater is not available with this model") - return retval - - def get_atomic_value(self, item: str = "") -> Union[float, None]: - """ - Read the latest value of a specific item - :param item: String, name of the item - returns value of item or None - """ - retval = None - if item.upper() in self.sensors or item in self.outputs: - if item.upper() in self.sensors: - retval = self.get_temperature(item) - else: - retval = self.get_heater_output(item) - else: - self.logger.error("Item %s is not available", item) - return retval -# end of class Controller diff --git a/src/hispec/util/lakeshore/pyproject.toml b/src/hispec/util/lakeshore/pyproject.toml deleted file mode 100644 index 70a1b3c..0000000 --- a/src/hispec/util/lakeshore/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "lakeshore" -version = "0.1.0" -description = "Lakeshore temperature controller software" -authors = [ - { name="Michael Langmayr", email="langmayr@caltech.edu" }, - { name="Don Neill", email="neill@astro.caltech.edu" }, - { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } -] -readme = "README.md" -requires-python = ">=3.7" -dependencies = [ - "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" -] -[tool.pytest.ini_options] -pythonpath = [ - "." -] diff --git a/src/hispec/util/lakeshore/tests/test_lakeshore_basic.py b/src/hispec/util/lakeshore/tests/test_lakeshore_basic.py deleted file mode 100644 index c019036..0000000 --- a/src/hispec/util/lakeshore/tests/test_lakeshore_basic.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Perform basic tests.""" -from lakeshore import LakeshoreController - -def test_not_connected(): - """Test not connected.""" - controller = LakeshoreController() - assert not controller.connected - -def test_not_initialized(): - """Test isn't initialized.""" - controller = LakeshoreController() - assert not controller.initialized - -def test_connection_fail(): - """Test connection failure.""" - controller = LakeshoreController() - controller.set_connection(ip="127.0.0.1", port=50000) - controller.connect() - assert not controller.connected diff --git a/src/hispec/util/newport/.gitignore b/src/hispec/util/newport/.gitignore deleted file mode 100644 index 15201ac..0000000 --- a/src/hispec/util/newport/.gitignore +++ /dev/null @@ -1,171 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# PyPI configuration file -.pypirc diff --git a/src/hispec/util/newport/LICENSE b/src/hispec/util/newport/LICENSE deleted file mode 100644 index bce361a..0000000 --- a/src/hispec/util/newport/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -MIT License - -Copyright (c) [year] [fullname] - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff --git a/src/hispec/util/newport/README.md b/src/hispec/util/newport/README.md deleted file mode 100644 index e756021..0000000 --- a/src/hispec/util/newport/README.md +++ /dev/null @@ -1,45 +0,0 @@ -# smc100pp_controller - -Low-level Python modules to send commands to Newport motion controllers. - -## Currently Supported Models -- SMC100PP - smc100pp.py - -## Features -- Connect to Newport controllers over serial through a terminal server -- Query stage state and parameters -- Move individual axes to absolute or relative positions - -## Usage - -```python -import smc100pp - -controller = smc100pp.StageController() -controller.connect(host='192.168.29.100', port=10006) - -# Print stage 1 parameters -print(controller.get_params(1)) - -# Print stage2 state -print(controller.get_state(2)) - -# Move axis 1 to position 12.0 -controller.move_abs(12.0, 1) - -# Move axis 2 to +12 degrees relative to current position -controller.move_rel(12.0, 2) - -# For a comprehensive list of classes and methods, use the help function -help(smc100pp) - -``` - -## 🧪 Testing -Unit tests are located in `tests/` directory. - -To run all tests from the project root: - -```bash -pytest -``` \ No newline at end of file diff --git a/src/hispec/util/newport/pyproject.toml b/src/hispec/util/newport/pyproject.toml deleted file mode 100644 index 2eda790..0000000 --- a/src/hispec/util/newport/pyproject.toml +++ /dev/null @@ -1,50 +0,0 @@ -[build-system] -# Specifies the build system to use. -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" - -[project] -# Basic information about your project. -name = "newport" -version = "0.1.0" -dependencies = [ - "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" -] -requires-python = ">=3.7" -authors = [ - {name = "Don Neill", email = "neill@astro.caltech.edu"} -] -maintainers = [ - {name = "Don Neill", email = "neill@astro.caltech.edu"} -] -description = "Newport controller software" -readme = "README.md" -license = { text = "MIT" } -# keywords = ["example", "package", "keywords"] -classifiers = [ - "Programming Language :: Python" -] - -[project.urls] -# Various URLs related to your project. These links are displayed on PyPI. -# Homepage = "https://example.com" -# Documentation = "https://readthedocs.org" -Repository = "https://github.com/COO-Utils/newport" -# "Bug Tracker" = "https://github.com/yourusername/your-repo/issues" -# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" - -[project.scripts] -# Defines command-line scripts for your package. Replace with your script and function. -# your-command = "your_module:your_function" - -[project.optional-dependencies] -# Optional dependencies that can be installed with extra tags, like "dev". -dev = [ - "pytest", - "black", - "flake8" -] -[tool.pytest.ini_options] -pythonpath = [ - "." -] diff --git a/src/hispec/util/newport/smc100pp.py b/src/hispec/util/newport/smc100pp.py deleted file mode 100644 index ab4be41..0000000 --- a/src/hispec/util/newport/smc100pp.py +++ /dev/null @@ -1,878 +0,0 @@ -# coding=utf-8 -""" -The following stage controller commands are available. Note that many -are not implemented at the moment. The asterisk indicates the commands -that are implemented. - -AC Set/Get acceleration -BA Set/Get backlash compensation -BH Set/Get hysteresis compensation -DV Set/Get driver voltage Not for PP -FD Set/Get low pass filter for Kd Not for PP -FE Set/Get following error limit Not for PP -FF Set/Get friction compensation Not for PP -FR Set/Get stepper motor configuration Not for CC -HT Set/Get HOME search type -ID Set/Get stage identifier -JD Leave JOGGING state -JM Enable/disable keypad -JR Set/Get jerk time -KD Set/Get derivative gain Not for PP -KI Set/Get integral gain Not for PP -KP Set/Get proportional gain Not for PP -KV Set/Get velocity feed forward Not for PP -MM Enter/Leave DISABLE state -OH Set/Get HOME search velocity -OR * Execute HOME search -OT Set/Get HOME search time-out -PA * Move absolute -PR * Move relative -PT Get motion time for a relative move -PW Enter/Leave CONFIGURATION state -QI Set/Get motor’s current limits -RA Get analog input value -RB Get TTL input value -RS * Reset controller -SA Set/Get controller’s RS-485 address -SB Set/Get TTL output value -SC Set/Get control loop state Not for PP -SE Configure/Execute simultaneous started move -SL * Set/Get negative software limit -SR * Set/Get positive software limit -ST Stop motion -SU Set/Get encoder increment value Not for PP -TB Get command error string -TE * Get last command error -TH Get set-point position -TP * Get current position -TS * Get positioner error and controller state -VA Set/Get velocity -VB Set/Get base velocity Not for CC -VE Get controller revision information -ZT * Get all axis parameters -ZX Set/Get SmartStage configuration - -The values below as of 2025-May-19 - -For stage 1 & 2 current values are: -80 - Acceleration --3600 - negative software limit, from 1SL? -+3600 - positive software limit, from 1SR? -20 - Microstep factor -0.0200682 - Full step value -0.04 - Jerk time in seconds -8 - deg/s Home velocity -1980 - Home timeout in seconds -0.3 - Peak current limit in Amperes -2 - Controller's RS485 address -8 - deg/s Move velocity -0 - deg/s Base velocity -ESP stage check enabled -Home type: use MZ switch only -Backlash and hysteresis compensations are disabled. -""" - -import errno -import logging -import time -import socket -import threading -import sys - - -class StageController: - """ - Controller class for Newport SMC100PP Stage Controller. - """ - # pylint: disable=too-many-instance-attributes - - controller_commands = ["OR", # Execute HOME search - "PA", # Absolute move - "PR", # Move relative - "RS", # Reset controller - "SL", # Set/Get positive software limit - "SR", # Set/Get negative software limit - "TE", # Get last command error - "TP", # Get current position - "TS", # Get positioner error and controller state - "ZT" # Get all axis parameters - ] - return_value_commands = ["TE", "TP", "TS"] - parameter_commands = ["PA", "PR", "SL", "SR"] - end_code_list = ['32', '33', '34', '35'] - not_ref_list = ['0A', '0B', '0C', '0D', '0F', '10', '11'] - moving_list = ['28'] - msg = { - "0A": "NOT REFERENCED from reset.", - "0B": "NOT REFERENCED from HOMING.", - "0C": "NOT REFERENCED from CONFIGURATION.", - "0D": "NOT REFERENCED from DISABLE.", - "0E": "NOT REFERENCED from READY.", - "0F": "NOT REFERENCED from MOVING.", - "10": "NOT REFERENCED ESP stage error.", - "11": "NOT REFERENCED from JOGGING.", - "14": "CONFIGURATION.", - "1E": "HOMING commanded from RS-232-C.", - "1F": "HOMING commanded by SMC-RC.", - "28": "MOVING.", - "32": "READY from HOMING.", - "33": "READY from MOVING.", - "34": "READY from DISABLE.", - "35": "READY from JOGGING.", - "3C": "DISABLE from READY.", - "3D": "DISABLE from MOVING.", - "3E": "DISABLE from JOGGING.", - "46": "JOGGING from READY.", - "47": "JOGGING from DISABLE." - } - error = { - "@": "No error.", - "A": "Unknown message code or floating point controller address.", - "B": "Controller address not correct", - "C": "Parameter missing or out of range.", - "D": "Command not allowed.", - "E": "Home sequence already started.", - "F": "ESP stage name unknown.", - "G": "Displacement out of limits.", - "H": "Command not allowed in NOT REFERENCED state.", - "I": "Command not allowed in CONFIGURATION state.", - "J": "Command not allowed in DISABLE state.", - "K": "Command not allowed in READY state.", - "L": "Command not allowed in HOMING state.", - "M": "Command not allowed in MOVING state.", - "N": "Current position out of software limit.", - "S": "Communication Time Out.", - "U": "Error during EEPROM access.", - "V": "Error durring command execution.", - "W": "Command not allowed for PP version.", - "X": "Command not allowed for CC version." - } - last_error = "" - - def __init__(self, num_stages=2, move_rate=5.0, log=True, - logfile=None): - - """ - Class to handle communications with the stage controller and any faults - - :param num_stages: Int, number of stages daisey-chained - :param move_rate: Float, move rate in degrees per second - :param log: Boolean, whether to log to file or not - :param logfile: Filename for log - - NOTE: default is INFO level logging, use set_verbose to increase verbosity. - """ - - # thread lock - self.lock = threading.Lock() - - # Set up socket - self.socket = None - self.connected = False - - # number of daisy-chained stages - self.num_stages = num_stages - - # stage rate in degrees per second - self.move_rate = move_rate - - # current values - self.current_position = [0.0] * (num_stages + 1) - self.current_limits = [(0., 0.)] * (num_stages + 1) - - # set up logging - self.verbose = False - if log: - if logfile is None: - logfile = __name__.rsplit('.', 1)[-1] + '.log' - self.logger = logging.getLogger(logfile) - self.logger.setLevel(logging.INFO) - formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) - file_handler = logging.FileHandler(logfile) - file_handler.setFormatter(formatter) - self.logger.addHandler(file_handler) - - console_formatter = logging.Formatter( - '%(asctime)s--%(message)s') - console_handler = logging.StreamHandler(sys.stdout) - console_handler.setFormatter(console_formatter) - self.logger.addHandler(console_handler) - else: - self.logger = None - - def set_verbose(self, verbose=True): - """ Set verbose mode. - - :param verbose: Boolean, set to True to enable DEBUG level messages, - False to disable DEBUG level messages - """ - self.verbose = verbose - if self.logger: - if self.verbose: - self.logger.setLevel(logging.DEBUG) - else: - self.logger.setLevel(logging.INFO) - - def connect(self, host=None, port=None): - """ Connect to stage controller. - - :param host: String, host ip address - :param port: Int, Port number - """ - start = time.time() - if self.socket is None: - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.socket.connect((host, port)) - if self.logger: - self.logger.debug("Connected to %(host)s:%(port)s", { - 'host': host, - 'port': port - }) - self.connected = True - ret = {'elaptime': time.time()-start, 'data': 'connected'} - - except OSError as ex: - if ex.errno == errno.EISCONN: - if self.logger: - self.logger.debug("Already connected") - self.connected = True - ret = {'elaptime': time.time()-start, 'data': 'already connected'} - else: - if self.logger: - self.logger.error("Connection error: %s", ex.strerror) - self.connected = False - ret = {'elaptime': time.time()-start, 'error': ex.strerror} - # clear socket - if self.connected: - self.__clear_socket() - - return ret - - def disconnect(self): - """ Disconnect stage controller. """ - start = time.time() - try: - self.socket.shutdown(socket.SHUT_RDWR) - self.socket.close() - self.socket = None - if self.logger: - self.logger.debug("Disconnected controller") - self.connected = False - ret = {'elaptime': time.time()-start, 'data': 'disconnected'} - except OSError as ex: - if self.logger: - self.logger.error("Disconnection error: %s", ex.strerror) - self.connected = False - self.socket = None - ret = {'elaptime': time.time()-start, 'error': ex.strerror} - - return ret - - def __clear_socket(self): - """ Clear socket buffer. """ - if self.socket is not None: - self.socket.setblocking(False) - while True: - try: - _ = self.socket.recv(1024) - except BlockingIOError: - break - self.socket.setblocking(True) - - def __read_value(self): - """ Read return value from controller """ - # Return value commands - - # Get return value - recv = self.socket.recv(2048) - recv_len = len(recv) - if self.logger: - self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) - - # Are we a valid return value? - if recv_len in [6, 11, 12, 13, 14]: - if self.logger: - self.logger.debug("Return value validated") - return str(recv.decode('utf-8')) - - def __read_params(self): - """ Read stage controller parameters """ - # Get return value - recv = self.socket.recv(2048) - - # Did we get all the params? - tries = 5 - while tries > 0 and b'PW0' not in recv: - recv += self.socket.recv(2048) - tries -= 1 - - if b'PW0' in recv: - recv_len = len(recv) - if self.logger: - self.logger.debug("ZT Return: len = %d", recv_len) - else: - if self.logger: - self.logger.warning("ZT command timed out") - - return str(recv.decode('utf-8')) - - def __read_blocking(self, stage_id=1, timeout=15): - """ Block while reading from the controller. - :param stage_id: Int, stage id - :param timeout: Timeout for blocking read - """ - - start = time.time() - - # Non-return value commands eventually return state output - sleep_time = 0.1 - start_time = time.time() - print_it = 0 - recv = None - while time.time() - start_time < timeout: - # Check state - statecmd = f'{stage_id}TS\r\n' - statecmd = statecmd.encode('utf-8') - self.socket.send(statecmd) - time.sleep(sleep_time) - recv = self.socket.recv(1024) - - # Valid state return - if len(recv) == 11: - # Parse state - recv = recv.rstrip() - code = str(recv[-2:].decode('utf-8')) - - # Valid end code or not referenced code (done) - if code in self.end_code_list or code in self.not_ref_list: - return {'elaptime': time.time()-start, - 'data': self.msg.get(code, 'Unknown state')} - - if print_it >= 10: - msg = (f"{time.time()-start:05.2f} " - f"{self.msg.get(code, 'Unknown state'):s}") - if self.logger: - self.logger.info(msg) - else: - print(msg) - print_it = 0 - - # Invalid state return (done) - else: - if self.logger: - self.logger.warning("Bad %dTS return: %s", stage_id, recv) - return {'elaptime': time.time()-start, - 'error': str(recv.decode('utf-8'))} - - # Increment tries and read state again - print_it += 1 - - # If we get here, we ran out of tries - recv = recv.rstrip() - code = str(recv[-2:].decode('utf-8')) - if self.logger: - self.logger.warning("Command timed out, final state: %s", - self.msg.get(code, "Unknown state")) - return {'elaptime': time.time()-start, - 'error': self.msg.get(code, 'Unknown state')} - - def __send_serial_command(self, stage_id=1, cmd=''): - """ - Send serial command to stage controller - - :param stage_id: Int, stage position in the daisy chain starting with 1 - :param cmd: String, command to send to stage controller - :return: - """ - - start = time.time() - - # Prep command - cmd_send = f"{stage_id}{cmd}\r\n" - if self.logger: - self.logger.debug("Sending command:%s", cmd_send) - cmd_encoded = cmd_send.encode('utf-8') - - # check connection - if not self.connected: - msg_type = 'error' - msg_text = "Not connected to controller!" - if self.logger: - self.logger.error(msg_text) - - try: - self.socket.settimeout(30) - # Send command - self.socket.send(cmd_encoded) - time.sleep(.05) - msg_type = 'data' - msg_text = 'Command sent successfully' - - except socket.error as ex: - msg_type = 'error' - msg_text = f"Command send error: {ex.strerror}" - if self.logger: - self.logger.error(msg_text) - - return {'elaptime': time.time()-start, msg_type: msg_text} - - def __send_command(self, cmd="", parameters=None, stage_id=1, custom_command=False): - """ - Send a command to the stage controller - - :param cmd: String, command to send to the stage controller - :param parameters: List of string parameters associated with cmd - :param stage_id: Int, stage position in the daisy chain starting with 1 - :param custom_command: Boolean, if true, command is custom - :return: - """ - - # verify cmd and stage_id - ret = self.__verify_send_command(cmd, stage_id, custom_command) - if 'error' in ret: - return ret - - # Check if the command should have parameters - if cmd in self.parameter_commands and parameters: - if self.logger: - self.logger.debug("Adding parameters") - parameters = [str(x) for x in parameters] - parameters = " ".join(parameters) - cmd += parameters - - if self.logger: - self.logger.debug("Input command: %s", cmd) - - # Send serial command - with self.lock: - result = self.__send_serial_command(stage_id, cmd) - - return result - - def __verify_send_command(self, cmd, stage_id, custom_command=False): - """ Verify cmd and stage_id - - :param cmd: String, command to send to the stage controller - :param stage_id: Int, stage position in the daisy chain starting with 1 - :param custom_command: Boolean, if true, command is custom - :return: dictionary {'elaptime': time, 'data|error': string_message}""" - - start = time.time() - - # Do we have a connection? - if not self.connected: - msg_type = 'error' - msg_text = 'Not connected to controller' - - # Is stage id valid? - elif not self.__verify_stage_id(stage_id): - msg_type = 'error' - msg_text = f"{stage_id} is not a valid stage" - - else: - # Do we have a legal command? - if cmd.rstrip().upper() in self.controller_commands: - msg_type = 'data' - msg_text = f"{cmd} is a valid or custom command" - else: - if not custom_command: - msg_type = 'error' - msg_text = f"{cmd} is not a valid command" - else: - msg_type = 'data' - msg_text = f"{cmd} is a custom command" - - return {'elaptime': time.time() - start, msg_type: msg_text} - - def __verify_stage_id(self, stage_id): - """ Check that the stage id is legal - - :param stage_id: Int, stage position in the daisy chain starting with 1 - :return: True if stage id is legal - """ - if stage_id > self.num_stages or stage_id < 1: - is_valid = False - else: - is_valid = True - - return is_valid - - def __verify_move_state(self, stage_id, position, move_type='absolute'): - """ Verify that the move is allowed - :param stage_id: Int, stage position in the daisy chain starting with 1 - :param position: String, move position - :param move_type: String, move type: 'absolute' or 'relative' - :return: True if move is allowed""" - - start = time.time() - - msg_type = 'data' - msg_text = 'OK to move' - # Verify inputs - if position is None or stage_id is None: - msg_type = 'error' - msg_text = 'must specify both position and stage_id' - else: - # Verify move state - current_state = self.get_state(stage_id=stage_id) - if 'error' in current_state: - msg_type = 'error' - msg_text = current_state['error'] - elif 'READY' not in current_state['data']: - msg_type = 'error' - msg_text = current_state['data'] - else: - # Verify position - if 'absolute' not in move_type: - position += self.current_position[stage_id] - if position < self.current_limits[stage_id][0] or \ - position > self.current_limits[stage_id][1]: - msg_type = 'error' - msg_text = 'position out of range' - ret = {'elaptime': time.time() - start, msg_type: msg_text} - if self.logger: - self.logger.debug("Move state: %s", msg_text) - return ret - - def __return_parse_state(self, message=""): - """ - Parse the return message from the controller. The message code is - given in the last two string characters - - :param message: String message code from the controller - :return: String message - """ - message = message.rstrip() - code = message[-2:] - return self.msg.get(code, "Unknown state") - - def __return_parse_error(self, error=""): - """ - Parse the return error message from the controller. The message code is - given in the last string character - - :param error: Error code from the controller - :return: String message - """ - error = error.rstrip() - code = error[-1:] - return self.error.get(code, "Unknown error") - - def home(self, stage_id=1): - """ - Home the stage - - :param stage_id: Int, stage position in the daisy chain starting with 1 - :return: return from __send_command - """ - - start = time.time() - - if not self.homed(stage_id): - ret = self.__send_command(cmd='OR', stage_id=stage_id) - - if 'error' not in ret: - while 'READY from HOMING' not in ret['data']: - time.sleep(1.) - ret = self.get_state(stage_id) - if 'error' in ret: - break - if self.logger: - self.logger.info(ret['data']) - ret['elaptime'] = time.time() - start - else: - ret = { 'elaptime': time.time()-start, 'data': 'already homed' } - - return ret - - def homed(self, stage_id=1): - """ Is the stage homed? - :param stage_id: Int, stage position in the daisy chain starting with 1 - :return: Boolean, True if homed else False - """ - - state = self.get_state(stage_id=stage_id) - - if 'error' in state: - if self.logger: - self.logger.error(state['error']) - ret = False - - else: - if 'NOT REFERENCED' in state['data']: - ret = False - else: - ret = True - - if self.logger: - self.logger.debug(state['data']) - - return ret - - def move_abs(self, position=None, stage_id=None, blocking=False): - """ - Move stage to absolute position and return when in position - - :param position: Float, absolute position in degrees - :param stage_id: Int, stage position in the daisy chain starting with 1 - :param blocking: Boolean, block until move complete or not - :return: return from __send_command - """ - - start = time.time() - - # Verify we are ready to move - ret = self.__verify_move_state(stage_id=stage_id, position=position) - if 'error' in ret: - if self.logger: - self.logger.error(ret['error']) - return ret - if 'OK to move' not in ret['data']: - if self.logger: - self.logger.error(ret['data']) - return {'elaptime': time.time()-start, 'error': ret['data']} - - # Send move to controller - ret = self.__send_command(cmd="PA", parameters=[position], - stage_id=stage_id) - - if blocking: - move_len = self.current_position[stage_id] - position - if self.move_rate <= 0: - timeout = 5 - else: - timeout = int(abs(move_len / self.move_rate)) - timeout = max(timeout, 5) - if self.logger: - self.logger.info("Timeout for move to absolute position: %d s", - timeout) - ret = self.__read_blocking(stage_id=stage_id, timeout=timeout) - - if 'error' not in ret: - self.current_position[stage_id] = position - - ret['elaptime'] = time.time() - start - return ret - - def move_rel(self, position=None, stage_id=None, blocking=False): - """ - Move stage to relative position and return when in position - - :param position: Float, relative position in degrees - :param stage_id: Int, stage position in the daisy chain starting with 1 - :param blocking: Boolean, block until move complete or not - :return: return from __send_command - """ - - start = time.time() - - # Verify we are ready to move - ret = self.__verify_move_state(stage_id=stage_id, position=position, - move_type='relative') - if 'error' in ret: - if self.logger: - self.logger.error(ret['error']) - return ret - if 'OK to move' not in ret['data']: - if self.logger: - self.logger.error(ret['data']) - return {'elaptime': time.time()-start, 'error': ret['data']} - - ret = self.__send_command(cmd="PR", parameters=[position], - stage_id=stage_id) - - if blocking: - if self.move_rate <= 0: - timeout = 5 - else: - timeout = int(abs(position / self.move_rate)) - timeout = max(timeout, 5) - if self.logger: - self.logger.info("Timeout for move to relative position: %d s", - timeout) - ret = self.__read_blocking(stage_id=stage_id, timeout=timeout) - - if 'error' not in ret: - self.current_position[stage_id] += position - - ret['elaptime'] = time.time() - start - return ret - - def get_state(self, stage_id=1): - """ Current state of the stage - - :param stage_id: int, stage position in the daisy chain starting with 1 - :return: return from __send_command - """ - - start = time.time() - - ret = self.__send_command(cmd="TS", stage_id=stage_id) - if 'error' not in ret: - state = self.__return_parse_state(self.__read_value()) - ret['data'] = state - ret['elaptime'] = time.time() - start - - return ret - - def get_last_error(self, stage_id=1): - """ Last error - - :param stage_id: int, stage position in the daisy chain starting with 1 - :return: return from __send_command - """ - - start = time.time() - - ret = self.__send_command(cmd="TE", stage_id=stage_id) - if 'error' not in ret: - last_error = self.__return_parse_error(self.__read_value()) - ret['data'] = last_error - ret['elaptime'] = time.time() - start - - return ret - - def get_position(self, stage_id=1): - """ Current position - - :param stage_id: int, stage position in the daisy chain starting with 1 - :return: return from __send_command - """ - - start = time.time() - - ret = self.__send_command(cmd="TP", stage_id=stage_id) - if 'error' not in ret: - position = float(self.__read_value().rstrip()[3:]) - self.current_position[stage_id] = position - ret['data'] = position - ret['elaptime'] = time.time() - start - - return ret - - def get_move_rate(self): - """ Current move rate - - :return: return from __send_command - """ - start = time.time() - return {'elaptime': time.time()-start, 'data': self.move_rate} - - def set_move_rate(self, rate=5.0): - """ Set move rate - - :param rate: Float, move rate in degrees per second - :return: dictionary {'elaptime': time, 'data': move_rate} - """ - start = time.time() - if rate > 0: - self.move_rate = rate - else: - if self.logger: - self.logger.error('set_move_rate input error, not changed') - return {'elaptime': time.time()-start, 'data': self.move_rate} - - def reset(self, stage_id=1): - """ Reset stage - - :param stage_id: int, stage position in the daisy chain starting with 1 - :return: return from __send_command - """ - - start = time.time() - - ret = self.__send_command(cmd="RS", stage_id=stage_id) - time.sleep(2.) - - if 'error' not in ret: - self.read_from_controller() - - ret['elaptime'] = time.time() - start - return ret - - def get_limits(self, stage_id=1): - """ Get stage limits - :param stage_id: int, stage position in the daisy chain starting with 1 - :return: return from __send_command - """ - start = time.time() - ret = self.__send_command(cmd="SL", parameters="?", stage_id=stage_id) - if 'error' not in ret: - lolim = int(self.__read_value().rstrip()[3:]) - ret = self.__send_command(cmd="SR", parameters="?", stage_id=stage_id) - if 'error' not in ret: - uplim = int(self.__read_value().rstrip()[3:]) - self.current_limits[stage_id] = (lolim, uplim) - ret = {'elaptime': time.time()-start, - 'data': self.current_limits[stage_id]} - return ret - - def get_params(self, stage_id=1, quiet=False): - """ Get stage parameters - - :param stage_id: int, stage position in the daisy chain starting with 1 - :param quiet: Boolean, do not print parameters - :return: return from __send_command - """ - - start = time.time() - - ret = self.__send_command(cmd="ZT", stage_id=stage_id) - - if 'error' not in ret: - params = self.__read_params() - if not quiet: - for param in params.split(): - if 'PW' not in param: - print(param) - ret['data'] = params - ret['elaptime'] = time.time() - start - - return ret - - def initialize_controller(self): - """ Initialize stage controller. """ - start = time.time() - for i in range(self.num_stages): - self.get_position(i+1) - self.get_limits(i+1) - return {'elaptime': time.time()-start, 'data': 'initialized'} - - def read_from_controller(self): - """ Read from controller""" - self.socket.setblocking(False) - try: - recv = self.socket.recv(2048) - recv_len = len(recv) - if self.logger: - self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) - except BlockingIOError: - recv = b"" - self.socket.setblocking(True) - return str(recv.decode('utf-8')) - - def run_manually(self, stage_id=1): - """ Input stage commands manually - - :param stage_id: int, stage position in the daisy chain starting with 1 - :return: None - """ - - while True: - - cmd = input("Enter Command") - - if not cmd: - break - - ret = self.__send_command(cmd=cmd, stage_id=stage_id, - custom_command=True) - if 'error' not in ret: - output = self.read_from_controller() - print(output) - - if self.logger: - self.logger.debug("End: %s", ret) diff --git a/src/hispec/util/newport/tests/test_basic.py b/src/hispec/util/newport/tests/test_basic.py deleted file mode 100644 index 9dbdc05..0000000 --- a/src/hispec/util/newport/tests/test_basic.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Perform basic tests.""" -import pytest -from smc100pp import StageController - -def test_initialization(): - """Test initialization.""" - controller = StageController() - assert not controller.connected - -def test_connection_fail(): - """Test connection failure.""" - with pytest.raises(Exception): - controller = StageController() - controller.connect(host="127.0.0.1", port=50000) - assert not controller.connected diff --git a/src/hispec/util/onewire/.gitignore b/src/hispec/util/onewire/.gitignore deleted file mode 100644 index b7faf40..0000000 --- a/src/hispec/util/onewire/.gitignore +++ /dev/null @@ -1,207 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ diff --git a/src/hispec/util/onewire/README.md b/src/hispec/util/onewire/README.md deleted file mode 100644 index 4d156a7..0000000 --- a/src/hispec/util/onewire/README.md +++ /dev/null @@ -1,29 +0,0 @@ -# Onewire Environmental Sensor - -Low-level Python modules to communicate with Embedded Data Systems OW-SERVER device. -It supports reading temperature, humidity, dew point, humidex, heat index, -pressure, and illuminance from OW-ENV sensor (pressure and illuminance are only -available on supported sensors). - -## Requirements - -- Python 3.7+ -- Install base class from https://github.com/COO-Utilities/hardware_device_base - -### Running from a Python Terminal - -```python -from onewire import ONEWIRE - -ow = ONEWIRE() -ow.connect("192.168.29.154", 80) - -ow.get_data() -print(ow.ow_data.read_sensors()) -ow.get_data() -print(ow.ow_data.read_sensors()) -``` -### NOTE -The OneWire disconnects after each call to get_data(), but the host and port -are stored after the first connection, so subsequent calls to get_data() will -reconnect. \ No newline at end of file diff --git a/src/hispec/util/onewire/__init__.py b/src/hispec/util/onewire/__init__.py deleted file mode 100644 index 5facccb..0000000 --- a/src/hispec/util/onewire/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Initialize the ONEWIRE module.""" -from .onewire import ONEWIRE - -__all__ = ["ONEWIRE"] diff --git a/src/hispec/util/onewire/onewire.py b/src/hispec/util/onewire/onewire.py deleted file mode 100644 index fd0d658..0000000 --- a/src/hispec/util/onewire/onewire.py +++ /dev/null @@ -1,371 +0,0 @@ -""" -Onewire Controller Interface -""" -import socket -import xml.etree.ElementTree as ET -from dataclasses import dataclass, field, asdict -import sys -from typing import List, Union - -from hardware_device_base import HardwareDeviceBase - -PARAMETER_QUERY = "GET /details.xml HTTP/1.1\r\n\r\n" - -@dataclass -class EDS0065DATA: - """Class to hold data from EDS0065""" - # pylint: disable=too-many-instance-attributes - rom_id: str = None - device_type: str = None - health: int = None - channel: int = None - raw_data: str = None - relative_humidity: float = None - temperature: float = None - humidity: float = None - dew_point: float = None - humidex: float = None - heat_index: float = None - version: float = None - -@dataclass -class EDS0068DATA: - """Class to hold data from EDS0068""" - # pylint: disable=too-many-instance-attributes - rom_id: str = None - device_type: str = None - health: int = None - channel: int = None - raw_data: str = None - relative_humidity: float = None - temperature: float = None - humidity: float = None - dew_point: float = None - humidex: float = None - heat_index: float = None - pressure_mb: float = None - pressure_hg: float = None - illuminance: int = None - version: float = None - -@dataclass -class ONEWIREDATA: - """Class to hold data from OneWire""" - # pylint: disable=too-many-instance-attributes - poll_count: int = None - total_devices: int = None - loop_time: float = None - ch1_connected: int = None - ch2_connected: int = None - ch3_connected: int = None - ch1_error: int = None - ch2_error: int = None - ch3_error: int = None - ch1_voltage: float = None - ch2_voltage: float = None - ch3_voltage: float = None - voltage_power: float = None - device_name: str = None - hostname: str = None - mac_address: str = None - datetime: str = None - eds0065_data: List[EDS0065DATA] = field(default_factory=list) - eds0068_data: List[EDS0068DATA] = field(default_factory=list) - - def read_sensors(self): - """Method to read sensor data from OneWire""" - sensors = [] - for sensor in self.eds0065_data: - sensors.append(asdict(sensor)) - for sensor in self.eds0068_data: - sensors.append(asdict(sensor)) - - return sensors - - def read_temperature(self): - """Method to read temperature data from OneWire""" - temperatures = [] - for sensor in self.eds0065_data: - if sensor.temperature is not None: - temperature = {"rom_id": sensor.rom_id, "temperature": sensor.temperature} - temperatures.append(temperature) - for sensor in self.eds0068_data: - if sensor.temperature is not None: - temperature = {"rom_id": sensor.rom_id, "temperature": sensor.temperature} - temperatures.append(temperature) - - return temperatures - - def read_humidity(self): - """Method to read humidity data from OneWire""" - humidities = [] - for sensor in self.eds0065_data: - if sensor.humidity is not None: - humidity = {"rom_id": sensor.rom_id, "humidity": sensor.humidity} - humidities.append(humidity) - for sensor in self.eds0068_data: - if sensor.humidity is not None: - humidity = {"rom_id": sensor.rom_id, "humidity": sensor.humidity} - humidities.append(humidity) - - return humidities - -class ONEWIRE(HardwareDeviceBase): - """Class for interfacing with OneWire""" - # pylint: disable=too-many-instance-attributes - def __init__(self, timeout=1, log=True, logfile=__name__.rsplit(".", 1)[-1]): - """Instantiate a OneWire device""" - - super().__init__(log, logfile) - - self.host = None - self.port = 80 - self.timeout = timeout - self.sock: socket.socket | None = None - - self.ow_data = ONEWIREDATA() - - def connect(self, *args, con_type="tcp") -> None: - """Method to connect to OneWire""" - if self.validate_connection_params(args): - if con_type == "tcp": - self.host = args[0] - self.port = args[1] - try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.connect((self.host, self.port)) - self.sock.settimeout(self.timeout) - self._set_connected(True) - self.logger.info("Connected to OneWire at %s:%d", self.host, self.port) - except (ConnectionRefusedError, OSError) as err: - raise DeviceConnectionError( - f"Could not connect to {self.host}:{self.port} {err}" - ) from err - else: - self.logger.error() - raise DeviceConnectionError(f"Connection type not supported: {con_type}") - else: - self._set_connected(False) - self.logger.error("Invalid connection arguments: %s", args) - - def disconnect(self): - """ - Close the connection to the controller. - """ - try: - if self.sock: - self.sock.close() - self._set_connected(False) - self.logger.info('Closed connection to controller') - except Exception as ex: - raise IOError(f"Failed to close connection: {ex}") from ex - - def _send_command(self, command: str, *args) -> bool: - """ - Send a message to the controller (adds newline). - - Args: - command (str): The message to send (e.g., '3A?'). - """ - try: - self.logger.debug('Sending: %s', command) - with self.lock: - self.sock.sendall(command.encode("ascii")) - except Exception as ex: - raise IOError(f'Failed to write message: {ex}') from ex - return True - - def _read_reply(self) -> bytes: - """ - Read a response from the controller. - - Returns: - str: The received message, stripped of trailing newline. - """ - try: - retval = self.sock.recv(25000) - self.logger.debug('Received: %s', retval.decode("ascii")) - return retval - except Exception as ex: - raise IOError(f"Failed to _read_reply message: {ex}") from ex - - def get_atomic_value(self, item: str ="") -> Union[float, int, str, None]: - """Get the atomic value from the controller.""" - self.logger.warning("""Not implemented, use: - > controller.get_data() - > data = controller.ow_data.read_sensors() - """) - - def get_data(self): - """Method to get data from OneWire""" - if not self.is_connected(): - self.connect(self.host, self.port) - self._send_command(PARAMETER_QUERY) - - response = self._read_reply() - - http_response = response.decode("ascii").split("\r\n")[0] - try: - self.__http_response_handler(http_response) - except HttpResponseError as err: - print(err) - sys.exit(1) - - while b'' not in response: - response += self.sock.recv(1024) - # at this point the server has dropped the connection, so disconnect - self.disconnect() - - response = response.decode("ascii") - xml_data = response.split("?>\r\n")[1] - self.__xml_data_handler(xml_data) - - def __http_response_handler(self, response): - response_code = int(response.split(' ')[1]) - - if response_code != 200: - raise HttpResponseError(f"Http response error: {response_code}") - - def __xml_data_handler(self, xml_data): - root = ET.fromstring(xml_data) - - for elem in root.iter(): - tag_elements = elem.tag.split("}") - elem.tag = tag_elements[1] - - if self.logger: - self.logger.debug("XML data received: %s", ET.tostring(root, encoding='unicode')) - # ET.dump(root) - # for elem in root.iter(): - # print(elem.tag, elem.attrib, elem.text) - - for elem in root.iter(): - self.__device_data_handler(elem) - - def __device_data_handler(self, element): - # pylint: disable=too-many-branches - if element.tag == "PollCount": - self.ow_data.poll_count = int(element.text) - elif element.tag == "DevicesConnected": - self.ow_data.total_devices = int(element.text) - elif element.tag == "LoopTime": - self.ow_data.loop_time = float(element.text) - elif element.tag == "DevicesConnectedChannel1": - self.ow_data.ch1_connected = int(element.text) - elif element.tag == "DevicesConnectedChannel2": - self.ow_data.ch2_connected = int(element.text) - elif element.tag == "DevicesConnectedChannel3": - self.ow_data.ch3_connected = int(element.text) - elif element.tag == "DataErrorsChannel1": - self.ow_data.ch1_error = int(element.text) - elif element.tag == "DataErrorsChannel2": - self.ow_data.ch2_error = int(element.text) - elif element.tag == "DataErrorsChannel3": - self.ow_data.ch3_error = int(element.text) - elif element.tag == "VoltageChannel1": - self.ow_data.ch1_voltage = float(element.text) - elif element.tag == "VoltageChannel2": - self.ow_data.ch2_voltage = float(element.text) - elif element.tag == "VoltageChannel3": - self.ow_data.ch3_voltage = float(element.text) - elif element.tag == "VoltagePower": - self.ow_data.voltage_power = float(element.text) - elif element.tag == "DeviceName": - self.ow_data.device_name = str(element.text) - elif element.tag == "HostName": - self.ow_data.hostname = str(element.text) - elif element.tag == "MACAddress": - self.ow_data.mac_address = str(element.text) - elif element.tag == "DateTime": - self.ow_data.datetime = str(element.text) - elif element.tag == "owd_EDS0065": - self.__sensor_data_handler(element, sensor_type="EDS0065") - elif element.tag == "owd_EDS0068": - self.__sensor_data_handler(element, sensor_type="EDS0068") - - def __sensor_data_handler(self, element, sensor_type): - # pylint: disable=too-many-branches,too-many-statements - if sensor_type == "EDS0065": - eds0065_data = EDS0065DATA() - for sensor in element: - if sensor.tag == "ROMId": - eds0065_data.rom_id = str(sensor.text) - elif sensor.tag == "Name": - eds0065_data.device_type = str(sensor.text) - elif sensor.tag == "Health": - eds0065_data.health = int(sensor.text) - elif sensor.tag == "Channel": - eds0065_data.channel = int(sensor.text) - elif sensor.tag == "RawData": - eds0065_data.raw_data = str(sensor.text) - elif sensor.tag == "PrimaryValue": - data = sensor.text.split(" ")[0] - eds0065_data.relative_humidity = float(data) - elif sensor.tag == "Temperature": - eds0065_data.temperature = float(sensor.text) - elif sensor.tag == "Humidity": - eds0065_data.humidity = float(sensor.text) - elif sensor.tag == "DewPoint": - eds0065_data.dew_point = float(sensor.text) - elif sensor.tag == "Humidex": - eds0065_data.humidex = float(sensor.text) - elif sensor.tag == "HeatIndex": - eds0065_data.heat_index = float(sensor.text) - elif sensor.tag == "Version": - eds0065_data.version = float(sensor.text) - - self.ow_data.eds0065_data.append(eds0065_data) - elif sensor_type == "EDS0068": - eds0068_data = EDS0068DATA() - for sensor in element: - # print(sensor.tag, sensor.attrib, sensor.text) - if sensor.tag == "ROMId": - eds0068_data.rom_id = str(sensor.text) - elif sensor.tag == "Name": - eds0068_data.device_type = str(sensor.text) - elif sensor.tag == "Health": - eds0068_data.health = int(sensor.text) - elif sensor.tag == "Channel": - eds0068_data.channel = int(sensor.text) - elif sensor.tag == "RawData": - eds0068_data.raw_data = str(sensor.text) - elif sensor.tag == "PrimaryValue": - data = sensor.text.split(" ")[0] - eds0068_data.relative_humidity = float(data) - elif sensor.tag == "Temperature": - eds0068_data.temperature = float(sensor.text) - elif sensor.tag == "Humidity": - eds0068_data.humidity = float(sensor.text) - elif sensor.tag == "DewPoint": - eds0068_data.dew_point = float(sensor.text) - elif sensor.tag == "Humidex": - eds0068_data.humidex = float(sensor.text) - elif sensor.tag == "HeatIndex": - eds0068_data.heat_index = float(sensor.text) - elif sensor.tag == "BarometricPressureMb": - eds0068_data.pressure_mb = float(sensor.text) - elif sensor.tag == "BarometricPressureHg": - eds0068_data.pressure_hg = float(sensor.text) - elif sensor.tag == "Light": - eds0068_data.illuminance = int(sensor.text) - elif sensor.tag == "Version": - eds0068_data.version = float(sensor.text) - self.ow_data.eds0068_data.append(eds0068_data) - -class HttpResponseError(Exception): - """Response Error from OneWire""" - # pass - -class DeviceConnectionError(Exception): - """Device Connection Error from OneWire""" - # pass - - -if __name__ == "__main__": - OW_ADDRESS = "hs1wireblue" - OW_PORT = 80 - ow = ONEWIRE() - ow.connect(OW_ADDRESS, OW_PORT) - ow.get_data() - ow_sensors = ow.ow_data.read_sensors() - print(ow_sensors) diff --git a/src/hispec/util/onewire/pyproject.toml b/src/hispec/util/onewire/pyproject.toml deleted file mode 100644 index 46ee8f3..0000000 --- a/src/hispec/util/onewire/pyproject.toml +++ /dev/null @@ -1,22 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "onewire" -version = "0.1.0" -description = "OneWire controller software" -authors = [ - { name="Michael Langmayr", email="langmayr@caltech.edu" }, - { name="Don Neill", email="neill@astro.caltech.edu" }, - { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } -] -readme = "README.md" -requires-python = ">=3.7" -dependencies = [ - "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" -] -[tool.pytest.ini_options] -pythonpath = [ - "." -] diff --git a/src/hispec/util/onewire/scripts/influxdb_log.json b/src/hispec/util/onewire/scripts/influxdb_log.json deleted file mode 100644 index eb0c271..0000000 --- a/src/hispec/util/onewire/scripts/influxdb_log.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "db_url": "localhost:8086", - "db_token": "", - "db_org": "Organization", - "db_bucket": "bucket", - "db_channel": "Temperature", - "log_channels": { - "temperature": {"field": "temperature", - "units": "degC"}, - "humidity": {"field": "humidity", - "units": "Perc"}, - "dew_point": {"field": "dewpoint", - "units": "degC"} - }, - "log_locations": { - "1": "Rack loc1", - "2": "Rack loc2", - "3": "Rack loc3", - "4": "Rack loc4", - "5": "Rack loc5", - "6": "Rack loc6", - "7": "Rack loc7" - }, - "device_host": "onewire", - "device_port": 80, - "interval_secs": 30, - "verbose": 0, - "logfile": "" -} \ No newline at end of file diff --git a/src/hispec/util/onewire/scripts/influxdb_log.py b/src/hispec/util/onewire/scripts/influxdb_log.py deleted file mode 100644 index e64d53a..0000000 --- a/src/hispec/util/onewire/scripts/influxdb_log.py +++ /dev/null @@ -1,108 +0,0 @@ -"""Script for logging to InfluxDB.""" -import time -import sys -import json -import logging -from influxdb_client import InfluxDBClient, Point -from influxdb_client.client.write_api import SYNCHRONOUS -from urllib3.exceptions import ReadTimeoutError -import onewire - - -def main(config_file): - """Query user for setup info and start logging to InfluxDB.""" - # pylint: disable=too-many-statements,too-many-locals - - # read the config file - with open(config_file, encoding='utf-8') as cfg_file: - cfg = json.load(cfg_file) - - verbose = cfg['verbose'] == 1 - - # set up logging - logfile = cfg['logfile'] - if logfile is None: - logfile = __name__.rsplit('.', 1)[-1] - logger = logging.getLogger(logfile) - if verbose: - logger.setLevel(logging.DEBUG) - else: - logger.setLevel(logging.INFO) - # log to console by default - console_formatter = logging.Formatter( - '%(asctime)s - %(levelname)s - %(message)s') - console_handler = logging.StreamHandler() - console_handler.setFormatter(console_formatter) - logger.addHandler(console_handler) - # Do we have a logfile? - if cfg['logfile'] is not None: - # log to a file - formatter = logging.Formatter( - '%(asctime)s - %(levelname)s - %(funcName)s() - %(message)s') - file_handler = logging.FileHandler(logfile if ".log" in logfile else logfile + '.log') - file_handler.setFormatter(formatter) - logger.addHandler(file_handler) - - # get channels to log - channels = cfg['log_channels'] - locations = cfg['log_locations'] - - # Try/except to catch exceptions - db_client = None - try: - # Loop until ctrl-C - while True: - try: - # Connect to onewire - ow = onewire.ONEWIRE() - logger.info('Connecting to OneWire controller...') - ow.connect(cfg['device_host'], cfg['device_port']) - ow.get_data() - ow_data = ow.ow_data.read_sensors() - - # Connect to InfluxDB - logger.info('Connecting to InfluxDB...') - db_client = InfluxDBClient(url=cfg['db_url'], token=cfg['db_token'], - org=cfg['db_org']) - write_api = db_client.write_api(write_options=SYNCHRONOUS) - - for sens_no, sensor in enumerate(ow_data): - location = locations[str(sens_no+1)] - for chan in channels: - value = sensor[chan] - point = ( - Point("onewire") - .field(channels[chan]['field']+str(sens_no+1), value) - .tag("location", location) - .tag("units", channels[chan]['units']) - .tag("channel", f"{cfg['db_channel']}") - ) - write_api.write(bucket=cfg['db_bucket'], org=cfg['db_org'], record=point) - logger.debug(point) - - # Close db connection - logger.info('Closing connection to InfluxDB...') - db_client.close() - db_client = None - - # Handle exceptions - except ReadTimeoutError as e: - logger.critical("ReadTimeoutError: %s, will retry.", e) - except Exception as e: - logger.critical("Unexpected error: %s, will retry.", e) - - # Sleep for interval_secs - logger.info("Waiting %d seconds...", cfg['interval_secs']) - time.sleep(cfg['interval_secs']) - - except KeyboardInterrupt: - logger.critical("Shutting down InfluxDB logging...") - if db_client: - db_client.close() - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python influxdb_log.py ") - sys.exit(0) - main(sys.argv[1]) diff --git a/src/hispec/util/onewire/tests/test_onewire_basic.py b/src/hispec/util/onewire/tests/test_onewire_basic.py deleted file mode 100644 index 28e1d7d..0000000 --- a/src/hispec/util/onewire/tests/test_onewire_basic.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Perform basic tests.""" -import pytest - -import onewire -from onewire import ONEWIRE - -def test_not_connected(): - """Test not connected.""" - controller = ONEWIRE() - assert not controller.connected - -def test_connection_fail(): - """Test connection failure.""" - controller = ONEWIRE() - with pytest.raises(onewire.DeviceConnectionError): - controller.connect("127.0.0.1", 9999) diff --git a/src/hispec/util/ozoptics/.gitignore b/src/hispec/util/ozoptics/.gitignore deleted file mode 100644 index 15201ac..0000000 --- a/src/hispec/util/ozoptics/.gitignore +++ /dev/null @@ -1,171 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# PyPI configuration file -.pypirc diff --git a/src/hispec/util/ozoptics/LICENSE b/src/hispec/util/ozoptics/LICENSE deleted file mode 100644 index bce361a..0000000 --- a/src/hispec/util/ozoptics/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -MIT License - -Copyright (c) [year] [fullname] - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff --git a/src/hispec/util/ozoptics/README.md b/src/hispec/util/ozoptics/README.md deleted file mode 100644 index 577aba2..0000000 --- a/src/hispec/util/ozoptics/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# ozoptics_software - -Low-level python modules for operating OZ Optics attenuators - -## Currently Supported Models -- DD-100-MC (RS232) -- DD-600-MC (RS232) - -## Features -- Connect to OZ Optics attenuators over serial through a terminal server -- Query attenuator state and parameters -- Command full range of attenuation values - -## Requirements - -- Install base class from https://github.com/COO-Utilities/hardware_device_base - -## Installation - -```bash -pip install . -``` - -## Usage - -```python -import dd100mc - -controller = dd100mc.OZController() -controller.connect('192.168.29.153', 10001) - -controller.set_attenuation(36.5) -print(controller.get_attenuation()) -controller.set_position(5750) -print(controller.get_position()) - -# For a comprehensive list of classes and methods, use the help function -help(dd100mc) -``` - -## 🧪 Testing -Unit tests are located in `tests/` directory. - -To run all tests from the project root: - -```bash -python -m pytest -``` \ No newline at end of file diff --git a/src/hispec/util/ozoptics/dd100mc.py b/src/hispec/util/ozoptics/dd100mc.py deleted file mode 100644 index 62d04c1..0000000 --- a/src/hispec/util/ozoptics/dd100mc.py +++ /dev/null @@ -1,621 +0,0 @@ -# coding=utf-8 -""" -The following controller commands are available. Not that many are not implemented at the moment. -The asterisk indicates the commands that are implemented. - -A * - Sets attenuation to . - Two digits to the right of the decimal point are allowed, but not required. -A? * - Gets attenuation. -B * - Steps the attenuator one step backward. - Returns the final position after the command is completed. -CD - Sends the current unit configuration only to the RS-232 communications port. -CH - Sets the I2C address to the hexadecimal address - when the address is a valid I2C address between 0x00 and 0x7F. -CI - Sets the I2C address to the decimal address - when the address is a valid I2C address between 0 and 127. -CS - Sets the SPI parameters to and , - where is clock polarity and is data position. -D * - Gets current attenuation and step position. -E0 * - In RS-232 mode, sets echo to OFF. - The unit does not echo any characters received through the RS-232 interface. -E1 * - In RS-232 mode, sets echo to ON. - The unit echoes all characters received through the RS-232 interface. -EVA8 - Set the unit in EVA8 mode -EVA9 - Set the unit in EVA9 mode -EVA? - Requests the current EVA configuration mode -F * - Steps the attenuator one step forward. -H * - Re-homes the unit. -I2C? - Gets I2C/SPI bus Voltage -I2C3 - Sets I2C/SPI bus voltage to 3.3V -I2C5 - Sets I2C/SPI bus voltage to 5.0V -L - Sets the unit’s insertion loss to . - Two digits following the decimal point are allowed, but not required. -RES? * - Read previous command response -RST * - Restarts in response to a hardware or software reset (RST) command - and is in self-test mode. -S? * - Requests the current position of the attenuator. - Returns the current number of steps from the home position. -S * - Sets the position of the attenuator to steps from the home position. -S+ * - Sets the step position of the attenuator to steps - numerically greater than the current position. -S- * - Sets the step position of the attenuator to steps - numerically less than the current position. -W - Selects the wavelength using . - This command is valid only when the unit is calibrated for more than one wavelength. - -""" -import dataclasses -import enum -import errno -import time -import socket -from typing import Union - -from hardware_device_base import HardwareDeviceBase - -class ResponseType(enum.Enum): - """Controller response types.""" - ATTEN = "attenuation" - POS = "steps" - DIFF = "diff" - BOTH = "attenuation and steps" - STRING = "string" - ERROR = "error" - - -@dataclasses.dataclass -class OzResponse: - """Oz controller response data.""" - type: ResponseType - value: Union[float, int, str, dict, None] - - -class OZController(HardwareDeviceBase): - """ - Controller class for OZ Optics DD-100-MC Attenuator Controller. - """ - # pylint: disable=too-many-instance-attributes - - controller_commands = ["A", # Set attenuation - "A?", # Get attenuation - "B", # Move attenuator one step backward - "CD", # Configuration Display - "D", # Gets current attenuation and step position - "E0", # In RS232 mode, sets echo to OFF - "E1", # In RS232 mode, sets echo to ON - "F", # Move attenuator one step forward - "H", # Re-homes the unit - "L", # Insertion loss - "RES?", # Read previous command response - "RST", # Restarts in self-test mode - "S?", # Requests current position of the attenuator - "S", # Sets the position of the attenuator to steps from home - "S+", # Adds steps to current position - "S-" # Subtracts steps from current position - ] - return_value_commands = ["A", "A?", "B", "CD", "D", "F", "H", "L", - "RES?", "RST", "S?", "S", "S+", "S-" ] - parameter_commands = ["A", "L", "S", "S+", "S-"] - error = { - "Done": "No error.", - "Error-2": "Bad command. The command is ignored.", - "Error-5": "Home sensor error. Return unit to factory for repair.", - "Error-6": "Overflow. The command is ignored.", - "Error-7": "Motor voltage exceeds safe limits" - } - - def __init__(self, log: bool =True, logfile: str =__name__.rsplit(".", 1)[-1]): - - """ - Class to handle communications with the stage controller and any faults - - :param log: Boolean, whether to log to file or not - :param logfile: Filename for log - - NOTE: default is INFO level logging, use set_verbose to increase verbosity. - """ - super().__init__(log, logfile) - - # Set up socket - self.socket = None - - self.current_attenuation = None - self.current_position = None - self.current_diff = None - self.configuration = "" - self.homed = False - self.last_error = "" - - def _clear_socket(self): - """ Clear socket buffer. """ - if self.socket is not None: - self.socket.setblocking(False) - while True: - try: - _ = self.socket.recv(1024) - except BlockingIOError: - break - self.socket.setblocking(True) - - def _read_reply(self) -> dict: - """Read the return message from stage controller.""" - # Get return value - recv = self.socket.recv(2048) - - # Did we get the entire return? - tries = 5 - while tries > 0 and b'Done' not in recv: - recv += self.socket.recv(2048) - if b'Error' in recv: - self.logger.error(recv) - return {'error': self._return_parse_error(str(recv.decode('utf-8')))} - tries -= 1 - - recv_len = len(recv) - self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) - - if b'Done' not in recv: - self.logger.warning("Read from controller timed out") - msg_type = 'error' - msg_data = str(recv.decode('utf-8')) - else: - resp = self._parse_response(str(recv.decode('utf-8'))) - msg_data = resp.value - if resp.type == ResponseType.ERROR: - msg_type = 'error' - else: - msg_type = 'data' - - return {msg_type: msg_data} - - def _parse_response(self, raw: str) -> OzResponse: - """Parse the response from stage controller.""" - # pylint: disable=too-many-branches - raw = raw.strip() - - if 'Pos:' in raw: - try: - pos = int(raw.split('Pos:')[1].split()[0]) - self.current_position = pos - pos_read = True - except ValueError: - self.logger.error("Error parsing position") - pos = None - pos_read = False - else: - pos = None - pos_read = False - - if 'Atten:' in raw: - try: - if 'unknown' in raw: - atten = None - else: - atten = float(raw.split('Atten:')[1].split('(')[0]) - self.current_attenuation = atten - atten_read = True - except ValueError: - self.logger.error("Error parsing attenuation") - atten = None - atten_read = False - else: - atten = None - atten_read = False - - # Diff (after homing) - if 'Diff=' in raw: - try: - diff = float(raw.split('Diff=')[1].split()[0]) - self.current_diff = diff - self.current_position = 0 - diff_read = True - except ValueError: - self.logger.error("Error parsing diff") - diff = None - diff_read = False - else: - diff = None - diff_read = False - - # Error case - if 'Error' in raw: - return OzResponse(ResponseType.ERROR, raw) - - # Both Attenuation and Steps - if pos_read and atten_read: - return OzResponse(ResponseType.BOTH, {"pos": pos, "atten": atten}) - - # Attenuation - if atten_read: - return OzResponse(ResponseType.ATTEN, atten) - - # Pos - if pos_read: - return OzResponse(ResponseType.POS, pos) - - # Diff (after homing) - if diff_read: - return OzResponse(ResponseType.DIFF, diff) - - # Default to string - return OzResponse(ResponseType.STRING, raw) - - def _send_serial_command(self, cmd=''): - """ - Send serial command to stage controller - - :param cmd: String, command to send to stage controller - :return: dictionary {'data|error': string_message} - """ - - # check connection - if not self.connected: - msg_text = "Not connected to controller!" - self.logger.error(msg_text) - - # Prep command - cmd_send = f"{cmd}\r\n" - self.logger.debug("Sending command: %s", cmd_send) - cmd_encoded = cmd_send.encode('utf-8') - - try: - self.socket.settimeout(30) - # Send command - self.socket.send(cmd_encoded) - time.sleep(.05) - msg_type = 'data' - msg_text = 'Command sent successfully' - - except socket.error as ex: - msg_type = 'error' - msg_text = f"Command send error: {ex.strerror}" - self.logger.error(msg_text) - - return {msg_type: msg_text} - - def _send_command(self, command: str, *args, custom_command=False) -> dict: - """ - Send a command to the stage controller - - :param command: String, command to send to the stage controller - :param *args: List of string parameters associated with cmd - :param custom_command: Boolean, if true, command is custom - :return: dictionary {'data|error': string_message} - """ - - # verify cmd and stage_id - ret = self._verify_send_command(command, custom_command) - if 'error' in ret: - return ret - - # Check if the command should have parameters - if command in self.parameter_commands and args: - self.logger.debug("Adding parameters") - parameters = [str(x) for x in args] - parameters = "".join(parameters) - command += parameters - - self.logger.debug("Input command: %s", command) - - # Send serial command - with self.lock: - result = self._send_serial_command(command) - - return result - - def _verify_send_command(self, cmd, custom_command=False): - """ Verify cmd and stage_id - - :param cmd: String, command to send to the stage controller - :param custom_command: Boolean, if true, command is custom - :return: dictionary {'data|error': string_message}""" - - # Do we have a connection? - if not self.connected: - msg_type = 'error' - msg_text = 'Not connected to controller' - - else: - # Do we have a legal command? - if cmd.rstrip().upper() in self.controller_commands: - msg_type = 'data' - msg_text = f"{cmd} is a valid or custom command" - else: - if not custom_command: - msg_type = 'error' - msg_text = f"{cmd} is not a valid command" - else: - msg_type = 'data' - msg_text = f"{cmd} is a custom command" - - return {msg_type: msg_text} - - def _return_parse_error(self, error=""): - """ - Parse the return error message from the controller. The message code is - given in the last string character - - :param error: Error code from the controller - :return: String message - """ - error = error.rstrip() - return self.error.get(error, "Unknown error") - - # --- User-Facing Methods - def connect(self, *args, con_type: str="tcp") -> None: - """ Connect to stage controller. - - :param args: for tcp connection, host and port, for serial, port and baudrate - :param con_type: tcp or serial - """ - if self.validate_connection_params(args): - if con_type == "tcp": - host = args[0] - port = args[1] - if self.socket is None: - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.socket.connect((host, port)) - self.logger.info("Connected to %s:%d", host, port) - self._set_connected(True) - - except OSError as ex: - if ex.errno == errno.EISCONN: - self.logger.debug("Already connected") - self._set_connected(True) - else: - self.logger.error("Connection error: %s", ex.strerror) - self._set_connected(False) - # clear socket - if self.is_connected(): - self._clear_socket() - elif con_type == "serial": - self.logger.error("Serial connection not implemented") - self._set_connected(False) - else: - self.logger.error("Unknown con_type: %s", con_type) - self._set_connected(False) - else: - self.logger.error("Invalid connection args: %s", args) - self._set_connected(False) - - def disconnect(self): - """ Disconnect stage controller. """ - try: - self.socket.shutdown(socket.SHUT_RDWR) - self.socket.close() - self.socket = None - self.logger.debug("Disconnected controller") - self._set_connected(False) - except OSError as ex: - self.logger.error("Disconnection error: %s", ex.strerror) - self._set_connected(False) - self.socket = None - - def home(self): - """ - Home the stage - - :return: return from __send_command - """ - - if not self.homed: - ret = self._send_command('H') - self.current_attenuation = None - self.current_position = None - - if 'data' in ret: - ret = self._read_reply() - if 'error' in ret: - self.logger.error(ret['error']) - else: - self.logger.debug(ret['data']) - self.homed = True - else: - self.logger.error(ret['error']) - else: - ret = {'data': 'already homed' } - - return ret - - def get_atomic_value(self, item: str ="") -> Union[float, int, str, None]: - """Return single value for item""" - if "pos" in item: - result = self.get_position() - if 'error' in result: - self.logger.error(result['error']) - value = None - else: - value = int(result['data']) - elif "atten" in item: - value = self.current_attenuation - else: - self.logger.error("Unknown item: %s, choose pos or atten", item) - value = None - return value - - def set_attenuation(self, atten: float=None): - """ - Move stage to input attenuation and return when in position - - :param atten: Float, absolute attenuation in dB (0. - 60.) - :return: dictionary {'data|error': current_attenuation|string_message} - """ - # check attenuation limits - if atten is None or atten < 0.0 or atten > 60.0: - self.logger.error("Invalid attenuation: %s, cannot be < 0. or > 60.", atten) - return {'error': 'Invalid attenuation'} - - # Send move to controller - ret = self._send_command("A", atten) - - if 'data' in ret: - ret = self._read_reply() - if 'error' in ret: - self.logger.error(ret['error']) - else: - time.sleep(0.5) - cur_atten = self.get_attenuation()['data'] - self.logger.debug(cur_atten) - if cur_atten != atten: - self.logger.error("Attenuation setting not achieved!") - return {'data': cur_atten} - - return ret - - def set_position(self, pos=None): - """ - Move stage to absolute position and return when in position - - :param pos: Int, absolute position in steps - :return: dictionary {'data|error': current_attenuation|string_message} - """ - - # Send move to controller - ret = self._send_command("S", pos) - - if 'data' in ret: - ret = self._read_reply() - if 'error' in ret: - self.logger.error(ret['error']) - else: - time.sleep(0.5) - cur_pos = ret['data'] - self.logger.debug(cur_pos) - if cur_pos != pos: - self.logger.error("Position not achieved!") - self.get_attenuation() - return {'data': cur_pos} - - return ret - - def step(self, direction:str = 'F'): - """ - Move stage to relative position and return when in position - :param direction: String, 'F' - forward or 'B' - backward - :return: dictionary {'data|error': current_position|string_message} - """ - direc = direction.upper() - # check inputs - if direc not in ['F', 'B']: - self.logger.error("Invalid direction: use F or B") - return {'error': 'Invalid direction'} - - ret = self._send_command(direc) - if 'data' in ret: - ret = self._read_reply() - if 'error' in ret: - self.logger.error(ret['error']) - else: - self.logger.debug(ret['data']) - cur_pos = ret['data'] - if cur_pos != self.current_position: - self.logger.error("Position setting not achieved!") - self.current_position = cur_pos - return {'data': cur_pos} - return ret - - def get_position(self): - """ Current position - - :return: dictionary {'data|error': current_position|string_message} - """ - - ret = self._send_command("S?") - if 'data' in ret: - ret = self._read_reply() - if 'error' in ret: - self.logger.error(ret['error']) - else: - self.logger.debug(ret['data']) - return ret - - def get_attenuation(self): - """ Current attenuation - - :return: dictionary {'data|error': current_attenuation|string_message} - """ - - ret = self._send_command("A?") - if 'data' in ret: - ret = self._read_reply() - if 'error' in ret: - self.logger.error(ret['error']) - else: - self.logger.debug(ret['data']) - return ret - - def reset(self): - """ Reset stage - - :return: return from __send_command - """ - - ret = self._send_command("RS") - time.sleep(2.) - - if 'data' in ret: - ret = self._read_reply() - if 'error' in ret: - self.logger.error(ret['error']) - else: - self.logger.debug(ret['data']) - - return ret - - def get_params(self): - """ Get stage parameters - - :return: return from __send_command - """ - - ret = self._send_command("CD") - - if 'data' in ret: - ret = self._read_reply() - if 'error' in ret: - self.logger.error(ret['error']) - else: - self.logger.debug(ret['data']) - self.configuration = ret['data'] - - return ret - - def initialize_controller(self): - """ Initialize stage controller. """ - ret = self.home() - if 'error' in ret: - self.logger.error(ret['error']) - return ret - - def read_from_controller(self): - """ Read from controller""" - self.socket.setblocking(False) - try: - recv = self.socket.recv(2048) - recv_len = len(recv) - self.logger.debug("Return: len = %d, Value = %s", recv_len, recv) - except BlockingIOError: - recv = b"" - self.socket.setblocking(True) - return str(recv.decode('utf-8')) - - def run_manually(self): - """ Input stage commands manually - - :return: None - """ - - while True: - - cmd = input("Enter Command") - - if not cmd: - break - - ret = self._send_command(cmd, custom_command=True) - if 'error' not in ret: - output = self.read_from_controller() - self.logger.info(output) - - self.logger.info("End: %s", ret) diff --git a/src/hispec/util/ozoptics/pyproject.toml b/src/hispec/util/ozoptics/pyproject.toml deleted file mode 100644 index a3380a0..0000000 --- a/src/hispec/util/ozoptics/pyproject.toml +++ /dev/null @@ -1,48 +0,0 @@ -[build-system] -# Specifies the build system to use. -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" - -[project] -# Basic information about your project. -name = "ozoptics" -version = "0.1.0" -dependencies = [ - "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main" -] -requires-python = ">=3.7" -authors = [ - {name = "Don Neill", email = "neill@astro.caltech.edu"} -] -maintainers = [ - {name = "Don Neill", email = "neill@astro.caltech.edu"} -] -description = "OZ Optics attenuator software" -readme = "README.md" -license = { text = "MIT" } -# keywords = ["example", "package", "keywords"] -classifiers = [ - "Programming Language :: Python" -] - -[project.urls] -# Various URLs related to your project. These links are displayed on PyPI. -# Homepage = "https://example.com" -# Documentation = "https://readthedocs.org" -Repository = "https://github.com/COO-Utils/ozoptics" -# "Bug Tracker" = "https://github.com/yourusername/your-repo/issues" -# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" - -[project.scripts] -# Defines command-line scripts for your package. Replace with your script and function. -# your-command = "your_module:your_function" - -[project.optional-dependencies] -# Optional dependencies that can be installed with extra tags, like "dev". -dev = [ - "pytest" -] -[tool.pytest.ini_options] - pythonpath = [ - "." - ] diff --git a/src/hispec/util/ozoptics/tests/test_basic.py b/src/hispec/util/ozoptics/tests/test_basic.py deleted file mode 100644 index 7241664..0000000 --- a/src/hispec/util/ozoptics/tests/test_basic.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Perform basic tests.""" -from dd100mc import OZController - -def test_initialization(): - """Test initialization.""" - controller = OZController() - assert not controller.connected - -def test_connection_fail(): - """Test connection failure.""" - controller = OZController() - controller.connect("127.0.0.1", 50000) - assert not controller.connected diff --git a/src/hispec/util/pi/.gitignore b/src/hispec/util/pi/.gitignore deleted file mode 100644 index 15201ac..0000000 --- a/src/hispec/util/pi/.gitignore +++ /dev/null @@ -1,171 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# PyPI configuration file -.pypirc diff --git a/src/hispec/util/pi/LICENSE b/src/hispec/util/pi/LICENSE deleted file mode 100644 index bce361a..0000000 --- a/src/hispec/util/pi/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -MIT License - -Copyright (c) [year] [fullname] - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff --git a/src/hispec/util/pi/README.md b/src/hispec/util/pi/README.md deleted file mode 100644 index 3431485..0000000 --- a/src/hispec/util/pi/README.md +++ /dev/null @@ -1,99 +0,0 @@ -# pi_controller - -Low-level Python library to control PI 863 and PI 663 motion controllers using the [pipython](https://pypi.org/project/pipython/) library. - -## Features -- Connect to a single PI controller over TCP/IP, including through terminal server -- Connect to a TCP/IP-based daisy chain via a terminal server -- Automatically detect and select connected devices in a daisy chain -- Manage multiple controllers in the same daisy chain -- Log controller actions and errors (with optional quiet mode) -- Check the reference (home) state and execute reference moves (homing) -- Store and recall named positions per controller -- Query and set servo status (open/close firmware loops) -- Query motion status -- Emergency halt of motion -- Structured error handling via `pipython.GCSError` - -## Example Usage -```python -from pi import PIControllerBase - -# Connect to a Single Controller -controller = PIControllerBase() -controller.connect_tcp('192.168.0.100') -device_key = ('192.168.0.100', 50000, 1) - -print(controller.get_idn(device_key)) - -# Connect to a Daisy Chain -controller = PIControllerBase() -controller.connect_tcpip_daisy_chain("192.168.29.100", 10005) - -# List available devices -devices = controller.list_devices_on_chain("192.168.29.100", 10005) -for device_id, desc in devices: - print(f"Device {device_id}: {desc}") - -# Use a device_key for further operations -device_key = ("192.168.29.100", 10005, 2) -print("Now on device 2:", controller.get_idn(device_key)) - -# Check if axis '1' is referenced -is_referenced = controller.is_controller_referenced(device_key, '1') -print("Axis 1 referenced:", is_referenced) - -# Perform a reference move (home) on axis '1' -success = controller.reference_move(device_key, '1', method="FRF", blocking=True, timeout=30) -if success: - print("Reference move completed successfully.") -else: - print("Reference move failed or timed out.") - -# Move axis 1 to position 12.0 -controller.set_position(device_key, '1', 12.0) - -# Save current position as "home" -controller.set_named_position(device_key, '1', 'home') - -# Later on, move back to "home" position -controller.go_to_named_position(device_key, 'home') - -controller.disconnect_device(device_key) -controller.disconnect_all() -``` - -## API Summary -| Method | Description | -|----------------------------------------------|----------------------------------------------| -| `connect_tcp(ip, port=50000)` | Connect to a single PI controller | -| `connect_tcpip_daisy_chain(ip, port)` | Open a TCP-based daisy chain | -| `list_devices_on_chain(ip, port)` | Return list of connected devices for a chain | -| `get_idn(device_key)` | Get the controller identification string | -| `get_serial_number(device_key)` | Get the serial number from the IDN | -| `get_axes(device_key)` | Return available axes | -| `get_position(device_key, axis_index)` | Get current position of axis by index | -| `servo_status(device_key, axis)` | Check if the servo on an axis is enabled | -| `get_error_code(device_key)` | Get the controller's last error code | -| `halt_motion(device_key)` | Stop all motion on the controller | -| `set_position(device_key, axis, position)` | Move an axis to a position | -| `set_named_position(device_key, axis, name)` | Save a position under a named label | -| `go_to_named_position(name)` | Move to a previously saved named position | -| `disconnect_device(device_key)` | Disconnect a single device | -| `disconnect_all()` | Disconnect all devices | - - -## Logging -By default, the controller logs info and error messages to the console. You can suppress logs (except warnings/errors) by passing quiet=True: -```python -controller = PIControllerBase(quiet=True) -``` - -## 🧪 Testing -Unit tests are located in `tests/` directory and use `pytest` with `unittest.mock` to simulate hardware behavior — no physical PI controller is required. - -To run all tests from the project root: - -```bash -pytest tests/ -``` \ No newline at end of file diff --git a/src/hispec/util/pi/__init__.py b/src/hispec/util/pi/__init__.py deleted file mode 100644 index 072b75e..0000000 --- a/src/hispec/util/pi/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""This module provides a base class for PI controllers.""" -from .pi_controller import PIControllerBase - -__all__ = ["PIControllerBase"] diff --git a/src/hispec/util/pi/pi_controller.py b/src/hispec/util/pi/pi_controller.py deleted file mode 100644 index 0815a9e..0000000 --- a/src/hispec/util/pi/pi_controller.py +++ /dev/null @@ -1,355 +0,0 @@ -""" -This module provides a base class for communicating with PI (Physik Instrumente) motion controllers -""" -import json -import os -import time -import logging -from pipython import GCSDevice, GCSError - - -class PIControllerBase: # pylint: disable=too-many-public-methods - """ - Base class for communicating with PI (Physik Instrumente) motion controllers daisy-chained - over TCP/IP via a terminal server. - """ - - def __init__(self, quiet=False): - """ - Initialize the controller, set up logging, and prepare device storage. - """ - self.devices = {} # {(ip, port, device_id): GCSDevice instance} - self.daisy_chains = {} # {(ip, port): [(device_id, desc)]} - self.connected = False - self.named_position_file = "config/pi_named_positions.json" - - # Logging - logfile = __name__.rsplit('.', 1)[-1] + '.log' - self.logger = logging.getLogger(logfile) - self.logger.setLevel(logging.INFO) - formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) - if not quiet: - file_handler = logging.FileHandler(logfile) - file_handler.setFormatter(formatter) - self.logger.addHandler(file_handler) - - def _require_connection(self): - """ - Raise an error if not connected to any device. - """ - if not self.connected: - raise RuntimeError("Controller is not connected") - - def connect_tcp(self, ip_address, port=50000): - """ - Connect to a single PI controller via TCP/IP (non-daisy-chain). - """ - device = GCSDevice() - device.ConnectTCPIP(ip_address, port) - self.devices[(ip_address, port, 1)] = device - self.connected = True - self.logger.info("Connected to single PI controller at %s:%s", ip_address, port) - - def connect_tcpip_daisy_chain(self, ip_address, port, blocking=True): - """ - Connect to all available devices on a daisy-chained set of PI controllers via TCP/IP. - Each device is a separate GCSDevice instance. - """ - main_device = GCSDevice() - devices = main_device.OpenTCPIPDaisyChain(ip_address, port) - dcid = main_device.dcid - - available = [] - for index, desc in enumerate(devices, start=1): - if "not connected" not in desc.lower(): - available.append((index, desc)) - - if not available: - raise RuntimeError(f"No connected devices found at {ip_address}:{port}") - - self.daisy_chains[(ip_address, port)] = available - - for device_id, desc in available: - if device_id == 1: - dev = main_device - else: - dev = GCSDevice() - - dev.ConnectDaisyChainDevice(device_id, dcid) - self.devices[(ip_address, port, device_id)] = dev - self.logger.info("[{ip}:{port}] Connected to device %s: %s", device_id, desc) - - self.connected = True - - if blocking: - # Wait until all devices are ready - while not all(dev.IsControllerReady() for dev in self.devices.values()): - time.sleep(0.1) - - def disconnect_device(self, device_key): - """ - Disconnect from a single device specified by device_key. - """ - if device_key in self.devices: - self.devices[device_key].CloseConnection() - del self.devices[device_key] - self.logger.info("Disconnected device %s", device_key) - if not self.devices: - self.connected = False - - def disconnect_all(self): - """ - Disconnect from all devices (e.g., the whole daisychain). - """ - for device_key in list(self.devices.keys()): - self.devices[device_key].CloseConnection() - self.logger.info("Disconnected device %s", device_key) - self.devices.clear() - self.connected = False - self.logger.info("Disconnected from all PI controllers") - - def list_devices_on_chain(self, ip_address, port): - """ - Return the list of available (device_id, description) tuples for the given daisy chain. - """ - if (ip_address, port) not in self.daisy_chains: - raise ValueError(f"No daisy chain found at {ip_address}:{port}") - return self.daisy_chains[(ip_address, port)] - - def is_connected(self) -> bool: - """ - Check if the controller is connected to any device. - """ - return self.connected - - def get_idn(self, device_key) -> str: - """ - Return the identification string for the specified device. - """ - self._require_connection() - return self.devices[device_key].qIDN() - - def get_serial_number(self, device_key) -> str: - """ - Return the serial number for the specified device. - """ - idn = self.get_idn(device_key) - return idn.split(",")[-2].strip() - - def get_axes(self, device_key): - """ - Return the list of axes for the specified device. - """ - self._require_connection() - return self.devices[device_key].axes - - def get_position(self, device_key, axis): - """ - Return the position of the specified axis for the given device. - """ - self._require_connection() - device = self.devices[device_key] - try: - return device.qPOS(axis)[axis] - except (GCSError, IndexError) as ex: - self.logger.error("Error getting position: %s", ex) - return None - - def servo_status(self, device_key, axis): - """ - Return True if the servo for the given axis is enabled, False otherwise. - """ - self._require_connection() - try: - return bool(self.devices[device_key].qSVO(axis)[axis]) - except GCSError as ex: - self.logger.error("Error checking servo status: %s", ex) - return False - - def get_error_code(self, device_key): - """ - Return the error code for the specified device, or None if an error occurs. - """ - self._require_connection() - try: - return self.devices[device_key].qERR() - except GCSError as ex: - self.logger.error("Error getting error code: %s", ex) - return None - - def halt_motion(self, device_key): - """ - Halt all motion for the specified device. - """ - self._require_connection() - try: - self.devices[device_key].HLT() - except GCSError as ex: - self.logger.error("Error halting motion: %s", ex) - - def set_position(self, device_key, axis, position, blocking=True): - """ - Move the specified axis to the given position for the specified device. - If blocking=True, wait until move is complete. - """ - self._require_connection() - try: - self.devices[device_key].MOV(axis, position) - if blocking: - while self.is_moving(device_key, axis): - time.sleep(0.1) - - except GCSError as ex: - self.logger.error("Error setting position: %s", ex) - - def set_named_position(self, device_key, axis, name): - """ - Save the current position of the axis under a named label, scoped to - the controller serial number. - """ - device = self.devices[device_key] - try: - pos = device.qMOV(axis)[axis] - except (GCSError, OSError, ValueError): - pos = self.get_position(device_key, axis) - - if pos is None: - self.logger.warning("Could not get position for axis %s", axis) - return - - serial = self.get_serial_number(device_key) - positions = {} - - if os.path.exists(self.named_position_file): - with open(self.named_position_file, "r") as file: - try: - positions = json.load(file) - except json.JSONDecodeError: - self.logger.warning( - "Could not parse JSON from %s", self.named_position_file - ) - - if serial not in positions: - positions[serial] = {} - - positions[serial][name] = [axis, pos] - - with open(self.named_position_file, "w") as file: - json.dump(positions, file, indent=2) - - self.logger.info( - "Saved position '%s' for controller %s, axis %s: %s", name, serial, axis, pos - ) - - def go_to_named_position(self, device_key, name, blocking=True): - """ - Move the specified device's axis to a previously saved named position. - """ - serial = self.get_serial_number(device_key) - - if not os.path.exists(self.named_position_file): - self.logger.warning( - "Named positions file not found: %s", self.named_position_file - ) - return - - try: - with open(self.named_position_file, "r") as file: - positions = json.load(file) - except json.JSONDecodeError: - self.logger.warning( - "Failed to read positions from %s", self.named_position_file - ) - return - - if serial not in positions: - self.logger.warning("No named positions found for controller %s", serial) - return - - if name not in positions[serial]: - self.logger.warning( - "Named position '%s' not found for controller %s", name, serial - ) - return - - axis, pos = positions[serial][name] - self.set_position(device_key, axis, pos, blocking) - self.logger.info( - "Moved axis %s to named position '%s' for controller %s: %s", axis, name, serial, pos - ) - - def is_moving(self, device_key, axis): - """Check if stage/axis is moving.""" - self._require_connection() - return self.devices[device_key].IsMoving(axis)[axis] - - def set_servo(self, device_key, axis, enable=True): - """Open (enable) or close (disable) servo loop.""" - self._require_connection() - return self.devices[device_key].SVO(axis, int(enable)) - - def get_limit_min(self, device_key, axis): - """Query stage minimum limit.""" - self._require_connection() - return self.devices[device_key].qTMN(axis)[axis] - - def get_limit_max(self, device_key, axis): - """Query stage maximum limit.""" - self._require_connection() - return self.devices[device_key].qTMX(axis)[axis] - - def is_controller_ready(self, device_key): - """Check if stage/controller is ready.""" - self._require_connection() - return self.devices[device_key].IsControllerReady() - - def is_controller_referenced(self, device_key, axis): - """Check reference/home state for axis.""" - self._require_connection() - return self.devices[device_key].qFRF(axis)[axis] - - def reference_move(self, device_key, axis, method="FRF", blocking=True, timeout=30): # pylint:disable=too-many-arguments - """ - Execute a reference/home move (FRF, FNL, FPL). - method: which command to use ("FRF", "FNL", "FPL") - blocking: if True, wait until move is complete - Returns True if successful, False otherwise. - """ - self._require_connection() - allowed_methods = {"FRF", "FNL", "FPL"} - if method not in allowed_methods: - self.logger.error( - "Invalid reference method: %s. Must be one of %s", method, allowed_methods - ) - return False - - device = self.devices[device_key] - - # Check if the device supports the specified method - if not getattr(device, "Has%s", method)(): - self.logger.error("Device %s does not support method '%s'", device_key, method) - return False - - try: - getattr(device, method)(axis) - self.logger.info( - "Started reference move '%s' on axis %s (device %s)", method, axis, device_key - ) - if blocking: - start_time = time.time() - while self.is_moving(device_key, axis): - if time.time() - start_time > timeout: - self.logger.error( - "Reference move timed out after %s seconds on axis %s", timeout, axis - ) - return False - time.sleep(0.1) - - return True - except (GCSError, OSError, ValueError) as ex: - self.logger.error( - "Error during reference move '%s' on axis %s: %s", method, axis, ex - ) - return False diff --git a/src/hispec/util/pi/pyproject.toml b/src/hispec/util/pi/pyproject.toml deleted file mode 100644 index b28c9e0..0000000 --- a/src/hispec/util/pi/pyproject.toml +++ /dev/null @@ -1,50 +0,0 @@ -[build-system] -requires = ["setuptools>=61"] -build-backend = "setuptools.build_meta" - -[project] -# Basic information about your project. -name = "pi" -version = "0.1.0" -dependencies = [ - "pipython" -] -requires-python = ">=3.9" -authors = [ - {name = "Michael Langmayr", email = "langmayr@caltech.edu"} -] -maintainers = [ - {name = "Michael Langmayr", email = "langmayr@caltech.edu"} -] -description = "Low-level Python library to control PI 863 and PI 663 motion controllers using the [pipython](https://pypi.org/project/pipython/) library." -readme = "README.md" -license = "MIT" -classifiers = [ - "Programming Language :: Python" -] - -[project.urls] -# Various URLs related to your project. These links are displayed on PyPI. -# Homepage = "https://example.com" -# Documentation = "https://readthedocs.org" -Repository = "https://github.com/COO-Utils/pi" -# "Bug Tracker" = "https://github.com/yourusername/your-repo/issues" -# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" - -[project.optional-dependencies] -# Optional dependencies that can be installed with extra tags, like "dev". -dev = [ - "pytest", - "black", - "flake8" -] - -[tool.pytest.ini_options] -testpaths = ["tests"] - -[tool.setuptools.packages.find] -where = ["."] - -[tool.setuptools] -py-modules = ["pi"] - diff --git a/src/hispec/util/pi/tests/__init__.py b/src/hispec/util/pi/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/pi/tests/test_pi_basic.py b/src/hispec/util/pi/tests/test_pi_basic.py deleted file mode 100644 index a172dcf..0000000 --- a/src/hispec/util/pi/tests/test_pi_basic.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -# Test for the PIControllerBase class from the hispec.util module -""" -import pytest -# pylint: disable=import-error, no-name-in-module -from pi import PIControllerBase - -def test_initialization(): - """Test that the PIControllerBase initializes correctly.""" - controller = PIControllerBase() - assert not controller.connected - - -def test_connection_fail(): - """Test that connecting to an invalid IP raises an exception.""" - with pytest.raises(Exception): - controller = PIControllerBase() - controller.connect_tcp(ip_address="10.0.0.1", port=50000) diff --git a/src/hispec/util/pi/tests/test_pi_mock.py b/src/hispec/util/pi/tests/test_pi_mock.py deleted file mode 100644 index 5146efb..0000000 --- a/src/hispec/util/pi/tests/test_pi_mock.py +++ /dev/null @@ -1,207 +0,0 @@ -"""Test cases for the PIControllerBase class in pi_controller module.""" -import json -from unittest.mock import MagicMock, patch -# pylint: disable=import-error, no-name-in-module -from pi import PIControllerBase - - -@patch("pi.pi_controller.GCSDevice") -def test_connect_tcpip_daisy_chain(mock_gcs_device_cls): - """Test connecting to a TCP/IP daisy chain.""" - mock_device = MagicMock() - mock_device.OpenTCPIPDaisyChain.return_value = [ - "PI Device 1", - "PI Device 2", - "", - ] - mock_device.dcid = 1 - mock_gcs_device_cls.return_value = mock_device - - controller = PIControllerBase() - controller.connect_tcpip_daisy_chain("192.168.29.100", 10003) - - mock_device.OpenTCPIPDaisyChain.assert_called_once_with("192.168.29.100", 10003) - assert controller.connected - assert controller.daisy_chains[("192.168.29.100", 10003)] == [ - (1, "PI Device 1"), - (2, "PI Device 2"), - ] - assert (1, "PI Device 1") in controller.daisy_chains[("192.168.29.100", 10003)] - assert (2, "PI Device 2") in controller.daisy_chains[("192.168.29.100", 10003)] - assert ("192.168.29.100", 10003, 1) in controller.devices - assert ("192.168.29.100", 10003, 2) in controller.devices - - -def test_list_devices_on_chain(): - """Test listing devices on a daisy chain.""" - controller = PIControllerBase(quiet=True) - ip_port = ("192.168.29.100", 10003) - controller.daisy_chains[ip_port] = [(1, "PI Device 1"), (2, "PI Device 2")] - - devices = controller.list_devices_on_chain(*ip_port) - assert devices == [(1, "PI Device 1"), (2, "PI Device 2")] - - -@patch("pi.pi_controller.GCSDevice") -def test_connect_disconnect_device(mock_gcs_device_cls): - """Test connecting and disconnecting a device.""" - mock_device = MagicMock() - mock_gcs_device_cls.return_value = mock_device - - controller = PIControllerBase(quiet=True) - controller.connect_tcp("127.0.0.1", 50000) - device_key = ("127.0.0.1", 50000, 1) - - mock_device.ConnectTCPIP.assert_called_once_with("127.0.0.1", 50000) - assert controller.connected - - controller.disconnect_device(device_key) - mock_device.CloseConnection.assert_called_once() - assert not controller.connected - - -def test_disconnect_all(): - """Test disconnecting all devices.""" - mock_device1 = MagicMock() - mock_device2 = MagicMock() - controller = PIControllerBase(quiet=True) - controller.devices[("ip", 1, 1)] = mock_device1 - controller.devices[("ip", 1, 2)] = mock_device2 - controller.connected = True - - controller.disconnect_all() - mock_device1.CloseConnection.assert_called_once() - mock_device2.CloseConnection.assert_called_once() - assert not controller.devices - assert not controller.connected - - -def test_get_serial_number(): - """Test getting the serial number of a device.""" - controller = PIControllerBase(quiet=True) - controller.connected = True - device_key = ("ip", 1, 1) - device = MagicMock() - device.qIDN.return_value = "PI,Model,123456,1.0.0" - controller.devices[device_key] = device - - serial = controller.get_serial_number(device_key) - assert serial == "123456" - - -@patch("pi.pi_controller.GCSDevice") -def test_get_position(mock_gcs_device_cls): - """Test getting the position of an axis.""" - mock_device = MagicMock() - mock_gcs_device_cls.return_value = mock_device - - controller = PIControllerBase(quiet=True) - controller.connected = True - device_key = ("ip", 1, 1) - mock_device.axes = ["1", "2"] - mock_device.qPOS.return_value = {"1": 42.0} - controller.devices[device_key] = mock_device - - pos = controller.get_position(device_key, "1") - assert pos == 42.0 - mock_device.qPOS.assert_called_once_with("1") - - -def test_set_named_position(tmp_path): - """Test setting a named position.""" - controller = PIControllerBase(quiet=True) - controller.named_position_file = tmp_path / "positions.json" - controller.connected = True - device_key = ("ip", 1, 1) - device = MagicMock() - device.axes = ["1"] - # Mock qMOV to return a real float value - device.qMOV.return_value = {"1": 10.0} - controller.devices[device_key] = device - controller.get_serial_number = MagicMock(return_value="123456") - - controller.set_named_position(device_key, "1", "home") - - with open(controller.named_position_file) as file: - data = json.load(file) - - assert "123456" in data - assert "home" in data["123456"] - assert data["123456"]["home"][1] == 10.0 - - -def test_go_to_named_position(tmp_path): - """Test going to a named position.""" - controller = PIControllerBase(quiet=True) - controller.named_position_file = tmp_path / "positions.json" - controller.connected = True - device_key = ("ip", 1, 1) - device = MagicMock() - controller.devices[device_key] = device - controller.get_serial_number = MagicMock(return_value="123456") - controller.set_position = MagicMock() - - # Prepare a named position file - named_positions = {"123456": {"home": ["1", 42.0]}} - with open(controller.named_position_file, "w") as file: - json.dump(named_positions, file) - - controller.go_to_named_position(device_key, "home", blocking=False) - controller.set_position.assert_called_once_with(device_key, "1", 42.0, False) - - -@patch("pi.pi_controller.GCSDevice") -def test_reference_move_success(mock_gcs_device_cls): - """Test successful reference move.""" - mock_device = MagicMock() - mock_device.IsMoving.return_value = {"1": False} - mock_gcs_device_cls.return_value = mock_device - - controller = PIControllerBase(quiet=True) - controller.connected = True - device_key = ("ip", 1, 1) - controller.devices[device_key] = mock_device - - # Test allowed method - for method in ["FRF", "FNL", "FPL"]: - getattr(mock_device, method).reset_mock() - result = controller.reference_move( - device_key, "1", method=method, blocking=True, timeout=1 - ) - assert result is True - getattr(mock_device, method).assert_called_once_with("1") - - -@patch("pi.pi_controller.GCSDevice") -def test_reference_move_invalid_method(mock_gcs_device_cls): - """Test reference move with an invalid method.""" - mock_device = MagicMock() - mock_gcs_device_cls.return_value = mock_device - - controller = PIControllerBase(quiet=True) - controller.connected = True - device_key = ("ip", 1, 1) - controller.devices[device_key] = mock_device - - # Test disallowed method - result = controller.reference_move(device_key, "1", method="INVALID", blocking=True) - assert result is False - - -@patch("pi.pi_controller.GCSDevice") -def test_reference_move_timeout(mock_gcs_device_cls): - """Test reference move with a timeout.""" - mock_device = MagicMock() - # Simulate IsMoving always True - mock_device.IsMoving.return_value = {"1": True} - mock_gcs_device_cls.return_value = mock_device - - controller = PIControllerBase(quiet=True) - controller.connected = True - device_key = ("ip", 1, 1) - controller.devices[device_key] = mock_device - - result = controller.reference_move( - device_key, "1", method="FRF", blocking=True, timeout=0.1 - ) - assert result is False diff --git a/src/hispec/util/srs/README.md b/src/hispec/util/srs/README.md deleted file mode 100644 index 03662bf..0000000 --- a/src/hispec/util/srs/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# PTC10 Python Interface - -A low level library for communicating with the **Stanford Research Systems PTC10 Programmable Temperature Controller** via Ethernet. - -## Features - -- Query identification string -- Read the current value of a specific sensor or output channel -- Read all channel values in a single query -- Retrieve names of all active channels -- Return values as a dictionary mapping channel name to current value -- Compatible with both serial and Ethernet connections - -## Requirements - -- Python 3.8+ -- Install base class from https://github.com/COO-Utilities/hardware_device_base - -## Installation - -```bash -pip install . -``` - -### Project Structure - -``` -ptc10/ -├── __init__.py -├── ptc10.py -└── README.md -``` - -### Basic Usage - -```python -import ptc10 - -# Ethernet example -ptc = ptc10.PTC10() -ptc.connect("192.168.29.150", 23) - -# Identify controller -print("Device ID:", ptc.identify()) - -# Read a specific channel -print("Temp at 3A:", ptc.get_channel_value("3A")) - -# Read all values -print("All values:", ptc.get_all_values()) - -# Channel name to value map -print("Named outputs:", ptc.get_named_output_dict()) - -ptc.close() -``` - -## API Reference - -### `PTC10` - -#### `connect()` -Connects to PTC10. - -#### `disconnect()` -Closes the connection to the controller - -#### `identify() -> str` -Returns the device identification string. - -#### `get_channel_value(channel: str) -> float` -Queries the most recent value of a single channel. Example: `"3A"`, `"Out1"`. - -#### `get_all_values() -> List[float]` -Returns a list of values for all available channels. Sensors out of range return `float('nan')`. - -#### `get_channel_names() -> List[str]` -Returns the channel names in the same order as `get_all_values()`. - -#### `get_named_output_dict() -> Dict[str, float]` -Returns a dictionary mapping each channel name to its latest value. - ---- - -## Notes - -- All messages must end in `\n` (linefeed). This is handled automatically by the connection class. -- Commands like `3A?`, `Out1?`, `getOutput?`, and `getOutputNames?` follow the PTC10 manual. -- Invalid channels or disconnected sensors will return `NaN`. diff --git a/src/hispec/util/srs/__init__.py b/src/hispec/util/srs/__init__.py deleted file mode 100644 index 842b485..0000000 --- a/src/hispec/util/srs/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -"""Initialize the PTC10 module.""" -from .ptc10 import PTC10 - -__all__ = ["PTC10"] diff --git a/src/hispec/util/srs/ptc10.py b/src/hispec/util/srs/ptc10.py deleted file mode 100644 index 0065456..0000000 --- a/src/hispec/util/srs/ptc10.py +++ /dev/null @@ -1,207 +0,0 @@ -""" -PTC10 Controller Interface -""" -from typing import List, Dict -from errno import EISCONN -import socket - -from hardware_device_base import HardwareDeviceBase - -class PTC10(HardwareDeviceBase): - """ - Interface for controlling the PTC10 controller. - """ - channel_names = None - - def __init__(self, log: bool = True, logfile: str = __name__.rsplit(".", 1)[-1] ): - """ - Initialize the PTC10 controller interface. - - Args: - log (bool): If True, start logging. - logfile (str, optional): Path to log file. - """ - super().__init__(log, logfile) - self.sock: socket.socket | None = None - - def connect(self, *args, con_type="tcp") -> None: - """ Connect to controller. """ - if self.validate_connection_params(args): - if con_type == "tcp": - host = args[0] - port = args[1] - if self.sock is None: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.sock.connect((host, port)) - self.logger.info("Connected to %(host)s:%(port)s", { - 'host': host, - 'port': port - }) - self._set_connected(True) - - except OSError as e: - if e.errno == EISCONN: - self.logger.info("Already connected") - self._set_connected(True) - else: - self.logger.error("Connection error: %s", e.strerror) - self._set_connected(False) - # clear socket - if self.is_connected(): - self._clear_socket() - elif con_type == "serial": - self.logger.error("Serial connection not yet implemented") - else: - self.logger.error("Unknown con_type: %s", con_type) - else: - self.logger.error("Invalid connection arguments: %s", args) - - def _clear_socket(self): - """ Clear socket buffer. """ - if self.sock is not None: - self.sock.setblocking(False) - while True: - try: - _ = self.sock.recv(1024) - except BlockingIOError: - break - self.sock.setblocking(True) - - def _send_command(self, command: str, *args) -> bool: - """ - Send a message to the controller (adds newline). - - Args: - command (str): The message to send (e.g., '3A?'). - """ - try: - self.logger.debug('Sending: %s', command) - with self.lock: - self.sock.sendall((command + "\n").encode()) - except Exception as ex: - raise IOError(f'Failed to write message: {ex}') from ex - return True - - def _read_reply(self) -> str: - """ - Read a response from the controller. - - Returns: - str: The received message, stripped of trailing newline. - """ - try: - retval = self.sock.recv(4096).decode().strip() - self.logger.debug('Received: %s', retval) - return retval - except Exception as ex: - raise IOError(f"Failed to _read_reply message: {ex}") from ex - - def query(self, msg: str) -> str: - """ - Send a command and _read_reply the immediate response. - - Args: - msg (str): Command string to send. - - Returns: - str: Response from the controller. - """ - self._send_command(msg) - return self._read_reply() - - def disconnect(self): - """ - Close the connection to the controller. - """ - try: - self.logger.info('Closing connection to controller') - if self.sock: - self.sock.close() - self._set_connected(False) - except Exception as ex: - raise IOError(f"Failed to close connection: {ex}") from ex - - def identify(self) -> str: - """ - Query the device identification string. - - Returns: - str: Device identification (e.g. manufacturer, model, serial number, firmware version). - """ - id_str = self.query("*IDN?") - self.logger.info("Device identification: %s", id_str) - return id_str - - def validate_channel_name(self, channel_name: str) -> bool: - """Is channel name valid?""" - if self.channel_names is None: - self.channel_names = self.get_channel_names() - return channel_name in self.channel_names - - def get_atomic_value(self, channel: str ="") -> float: - """ - Read the latest value of a specific channel. - - Args: - channel (str): Channel name (e.g., "3A", "Out1") - - Returns: - float: Current value, or NaN if invalid. - """ - if self.validate_channel_name(channel): - self.logger.debug("Channel name validated: %s", channel) - # Spaces not allowed - query_channel = channel.replace(" ", "") - response = self.query(f"{query_channel}?") - try: - value = float(response) - self.logger.debug("Channel %s value: %f", channel, value) - return value - except ValueError: - self.logger.error( - "Invalid float returned for channel %s: %s", channel, response - ) - return float("nan") - else: - self.logger.error("Invalid channel name: %s", channel) - return float("nan") - - def get_all_values(self) -> List[float]: - """ - Read the latest values of all channels. - - Returns: - List[float]: List of float values, with NaN where applicable. - """ - response = self.query("getOutput?") - values = [ - float(val) if val != "NaN" else float("nan") for val in response.split(",") - ] - self.logger.debug("Output values: %s", values) - return values - - def get_channel_names(self) -> List[str]: - """ - Get the list of channel names corresponding to the getOutput() values. - - Returns: - List[str]: List of channel names. - """ - response = self.query("getOutputNames?") - names = [name.strip() for name in response.split(",")] - self.logger.debug("Channel names: %s", names) - return names - - def get_named_output_dict(self) -> Dict[str, float]: - """ - Get a dictionary mapping channel names to their current values. - - Returns: - Dict[str, float]: Mapping of channel names to values. - """ - names = self.get_channel_names() - values = self.get_all_values() - output_dict = dict(zip(names, values)) - self.logger.debug("Named outputs: %s", output_dict) - return output_dict diff --git a/src/hispec/util/srs/pyproject.toml b/src/hispec/util/srs/pyproject.toml deleted file mode 100644 index 7f80059..0000000 --- a/src/hispec/util/srs/pyproject.toml +++ /dev/null @@ -1,23 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "srs" -version = "0.1.0" -description = "Stanford Research Systems controller software" -authors = [ - { name="Michael Langmayr", email="langmayr@caltech.edu" }, - { name="Don Neill", email="neill@astro.caltech.edu" }, - { name="Prakriti Gupta", email="pgupta@astro.caltech.edu" } -] -readme = "README.md" -requires-python = ">=3.7" -dependencies = [ - "hardware_device_base@git+https://github.com/COO-Utilities/hardware_device_base#egg=main", - "pytest" -] -[tool.pytest.ini_options] -pythonpath = [ - "." -] diff --git a/src/hispec/util/srs/tests/__init__.py b/src/hispec/util/srs/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/srs/tests/test_ptc10.py b/src/hispec/util/srs/tests/test_ptc10.py deleted file mode 100644 index 8328613..0000000 --- a/src/hispec/util/srs/tests/test_ptc10.py +++ /dev/null @@ -1,16 +0,0 @@ -""" -Unit tests for the PTC10 class in the srs.ptc10 module. -""" -# pylint: disable=import-error -from ptc10 import PTC10 - -def test_not_connected(): - """Test not connected.""" - controller = PTC10() - assert not controller.connected - -def test_connection_fail(): - """Test connection failure.""" - controller = PTC10() - controller.connect("127.0.0.1", 50000) - assert not controller.connected diff --git a/src/hispec/util/standa/.gitignore b/src/hispec/util/standa/.gitignore deleted file mode 100644 index b7faf40..0000000 --- a/src/hispec/util/standa/.gitignore +++ /dev/null @@ -1,207 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[codz] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py.cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock -#poetry.toml - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python. -# https://pdm-project.org/en/latest/usage/project/#working-with-version-control -#pdm.lock -#pdm.toml -.pdm-python -.pdm-build/ - -# pixi -# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control. -#pixi.lock -# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one -# in the .venv directory. It is recommended not to include this directory in version control. -.pixi - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.envrc -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# Abstra -# Abstra is an AI-powered process automation framework. -# Ignore directories containing user credentials, local state, and settings. -# Learn more at https://abstra.io/docs -.abstra/ - -# Visual Studio Code -# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore -# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore -# and can be added to the global gitignore or merged into this file. However, if you prefer, -# you could uncomment the following to ignore the entire vscode folder -# .vscode/ - -# Ruff stuff: -.ruff_cache/ - -# PyPI configuration file -.pypirc - -# Cursor -# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to -# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data -# refer to https://docs.cursor.com/context/ignore-files -.cursorignore -.cursorindexingignore - -# Marimo -marimo/_static/ -marimo/_lsp/ -__marimo__/ diff --git a/src/hispec/util/standa/README.md b/src/hispec/util/standa/README.md deleted file mode 100644 index 3bae9d1..0000000 --- a/src/hispec/util/standa/README.md +++ /dev/null @@ -1,70 +0,0 @@ -# Standa Controllers - -Low-level Python or simplified wrapper modules to send commands to Standa controllers. - -## Currently Supported Models -- 8SMC5 - smc8.py - -## Features -- Connect to Standa Controllers -- Query state and parameters -- Move individual axes to absolute or relative positions - -## Usage - -### smc8.py Example -```python - from util.smc8 import SMC - - # Open connection examples - dev = SMC(device_connection = "192.168.31.123/9219", connection_type = "xinet", log = False) - dev = SMC(device_connection="/dev/ximc/00007DF6", connection_type = "serial",log = True) - dev.open_connection() - time.sleep(.25) - #Populates dev with device info - dev.get_info() - - # checks status - status = dev.status() - - # Homes device - dev.home() - time.sleep(5) #Give time for stage to move - - # Query Position - pos = dev.get_position() # Query Position - - # Move Relative to its current position - dev.move_rel(position = 5) #positive ot negative - time.sleep(5) - - # Move to absolute position - dev.move_abs(position = 10) - time.sleep(5) - - pos = dev.get_position() - dev.home() - time.sleep(5) - #Close connection - dev.close_connection() -``` - -## 🧪 Testing -Unit tests are located in `tests/` directory. - -TODO: Make "Mock test" for PPC102 get_position and get_status which threw errors and was removed. - Assumed to be due to the byte and int convertion - -To run tests from the project root based on what you need: -Software check: -```bash -pytest -m unit -``` -Connection Test: -```bash -pytest -m default -``` -Functionality Test: -```bash -pytest -m functional -``` \ No newline at end of file diff --git a/src/hispec/util/standa/__init__.py b/src/hispec/util/standa/__init__.py deleted file mode 100644 index 621e564..0000000 --- a/src/hispec/util/standa/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .smc8 import SMC - -__all__ = ["SMC"] diff --git a/src/hispec/util/standa/pyproject.toml b/src/hispec/util/standa/pyproject.toml deleted file mode 100644 index 0abe938..0000000 --- a/src/hispec/util/standa/pyproject.toml +++ /dev/null @@ -1,43 +0,0 @@ -[build-system] -requires = ["setuptools>=75"] -build-backend = "setuptools.build_meta" - -[project] -name = "standa" -version = "0.1.0" -description = "A collection of Python interfaces for communicating with standa controllers" -authors = [ - { name = "Elijah Anakalea-Buckley", email = "elijahab@caltech.edu" } -] -maintainers = [ - { name = "Elijah Anakalea-Buckley", email = "elijahab@caltech.edu" } -] -readme = "README.md" -requires-python = ">=3.8" - -dependencies = [ - "libximc" -] - -[project.urls] -Repository = "https://github.com/COO-Utilities/standa" - -[project.optional-dependencies] -dev = [ - "pytest-mock", - "pytest", - "black", - "flake8" -] - -[tool.pytest.ini_options] -testpaths = ["tests"] -markers = [ - "default: marks tests as default run set", - "unit: marks tests as unit tests", - "functional: marks tests as functional tests", -] - -[tool.setuptools.packages.find] -# find packages under the "standa" namespace -include = ["smc8"] \ No newline at end of file diff --git a/src/hispec/util/standa/smc8.py b/src/hispec/util/standa/smc8.py deleted file mode 100644 index 4eb9cd9..0000000 --- a/src/hispec/util/standa/smc8.py +++ /dev/null @@ -1,379 +0,0 @@ -#NOTE:: Pip install of libximc is needed to use the library imported -# These are not standard python librarys but are on PyPI -# -Elijah Anakalea-Buckley - -import libximc.highlevel as ximc -import logging -import pathlib -import os -import time - - -class SMC(object): - ''' - Class is for utilizing the libximc Library. - Functions from lib.ximc is incorporated into this class - to make it easier to use for common tasks. - - using the more recently developed libximc.highlevel API - - step_size:float = 0.0025 Conversion Coefficient, Example for - converting steps to mm used in API, adjust as needed - - All functions log their actions and errors to a log file - - Required Parameters: - device_connection: str = Connection string for device - - Ex: serial connection: '/COM3', '/dev/ximc/000746D30' or '192.123.123.92' - - NOTE:: For Network you must provide IP/Name and device ID. Device ID is the - serial number tranlslated to hex - EX: SMC(device_connection = "192.168.29.123/9219", connection_type="xinet") - connection_type: str = Type of connection - - Options: 'serial'=USB, 'tcp'=Raw TCP, 'xinet'=Network - log: bool = Enable or disable logging to file - ''' - - def __init__(self, device_connection: str, connection_type: str,log: bool, step_size:float = 0.0025): - ''' - Inicializes the device - parameters: ip string, port integer, logging bool - - full device capabilities will be under "self.device." - ''' - # Logger setup - logname = __name__.rsplit(".", 1)[-1] - self.logger = logging.getLogger(logname) - self.logger.setLevel(logging.DEBUG) - if log: - log_handler = logging.FileHandler(logname + ".log") - formatter = logging.Formatter( - "%(asctime)s--%(name)s--%(levelname)s--%(module)s--" - "%(funcName)s--%(message)s") - log_handler.setFormatter(formatter) - self.logger.addHandler(log_handler) - # Console handler for real-time output - console_handler = logging.StreamHandler() - console_formatter = logging.Formatter("%(asctime)s--%(message)s") - console_handler.setFormatter(console_formatter) - self.logger.addHandler(console_handler) - - - self.logger.info("Logger initialized for SMC8 Stage") - - #Inicialize variables and objects - self._move_cmd_flags = ximc.MvcmdStatus # Default move command flags - self._state_flags = ximc.StateFlags - self.serial_number = None - self.power_setting = None - self.device_information = None - self._engine_settings = None - self.min_limit = None - self.max_limit = None - self._homed_and_happy_bool = False - self._uPOSITION = 0 #Constant is 0 for DC motors and avaries for stepper motors - #look into ximc library for details on uPOSITION - self.device_uri = None - - # Reference for connecting to device - # device_uri = r"xi-emu:///ABS_PATH/virtual_controller.bin" # Virtual device - # device_uri = r"xi-com:\\.\COM111" # Serial port - # device_uri = "xi-tcp://172.16.130.155:1820" # Raw TCP connection - # device_uri = "xi-net://192.168.1.120/abcd" # XiNet connection - connection_type = connection_type.lower().strip() - if connection_type == "serial": - self.device_uri = f"xi-com://{device_connection}" - elif connection_type == "tcp": - self.device_uri = f"xi-tcp://{device_connection}" - elif connection_type == "xinet": - self.device_uri = f"xi-net://{device_connection}" - else: - self.logger.error(f"Unknown connection type: {connection_type}") - raise ValueError(f"Unknown connection type: {connection_type}") - - - self.step_size_coeff = step_size # Example conversion coefficient, adjust as needed(mm) - self.dev_open = False - self._axis = ximc.Axis(self.device_uri) - - def open_connection(self): - ''' - Opens communication to the Device, gathers general information to - store in local variables. - return: Bool for successful or unsuccessful connection - libximc:: open_device() - ''' - #Check if already open - if self.dev_open: - #log that device is already open - self.logger.info("Device already open, skipping open command.") - #return true if already open - return True - - #try to open - try: - #open device - self._axis.open_device() - #get and save engine settings - self._engine_settings = self._axis.get_engine_settings() - #Set calb for user units TODO:: Check if this is correct(SPECIFICALLY THE MICROSTEP MODE) - self._axis.set_calb(self.step_size_coeff, self._engine_settings.MicrostepMode) - #Set limits - self.limits = self._axis.get_edges_settings() - self.min_limit = self.limits.LeftBorder - self.max_limit = self.limits.RightBorder - - self.logger.info("Device opened successfully.") - - #return true if successful - self.dev_open = True - return True - except Exception as e: - #log error - self.logger.error(f"Error opening device: {e}") - - #return false if unsuccessful - self.dev_open = False - return False - - def close_connection(self): - ''' - Closes communication to the Device - return: Bool for successful or unsuccessful termination - libximc:: close_device() - ''' - #Check if already open - if not self.dev_open: - #log that de is closed - self.logger.info("Device already closed, skipping close command.") - - #return true if already closed - return True - - #Try to close - try: - self._axis.close_device() - self.dev_open = False - self.logger.info("Device closed successfully.") - #return true if succesful - return True - except Exception as e: - #catch error - #log error and return false - self.logger.error(f"Error closing device: {e}") - - #return false if unsuccessful - self.dev_open = True - return False - - def get_info(self): - ''' - Gets information about the device, such as serial number, power setting, - command read settings, and device information. That information is stored - in local variables for later use. - - This function is called after opening the device to gather information - return: dict with device information - libximc:: get_serial_number(), get_power_setting(), command_read_settings(), - get_device_information() - ''' - #Check if connection not open - if not self.dev_open: - #log closed connection - self.logger.error("Device not open, cannot get info.") - return False - - #Try to get info - try: - #get serial number - self.serial_number = self._axis.get_serial_number() - #get power settings - self.power_setting = self._axis.get_power_settings() - #get device information - self.device_information = self._axis.get_device_information() - - self.logger.info("Device opened successfully.") - self.logger.info(f"Serial number: {self.serial_number}") - self.logger.info(f"Power setting: {self.power_setting}") - #Log device information - self.logger.info(f"Device information: {self.device_information}") - - #return true if successful - return True - except Exception as e: - #log error and return None - self.logger.error(f"Error getting device information: {e}") - return False - - - def home(self): - ''' - Homes stage into "parked" positon - -Will Home and stay at homed position. - return: bool on successful home - libximc:: command_homezero() - ''' - #Check if connection not open - if not self.dev_open: - #log closed connection - self.logger.error("Device not open, cannot home stage.") - return False - - #Try to home to zero or parked position - try: - self._axis.command_homezero() - #Check position after homing - self.logger.info("Stage sent to homed position which is 0") - #return true if succesful - self.status() - return True - #catch error - except Exception as e: - #log error - self.logger.error(f"Error homing stage: {e}") - #return false if unsuccessful - return False - - def move_abs(self, position:int): - ''' - Move the stage to a ABSOLUTE position. Send stage to any specific - location within the device limits. - - Check min_limit and max_limit for valid inputs - parameters: min_limit < int:"position" < max_limit - return: bool on successful or unsuccessful absolute move - libximc:: command_move() - ''' - #Check if connection not open - if not self.dev_open: - #log closed connection - self.logger.error("Device not open, cannot move stage.") - return False - - #Try move absolute - try: - #check limits/valid inputs - if position < self.min_limit or position > self.max_limit: - self.logger.error(f"Position out of limits: {position}") - return False - #move absolute - self._axis.command_move(position, self._uPOSITION) - #return true if succesful - return True - #catch error - except Exception as e: - #log error and return false - self.logger.error(f"Error moving stage: {e}") - return False - - def move_rel(self, position:int): - ''' - Move the stage to a RELATIVE position. Send stage to a position - relative to its current position. - - Check min_limit and max_limit for range of device - parameters: min_limit < +- int for relative move < max_limit - return: bool on successful or unsuccessful relative move - libximc:: command_movr() - ''' - #Check if connection not open - if not self.dev_open: - #log closed connection - self.logger.error("Device not open, cannot move stage.") - return False - - #Try move relative - try: - #check limits/valid inputs - #get current position - current_position = self.get_position() - #calculate new position - new_position = current_position + position - #check if new position is within limits - if new_position < self.min_limit or new_position > self.max_limit: - self.logger.error(f"Position out of limits: {new_position}") - return False - #move relative - self._axis.command_movr(position, self._uPOSITION) - #return true if succesful - return True - #catch error - except Exception as e: - #log error and return false - self.logger.error(f"Error moving stage: {e}") - return False - - def get_position(self): - ''' - Gets Position of stage - return: position in stage specific units - libximc:: - ''' - #Check if connection not open - if not self.dev_open: - #log closed connection - self.logger.error("Device not open, cannot get position.") - return False - - #Try get_position - try: - #get position - pos = self._axis.get_position() - #return aspects of the position object - return pos.Position - #catch error - except Exception as e: - #log error and return None - self.logger.error(f"Error getting position: {e}") - return None - - def status(self): - ''' - Gathers status and formats it in a usable and readable format. - mostly for logging - return: status string and variables nessesary - libximc:: get_status() - ''' - #Check if connection not open - if not self.dev_open: - #log closed connection - self.logger.error("Device not open, cannot get status.") - return False - - #Try status function - try: - #get status - status = self._axis.get_status() - #parse results - #return status in user friendly way - self.logger.info(f"Position: {status.CurPosition}") - self._homed_and_happy_bool = bool(status.Flags & self._state_flags.STATE_IS_HOMED | - self._state_flags.STATE_EEPROM_CONNECTED) - return status - #catch error - except Exception as e: - #log error and return false - self.logger.error(f"Error getting status: {e}") - return None - - def halt(self): - ''' - IMMITATELY halts the stage, no matter the status or if moving, stage - stops(for safety purposes) - return: status of the stage(log and/or print hald command called) - libximc:: command_stop() - ''' - #Check if connection not open - if not self.dev_open: - #log closed connection - self.logger.error("Device not open, cannot halt stage.") - return False - - #Try imidiate stop of stage - try: - self._axis.command_stop() - #Check status after halting - status = self._axis.get_status() - if status.MvCmdSts != self._move_cmd_flags.MVCMD_STOP: - self.halt() #Recursively call halt if not stopped - - #status.Moving - self.logger.info("Stage halted successfully.") - #return true if succesful - return True - #catch error - except Exception as e: - #log error and return false - self.logger.error(f"Error halting stage: {e}") - return False \ No newline at end of file diff --git a/src/hispec/util/standa/tests/default_smc8_test.py b/src/hispec/util/standa/tests/default_smc8_test.py deleted file mode 100644 index f0e3d41..0000000 --- a/src/hispec/util/standa/tests/default_smc8_test.py +++ /dev/null @@ -1,79 +0,0 @@ -################# -#Default Communication test -#Description: Test connection, disconnection and confirming communication with stage -################# - -import pytest -pytestmark = pytest.mark.default -import sys -import os -import unittest -import time -from smc8 import SMC - -########################## -## CONFIG -## connection and Disconnection in all test -########################## - -class Comms_Test(unittest.TestCase): - - #Instances for Test management - #def setUp(self): - dev = None - success = True - device = "" - log = False - error_tolerance = 0.1 - device_connection = "192.168.29.123/9219" - connection_type = "xinet" - - ########################## - ## TestConnection and failure connection - ########################## - def test_connection(self): - # Open connection - self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) - time.sleep(.2) - self.dev.open_connection() - time.sleep(.25) - assert self.dev.get_info() - assert self.dev.serial_number is not None - assert self.dev.power_setting is not None - assert self.dev.device_information is not None - #Close connection - self.dev.close_connection() - time.sleep(.25) - - def test_connection_failure(self): - # Use an unreachable IP (TEST-NET-1 range, reserved for docs/testing) - bad_connection = "dev/ximc/0000" - self.dev = SMC(device_connection = bad_connection, connection_type = self.connection_type, log = self.log) - success = self.dev.open_connection() - self.assertFalse(success, "Expected connection failure with invalid IP/port") - self.dev.close_connection() - - ########################## - ## Status Communication - ########################## - def status_communication(self): - time.sleep(.2) - # Open connection - self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) - time.sleep(.2) - self.dev.open_connection() - time.sleep(.25) - assert self.dev.get_info() - status = self.dev.status() - assert status is not None - - self.dev.close_connection() - time.sleep(.25) - - -if __name__ == '__main__': - loader = unittest.TestLoader() - suite = loader.loadTestsFromTestCase(Comms_Test) - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/standa/tests/mock_smc8_test.py b/src/hispec/util/standa/tests/mock_smc8_test.py deleted file mode 100644 index 9a9a1c0..0000000 --- a/src/hispec/util/standa/tests/mock_smc8_test.py +++ /dev/null @@ -1,87 +0,0 @@ -################# -#Unit test -#Description: Validate software functions are correctly implemented via mocking -################# - -import pytest -pytestmark = pytest.mark.unit -import unittest -from unittest.mock import patch, MagicMock -# pylint: disable=import-error,no-name-in-module -from smc8 import SMC -import time -import ctypes - -class TestSMC8(unittest.TestCase): - """Unit tests for the SunpowerCryocooler class.""" - - def setUp(self): # pylint: disable=arguments-differ - """Set up the test case with a mocked ximc connection.""" - patcher = patch("smc8.ximc.Axis") # <- patch the right function - self.addCleanup(patcher.stop) - mock_open_device = patcher.start() - self.mock_ximc = MagicMock() - mock_open_device.return_value = self.mock_ximc - self.controller = SMC(device_connection = "192.168.29.123/9219", connection_type = "xinet", log = False) - self.controller._axis = self.mock_ximc - self.controller.dev_open = True - self.controller.min_limit = -500 - self.controller.max_limit = 500 - self.mock_ximc.get_serial_number.return_value = 12345678 - self.mock_ximc.get_power_setting.return_value = 1 - self.mock_ximc.get_device_information.return_value = 5000 - self.mock_ximc.command_homezero.return_value = True - self.mock_ximc.get_position_calb.return_value = 0 , "0.0" - self.mock_ximc.Position = 10 - self.mock_ximc.CurPosition = 10 - self.mock_ximc.CurSpeed = 0.12 - - def test_info(self): - """Test getting the info from the attenuator.""" - assert self.controller.get_info() - assert self.controller.serial_number is not None - assert self.controller.power_setting is not None - assert self.controller.device_information is not None - - - def test_abs_move(self): - """Testing sending the correct commands to abs move the SMC.""" - mock_axis = MagicMock() - self.controller._axis = mock_axis # inject the mock axis - - self.controller.move_abs(10) - mock_axis.command_move.assert_called_once_with(10,0) - - def test_rel_move(self): - """Testing sending the correct commands to rel move the SMC.""" - mock_axis = MagicMock() - self.controller._axis = mock_axis # inject the mock axis - self.controller.get_position = MagicMock(return_value=0) - - self.controller.move_rel(10) - mock_axis.command_movr.assert_called_once_with(10,0) - - def test_home(self): - """Test setting the position from the SMC.""" - with patch.object(self.controller._axis, "command_homezero") as mock_home: - self.controller.home() - mock_home.assert_called_once() - - def test_get_position(self): - """Test getting the position from the SMC.""" - self.controller._axis.get_position = MagicMock(return_value=self.mock_ximc) - pos = self.controller.get_position() - assert pos == 10 - - def test_get_status(self): - """Test getting the status from the SMC.""" - self.controller._axis.get_status = MagicMock(return_value=self.mock_ximc) - status = self.controller.status() - Position = status.CurPosition - Moving_speed = status.CurSpeed - assert Position is not None - - -if __name__ == "__main__": - unittest.main() - \ No newline at end of file diff --git a/src/hispec/util/standa/tests/physical_smc8_test.py b/src/hispec/util/standa/tests/physical_smc8_test.py deleted file mode 100644 index 0fc4116..0000000 --- a/src/hispec/util/standa/tests/physical_smc8_test.py +++ /dev/null @@ -1,152 +0,0 @@ -################# -#Functionality test -#Description: Test connection, disconnection, confirming communication with stage, -# inicialization(or something similar) and movement/position query -# tests are successful and correct -################# - -import pytest -pytestmark = pytest.mark.functional -import sys -import os -import unittest -import time -from smc8 import SMC - -########################## -## CONFIG -## connection and Disconnection in all test -########################## -class Physical_Test(unittest.TestCase): - - #Instances for Test management - def setUp(self): - self.dev = None - self.success = True - self.device = "" - self.log = False - self.error_tolerance = 0.1 - self.device_connection = "192.168.29.123/9219" - self.connection_type = "xinet" - - ########################## - ## TestConnection and failure connection - ########################## - def test_connection(self): - # Open connection - self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) - time.sleep(.2) - self.dev.open_connection() - time.sleep(.25) - assert self.dev.get_info() - assert self.dev.serial_number is not None - assert self.dev.power_setting is not None - assert self.dev.device_information is not None - #Close connection - self.dev.close_connection() - time.sleep(.25) - - def test_connection_failure(self): - # Use an unreachable IP (TEST-NET-1 range, reserved for docs/testing) - bad_connection = "dev/ximc/0000" - self.dev = SMC(device_connection = bad_connection, connection_type = self.connection_type, log = self.log) - success = self.dev.open_connection() - self.assertFalse(success, "Expected connection failure with invalid IP/port") - self.dev.close_connection() - - ########################## - ## Status Communication - ########################## - def status_communication(self): - # Open connection - self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) - time.sleep(.2) - self.dev.open_connection() - time.sleep(.25) - self.dev.get_info() - status = self.dev.status() - assert status is not None - - self.dev.close_connection() - time.sleep(.25) - - ########################## - ## Test Move and Home - ########################## - def test_home(self): - # Open connection - self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) - time.sleep(.2) - self.dev.open_connection() - time.sleep(.25) - assert self.dev.get_info() - status = self.dev.status() - assert status is not None - assert self.dev.home() - time.sleep(.25) - pos = self.dev.get_position() - assert abs(pos - 0) < self.error_tolerance*2 - - #Close connection - self.dev.close_connection() - time.sleep(.25) - - def test_move(self): - # Open connection - self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) - time.sleep(.2) - self.dev.open_connection() - time.sleep(.25) - assert self.dev.get_info() - status = self.dev.status() - assert status is not None - assert self.dev.home() - time.sleep(.25) - pos = self.dev.get_position() - assert abs(pos - 0) < self.error_tolerance*2 - assert self.dev.move_abs(position = 5) - time.sleep(.25) - pos = self.dev.get_position() - assert abs(pos - 5) < self.error_tolerance*2 - assert self.dev.move_rel(position = 5) - time.sleep(.25) - pos = self.dev.get_position() - assert abs(pos - 10) < self.error_tolerance*2 - assert self.dev.home() - time.sleep(.25) - pos = self.dev.get_position() - assert abs(pos - 0) < self.error_tolerance*2 - #Close connection - self.dev.close_connection() - time.sleep(.25) - - def test_halt(self): - # Open connection - self.dev = SMC(device_connection = self.device_connection, connection_type = self.connection_type, log = self.log) - time.sleep(.2) - self.dev.open_connection() - time.sleep(.25) - assert self.dev.get_info() - status = self.dev.status() - assert status is not None - end = self.dev.max_limit - 1 - assert self.dev.move_abs(position = end) - time.sleep(2) - assert self.dev.move_abs(position = (self.dev.min_limit + 1)) - assert self.dev.halt() - time.sleep(.25) - pos = self.dev.get_position() - assert pos != (self.dev.min_limit + 1) - #Close connection - self.dev.home() - time.sleep(.25) - self.dev.close_connection() - time.sleep(.25) - - -if __name__ == '__main__': - loader = unittest.TestLoader() - suite = loader.loadTestsFromTestCase(Robust_Test) - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - sys.exit(not result.wasSuccessful()) \ No newline at end of file diff --git a/src/hispec/util/sunpower/.gitignore b/src/hispec/util/sunpower/.gitignore deleted file mode 100644 index 3f029b2..0000000 --- a/src/hispec/util/sunpower/.gitignore +++ /dev/null @@ -1,15 +0,0 @@ -# Python -__pycache__ -*.pyc -*.pyo -*.pyd -*.pdb -*.egg -*.egg-info -.pytest_cache/ -.venv -venv - -# IDEs -.idea/ -.vscode/ \ No newline at end of file diff --git a/src/hispec/util/sunpower/README.md b/src/hispec/util/sunpower/README.md deleted file mode 100644 index 87289f8..0000000 --- a/src/hispec/util/sunpower/README.md +++ /dev/null @@ -1,66 +0,0 @@ -# sunpower - -A collection of Python interfaces for communicating with HISPEC FEI components. - -## Features - -- Query device status, error, and firmware version -- Get and set target temperature -- Get and set user commanded power -- Get reject and cold head temperatures -- Turn cooler on or off -- Supports both serial and TCP (socket) connections with error handling - -## Requirements - -- Install base class from https://github.com/COO-Utilities/hardware_device_base - -## Installation - -```bash -pip install . -``` - -## Usage -### Serial Connection -```python -from sunpower_cryocooler import SunpowerCryocooler - -controller = SunpowerCryocooler() -controller.connect('/dev/ttyUSB0', 9600, con_type="serial") - -print("\n".join(controller.get_status())) -controller.set_target_temp(300.0) -controller.turn_on_cooler() -print("\n".join(controller.get_commanded_power())) -print("\n".join(controller.get_measured_power())) -controller.set_commanded_power(10.0) -print("\n".join(controller.get_reject_temp())) -print("\n".join(controller.get_cold_head_temp())) -``` - -### TCP Connection -```python -from sunpower_cryocooler import SunpowerCryocooler - -controller = SunpowerCryocooler() -controller.connect("192.168.29.100", 10016, con_type="tcp") - -print("\n".join(controller.get_status())) -controller.set_target_temp(300.0) -controller.turn_on_cooler() -print("\n".join(controller.get_commanded_power())) -print("\n".join(controller.get_measured_power())) -controller.set_commanded_power(10.0) -print("\n".join(controller.get_reject_temp())) -print("\n".join(controller.get_cold_head_temp())) -``` - -## 🧪 Testing -Unit tests are located in `tests/` directory and use `pytest` with `unittest.mock` to simulate hardware behavior — no physical sunpower controller is required. - -To run all tests from the project root: - -```bash -pytest -``` diff --git a/src/hispec/util/sunpower/__init__.py b/src/hispec/util/sunpower/__init__.py deleted file mode 100644 index dfb869d..0000000 --- a/src/hispec/util/sunpower/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -This module provides a controller for the Sunpower Cryocooler. -""" -from .sunpower_cryocooler import SunpowerCryocooler - -__all__ = ["SunpowerCryocooler"] diff --git a/src/hispec/util/sunpower/pyproject.toml b/src/hispec/util/sunpower/pyproject.toml deleted file mode 100644 index caa8a46..0000000 --- a/src/hispec/util/sunpower/pyproject.toml +++ /dev/null @@ -1,39 +0,0 @@ -[build-system] -requires = ["setuptools>=42"] -build-backend = "setuptools.build_meta" - -[project] -name = "sunpower" -version = "0.1.0" -dependencies = [ - "pyserial>=3.4", -] -description = "A collection of Python interfaces for communicating with HISPEC FEI components, including Sunpower cryocooler control." -authors = [ - { name = "Michael Langmayr", email = "langmayr@caltech.edu" } -] -maintainers = [ - {name = "Michael Langmayr", email = "langmayr@caltech.edu"} -] -readme = "README.md" -requires-python = ">=3.9" -license = {text = "MIT"} - -[project.urls] -# Various URLs related to your project. These links are displayed on PyPI. -# Homepage = "https://example.com" -# Documentation = "https://readthedocs.org" -Repository = "https://github.com/COO-Utils/sunpower" -# "Bug Tracker" = "https://github.com/COO-Utils/sunpower/issues" -# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" - -[project.optional-dependencies] -dev = [ - "pytest-mock", - "pytest", - "black", - "flake8" -] - -[tool.pytest.ini_options] -testpaths = ["tests"] diff --git a/src/hispec/util/sunpower/sunpower_cryocooler.py b/src/hispec/util/sunpower/sunpower_cryocooler.py deleted file mode 100644 index e6c51f1..0000000 --- a/src/hispec/util/sunpower/sunpower_cryocooler.py +++ /dev/null @@ -1,215 +0,0 @@ -""" -A Python class to control a Sunpower cryocooler via serial or TCP connection. -""" -import socket -import time -from typing import Union -import serial - -from hardware_device_base import HardwareDeviceBase - - -def parse_single_value(reply: list) -> Union[float, int, bool, str]: - """Attempt to parse a single value from the reply list.""" - if not isinstance(reply, list): - raise TypeError("reply must be a list") - - try: - val = reply[1] - except IndexError: - return "No reply" - - # Parse Booleans - if val.lower() in ("true", "yes", "on", "1"): - return True - if val.lower() in ("false", "no", "off", "0"): - return False - - # Parse integers - try: - return int(val) - except ValueError: - pass - - # Parse floats - try: - return float(val) - except ValueError: - pass - - # Fallback: return string - return val.strip() - -class SunpowerCryocooler(HardwareDeviceBase): - """A class to control a Sunpower cryocooler via serial or TCP connection.""" - # pylint: disable=too-many-instance-attributes - def __init__(self, log: bool = True, logfile: str = __name__.rsplit(".", 1)[-1], - read_timeout: float = 1.0): - """ Initialize the SunpowerCryocooler.""" - - super().__init__(log, logfile) - self.con_type = None - self.read_timeout = read_timeout - self.ser = None - self.sock = None - - def connect(self, *args, con_type: str ="tcp"): - """Connect to the Sunpower controller.""" - if self.validate_connection_params(args): - try: - if con_type == "serial": - port = args[0] - baudrate = args[1] - self.ser = serial.Serial( - port=port, - baudrate=baudrate, - timeout=self.read_timeout, - parity=serial.PARITY_NONE, - stopbits=serial.STOPBITS_ONE, - ) - self.logger.info("Serial connection opened: %s",self.ser.is_open) - self._set_connected(True) - self.con_type = con_type - elif con_type == "tcp": - tcp_host = args[0] - tcp_port = args[1] - self.sock = socket.create_connection((tcp_host, tcp_port), timeout=2) - self.sock.settimeout(self.read_timeout) - self.logger.info("TCP connection opened: %s:%d", tcp_host, tcp_port) - self._set_connected(True) - self.con_type = con_type - else: - self._set_connected(False) - raise ValueError("connection_type must be 'serial' or 'tcp'") - except Exception as ex: - self._set_connected(False) - self.logger.error("Failed to establish connection: %s", ex) - raise - - def disconnect(self): - """Close the connection.""" - if self.con_type == "serial": - self.ser.close() - self.logger.info("Serial connection closed.") - elif self.con_type == "tcp": - self.sock.close() - self.logger.info("TCP connection closed.") - self._set_connected(False) - - def _send_command(self, command: str, *args) -> bool: - """Send a command to the Sunpower controller.""" - full_cmd = f"{command}\r" - try: - if self.con_type == "serial": - self.ser.write(full_cmd.encode()) - elif self.con_type == "tcp": - self.sock.sendall(full_cmd.encode()) - self.logger.debug("Sent command: %s", repr(full_cmd)) - except Exception as ex: - self.logger.error("Failed to send command '%s': %s", command, ex) - raise - return True - - def _read_reply(self) -> list: - """Read and return lines from the device.""" - lines_out = [] - try: - raw_data = None - if self.con_type == "serial": - raw_data = self.ser.read(1024) - elif self.con_type == "tcp": - try: - raw_data = self.sock.recv(1024) - except socket.timeout: - self.logger.warning("TCP read timeout.") - return [] - - if not raw_data: - self.logger.warning("No data received.") - return [] - - self.logger.debug("Raw received: %s", repr(raw_data)) - lines = raw_data.decode(errors="replace").splitlines() - for line in lines: - stripped = line.strip() - if stripped: - lines_out.append(stripped) - return lines_out - except (serial.SerialException, socket.error, ValueError) as ex: - self.logger.error("Failed to read reply: %s", ex) - return [] - - def _send_and_read(self, command: str): - """Send a command and read the reply.""" - if self.is_connected(): - self._send_command(command) - time.sleep(0.2) # wait a bit for device to reply - return self._read_reply() - self.logger.error("Failed to send command '%s': Not connected", command) - return [] - - # --- User-Facing Methods (synchronous) --- - def get_atomic_value(self, item: str ="") -> Union[float, int, str, None]: - """Get the atomic value from the Sunpower cryocooler.""" - retval = None - if item == "cold_head_temp": - retval = self.get_cold_head_temp() - elif item == "reject_temp": - retval = self.get_reject_temp() - elif item == "target_temp": - retval = self.get_target_temp() - elif item == "measured_power": - retval = self.get_measured_power() - elif item == "commanded_power": - retval = self.get_commanded_power() - else: - self.logger.error("Unknown item: %s", item) - return retval - - def get_status(self): - """Get the status of the Sunpower cryocooler.""" - return self._send_and_read("STATUS") - - def get_error(self): - """Get the last error message from the Sunpower cryocooler.""" - return parse_single_value(self._send_and_read("ERROR")) - - def get_version(self): - """Get the firmware version of the Sunpower cryocooler.""" - return parse_single_value(self._send_and_read("VERSION")) - - def get_cold_head_temp(self): - """Get the temperature of the cold head.""" - return parse_single_value(self._send_and_read("TC")) - - def get_reject_temp(self): - """Get the temperature of the reject heat.""" - return parse_single_value(self._send_and_read("TEMP RJ")) - - def get_target_temp(self): - """Get the target temperature set for the cryocooler.""" - return parse_single_value(self._send_and_read("TTARGET")) - - def set_target_temp(self, temp_kelvin: float): - """Set the target temperature for the cryocooler in Kelvin.""" - return parse_single_value(self._send_and_read(f"TTARGET={temp_kelvin}")) - - def get_measured_power(self): - """Get the measured power of the cryocooler.""" - return parse_single_value(self._send_and_read("P")) - - def get_commanded_power(self): - """Get the commanded power of the cryocooler.""" - return parse_single_value(self._send_and_read("PWOUT")) - - def set_commanded_power(self, watts: float): - """Set the commanded power for the cryocooler in watts.""" - return parse_single_value(self._send_and_read(f"PWOUT={watts}")) - - def turn_on_cooler(self): - """Turn on the cryocooler.""" - return parse_single_value(self._send_and_read("COOLER=ON")) - - def turn_off_cooler(self): - """Turn off the cryocooler.""" - return parse_single_value(self._send_and_read("COOLER=OFF")) diff --git a/src/hispec/util/sunpower/tests/__init__.py b/src/hispec/util/sunpower/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/sunpower/tests/test_sunpower.py b/src/hispec/util/sunpower/tests/test_sunpower.py deleted file mode 100644 index 5ac515a..0000000 --- a/src/hispec/util/sunpower/tests/test_sunpower.py +++ /dev/null @@ -1,76 +0,0 @@ -"""Test suite for the SunpowerCryocooler class in hispec.util module.""" -import unittest -from unittest.mock import patch -# pylint: disable=import-error,no-name-in-module -from sunpower import SunpowerCryocooler - - -class TestSunpowerController(unittest.TestCase): - """Unit tests for the SunpowerCryocooler class.""" - - @patch("serial.Serial") - def setUp(self, mock_serial): # pylint: disable=arguments-differ - """Set up the test case with a mocked serial connection.""" - self.mock_serial = mock_serial.return_value - self.mock_serial.read.return_value = b"" - self.controller = SunpowerCryocooler( - port="COM1", baudrate=19200, quiet=False, connection_type="serial" - ) - - def test_send_command(self): - """Test sending a command to the cryocooler.""" - self.controller._send_command("TEST") # pylint: disable=protected-access - self.mock_serial.write.assert_called_with(b"TEST\r") - - def test_read_reply_parses_float(self): - """Test reading a reply and parsing a float value.""" - self.mock_serial.read.return_value = b"TTARGET= 123.456\r\n" - with patch.object(self.controller.logger, "info") as mock_info: - result = self.controller._read_reply() # pylint: disable=protected-access - assert "TTARGET= 123.456" in result - mock_info.assert_not_called() # we don't log parsed floats directly anymore - - def test_read_reply_handles_non_float(self): - """Test reading a reply that does not contain a float.""" - self.mock_serial.read.return_value = b"ERROR= notanumber\r\n" - with patch.object(self.controller.logger, "warning") as mock_warn: - result = self.controller._read_reply() # pylint: disable=protected-access - assert "ERROR= notanumber" in result - mock_warn.assert_not_called() # no parse attempted anymore - - def test_get_commanded_power(self): - """Test getting the commanded power from the cryocooler.""" - with patch.object(self.controller, "_send_and_read") as mock_send_and_read: - self.controller.get_commanded_power() - mock_send_and_read.assert_called_once_with("PWOUT") - - def test_set_commanded_power(self): - """Test setting the commanded power on the cryocooler.""" - with patch.object(self.controller, "_send_and_read") as mock_send_and_read: - self.controller.set_commanded_power(12.34) - mock_send_and_read.assert_called_once_with("PWOUT=12.34") - - def test_get_reject_temp(self): - """Test getting the reject temperature from the cryocooler.""" - with patch.object(self.controller, "_send_and_read") as mock_send_and_read: - self.controller.get_reject_temp() - mock_send_and_read.assert_called_once_with("TEMP RJ") - - def test_get_cold_head_temp(self): - """Test getting the cold head temperature from the cryocooler.""" - with patch.object(self.controller, "_send_and_read") as mock_send_and_read: - self.controller.get_cold_head_temp() - mock_send_and_read.assert_called_once_with("TC") - - def test_get_measured_power_returns_p_and_value(self): - """Test getting the measured power from the cryocooler.""" - with patch.object( - self.controller, "_send_and_read", return_value=["P", "72"] - ) as mock_send_and_read: - result = self.controller.get_measured_power() - self.assertEqual(result, ["P", "72"]) - mock_send_and_read.assert_called_once_with("P") - - -if __name__ == "__main__": - unittest.main() diff --git a/src/hispec/util/thorlabs/.gitignore b/src/hispec/util/thorlabs/.gitignore deleted file mode 100644 index 1681eb4..0000000 --- a/src/hispec/util/thorlabs/.gitignore +++ /dev/null @@ -1,165 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -.idea/ - -# MacOS -.DS_Store diff --git a/src/hispec/util/thorlabs/README.md b/src/hispec/util/thorlabs/README.md deleted file mode 100644 index 186da4e..0000000 --- a/src/hispec/util/thorlabs/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Thorlabs_controllers - -Low-level Python modules to send commands to Thorlabs motion controllers. - -## Currently Supported Models -- FW102C - fw102c.py -- PPC102 - ppc102.py - -## Features -- Connect to Thorlabs controllers over serial through a terminal server -- Query state and parameters -- Move individual axes to absolute or relative positions - -## Usage - -### FW102C Example -```python -from hispec.util import fw102c - -controller = fw102c.FilterWheelController() -controller.set_connection(ip='192.168.29.100', port=10010) -controller.connect() - -# Print filter wheel current position -print(controller.get_position()) - -# Move filter wheel to filter 5 -controller.move(5) - -# For a comprehensive list of classes and methods, use the help function -help(fw102c) - -``` - -### PPC102 Example -```python - from hispec.util.thorlabs.ppc102 import PPC102_Coms - - # log = false will now print to command line - dev = PPC102_Coms(ip="",port="",log=False) - - #Open connection - dev.open() - - # set voltage on channel 1 and get result (open loop control) - dev.set_output_volts(channel=1,volts=100) - res = dev.get_output_volts(channel=1) - - # switch channels to closed loop - dev.set_loop(channel=1,loop=2) - dev.set_loop(channel=2,loop=2) - - # set positions on channel 1 or 2 and get result - dev.set_position(channel=1,pos=5.0) - dev.set_position(channel=2,pos=-5.0) - cur_pos1 = dev.get_position(channel=1) - cur_pos2 = dev.get_position(channel=2) - - # switch channels to open loop - dev.set_loop(channel=1,loop=1) - dev.set_loop(channel=2,loop=1) - - #Set voltages to zero - dev.set_output_volts(channel=1,volts=0) - dev.set_output_volts(channel=2,volts=0) - - # close socket connection - dev.close() -``` - -## 🧪 Testing -Unit tests are located in `tests/` directory. - -TODO: Make "Mock test" for PPC102 get_position and get_status which threw errors and was removed. - Assumed to be due to the byte and int convertion - -To run tests from the project root based on what you need: -Software check: -```bash -pytest -m unit -``` -Connection Test: -```bash -pytest -m default -``` -Functionality Test: -```bash -pytest -m functional -``` \ No newline at end of file diff --git a/src/hispec/util/thorlabs/__init__.py b/src/hispec/util/thorlabs/__init__.py deleted file mode 100644 index 7d82257..0000000 --- a/src/hispec/util/thorlabs/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -from .fw102c import FilterWheelController -from .ppc102 import PPC102_Coms - -__all__ = ["FilterWheelController", "PPC102_Coms"] diff --git a/src/hispec/util/thorlabs/fw102c.py b/src/hispec/util/thorlabs/fw102c.py deleted file mode 100755 index ba7406b..0000000 --- a/src/hispec/util/thorlabs/fw102c.py +++ /dev/null @@ -1,333 +0,0 @@ -#! @KPYTHON3@ -""" Thorlabs FW102C controller class """ - -from errno import ETIMEDOUT, EISCONN -import logging -import socket -import threading -import time - - -class FilterWheelController: - """ Handle all correspondence with the serial interface of the - Thorlabs FW102C filter wheel. - """ - - - connected = False - status = None - ip = '' - port = 0 - - initialized = False - revision = None - success = False - - def __init__(self, log=True, logfile=None, quiet=False): - - self.lock = threading.Lock() - self.socket = None - - # Logger setup - logname = __name__.rsplit(".", 1)[-1] - self.logger = logging.getLogger(logname) - self.logger.setLevel(logging.DEBUG) - if log: - log_handler = logging.FileHandler(logname + ".log") - formatter = logging.Formatter( - "%(asctime)s--%(name)s--%(levelname)s--%(module)s--" - "%(funcName)s--%(message)s") - log_handler.setFormatter(formatter) - self.logger.addHandler(log_handler) - # Console handler for real-time output - console_handler = logging.StreamHandler() - console_formatter = logging.Formatter("%(asctime)s--%(message)s") - console_handler.setFormatter(console_formatter) - self.logger.addHandler(console_handler) - - def set_connection(self, ip=None, port=None): - """ Configure the connection to the controller. - - :param ip: String, IP address of the controller. - :param port: Int, port number of the controller. - - """ - self.ip = ip - self.port = port - - def disconnect(self): - """ Disconnect controller. """ - - try: - self.socket.shutdown(socket.SHUT_RDWR) - self.socket.close() - self.socket = None - if self.logger: - self.logger.debug("Disconnected controller") - self.connected = False - self.success = True - - except OSError as e: - if self.logger: - self.logger.error("Disconnection error: %s", e.strerror) - self.connected = False - self.socket = None - self.success = False - - self.set_status("disconnected") - - def connect(self): - """ Connect to controller. """ - if self.socket is None: - self.socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - try: - self.socket.connect((self.ip, self.port)) - if self.logger: - self.logger.debug("Connected to %(host)s:%(port)s", { - 'host': self.ip, - 'port': self.port - }) - self.connected = True - self.success = True - self.set_status('ready') - - except OSError as e: - if e.errno == EISCONN: - if self.logger: - self.logger.debug("Already connected") - self.connected = True - self.success = True - self.set_status('ready') - else: - if self.logger: - self.logger.error("Connection error: %s", e.strerror) - self.connected = False - self.success = False - self.set_status('not connected') - # clear socket - if self.connected: - self.__clear_socket() - - def __clear_socket(self): - """ Clear socket buffer. """ - if self.socket is not None: - self.socket.setblocking(False) - while True: - try: - _ = self.socket.recv(1024) - except BlockingIOError: - break - self.socket.setblocking(True) - - def check_status(self): - """ Check connection status """ - if not self.connected: - status = 'not connected' - elif not self.success: - status = 'unresponsive' - else: - status = 'ready' - - self.set_status(status) - - - def set_status(self, status): - """ Set the status of the filter wheel. - - :param status: String, status of the controller. - - """ - - status = status.lower() - - if self.status is None: - current = None - else: - current = self.status - - if current != 'locked' or status == 'unlocked': - self.status = status - - - def initialize(self): - """ Initialize the filter wheel. """ - - save = False - - # Give it an initial dummy command to flush out the buffer. - self.command('*idn?') - - self.revision = self.command('*idn?') - - # Turn off the position sensors when the wheel is - # idle to mitigate stray light. - - sensors = self.command('sensors?') - - if sensors != '0': - self.command('sensors=0') - save = True - - # Make sure the wheel is set to move at "high" speed, - # which takes ~3 seconds to rotate 180 degrees. - - speed = self.command('speed?') - - if speed != '1': - self.command('speed=1') - save = True - - # Make sure the external trigger is in 'output' mode. - - trigger = self.command('trig?') - - if trigger != '1': - self.command('trig=1') - save = True - - if save is True: - self.command('save') - - self.initialized = True - - - def command(self, command): - """ Wrapper to issue_command(), ensuring the command lock is - released if an exception occurs. - - :param command: String, command to issue. - - """ - - with self.lock: - try: - result = self.issue_command(command) - self.success = True - finally: - # Ensure that status is always checked, even on failure - self.check_status() - - return result - - def issue_command(self, command): - """ Wrapper to send/receive with error checking and retries. - - :param command: String, command to issue. - - """ - - if not self.connected: - self.set_status('connecting') - self.connect() - - retries = 3 - reply = '' - send_command = f"{command}\r".encode('utf-8') - - while retries > 0: - self.logger.debug("sending command %s", send_command) - try: - self.socket.send(send_command) - - except socket.error: - self.logger.error( - "Failed to send command, re-opening socket, %d retries remaining", retries) - self.disconnect() - try: - self.connect() - except OSError: - self.logger.error( - 'Could not reconnect to controller, aborting') - return None - retries -= 1 - continue - - # Wait for a reply. - delimiter = b'>' - - if 'pos=' in command: - # The next response will wait - # until the filter wheel is - # actually in position. - timeout = 5 - else: - timeout = 1 - - start = time.time() - time.sleep(0.1) - reply = self.socket.recv(1024) - while delimiter not in reply and time.time() - start < timeout: - try: - reply += self.socket.recv(1024) - self.logger.debug("reply: %s", reply) - except OSError as e: - if e.errno == ETIMEDOUT: - reply = '' - time.sleep(0.1) - - if reply == '': - # Don't log here, because it happens a lot when the controller - # is unresponsive. Just try again. - retries -= 1 - continue - break - - if isinstance(reply, str): - reply = reply.strip() - else: - reply = reply.decode('utf-8') - - if retries == 0: - raise RuntimeError('unable to successfully issue command: ' + repr(command)) - - # For a command with a reply, the response always looks like: - # - # command\rreply\r> - # - # For commands that do not have a reply, the response is: - # - # command\r> - - if command[-1] == '?': - expected = 3 - else: - expected = 2 - - chunks = reply.split('\r') - - if len(chunks) != expected: - raise ValueError(f"unexpected number of fields in response: {repr(reply)}") - - if expected == 3: - return chunks[1] - - return None - - def get_position(self): - """ Get the current position from the controller.""" - return self.command('pos?') - - def move(self, target): - """ Move the filter wheel to the target position. - - :param target: Int, target position to move. - - """ - if not self.initialized: - self.initialize() - - target = int(target) - command = f"pos={target:d}" - - response = self.command(command) - - if response is not None: - raise RuntimeError(f"error response to command: {response}") - - current = int(self.get_position()) - - if current != target: - raise RuntimeError( - f"wound up at position {current:d} instead of commanded {target:d}") - -# end of class Controller diff --git a/src/hispec/util/thorlabs/ppc102.py b/src/hispec/util/thorlabs/ppc102.py deleted file mode 100644 index 1fc30fc..0000000 --- a/src/hispec/util/thorlabs/ppc102.py +++ /dev/null @@ -1,1662 +0,0 @@ -##### IMPORTANT NOTE:: ##### -# The PPC102 can(EXTREAMELY RARELY) fall into an "unhappy state" where the user -# is unable to command or query the stage in any way. This state is not reflected -# with software or hardware indications. The issue is that an interupt can get -# out of sync with the internal firmware loop, and you are unable to hop back -# into the loop. SOLUTION:: Power Cycle -# -Elijah A-B(Dev of this Library) - -import time -import socket -from enum import IntEnum, IntFlag -import logging -import struct -import contextlib -import io - -# Should Modify: -# Provide a build mode which does not print -# Can use buildFLG to supress prints and take it in as arg -# Can keep printDev() for printing if really needed - -# -- "destination" byte formatting -- -# The destination byte in a given packet is based on which hardware element -# is being commanded and the length of the message. -# Hardware element codes: -# - 0x01 (00000001) = host computer [us] -# - 0x11 (00010001) = motherboard [for controller-general commands] -# - 0x21 (00100001) = motor channel 1 [for commands to channel 1] -# - 0x22 (00100010) = motor channel 2 -# -# When a message is going to be >6 bytes long, the MSB must be set. The manual -# suggests doing that with a bitwise OR against 0x80 (shown in manual as 'd|') -# - 0x80 (10000000) = used to bit flip the MSB, signaling a longer-than-6-byte message -# -# Example: -# In the set_position() function, we're can command channel one, so we use 0x21. -# Since the command carries data (>6 bytes), we need to OR with 0x80. -# ==>> 0x21 | 0x80 = 0xA1 -# (00100001) | (10000000) = (10100001) - -class PPC102_Coms(object): - '''Class for controlling the Throlabs PPC102 - ***Device not setting Keys/Intr bits correctly so some items are omitted - from this code to avoid confusion - - The output of the device depends solely on the 'enable' bit - ''' - - def __init__(self, ip: str, port: int, timeout: float= 2.0, - log: bool = True): - ''' - Create socket connection instance variable - Parameters: Ini file and logger bool - old default ini params - (host: str = '192.168.29.100', port: int = 10013, - timeout: float = 2.0) - ''' - # Logger setup - logname = __name__.rsplit(".", 1)[-1] - self.logger = logging.getLogger(logname) - self.logger.setLevel(logging.DEBUG) - if log: - log_handler = logging.FileHandler(logname + ".log") - formatter = logging.Formatter( - "%(asctime)s--%(name)s--%(levelname)s--%(module)s--" - "%(funcName)s--%(message)s") - log_handler.setFormatter(formatter) - self.logger.addHandler(log_handler) - # Console handler for real-time output - console_handler = logging.StreamHandler() - console_formatter = logging.Formatter("%(asctime)s--%(message)s") - console_handler.setFormatter(console_formatter) - self.logger.addHandler(console_handler) - - self.logger.info("Logger initialized for PPC102_Coms") - - # get coms - self.ip = ip - self.port = port - self.timeout = timeout - self.sock = None - self.buffsize = 1024 - # Other Instance Variables - self.sock = None - self.DELAY = .1 # Number of seconds to wait after writing a message - #Class Constants - self.OPEN_LOOP = 1 - self.CLOSED_LOOP = 2 - self.CHAN_ENABLED = 1 - self.CHAN_DISABLED = 2 - - ########### Socket Communitcations ########### - def open(self): - ''' - Opens connection to device - -Also queries the device to obtain basic information - -This serves to confirm communication - -*Closes Device and reopens if already opens - RETURNS: True/False based on Successful connection - ''' - # if instranticated then close and open a new connection - if self.sock: - self.close() - # Try for error handling - try: - self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - self.sock.settimeout(self.timeout) - self.sock.connect((self.ip, self.port)) - self.logger.info(f"Connected to {self.ip}:{self.port}") - self.logger.info("Preliminary read_buff to clear buffer: " \ - "Sometimes inicializes with 0x00 in buffer") - # silent this single read buff execution!!! - original_logger_level = None - original_logger_level = self.logger.level - self.logger.setLevel(100) # Temporarily silence logger - #(higher than CRITICAL) - - try: - with contextlib.redirect_stdout(io.StringIO()): - try: - _ = self.read_buff() - except Exception: - pass - except Exception: - pass # Silently ignore - finally: - self.logger.setLevel(original_logger_level) - - return True # Successful Connection to Device - except socket.error as e: - self.logger.error(f"Socket connection failed: {e}") - self.sock = None - return False #Unsuccessful Connection - - def close(self): - ''' - Closes the device connection - ''' - #Socket close in try statements for error handling - if self.sock: - try: - self.sock.close() - self.logger.info("Socket closed.") - except socket.error as e: - self.logger.error(f"Error closing socket: {e}") - finally: - self.sock = None - - def write(self, msg: bytes): - ''' - Sends a message to the device - msg should be bytes(ex. b'05 00 00 00 50 01') - *Data requests using 'write' should be followed by a read - Otherwise unread items in buffer may cause problems - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - - # Send message using Socket - try: - self.sock.sendall(msg) - except socket.error as e: - self.logger.error(f"Error sending data: {e}") - - def read_buff(self): - ''' - This function will read socket(max: self.bufssize). - If buffer had values, it will return those values in hex form for the - calling fucntion to disect(Also clears buffer) - ''' - #Read socket - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - #return array of hex values for other functions to Disect - res = self.sock.recv(self.buffsize) - hex_array = [f'0x{byte:02X}' for byte in res] - #print(hex_array) - return hex_array - except socket.timeout: - self.logger.error("Read timed out.") - return [] - except socket.error as e: - self.logger.error(f"Error receiving data: {e}") - return [] - - def _interpret_bit_flags(self, byte_data): - """ - Helper function to interpret bits - All bit interpretation comes from pg.204 of APT Coms Doc - """ - if len(byte_data) != 4: - raise ValueError("Expected exactly 4 bytes") - - # Convert from little endian bytes to a 32-bit unsigned integer - status = int.from_bytes(byte_data, byteorder='little', signed=False) - - # Define bit meanings - bit_flags = { - 0: "Piezo actuator connected", - 10: "Position control mode (closed loop)", - 29: "Active (unit is active)", - 31: "Channel enabled", - } - - # Extract and report set bits - results = {} - for bit, description in bit_flags.items(): - results[description] = bool(status & (1 << bit)) - - return results - - def _check_for_reboot_(self): - ''' - Checks if an unrecoverable error has occured and the device - needs to be power cycled - NOTE:: Checks for consistent behavior of unhappy state - Returns: N/A, print statement if reboot needed - ''' - self.logger.info("Checking for unrecoverable state") - #Send a set of commands to see if device responds correctly - try: - #Message to query enable state, position and loop state - enableq = bytes([0x11, 0x02, 0x01, 0x00, 0x21, 0x01]) - posq = bytes([0x21, 0x06, 0x01, 0x00, 0x21, 0x01]) - loopq = bytes([0x41, 0x06, 0x01, 0x00, 0x21, 0x01]) - #counter for number of failed responses - message_list = [enableq, posq, loopq] - counter = 0 - for message in message_list: - self.write(message) - time.sleep(self.DELAY) - result = self.read_buff() - if len(result) < 6: - counter += 1 - if counter >= 2: - raise BrokenPipeError("Device in unrecoverable State, Power Cycle Needed") - else: - self.logger.info("Device responding correctly") - return - except Exception as e: - self.logger.error(f"Error: {e}") - return - - - ######## Functions for Complete Stage Control ######## - - def identify(self): - ''' - Makes device flash screen and LED for 3 seconds - Useful for identifying connected device Visually - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - - # Send identify command - try: - self.write(bytes([0x23, 0x02, 0x01, 0x00, 0x11, 0x01])) - time.sleep(3) # Wait until identify is complete - self.write(bytes([0x23, 0x02, 0x02, 0x00, 0x11, 0x01])) - time.sleep(3) - except socket.error as e: - self.logger.error(f"Error: {e}") - return None - - def set_enable(self, channel: int = 0, enable: int = 1): - ''' - Sets enable on PPC102 Controller - channel param:(int) 1 or 2 - NOTE: Default channel is set to 0, This will change both - channels to the desired enable state provided by the user - enable param:(int) Enable=1 or Disable=2 - Returns: True/False based on successful com send - **MGMSG_MOD_SET_CHANENABLESTATE**(10 02 Chan_Ident Enable_State d s) - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - #check for valid params - if enable not in (1, 2): - raise ValueError("Enable state must be 1 (Enable) or 2 (Disable)") - - if channel == 0: - command = bytes([0x10, 0x02, 0x01, enable, 0x21, 0x01]) - self.write(command) - time.sleep(self.DELAY) - command = bytes([0x10, 0x02, 0x01, enable, 0x22, 0x01]) - self.write(command) - time.sleep(self.DELAY) - return True - if channel not in (1, 2): - raise ValueError("Channel must be 0, 1 or 2") - - chan = 0x20 + channel # Channel identifier: 0x21 or 0x22 - set_val = enable # Already an int: 1 or 2 - - command = bytes([0x10, 0x02, 0x01, set_val, chan, 0x01]) - self.write(command) - time.sleep(self.DELAY) - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def get_enable(self, channel: int = 0): - ''' - Gets enable on PPC102 Controller - channel param:(int) 1 or 2 - NOTE: channel=0 will query both channels, returning a - list (channel 1 result, channel 2 result) - Returns: enable state for that channel as int - **MGMSG_MOD_REQ_CHANENABLESTATE**(11 02 Chan_Ident 0 d s) - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - #Check channels - if channel == 0: - # Construct command: [0x11, 0x02, 0x01, 0x00, chan, 0x01] - command = bytes([0x11, 0x02, 0x01, 0x00, 0x21, 0x01]) - self.write(command) - time.sleep(self.DELAY) - ch1 = self.read_buff() - ch1_state = ch1[3] - if len(ch1) != 6: - raise BufferError("Invalid number of bytes received") - - command = bytes([0x11, 0x02, 0x01, 0x00, 0x22, 0x01]) - self.write(command) - time.sleep(self.DELAY) - ch2 = self.read_buff() - ch2_state = ch2[3] - if len(ch2) != 6: - raise BufferError("Invalid number of bytes received") - - # retrun loop state - return int(ch1_state[2:],16), int(ch2_state[2:],16) - if channel not in (1, 2): - raise ValueError("Channel must be 0, 1 or 2") - - # Send Req Enable Command - chan = 0x20 + channel - command = bytes([0x11, 0x02, 0x01, 0x00, chan, 0x01]) - - # REQ - self.write(command) - time.sleep(self.DELAY) # Wait Delay time for write - - # returns self.logger.errored state of Channel and Enable - enable_status = self.read_buff() - if len(enable_status) == 0: - raise BufferError("Buffer empty when expecting response") - - enable_state = enable_status[3] # This should be a single byte - return int(enable_state[2:],16) # Already an int if read_buff - #returns a byte array - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return -1 - - def _set_digital_outputs(self,channel:int = 1, bit=0000): - ''' - Sets Digital Output on PPC102 Controller - (Trigger Fucntionality must be disabled by calling set_trigger first) - channel param:(int) 1 or 2 - bit param:1111 for all on and 0000 for all off - (Only capable of all or nothing setting) - Returns: True/False based on successful com send - **MGMSG_MOD_SET_DIGOUTPUTS**(13 02 Bit 00 d s)** - - NOTE: Only sets all on or all off, must implment more detailed - controls if you need it - - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - chan = 0x20 + channel # '2' + channel, as hex - - # Validate bit - if bit == 0b1111: - set_val = 0x0F - elif bit == 0b0000: - set_val = 0x00 - else: - raise ReferenceError('Bit not valid – must be 0b0000 or 0b1111') - - # Construct command - command = bytes([ - 0x13, # ID - 0x02, # Param 1 - set_val, # Bits - 0x00, # Unused - chan, # Destination (e.g., 0x21 for channel 1) - 0x01 # Source - ]) - - # Send MGMSG_MOD_SET_DIGOUTPUTS command - self.write(command) - time.sleep(self.DELAY) # Wait for execution of set - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def _get_digital_outputs(self,channel:int = 1, bit=0000): - ''' - Gets Digital Output on PPC102 Controller - channel param:(int) 1 or 2 - Returns: Bit - **MGMSG_MOD_REQ_DIGOUTPUTS**(14 02 Bits 00 d s)** - - NOTE:: bit not requred but original logic from maunal includes - - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - chan = 0x20 + channel # '2' + channel, as hex - - # Validate bit (not strictly needed for a "get", - # but preserved from original logic) - if bit == 0b1111: - set_val = 0x0F - elif bit == 0b0000: - set_val = 0x00 - else: - raise ReferenceError('Bit not valid - must be 0b0000 or 0b1111') - - # Construct command - command = bytes([ - 0x14, # ID - 0x02, # Param 1 - set_val, # Bits - 0x00, # Unused - chan, # Destination - 0x01 # Source - ]) - - # Send MGMSG_MOD_REQ_DIGOUTPUTS command - self.write(command) - time.sleep(self.DELAY) # Wait for response - - # Read response - digioutputs_status = self.read_buff() - if len(digioutputs_status) == 0: - raise BufferError("Buffer empty when expecting response") - - # Parse status byte (typically at index 2 or 3 depending on format) - digioutputs_state = digioutputs_status[2] - return int(digioutputs_state[2:],16) - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def _hw_disconnect(self): - ''' - Sent by hardware unit or host to disconnect from Ethernet or USB bus - Returns: True/False based on successful com send - **MGMSG_HW_DISCONNECT**(02 00 00 00 d s)** - - NOTE:: Do not disconnect, this would require a power cycle as there - is noreconnect set of bytes to send based on the thorlabs comms - documentation - - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Send identify command - self.write(bytes([0x02, 0x00, 0x00, 0x00, 0x11, 0x01])) - time.sleep(self.DELAY) # Data Grab - res = self.read_buff() - #Save all info needed into self.variables - self.logger.info("Disconnected from Hardware") - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def _hw_response(self): - ''' - Sent by the controllers to notify Thorlabs Server of some event that - requires user intervention, usually some fault or error condition - that needs to be handled before normal operation can resume. The - message transmits the fault code as a numerical value--see the - Return Codes listed in the Thorlabs Server helpfile for details - on the specific return codes. - Returns: return code - **MGMSG_HW_RESPONSE**(80 00 00 00 d s)** - - NOTE:: According to thor labs technical team, this function and - hw_richresponse are messages that we recieve from the hardware. - Rare occation. - - ''' - raise NotImplementedError("MGMSG_HW_RESPONSE: Has not been fully implemented") - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Send Req response Command - command = bytes([0x80, 0x00, 0x00, 0x00, 0x11, 0x01]) - #REQ - self.write(command) - time.sleep(self.DELAY) # Wait Delay time for write - #returns printed - res = self.read_buff() - if(len(res) == 0): - raise BufferError("Buffer empty when expecting response") - return res # TODO: Optional – parse return code if needed - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def _hw_richresponse(self): #TODO:: Finish - ''' - Similarly, to HW_RESPONSE, this message is sent by the controllers - to notify Thorlabs Server of some event that requires user - intervention, usually some fault or error condition that needs to be - handled before normal operation can resume. However, unlike - HW_RESPONSE, this message also transmits a printable text string. - Upon receiving the message, Thorlabs Server displays both the - numerical value and the text information, which is useful in finding - the cause of the problem. - Returns: - **MGMSG_HW_RICHRESPONSE**(81 00 44 00 d s MsgIdent(x2bytes) code(x2bytes))** - - NOTE:: HW_Response and HW_RichResponse basically do the same thing, - these are usually sent by the controller indicating some sort of fault - that needs to be addressed by the user before continuing. The only - difference being that RichResponse gives you a text string to help - debug the fault. I've never seen these be returned before so they - don't come up very often. - ''' - raise NotImplementedError("MGMSG_HW_RICHRESPONSE: Has not been fully implemented") - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Send Req rich response Command - command = bytes([ - 0x81, 0x00, 0x44, 0x00, 0x11, 0x01, 0x00, 0x00, 0x00, 0x00 - ]) - #REQ - self.write(command) - time.sleep(self.DELAY) # Wait Delay time for write - #returns printed - res = self.read_buff() - if(len(res) == 0): - raise BufferError("Buffer empty when expecting response") - return res # TODO: Optional – parse message content - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def _hw_start_update_msgs(self): - ''' - Sent to start automatic status updates from the embedded - controller. Status update messages contain information about the - position and status of the controller (for example limit switch - status, motion indication, etc). The messages will be sent by - the controller every 100 msec until it receives a STOP STATUS - UPDATE MESSAGES command. In applications where spontaneous - messages (i.e., messages which are not received as a response to - a specific command) must be avoided the same information can - also be obtained by using the relevant GET_STATUTSUPDATES function. - Returns: True/False on successful com send - **MGMSG_HW_START_UPDATEMSGS**(11 00 unused unused d s)** - - NOTE: This function starts the polling loop inside the hardware that is - ''' - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Send start update response Command - command = bytes([0x11, 0x00, 0x00, 0x00, 0x11, 0x01]) - #REQ - self.write(command) - time.sleep(self.DELAY) # Wait Delay time for write - #returns printed state - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def _hw_stop_update_msgs(self): - ''' - Sent to stop automatic status updates from the controller – usually - called by a client application when it is shutting down, to instruct - the controller to turn off status updates to prevent USB buffer - overflows on the PC. - Returns: True/False on successful com send - **MGMSG_HW_STOP_UPDATEMSGS**(12 00 unused unused d s)** - ''' - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Send stop update response Command - command = bytes([0x12, 0x00, 0x00, 0x00, 0x11, 0x01]) - #REQ - self.write(command) - time.sleep(self.DELAY) # Wait Delay time for write - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def _get_info(self):#TODO:: Parse this message - ''' - Sent to request hardware information from the controller. - Returns: True/False on successful com send - **MGMSG_HW_REQ_INFO**(05 00 00 00 d s)** - - NOTE:: Response Data Packet Not parsed yet - - This function is used to get the hardware information from the - controller, such as firmware version, serial number, etc - - ''' - raise NotImplementedError(" MGMSG_HW_REQ_INFO Correctly send and " \ - "recieved but not parsed ") - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Send identify command - self.write(bytes([0x05, 0x00, 0x00, 0x00, 0x11, 0x01])) - time.sleep(self.DELAY) # Data Grab - res = self.read_buff() - if(len(res) != 90): - raise BufferError("Buffer empty when expecting response") - #Save all info needed into self.variables - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def get_rack_bay_used(self, bay:int = 0): - ''' - Sent to determine whether the specified bay in the controller is occupied. - bay param: int - Returns: True=Occupied//False=Empty - **MGMSG_RACK_REQ_BAYUSED**(60 00 Bay_Ident 00 d s)** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - if 0 <= bay < 10: - set_val = bay # already an integer, 0–9 - else: - raise ReferenceError('Bay Out of Range') - - # Send Req digioutput Command - command = bytes([0x60, 0x00, set_val, 0x00, 0x11, 0x01]) - - # REQ - self.write(command) - time.sleep(self.DELAY) # Wait Delay time for write - - # Read and process response - bay_res = self.read_buff() - if len(bay_res) == 0: - raise BufferError("Buffer empty when expecting response") - - bay_state = bay_res[3] # Already an int if read_buff returns bytes/bytearray - return int(bay_state[2:],16) == 1 - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def set_loop(self, channel: int = 0, loop:int = 1): - ''' - Sets the loop to open or closed on each channel - -Must change for each channel to have a completely closed loop - -each channel must be enabled - channel:(int) 1 or 2 - NOTE: Default channel is set to 0, This will change both - loops to the desired state the user is attempting to set it to - loop: Loop state int: 1 Open Loop (no feedback) - 2 Closed Loop (feedback employed) - 3 Open Loop Smooth - 4 Closed Loop Smooth - **MGMSG_PZ_GET_POSCONTROLMODE**(41 06 Chan_Iden - Returns: True or False on successful com send - **MGMSG_PZ_SET_POSCONTROLMODE**(40 06 Chan_Ident Mode d s)** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - #Check valid loop state - if loop in (1,2,3,4): - set_val = loop - else: - raise ReferenceError('Loop mode out of range (must be 1,2,3 or 4)') - - # Validate channel - if channel == 0: - #Check for enable, instruct to set enable if needed - if (self.get_enable(channel = 1) == self.CHAN_DISABLED or - self.get_enable(channel = 2) == self.CHAN_DISABLED): - raise PermissionError( - 'Channel must be enabled.\n' - ' Solution: call set_enable(channel= , enable=1)') - command = bytes([0x40, 0x06, 0x01, set_val, 0x21, 0x01]) - self.write(command) - command = bytes([0x40, 0x06, 0x01, set_val, 0x22, 0x01]) - self.write(command) - time.sleep(self.DELAY) - return True - elif channel not in (1, 2): - raise ValueError("Channel must be 0, 1 or 2") - - chan = 0x20 + channel # '2' + channel, as hex - - # Construct command: [0x40, 0x06, 0x01, set_val, chan, 0x01] - #Check for enable, instruct to set enable if needed - if (self.get_enable(channel) == self.CHAN_DISABLED): - raise PermissionError( - 'Channel must be enabled.\n' - ' Solution: call set_enable(channel= , enable=1)') - command = bytes([0x40, 0x06, 0x01, set_val, chan, 0x01]) - self.write(command) - time.sleep(self.DELAY) - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def get_loop(self, channel: int = 0): - ''' - Gathers the current state of a channels loop - channel:(int) 1 or 2 - NOTE: channel=0 will query both channels, returning a - list (channel 1 result, channel 2 result) - Returns: Loop state int 1 Open Loop (no feedback) - 2 Closed Loop (feedback employed) - 3 Open Loop Smooth - 4 Closed Loop Smooth - **MGMSG_PZ_GET_POSCONTROLMODE**(41 06 Chan_Ident 00 d s)** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel == 0: - # Construct command: [0x41, 0x06, 0x01, 0x00, chan, 0x01] - command = bytes([0x41, 0x06, 0x01, 0x00, 0x21, 0x01]) - self.write(command) - time.sleep(self.DELAY) - ch1 = self.read_buff() - ch1_state = ch1[3] - if len(ch1) != 6: - raise BufferError("Invalid number of bytes received") - - command = bytes([0x41, 0x06, 0x01, 0x00, 0x22, 0x01]) - self.write(command) - time.sleep(self.DELAY) - ch2 = self.read_buff() - ch2_state = ch2[3] - if len(ch2) != 6: - raise BufferError("Invalid number of bytes received") - - # retrun loop state - return int(ch1_state[2:],16), int(ch2_state[2:],16) - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - chan = 0x20 + channel # '2' + channel, as hex - - # Construct command: [0x41, 0x06, 0x01, 0x00, chan, 0x01] - command = bytes([0x41, 0x06, 0x01, 0x00, chan, 0x01]) - self.write(command) - time.sleep(self.DELAY) - - loop_status = self.read_buff() - loop_state = loop_status[3] - if len(loop_status) != 6: - raise BufferError("Invalid number of bytes received") - - # retrun loop state - return int(loop_state[2:],16) - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def are_loops_closed(self, channel: int = 0): - ''' - Uses the get_loop function that returns an int to return a - boolean for the state of the loops - channel: 0=both loops - 1=channel 1 loop - 2=channel 2 loop - returns: Bool True(int returned = 2) False(int returned = 1) - NOTE: ONLY returns true when both channels are in a closed-loop - state. will return true/false if querying for individual - channel - ''' - loop_state = self.get_loop(channel) - if isinstance(loop_state, tuple): - return loop_state[0] == 2 and loop_state[1] == 2 - return loop_state == 2 - - def set_output_volts(self, channel: int = 1, volts:int = 0): - ''' - Sets voltage going to specified channel - -Must be in open loop - -each channel must be enabled - channel:(int) 1 or 2 - volts:(int) -32768 --> 32767 - Returns: True or False on successful com send - **MGMSG_PZ_SET_OUTPUTVOLTS**(43 06 04 00 d s Chan_Ident(x2bytes) - Volts(x2bytes))** - ''' - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - destination = (0x20 + channel) | 0x80 # '2' + channel, as hex - - # Check if channel is enabled - if self.get_enable(channel) == self.CHAN_DISABLED: - raise PermissionError('Channel Must Be enabled\n' + - ' solution: call set_enable(channel= , enable=1)') - - # Check if loop is open - if self.get_loop(channel) == self.CLOSED_LOOP: - raise PermissionError("Loops Must be OPEN") - - # Check voltage range - if -32768 < volts < 32767: - volts_bytes = volts.to_bytes(2, byteorder='little', signed=True) - else: - self.logger.error('Voltage out of Range') - return False - - # Channel identifier (usually 0x01 0x00) - chan_ident = bytes([0x01, 0x00]) - - # Build command - command = bytes([0x43, 0x06, 0x04, 0x00,destination, - 0x01,]) + chan_ident + volts_bytes - - self.write(command) - time.sleep(self.DELAY) - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def get_output_volts(self, channel: int = 1): - ''' - Gathers the current state of a channels voltage - -must be in open loop - channel:(int) 1 or 2 - Returns: Voltage state in int (-32768 --> 32767) - **MGMSG_PZ_GET_OUTPUTVOLTS**(44 6 Chan_Ident 00 d s)** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - destination = (0x20 + channel) - - # Only proceed if open loop and enabled - if (self.get_loop(channel) == self.OPEN_LOOP and - self.get_enable(channel) == self.CHAN_ENABLED): - - # Construct command - command = bytes([0x44, 0x06, 0x01, 0x00,destination,0x01 ]) - self.write(command) - else: - raise PermissionError("Loops Must be OPEN and channel must be " \ - "enabled") - - time.sleep(self.DELAY) - - # Read response - volts = self.read_buff() - if len(volts) != 10: - raise BufferError("Buffer did not return expected response " \ - "length (10 bytes)") - - # Voltage is in bytes 8 and 9 (little endian hex strings like '0xA3', '0x00') - low_byte = int(volts[8], 16) # LSB - high_byte = int(volts[9], 16) # MSB - - # Convert to signed 16-bit int - voltage_raw = (high_byte << 8) | low_byte - if voltage_raw >= 0x8000: - voltage_raw -= 0x10000 - return voltage_raw - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def set_position(self, channel: int = 1, pos:float = 0.00): - ''' - Sets the position of the stage channel - -only settable while in closed loop - pos: (float-10.0 mRad -> +10.0 mRad - Returns: True or False based on successful com send - NOTE:Sending Controller 0 --> 32767 based on the angular range - user provides - **MGMSG_PZ_SET_OUTPUTPOS**(46 06 04 00 d s Chan_Ident(x2bytes) - Pos(x2bytes))** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - destination = (0x20 + channel) | 0x80 - - #Check for enable, set enable if needed - if self.get_enable(channel) == self.CHAN_DISABLED: - raise PermissionError('Channel Must Be enabled\n' + - ' solution: call set_enable(channel= , enable=1)') - - #check for loop state - if self.get_loop(channel) == self.OPEN_LOOP: - raise PermissionError("Loops Must be Closed") - - #Check for valid inputs - converted_pos = int(round((pos + 10)/20*32767)) - #Check Loop State - if 0 <= converted_pos <= 32767: - pos_bytes = converted_pos.to_bytes(2, byteorder='little', signed=False) - else: - self.logger.error('Position out of Range') - return False - - #Write command - command = bytes([0x46, 0x06, 0x04, 0x00,destination, 0x01, - 0x01, 0x00,pos_bytes[0], pos_bytes[1] ]) - self.write(command) - time.sleep(self.DELAY) - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def get_position(self, channel: int = 1): - ''' - Gets Positional Value of an axis of a stage - -can only read positions while in closed loop - channel: (int) 1 0r 2 - Returns: -10.0 mRad -> 10.0 mRad - NOTE:Contoller return 0 --> 32768 and converted is converted - to the angular range - **MGMSG_PZ_REQ_OUTPUTPOS**(47 06 Chan_Ident 00 d s)** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - return None - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - destination = (0x20 + channel) - - # Send Req OUTPUTPOS command if in closed loop - if (self.get_loop(channel) == self.CLOSED_LOOP and - self.get_enable(channel) == self.CHAN_ENABLED): - command = bytes([0x47, 0x06, 0x01, 0x00,destination, 0x01]) - self.write(command) #REQ - else: - raise PermissionError("Loops Must be Closed and channel must be " \ - "enabled") - - time.sleep(self.DELAY) # Wait Delay time for write - - #returns printed state of Channel and Enable - pos = self.read_buff() - if(len(pos) == 0): - raise BufferError("Buffer empty when expecting response") - - #Return Positional Value, 2hex or the positional value in int(bytes 8 and 9) - # Convert hex string to bytes and parse little-endian unsigned int - low_byte = int(pos[8], 16) - high_byte = int(pos[9], 16) - position = (high_byte << 8) | low_byte - - #Convert for user readability - if position > 32767: - position = 32767 - position - mRad_pos = (position / 32767) * 20 - 10 - - return mRad_pos - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def get_max_travel(self, channel: int = 1): - ''' - In the case of actuators with built in position sensing, the - Piezoelectric Control Unit can detect the range of travel of the - actuator since this information is programmed in the electronic - circuit inside the actuator. This function retrieves the maximum - travel for the piezo actuator associated with the channel specified - by the Chan Ident parameter, and returns a value (in microns) in the - Travel parameter. - channel: (int) 1 0r 2 - Returns: travel of a single acuator in microns(Linear Travel) not - Angular travel - (ThorLabs Support states: 10nm of linear travel equates - to about 20 mrad of angular movement in the mount) - **MGMSG_PZ_REQ_MAXTRAVEL**(50 06 Chan_Ident 00 d s)** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - destination = (0x20 + channel) - - # Send Req - command = bytes([0x50, 0x06, 0x01, 0x00,destination, 0x01]) - self.write(command) #REQ - - time.sleep(self.DELAY) # Wait Delay time for write - - #returns printed state of Channel and Enable - trav = self.read_buff() - if(len(trav) == 0): - raise BufferError("Buffer empty when expecting response") - - #Return travitional Value, 2hex or the travitional value in int(bytes 8 and 9) - byte1 = int(trav[8], 16) - byte2 = int(trav[9], 16) - hexVal = byte2 << 8 | byte1 - return hexVal - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def get_status_bits(self, channel: int = 1): - ''' - Returns a number of status flags pertaining to the operation of the - piezo controller channel specified in the Chan Ident parameter. - These flags are returned in a single 32 bit integer parameter and can - provide additional useful status information for client application - development. The individual bits (flags) of the 32 bit integer value - are described in the following tables. - channel: (int) 1 or 2 - Returns: Status Bytes 4 hex values - **MGMSG_PZ_REQ_PZSTATUSBITS**(5B 06 Chan_Ident 00 d s)** - - NOTE::Bit status comes from pg.204 of thor labs APT Coms documentation - - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - destination = (0x20 + channel) - - # Send - command = bytes([0x5B, 0x06, 0x01, 0x00,destination, 0x01]) - self.write(command) #REQ - - time.sleep(self.DELAY) # Wait Delay time for write - #returns printed state of Channel and Enable - status = self.read_buff() - if len(status) < 12: - raise BufferError("Buffer empty when expecting response") - - # Collect status bytes 8 through 11 (LSB to MSB) - status_bytes = bytes(int(b, 16) for b in status[8:12]) - - #deliver to interpret bytes function - results = self._interpret_bit_flags(status_bytes) - - self.logger.info("Status Flags:", results) - return results - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def get_status_update(self, channel: int = 1): - ''' - This function is used in applications where spontaneous status - messages (i.e. messages sent using the START_STATUSUPDATES - command) must be avoided. - Status update messages contain information about the position and - status of the controller (for example position and O/P voltage). The - messages will be sent by the controller each time the function is - called. - channel: (int) 1 or 2 - Returns: OPVoltage, Position,StatusBits - **MGMSG_PZ_REQ_PZSTATUSUPDATE**(60 06 Chan_Ident 00 d s)** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - destination = (0x20 + channel) - - command = bytes([0x60, 0x06, 0x01, 0x00,destination, 0x01]) - self.write(command) #REQ - time.sleep(self.DELAY) # Wait Delay time for write - #returns printed state of Channel and Enable - status = self.read_buff() - if len(status) < 16: - raise BufferError("Buffer empty when expecting response") - - volt_bytes = bytes(int(b, 16) for b in status[8:10]) - pos_bytes = bytes(int(b, 16) for b in status[10:12]) - stat_bytes = bytes(int(b, 16) for b in status[12:16]) - - voltage = int.from_bytes(volt_bytes, byteorder='little') - position = int.from_bytes(pos_bytes, byteorder='little') - #Convert for user readability - if position > 32767: - position = 32767 - position - mRad_pos = (position / 32767) * 20 - 10 - flags = self._interpret_bit_flags(stat_bytes) - #flags = self.interpret_bit_flags(stat_bytes) - - self.logger.info(f"Voltage: {voltage}") - self.logger.info(f"Position: {mRad_pos}") - - return voltage, mRad_pos, flags - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def set_max_output_voltage(self, channel: int = 1, limit:int = 150): - ''' - The piezo actuator connected to the unit has a specific maximum - operating voltage range: 75, 100 or 150 V. This function sets the - maximum voltage for the piezo actuator associated with the - specified channel. - channel: (int) 1 or 2 - Returns: True or False on successful com send - **MGMSG_PZ_SET_OUTPUTMAXVOLTS**(80 06 06 00 d| s Chan_Itent(x2bytes) - Volts(x2bytes) - Flags(x2bytes))** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - #Check for enable, set enable if needed - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - #Convert User friendly volt units to controller expected decavolt units - limit = int(limit * 10) - - destination = (0x20 + channel) | 0x80 - - #Check for valid inputs - if 0 < limit <= 1500: - hex_val = f'{limit:04X}' - #Backwards voltage section according to the manual - #little Edian - volt_lsb = int(hex_val[2:], 16) - volt_msb = int(hex_val[:2], 16) - else: - raise ValueError('Voltage out of Range') - - #Format and write commad - command = bytes([ - 0x80, 0x06, 0x06, 0x00,destination, 0x01, 0x01, 0x00, - volt_lsb, volt_msb,0x00, 0x00]) - self.write(command) - time.sleep(self.DELAY) - return True - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return False - - def get_max_output_voltage(self, channel: int = 1): - ''' - Gets Max voltage for associated channel - channel: (int) 1 or 2 - Returns: Max Volts (0v --> 150v) - **MGMSG_PZ_GET_OUTPUTMAXVOLTS**(81 06 Chan_Ident 00 d s)** - ''' - # Check if socket is open - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - # Validate channel - if channel not in (1, 2): - raise ValueError("Channel must be 1 or 2") - - destination = (0x20 + channel) - - #Send command for reqOUTPUTMAXVOLTS - command = bytes([0x81, 0x06, 0x01, 0x00,destination, 0x01]) - self.write(command) - time.sleep(self.DELAY) - msg = self.read_buff() - if(len(msg) == 0): - raise BufferError("Buffer empty when expecting response") - byte1 = int(msg[8], 16) - byte2 = int(msg[9], 16) - max_volts = byte2 << 8 | byte1 - max_volts = max_volts/10 - return max_volts - except Exception as e: - self.logger.error(f"Error: {e}") - self._check_for_reboot_() - return None - - def _set_ppc_PIDCONSTS(self, channel: int = 1, p_const: float = 900.0, - i_const: float = 800.0, d_const: float = 90.0, - dfc_const: float = 1000.0, derivFilter: bool = True): - ''' - When operating in Closed Loop mode, the proportional, integral and - derivative (PID) constants can be used to fine tune the behaviour of - the feedback loop to changes in the output voltage or position. - While closed loop operation allows more precise control of the - position, feedback loops need to be adjusted to suit the different - types of focus mount assemblies that can be connected to the - system. Due to the wide range of objectives that can be used with - the PFM450 and their different masses, some loop tuning may be - necessary to optimize the response of the system and to avoid - instability. - This message sets values for these PID parameters. The default - values have been optimized to work with the actuator shipped with - the controller and any changes should be made with caution. - channel: (int) 1 or 2 - p_const: float 0-10000 - i_const: float 0-10000 - d_const: float 0-10000 - dfc_const: float 10000 - derivFilter: True=ON False=OFF - Returns: True or False based on successful com send - **MGMSG_PZ_SET_PPC_PIDCONSTS**(90 06 0C 00 d s Chan_Ident(x2bytes) - p_const(x2bytes) i_const(x2bytes) - d_const(x2bytes) dfc_const(x2bytes) - derivFilter(x2bytes))** - - TODO:: tests - ''' - raise NotImplementedError("MGMSG_PZ_SET_PPC_PIDCONSTS: Implemented but " \ - "not tested") - #check Connection - if not self.sock: - raise RuntimeError("Socket is not connected.") - - try: - if 0 < channel < 3: - destination = (0x20 + channel) | 0x80 - - if not all(0 <= val <= 10000 for val in (p_const, i_const, - d_const, dfc_const)): - raise ValueError("PID values must be between 0 and 10000") - - chan_ident = (1).to_bytes(2, byteorder='little') - - # Convert PID values to little-endian 2-byte words - p_bytes = int(p_const).to_bytes(2, byteorder='little') - i_bytes = int(i_const).to_bytes(2, byteorder='little') - d_bytes = int(d_const).to_bytes(2, byteorder='little') - dfc_bytes = int(dfc_const).to_bytes(2, byteorder='little') - filter_flag = (1 if derivFilter else 2).to_bytes(2, byteorder='little') - - # Header: 90 06 0C 00 d s - # Assuming destination is generic device 0xD0 and source is 0x01 - header = bytes([0x90, 0x06, 0x0C, 0x00, destination, 0x01]) - data = chan_ident + p_bytes + i_bytes + d_bytes + dfc_bytes + filter_flag - - packet = header + data - self.write(packet) - time.sleep(self.DELAY) - - self.logger.info(f"PID constants sent: P={p_const}, "\ - "I={i_const}, D={d_const}, DFC={dfc_const}, "\ - "Filter={'ON' if derivFilter else 'OFF'}") - return True - except Exception as e: - self.logger.error(f"Error in set_pid_consts: {e}") - self._check_for_reboot_() - return False - - def _get_ppc_PIDCONSTS(self, channel:int = 1): - ''' - Gets current state values based on description from set - channel:(int) 1 or 2 - Returns: PID constants in the same format as the set - **MGMSG_PZ_GET_PPC_PIDCONSTS**(91 06 Chan_Ident 00 d s )** - - NOTE:: Parsing seems to be incorrect - - ''' - raise NotImplementedError("MGMSG_PZ_GET_PPC_PIDCONSTS: " \ - "Parseing seems to be Incorrect") - # check connection - if not self.sock: - raise RuntimeError("Socket is not connected.") - - try: - if 0 < channel < 3: - destination = (0x20 + channel) - else: - raise ReferenceError("Channel must be 1 or 2.") - - chan_ident = (1).to_bytes(1, byteorder='little') - - # Header for GET command: 91 06 + ChanIdent + 00 + d + s - header = bytes([0x91, 0x06]) + chan_ident + bytes([0x00, destination, 0x01]) - self.write(header) - time.sleep(self.DELAY) - - response = self.read_buff() - #Make into bytes for easier parsing - response_bytes = bytes([int(b, 16) for b in response]) - - # Parse the 12-byte payload from byte 6 onwards - p_const = int.from_bytes(response_bytes[8:10], byteorder='little') - i_const = int.from_bytes(response_bytes[10:12], byteorder='little') - d_const = int.from_bytes(response_bytes[12:14], byteorder='little') - dfc_const = int.from_bytes(response_bytes[14:16], byteorder='little') - deriv_filter_flag = int.from_bytes(response_bytes[16:18], byteorder='little') - deriv_filter = True if deriv_filter_flag == 1 else False - - pid_consts = { - 'p_const': p_const, - 'i_const': i_const, - 'd_const': d_const, - 'dfc_const': dfc_const, - 'derivFilter': deriv_filter - } - - self.logger.info(f"Retrieved PID constants: {pid_consts}") - return pid_consts - - except Exception as e: - self.logger.error(f"Error in get_pid_consts: {e}") - self._check_for_reboot_() - return None - - def _set_ppc_NOTCHPARAMS(self, channel: int, filterNO: int, - filter_1fc: float, filter_1q: float, notch_filter1_on: bool, - filter_2fc: float, filter_2q: float, notch_filter2_on: bool): - ''' - Due to their construction, most actuators are prone to mechanical - resonance at well-defined frequencies. The underlying reason is that - all spring-mass systems are natural harmonic oscillators. This - proneness to resonance can be a problem in closed loop systems - because, coupled with the effect of the feedback, it can result in - oscillations. With some actuators, the resonance peak is either weak - enough or at a high enough frequency for the resonance not to be - troublesome. With other actuators the resonance peak is very - significant and needs to be eliminated for operation in a stable - closed loop system. The notch filter is an adjustable electronic anti - resonance that can be used to counteract the natural resonance of - the mechanical system. - As the resonant frequency of actuators varies with load in addition - to the minor variations from product to product, the notch filter is - tuneable so that its characteristics can be adjusted to match those - of the actuator. In addition to its centre frequency, the bandwidth of - the notch (or the equivalent quality factor, often referred to as the - Q-factor) can also be adjusted. In simple terms, the Q factor is the - centre frequency/bandwidth, and defines how wide the notch is, a - higher Q factor defining a narrower ("higher quality") notch. - Optimizing the Q factor requires some experimentation but in - general a value of 5 to 10 is in most cases a good starting point. - channel: (int) 1 or 2 - filterNO: int 1,2,3 - filter_1fc: float 20-500 - filter_1q: float 0.2 100 - notch_filter1_on: word ON or OFF - filter_2fc: float 20-500 - filter_2q: float 0.2 100 - notch_filter2_on: word ON or OFF - Returns: true or false based on successful com send - **MGMSG_PZ_SET_PPC_NOTCHPARAMS**(93 06 10 00 d s (16 byte data packet))** - - TODO:: Requires testing - ''' - # Check for connection - if not self.sock: - raise RuntimeError("Socket is not connected.") - try: - #Check Channel and Filter values - if channel not in [1, 2]: - raise ValueError("Channel must be 1 or 2") - if filterNO not in [1, 2, 3]: - raise ValueError("Filter number must be 1, 2, or 3") - - #Assign values - destination = (0x20 + channel) | 0x80 - chan_ident = (1).to_bytes(2, byteorder='little') - filter_no_bytes = filterNO.to_bytes(2, byteorder='little') - - #Notch filters based on bools - notch1_on_bytes = (1 if notch_filter1_on else 2).to_bytes(2, 'little') - notch2_on_bytes = (1 if notch_filter2_on else 2).to_bytes(2, 'little') - - #Creation of data packet - package = ( - chan_ident + - filter_no_bytes + - struct.pack('=75"] -build-backend = "setuptools.build_meta" - -[project] -name = "thorlabs" -version = "0.1.0" -description = "A collection of Python interfaces for communicating with HISPEC FEI components, including Filter Wheel and Gimbal Mount Control." -authors = [ - { name = "Elijah Anakalea-Buckley", email = "elijahab@caltech.edu" } -] -maintainers = [ - { name = "Elijah Anakalea-Buckley", email = "elijahab@caltech.edu" } -] -readme = "README.md" -requires-python = ">=3.8" - -dependencies = [ - "pyserial" -] - -[project.urls] -Repository = "https://github.com/COO-Utils/thorlabs" - -[project.optional-dependencies] -dev = [ - "pytest-mock", - "pytest", - "black", - "flake8" -] - -[tool.pytest.ini_options] -testpaths = ["tests"] -markers = [ - "default: marks tests as default run set", - "unit: marks tests as unit tests", - "functional: marks tests as functional tests", -] - -[tool.setuptools.packages.find] -# find packages under the "thorlabs" namespace -include = ["ppc102", "fw102c"] diff --git a/src/hispec/util/thorlabs/tests/default_fw102c_test.py b/src/hispec/util/thorlabs/tests/default_fw102c_test.py deleted file mode 100644 index 26145de..0000000 --- a/src/hispec/util/thorlabs/tests/default_fw102c_test.py +++ /dev/null @@ -1,90 +0,0 @@ -################# -#Default Communication test -#Description: Test connection, disconnection and confirming communication with stage -################# - -import pytest -pytestmark = pytest.mark.default -import sys -import os -import unittest -import time -from fw102c import FilterWheelController - -########################## -## CONFIG -## connection and Disconnection in all test -########################## - -class Default_Test(unittest.TestCase): - - #Instances for Test management - def setUp(self): - self.dev = FilterWheelController() - self.success = True - self.ip = '192.168.29.100' - self.port = 10010 - self.log = False - self.error_tolerance = 0.1 - - ########################## - ## Test Connection - ########################## - def test_connection(self): - time.sleep(.2) - # Open connection - self.dev = FilterWheelController(log = self.log) - self.dev.set_connection(ip=self.ip, port=self.port) - assert self.dev.status is None - self.dev.connect() - time.sleep(.25) - assert self.dev.connected - assert self.dev.success - assert self.dev.status == 'ready' - self.dev.disconnect() - time.sleep(.25) - assert not self.dev.connected - assert self.dev.status == 'disconnected' - time.sleep(.25) - - - ########################## - ## Negative test: failed connect - ########################## - def failed_connect_test(self): - # Use an unreachable ip (TEST-NET-1 range, reserved for docs/testing) - bad_ip = "192.1.2.123" - bad_port = 65535 # usually blocked/unusable - - self.dev = FilterWheelController(log=self.log) - self.dev.set_connection(ip=bad_ip, port=bad_port) - self.dev.connect() - time.sleep(.25) - assert self.dev.success is False, "Expected connection failure with invalid ip/port" - assert self.dev.connected is False, "Expected not connected state with invalid ip/port" - self.assertFalse(dev.connected, "Expected connection failure with invalid ip/port") - self.dev.disconnect() - time.sleep(.25) - - ########################## - ## Inicialize test - ########################## - def inicialize(self): - self.dev = FilterWheelController(log = self.log) - self.dev.set_connection(ip=self.ip, port=self.port) - self.dev.connect() - time.sleep(.25) - self.dev.initialize() - time.sleep(.25) - assert self.dev.initialized - assert self.dev.revision is not None - self.dev.disconnect() - time.sleep(.25) - - -if __name__ == '__main__': - loader = unittest.TestLoader() - suite = loader.loadTestsFromTestCase(Default_Test) - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/thorlabs/tests/default_ppc102_test.py b/src/hispec/util/thorlabs/tests/default_ppc102_test.py deleted file mode 100644 index 4a74903..0000000 --- a/src/hispec/util/thorlabs/tests/default_ppc102_test.py +++ /dev/null @@ -1,160 +0,0 @@ -################# -#Default Communication test -#Description: Test connection, disconnection and confirming communication with stage -################# - -import pytest -pytestmark = pytest.mark.default -import sys -import os -import unittest -import time -from ppc102 import PPC102_Coms - -########################## -## CONFIG -## connection and Disconnection in all test -########################## - -class Comms_Test(unittest.TestCase): - - #Instances for Test management - #def setUp(self): - dev = None - success = True - ip = '192.168.29.100' - port = 10012 - log = False - error_tolerance = 0.1 - - ########################## - ## Servos / Loops [ Not really applicable] - ########################## - def test_loop(self): - time.sleep(.2) - # Open connection - self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) - time.sleep(.2) - self.dev.open() - time.sleep(.25) - for ch in [1,2]:#Check for channels that are applicable - #Close Loop assert Loop states - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.OPEN_LOOP or ret == self.dev.CLOSED_LOOP - assert self.dev.set_loop(channel=ch, loop=2) - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.CLOSED_LOOP - #Open Loops and assert the states - assert self.dev.set_loop(channel=ch, loop=1) - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.OPEN_LOOP - self.assertFalse(self.dev.set_loop(channel=5)) - self.assertFalse(self.dev.set_loop(channel=-1)) - self.assertTrue(self.dev.set_loop(loop = 4)) - ret = self.dev.get_loop(channel = 0) - assert ret[0] == self.dev.CLOSED_LOOP - assert ret[1] == self.dev.CLOSED_LOOP - self.assertTrue(self.dev.set_loop(loop = 1)) - ret = self.dev.get_loop(channel = 0) - assert ret[0] == self.dev.OPEN_LOOP - assert ret[1] == self.dev.OPEN_LOOP - self.dev.close() - time.sleep(.25) - with self.assertRaises(Exception): - self.dev.get_loop() - self.dev.set_loop() - time.sleep(.25) - #Close connection - self.dev.close() - time.sleep(.25) - - ########################## - ## Negative test: failed connect - ########################## - def failed_connect_test(self): - # Use an unreachable ip (TEST-NET-1 range, reserved for docs/testing) - bad_ip = "192.1.2.123" - bad_port = 65535 # usually blocked/unusable - - dev = PPC102_Coms(ip=bad_ip, port=bad_port, log=self.log) - - success = dev.open() - self.assertFalse(success, "Expected connection failure with invalid ip/port") - dev.close() - - ########################## - ## Limit Check - ########################## - def test_limit(self): - # Open connection - self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) - time.sleep(.2) - self.dev.open() - time.sleep(.25) - for ch in [1,2]: # Check for channels that are applicable - # Check limit states and save to variable - original_limit = self.dev.get_max_output_voltage(channel=ch) - print(f"Channel {ch} Max output Voltage: {original_limit}") - # Set limit states and assert - assert self.dev.set_max_output_voltage(channel=ch, limit=75) - ret = self.dev.get_max_output_voltage(channel=ch) - print(f"New Channel {ch} Max output Voltage: {ret}") - # set limits back to default - assert self.dev.set_max_output_voltage(channel=ch, limit=original_limit) - ret = self.dev.get_max_output_voltage(channel=ch) - print(f"Back to Original Channel {ch} Max output Voltage: {ret}") - - #Close connection - self.dev.close() - time.sleep(.25) - - ########################## - ## Position Query and Movement - ########################## - def test_position_query(self): - self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) - self.dev.open() - time.sleep(.25) - for ch in [1,2]: # Check for channels that are applicable - # Close loops and assert - assert self.dev.set_loop(channel=ch, loop=self.dev.CLOSED_LOOP) - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.CLOSED_LOOP - - # Get position and assert - original_position = self.dev.get_position(channel=ch) - #make sure that balue returned is a not none type - assert original_position is not None - #open loops and assert - assert self.dev.set_loop(channel=ch, loop=self.dev.OPEN_LOOP) - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.OPEN_LOOP - - #Close connection - self.dev.close() - time.sleep(.25) - - ########################## - ## Status Communication - ########################## - def status_communication(self): - self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) - self.dev.open() - time.sleep(.25) - for ch in [1,2]: # Check for channels that are applicable - # Get status and assert - ret = self.dev.get_status_update(channel=ch) - assert ret is not None - # Get status bits - ret = self.dev.get_status_bits(channel=ch) - assert ret is not None - self.dev.close() - time.sleep(.25) - - -if __name__ == '__main__': - loader = unittest.TestLoader() - suite = loader.loadTestsFromTestCase(Comms_Test) - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/thorlabs/tests/mock_fw102c_test.py b/src/hispec/util/thorlabs/tests/mock_fw102c_test.py deleted file mode 100644 index e4ad4da..0000000 --- a/src/hispec/util/thorlabs/tests/mock_fw102c_test.py +++ /dev/null @@ -1,46 +0,0 @@ -################# -#Unit test -#Description: Validate software functions are correctly implemented via mocking -################# - -"""Test suite for the FilterWheel class in hispec.util module.""" -import unittest -from unittest.mock import patch, MagicMock -from fw102c import FilterWheelController -import pytest -pytestmark = pytest.mark.unit - - -class TestFilterWheelController(unittest.TestCase): - """Unit tests for the FilterWheelController class.""" - - @patch("socket.socket") - def setUp(self, mock_socket_obj): # pylint: disable=arguments-differ - """Set up the test case with a mocked socket connection.""" - self.mock_socket = MagicMock() - mock_socket_obj.return_value = self.mock_socket - self.mock_socket.read.return_value = b"" - self.controller = FilterWheelController(log=False) - self.controller.set_connection(ip="123.456.789.101", port=1234) - self.controller.connected = True - - def test_get_position(self): - """Test getting the position of the filter wheel.""" - with patch.object(self.controller, "command") as mock_command: - self.controller.get_position() - mock_command.assert_called_once_with("pos?") - - def test_set_position(self): - """Test setting the position of the filter wheel.""" - with patch.object(self.controller, "command") as mock_command: - mock_command.return_value = None - with patch.object(self.controller, "get_position") as mock_getpos: - mock_getpos.return_value = 10 - self.controller.initialized = True - self.controller.move(target = 10) - mock_command.assert_called_once_with("pos=10") - - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/src/hispec/util/thorlabs/tests/mock_ppc102_test.py b/src/hispec/util/thorlabs/tests/mock_ppc102_test.py deleted file mode 100644 index 286ad35..0000000 --- a/src/hispec/util/thorlabs/tests/mock_ppc102_test.py +++ /dev/null @@ -1,60 +0,0 @@ -################# -#Unit test -#Description: Validate software functions are correctly implemented via mocking -################# - -import unittest -from unittest.mock import patch, MagicMock -# pylint: disable=import-error,no-name-in-module -from ppc102 import PPC102_Coms -import time -import pytest -pytestmark = pytest.mark.unit - - -class TestPPC102_Coms(unittest.TestCase): - """Unit tests for the SunpowerCryocooler class.""" - - @patch("socket.socket", autospec=True) - def setUp(self, mock_socket_obj): # pylint: disable=arguments-differ - """Set up the test case with a mocked socket connection.""" - self.mock_socket = MagicMock() - mock_socket_obj.return_value = self.mock_socket - self.mock_socket.read.return_value = b"" - self.controller = PPC102_Coms(ip="123.456.789.101", port=1234, log=False) - self.controller.sock = self.mock_socket - self.controller.get_loop() - - - def test_send_command(self): - """Test sending _get_infocommand to the controller.""" - with patch.object(self.controller, "write") as mock_write: - with self.assertRaises(NotImplementedError): - self.controller._get_info()#pylint: disable=protected-access - mock_write.assert_called_with(bytes([0x05, 0x00, 0x00, 0x00, 0x11, 0x01])) - - def test_get_loop(self): - """Testing sending the correct bytes to get the loop status from the gimbal.""" - with patch.object(self.controller, "write") as mock_get_loop: - self.controller.read_buff = MagicMock(return_value=bytes([0x41, 0x06, 0x01, 0x00, 0x21, 0x01, 0x02])) - self.controller.get_loop(channel = 1) - mock_get_loop.assert_called_with(bytes([0x41, 0x06, 0x01, 0x00, 0x21, 0x01])) - - - def test_set_position(self): - """Test setting the position from the Gimbal.""" - #make get_loop and get_enable return the correct responses using MagicMock - with patch.object(self.controller, "write") as mock_setpos: - self.controller.get_loop = MagicMock(return_value=2) - self.controller.get_enable = MagicMock(return_value=1) - self.controller.set_position(channel = 1, pos = 5.0) - dest = (0x20 + 1) | 0x80 - converted_pos = int(round((5.0 + 10)/20*32767)) - pos_bytes = converted_pos.to_bytes(2, byteorder='little', signed=False) - mock_setpos.assert_called_with(bytes([0x46, 0x06, 0x04, 0x00,dest, 0x01, - 0x01, 0x00,pos_bytes[0], pos_bytes[1]])) - - - -if __name__ == "__main__": - unittest.main() \ No newline at end of file diff --git a/src/hispec/util/thorlabs/tests/physical_fw102c_test.py b/src/hispec/util/thorlabs/tests/physical_fw102c_test.py deleted file mode 100644 index 32ce275..0000000 --- a/src/hispec/util/thorlabs/tests/physical_fw102c_test.py +++ /dev/null @@ -1,104 +0,0 @@ -################# -#Functionality test -#Description: Test connection, disconnection, confirming communication with stage, -# inicialization(or something similar) and movement/position query -# tests are successful and correct -################# - - -import pytest -pytestmark = pytest.mark.functional -import sys -import os -import unittest -import time -from fw102c import FilterWheelController - - -########################## -## CONFIG -## connection and Disconnection in all test -########################## -class Physical_Test(unittest.TestCase): - - #Instances for Test management - def setUp(self): - self.dev = FilterWheelController() - self.success = True - self.ip = '192.168.29.100' - self.port = 10010 - self.log = False - self.error_tolerance = 0.1 - - ########################## - ## Test Connection - ########################## - def test_connection(self): - time.sleep(.2) - # Open connection - self.dev = FilterWheelController(log = self.log) - self.dev.set_connection(ip=self.ip, port=self.port) - assert self.dev.status is None - self.dev.connect() - time.sleep(.25) - assert self.dev.connected - assert self.dev.success - assert self.dev.status == 'ready' - self.dev.disconnect() - time.sleep(.25) - assert not self.dev.connected - assert self.dev.status == 'disconnected' - time.sleep(.25) - - ########################## - ## Inicialize test - ########################## - def inicialize(self): - self.dev = FilterWheelController(log = self.log) - self.dev.set_connection(ip=self.ip, port=self.port) - self.dev.connect() - time.sleep(.25) - self.dev.initialize() - time.sleep(.25) - assert self.dev.initialized - assert self.dev.revision is not None - self.dev.disconnect() - time.sleep(.25) - - ########################## - ## Position Query and Movement - ########################## - def test_position_query_and_movement(self): - self.dev = FilterWheelController(log = self.log) - self.dev.set_connection(ip=self.ip, port=self.port) - self.dev.connect() - time.sleep(.25) - self.dev.initialize() - # Set position and assert - self.dev.move(target=1) - time.sleep(.25) - ret = int(self.dev.get_position()) - assert ret == 1 - self.dev.move(target=2) - time.sleep(.25) - ret = int(self.dev.get_position()) - assert ret == 2 - self.dev.move(target=5) - time.sleep(.25) - ret = int(self.dev.get_position()) - assert ret == 5 - self.dev.move(target=1) - time.sleep(.25) - ret = int(self.dev.get_position()) - assert ret == 1 - #Close connection - self.dev.disconnect() - time.sleep(.25) - - -if __name__ == '__main__': - loader = unittest.TestLoader() - suite = loader.loadTestsFromTestCase(Robust_Test) - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/thorlabs/tests/physical_ppc102_test.py b/src/hispec/util/thorlabs/tests/physical_ppc102_test.py deleted file mode 100644 index 3aca85f..0000000 --- a/src/hispec/util/thorlabs/tests/physical_ppc102_test.py +++ /dev/null @@ -1,149 +0,0 @@ -################# -#Functionality test -#Description: Test connection, disconnection, confirming communication with stage, -# inicialization(or something similar) and movement/position query -# tests are successful and correct -################# - -import pytest -pytestmark = pytest.mark.functional -import sys -import os -import unittest -import time -from ppc102 import PPC102_Coms - -########################## -## CONFIG -## connection and Disconnection in all test -########################## -class Physical_Test(unittest.TestCase): - - #Instances for Test management - def setUp(self): - self.dev = None - self.success = True - self.ip = '192.168.29.100' - self.port = 10012 - self.log = False - self.error_tolerance = 0.1 - - - ########################## - ## Servos / Loops [ Not really applicable] - ########################## - def test_loop(self): - time.sleep(.2) - # Open connection - self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) - time.sleep(.2) - self.dev.open() - time.sleep(.25) - for ch in [1,2]:#Check for channels that are applicable - #Close Loop assert Loop states - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.OPEN_LOOP or ret == self.dev.CLOSED_LOOP - assert self.dev.set_loop(channel=ch, loop=2) - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.CLOSED_LOOP - #Open Loops and assert the states - assert self.dev.set_loop(channel=ch, loop=1) - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.OPEN_LOOP - self.assertFalse(self.dev.set_loop(channel=5)) - self.assertFalse(self.dev.set_loop(channel=-1)) - self.assertTrue(self.dev.set_loop(loop = 4)) - ret = self.dev.get_loop(channel = 0) - assert ret[0] == self.dev.CLOSED_LOOP - assert ret[1] == self.dev.CLOSED_LOOP - self.assertTrue(self.dev.set_loop(loop = 1)) - ret = self.dev.get_loop(channel = 0) - assert ret[0] == self.dev.OPEN_LOOP - assert ret[1] == self.dev.OPEN_LOOP - self.dev.close() - time.sleep(.25) - with self.assertRaises(Exception): - self.dev.get_loop() - self.dev.set_loop() - time.sleep(.25) - #Close connection - self.dev.close() - time.sleep(.25) - - - ########################## - ## Limit Check - ########################## - def test_limit(self): - # Open connection - self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) - time.sleep(.2) - self.dev.open() - time.sleep(.25) - for ch in [1,2]: # Check for channels that are applicable - # Check limit states and save to variable - original_limit = self.dev.get_max_output_voltage(channel=ch) - print(f"Channel {ch} Max output Voltage: {original_limit}") - # Set limit states and assert - assert self.dev.set_max_output_voltage(channel=ch, limit=75) - ret = self.dev.get_max_output_voltage(channel=ch) - print(f"New Channel {ch} Max output Voltage: {ret}") - # set limits back to default - assert self.dev.set_max_output_voltage(channel=ch, limit=original_limit) - ret = self.dev.get_max_output_voltage(channel=ch) - print(f"Back to Original Channel {ch} Max output Voltage: {ret}") - - #Close connection - self.dev.close() - time.sleep(.25) - - ########################## - ## Position Query and Movement - ########################## - def test_position_query_and_movement(self): - self.dev = PPC102_Coms(ip=self.ip, port = self.port,log = self.log) - self.dev.open() - time.sleep(.25) - for ch in [1,2]: # Check for channels that are applicable - # Close loops and assert - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.OPEN_LOOP or ret == self.dev.CLOSED_LOOP - assert self.dev.set_loop(channel=ch, loop=self.dev.CLOSED_LOOP) - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.CLOSED_LOOP - # Set position and assert - assert self.dev.set_position(channel=ch, pos=0) - time.sleep(.2) - # Get position and assert - ret = self.dev.get_position(channel=ch) - assert abs(ret - 0) < self.error_tolerance*2 - original_position = ret - print(f"Channel {ch} Original Position: {original_position}") - # Set position and assert with Error Tolerance x2 - assert self.dev.set_position(channel=ch, pos=1.0) - time.sleep(.2) - ret = self.dev.get_position(channel=ch) - assert abs(ret - 1.0) < self.error_tolerance*2 - print(f"Channel {ch} New Position: {ret}") - # Set position back to default - assert self.dev.set_position(channel=ch, pos=original_position) - time.sleep(.2) - ret = self.dev.get_position(channel=ch) - assert abs(ret - original_position) < self.error_tolerance*2 - print(f"Channel {ch} Back to Original Position: {ret}") - #open loops and assert - assert self.dev.set_loop(channel=ch, loop=self.dev.OPEN_LOOP) - ret = self.dev.get_loop(channel=ch) - assert ret == self.dev.OPEN_LOOP - - #Close connection - self.dev.close() - time.sleep(.25) - - -if __name__ == '__main__': - loader = unittest.TestLoader() - suite = loader.loadTestsFromTestCase(Robust_Test) - runner = unittest.TextTestRunner(verbosity=2) - result = runner.run(suite) - sys.exit(not result.wasSuccessful()) diff --git a/src/hispec/util/xeryon/.gitignore b/src/hispec/util/xeryon/.gitignore deleted file mode 100644 index 15201ac..0000000 --- a/src/hispec/util/xeryon/.gitignore +++ /dev/null @@ -1,171 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# UV -# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -#uv.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ - -# PyPI configuration file -.pypirc diff --git a/src/hispec/util/xeryon/LICENSE b/src/hispec/util/xeryon/LICENSE deleted file mode 100644 index bce361a..0000000 --- a/src/hispec/util/xeryon/LICENSE +++ /dev/null @@ -1,22 +0,0 @@ -MIT License - -Copyright (c) [year] [fullname] - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - diff --git a/src/hispec/util/xeryon/README.md b/src/hispec/util/xeryon/README.md deleted file mode 100644 index 82fe377..0000000 --- a/src/hispec/util/xeryon/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# Xeryon Motion Controller Library - -This module provides a Python interface to communicate with and control Xeryon precision stages. It supports serial communication, axis movement, settings management, and safe handling of errors and edge cases. - -## Features -- Serial or TCP/IP communication with Xeryon controllers -- Multi-axis system support -- Configurable stage settings from a file -- Blocking/non-blocking movement -- Real-time data logging and error monitoring -- Configurable output via logger - ---- - -## Folder Structure -``` -xeryon/ -├── __init__.py -├── axis.py # Axis class abstraction -├── communication.py # Low-level serial communication logic -├── config.py # Centralized constants and flags -├── controller.py # XeryonController high-level interface -├── stage.py # Stage definitions (e.g. XLS, XLA, XRTU) -├── units.py # Unit definitions and conversion -├── utils.py # Logging, time utilities, formatting helpers -├── settings_default.txt -└── tests/ - ├── test_axis.py - ├── test_communication.py - ├── test_controller.py - └── test_utils.py -``` - ---- - -## Getting Started -### Prerequisites -- Python 3.7+ -- Xeryon controller connected via serial -- `pyserial` library - - -### Example Usage -#### Serial Connection -```python -from xeryon.controller import XeryonController -from xeryon.stage import Stage - -# Initialize controller -controller = XeryonController(COM_port="/dev/ttyUSB0") -controller.addAxis(Stage.XLS_312, "X") -controller.start() - -# Move axis -x_axis = controller.getAxis("X") -x_axis.setDPOS(1000) # Move to position 1000 in current units - -controller.stop() -``` -#### TCP/IP Connection -```python -from xeryon.controller import XeryonController -from xeryon.stage import Stage - -# Initialize controller via TCP/IP (e.g., through a terminal server) -controller = XeryonController( - connection_type="tcp", - tcp_host="192.168.1.100", - tcp_port=12345 -) -controller.add_axis(Stage.XLS_312, "X") -controller.start() - -# Move axis -x_axis = controller.get_axis("X") -x_axis.set_DPOS(1000) - -controller.stop() -``` - ---- - -## Settings File -Place a `settings_default.txt` file in the config directory. Format: -```txt -X:LLIM=0 -X:HLIM=100000 -X:SSPD=2000 -POLI=5 -``` -Each line sets a controller or axis setting. - ---- - -## Logging -The `utils.py` module provides a `output_console` function with logger integration. Messages can be printed to stdout or stderr depending on severity. - ---- - -## 🧪 Testing -Tests are written using `pytest`. Run with: -```bash -pytest -``` - - diff --git a/src/hispec/util/xeryon/__init__.py b/src/hispec/util/xeryon/__init__.py deleted file mode 100644 index b973340..0000000 --- a/src/hispec/util/xeryon/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Exposes the main public interface for the Xeryon motion control library. - -Includes: -- XeryonController: High-level interface for Xeryon motion controllers. -- Stage: Represents a single controllable motion stage. -""" -from .xeryon_controller import XeryonController -from .stage import Stage - -__all__ = ["XeryonController", "Stage"] diff --git a/src/hispec/util/xeryon/axis.py b/src/hispec/util/xeryon/axis.py deleted file mode 100644 index 933a553..0000000 --- a/src/hispec/util/xeryon/axis.py +++ /dev/null @@ -1,807 +0,0 @@ -# pylint: skip-file -import time -import math -from .units import Units -from .config import DEFAULT_POLI_VALUE, DISABLE_WAITING, DEBUG_MODE, NOT_SETTING_COMMANDS, AUTO_SEND_ENBL -from .utils import get_dpos_epos_string, get_actual_time - - -class Axis: - axis_letter = None # Stores the axis letter for this specific axis. - xeryon_object = None # Stores the "XeryonController" object. - axis_data = None # Stores all the data the controller sends. - settings = None # Stores all the settings from the settings file - stage = None # Specifies the type of stage used in this axis. - units = Units.mm # Specifies the units this axis is currently working in. - # This number increments each time an update is recieved from the controller. - update_nb = 0 - # if True, the STEP command takes DPOS as the refrence. It's called "targeted_position=1/0" in the Microcontroller - was_valid_DPOS = False - def_poli_value = str(DEFAULT_POLI_VALUE) - - # Stores if this axis is currently "Logging": it's storing its axis_data. - isLogging = False - logs = {} # This stores all the data. It's a dictionary of the form: - - previous_epos = [0, 0] # Two samples to calculate speed - previous_time = [0, 0] - - # { "EPOS": [...,...,...], "DPOS": [...,...,...], "STAT":[...,...,...],...} - - def __init__(self, xeryon_object, axis_letter, stage, logger): - """ - Initialize an Axis object. - :param xeryon_object: This points to the XeryonController object. - :type xeryon_object: Xeryon - :param axis_letter: This specifies a specific letter to this axis. - :type axis_letter: str - :param stage: This specifies the stage used in this axis. - :type stage: Stage - """ - self.logger = logger - self.axis_letter = axis_letter - self.xeryon_object = xeryon_object - self.stage = stage - self.axis_data = dict({"EPOS": 0, "DPOS": 0, "STAT": 0, "SSPD": 0}) - self.settings = dict({}) - if self.stage.isLineair: - self.units = Units.mm - else: - self.units = Units.deg - # self.settings = self.stage.defaultSettings # Load default settings - - def find_index(self, forceWaiting=False, direction=0): - """ - :return: None - This function finds the index, after finding the index it goes to the index position. - It blocks the program until the index is found. - """ - self.__send_command("INDX=" + str(direction)) - self.was_valid_DPOS = False - - if DISABLE_WAITING is False or forceWaiting is True: - # Waits a couple of updates, so the EncoderValid flag is valid and doesn't lagg behind. - self.__wait_for_update() - self.__wait_for_update() - self.logger.info("Searching index for axis " + str(self) + ".") - while not self.is_encoder_valid(): # While index not found, wait. - if not self.is_searching_index(): # Check if searching for index bit is true. - self.logger.info( - "Index is not found, but stopped searching for index.", True) - break - time.sleep(0.2) - - if self.is_encoder_valid(): - self.logger.info("Index of axis " + str(self) + " found.") - - def move(self, value): - value = int(value) - direction = 0 - if value > 0: - direction = 1 - elif value < 0: - direction = -1 - self.send_command("MOVE=" + str(direction)) - - def set_D_POS(self, value, differentUnits=None, outputToConsole=True): - """ - :param value: The new value DPOS has to become. - :param differentUnits: If the value isn't specified in the current units, specify the correct units. - :type differentUnits: Units - :param outputToConsole: Default set to True. If set to False, this function won't output text to the console. - :return: None - Note: This function makes use of the send_command function, which is blocking the program until the position is reached. - """ - unit = self.units # Current units - # If the value given are in different units than the current units: - if differentUnits is not None: - # Then specify the unit in differentUnits argument. - unit = differentUnits - - # Convert into encoder units. - DPOS = int(self.convert_units_to_encoder(value, unit)) - error = False - - self.__send_command("DPOS=" + str(DPOS)) - # And keep it True in order to avoid an accumulating error. - self.was_valid_DPOS = True - - # Block all futher processes until position is reached. - # This check isn't nessecary in DEBUG mode or when DISABLE_WAITING is True - if DEBUG_MODE is False and DISABLE_WAITING is False: - # send_time = get_actual_time() - # distance = abs(int(DPOS) - int(self.get_data("EPOS"))) # For calculating timeout time. - - # Wait some updates. This is so the flags (e.g. left end stop) of the previous command aren't received. - # self.__wait_for_update() - - # Wait until EPOS is within PTO2 AND positionReached status is received. - while not (self.__is_within_tol(DPOS) and self.is_position_reached()): - - # Check if stage is at left end or right end. ==> out of range movement. - if self.is_at_left_end() or self.is_at_right_end(): - self.logger.info("DPOS is out or range. (1) " + - get_dpos_epos_string(value, self.get_EPOS(), unit), True) - error = True - break - - # # Position reached flag is set, but EPOS not within tolerance of DPOS. - # if self.is_position_reached() and not self.__is_within_tol(DPOS): - # # if self.is_position_reached(): - # # Check if it's a lineair stage and DPOS is beyond it's limits. - # if self.stage.isLineair and ( - # int(self.get_setting("LLIM")) > int(DPOS) or int(self.get_setting("HLIM")) < int(DPOS)): - # self.logger.info("DPOS is out or range.(2)" + get_dpos_epos_string(value, self.get_EPOS(), unit), True) - # error = True - # break - - # # EPOS is not within tolerance of DPOS, unknown reason. - # self.logger.info("Position not reached. (3) " + get_dpos_epos_string(value, self.get_EPOS(), unit), True) - # error = True - # break - - if self.is_encoder_error(): - self.logger.info( - "Position not reached. (4). Encoder gave an error.", True) - error = True - break - - if self.is_error_limit(): - self.logger.info( - "Position not reached. (5) ELIM Triggered.", True) - error = True - break - - if self.is_safety_timeout_triggered(): - self.logger.info( - "Position not reached. (6) TOU2 (Timeout 2) triggered.", True) - error = True - break - - if self.is_thermal_protection_1() or self.is_thermal_protection_2(): - self.logger.info( - "Position not reached. (7) amplifier error.", True) - error = True - break - - # # This movement took too long, timeout time is estimated with speed & distance. - # if self.__time_out_reached(send_time, distance): - # self.logger.info( - # "Position not reached, timeout reached. (4) " + get_dpos_epos_string(value, self.get_EPOS(), unit), - # True) - # error = True - # break - # Keep polling ==> if timeout is not done, the computer will poll too fast. The microcontroller can't follow. - - time.sleep(0.01) - - if outputToConsole and error is False and DISABLE_WAITING is False: # Output new DPOS & EPOS if necessary - self.logger.info(get_dpos_epos_string(value, self.get_EPOS(), unit)) - - def set_TRGS(self, value): - """ - Define the start of the trigger pulses. - Expressed in the current units. - :param value: Start position to trigger the pulses. Expressed in the current units. - :return: - """ - value_in_encoder_positions = int(self.convert_units_to_encoder(value)) - self.send_command("TRGS=" + str(value_in_encoder_positions)) - - def set_TRGW(self, value): - """ - Define the width of the trigger pulses. - Expressed in the current units. - :param value: Width of the trigger pulses. Expressed in the current units. - :return: - """ - value_in_encoder_positions = int(self.convert_units_to_encoder(value)) - self.send_command("TRGW=" + str(value_in_encoder_positions)) - - def set_TRGP(self, value): - """ - Define the pitch of the trigger pulses. - Expressed in the current units. - :param value: Pitch of the trigger pulses. Expressed in the current units. - :return: - """ - value_in_encoder_positions = int(self.convert_units_to_encoder(value)) - self.send_command("TRGP=" + str(value_in_encoder_positions)) - - def set_TRGN(self, value): - """ - Define the number of trigger pulses. - :param value: Number of trigger pulses. - :return: - """ - self.send_command("TRGN=" + str(int(value))) - - def get_DPOS(self): - """ - :return: Return the desired position (DPOS) in the current units. - """ - return self.convert_encoder_units_to_units(self.get_data("DPOS"), self.units) - - def get_unit(self): - """ - :return: Return the current units this stage is working in. - """ - return self.units - - def step(self, value): - """ - :param value: The amount it needs to step (specified in the current units) - If this axis has a rotating stage, this function handles the "wrapping". (Going around in a full circle) - This function makes use of send_command, which blocks the program until the desired position is reached. - """ - step = self.convert_units_to_encoder(value, self.units) - if self.was_valid_DPOS: - # If the previous DPOS was valid, DPOS is taken as a refrence. - new_DPOS = int(self.get_data("DPOS")) + step - else: - new_DPOS = int(self.get_data("EPOS")) + step - - if not self.stage.isLineair: # Rotating Stage - # Below is the amount of encoder units in one revolution. - # From -180 => +180 - # -180 *(val // 180 % 2) + (val % 180) - encoderUnitsPerRevolution = self.convert_units_to_encoder( - 360, Units.deg) - new_DPOS = -encoderUnitsPerRevolution/2 * \ - (new_DPOS // (encoderUnitsPerRevolution/2) % - 2) + (new_DPOS % (encoderUnitsPerRevolution/2)) - - # This is used so position is checked in here. - self.set_D_POS(new_DPOS, Units.enc, False) - if DISABLE_WAITING is False: - # Waits a couple of updates, so the EPOS is valid and doesn't lagg behind. - self.__wait_for_update() - self.logger.info("Stepped: " + str(self.convert_encoder_units_to_units(step, self.units)) + " " + str( - self.units) + " " + get_dpos_epos_string(self.getDPOS(), self.get_EPOS(), self.units)) - - def get_EPOS(self): - """ - :return: Returns the EPOS in the correct units this axis is working in. - """ - return self.convert_encoder_units_to_units(self.get_data("EPOS"), self.units) - - def set_units(self, units): - """ - :param units: The units this axis needs to work in. - :type units: Units - """ - self.units = units - - def start_logging(self, increase_poli=True): - """ - This function starts logging all data that the controller sends. - It updates the POLI (Polling Interval) to get more data. - """ - self.isLogging = True - if increase_poli: - self.set_setting("POLI", "1") - self.__wait_for_update() # To make sure the POLI is set. - # DISABLE_WAITING isn't checked here, because it is really necessary. - - def end_logging(self): - """ - This function stops the logging of all the data. - It updates the POLI (Polling Interval) back to the default value. - """ - self.isLogging = False - logs = self.logs # Store logs - self.logs = {} # Reset logs - - # Restore POLI back to default value. - self.set_setting("POLI", str(self.def_poli_value)) - return logs - - def get_frequency(self): - return self.get_data("FREQ") - - def set_setting(self, tag, value, fromSettingsFile=False, doNotSendThrough=False): - """ - :param tag: The tag that needs to be stored - :param value: The value - :return: None - This stores the settings in a list as specified in the settings file. - """ - - if fromSettingsFile: - value = self.apply_setting_multipliers(tag, value) - if "MASS" in tag: - tag = "CFRQ" - if "?" not in str(value): - self.settings.update({tag: value}) - # a change: settings are send when they are set. - if not doNotSendThrough: - self.__send_command(str(tag) + "=" + str(value)) - - def start_scan(self, direction, execTime=None): - """ - :param direction: Positive or negative number. - :param execTime: Specify the execution time in seconds. If no time is specified, it scans until scanStop() is used. - :return: - This function starts a scan. - A scan is a continous movement with fixed speed. The speed is maintained by closed-loop control. - A positive number sends the stage towards increasing encoder values. - A negative number sends the stage towards decreasing encoder values. - If a time is specified, the scan will go on for that amount of seconds - If no time is specified, the scan will go on until scanStop() is ran. - """ - self.__send_command("SCAN=" + str(int(direction))) - self.was_valid_DPOS = False - - if execTime is not None: - time.sleep(execTime) - self.__send_command("SCAN=0") - - def stop_scan(self): - """ - Stop scanning. - """ - self.__send_command("SCAN=0") - self.was_valid_DPOS = False - - def set_speed(self, speed): - """ - :param speed: The new speed this axis needs to operate on. The speed is specified in the current units/second. - :type speed: int - - """ - if self.stage.isLineair: - speed = int(self.convert_encoder_units_to_units(self.convert_units_to_encoder(speed, self.units), - Units.mu)) # Convert to micrometer - else: - speed = self.convert_encoder_units_to_units(self.convert_units_to_encoder(speed, self.units), - Units.deg) # Convert to degrees - speed = int(speed) * 100 # *100 conversion factor. - self.set_setting("SSPD", str(speed)) - - def get_setting(self, tag): - """ - :param tag: The tag that indicates the setting. - :return: The value of the setting with the given tag. - """ - return self.settings.get(tag) - - def set_PTOL(self, value): - """ - :param value: The new value for PTOL (in encoder units!) - """ - self.set_setting("PTOL", value) - - def set_PTO2(self, value): - """ - :param value: The new value for PTO2 (in encoder units!) - """ - self.set_setting("PTO2", value) - - def send_command(self, command): - """ - :param command: the command that needs to be send. - This function is used to let the user send commands. - If one of the 'setting commands' are used, it is detected. - This way the settings are saved in self.settings - """ - - tag = command.split("=")[0] - value = str(command.split("=")[1]) - - if tag in NOT_SETTING_COMMANDS: - self.__send_command(command) # These settings are not stored. - else: - self.set_setting(tag, value) # These settings are stored - - def reset(self): - """ - Reset this axis. - """ - self.send_command("RSET=0") - self.was_valid_DPOS = False - - """ - Here all the status bits are checked. - """ - - def is_thermal_protection_1(self): - """ - :return: True if the "Thermal Protection 1" flag is set to true. - """ - return self.__get_stat_bit_at_index(2) == "1" - - def is_thermal_protection_2(self): - """ - :return: True if the "Thermal Protection 2" flag is set to true. - """ - return self.__get_stat_bit_at_index(3) == "1" - - def is_force_zero(self): - """ - :return: True if the "Force Zero" flag is set to true. - """ - return self.__get_stat_bit_at_index(4) == "1" - - def is_motor_on(self): - """ - :return: True if the "Motor On" flag is set to true. - """ - return self.__get_stat_bit_at_index(5) == "1" - - def is_closed_loop(self): - """ - :return: True if the "Closed Loop" flag is set to true. - """ - return self.__get_stat_bit_at_index(6) == "1" - - def is_encoder_at_index(self): - """ - :return: True if the "Encoder index" flag is set to true. - """ - return self.__get_stat_bit_at_index(7) == "1" - - def is_encoder_valid(self): - """ - :return: True if the "Encoder Valid" flag is set to true. - """ - return self.__get_stat_bit_at_index(8) == "1" - - def is_searching_index(self): - """ - :return: True if the "Searching index" flag is set to true. - """ - return self.__get_stat_bit_at_index(9) == "1" - - def is_position_reached(self): - """ - :return: True if the position reached flag is set to true. - """ - return self.__get_stat_bit_at_index(10) == "1" - - def is_encoder_error(self): - """ - :return: True if the "Encoder Error" flag is set to true. - """ - return self.__get_stat_bit_at_index(12) == "1" - - def is_scanning(self): - """ - :return: True if the "Scanning" flag is set to true. - """ - return self.__get_stat_bit_at_index(13) == "1" - - def is_at_left_end(self): - """ - :return: True if the "Left end stop" flag is set to true. - """ - return self.__get_stat_bit_at_index(14) == "1" - - def is_at_right_end(self): - """ - :return: True if the "Right end stop" flag is set to true. - """ - return self.__get_stat_bit_at_index(15) == "1" - - def is_error_limit(self): - """ - :return: True if the "ErrorLimit" flag is set to true. - """ - return self.__get_stat_bit_at_index(16) == "1" - - def is_searching_optimal_frequency(self): - """ - :return: True if the "Searching Optimal Frequency" flag is set to true. - """ - return self.__get_stat_bit_at_index(17) == "1" - - def is_safety_timeout_triggered(self): - """ - :return: True if the "Searching Optimal Frequency" flag is set to true. - """ - return self.__get_stat_bit_at_index(18) == "1" - - def get_letter(self): - """ - :return: The letter of the axis. If single axis system, it returns "X". - """ - return self.axis_letter - - def apply_setting_multipliers(self, tag, value): - """ - Some settings have to be multiplied before it can be send to the controller. - That's done in this function. - :param tag: The tag of the setting - :param value: The value of the setting - :return: Return an adjusted value for this setting. - """ - # Apply multipliers (different units in settings file and in controller) - if "MAMP" in tag or "OFSA" in tag or "OFSB" in tag or "AMPL" in tag or "MAM2" in tag: - # Use amplitude multiplier. - value = str(int(int(value) * self.stage.amplitudeMultiplier)) - elif "PHAC" in tag or "PHAS" in tag: - value = str(int(int(value) * self.stage.phaseMultiplier)) - # In the settigns file, SSPD is in mm/s ==> gets translated to mu/s - elif "SSPD" in tag or "MSPD" in tag or "ISPD" in tag: - value = str(int(float(value) * self.stage.speedMultiplier)) - elif "LLIM" in tag or "RLIM" in tag or "HLIM" in tag: - # These are given in mm/deg and need to be converted to encoder units - if self.stage.isLineair: - value = str(self.convert_units_to_encoder(value, Units.mm)) - else: - value = str(self.convert_units_to_encoder(value, Units.deg)) - elif "POLI" in tag: - self.def_poli_value = value - elif "MASS" in tag: - value = str(self.__mass_to_CFREQ(value)) - elif "ZON1" in tag or "ZON2" in tag: - if self.stage.isLineair: - value = str(self.convert_units_to_encoder(value, Units.mm)) - else: - value = str(self.convert_units_to_encoder(value, Units.deg)) - return str(value) - - def __mass_to_CFREQ(self, mass): - """ - Conversion table to change the value of the setting "MASS" into a value for the settings "CFRQ". - :return: - """ - mass = int(mass) - if mass <= 50: - return 100000 - if mass <= 100: - return 60000 - if mass <= 250: - return 30000 - if mass <= 500: - return 10000 - if mass <= 1000: - return 5000 - return 3000 - - def __str__(self): - return str(self.axis_letter) - - def __is_within_tol(self, DPOS): - """ - :param DPOS: The desired position - :return: True if EPOS is within PTO2 of DPOS. (PTO2 = Position Tolerance 2) - """ - DPOS = abs(int(DPOS)) - if self.get_setting("PTO2") is not None: - PTO2 = int(self.get_setting("PTO2")) - elif self.get_setting("PTOL") is not None: - PTO2 = int(self.get_setting("PTOL")) - else: - PTO2 = 100 # TODO - EPOS = abs(int(self.get_data("EPOS"))) - - if DPOS - PTO2 <= EPOS <= DPOS + PTO2: - return True - - def __time_out_reached(self, start_time, distance): - """ - :param start_time: The time the command started in ms. - :param distance: The distance the stage needs to travel. - :return: True if the timeout time has been reached. - The timeout time is calculated based on the speed (SSPD) and the distance. - """ - t = get_actual_time() - speed = int(self.get_setting("SSPD")) - # Convert seconds to milliseconds - timeout_t = (distance / speed * 1000) - timeout_t *= 1.25 # 25% safety factor - - # For quick and tiny movements, the method above is not accurate. - # If the timeout_t is smaller than the specified TOUT&TOU2, use TOUT+TOU2 - if self.get_setting("TOUT") is not None: - TOUT = int(self.get_setting("TOUT"))*3 - if TOUT > timeout_t: - timeout_t = TOUT - - return (t - start_time) > timeout_t - - def receive_data(self, data): - """ - :param data: The command that is received. - :return: None - This function processes the commands that are send to this axis. - eg: if "EPOS=5" is send, it stores "EPOS", "5". - If logging is enabled, this function will store the new incoming data. - """ - if "=" in data: - tag = data.split("=")[0] - val = data.split("=")[1].rstrip("\n\r").replace(" ", "") - - # The received command is a setting that's requested. - if tag not in NOT_SETTING_COMMANDS and "EPOS" not in tag and "DPOS" not in tag and not "FREQ" in tag: - self.set_setting(tag, val) - elif "FREQ" in tag: - if self.get_setting("FREQ") is not None and int(self.get_setting("FREQ")) != int(val): - self.set_setting("FREQ", val) - else: - self.axis_data[tag] = val - - if "STAT" in tag: - if self.is_safety_timeout_triggered(): - self.logger.info("The safety timeout was triggered (TOU2 command). " - "This means that the stage kept moving and oscillating around the desired position. " - "A reset is required now OR 'ENBL=1' should be send.", True) - - if self.is_thermal_protection_1() or self.is_thermal_protection_2() or self.is_error_limit() or self.is_safety_timeout_triggered(): - if self.is_error_limit(): - self.logger.info( - "Error limit is reached (status bit 16). A reset is required now OR 'ENBL=1' should be send.", True) - - if self.is_thermal_protection_2() or self.is_thermal_protection_1(): - self.logger.info( - "Thermal protection 1 or 2 is raised (status bit 2 or 3). A reset is required now OR 'ENBL=1' should be send.", True) - - if self.is_safety_timeout_triggered(): - self.logger.info( - "Saftety timeout (TOU2 timeout reached) triggered. A reset is required now OR 'ENBL=1' should be send.", True) - - if AUTO_SEND_ENBL: - self.xeryon_object.set_master_setting("ENBL", "1") - self.logger.info("'ENBL=1' is automatically send.") - - if "EPOS" in tag: # This uses "EPOS" as an indicator that a new round of data is coming in. - - self.previous_epos.remove( - self.previous_epos[0]) # Remove first entry - # Add EPOS: this is like a FIFO list - self.previous_epos.append(int(self.axis_data["EPOS"])) - self.update_nb += 1 # This update_nb is for the function __wait_for_update - - if self.isLogging: # Log all received data if logging is enabled. - # This data is useless. - if tag not in ["SRNO", "XLS ", "XRTU", "XLA ", "XTRA", "SOFT", "SYNC"]: - if self.logs.get(tag) is None: - self.logs[tag] = [] - self.logs[tag].append(int(val)) - - if "TIME" in tag: - # CALCULATE SPEED - if len(self.previous_time) > 0: - self.previous_time.remove(self.previous_time[0]) - if "TIME" in self.axis_data.items(): - self.previous_time.append(int(self.axis_data["TIME"])) - if len(self.previous_time) >= 2: - t1 = self.previous_time[0] - t2 = self.previous_time[1] - if int(t2) < int(t1): - t2 += 2**16 - - if len(self.previous_epos) >= 2: - self.axis_data["SSPD"] = ( - self.previous_epos[1] - self.previous_epos[0])/(t2 - t1) - - pass - - def get_data(self, TAG): - """ - :param TAG: The tag requested. - :return: Returns the value of this tag stored, if no data it returns None. - eg: get("DPOS") returns the value stored for "DPOS". - """ - return self.axis_data.get(TAG) # Returnt zelf None als TAG niet bestaat. - - def send_settings(self): - """ - :return: None - This function sends ALL settings to the controller. - """ - self.__send_command( - str(self.stage.encoderResolutionCommand)) # This sends: XLS =.. || XRTU=.. || XRTA=.. || XLA =.. - for tag in self.settings: - self.__send_command(str(tag) + "=" + str(self.get_setting(tag))) - - def save_settings(self): - """ - :return: None - This function just sends the "AXIS:SAVE" command to store the settings for this axis. - """ - self.send_command("SAVE=0") - - def convert_units_to_encoder(self, value, units=None): - """ - :param value: The value that needs to be converted into encoder units. - :param units: The units the value is in. - :return: The value converted into encoder units. - """ - if units is None: - units = self.units - value = float(value) - if units == Units.mm: - return round(value * 10 ** 6 * 1 / self.stage.encoderResolution) - elif units == Units.mu: - return round(value * 10 ** 3 * 1 / self.stage.encoderResolution) - elif units == Units.nm: - return round(value * 1 / self.stage.encoderResolution) - elif units == Units.inch: - return round(value * 25.4 * 10 ** 6 * 1 / self.stage.encoderResolution) - elif units == Units.minch: - return round(value * 25.4 * 10 ** 3 * 1 / self.stage.encoderResolution) - elif units == Units.enc: - return round(value) - elif units == Units.mrad: - return round(value * 10 ** 3 * 1 / self.stage.encoderResolution) - elif units == Units.rad: - return round(value * 10 ** 6 * 1 / self.stage.encoderResolution) - elif units == Units.deg: - return round(value * (2 * math.pi) / 360 * 10 ** 6 / self.stage.encoderResolution) - else: - self.xeryon_object.stop() - raise ("Unexpected unit") - - def convert_encoder_units_to_units(self, value, units=None): - """ - :param value: The value (in encoder units) that needs to be converted. - :param units: The output unit. - :return: The value converted into the output unit. - """ - if units is None: - units = self.units - value = float(value) - if units == Units.mm: - return value / (10 ** 6 * 1 / self.stage.encoderResolution) - elif units == Units.mu: - return value / (10 ** 3 * 1 / self.stage.encoderResolution) - elif units == Units.nm: - return value / (1 / self.stage.encoderResolution) - elif units == Units.inch: - return value / (25.4 * 10 ** 6 * 1 / self.stage.encoderResolution) - elif units == Units.minch: - return value / (25.4 * 10 ** 3 * 1 / self.stage.encoderResolution) - elif units == Units.enc: - return value - elif units == Units.mrad: - return value / (10 ** 3 * 1 / self.stage.encoderResolution) - elif units == Units.rad: - return value / (10 ** 6 * 1 / self.stage.encoderResolution) - elif units == Units.deg: - return value / ((2 * math.pi) / 360 * 10 ** 6 / self.stage.encoderResolution) - else: - self.xeryon_object.stop() - raise ("Unexpected unit") - - def __send_command(self, command): - """ - :param command: The command that needs to be send. - THIS IS A HIDDEN FUNCTION. Just to make sure that the SETTING commands are send via set_setting() and the other commands via send_command() - This function is used to send a command to the controller. - No "AXIS:" (e.g.: "X:") needs to be specified, just the command. - """ - tag = command.split("=")[0] - value = str(command.split("=")[1]) - - prefix = "" # In a multi axis system, prefix stores the "LETTER:". - if not self.xeryon_object.is_single_axis_system(): - prefix = self.axis_letter + ":" - - # Construct and send the command. - command = tag + "=" + str(value) - self.xeryon_object.get_communication().send_command(prefix + command) - - def __wait_for_update(self): - """ - This function waits a couple of update messages. - :return: - """ - wait_nb = 3 # This number defines how much updates need to be passed. - - # The wait number needs to adjust to POLI. - if self.get_setting("POLI") is not None: - wait_nb = wait_nb / int(self.def_poli_value) * \ - int(self.get_setting("POLI")) - - start_nb = int(self.update_nb) - while (int(self.update_nb) - start_nb) < wait_nb: - time.sleep(0.01) # Wait 10 ms - - def __get_stat_bit_at_index(self, bit_index): - if self.get_data("STAT") is not None: - bits = bin(int(self.get_data("STAT"))).replace("0b", "")[::-1] - # [::-1 mirrors the string so the status bit numbering is the same. - if len(bits) >= bit_index + 1: - return bits[bit_index] - return "0" diff --git a/src/hispec/util/xeryon/communication.py b/src/hispec/util/xeryon/communication.py deleted file mode 100644 index 28c10f3..0000000 --- a/src/hispec/util/xeryon/communication.py +++ /dev/null @@ -1,229 +0,0 @@ -# pylint: skip-file -import time -import serial -import threading -import socket - - -class Communication: - """ - Manages serial or TCP/IP communication with a Xeryon device. - - Supports automatic COM port detection, background data processing, - and queuing commands for asynchronous communication. - """ - - def __init__(self, xeryon_object, com_port, baud, logger, - connection_type='serial', tcp_host=None, tcp_port=None): - """ - Initializes the Communication object. - - :param xeryon_object: Object that manages Xeryon device and axes. - :param com_port: COM port to use (for serial communication). - :param baud: Baud rate for serial communication. - :param logger: Logger instance for error and status messages. - :param connection_type: 'serial' or 'tcp' (default is 'serial'). - :param tcp_host: Hostname or IP address for TCP connection. - :param tcp_port: Port number for TCP connection. - """ - self.xeryon_object = xeryon_object - self.COM_port = com_port - self.baud = baud - self.readyToSend = [] - self.thread = None - self.ser = None - self.sock = None - self.sio = None - self.stop_thread = False - self.logger = logger - self.connection_type = connection_type - self.tcp_host = tcp_host - self.tcp_port = tcp_port - self.last_heartbeat = None - - def start(self, external_communication_thread=False): - """ - Starts communication with the device and optionally launches a background thread. - - :param external_communication_thread: If True, returns the internal data handler - instead of starting a background thread. - :return: None or a callable for external data handling. - :raises Exception: If required connection parameters are missing or invalid. - """ - if self.connection_type == 'serial': - if self.COM_port is None: - self.xeryon_object.find_COM_port() - if self.COM_port is None: - raise Exception("No COM port found. Please provide one manually.") - self.ser = serial.Serial(self.COM_port, self.baud, timeout=1, xonxoff=True) - self.ser.flush() - time.sleep(0.1) - self.ser.flushInput() - self.ser.flushOutput() - time.sleep(0.1) - - elif self.connection_type == 'tcp': - if not self.tcp_host or not self.tcp_port: - raise Exception("TCP host and port must be specified.") - self.sock = socket.create_connection((self.tcp_host, self.tcp_port), timeout=2) - self.sio = self.sock.makefile('rwb', buffering=0) - - else: - raise Exception(f"Unknown connection_type: {self.connection_type}") - - if not external_communication_thread: - self.thread = threading.Thread(target=self.__process_data) - self.thread.daemon = True - self.thread.start() - else: - return self.__process_data - - def send_command(self, command): - """ - Queues a command to be sent to the device. - - :param command: Command string to send. - """ - self.readyToSend.append(command) - - def set_COM_port(self, com_port): - """ - Sets the COM port manually. - - :param com_port: New COM port string. - """ - self.COM_port = com_port - - def __process_data(self, external_while_loop=False): - """ - Handles sending commands and reading responses in a loop. - - :param external_while_loop: If True, run a single iteration and return (for external loops). - :return: None - """ - while not self.stop_thread: - try: - # Update heartbeat timestamp - self.last_heartbeat = time.time() - - data_to_send = self.readyToSend[:10] - self.readyToSend = self.readyToSend[10:] - - # Send commands - for command in data_to_send: - try: - msg = (command.rstrip("\n\r") + "\n").encode() - if self.connection_type == 'serial': - self.ser.write(msg) - elif self.connection_type == 'tcp': - self.sio.write(msg) - self.sio.flush() - except Exception as e: - self.logger.error(f"Write error: {e}") - continue - - # Read responses - try: - for _ in range(10): - if self.connection_type == 'serial': - if self.ser.in_waiting == 0: - break - reading = self.ser.readline().decode() - elif self.connection_type == 'tcp': - self.sock.settimeout(0.1) - reading = self.sio.readline().decode() - if not reading: - break - else: - break - - if "=" in reading: - if ":" in reading: - key, value = reading.split(":", 1) - axis = self.xeryon_object.get_axis(key) or self.xeryon_object.axis_list[0] - axis.receive_data(value) - else: - axis = self.xeryon_object.axis_list[0] - axis.receive_data(reading) - - except Exception as e: - self.logger.error(f"Read error: {e}") - - if external_while_loop: - return - - # NOTE: (KPIC MOD) we added a delay here so that we don't use as much CPU power on this loop - time.sleep(0.01) - - except Exception as e: - self.logger.error(f"CRITICAL: Communication thread encountered fatal error: {e}", exc_info=True) - self.logger.error("Communication thread is terminating!") - raise - - def is_thread_alive(self): - """ - Check if the communication thread is still running. - - :return: True if thread is alive, False otherwise - """ - return self.thread is not None and self.thread.is_alive() - - def get_thread_health_status(self): - """ - Get detailed health status of the communication thread. - - :return: Dictionary with thread health information - """ - status = { - 'thread_alive': self.is_thread_alive(), - 'last_heartbeat': self.last_heartbeat, - 'time_since_heartbeat': None, - 'heartbeat_stale': False - } - - if self.last_heartbeat is not None: - time_since = time.time() - self.last_heartbeat - status['time_since_heartbeat'] = time_since - # Consider heartbeat stale if more than 1 second old (100x the loop delay) - status['heartbeat_stale'] = time_since > 1.0 - - return status - - def check_thread_health(self, raise_on_dead=False): - """ - Check if communication thread is healthy and optionally raise exception if not. - - :param raise_on_dead: If True, raise an exception when thread is dead - :return: True if healthy, False otherwise - :raises Exception: If raise_on_dead=True and thread is not healthy - """ - status = self.get_thread_health_status() - - is_healthy = status['thread_alive'] and not status['heartbeat_stale'] - - if not is_healthy: - error_msg = "Communication thread is unhealthy: " - if not status['thread_alive']: - error_msg += "Thread is dead. " - if status['heartbeat_stale']: - error_msg += f"Heartbeat is stale ({status['time_since_heartbeat']:.2f}s old). " - - self.logger.warning(error_msg) - - if raise_on_dead: - raise Exception(error_msg) - - return is_healthy - - def close_communication(self): - """ - Closes the communication channel and stops the background thread. - """ - self.stop_thread = True - time.sleep(0.1) - - if self.connection_type == 'serial' and self.ser: - self.ser.close() - elif self.connection_type == 'tcp' and self.sock: - self.sio.close() - self.sock.close() diff --git a/src/hispec/util/xeryon/config.py b/src/hispec/util/xeryon/config.py deleted file mode 100644 index f54683f..0000000 --- a/src/hispec/util/xeryon/config.py +++ /dev/null @@ -1,38 +0,0 @@ -# pylint: skip-file -# Configuration constants for the Xeryon controller library - -SETTINGS_FILENAME = "config/xeryon_default_settings.txt" -LIBRARY_VERSION = "v1.64" - -# DEBUG MODE -# This variable is set to True if you are in debug mode. -# It ignores some checks, e.g., when sending DPOS without checking EPOS range. -DEBUG_MODE = False - -# OUTPUT TO CONSOLE -# If set to True, debug output will be printed to the console. -OUTPUT_TO_CONSOLE = True - -# DISABLE WAITING -# If set to True, the library won't wait until positions are reached. -DISABLE_WAITING = False # Important: set to False in production! - -# AUTO SEND SETTINGS -# Automatically send settings from the config file to the controller on startup. -AUTO_SEND_SETTINGS = True - -# AUTO SEND ENBL -# Automatically send ENBL=1 when specific errors occur to bypass protection. -AUTO_SEND_ENBL = False - -# Commands whose values are not stored in the library -NOT_SETTING_COMMANDS = [ - "DPOS", "EPOS", "HOME", "ZERO", "RSET", "INDX", "STEP", "MOVE", "STOP", "CONT", - "SAVE", "STAT", "TIME", "SRNO", "SOFT", "XLA3", "XLA1", "XRT1", "XRT3", "XLS1", - "XLS3", "SFRQ", "SYNC" -] - -# Default values for motion calculations -DEFAULT_POLI_VALUE = 200 -AMPLITUDE_MULTIPLIER = 1456.0 -PHASE_MULTIPLIER = 182 diff --git a/src/hispec/util/xeryon/config/xeryon_default_settings.txt b/src/hispec/util/xeryon/config/xeryon_default_settings.txt deleted file mode 100644 index b9126b1..0000000 --- a/src/hispec/util/xeryon/config/xeryon_default_settings.txt +++ /dev/null @@ -1,13 +0,0 @@ -% Default settings for Xeryon motion stages -% Axis-specific settings use the format X:= -% Master settings use = - -X:LLIM=10 -X:HLIM=200 -X:SSPD=5000 -X:PTO2=100 -X:PTOL=100 -X:POLI=5 - -POLI=7 -SAVE=0 diff --git a/src/hispec/util/xeryon/pyproject.toml b/src/hispec/util/xeryon/pyproject.toml deleted file mode 100644 index 6c606e8..0000000 --- a/src/hispec/util/xeryon/pyproject.toml +++ /dev/null @@ -1,48 +0,0 @@ -[build-system] -requires = ["setuptools>=61.0"] -build-backend = "setuptools.build_meta" - -[project] -name = "xeryon" -version = "0.1.0" -dependencies = [ - "pyserial" -] -description = "This module provides a Python interface to communicate with and control Xeryon precision stages. It supports serial communication, axis movement, settings management, and safe handling of errors and edge cases." -authors = [ - { name = "Michael Langmayr", email = "langmayr@caltech.edu" } -] -maintainers = [ - {name = "Michael Langmayr", email = "langmayr@caltech.edu"} -] -readme = "README.md" -requires-python = ">=3.8" -license = "MIT" -classifiers = [ - "Programming Language :: Python" -] - -[project.urls] -# Various URLs related to your project. These links are displayed on PyPI. -# Homepage = "https://example.com" -# Documentation = "https://readthedocs.org" -Repository = "https://github.com/COO-Utils/xeryon" -# "Bug Tracker" = "https://github.com/COO-Utils/xeryon/issues" -# Changelog = "https://github.com/yourusername/your-repo/blob/master/CHANGELOG.md" - -[project.optional-dependencies] -dev = [ - "pytest-mock", - "pytest", - "black", - "flake8" -] -[tool.setuptools.packages.find] -include = ["axis", "config", "units", "utils", "communication", "stage", "xeryon_controller"] -where = ["."] - -[tool.pytest.ini_options] -testpaths = ["tests"] - -[tool.setuptools] -py-modules = ["xeryon"] diff --git a/src/hispec/util/xeryon/stage.py b/src/hispec/util/xeryon/stage.py deleted file mode 100644 index f4802b4..0000000 --- a/src/hispec/util/xeryon/stage.py +++ /dev/null @@ -1,228 +0,0 @@ -# pylint: skip-file -from enum import Enum -import math -from .config import AMPLITUDE_MULTIPLIER, PHASE_MULTIPLIER - - -class Stage(Enum): - XLS_312 = (True, # isLineair (True/False) - # Encoder Resolution Command (XLS =|XRTU=|XRTA=|XLA =) - "XLS1=312", - 312.5, # Encoder Resolution always in nanometer/microrad - 1000) # Speed multiplier - - XLS_1250 = (True, - "XLS1=1251", - 1250, - 1000) - XLS_1250_OLD = (True, - "XLS1=1250", - 1250, - 1000) - - XLS_1250_OLD_2 = (True, - "XLS1=1250", - 312.5, - 1000) - - XLS_78 = (True, - "XLS1=78", - 78.125, - 1000) - - XLS_5 = (True, - "XLS1=5", - 5, - 1000) - - XLS_1 = (True, - "XLS1=1", - 1, - 1000) - XLS_312_3N = (True, # isLineair (True/False) - # Encoder Resolution Command (XLS =|XRTU=|XRTA=|XLA =) - "XLS3=312", - 312.5, # Encoder Resolution always in nanometer/microrad - 1000) # Speed multiplier - - XLS_1250_3N = (True, - "XLS3=1251", - 1250, - 1000) - - XLS_1250_3N_OLD = (True, - "XLS3=1250", - 312.5, - 1000) - - XLS_78_3N = (True, - "XLS3=78", - 78.125, - 1000) - - XLS_5_3N = (True, - "XLS3=5", - 5, - 1000) - - XLS_1_3N = (True, - "XLS3=1", - 1, - 1000) - - XLA_312 = (True, - "XLA1=312", - 312.5, - 1000) - - XLA_1250 = (True, - "XLA1=1250", - 1250, - 1000) - - XLA_78 = (True, - "XLA1=78", - 78.125, - 1000) - - XLA_OL = (True, - "XLA1=0", - 1, - 1000) - - XLA_OL_3N = (True, - "XLA3=0", - 1, - 1000) - - XLA_312_3N = (True, - "XLA3=312", - 312.5, - 1000) - - XLA_1250_3N = (True, - "XLA3=1250", - 1250, - 1000) - - XLA_78_3N = (True, - "XLA3=78", - 78.125, - 1000) - - XLA_312_OLD = (True, - "XLA=312", - 312.5, - 1000) - - XLA_1250_OLD = (True, - "XLA=1250", - 1250, - 1000) - - XLA_78_OLD = (True, - "XLA=78", - 78.125, - 1000) - - XRTA = (False, - "XRTA=109", # ? - (2 * math.pi * 1e6) / 57600, - 100) - - # TODO: CHECK RES - # XRTU's 1N VERSION - XRTU_40_3 = (False, - "XRT1=2", - (2 * math.pi * 1e6) / 86400, - 100) - - XRTU_40_19 = (False, - "XRT1=18", - (2 * math.pi * 1e6) / 86400, - 100) - XRTU_40_49 = (False, - "XRT1=47", - (2 * math.pi * 1e6) / 86400, - 100) - - XRTU_40_73 = (False, - "XRT1=73", - (2 * math.pi * 1e6) / 86400, # CORRECT ??? - 100) - - XRTU_30_3 = (False, - "XRT1=3", - (2 * math.pi * 1e6) / 1843200, - 100) - - XRTU_30_19 = (False, - "XRT1=19", - (2 * math.pi * 1e6) / 360000, - 100) - - XRTU_30_49 = (False, - "XRT1=49", - (2 * math.pi * 1e6) / 144000, - 100) - - XRTU_30_109 = (False, - "XRT1=109", - (2 * math.pi * 1e6) / 57600, - 100) - - XRTU_60_3 = (False, - "XRT3=3", - (2 * math.pi * 1e6) / 2073600, - 100) - XRTU_60_19 = (False, - "XRT3=19", - (2 * math.pi * 1e6) / 324000, - 100) - XRTU_60_49 = (False, - "XRT3=49", - (2 * math.pi * 1e6) / 129600, - 100) - XRTU_60_109 = (False, - "XRT3=109", - (2 * math.pi * 1e6) / 64800, - 100) - - # For backwards compatibility - - XRTU_30_109_OLD = (False, - "XRTU=109", - (2 * math.pi * 1e6) / 57600, - 100) - XRTU_40_73_OLD = (False, - "XRTU=73", - (2 * math.pi * 1e6) / 86400, - 100) - XRTU_40_3_OLD = (False, - "XRTU=3", # ? - (2 * math.pi * 1e6) / 1800000, - 100) - - def __init__(self, isLineair, encoderResolutionCommand, encoderResolution, - speedMultiplier): - - self.isLineair = isLineair - self.encoderResolutionCommand = encoderResolutionCommand - # ALTIJD IN nm / nanorad !!! ==> Verschillend met windows interface. - self.encoderResolution = encoderResolution - self.speedMultiplier = speedMultiplier # used. - self.amplitudeMultiplier = AMPLITUDE_MULTIPLIER - self.phaseMultiplier = PHASE_MULTIPLIER - - def get_stage(self, stage_command): - """ - Get stagetype by specifying "stage_command". - 'stage_command' is how the stage is specified in the config file. - e.g.: XLS=312 or XRTU=40, .... - :param stage_command: String containing "XLS=.." or "XRTU=..." or ... - :return: Stagetype, or none if invalid stage command. - """ - for stage in Stage: - if stage_command in str(stage.encoderResolutionCommand).replace(" ", ""): - return stage - return None diff --git a/src/hispec/util/xeryon/tests/__init__.py b/src/hispec/util/xeryon/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/hispec/util/xeryon/tests/test_xeryon_axis.py b/src/hispec/util/xeryon/tests/test_xeryon_axis.py deleted file mode 100644 index 7c1dc2c..0000000 --- a/src/hispec/util/xeryon/tests/test_xeryon_axis.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Unit tests for the Axis class in the hispec.util.xeryon.axis module.""" -import unittest -from unittest.mock import MagicMock -from dataclasses import dataclass -# pylint: disable=no-name-in-module,import-error -from ..axis import Axis -from .test_xeryon_controller import MockStage -from .test_xeryon_communication import MockLogger - - -@dataclass -class MockXeryonController: - """A mock Xeryon controller for testing purposes.""" - - # pylint: disable=no-self-use - def is_single_axis_system(self): - """Return True to indicate this is a single-axis system.""" - return True - - # pylint: disable=no-self-use - def get_communication(self): - """Return a mock communication object.""" - return MagicMock(send_command=MagicMock()) - - -class TestAxis(unittest.TestCase): - """Unit tests for the Axis class.""" - - def setUp(self): - """Set up a mock stage and xeryon controller for testing.""" - self.stage = MockStage() - self.xeryon = MockXeryonController() - self.axis = Axis(self.xeryon, "X", self.stage, MockLogger()) - - def test_set_setting_stores_value(self): - """Test that set_setting correctly stores a setting in the internal dictionary.""" - self.axis.set_setting("VEL", "500") - self.assertEqual(self.axis.settings["VEL"], "500") - - def test_get_setting_returns_value(self): - """Test that get_setting returns the correct value for a known tag.""" - self.axis.settings["ACC"] = "100" - self.assertEqual(self.axis.get_setting("ACC"), "100") - - def test_send_command_stores_in_settings(self): - """Test that send_command routes to set_setting for supported tags.""" - self.axis.set_setting = MagicMock() - self.axis.send_command("VEL=200") - self.axis.set_setting.assert_called_with("VEL", "200") - - def test_reset_clears_flag_and_sends(self): - """Test that reset sends the correct command and clears the was_valid_DPOS flag.""" - self.axis.send_command = MagicMock() - self.axis.was_valid_DPOS = True - self.axis.reset() - self.axis.send_command.assert_called_with("RSET=0") - self.assertFalse(self.axis.was_valid_DPOS) - - def test_get_letter_returns_axis_letter(self): - """Test that get_letter returns the correct axis letter.""" - self.assertEqual(self.axis.get_letter(), "X") - - def test_convert_units_to_encoder_mm(self): - """Test conversion from millimeters to encoder units.""" - enc = self.axis.convert_units_to_encoder(1, self.axis.units) - expected = round(1 * 1e6 / self.stage.encoderResolution) - self.assertEqual(enc, expected) - - def test_convert_encoder_to_units_mm(self): - """Test conversion from encoder units to millimeters.""" - val = self.axis.convert_encoder_units_to_units(312500, self.axis.units) - expected = 312500 / (1e6 / self.stage.encoderResolution) - self.assertAlmostEqual(val, expected, places=2) - - -if __name__ == '__main__': - unittest.main() diff --git a/src/hispec/util/xeryon/tests/test_xeryon_communication.py b/src/hispec/util/xeryon/tests/test_xeryon_communication.py deleted file mode 100644 index 8cc5b2a..0000000 --- a/src/hispec/util/xeryon/tests/test_xeryon_communication.py +++ /dev/null @@ -1,108 +0,0 @@ -""" Mock classes to simulate the Xeryon environment for testing. """ -import unittest -from unittest.mock import MagicMock, patch -from dataclasses import dataclass -# pylint: disable=import-error,no-name-in-module -from xeryon.communication import Communication - - -@dataclass -class MockAxis: - """A mock class to simulate an axis in the Xeryon system.""" - def __init__(self): - """Initialize the mock axis with an empty list to store received data.""" - self.received_data = [] - - def receive_data(self, data): - """Simulate receiving data by appending it to the received_data list.""" - self.received_data.append(data) - - -@dataclass -class MockXeryon: - """A mock class to simulate the Xeryon system.""" - def __init__(self): - self.axis_list = [MockAxis()] - - # pylint: disable=unused-argument - def get_axis(self, letter): - """Return the first axis for simplicity.""" - return self.axis_list[0] - -class MockLogger: - """A mock logger to capture log messages.""" - def __init__(self): - """Initialize the mock logger with an empty list to store messages.""" - self.messages = [] - - def info(self, msg): - """Capture info messages.""" - self.messages.append(('info', msg)) - - def debug(self, msg): - """Capture debug messages.""" - self.messages.append(('debug', msg)) - - def warning(self, msg): - """Capture warning messages.""" - self.messages.append(('warning', msg)) - - def error(self, msg): - """Capture error messages.""" - self.messages.append(('error', msg)) - - def critical(self, msg): - """Capture critical messages.""" - self.messages.append(('critical', msg)) - -class TestCommunication(unittest.TestCase): - """Unit tests for the Communication class in the Xeryon system.""" - - @patch('serial.Serial') - # pylint: disable=no-self-use - def test_start_sets_up_serial_connection(self, mock_serial_class): - """Test that the Communication class sets up the serial connection correctly.""" - mock_serial = MagicMock() - mock_serial.in_waiting = 0 - mock_serial_class.return_value = mock_serial - mock_xeryon = MockXeryon() - - comm = Communication(mock_xeryon, 'COM3', 115200, MockLogger()) - comm.start() - - mock_serial_class.assert_called_with( - 'COM3', 115200, timeout=1, xonxoff=True) - mock_serial.flush.assert_called() - mock_serial.flushInput.assert_called() - mock_serial.flushOutput.assert_called() - - def test_send_command_queues_command(self): - """Test that the send_command method queues a command.""" - mock_xeryon = MockXeryon() - comm = Communication(mock_xeryon, 'COM3', 115200, MockLogger()) - comm.send_command("DPOS=100") - - self.assertEqual(comm.readyToSend, ["DPOS=100"]) - - @patch('serial.Serial') - def test_process_data_reads_and_dispatches(self, mock_serial_class): - """Test that the __process_data method reads from serial and dispatches commands.""" - mock_serial = MagicMock() - mock_serial.readline.return_value = b'X:DPOS=1000\n' - mock_serial.in_waiting = 1 - mock_serial_class.return_value = mock_serial - - mock_xeryon = MockXeryon() - comm = Communication(mock_xeryon, 'COM3', 115200, MockLogger()) - comm.ser = mock_serial - comm.readyToSend = ["X:MOVE=1"] - - # pylint: disable=protected-access - comm._Communication__process_data(external_while_loop=True) - - self.assertIn("DPOS=1000\n", mock_xeryon.axis_list[0].received_data) - mock_serial.write.assert_called_with(b"X:MOVE=1\n") - - -if __name__ == '__main__': - unittest.main() diff --git a/src/hispec/util/xeryon/tests/test_xeryon_controller.py b/src/hispec/util/xeryon/tests/test_xeryon_controller.py deleted file mode 100644 index 57bb85b..0000000 --- a/src/hispec/util/xeryon/tests/test_xeryon_controller.py +++ /dev/null @@ -1,240 +0,0 @@ -""" -Unit tests for the XeryonController class in the hispec.util.xeryon module. -""" -import unittest -import os -import tempfile -from unittest.mock import patch, mock_open -from dataclasses import dataclass -# pylint: disable=no-name-in-module,import-error -from xeryon.xeryon_controller import XeryonController -from xeryon.stage import Stage -from xeryon.units import Units - - -def get_letter(): - """ - Return the letter associated with this axis. - """ - return "X" - - -class MockAxis: - """ - Mock class to simulate an axis in the XeryonController. - """ - def __init__(self): - """ - Initialize the mock axis with default values. - """ - self.commands = [] - self.settings = {} - - # pylint: disable=invalid-name - self.was_valid_DPOS = False - - def send_command(self, cmd): - """ - Simulate sending a command to the axis. - """ - self.commands.append(cmd) - - def set_setting(self, tag, value, *_, **__): - """ - Simulate setting a configuration value for the axis. - """ - self.settings[tag] = value - - def reset(self): - """ - Simulate resetting the axis. - """ - self.commands.append("RSET=0") - - def send_settings(self): - """ - Simulate sending the current settings of the axis. - """ - self.commands.append("send_settings") - - -class MockComm: - """ - Mock class to simulate communication with the Xeryon controller. - """ - def __init__(self): - """ - Initialize the mock communication with an empty command list. - """ - self.port = None - self.sent_commands = [] - - def send_command(self, cmd): - """ - Simulate sending a command through the communication interface. - """ - self.sent_commands.append(cmd) - - def close_communication(self): - """ - Simulate closing the communication interface. - """ - self.sent_commands.append("CLOSE") - - # pylint: disable=invalid-name - def set_COM_port(self, port): - """ - Simulate setting the communication port. - """ - self.port = port - - def start(self, *_): - """ - Simulate starting the communication interface. - """ - return self - -@dataclass() -class MockStage: - """ - Mock class to simulate a stage in the XeryonController. - """ - # pylint: disable=invalid-name - isLineair = True - # pylint: disable=invalid-name - encoderResolutionCommand = "XLS1=312" - # pylint: disable=invalid-name - encoderResolution = 312.5 - # pylint: disable=invalid-name - speedMultiplier = 1000 - # pylint: disable=invalid-name - amplitudeMultiplier = 1456.0 - # pylint: disable=invalid-name - phaseMultiplier = 182 - - -class TestXeryonController(unittest.TestCase): - """ - Unit tests for the XeryonController class. - """ - - def setUp(self): - """ - Set up the test environment by initializing a XeryonController instance - """ - self.controller = XeryonController() - self.controller.comm = MockComm() - self.axis = MockAxis() - self.controller.axis_list = [self.axis] - self.controller.axis_letter_list = ["X"] - - def test_add_axis(self): - """ - Test adding a new axis to the controller. - """ - result = self.controller.add_axis(MockStage(), "Y") - self.assertEqual(len(self.controller.axis_list), 2) - self.assertEqual(result.axis_letter, "Y") - - def test_is_single_axis(self): - """ - Test if the controller recognizes a single-axis system. - """ - self.assertTrue(self.controller.is_single_axis_system()) - - def test_stop_sends_commands(self): - """ - Test that the stop method sends the correct commands to the axis and communication - interface. - """ - self.controller.stop() - self.assertIn("ZERO=0", self.axis.commands) - self.assertIn("STOP=0", self.axis.commands) - self.assertIn("CLOSE", self.controller.comm.sent_commands) - - def test_set_master_setting(self): - """ - Test setting a master setting in the controller and ensuring it is sent correctly. - """ - self.controller.set_master_setting("VEL", "100") - self.assertEqual(self.controller.master_settings["VEL"], "100") - self.assertIn("VEL=100", self.controller.comm.sent_commands) - - def test_send_master_settings(self): - """ - Test sending master settings to the communication interface. - """ - self.controller.master_settings = {"VEL": "100", "ACC": "10"} - self.controller.send_master_settings() - self.assertIn("VEL=100", self.controller.comm.sent_commands) - self.assertIn("ACC=10", self.controller.comm.sent_commands) - - @patch("builtins.open", new_callable=mock_open, read_data="X:VEL=100\nACC=10\n") - # pylint: disable=unused-argument - def test_read_settings(self, mock_file): - """ - Test reading settings from a file and applying them to the axis. - """ - self.controller.read_settings() - self.assertEqual(self.axis.settings["VEL"], "100") - - def test_read_settings_applies_axis_values_correctly(self): - """ - Test that reading settings applies axis values correctly. - """ - settings_content = """ - X:LLIM=10 - X:HLIM=200 - X:SSPD=5000 - X:POLI=7 - """ - with tempfile.NamedTemporaryFile(mode="w+", delete=False) as tmp: - tmp.write(settings_content) - tmp_path = tmp.name - - try: - controller = XeryonController() - - axis = controller.add_axis(Stage.XLS_312, "X") - controller.read_settings(tmp_path) - - expected_llim = str( - axis.convert_units_to_encoder(10, Units.mm)) - self.assertEqual(axis.get_setting("LLIM"), expected_llim) - - expected_hlim = str( - axis.convert_units_to_encoder(200, Units.mm)) - self.assertEqual(axis.get_setting("HLIM"), expected_hlim) - - expected_sspd = str(int(5000 * axis.stage.speedMultiplier)) - self.assertEqual(axis.get_setting("SSPD"), expected_sspd) - - self.assertEqual(axis.get_setting("POLI"), "7") - finally: - os.remove(tmp_path) - - @patch("serial.tools.list_ports.comports") - # pylint: disable=invalid-name - def test_find_COM_port(self, mock_comports): - """ - Test finding the COM port for the Xeryon controller. - """ - @dataclass - class Port: - """ - Mock class to simulate a serial port. - """ - def __init__(self, device, hwid): - """ - Initialize the mock port with device and hardware ID. - """ - self.device = device - self.hwid = hwid - - mock_comports.return_value = [Port("COM3", "USB VID:PID=04D8")] - self.controller.find_com_port() - self.assertEqual(self.controller.comm.port, "COM3") - - -if __name__ == "__main__": - unittest.main() diff --git a/src/hispec/util/xeryon/tests/test_xeryon_utils.py b/src/hispec/util/xeryon/tests/test_xeryon_utils.py deleted file mode 100644 index 4460973..0000000 --- a/src/hispec/util/xeryon/tests/test_xeryon_utils.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Test suite for Xeryon utility functions.""" -# pylint: disable=import-error,no-name-in-module -from xeryon.utils import get_actual_time, get_dpos_epos_string - - -def test_get_actual_time_returns_int(): - """Test that get_actual_time returns an integer.""" - timestamp = get_actual_time() - assert isinstance(timestamp, int) - - -def test_get_dpos_epos_string_format(): - """Test that get_dpos_epos_string formats the string correctly.""" - dpos, epos, unit = 123, 456, 'mm' - result = get_dpos_epos_string(dpos, epos, unit) - assert result == "DPOS: 123 mm and EPOS: 456 mm" diff --git a/src/hispec/util/xeryon/units.py b/src/hispec/util/xeryon/units.py deleted file mode 100644 index ed976ee..0000000 --- a/src/hispec/util/xeryon/units.py +++ /dev/null @@ -1,35 +0,0 @@ -# pylint: skip-file -""" -Defines the Units enum for standardized unit representation and improved code readability. - -Each unit has a unique ID and string name. Includes a method to match a unit from a string. -""" -from enum import Enum - - -class Units(Enum): - """ - This class is only made for making the program more readable. - """ - mm = (0, "mm") - mu = (1, "mu") - nm = (2, "nm") - inch = (3, "inches") - minch = (4, "milli inches") - enc = (5, "encoder units") - rad = (6, "radians") - mrad = (7, "mrad") - deg = (8, "degrees") - - def __init__(self, ID, str_name): - self.ID = ID - self.str_name = str_name - - def __str__(self): - return self.str_name - - def get_unit(self, str): - for unit in Units: - if unit.str_name in str: - return unit - return None diff --git a/src/hispec/util/xeryon/utils.py b/src/hispec/util/xeryon/utils.py deleted file mode 100644 index 5030fd5..0000000 --- a/src/hispec/util/xeryon/utils.py +++ /dev/null @@ -1,24 +0,0 @@ -# pylint: skip-file -""" -Utility functions for time measurement and formatted position string generation. - -This module includes: -- `get_actual_time()`: Returns the current time in milliseconds. -- `get_dpos_epos_string(dpos, epos, unit)`: Returns a formatted string - of dpos and epos values with units. -""" -import time - - -def get_actual_time(): - """ - :return: Returns the actual time in ms. - """ - return int(round(time.time() * 1000)) - - -def get_dpos_epos_string(DPOS, EPOS, Unit): - """ - :return: A string containting the EPOS & DPOS value's and the current units. - """ - return str("DPOS: " + str(DPOS) + " " + str(Unit) + " and EPOS: " + str(EPOS) + " " + str(Unit)) diff --git a/src/hispec/util/xeryon/xeryon_controller.py b/src/hispec/util/xeryon/xeryon_controller.py deleted file mode 100644 index 1c1ebbc..0000000 --- a/src/hispec/util/xeryon/xeryon_controller.py +++ /dev/null @@ -1,334 +0,0 @@ -""" -Defines the XeryonController class, the main interface for communicating with -Xeryon motion controllers. - -Includes setup of communication, management of connected axes, settings handling, -and motion control utilities. -""" -import time -import serial -import os -import logging -from .communication import Communication -from .axis import Axis -from .config import AUTO_SEND_SETTINGS, SETTINGS_FILENAME - - -class XeryonController: - """ - Main controller class for Xeryon motion systems. - - Handles communication setup, axis registration, system initialization, and command execution. - Supports both serial and TCP communication, single or multi-axis setups, - and persistent settings. - - Typical usage: - controller = XeryonController(COM_port="COM3") - controller.add_axis(stage="linear", axis_letter="X") - controller.start() - """ - # pylint: disable=too-many-arguments - def __init__(self, COM_port=None, baudrate=115200, log=True, logfile=None, - settings_filename=SETTINGS_FILENAME, - connection_type='serial', tcp_host=None, tcp_port=None): - """ - :param COM_port: Specify the COM port used. - :type COM_port: string - :param baudrate: Specify the baudrate. - :type baudrate: int - :param quiet: If True, suppresses logger output to stdout. - :type quiet: bool - :param settings_filename: Path to the settings file to use for this controller instance. - :type settings_filename: str - :return: A XeryonController object. - - Main Xeryon Drive Class, onitialize with the COM port, baudrate, and a settings file - for communication with the driver. - """ - - # Logging - logfile = __name__.rsplit('.', 1)[-1] + '.log' - self.logger = logging.getLogger(logfile) - self.logger.setLevel(logging.INFO) - formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' - ) - - if log: - file_handler = logging.FileHandler(logfile) - file_handler.setFormatter(formatter) - self.logger.addHandler(file_handler) - - self.comm = Communication( - self, COM_port, baudrate, self.logger, - connection_type=connection_type, tcp_host=tcp_host, tcp_port=tcp_port - ) # Startup communication - self.axis_list = [] - self.axis_letter_list = [] - self.master_settings = {} - self.settings_filename = settings_filename - - def is_single_axis_system(self): - """ - :return: Returns True if it's a single axis system, False if its a multiple axis system. - """ - return len(self.get_all_axis()) <= 1 - - def start(self, external_communication_thread=False, do_reset=True, auto_send_settings=AUTO_SEND_SETTINGS): - """ - :return: Nothing. - This functions NEEDS to be ran before any commands are executed. - This function starts the serial communication and configures the settings - with the controller. - - - NOTE: (KPIC MOD) we added the do_reset flag so that we can disconnect and reconnect - to the stage without doing a reset. This allows us to reconnect without having - to re-reference the stage. - """ - if len(self.get_all_axis()) <= 0: - raise Exception( - "Cannot start the system without stages. The stages don't have to be connnected, " - "only initialized in the software.") - - comm = self.get_communication().start( - external_communication_thread) # Start communication - - if do_reset: - for axis in self.get_all_axis(): - axis.reset() - time.sleep(0.2) - - self.read_settings() # Read settings file - if auto_send_settings: - self.send_master_settings() - for axis in self.get_all_axis(): # Loop trough each axis: - axis.send_settings() # Send the settings - # ask for LLIM & HLIM value's - for axis in self.get_all_axis(): - axis.send_command("HLIM=?") - axis.send_command("LLIM=?") - axis.send_command("SSPD=?") - axis.send_command("PTO2=?") - axis.send_command("PTOL=?") - - if external_communication_thread: - return comm - return None - - def stop(self, is_print_end=True): - """ - :return: None - This function sends STOP to the controller and closes the communication. - - NOTE: (KPIC MOD) we added the is_print_end flag to avoid unnecessary prints that - may confuse users - """ - for axis in self.get_all_axis(): # Send STOP to each axis. - axis.send_command("ZERO=0") - axis.send_command("STOP=0") - axis.was_valid_DPOS = False - self.get_communication().close_communication() # Close communication - if is_print_end: - self.logger.info("Program stopped running.") - - def stop_movements(self): - """ - Just stop moving. - """ - for axis in self.get_all_axis(): - axis.send_command("STOP=0") - axis.was_valid_DPOS = False - - def reset(self): - """ - :return: None - This function sends RESET to the controller, and resends all settings. - """ - for axis in self.get_all_axis(): - axis.reset() - time.sleep(0.2) - - self.read_settings() # Read settings file again - - if AUTO_SEND_SETTINGS: - for axis in self.get_all_axis(): - axis.send_settings() # Update settings - - def get_all_axis(self): - """ - :return: A list containing all axis objects belonging to this controller. - """ - return self.axis_list - - def add_axis(self, stage, axis_letter): - """ - :param stage: Specify the type of stage that is connected. - :type stage: Stage - :return: Returns an Axis object - """ - new_axis = Axis(self, axis_letter, stage, self.logger) - self.axis_list.append(new_axis) # Add axis to axis list. - self.axis_letter_list.append(axis_letter) - return new_axis - - # End User Commands - def get_communication(self): - """ - :return: The communication class. - """ - return self.comm - - def get_axis(self, letter): - """ - :param letter: Specify the axis letter - :return: Returns the correct axis object. Or None if the axis does not exist. - """ - if self.axis_letter_list.count(letter) == 1: # Axis letter found - indx = self.axis_letter_list.index(letter) - if len(self.get_all_axis()) > indx: - return self.get_all_axis()[indx] # Return axis - return None - - def read_settings(self, settings_file: str = None): - """ - :param settings_file: Optional path to a settings file. If not provided, - uses self.settings_filename. - :return: None - This function reads the settings.txt file and processes each line. - It first determines for what axis the setting is, then it reads the setting and saves it. - If there are commands for axis that don't exist, it just ignores them. - """ - filepath = settings_file if settings_file is not None else self.settings_filename - - print(filepath) - - try: - with open(filepath, "r") as file: - for line in file.readlines(): # For each line: - # Check if it's a command and not a comment or blank line. - if "=" in line and line.find("%") != 0: - - # Strip spaces and newlines. - line = line.strip("\n\r").replace(" ", "") - # Default select the first axis. - axis = self.get_all_axis()[0] - if ":" in line: # Check if axis is specified - axis = self.get_axis(line.split(":")[0]) - if axis is None: # Check if specified axis exists - # No valid axis? ==> IGNORE and loop further. - continue - line = line.split(":")[1] # Strip "X:" from command - elif not self.is_single_axis_system(): - # This line doesn't contain ":", so it doesn't specify an axis. - # BUT It's a multi-axis system ==> so these settings are for the master. - if "%" in line: # Ignore comments - line = line.split("%")[0] - self.set_master_setting(line.split( - "=")[0], line.split("=")[1], True) - continue - - if "%" in line: # Ignore comments - line = line.split("%")[0] - - tag = line.split("=")[0] - value = line.split("=")[1] - - # Update settings for specified axis. - axis.set_setting(tag, value, True, doNotSendThrough=True) - - except FileNotFoundError as ex: - print("Trying to open:", os.path.abspath(filepath)) - self.logger.info("No settings_default.txt found.") - # self.stop() # Make sure the thread also stops. - # raise Exception( - # "ERROR: settings_default.txt file not found. Place it in the same folder - # as Xeryon.py. \n " - # "The settings_default.txt is delivered in the same folder as the - # Windows Interface. \n " + str(e)) - except Exception as ex: - raise ex - - def set_master_setting(self, tag, value, from_settings_file=False): - """ - In multi-axis systems, commands without an axis specified are for the master. - This function adds a setting (tag, value) to the list of settings for the master. - """ - self.master_settings.update({tag: value}) - if not from_settings_file: - self.comm.send_command(str(tag)+"="+str(value)) - if "COM" in tag: - self.set_com_port(str(value)) - - def send_master_settings(self, axis=False): - """ - In multi-axis systems, commands without an axis specified are for the master. - This function sends the stored settings to the controller; - """ - prefix = "" - if axis is not False: - prefix = str(self.get_all_axis()[0].get_letter()) + ":" - - for tag, value in self.master_settings.items(): - self.comm.send_command(str(prefix) + str(tag) + "="+str(value)) - - def save_master_settings(self, axis=False): - """ - In multi-axis systems, commands without an axis specified are for the master. - This function saves the master settings on the controller. - """ - if axis is None: - self.comm.send_command("SAVE=0") - else: - self.comm.send_command( - str(self.get_all_axis()[0].get_letter()) + ":SAVE=0") - - def set_com_port(self, com_port): - """ - :param com_port: Specify the COM port used. - """ - self.get_communication().set_COM_port(com_port) - - def find_com_port(self): - """ - This function loops through every available COM-port. - It check's if it contains any signature of Xeryon. - :return: - """ - self.logger.info("Automatically searching for COM-Port. If you want to speed things up " - "you should manually provide it inside the controller object.") - ports = list(serial.tools.list_ports.comports()) - for port in ports: - if "04D8" in str(port.hwid): - self.set_com_port(str(port.device)) - break - - def is_communication_thread_alive(self): - """ - Check if the communication thread is still running. - - :return: True if thread is alive, False otherwise - """ - return self.comm.is_thread_alive() - - def get_communication_health_status(self): - """ - Get detailed health status of the communication thread. - - :return: Dictionary with thread health information including: - - thread_alive: bool - - last_heartbeat: timestamp or None - - time_since_heartbeat: float (seconds) or None - - heartbeat_stale: bool - """ - return self.comm.get_thread_health_status() - - def check_communication_health(self, raise_on_dead=False): - """ - Check if communication thread is healthy and optionally raise exception if not. - - :param raise_on_dead: If True, raise an exception when thread is dead - :return: True if healthy, False otherwise - :raises Exception: If raise_on_dead=True and thread is not healthy - """ - return self.comm.check_thread_health(raise_on_dead=raise_on_dead) From 3b337e4a3b2299723ee96a68a7ff5424aab61e68 Mon Sep 17 00:00:00 2001 From: Reed Riddle Date: Mon, 15 Dec 2025 14:01:00 -0800 Subject: [PATCH 3/4] Re-add util submodules under src/hispec/util --- .gitmodules | 48 +++++++++++++++++++++++++++++++++++++++ src/hispec/util/gammavac | 1 + src/hispec/util/inficon | 1 + src/hispec/util/lakeshore | 1 + src/hispec/util/newport | 1 + src/hispec/util/onewire | 1 + src/hispec/util/ozoptics | 1 + src/hispec/util/pi | 1 + src/hispec/util/srs | 1 + src/hispec/util/standa | 1 + src/hispec/util/sunpower | 1 + src/hispec/util/thorlabs | 1 + src/hispec/util/xeryon | 1 + 13 files changed, 60 insertions(+) create mode 160000 src/hispec/util/gammavac create mode 160000 src/hispec/util/inficon create mode 160000 src/hispec/util/lakeshore create mode 160000 src/hispec/util/newport create mode 160000 src/hispec/util/onewire create mode 160000 src/hispec/util/ozoptics create mode 160000 src/hispec/util/pi create mode 160000 src/hispec/util/srs create mode 160000 src/hispec/util/standa create mode 160000 src/hispec/util/sunpower create mode 160000 src/hispec/util/thorlabs create mode 160000 src/hispec/util/xeryon diff --git a/.gitmodules b/.gitmodules index e69de29..f733c1a 100644 --- a/.gitmodules +++ b/.gitmodules @@ -0,0 +1,48 @@ +[submodule "src/hispec/util/lakeshore"] + path = src/hispec/util/lakeshore + url = https://github.com/COO-Utilities/lakeshore + branch = main +[submodule "src/hispec/util/inficon"] + path = src/hispec/util/inficon + url = https://github.com/COO-Utilities/inficon + branch = main +[submodule "src/hispec/util/gammavac"] + path = src/hispec/util/gammavac + url = https://github.com/COO-Utilities/gammavac + branch = main +[submodule "src/hispec/util/standa"] + path = src/hispec/util/standa + url = https://github.com/COO-Utilities/standa + branch = main +[submodule "src/hispec/util/thorlabs"] + path = src/hispec/util/thorlabs + url = https://github.com/COO-Utilities/thorlabs + branch = main +[submodule "src/hispec/util/sunpower"] + path = src/hispec/util/sunpower + url = https://github.com/COO-Utilities/sunpower + branch = main +[submodule "src/hispec/util/onewire"] + path = src/hispec/util/onewire + url = https://github.com/COO-Utilities/onewire + branch = main +[submodule "src/hispec/util/xeryon"] + path = src/hispec/util/xeryon + url = https://github.com/COO-Utilities/xeryon + branch = main +[submodule "src/hispec/util/pi"] + path = src/hispec/util/pi + url = https://github.com/COO-Utilities/pi + branch = main +[submodule "src/hispec/util/srs"] + path = src/hispec/util/srs + url = https://github.com/COO-Utilities/srs + branch = main +[submodule "src/hispec/util/ozoptics"] + path = src/hispec/util/ozoptics + url = https://github.com/COO-Utilities/ozoptics + branch = main +[submodule "src/hispec/util/newport"] + path = src/hispec/util/newport + url = https://github.com/COO-Utilities/newport + branch = main diff --git a/src/hispec/util/gammavac b/src/hispec/util/gammavac new file mode 160000 index 0000000..add84ce --- /dev/null +++ b/src/hispec/util/gammavac @@ -0,0 +1 @@ +Subproject commit add84ce3a7139b1627cadcf270769941cbe2648d diff --git a/src/hispec/util/inficon b/src/hispec/util/inficon new file mode 160000 index 0000000..204da34 --- /dev/null +++ b/src/hispec/util/inficon @@ -0,0 +1 @@ +Subproject commit 204da34c58d1096ce637a9f9f2af6d171bd861ad diff --git a/src/hispec/util/lakeshore b/src/hispec/util/lakeshore new file mode 160000 index 0000000..4f234e4 --- /dev/null +++ b/src/hispec/util/lakeshore @@ -0,0 +1 @@ +Subproject commit 4f234e4bdd64ffd94b6558709adfacde86533da5 diff --git a/src/hispec/util/newport b/src/hispec/util/newport new file mode 160000 index 0000000..32c371a --- /dev/null +++ b/src/hispec/util/newport @@ -0,0 +1 @@ +Subproject commit 32c371af331fa20753b45fade7b5aba98c28522d diff --git a/src/hispec/util/onewire b/src/hispec/util/onewire new file mode 160000 index 0000000..f07915a --- /dev/null +++ b/src/hispec/util/onewire @@ -0,0 +1 @@ +Subproject commit f07915a1420c8083e8fd51df81746bfdf6106265 diff --git a/src/hispec/util/ozoptics b/src/hispec/util/ozoptics new file mode 160000 index 0000000..f6a5dd2 --- /dev/null +++ b/src/hispec/util/ozoptics @@ -0,0 +1 @@ +Subproject commit f6a5dd213be4a5bd47150c81e45e9a104f6c32ed diff --git a/src/hispec/util/pi b/src/hispec/util/pi new file mode 160000 index 0000000..3142e64 --- /dev/null +++ b/src/hispec/util/pi @@ -0,0 +1 @@ +Subproject commit 3142e6481d10ea99a8e326a8485639061dcbbb93 diff --git a/src/hispec/util/srs b/src/hispec/util/srs new file mode 160000 index 0000000..74068fb --- /dev/null +++ b/src/hispec/util/srs @@ -0,0 +1 @@ +Subproject commit 74068fb2430f390bbe967c76da3eb6ea60cd06d6 diff --git a/src/hispec/util/standa b/src/hispec/util/standa new file mode 160000 index 0000000..e0bf68b --- /dev/null +++ b/src/hispec/util/standa @@ -0,0 +1 @@ +Subproject commit e0bf68b664958d9ab53bb6ed1829361c780df738 diff --git a/src/hispec/util/sunpower b/src/hispec/util/sunpower new file mode 160000 index 0000000..a0d007e --- /dev/null +++ b/src/hispec/util/sunpower @@ -0,0 +1 @@ +Subproject commit a0d007e84cb1de8b82fbe903858c3726c2a904a5 diff --git a/src/hispec/util/thorlabs b/src/hispec/util/thorlabs new file mode 160000 index 0000000..6ab0638 --- /dev/null +++ b/src/hispec/util/thorlabs @@ -0,0 +1 @@ +Subproject commit 6ab06380dc1c0289028fd2f3e7ca461ae6771218 diff --git a/src/hispec/util/xeryon b/src/hispec/util/xeryon new file mode 160000 index 0000000..33b6823 --- /dev/null +++ b/src/hispec/util/xeryon @@ -0,0 +1 @@ +Subproject commit 33b68232fd08450f2f312ed629ef4a6664dbf738 From 7f3b7f2fe763c8d39e49455000c4a2cd255b5e35 Mon Sep 17 00:00:00 2001 From: Reed Riddle Date: Tue, 16 Dec 2025 18:15:54 -0800 Subject: [PATCH 4/4] Re-add PIPython and camera-interface submodules under etc/ --- .gitmodules | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.gitmodules b/.gitmodules index f733c1a..e02fa22 100644 --- a/.gitmodules +++ b/.gitmodules @@ -46,3 +46,10 @@ path = src/hispec/util/newport url = https://github.com/COO-Utilities/newport branch = main +[submodule "etc/PIPython"] + path = etc/PIPython + url = https://github.com/PI-PhysikInstrumente/PIPython +[submodule "etc/camera-interface"] + path = etc/camera-interface + url = https://github.com/CaltechOpticalObservatories/camera-interface + branch = main