citrascope 0.1.0__py3-none-any.whl → 0.3.0__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 (35) hide show
  1. citrascope/__main__.py +8 -5
  2. citrascope/api/abstract_api_client.py +7 -0
  3. citrascope/api/citra_api_client.py +30 -1
  4. citrascope/citra_scope_daemon.py +214 -61
  5. citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
  6. citrascope/hardware/adapter_registry.py +94 -0
  7. citrascope/hardware/indi_adapter.py +456 -16
  8. citrascope/hardware/kstars_dbus_adapter.py +179 -0
  9. citrascope/hardware/nina_adv_http_adapter.py +593 -0
  10. citrascope/hardware/nina_adv_http_survey_template.json +328 -0
  11. citrascope/logging/__init__.py +2 -1
  12. citrascope/logging/_citrascope_logger.py +80 -1
  13. citrascope/logging/web_log_handler.py +74 -0
  14. citrascope/settings/citrascope_settings.py +145 -0
  15. citrascope/settings/settings_file_manager.py +126 -0
  16. citrascope/tasks/runner.py +124 -28
  17. citrascope/tasks/scope/base_telescope_task.py +25 -10
  18. citrascope/tasks/scope/static_telescope_task.py +11 -3
  19. citrascope/web/__init__.py +1 -0
  20. citrascope/web/app.py +470 -0
  21. citrascope/web/server.py +123 -0
  22. citrascope/web/static/api.js +82 -0
  23. citrascope/web/static/app.js +500 -0
  24. citrascope/web/static/config.js +362 -0
  25. citrascope/web/static/img/citra.png +0 -0
  26. citrascope/web/static/img/favicon.png +0 -0
  27. citrascope/web/static/style.css +120 -0
  28. citrascope/web/static/websocket.js +127 -0
  29. citrascope/web/templates/dashboard.html +354 -0
  30. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
  31. citrascope-0.3.0.dist-info/RECORD +38 -0
  32. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
  33. citrascope/settings/_citrascope_settings.py +0 -42
  34. citrascope-0.1.0.dist-info/RECORD +0 -21
  35. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/entry_points.txt +0 -0
citrascope/__main__.py CHANGED
@@ -1,21 +1,24 @@
1
1
  import click
2
2
 
3
3
  from citrascope.citra_scope_daemon import CitraScopeDaemon
4
- from citrascope.settings._citrascope_settings import CitraScopeSettings
4
+ from citrascope.settings.citrascope_settings import CitraScopeSettings
5
5
 
6
6
 
7
7
  @click.group()
8
8
  @click.option("--dev", is_flag=True, default=False, help="Use the development API (dev.app.citra.space)")
9
9
  @click.option("--log-level", default="INFO", help="Set logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)")
10
+ @click.option("--keep-images", is_flag=True, default=False, help="Keep image files after upload (do not delete)")
10
11
  @click.pass_context
11
- def cli(ctx, dev, log_level):
12
- ctx.obj = {"settings": CitraScopeSettings(dev=dev, log_level=log_level)}
12
+ def cli(ctx, dev, log_level, keep_images):
13
+ ctx.obj = {"settings": CitraScopeSettings(dev=dev, log_level=log_level, keep_images=keep_images)}
13
14
 
14
15
 
15
16
  @cli.command("start")
17
+ @click.option("--web-host", default="0.0.0.0", help="Web server host address (default: 0.0.0.0)")
18
+ @click.option("--web-port", default=24872, type=int, help="Web server port (default: 24872)")
16
19
  @click.pass_context
17
- def start(ctx):
18
- daemon = CitraScopeDaemon(ctx.obj["settings"])
20
+ def start(ctx, web_host, web_port):
21
+ daemon = CitraScopeDaemon(ctx.obj["settings"], enable_web=True, web_host=web_host, web_port=web_port)
19
22
  daemon.run()
20
23
 
21
24
 
@@ -21,3 +21,10 @@ class AbstractCitraApiClient(ABC):
21
21
  @abstractmethod
22
22
  def get_ground_station(self, ground_station_id):
23
23
  pass
