citrascope 0.6.1__py3-none-any.whl → 0.8.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/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +97 -38
- citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
- citrascope/hardware/adapter_registry.py +10 -3
- citrascope/hardware/devices/__init__.py +17 -0
- citrascope/hardware/devices/abstract_hardware_device.py +79 -0
- citrascope/hardware/devices/camera/__init__.py +13 -0
- citrascope/hardware/devices/camera/abstract_camera.py +102 -0
- citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope/hardware/devices/camera/usb_camera.py +402 -0
- citrascope/hardware/devices/camera/ximea_camera.py +744 -0
- citrascope/hardware/devices/device_registry.py +273 -0
- citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
- citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
- citrascope/hardware/devices/focuser/__init__.py +7 -0
- citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
- citrascope/hardware/devices/mount/__init__.py +7 -0
- citrascope/hardware/devices/mount/abstract_mount.py +115 -0
- citrascope/hardware/direct_hardware_adapter.py +787 -0
- citrascope/hardware/filter_sync.py +94 -0
- citrascope/hardware/indi_adapter.py +6 -2
- citrascope/hardware/kstars_dbus_adapter.py +67 -96
- citrascope/hardware/nina_adv_http_adapter.py +81 -64
- citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- citrascope/settings/citrascope_settings.py +25 -0
- citrascope/tasks/runner.py +105 -0
- citrascope/tasks/scope/static_telescope_task.py +17 -12
- citrascope/tasks/task.py +3 -0
- citrascope/time/__init__.py +13 -0
- citrascope/time/time_health.py +96 -0
- citrascope/time/time_monitor.py +164 -0
- citrascope/time/time_sources.py +62 -0
- citrascope/web/app.py +274 -51
- citrascope/web/static/app.js +379 -36
- citrascope/web/static/config.js +448 -108
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/style.css +39 -0
- citrascope/web/templates/dashboard.html +176 -36
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
- citrascope-0.8.0.dist-info/RECORD +62 -0
- citrascope-0.6.1.dist-info/RECORD +0 -41
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
"""Direct hardware adapter using composable device adapters."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional, cast
|
|
7
|
+
|
|
8
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import (
|
|
9
|
+
AbstractAstroHardwareAdapter,
|
|
10
|
+
ObservationStrategy,
|
|
11
|
+
SettingSchemaEntry,
|
|
12
|
+
)
|
|
13
|
+
from citrascope.hardware.devices.camera import AbstractCamera
|
|
14
|
+
from citrascope.hardware.devices.device_registry import (
|
|
15
|
+
check_dependencies,
|
|
16
|
+
get_camera_class,
|
|
17
|
+
get_device_schema,
|
|
18
|
+
get_filter_wheel_class,
|
|
19
|
+
get_focuser_class,
|
|
20
|
+
get_mount_class,
|
|
21
|
+
list_devices,
|
|
22
|
+
)
|
|
23
|
+
from citrascope.hardware.devices.filter_wheel import AbstractFilterWheel
|
|
24
|
+
from citrascope.hardware.devices.focuser import AbstractFocuser
|
|
25
|
+
from citrascope.hardware.devices.mount import AbstractMount
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DirectHardwareAdapter(AbstractAstroHardwareAdapter):
|
|
29
|
+
"""Hardware adapter that directly controls individual device components.
|
|
30
|
+
|
|
31
|
+
This adapter composes individual device adapters (camera, mount, filter wheel, focuser)
|
|
32
|
+
to provide complete telescope system control. It's designed for direct device control
|
|
33
|
+
via USB, serial, or network protocols rather than through orchestration software.
|
|
34
|
+
|
|
35
|
+
Device types are selected via settings, and device-specific configuration is passed
|
|
36
|
+
through to each device adapter.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
|
|
40
|
+
"""Initialize the direct hardware adapter.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
logger: Logger instance
|
|
44
|
+
images_dir: Directory for saving images
|
|
45
|
+
**kwargs: Configuration including:
|
|
46
|
+
- camera_type: Type of camera device (e.g., "ximea", "zwo")
|
|
47
|
+
- mount_type: Type of mount device (e.g., "celestron", "skywatcher")
|
|
48
|
+
- filter_wheel_type: Optional filter wheel type
|
|
49
|
+
- focuser_type: Optional focuser type
|
|
50
|
+
- camera_*: Camera-specific settings
|
|
51
|
+
- mount_*: Mount-specific settings
|
|
52
|
+
- filter_wheel_*: Filter wheel-specific settings
|
|
53
|
+
- focuser_*: Focuser-specific settings
|
|
54
|
+
"""
|
|
55
|
+
super().__init__(images_dir, **kwargs)
|
|
56
|
+
self.logger = logger
|
|
57
|
+
|
|
58
|
+
# Track dependency issues for reporting
|
|
59
|
+
self._dependency_issues: list[dict[str, str]] = []
|
|
60
|
+
|
|
61
|
+
# Extract device types from settings
|
|
62
|
+
camera_type = kwargs.get("camera_type")
|
|
63
|
+
mount_type = kwargs.get("mount_type")
|
|
64
|
+
filter_wheel_type = kwargs.get("filter_wheel_type")
|
|
65
|
+
focuser_type = kwargs.get("focuser_type")
|
|
66
|
+
|
|
67
|
+
if not camera_type:
|
|
68
|
+
raise ValueError("camera_type is required in settings")
|
|
69
|
+
|
|
70
|
+
# Extract device-specific settings
|
|
71
|
+
camera_settings = {k[7:]: v for k, v in kwargs.items() if k.startswith("camera_")}
|
|
72
|
+
mount_settings = {k[6:]: v for k, v in kwargs.items() if k.startswith("mount_")}
|
|
73
|
+
filter_wheel_settings = {k[13:]: v for k, v in kwargs.items() if k.startswith("filter_wheel_")}
|
|
74
|
+
focuser_settings = {k[8:]: v for k, v in kwargs.items() if k.startswith("focuser_")}
|
|
75
|
+
|
|
76
|
+
# Check and instantiate camera (required)
|
|
77
|
+
self.logger.info(f"Checking camera dependencies: {camera_type}")
|
|
78
|
+
camera_class = get_camera_class(camera_type)
|
|
79
|
+
camera_deps = check_dependencies(camera_class)
|
|
80
|
+
|
|
81
|
+
self.camera: Optional[AbstractCamera] = None
|
|
82
|
+
if not camera_deps["available"]:
|
|
83
|
+
self.logger.warning(
|
|
84
|
+
f"Camera '{camera_type}' missing dependencies: {', '.join(camera_deps['missing'])}. "
|
|
85
|
+
f"Install with: {camera_deps['install_cmd']}"
|
|
86
|
+
)
|
|
87
|
+
self._dependency_issues.append(
|
|
88
|
+
{
|
|
89
|
+
"device_type": "Camera",
|
|
90
|
+
"device_name": camera_class.get_friendly_name(),
|
|
91
|
+
"missing_packages": ", ".join(camera_deps["missing"]),
|
|
92
|
+
"install_cmd": camera_deps["install_cmd"],
|
|
93
|
+
}
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
self.logger.info(f"Instantiating camera: {camera_type}")
|
|
97
|
+
self.camera = camera_class(logger=self.logger, **camera_settings)
|
|
98
|
+
|
|
99
|
+
# Check and instantiate mount (optional)
|
|
100
|
+
self.mount: Optional[AbstractMount] = None
|
|
101
|
+
if mount_type:
|
|
102
|
+
self.logger.info(f"Checking mount dependencies: {mount_type}")
|
|
103
|
+
mount_class = get_mount_class(mount_type)
|
|
104
|
+
mount_deps = check_dependencies(mount_class)
|
|
105
|
+
|
|
106
|
+
if not mount_deps["available"]:
|
|
107
|
+
self.logger.warning(
|
|
108
|
+
f"Mount '{mount_type}' missing dependencies: {', '.join(mount_deps['missing'])}. "
|
|
109
|
+
f"Install with: {mount_deps['install_cmd']}"
|
|
110
|
+
)
|
|
111
|
+
self._dependency_issues.append(
|
|
112
|
+
{
|
|
113
|
+
"device_type": "Mount",
|
|
114
|
+
"device_name": mount_class.get_friendly_name(),
|
|
115
|
+
"missing_packages": ", ".join(mount_deps["missing"]),
|
|
116
|
+
"install_cmd": mount_deps["install_cmd"],
|
|
117
|
+
}
|
|
118
|
+
)
|
|
119
|
+
else:
|
|
120
|
+
self.logger.info(f"Instantiating mount: {mount_type}")
|
|
121
|
+
self.mount = mount_class(logger=self.logger, **mount_settings)
|
|
122
|
+
|
|
123
|
+
# Check and instantiate filter wheel (optional)
|
|
124
|
+
self.filter_wheel: Optional[AbstractFilterWheel] = None
|
|
125
|
+
if filter_wheel_type:
|
|
126
|
+
self.logger.info(f"Checking filter wheel dependencies: {filter_wheel_type}")
|
|
127
|
+
filter_wheel_class = get_filter_wheel_class(filter_wheel_type)
|
|
128
|
+
fw_deps = check_dependencies(filter_wheel_class)
|
|
129
|
+
|
|
130
|
+
if not fw_deps["available"]:
|
|
131
|
+
self.logger.warning(
|
|
132
|
+
f"Filter wheel '{filter_wheel_type}' missing dependencies: {', '.join(fw_deps['missing'])}. "
|
|
133
|
+
f"Install with: {fw_deps['install_cmd']}"
|
|
134
|
+
)
|
|
135
|
+
self._dependency_issues.append(
|
|
136
|
+
{
|
|
137
|
+
"device_type": "Filter Wheel",
|
|
138
|
+
"device_name": filter_wheel_class.get_friendly_name(),
|
|
139
|
+
"missing_packages": ", ".join(fw_deps["missing"]),
|
|
140
|
+
"install_cmd": fw_deps["install_cmd"],
|
|
141
|
+
}
|
|
142
|
+
)
|
|
143
|
+
else:
|
|
144
|
+
self.logger.info(f"Instantiating filter wheel: {filter_wheel_type}")
|
|
145
|
+
self.filter_wheel = filter_wheel_class(logger=self.logger, **filter_wheel_settings)
|
|
146
|
+
|
|
147
|
+
# Check and instantiate focuser (optional)
|
|
148
|
+
self.focuser: Optional[AbstractFocuser] = None
|
|
149
|
+
if focuser_type:
|
|
150
|
+
self.logger.info(f"Checking focuser dependencies: {focuser_type}")
|
|
151
|
+
focuser_class = get_focuser_class(focuser_type)
|
|
152
|
+
focuser_deps = check_dependencies(focuser_class)
|
|
153
|
+
|
|
154
|
+
if not focuser_deps["available"]:
|
|
155
|
+
self.logger.warning(
|
|
156
|
+
f"Focuser '{focuser_type}' missing dependencies: {', '.join(focuser_deps['missing'])}. "
|
|
157
|
+
f"Install with: {focuser_deps['install_cmd']}"
|
|
158
|
+
)
|
|
159
|
+
self._dependency_issues.append(
|
|
160
|
+
{
|
|
161
|
+
"device_type": "Focuser",
|
|
162
|
+
"device_name": focuser_class.get_friendly_name(),
|
|
163
|
+
"missing_packages": ", ".join(focuser_deps["missing"]),
|
|
164
|
+
"install_cmd": focuser_deps["install_cmd"],
|
|
165
|
+
}
|
|
166
|
+
)
|
|
167
|
+
else:
|
|
168
|
+
self.logger.info(f"Instantiating focuser: {focuser_type}")
|
|
169
|
+
self.focuser = focuser_class(logger=self.logger, **focuser_settings)
|
|
170
|
+
|
|
171
|
+
# State tracking
|
|
172
|
+
self._current_filter_position: Optional[int] = None
|
|
173
|
+
self._current_focus_position: Optional[int] = None
|
|
174
|
+
|
|
175
|
+
self.logger.info("DirectHardwareAdapter initialized with:")
|
|
176
|
+
self.logger.info(f" Camera: {camera_type}")
|
|
177
|
+
if mount_type:
|
|
178
|
+
self.logger.info(f" Mount: {mount_type}")
|
|
179
|
+
else:
|
|
180
|
+
self.logger.info(f" Mount: None (static camera)")
|
|
181
|
+
if filter_wheel_type:
|
|
182
|
+
self.logger.info(f" Filter Wheel: {filter_wheel_type}")
|
|
183
|
+
if focuser_type:
|
|
184
|
+
self.logger.info(f" Focuser: {focuser_type}")
|
|
185
|
+
|
|
186
|
+
@classmethod
|
|
187
|
+
def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
|
|
188
|
+
"""Return schema for direct hardware adapter settings.
|
|
189
|
+
|
|
190
|
+
This includes device type selection and adapter-level settings.
|
|
191
|
+
If device types are provided in kwargs, will dynamically include
|
|
192
|
+
device-specific settings with appropriate prefixes.
|
|
193
|
+
|
|
194
|
+
Args:
|
|
195
|
+
**kwargs: Can include camera_type, mount_type, etc. to get dynamic schemas
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
List of setting schema entries
|
|
199
|
+
"""
|
|
200
|
+
# Get available devices for dropdown options
|
|
201
|
+
camera_devices = list_devices("camera")
|
|
202
|
+
mount_devices = list_devices("mount")
|
|
203
|
+
filter_wheel_devices = list_devices("filter_wheel")
|
|
204
|
+
focuser_devices = list_devices("focuser")
|
|
205
|
+
|
|
206
|
+
# Build options as list of dicts with value (key) and display (friendly_name)
|
|
207
|
+
# Format: [{"value": "rpi_hq", "label": "Raspberry Pi HQ Camera"}, ...]
|
|
208
|
+
camera_options = [{"value": k, "label": v["friendly_name"]} for k, v in camera_devices.items()]
|
|
209
|
+
mount_options = [{"value": k, "label": v["friendly_name"]} for k, v in mount_devices.items()]
|
|
210
|
+
filter_wheel_options = [{"value": k, "label": v["friendly_name"]} for k, v in filter_wheel_devices.items()]
|
|
211
|
+
focuser_options = [{"value": k, "label": v["friendly_name"]} for k, v in focuser_devices.items()]
|
|
212
|
+
|
|
213
|
+
schema: list[Any] = [
|
|
214
|
+
# Camera is always required
|
|
215
|
+
{
|
|
216
|
+
"name": "camera_type",
|
|
217
|
+
"friendly_name": "Camera Type",
|
|
218
|
+
"type": "str",
|
|
219
|
+
"default": camera_options[0]["value"] if camera_options else "",
|
|
220
|
+
"description": "Type of camera device to use",
|
|
221
|
+
"required": True,
|
|
222
|
+
"options": camera_options,
|
|
223
|
+
"group": "Camera",
|
|
224
|
+
},
|
|
225
|
+
]
|
|
226
|
+
|
|
227
|
+
# Only include optional device fields if devices are available
|
|
228
|
+
if mount_options:
|
|
229
|
+
schema.append(
|
|
230
|
+
{
|
|
231
|
+
"name": "mount_type",
|
|
232
|
+
"friendly_name": "Mount Type",
|
|
233
|
+
"type": "str",
|
|
234
|
+
"default": "",
|
|
235
|
+
"description": "Type of mount device (leave empty for static camera setups)",
|
|
236
|
+
"required": False,
|
|
237
|
+
"options": mount_options,
|
|
238
|
+
"group": "Mount",
|
|
239
|
+
}
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
if filter_wheel_options:
|
|
243
|
+
schema.append(
|
|
244
|
+
{
|
|
245
|
+
"name": "filter_wheel_type",
|
|
246
|
+
"friendly_name": "Filter Wheel Type",
|
|
247
|
+
"type": "str",
|
|
248
|
+
"default": "",
|
|
249
|
+
"description": "Type of filter wheel device (leave empty if none)",
|
|
250
|
+
"required": False,
|
|
251
|
+
"options": filter_wheel_options,
|
|
252
|
+
"group": "Filter Wheel",
|
|
253
|
+
}
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if focuser_options:
|
|
257
|
+
schema.append(
|
|
258
|
+
{
|
|
259
|
+
"name": "focuser_type",
|
|
260
|
+
"friendly_name": "Focuser Type",
|
|
261
|
+
"type": "str",
|
|
262
|
+
"default": "",
|
|
263
|
+
"description": "Type of focuser device (leave empty if none)",
|
|
264
|
+
"required": False,
|
|
265
|
+
"options": focuser_options,
|
|
266
|
+
"group": "Focuser",
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
# Dynamically add device-specific settings if device types are provided
|
|
271
|
+
camera_type = kwargs.get("camera_type")
|
|
272
|
+
if camera_type and camera_type in camera_devices:
|
|
273
|
+
camera_schema = get_device_schema("camera", camera_type)
|
|
274
|
+
for entry in camera_schema:
|
|
275
|
+
prefixed_entry = dict(entry)
|
|
276
|
+
prefixed_entry["name"] = f"camera_{entry['name']}"
|
|
277
|
+
schema.append(prefixed_entry)
|
|
278
|
+
|
|
279
|
+
mount_type = kwargs.get("mount_type")
|
|
280
|
+
if mount_type and mount_type in mount_devices:
|
|
281
|
+
mount_schema = get_device_schema("mount", mount_type)
|
|
282
|
+
for entry in mount_schema:
|
|
283
|
+
prefixed_entry = dict(entry)
|
|
284
|
+
prefixed_entry["name"] = f"mount_{entry['name']}"
|
|
285
|
+
schema.append(prefixed_entry)
|
|
286
|
+
|
|
287
|
+
filter_wheel_type = kwargs.get("filter_wheel_type")
|
|
288
|
+
if filter_wheel_type and filter_wheel_type in filter_wheel_devices:
|
|
289
|
+
fw_schema = get_device_schema("filter_wheel", filter_wheel_type)
|
|
290
|
+
for entry in fw_schema:
|
|
291
|
+
prefixed_entry = dict(entry)
|
|
292
|
+
prefixed_entry["name"] = f"filter_wheel_{entry['name']}"
|
|
293
|
+
schema.append(prefixed_entry)
|
|
294
|
+
|
|
295
|
+
focuser_type = kwargs.get("focuser_type")
|
|
296
|
+
if focuser_type and focuser_type in focuser_devices:
|
|
297
|
+
focuser_schema = get_device_schema("focuser", focuser_type)
|
|
298
|
+
for entry in focuser_schema:
|
|
299
|
+
prefixed_entry = dict(entry)
|
|
300
|
+
prefixed_entry["name"] = f"focuser_{entry['name']}"
|
|
301
|
+
schema.append(prefixed_entry)
|
|
302
|
+
return cast(list[SettingSchemaEntry], schema)
|
|
303
|
+
|
|
304
|
+
def get_observation_strategy(self) -> ObservationStrategy:
|
|
305
|
+
"""Get the observation strategy for direct control.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
ObservationStrategy.MANUAL - direct control handles exposures manually
|
|
309
|
+
"""
|
|
310
|
+
return ObservationStrategy.MANUAL
|
|
311
|
+
|
|
312
|
+
def perform_observation_sequence(self, task, satellite_data) -> str:
|
|
313
|
+
"""Not implemented for manual observation strategy.
|
|
314
|
+
|
|
315
|
+
Direct hardware adapter uses manual control - exposures are taken
|
|
316
|
+
via explicit calls to expose_camera() rather than sequences.
|
|
317
|
+
|
|
318
|
+
Raises:
|
|
319
|
+
NotImplementedError: This adapter uses manual observation
|
|
320
|
+
"""
|
|
321
|
+
raise NotImplementedError(
|
|
322
|
+
"DirectHardwareAdapter uses MANUAL observation strategy. "
|
|
323
|
+
"Use expose_camera() to take individual exposures."
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
def connect(self) -> bool:
|
|
327
|
+
"""Connect to all hardware devices.
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
True if all required devices connected successfully
|
|
331
|
+
"""
|
|
332
|
+
self.logger.info("Connecting to direct hardware devices...")
|
|
333
|
+
|
|
334
|
+
success = True
|
|
335
|
+
|
|
336
|
+
# Connect mount (if present)
|
|
337
|
+
if self.mount:
|
|
338
|
+
if not self.mount.connect():
|
|
339
|
+
self.logger.error("Failed to connect to mount")
|
|
340
|
+
success = False
|
|
341
|
+
else:
|
|
342
|
+
self.logger.info("No mount configured (static camera mode)")
|
|
343
|
+
|
|
344
|
+
# Connect camera
|
|
345
|
+
if not self.camera:
|
|
346
|
+
self.logger.error("Camera not initialized (missing dependencies)")
|
|
347
|
+
return False
|
|
348
|
+
if not self.camera.connect():
|
|
349
|
+
self.logger.error("Failed to connect to camera")
|
|
350
|
+
success = False
|
|
351
|
+
|
|
352
|
+
# Connect optional devices
|
|
353
|
+
if self.filter_wheel and not self.filter_wheel.connect():
|
|
354
|
+
self.logger.warning("Failed to connect to filter wheel (optional)")
|
|
355
|
+
|
|
356
|
+
if self.focuser and not self.focuser.connect():
|
|
357
|
+
self.logger.warning("Failed to connect to focuser (optional)")
|
|
358
|
+
|
|
359
|
+
if success:
|
|
360
|
+
self.logger.info("All required devices connected successfully")
|
|
361
|
+
|
|
362
|
+
return success
|
|
363
|
+
|
|
364
|
+
def disconnect(self):
|
|
365
|
+
"""Disconnect from all hardware devices."""
|
|
366
|
+
self.logger.info("Disconnecting from direct hardware devices...")
|
|
367
|
+
|
|
368
|
+
# Disconnect all devices
|
|
369
|
+
if self.camera:
|
|
370
|
+
self.camera.disconnect()
|
|
371
|
+
|
|
372
|
+
if self.mount:
|
|
373
|
+
self.mount.disconnect()
|
|
374
|
+
|
|
375
|
+
if self.filter_wheel:
|
|
376
|
+
self.filter_wheel.disconnect()
|
|
377
|
+
|
|
378
|
+
if self.focuser:
|
|
379
|
+
self.focuser.disconnect()
|
|
380
|
+
|
|
381
|
+
self.logger.info("All devices disconnected")
|
|
382
|
+
|
|
383
|
+
def is_telescope_connected(self) -> bool:
|
|
384
|
+
"""Check if telescope mount is connected.
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
True if mount is connected and responsive, or True if no mount (static camera)
|
|
388
|
+
"""
|
|
389
|
+
if not self.mount:
|
|
390
|
+
return True # No mount required for static camera
|
|
391
|
+
return self.mount.is_connected()
|
|
392
|
+
|
|
393
|
+
def is_camera_connected(self) -> bool:
|
|
394
|
+
"""Check if camera is connected.
|
|
395
|
+
|
|
396
|
+
Returns:
|
|
397
|
+
True if camera is connected and responsive
|
|
398
|
+
"""
|
|
399
|
+
if not self.camera:
|
|
400
|
+
return False
|
|
401
|
+
return self.camera.is_connected()
|
|
402
|
+
|
|
403
|
+
def _do_point_telescope(self, ra: float, dec: float):
|
|
404
|
+
"""Point the telescope to specified RA/Dec coordinates.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
ra: Right Ascension in degrees
|
|
408
|
+
dec: Declination in degrees
|
|
409
|
+
"""
|
|
410
|
+
if not self.mount:
|
|
411
|
+
self.logger.warning("No mount configured - cannot point telescope (static camera mode)")
|
|
412
|
+
return
|
|
413
|
+
|
|
414
|
+
self.logger.info(f"Slewing telescope to RA={ra:.4f}°, Dec={dec:.4f}°")
|
|
415
|
+
|
|
416
|
+
if not self.mount.slew_to_radec(ra, dec):
|
|
417
|
+
raise RuntimeError(f"Failed to initiate slew to RA={ra}, Dec={dec}")
|
|
418
|
+
|
|
419
|
+
# Wait for slew to complete
|
|
420
|
+
timeout = 300 # 5 minute timeout
|
|
421
|
+
start_time = time.time()
|
|
422
|
+
|
|
423
|
+
while self.mount.is_slewing():
|
|
424
|
+
if time.time() - start_time > timeout:
|
|
425
|
+
self.mount.abort_slew()
|
|
426
|
+
raise RuntimeError("Slew timeout exceeded")
|
|
427
|
+
time.sleep(0.5)
|
|
428
|
+
|
|
429
|
+
self.logger.info("Slew complete")
|
|
430
|
+
|
|
431
|
+
# Ensure tracking is enabled
|
|
432
|
+
if not self.mount.is_tracking():
|
|
433
|
+
self.logger.info("Starting sidereal tracking")
|
|
434
|
+
self.mount.start_tracking("sidereal")
|
|
435
|
+
|
|
436
|
+
def get_scope_radec(self) -> tuple[float, float]:
|
|
437
|
+
"""Get current telescope RA/Dec position.
|
|
438
|
+
|
|
439
|
+
Returns:
|
|
440
|
+
Tuple of (RA in degrees, Dec in degrees), or (0.0, 0.0) if no mount
|
|
441
|
+
"""
|
|
442
|
+
if not self.mount:
|
|
443
|
+
# self.logger.warning("No mount configured - returning default RA/Dec")
|
|
444
|
+
return (0.0, 0.0)
|
|
445
|
+
return self.mount.get_radec()
|
|
446
|
+
|
|
447
|
+
def expose_camera(
|
|
448
|
+
self,
|
|
449
|
+
exposure_time: float,
|
|
450
|
+
gain: Optional[int] = None,
|
|
451
|
+
offset: Optional[int] = None,
|
|
452
|
+
count: int = 1,
|
|
453
|
+
) -> str:
|
|
454
|
+
"""Take camera exposure(s).
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
exposure_time: Exposure duration in seconds
|
|
458
|
+
gain: Camera gain setting
|
|
459
|
+
offset: Camera offset setting
|
|
460
|
+
count: Number of exposures to take
|
|
461
|
+
|
|
462
|
+
Returns:
|
|
463
|
+
Path to the last saved image
|
|
464
|
+
"""
|
|
465
|
+
if not self.camera:
|
|
466
|
+
raise RuntimeError("Camera not initialized (missing dependencies)")
|
|
467
|
+
|
|
468
|
+
self.logger.info(f"Taking {count} exposure(s): {exposure_time}s, " f"gain={gain}, offset={offset}")
|
|
469
|
+
|
|
470
|
+
last_image_path = ""
|
|
471
|
+
|
|
472
|
+
for i in range(count):
|
|
473
|
+
if count > 1:
|
|
474
|
+
self.logger.info(f"Exposure {i+1}/{count}")
|
|
475
|
+
|
|
476
|
+
# Generate save path
|
|
477
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
478
|
+
save_path = self.images_dir / f"direct_capture_{timestamp}_{i:03d}.fits"
|
|
479
|
+
|
|
480
|
+
# Take exposure
|
|
481
|
+
image_path = self.camera.take_exposure(
|
|
482
|
+
duration=exposure_time,
|
|
483
|
+
gain=gain,
|
|
484
|
+
offset=offset,
|
|
485
|
+
binning=1,
|
|
486
|
+
save_path=save_path,
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
last_image_path = str(image_path)
|
|
490
|
+
|
|
491
|
+
return last_image_path
|
|
492
|
+
|
|
493
|
+
def set_filter(self, filter_position: int) -> bool:
|
|
494
|
+
"""Change to specified filter.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
filter_position: Filter position (0-indexed)
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
True if filter change successful
|
|
501
|
+
"""
|
|
502
|
+
if not self.filter_wheel:
|
|
503
|
+
self.logger.warning("No filter wheel available")
|
|
504
|
+
return False
|
|
505
|
+
|
|
506
|
+
self.logger.info(f"Changing to filter position {filter_position}")
|
|
507
|
+
|
|
508
|
+
if not self.filter_wheel.set_filter_position(filter_position):
|
|
509
|
+
self.logger.error(f"Failed to set filter position {filter_position}")
|
|
510
|
+
return False
|
|
511
|
+
|
|
512
|
+
# Wait for filter wheel to finish moving
|
|
513
|
+
timeout = 30
|
|
514
|
+
start_time = time.time()
|
|
515
|
+
|
|
516
|
+
while self.filter_wheel.is_moving():
|
|
517
|
+
if time.time() - start_time > timeout:
|
|
518
|
+
self.logger.error("Filter wheel movement timeout")
|
|
519
|
+
return False
|
|
520
|
+
time.sleep(0.1)
|
|
521
|
+
|
|
522
|
+
self._current_filter_position = filter_position
|
|
523
|
+
|
|
524
|
+
# Adjust focus if configured
|
|
525
|
+
if self.focuser and filter_position in self.filter_map:
|
|
526
|
+
focus_position = self.filter_map[filter_position].get("focus_position")
|
|
527
|
+
if focus_position is not None:
|
|
528
|
+
self.logger.info(f"Adjusting focus to {focus_position} for filter {filter_position}")
|
|
529
|
+
self.set_focus(focus_position)
|
|
530
|
+
|
|
531
|
+
self.logger.info(f"Filter change complete: position {filter_position}")
|
|
532
|
+
return True
|
|
533
|
+
|
|
534
|
+
def get_filter_position(self) -> Optional[int]:
|
|
535
|
+
"""Get current filter position.
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
Current filter position (0-indexed), or None if unavailable
|
|
539
|
+
"""
|
|
540
|
+
if not self.filter_wheel:
|
|
541
|
+
return None
|
|
542
|
+
return self.filter_wheel.get_filter_position()
|
|
543
|
+
|
|
544
|
+
def set_focus(self, position: int) -> bool:
|
|
545
|
+
"""Move focuser to absolute position.
|
|
546
|
+
|
|
547
|
+
Args:
|
|
548
|
+
position: Target focus position in steps
|
|
549
|
+
|
|
550
|
+
Returns:
|
|
551
|
+
True if focus move successful
|
|
552
|
+
"""
|
|
553
|
+
if not self.focuser:
|
|
554
|
+
self.logger.warning("No focuser available")
|
|
555
|
+
return False
|
|
556
|
+
|
|
557
|
+
self.logger.info(f"Moving focuser to position {position}")
|
|
558
|
+
|
|
559
|
+
if not self.focuser.move_absolute(position):
|
|
560
|
+
self.logger.error(f"Failed to move focuser to {position}")
|
|
561
|
+
return False
|
|
562
|
+
|
|
563
|
+
# Wait for focuser to finish moving
|
|
564
|
+
timeout = 60
|
|
565
|
+
start_time = time.time()
|
|
566
|
+
|
|
567
|
+
while self.focuser.is_moving():
|
|
568
|
+
if time.time() - start_time > timeout:
|
|
569
|
+
self.logger.error("Focuser movement timeout")
|
|
570
|
+
return False
|
|
571
|
+
time.sleep(0.1)
|
|
572
|
+
|
|
573
|
+
self._current_focus_position = position
|
|
574
|
+
self.logger.info(f"Focus move complete: position {position}")
|
|
575
|
+
return True
|
|
576
|
+
|
|
577
|
+
def get_focus_position(self) -> Optional[int]:
|
|
578
|
+
"""Get current focuser position.
|
|
579
|
+
|
|
580
|
+
Returns:
|
|
581
|
+
Current focus position in steps, or None if unavailable
|
|
582
|
+
"""
|
|
583
|
+
if not self.focuser:
|
|
584
|
+
return None
|
|
585
|
+
return self.focuser.get_position()
|
|
586
|
+
|
|
587
|
+
def get_sensor_temperature(self) -> Optional[float]:
|
|
588
|
+
"""Get camera sensor temperature.
|
|
589
|
+
|
|
590
|
+
Returns:
|
|
591
|
+
Temperature in Celsius, or None if unavailable
|
|
592
|
+
"""
|
|
593
|
+
if not self.camera:
|
|
594
|
+
return None
|
|
595
|
+
return self.camera.get_temperature()
|
|
596
|
+
|
|
597
|
+
def is_hyperspectral(self) -> bool:
|
|
598
|
+
"""Indicates whether this adapter uses a hyperspectral camera.
|
|
599
|
+
|
|
600
|
+
Returns:
|
|
601
|
+
bool: True if camera is hyperspectral, False otherwise
|
|
602
|
+
"""
|
|
603
|
+
if self.camera:
|
|
604
|
+
return self.camera.is_hyperspectral()
|
|
605
|
+
return False
|
|
606
|
+
|
|
607
|
+
def get_missing_dependencies(self) -> list[dict[str, str]]:
|
|
608
|
+
"""Check for missing dependencies on all configured devices.
|
|
609
|
+
|
|
610
|
+
Returns:
|
|
611
|
+
List of dicts with keys: device_type, device_name, missing_packages, install_cmd
|
|
612
|
+
"""
|
|
613
|
+
return self._dependency_issues
|
|
614
|
+
|
|
615
|
+
def abort_current_operation(self):
|
|
616
|
+
"""Abort any ongoing operations."""
|
|
617
|
+
self.logger.warning("Aborting all operations")
|
|
618
|
+
|
|
619
|
+
# Abort camera exposure if running
|
|
620
|
+
if self.camera:
|
|
621
|
+
self.camera.abort_exposure()
|
|
622
|
+
|
|
623
|
+
# Stop mount slew if running
|
|
624
|
+
if self.mount and self.mount.is_slewing():
|
|
625
|
+
self.mount.abort_slew()
|
|
626
|
+
|
|
627
|
+
# Stop focuser if moving
|
|
628
|
+
if self.focuser and self.focuser.is_moving():
|
|
629
|
+
self.focuser.abort_move()
|
|
630
|
+
|
|
631
|
+
# Required abstract method implementations
|
|
632
|
+
|
|
633
|
+
def list_devices(self) -> list[str]:
|
|
634
|
+
"""List all connected devices.
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
List of device names/descriptions
|
|
638
|
+
"""
|
|
639
|
+
devices = []
|
|
640
|
+
|
|
641
|
+
if self.camera:
|
|
642
|
+
devices.append(f"Camera: {self.camera.get_friendly_name()}")
|
|
643
|
+
else:
|
|
644
|
+
devices.append("Camera: Not initialized (missing dependencies)")
|
|
645
|
+
|
|
646
|
+
if self.mount:
|
|
647
|
+
devices.append(f"Mount: {self.mount.get_friendly_name()}")
|
|
648
|
+
else:
|
|
649
|
+
devices.append("Mount: None (static camera mode)")
|
|
650
|
+
|
|
651
|
+
if self.filter_wheel:
|
|
652
|
+
devices.append(f"Filter Wheel: {self.filter_wheel.get_friendly_name()}")
|
|
653
|
+
|
|
654
|
+
if self.focuser:
|
|
655
|
+
devices.append(f"Focuser: {self.focuser.get_friendly_name()}")
|
|
656
|
+
|
|
657
|
+
return devices
|
|
658
|
+
|
|
659
|
+
def select_telescope(self, device_name: str) -> bool:
|
|
660
|
+
"""Select telescope device (not applicable for direct control).
|
|
661
|
+
|
|
662
|
+
Direct hardware adapter has mount pre-configured at initialization.
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
device_name: Ignored
|
|
666
|
+
|
|
667
|
+
Returns:
|
|
668
|
+
True if mount is configured and connected
|
|
669
|
+
"""
|
|
670
|
+
if not self.mount:
|
|
671
|
+
self.logger.warning("No mount configured")
|
|
672
|
+
return False
|
|
673
|
+
return self.mount.is_connected()
|
|
674
|
+
|
|
675
|
+
def get_telescope_direction(self) -> tuple[float, float]:
|
|
676
|
+
"""Get current telescope RA/Dec position.
|
|
677
|
+
|
|
678
|
+
Returns:
|
|
679
|
+
Tuple of (RA in degrees, Dec in degrees)
|
|
680
|
+
"""
|
|
681
|
+
return self.get_scope_radec()
|
|
682
|
+
|
|
683
|
+
def telescope_is_moving(self) -> bool:
|
|
684
|
+
"""Check if telescope is currently moving.
|
|
685
|
+
|
|
686
|
+
Returns:
|
|
687
|
+
True if mount is slewing, False otherwise
|
|
688
|
+
"""
|
|
689
|
+
if not self.mount:
|
|
690
|
+
return False
|
|
691
|
+
return self.mount.is_slewing()
|
|
692
|
+
|
|
693
|
+
def select_camera(self, device_name: str) -> bool:
|
|
694
|
+
"""Select camera device (not applicable for direct control).
|
|
695
|
+
|
|
696
|
+
Direct hardware adapter has camera pre-configured at initialization.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
device_name: Ignored
|
|
700
|
+
|
|
701
|
+
Returns:
|
|
702
|
+
True if camera is connected
|
|
703
|
+
"""
|
|
704
|
+
if not self.camera:
|
|
705
|
+
return False
|
|
706
|
+
return self.camera.is_connected()
|
|
707
|
+
|
|
708
|
+
def take_image(self, task_id: str, exposure_duration_seconds: float = 1.0) -> str:
|
|
709
|
+
"""Capture an image with the camera.
|
|
710
|
+
|
|
711
|
+
Args:
|
|
712
|
+
task_id: Task ID for organizing images
|
|
713
|
+
exposure_duration_seconds: Exposure time in seconds
|
|
714
|
+
|
|
715
|
+
Returns:
|
|
716
|
+
Path to the saved image
|
|
717
|
+
"""
|
|
718
|
+
if not self.camera:
|
|
719
|
+
raise RuntimeError("Camera not initialized (missing dependencies)")
|
|
720
|
+
|
|
721
|
+
# Generate save path with task ID
|
|
722
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
723
|
+
save_path = self.images_dir / f"task_{task_id}_{timestamp}.fits"
|
|
724
|
+
|
|
725
|
+
return str(
|
|
726
|
+
self.camera.take_exposure(
|
|
727
|
+
duration=exposure_duration_seconds,
|
|
728
|
+
save_path=save_path,
|
|
729
|
+
)
|
|
730
|
+
)
|
|
731
|
+
|
|
732
|
+
def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
|
|
733
|
+
"""Set custom tracking rate for telescope.
|
|
734
|
+
|
|
735
|
+
Args:
|
|
736
|
+
ra_rate: RA tracking rate in arcseconds/second
|
|
737
|
+
dec_rate: Dec tracking rate in arcseconds/second
|
|
738
|
+
"""
|
|
739
|
+
if not self.mount:
|
|
740
|
+
self.logger.warning("No mount configured - cannot set tracking rate")
|
|
741
|
+
return
|
|
742
|
+
|
|
743
|
+
self.logger.info(f'Setting custom tracking rate: RA={ra_rate}"/s, Dec={dec_rate}"/s')
|
|
744
|
+
if hasattr(self.mount, "set_tracking_rate"):
|
|
745
|
+
self.mount.set_tracking_rate(ra_rate, dec_rate) # type: ignore
|
|
746
|
+
else:
|
|
747
|
+
self.logger.warning("Mount does not support custom tracking rates")
|
|
748
|
+
|
|
749
|
+
def get_tracking_rate(self) -> tuple[float, float]:
|
|
750
|
+
"""Get current telescope tracking rate.
|
|
751
|
+
|
|
752
|
+
Returns:
|
|
753
|
+
Tuple of (RA rate in arcsec/s, Dec rate in arcsec/s), or (0.0, 0.0) if no mount
|
|
754
|
+
"""
|
|
755
|
+
if not self.mount:
|
|
756
|
+
return (0.0, 0.0)
|
|
757
|
+
if hasattr(self.mount, "get_tracking_rate"):
|
|
758
|
+
return self.mount.get_tracking_rate() # type: ignore
|
|
759
|
+
return (0.0, 0.0)
|
|
760
|
+
|
|
761
|
+
def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
|
|
762
|
+
"""Perform plate-solving alignment.
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
target_ra: Target RA in degrees
|
|
766
|
+
target_dec: Target Dec in degrees
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
True if alignment successful
|
|
770
|
+
|
|
771
|
+
Note:
|
|
772
|
+
Basic implementation - subclasses can override with plate-solving
|
|
773
|
+
"""
|
|
774
|
+
if not self.mount:
|
|
775
|
+
self.logger.warning("No mount configured - cannot perform alignment")
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
# Basic alignment: just slew to position
|
|
779
|
+
# TODO: Add plate-solving support
|
|
780
|
+
self.logger.info(f"Performing basic alignment to RA={target_ra:.4f}°, Dec={target_dec:.4f}°")
|
|
781
|
+
|
|
782
|
+
try:
|
|
783
|
+
self._do_point_telescope(target_ra, target_dec)
|
|
784
|
+
return True
|
|
785
|
+
except Exception as e:
|
|
786
|
+
self.logger.error(f"Alignment failed: {e}")
|
|
787
|
+
return False
|