horiba-sdk 0.3.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. horiba_sdk/__init__.py +19 -0
  2. horiba_sdk/communication/__init__.py +44 -0
  3. horiba_sdk/communication/abstract_communicator.py +59 -0
  4. horiba_sdk/communication/communication_exception.py +19 -0
  5. horiba_sdk/communication/messages.py +87 -0
  6. horiba_sdk/communication/websocket_communicator.py +213 -0
  7. horiba_sdk/core/resolution.py +45 -0
  8. horiba_sdk/devices/__init__.py +11 -0
  9. horiba_sdk/devices/abstract_device_discovery.py +7 -0
  10. horiba_sdk/devices/abstract_device_manager.py +68 -0
  11. horiba_sdk/devices/ccd_discovery.py +57 -0
  12. horiba_sdk/devices/device_manager.py +250 -0
  13. horiba_sdk/devices/fake_device_manager.py +133 -0
  14. horiba_sdk/devices/fake_icl_server.py +56 -0
  15. horiba_sdk/devices/fake_responses/ccd.json +168 -0
  16. horiba_sdk/devices/fake_responses/icl.json +29 -0
  17. horiba_sdk/devices/fake_responses/monochromator.json +187 -0
  18. horiba_sdk/devices/monochromator_discovery.py +48 -0
  19. horiba_sdk/devices/single_devices/__init__.py +5 -0
  20. horiba_sdk/devices/single_devices/abstract_device.py +79 -0
  21. horiba_sdk/devices/single_devices/ccd.py +443 -0
  22. horiba_sdk/devices/single_devices/monochromator.py +395 -0
  23. horiba_sdk/icl_error/__init__.py +34 -0
  24. horiba_sdk/icl_error/abstract_error.py +65 -0
  25. horiba_sdk/icl_error/abstract_error_db.py +25 -0
  26. horiba_sdk/icl_error/error_list.json +265 -0
  27. horiba_sdk/icl_error/icl_error.py +30 -0
  28. horiba_sdk/icl_error/icl_error_db.py +81 -0
  29. horiba_sdk/sync/__init__.py +0 -0
  30. horiba_sdk/sync/communication/__init__.py +7 -0
  31. horiba_sdk/sync/communication/abstract_communicator.py +48 -0
  32. horiba_sdk/sync/communication/test_client.py +16 -0
  33. horiba_sdk/sync/communication/websocket_communicator.py +212 -0
  34. horiba_sdk/sync/devices/__init__.py +15 -0
  35. horiba_sdk/sync/devices/abstract_device_discovery.py +17 -0
  36. horiba_sdk/sync/devices/abstract_device_manager.py +68 -0
  37. horiba_sdk/sync/devices/device_discovery.py +82 -0
  38. horiba_sdk/sync/devices/device_manager.py +209 -0
  39. horiba_sdk/sync/devices/fake_device_manager.py +91 -0
  40. horiba_sdk/sync/devices/fake_icl_server.py +79 -0
  41. horiba_sdk/sync/devices/single_devices/__init__.py +5 -0
  42. horiba_sdk/sync/devices/single_devices/abstract_device.py +83 -0
  43. horiba_sdk/sync/devices/single_devices/ccd.py +219 -0
  44. horiba_sdk/sync/devices/single_devices/monochromator.py +150 -0
  45. horiba_sdk-0.3.2.dist-info/LICENSE +20 -0
  46. horiba_sdk-0.3.2.dist-info/METADATA +438 -0
  47. horiba_sdk-0.3.2.dist-info/RECORD +48 -0
  48. horiba_sdk-0.3.2.dist-info/WHEEL +4 -0