24
+
25
+ @abstractmethod
26
+ def put_telescope_status(self, body):
27
+ """
28
+ PUT to /telescopes to report online status.
29
+ """
30
+ pass
@@ -6,6 +6,20 @@ from .abstract_api_client import AbstractCitraApiClient
6
6
 
7
7
 
8
8
  class CitraApiClient(AbstractCitraApiClient):
9
+ def put_telescope_status(self, body):
10
+ """
11
+ PUT to /telescopes to report online status.
12
+ """
13
+ try:
14
+ response = self._request("PUT", "/telescopes", json=body)
15
+ if self.logger:
16
+ self.logger.debug(f"PUT /telescopes: {response}")
17
+ return response
18
+ except Exception as e:
19
+ if self.logger:
20
+ self.logger.error(f"Failed PUT /telescopes: {e}")
21
+ return None
22
+
9
23
  def __init__(self, host: str, token: str, use_ssl: bool = True, logger=None):
10
24
  self.base_url = ("https" if use_ssl else "http") + "://" + host
11
25
  self.token = token
@@ -57,8 +71,10 @@ class CitraApiClient(AbstractCitraApiClient):
57
71
 
58
72
  def upload_image(self, task_id, telescope_id, filepath):
59
73
  """Upload an image file for a given task."""
74
+ file_size = os.path.getsize(filepath)
60
75
  signed_url_response = self._request(
61
- "POST", f"/my/images?filename=citra_task_{task_id}_image.fits&telescope_id={telescope_id}&task_id={task_id}"
76
+ "POST",
77
+ f"/my/images?filename=citra_task_{task_id}_image.fits&telescope_id={telescope_id}&task_id={task_id}&file_size={file_size}",
62
78
  )
63
79
  if not signed_url_response or "uploadUrl" not in signed_url_response:
64
80
  if self.logger:
@@ -99,3 +115,16 @@ class CitraApiClient(AbstractCitraApiClient):
99
115
  if self.logger:
100
116
  self.logger.error(f"Failed to mark task {task_id} as complete: {e}")
101
117
  return None
118
+
119
+ def mark_task_failed(self, task_id):
120
+ """Mark a task as failed using the API."""
121
+ try:
122
+ body = {"status": "Failed"}
123
+ response = self._request("PUT", f"/tasks/{task_id}", json=body)
124
+ if self.logger:
125
+ self.logger.debug(f"Marked task {task_id} as failed: {response}")
126
+ return response
127
+ except Exception as e:
128
+ if self.logger:
129
+ self.logger.error(f"Failed to mark task {task_id} as failed: {e}")
130
+ return None
@@ -3,10 +3,12 @@ from typing import Optional
3
3
 
4
4
  from citrascope.api.citra_api_client import AbstractCitraApiClient, CitraApiClient
5
5
  from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
6
- from citrascope.hardware.indi_adapter import IndiAdapter
6
+ from citrascope.hardware.adapter_registry import get_adapter_class
7
7
  from citrascope.logging import CITRASCOPE_LOGGER
8
- from citrascope.settings._citrascope_settings import CitraScopeSettings
8
+ from citrascope.logging._citrascope_logger import setup_file_logging
9
+ from citrascope.settings.citrascope_settings import CitraScopeSettings
9
10
  from citrascope.tasks.runner import TaskManager
11
+ from citrascope.web.server import CitraScopeWebServer
10
12
 
11
13
 
12
14
  class CitraScopeDaemon:
@@ -15,76 +17,227 @@ class CitraScopeDaemon:
15
17
  settings: CitraScopeSettings,
16
18
  api_client: Optional[AbstractCitraApiClient] = None,
17
19
  hardware_adapter: Optional[AbstractAstroHardwareAdapter] = None,
20
+ enable_web: bool = True,
21
+ web_host: str = "0.0.0.0",
22
+ web_port: int = 24872,
18
23
  ):
19
24
  self.settings = settings
20
25
  CITRASCOPE_LOGGER.setLevel(self.settings.log_level)
