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.
- citrascope/__main__.py +8 -5
- citrascope/api/abstract_api_client.py +7 -0
- citrascope/api/citra_api_client.py +30 -1
- citrascope/citra_scope_daemon.py +214 -61
- citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
- citrascope/hardware/adapter_registry.py +94 -0
- citrascope/hardware/indi_adapter.py +456 -16
- citrascope/hardware/kstars_dbus_adapter.py +179 -0
- citrascope/hardware/nina_adv_http_adapter.py +593 -0
- citrascope/hardware/nina_adv_http_survey_template.json +328 -0
- citrascope/logging/__init__.py +2 -1
- citrascope/logging/_citrascope_logger.py +80 -1
- citrascope/logging/web_log_handler.py +74 -0
- citrascope/settings/citrascope_settings.py +145 -0
- citrascope/settings/settings_file_manager.py +126 -0
- citrascope/tasks/runner.py +124 -28
- citrascope/tasks/scope/base_telescope_task.py +25 -10
- citrascope/tasks/scope/static_telescope_task.py +11 -3
- citrascope/web/__init__.py +1 -0
- citrascope/web/app.py +470 -0
- citrascope/web/server.py +123 -0
- citrascope/web/static/api.js +82 -0
- citrascope/web/static/app.js +500 -0
- citrascope/web/static/config.js +362 -0
- citrascope/web/static/img/citra.png +0 -0
- citrascope/web/static/img/favicon.png +0 -0
- citrascope/web/static/style.css +120 -0
- citrascope/web/static/websocket.js +127 -0
- citrascope/web/templates/dashboard.html +354 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
- citrascope-0.3.0.dist-info/RECORD +38 -0
- {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
- citrascope/settings/_citrascope_settings.py +0 -42
- citrascope-0.1.0.dist-info/RECORD +0 -21
- {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.
|
|
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
|
|
|
@@ -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",
|
|
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
|
citrascope/citra_scope_daemon.py
CHANGED
|
@@ -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.
|
|
6
|
+
from citrascope.hardware.adapter_registry import get_adapter_class
|
|
7
7
|
from citrascope.logging import CITRASCOPE_LOGGER
|
|
8
|
-
from citrascope.
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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"
|
|
57
|
+
f"{self.settings.hardware_adapter} adapter requested but dependencies not available. " f"Error: {e}"
|
|
61
58
|
)
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
80
|
-
self.
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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()
|