horiba_sdk/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ # mypy: disable-error-code="attr-defined"
2
+ """'horiba-sdk' is a package that provides source code for the development with Horiba devices"""
3
+
4
+ __version__ = '0.2.0' # It MUST match the version in pyproject.toml file
5
+ from importlib import metadata as importlib_metadata
6
+
7
+ from pint import UnitRegistry
8
+
9
+
10
+ def get_version() -> str:
11
+ try:
12
+ return importlib_metadata.version(__name__)
13
+ except importlib_metadata.PackageNotFoundError: # pragma: no cover
14
+ return 'unknown'
15
+
16
+
17
+ version: str = get_version()
18
+
19
+ ureg = UnitRegistry()
@@ -0,0 +1,44 @@
1
+ """
2
+ communication
3
+
4
+ A package that provides tools and abstractions for communication. The package includes
5
+ implementations for WebSocket communication, message formatting and parsing, and an abstract
6
+ base for defining communication contracts.
7
+
8
+ Modules:
9
+ - base_communication: Contains the abstract base class defining the communication contract.
10
+ - message_parser: Provides utility functions for message formatting and parsing.
11
+ - websocket_client: Concrete implementation of the communication contract using WebSockets.
12
+
13
+ Directly Available Imports:
14
+ - WebSocketClient: WebSocket-based communication client.
15
+ - MessageParsingError: Exception raised for message format errors.
16
+ - format_message: Utility to format messages for sending.
17
+ - parse_received_message: Utility to parse received messages into a structured format.
18
+
19
+ Typical usage example:
20
+
21
+ from communication import WebSocketClient, format_message, parse_received_message
22
+
23
+ ws = WebSocketClient("ws://example.com")
24
+ await ws.connect()
25
+ await ws.send("command", {"param": "value"})
26
+ response = await ws.receive()
27
+ await ws.disconnect()
28
+ """
29
+
30
+ # Necessary to make Python treat the directory as a package
31
+ from .abstract_communicator import AbstractCommunicator
32
+ from .communication_exception import CommunicationException
33
+ from .messages import BinaryResponse, Command, JSONResponse, Response
34
+ from .websocket_communicator import WebsocketCommunicator
35
+
36
+ __all__ = [
37
+ 'AbstractCommunicator',
38
+ 'WebsocketCommunicator',
39
+ 'CommunicationException',
40
+ 'Command',
41
+ 'Response',
42
+ 'JSONResponse',
43
+ 'BinaryResponse',
44
+ ]
@@ -0,0 +1,59 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from .messages import BinaryResponse, Command, Response
4
+
5
+
6
+ class AbstractCommunicator(ABC):
7
+ """
8
+ Abstract base class for communication protocols.
9
+ """
10
+
11
+ @abstractmethod
12
+ async def open(self) -> None:
13
+ """
14
+ Abstract method to establish a connection.
15
+ """
16
+ pass
17
+
18
+ @abstractmethod
19
+ def opened(self) -> bool:
20
+ """
21
+ Abstract method that says if the connection is open
22
+
23
+ Returns:
24
+ bool: True if the connection is open, False otherwise
25
+ """
26
+ pass
27
+
28
+ @abstractmethod
29
+ async def request_with_response(self, command: Command, timeout: int = 5) -> Response:
30
+ """
31
+ Abstract method to fetch a response from a command.
32
+
33
+ Args:
34
+ command (Command): Command for which a response is desired
35
+ timeout (int, optional): Timeout [s] for waiting for the response. Defaults to 5
36
+
37
+ Returns:
38
+ Response: The response corresponding to the sent command.
39
+ """
40
+ pass
41
+
42
+ @abstractmethod
43
+ async def binary_response(self) -> BinaryResponse:
44
+ """
45
+ Abstract method that fetches the next binary response.
46
+
47
+ Returns:
48
+ BinaryResponse: The binary response from the server
49
+
50
+ .. todo:: `[saga]` is this still needed?
51
+ """
52
+ pass
53
+
54
+ @abstractmethod
55
+ async def close(self) -> None:
56
+ """
57
+ Abstract method to close the connection.
58
+ """
59
+ pass
@@ -0,0 +1,19 @@
1
+ from typing import Optional, final
2
+
3
+
4
+ @final
5
+ class CommunicationException(Exception):
6
+ """CommunicationException is raised for issues encountered in the communicator classes.
7
+
8
+ Specifically all subclasses of horiba_sdk.communication.AbstractCommunicator
9
+ """
10
+
11
+ def __init__(self, exception: Optional[Exception] = None, message: str = ''):
12
+ """__init__.
13
+
14
+ Args:
15
+ exception (Exception): the lower-level exception
16
+ message (str): explanation of what went wrong
17
+ """
18
+ self.exception = exception
19
+ self.message = message
@@ -0,0 +1,87 @@
1
+ import json
2
+ from itertools import count
3
+ from typing import Any, Dict, List, Optional, final
4
+
5
+
6
+ class Command:
7
+ """
8
+ Represents a command to be sent to the server.
9
+
10
+ Class Attributes:
11
+ _id_counter (iterator): An iterator to generate unique IDs.
12
+
13
+ Instance Attributes:
14
+ id (int): Unique identifier for the command.
15
+ command (str): The command string.
16
+ parameters (Dict[str, Any]): Parameters for the command.
17
+
18
+ Methods:
19
+ json(): Converts the command to a JSON string.
20
+ """
21
+
22
+ _id_counter = count(start=1) # Starts counting from 1
23
+
24
+ def __init__(self, command: str, parameters: Dict[str, Any]):
25
+ self.id = next(self._id_counter) # Automatically assigns the next unique ID
26
+ self.command = command
27
+ self.parameters = parameters
28
+
29
+ def json(self) -> str:
30
+ """Converts the command object to a JSON string."""
31
+ return json.dumps({'id': self.id, 'command': self.command, 'parameters': self.parameters})
32
+
33
+
34
+ class Response:
35
+ """
36
+ Represents a response received from the server.
37
+
38
+ Attributes:
39
+ id (int): Unique identifier matching the command's ID.
40
+ command (str): The command string echoed back from the server.
41
+ results (Optional[Dict[str, Any]]): Results returned by the server.
42
+ errors (Optional[List]): List of errors returned by the server.
43
+ """
44
+
45
+ def __init__(
46
+ self, id: int, command: str, results: Optional[Dict[str, Any]] = None, errors: Optional[List[str]] = None
47
+ ):
48
+ self.id = id
49
+ self.command = command
50
+ self.results = results or {}
51
+ self.errors = errors or []
52
+
53
+
54
+ @final
55
+ class JSONResponse(Response):
56
+ """Represents a JSON response received from the server.
57
+
58
+ Attributes:
59
+ json_response (str): JSON response string
60
+ """
61
+
62
+ def __init__(self, json_response: str):
63
+ data = json.loads(json_response)
64
+ super().__init__(id=data['id'], command=data['command'], results=data.get('results'), errors=data.get('errors'))
65
+
66
+
67
+ @final
68
+ class BinaryResponse:
69
+ """Represents a Binary response received from the server.
70
+
71
+ Attributes:
72
+ binary_data (bytes): Response in binary format
73
+ """
74
+
75
+ def __init__(self, binary_data: bytes):
76
+ self._binary_data = binary_data
77
+
78
+
79
+ # # Example usage
80
+ # command = Command("example_command", {"key1": "value1", "key2": "value2"})
81
+ # command_json = command.to_json()
82
+ #
83
+ # response_json = '{"id": 1357, "command": "example_command","results": ..
84
+ # ..{"key1": "value1", "key2": "value2"}, "errors":[]}'
85
+ # response = JSONResponse(response_json)
86
+ #
87
+ # # You can now access attributes like response.id, response.command, etc.
@@ -0,0 +1,213 @@
1
+ import asyncio
2
+ import contextlib
3
+ from types import TracebackType
4
+ from typing import Any, Callable, Optional, final
5
+
6
+ import websockets
7
+ from loguru import logger
8
+ from overrides import override
9
+ from websockets.legacy.client import WebSocketClientProtocol
10
+
11
+ from .abstract_communicator import AbstractCommunicator
12
+ from .communication_exception import CommunicationException
13
+ from .messages import BinaryResponse, Command, JSONResponse, Response
14
+
15
+
16
+ @final
17
+ class WebsocketCommunicator(AbstractCommunicator):
18
+ """
19
+ The WebsocketCommunicator implements the `horiba_sdk.communication.AbstractCommunicator` via websockets.
20
+ A background task listens continuously for incoming binary data.
21
+
22
+ It supports Asynchronous Context Managers and can be used like the following::
23
+
24
+ websocket_communicator: WebsocketCommunicator = WebsocketCommunicator(uri)
25
+ async with websocket_communicator:
26
+ assert websocket_communicator.opened()
27
+
28
+ request: str = '{"command": "some_command"}'
29
+ await websocket_communicator.send(request)
30
+ response = await websocket_communicator.response()
31
+ # do something with the response...
32
+
33
+ """
34
+
35
+ def __init__(self, uri: str = 'ws://127.0.0.1:25010') -> None:
36
+ self.uri: str = uri
37
+ self.websocket: Optional[WebSocketClientProtocol] = None
38
+ self.listen_task: Optional[asyncio.Task[Any]] = None
39
+ self.json_message_queue: asyncio.Queue[str] = asyncio.Queue()
40
+ self.binary_message_queue: asyncio.Queue[bytes] = asyncio.Queue()
41
+ self.binary_message_callback: Optional[Callable[[bytes], Any]] = None
42
+ self.icl_info: dict[str, Any] = {}
43
+
44
+ async def __aenter__(self) -> 'WebsocketCommunicator':
45
+ await self.open()
46
+ return self
47
+
48
+ async def __aexit__(
49
+ self, exc_type: type[BaseException], exc_value: BaseException, traceback: Optional[TracebackType]
50
+ ) -> None:
51
+ await self.close()
52
+
53
+ @override
54
+ async def open(self) -> None:
55
+ """
56
+ Opens the WebSocket connection and starts listening for binary data.
57
+
58
+ Raises:
59
+ CommunicationException: When the websocket is already opened or
60
+ there is an issue with the underlying websockets connection attempt.
61
+ """
62
+ if self.opened():
63
+ raise CommunicationException(None, 'websocket already opened')
64
+
65
+ try:
66
+ self.websocket = await websockets.connect(self.uri)
67
+ # self.flush_incoming_messages(self) # Flush any incoming messages (if any
68
+
69
+ except websockets.WebSocketException as e:
70
+ raise CommunicationException(None, 'websocket connection issue') from e
71
+
72
+ logger.debug(f'Websocket connection established to {self.uri}')
73
+ self.listen_task = asyncio.create_task(self._receive_data())
74
+
75
+ async def send(self, command: Command) -> None:
76
+ """
77
+ Sends a command to the WebSocket server.
78
+
79
+ Args:
80
+ command (Command): The command to send to the server.
81
+
82
+ Raises:
83
+ CommunicationException: When trying to send a command while the websocket is closed.
84
+
85
+ """
86
+ if not self.opened():
87
+ raise CommunicationException(None, 'WebSocket is not opened.')
88
+
89
+ try:
90
+ # mypy cannot infer the check from self.opened() done above
91
+ logger.debug(f'Sending JSON command: {command.json()}')
92
+ await self.websocket.send(command.json()) # type: ignore
93
+ except websockets.exceptions.ConnectionClosed as e:
94
+ raise CommunicationException(None, 'Trying to send data while websocket is closed') from e
95
+
96
+ @override
97
+ def opened(self) -> bool:
98
+ """
99
+ Returns if the websocket connection is open or not
100
+
101
+ Returns:
102
+ bool: True if the websocket connection is open, False otherwise
103
+ """
104
+ return self.websocket is not None and self.websocket.open
105
+
106
+ async def response(self) -> Response:
107
+ """Fetches the next response
108
+
109
+ Returns:
110
+ Response: The response from the server
111
+
112
+ Raises:
113
+ CommunicationException: When the connection terminated with an error
114
+ """
115
+ try:
116
+ response: str = await self.json_message_queue.get()
117
+ return JSONResponse(response)
118
+ except asyncio.CancelledError as e:
119
+ raise CommunicationException(None, 'Response reception was canceled') from e
120
+
121
+ @override
122
+ async def binary_response(self) -> BinaryResponse:
123
+ """Fetches the next binary response.
124
+
125
+ Returns:
126
+ BinaryResponse: The binary response from the server
127
+
128
+ Raises:
129
+ CommunicationException: When the connection terminated with an error
130
+ or the binary data failed to be processed.
131
+
132
+ .. todo:: `[saga]` is this still needed?
133
+ """
134
+ try:
135
+ response: bytes = await self.binary_message_queue.get()
136
+ logger.debug(f'Received binary response: {response!r}')
137
+ return BinaryResponse(response)
138
+ except asyncio.CancelledError as e:
139
+ raise CommunicationException(None, 'Response reception was canceled') from e
140
+
141
+ @override
142
+ async def close(self) -> None:
143
+ """
144
+ Closes the WebSocket connection.
145
+
146
+ Raises:
147
+ CommunicationException: When the websocket is already closed
148
+ """
149
+ if not self.opened():
150
+ raise CommunicationException(None, 'cannot close already closed websocket')
151
+ if self.binary_message_callback:
152
+ self.binary_message_callback = None
153
+ if self.websocket:
154
+ logger.debug('Waiting websocket close...')
155
+ await self.websocket.close()
156
+ self.websocket = None
157
+
158
+ if self.listen_task:
159
+ logger.debug('Canceling listening task...')
160
+ self.listen_task.cancel()
161
+ with contextlib.suppress(asyncio.CancelledError):
162
+ logger.debug('Await listening task...')
163
+ await self.listen_task
164
+
165
+ logger.debug('Websocket connection closed')
166
+
167
+ def register_binary_message_callback(self, callback: Callable[[bytes], Any]) -> None:
168
+ """Registers a callback to be called with every incoming binary message."""
169
+ logger.info('Binary message callback registered.')
170
+ self.binary_message_callback = callback
171
+
172
+ async def _receive_data(self) -> None:
173
+ try:
174
+ async for message in self.websocket: # type: ignore
175
+ logger.info(f'Received message: {message!r}')
176
+ if isinstance(message, str):
177
+ await self.json_message_queue.put(message)
178
+ elif isinstance(message, bytes):
179
+ if self.binary_message_callback:
180
+ await asyncio.create_task(self.binary_message_callback(message)) # Call the callback
181
+ else:
182
+ raise CommunicationException(None, f'Unknown type of message {type(message)}')
183
+ except websockets.ConnectionClosedOK:
184
+ logger.debug('websocket connection terminated properly')
185
+ except websockets.ConnectionClosedError as e:
186
+ raise CommunicationException(None, 'connection terminated with error') from e
187
+ except Exception as e:
188
+ raise CommunicationException(None, 'failure to process binary data') from e
189
+
190
+ @override
191
+ async def request_with_response(self, command: Command, timeout: int = 5) -> Response:
192
+ """
193
+ Concrete method to fetch a response from a command.
194
+
195
+ Args:
196
+ command (Command): Command for which a response is desired
197
+ timeout (int): Maximum time to wait for a response
198
+
199
+ Returns:
200
+ Response: The response corresponding to the sent command.
201
+
202
+ Raises:
203
+ Exception: When an error occurred with the communication channel
204
+ """
205
+ # send the command with the send function and wait a maximum of 5 seconds for the response
206
+ await self.send(command)
207
+ try:
208
+ async with asyncio.timeout(timeout):
209
+ response: Response = await self.response()
210
+ except TimeoutError as te:
211
+ raise CommunicationException(None, f'Timeout of {timeout}s while waiting for response.') from te
212
+
213
+ return response
@@ -0,0 +1,45 @@
1
+ from typing import final
2
+
3
+
4
+ @final
5
+ class Resolution:
6
+ """Width x height, non-zero, non-negative resolution in pixels.
7
+
8
+ Attributes:
9
+ width (int): The width in pixels
10
+ height (int): The height in pixels
11
+ """
12
+
13
+ def __init__(self, width: int, height: int) -> None:
14
+ """Initializes a new Resolution instance
15
+
16
+ Args:
17
+ width (int): width
18
+ height (int): height
19
+
20
+ Raises:
21
+ Exception: when an invalid (<= 0) width or height is given
22
+ """
23
+ if width <= 0 or height <= 0:
24
+ raise Exception(f'Cannot have width or height less or equal to 0: {width} x {height} not allowed')
25
+
26
+ self._width = width
27
+ self._height = height
28
+
29
+ @property
30
+ def width(self) -> int:
31
+ """Width in pixels.
32
+
33
+ Returns:
34
+ int: width
35
+ """
36
+ return self._width
37
+
38
+ @property
39
+ def height(self) -> int:
40
+ """Height in pixels.
41
+
42
+ Returns:
43
+ int: height
44
+ """
45
+ return self._height
@@ -0,0 +1,11 @@
1
+ from .abstract_device_discovery import AbstractDeviceDiscovery
2
+ from .abstract_device_manager import AbstractDeviceManager
3
+ from .device_manager import DeviceManager
4
+ from .fake_device_manager import FakeDeviceManager
5
+
6
+ __all__ = [
7
+ 'AbstractDeviceManager',
8
+ 'DeviceManager',
9
+ 'FakeDeviceManager',
10
+ 'AbstractDeviceDiscovery',
11
+ ]
@@ -0,0 +1,7 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class AbstractDeviceDiscovery(ABC):
5
+ @abstractmethod
6
+ async def execute(self, error_on_no_device: bool = False) -> None:
7
+ pass
@@ -0,0 +1,68 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from horiba_sdk.communication import AbstractCommunicator
4
+ from horiba_sdk.devices.single_devices import ChargeCoupledDevice, Monochromator
5
+
6
+
7
+ class AbstractDeviceManager(ABC):
8
+ """
9
+ DeviceManager class manages the lifecycle and interactions with devices.
10
+
11
+ """
12
+
13
+ @abstractmethod
14
+ async def start(self) -> None:
15
+ """
16
+ Abstract method to start the device manager.
17
+ """
18
+ pass
19
+
20
+ @abstractmethod
21
+ async def stop(self) -> None:
22
+ """
23
+ Abstract method to stop the device manager.
24
+ """
25
+ pass
26
+
27
+ @abstractmethod
28
+ async def discover_devices(self, error_on_no_device: bool = False) -> None:
29
+ """
30
+ Abstract method that discovers and registers devices.
31
+
32
+ Args:
33
+ error_on_no_device (bool): If True, an exception is raised if no device is connected.
34
+ """
35
+ pass
36
+
37
+ @property
38
+ @abstractmethod
39
+ def communicator(self) -> AbstractCommunicator:
40
+ """
41
+ Abstract method to get the communicator attribute.
42
+
43
+ Returns:
44
+ AbstractCommunicator: Returns the internal communicator instance.
45
+ """
46
+ pass
47
+
48
+ @property
49
+ @abstractmethod
50
+ def monochromators(self) -> list[Monochromator]:
51
+ """
52
+ Abstract method to get the detected monochromators.
53
+
54
+ Returns:
55
+ List[Monochromator]: The detected monochromators
56
+ """
57
+ pass
58
+
59
+ @property
60
+ @abstractmethod
61
+ def charge_coupled_devices(self) -> list[ChargeCoupledDevice]:
62
+ """
63
+ Abstract method to get the detected CCDs.
64
+
65
+ Returns:
66
+ List[ChargeCoupledDevice]: The detected CCDS.
67
+ """
68
+ pass
@@ -0,0 +1,57 @@
1
+ import re
2
+ from typing import Any, final
3
+
4
+ from loguru import logger
5
+ from overrides import override
6
+
7
+ from horiba_sdk.communication import AbstractCommunicator, Command, Response
8
+ from horiba_sdk.devices.abstract_device_discovery import AbstractDeviceDiscovery
9
+ from horiba_sdk.devices.single_devices import ChargeCoupledDevice
10
+ from horiba_sdk.icl_error import AbstractErrorDB
11
+
12
+
13
+ @final
14
+ class ChargeCoupledDevicesDiscovery(AbstractDeviceDiscovery):
15
+ def __init__(self, communicator: AbstractCommunicator, error_db: AbstractErrorDB):
16
+ self._communicator: AbstractCommunicator = communicator
17
+ self._charge_coupled_devices: list[ChargeCoupledDevice] = []
18
+ self._error_db: AbstractErrorDB = error_db
19
+
20
+ @override
21
+ async def execute(self, error_on_no_device: bool = False) -> None:
22
+ """
23
+ Discovers the connected devices and saves them internally.
24
+
25
+ Raises:
26
+ Exception: When no CCDs are discovered and that `error_on_no_device` is set. Or when there is an issue
27
+ parsing the CCD list
28
+ """
29
+ if not self._communicator.opened():
30
+ await self._communicator.open()
31
+
32
+ response: Response = await self._communicator.request_with_response(Command('ccd_discover', {}))
33
+ if response.results.get('count', 0) == 0 and error_on_no_device:
34
+ raise Exception('No CCDs connected')
35
+ response = await self._communicator.request_with_response(Command('ccd_list', {}))
36
+
37
+ raw_device_list = response.results
38
+ self._charge_coupled_devices = self._parse_ccds(raw_device_list)
39
+ logger.info(f'Found {len(self._charge_coupled_devices)} CCD devices: {self._charge_coupled_devices}')
40
+
41
+ def _parse_ccds(self, raw_device_list: dict[str, Any]) -> list[ChargeCoupledDevice]:
42
+ detected_ccds: list[ChargeCoupledDevice] = []
43
+ for key, value in raw_device_list.items():
44
+ logger.debug(f'Parsing CCD: {key} - {value}')
45
+ ccd_index: int = int(key.split(':')[0].replace('index', '').strip())
46
+ ccd_type_match = re.search(r'deviceType: (.*?),', value)
47
+ if not ccd_type_match:
48
+ raise Exception(f'Failed to find ccd type "deviceType" in string "{value}"')
49
+ ccd_type: str = str(ccd_type_match.group(1).strip())
50
+
51
+ logger.info(f'Detected CCD: {ccd_type}')
52
+ detected_ccds.append(ChargeCoupledDevice(ccd_index, self._communicator, self._error_db))
53
+
54
+ return detected_ccds
55
+
56
+ def charge_coupled_devices(self) -> list[ChargeCoupledDevice]:
57
+ return self._charge_coupled_devices