21
- self.api_client = api_client or CitraApiClient(
22
- self.settings.host,
23
- self.settings.personal_access_token,
24
- self.settings.use_ssl,
25
- CITRASCOPE_LOGGER,
26
- )
27
- self.hardware_adapter = hardware_adapter or IndiAdapter(
28
- CITRASCOPE_LOGGER, self.settings.indi_server_url, int(self.settings.indi_server_port)
29
- )
30
26
 
31
- def run(self):
32
- CITRASCOPE_LOGGER.info(f"CitraAPISettings host is {self.settings.host}")
33
- CITRASCOPE_LOGGER.info(f"CitraAPISettings telescope_id is {self.settings.telescope_id}")
34
-
35
- # check api for valid key, telescope and ground station
36
- if not self.api_client.does_api_server_accept_key():
37
- CITRASCOPE_LOGGER.error("Aborting: could not authenticate with Citra API.")
38
- return
39
-
40
- citra_telescope_record = self.api_client.get_telescope(self.settings.telescope_id)
41
- if not citra_telescope_record:
42
- CITRASCOPE_LOGGER.error("Aborting: telescope_id is not valid on the server.")
43
- return
44
-
45
- ground_station = self.api_client.get_ground_station(citra_telescope_record["groundStationId"])
46
- if not ground_station:
47
- CITRASCOPE_LOGGER.error("Aborting: could not get ground station info from the server.")
48
- return
49
-
50
- # connect to hardware server
51
- CITRASCOPE_LOGGER.info(f"Connecting to hardware server with {type(self.hardware_adapter).__name__}...")
52
- self.hardware_adapter.connect()
53
- time.sleep(1)
54
- CITRASCOPE_LOGGER.info("List of hardware devices")
55
- device_list = self.hardware_adapter.list_devices() or []
56
-
57
- # check for telescope
58
- if not self.settings.indi_telescope_name in device_list:
27
+ # Setup file logging if enabled
28
+ if self.settings.file_logging_enabled:
29
+ self.settings.config_manager.ensure_log_directory()
30
+ log_path = self.settings.config_manager.get_current_log_path()
31
+ setup_file_logging(log_path, backup_count=self.settings.log_retention_days)
32
+ CITRASCOPE_LOGGER.info(f"Logging to file: {log_path}")
33
+
34
+ self.api_client = api_client
35
+ self.hardware_adapter = hardware_adapter
36
+ self.enable_web = enable_web
37
+ self.web_server = None
38
+ self.task_manager = None
39
+ self.ground_station = None
40
+ self.telescope_record = None
41
+ self.configuration_error: Optional[str] = None
42
+
43
+ # Create web server instance if enabled (always start web server)
44
+ if self.enable_web:
45
+ self.web_server = CitraScopeWebServer(daemon=self, host=web_host, port=web_port)
46
+
47
+ def _create_hardware_adapter(self) -> AbstractAstroHardwareAdapter:
48
+ """Factory method to create the appropriate hardware adapter based on settings."""
49
+ try:
50
+ adapter_class = get_adapter_class(self.settings.hardware_adapter)
51
+ # Ensure images directory exists and pass it to the adapter
52
+ self.settings.ensure_images_directory()
53
+ images_dir = self.settings.get_images_dir()
54
+ return adapter_class(logger=CITRASCOPE_LOGGER, images_dir=images_dir, **self.settings.adapter_settings)
55
+ except ImportError as e:
59
56
  CITRASCOPE_LOGGER.error(
60
- f"Aborting: could not find configured telescope ({self.settings.indi_telescope_name}) on hardware server."
57
+ f"{self.settings.hardware_adapter} adapter requested but dependencies not available. " f"Error: {e}"
61
58
  )
62
- return
63
- self.hardware_adapter.select_telescope(self.settings.indi_telescope_name)
64
- self.hardware_adapter.scope_slew_rate_degrees_per_second = citra_telescope_record["maxSlewRate"]
65
- CITRASCOPE_LOGGER.info(
66
- f"Found configured Telescope ({self.settings.indi_telescope_name}) on hardware server"
67
- + f" (slew rate: {self.hardware_adapter.scope_slew_rate_degrees_per_second} deg/sec)"
68
- )
69
-
70
- # check for camera
71
- if not self.settings.indi_camera_name in device_list:
72
- CITRASCOPE_LOGGER.error(
73
- f"Aborting: could not find configured camera ({self.settings.indi_camera_name}) on hardware server."
59
+ raise RuntimeError(
60
+ f"{self.settings.hardware_adapter} adapter requires additional dependencies. "
61
+ f"Check documentation for installation instructions."
62
+ ) from e
63
+
64
+ def _initialize_components(self, reload_settings: bool = False) -> tuple[bool, Optional[str]]:
65
+ """Initialize or reinitialize all components.
66
+
67
+ Args:
68
+ reload_settings: If True, reload settings from disk before initializing
69
+
70
+ Returns:
71
+ Tuple of (success, error_message)
72
+ """
73
+ try:
74
+ if reload_settings:
75
+ CITRASCOPE_LOGGER.info("Reloading configuration...")
76
+ # Reload settings from file
77
+ new_settings = CitraScopeSettings(
78
+ dev=("dev.api" in self.settings.host),
79
+ log_level=self.settings.log_level,
80
+ keep_images=self.settings.keep_images,
81
+ )
82
+ self.settings = new_settings
83
+ CITRASCOPE_LOGGER.setLevel(self.settings.log_level)
84
+
85
+ # Ensure web log handler is still attached after logger changes
86
+ if self.web_server:
87
+ self.web_server.ensure_log_handler()
88
+
89
+ # Re-setup file logging if enabled
90
+ if self.settings.file_logging_enabled:
91
+ self.settings.config_manager.ensure_log_directory()
92
+ log_path = self.settings.config_manager.get_current_log_path()
93
+ setup_file_logging(log_path, backup_count=self.settings.log_retention_days)
94
+
95
+ # Cleanup existing resources
96
+ if self.task_manager:
97
+ CITRASCOPE_LOGGER.info("Stopping existing task manager...")
98
+ self.task_manager.stop()
99
+ self.task_manager = None
100
+
101
+ if self.hardware_adapter:
102
+ try:
103
+ self.hardware_adapter.disconnect()
104
+ except Exception as e:
105
+ CITRASCOPE_LOGGER.warning(f"Error disconnecting hardware: {e}")
106
+ self.hardware_adapter = None
107
+
108
+ # Check if configuration is complete
109
+ if not self.settings.is_configured():
110
+ error_msg = "Configuration incomplete. Please set access token, telescope ID, and hardware adapter."
111
+ CITRASCOPE_LOGGER.warning(error_msg)
112
+ self.configuration_error = error_msg
113
+ return False, error_msg
114
+
115
+ # Initialize API client
116
+ self.api_client = CitraApiClient(
117
+ self.settings.host,
118
+ self.settings.personal_access_token,
119
+ self.settings.use_ssl,
120
+ CITRASCOPE_LOGGER,
74
121
  )
75
- return
76
- self.hardware_adapter.select_camera(self.settings.indi_camera_name)
77
- CITRASCOPE_LOGGER.info(f"Found configured Camera ({self.settings.indi_camera_name}) on hardware server!")
78
122
 
79
- task_manager = TaskManager(
80
- self.api_client, citra_telescope_record, ground_station, CITRASCOPE_LOGGER, self.hardware_adapter
81
- )
82
- task_manager.start()
123
+ # Initialize hardware adapter
124
+ self.hardware_adapter = self._create_hardware_adapter()
125
+
126
+ # Initialize telescope
127
+ success, error = self._initialize_telescope()
128
+
129
+ if success:
130
+ self.configuration_error = None
131
+ CITRASCOPE_LOGGER.info("Components initialized successfully!")
132
+ return True, None
133
+ else:
134
+ self.configuration_error = error
135
+ return False, error
136
+
137
+ except Exception as e:
138
+ error_msg = f"Failed to initialize components: {str(e)}"
139
+ CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
140
+ self.configuration_error = error_msg
141
+ return False, error_msg
142
+
143
+ def reload_configuration(self) -> tuple[bool, Optional[str]]:
144
+ """Reload configuration from file and reinitialize all components."""
145
+ return self._initialize_components(reload_settings=True)
83
146
 
84
- CITRASCOPE_LOGGER.info("Starting telescope task daemon... (press Ctrl+C to exit)")
147
+ def _initialize_telescope(self) -> tuple[bool, Optional[str]]:
148
+ """Initialize telescope connection and task manager.
149
+
150
+ Returns:
151
+ Tuple of (success, error_message)
152
+ """
153
+ try:
154
+ CITRASCOPE_LOGGER.info(f"CitraAPISettings host is {self.settings.host}")
155
+ CITRASCOPE_LOGGER.info(f"CitraAPISettings telescope_id is {self.settings.telescope_id}")
156
+
157
+ # check api for valid key, telescope and ground station
158
+ if not self.api_client.does_api_server_accept_key():
159
+ error_msg = "Could not authenticate with Citra API. Check your access token."
160
+ CITRASCOPE_LOGGER.error(error_msg)
161
+ return False, error_msg
162
+
163
+ citra_telescope_record = self.api_client.get_telescope(self.settings.telescope_id)
164
+ if not citra_telescope_record:
165
+ error_msg = f"Telescope ID '{self.settings.telescope_id}' is not valid on the server."
166
+ CITRASCOPE_LOGGER.error(error_msg)
167
+ return False, error_msg
168
+ self.telescope_record = citra_telescope_record
169
+
170
+ ground_station = self.api_client.get_ground_station(citra_telescope_record["groundStationId"])
171
+ if not ground_station:
172
+ error_msg = "Could not get ground station info from the server."
173
+ CITRASCOPE_LOGGER.error(error_msg)
174
+ return False, error_msg
175
+ self.ground_station = ground_station
176
+
177
+ # connect to hardware server
178
+ CITRASCOPE_LOGGER.info(f"Connecting to hardware with {type(self.hardware_adapter).__name__}...")
179
+ if not self.hardware_adapter.connect():
180
+ error_msg = f"Failed to connect to hardware adapter: {type(self.hardware_adapter).__name__}"
181
+ CITRASCOPE_LOGGER.error(error_msg)
182
+ return False, error_msg
183
+
184
+ self.hardware_adapter.scope_slew_rate_degrees_per_second = citra_telescope_record["maxSlewRate"]
185
+ CITRASCOPE_LOGGER.info(
186
+ f"Hardware connected. Slew rate: {self.hardware_adapter.scope_slew_rate_degrees_per_second} deg/sec"
187
+ )
188
+
189
+ self.task_manager = TaskManager(
190
+ self.api_client,
191
+ citra_telescope_record,
192
+ ground_station,
193
+ CITRASCOPE_LOGGER,
194
+ self.hardware_adapter,
195
+ self.settings.keep_images,
196
+ self.settings,
197
+ )
198
+ self.task_manager.start()
199
+
200
+ CITRASCOPE_LOGGER.info("Telescope initialized successfully!")
201
+ return True, None
202
+
203
+ except Exception as e:
204
+ error_msg = f"Error initializing telescope: {str(e)}"
205
+ CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
206
+ return False, error_msg
207
+
208
+ def run(self):
209
+ # Start web server FIRST if enabled, so users can monitor/configure
210
+ # The web interface will remain available even if configuration is incomplete
211
+ if self.enable_web:
212
+ self.web_server.start()
213
+ CITRASCOPE_LOGGER.info(f"Web interface available at http://{self.web_server.host}:{self.web_server.port}")
214
+
215
+ try:
216
+ # Try to initialize components
217
+ success, error = self._initialize_components()
218
+ if not success:
219
+ CITRASCOPE_LOGGER.warning(
220
+ f"Could not start telescope operations: {error}. "
221
+ f"Configure via web interface at http://{self.web_server.host}:{self.web_server.port}"
222
+ )
223
+
224
+ CITRASCOPE_LOGGER.info("Starting telescope task daemon... (press Ctrl+C to exit)")
225
+ self._keep_running()
226
+ finally:
227
+ self._shutdown()
228
+
229
+ def _keep_running(self):
230
+ """Keep the daemon running until interrupted."""
85
231
  try:
86
232
  while True:
87
233
  time.sleep(1)
88
234
  except KeyboardInterrupt:
89
235
  CITRASCOPE_LOGGER.info("Shutting down daemon.")
90
- task_manager.stop()
236
+
237
+ def _shutdown(self):
238
+ """Clean up resources on shutdown."""
239
+ if self.task_manager:
240
+ self.task_manager.stop()
241
+ if self.enable_web and self.web_server:
242
+ CITRASCOPE_LOGGER.info("Stopping web server...")
243
+ self.web_server.stop()
@@ -1,14 +1,62 @@
1
+ import logging
1
2
  import math
2
3
  from abc import ABC, abstractmethod
4
+ from enum import Enum
5
+ from pathlib import Path
6
+ from typing import Any, Optional, TypedDict
3
7
 
4
8
 
5
- class AbstractAstroHardwareAdapter(ABC):
9
+ class SettingSchemaEntry(TypedDict, total=False):
10
+ name: str
11
+ friendly_name: str # Human-readable display name for UI
12
+ type: str # e.g., 'float', 'int', 'str', 'bool'
13
+ default: Optional[Any]
14
+ description: str
15
+ required: bool # Whether this field is required
16
+ placeholder: str # Placeholder text for UI inputs
17
+ min: float # Minimum value for numeric types
18
+ max: float # Maximum value for numeric types
19
+ pattern: str # Regex pattern for string validation
20
+ options: list[str] # List of valid options for select/dropdown inputs
21
+
22
+
23
+ class ObservationStrategy(Enum):
24
+ MANUAL = 1
25
+ SEQUENCE_TO_CONTROLLER = 2
26
+
6
27
 
7
- logger = None # Optional logger, can be set by subclasses
28
+ class AbstractAstroHardwareAdapter(ABC):
29
+ logger: logging.Logger # Logger instance, must be provided by subclasses
30
+ images_dir: Path # Path to images directory, must be provided during initialization
8
31
 
9
32
  _slew_min_distance_deg: float = 2.0
10
33
  scope_slew_rate_degrees_per_second: float = 0.0
11
34
 
35
+ def __init__(self, images_dir: Path):
36
+ """Initialize the adapter with images directory.
37
+
38
+ Args:
39
+ images_dir: Path to the images directory
40
+ """
41
+ self.images_dir = images_dir
42
+
43
+ @classmethod
44
+ @abstractmethod
45
+ def get_settings_schema(cls) -> list[SettingSchemaEntry]:
46
+ """
47
+ Return a schema describing configurable settings for this hardware adapter.
48
+
49
+ Each setting is described as a SettingSchemaEntry TypedDict with keys:
50
+ - name (str): The setting's name
51
+ - type (str): The expected Python type (e.g., 'float', 'int', 'str', 'bool')
52
+ - default (optional): The default value
53
+ - description (str): Human-readable description of the setting
54
+
55
+ Returns:
56
+ list[SettingSchemaEntry]: List of setting schema entries.
57
+ """
58
+ pass
59
+
12
60
  def point_telescope(self, ra: float, dec: float):
13
61
  """Point the telescope to the specified RA/Dec coordinates."""
14
62
  # separated out to allow pre/post processing if needed
@@ -45,6 +93,16 @@ class AbstractAstroHardwareAdapter(ABC):
45
93
  filter wheels, focus dials, and other astrophotography devices.
46
94
  """
47
95
 
96
+ @abstractmethod
97
+ def get_observation_strategy(self) -> ObservationStrategy:
98
+ """Get the current observation strategy from the hardware."""
99
+ pass
100
+
101
+ @abstractmethod
102
+ def perform_observation_sequence(self, task_id, satellite_data) -> str:
103
+ """For hardware driven by sequences, perform the observation sequence and return image path."""
104
+ pass
105
+
48
106
  @abstractmethod
49
107
  def connect(self) -> bool:
50
108
  """Connect to the hardware server."""
@@ -55,6 +113,16 @@ class AbstractAstroHardwareAdapter(ABC):
55
113
  """Disconnect from the hardware server."""
56
114
  pass
57
115
 
116
+ @abstractmethod
117
+ def is_telescope_connected(self) -> bool:
118
+ """Check if telescope is connected and responsive."""
119
+ pass
120
+
121
+ @abstractmethod
122
+ def is_camera_connected(self) -> bool:
123
+ """Check if camera is connected and responsive."""
124
+ pass
125
+
58
126
  @abstractmethod
59
127
  def list_devices(self) -> list[str]:
60
128
  """List all connected devices."""
@@ -0,0 +1,94 @@
1
+ """Hardware adapter registry.
2
+
3
+ This module provides a centralized registry for all hardware adapters.
4
+ To add a new adapter, simply add an entry to the REGISTERED_ADAPTERS dict below.
5
+
6
+ Each adapter entry should include:
7
+ - module: The full module path to import
8
+ - class_name: The class name within that module
9
+ - description: A human-readable description of the adapter
10
+
11
+ Third-party adapters can be added by modifying this registry.
12
+ """
13
+
14
+ import importlib
15
+ from typing import Any, Dict, List, Type
16
+
17
+ from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
18
+
19
+ # Central registry of all available hardware adapters
20
+ REGISTERED_ADAPTERS: Dict[str, Dict[str, str]] = {
21
+ "indi": {
22
+ "module": "citrascope.hardware.indi_adapter",
23
+ "class_name": "IndiAdapter",
24
+ "description": "INDI Protocol - Universal astronomy device control",
25
+ },
26
+ "nina": {
27
+ "module": "citrascope.hardware.nina_adv_http_adapter",
28
+ "class_name": "NinaAdvancedHttpAdapter",
29
+ "description": "N.I.N.A. Advanced HTTP API - Windows-based astronomy imaging",
30
+ },
31
+ "kstars": {
32
+ "module": "citrascope.hardware.kstars_dbus_adapter",
33
+ "class_name": "KStarsDBusAdapter",
34
+ "description": "KStars/Ekos via D-Bus - Linux astronomy suite",
35
+ },
36
+ }
37
+
38
+
39
+ def get_adapter_class(adapter_name: str) -> Type[AbstractAstroHardwareAdapter]:
40
+ """Get the adapter class for the given adapter name.
41
+
42
+ Args:
43
+ adapter_name: The name of the adapter (e.g., "indi", "nina", "kstars")
44
+
45
+ Returns:
46
+ The adapter class
47
+
48
+ Raises:
49
+ ValueError: If the adapter name is not registered
50
+ ImportError: If the adapter module cannot be imported (e.g., missing dependencies)
51
+ """
52
+ if adapter_name not in REGISTERED_ADAPTERS:
53
+ available = ", ".join(f"'{name}'" for name in REGISTERED_ADAPTERS.keys())
54
+ raise ValueError(f"Unknown hardware adapter type: '{adapter_name}'. " f"Valid options are: {available}")
55
+
56
+ adapter_info = REGISTERED_ADAPTERS[adapter_name]
57
+ module = importlib.import_module(adapter_info["module"])
58
+ adapter_class = getattr(module, adapter_info["class_name"])
59
+
60
+ return adapter_class
61
+
62
+
63
+ def list_adapters() -> Dict[str, Dict[str, str]]:
64
+ """Get a dictionary of all registered adapters with their descriptions.
65
+
66
+ Returns:
67
+ Dict mapping adapter names to their info (description, module, class_name)
68
+ """
69
+ return {
70
+ name: {
71
+ "description": info["description"],
72
+ "module": info["module"],
73
+ "class_name": info["class_name"],
74
+ }
75
+ for name, info in REGISTERED_ADAPTERS.items()
76
+ }
77
+
78
+
79
+ def get_adapter_schema(adapter_name: str) -> list:
80
+ """Get the configuration schema for a specific adapter.
81
+
82
+ Args:
83
+ adapter_name: The name of the adapter
84
+
85
+ Returns:
86
+ The adapter's settings schema
87
+
88
+ Raises:
89
+ ValueError: If the adapter name is not registered
90
+ ImportError: If the adapter module cannot be imported
91
+ """
92
+ adapter_class = get_adapter_class(adapter_name)
93
+ # Call classmethod directly without instantiation
94
+ return adapter_class.get_settings_schema()