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.
Files changed (45) hide show
  1. citrascope/api/abstract_api_client.py +14 -0
  2. citrascope/api/citra_api_client.py +41 -0
  3. citrascope/citra_scope_daemon.py +97 -38
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
  5. citrascope/hardware/adapter_registry.py +10 -3
  6. citrascope/hardware/devices/__init__.py +17 -0
  7. citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  8. citrascope/hardware/devices/camera/__init__.py +13 -0
  9. citrascope/hardware/devices/camera/abstract_camera.py +102 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +402 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +744 -0
  13. citrascope/hardware/devices/device_registry.py +273 -0
  14. citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  15. citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  16. citrascope/hardware/devices/focuser/__init__.py +7 -0
  17. citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  18. citrascope/hardware/devices/mount/__init__.py +7 -0
  19. citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  20. citrascope/hardware/direct_hardware_adapter.py +787 -0
  21. citrascope/hardware/filter_sync.py +94 -0
  22. citrascope/hardware/indi_adapter.py +6 -2
  23. citrascope/hardware/kstars_dbus_adapter.py +67 -96
  24. citrascope/hardware/nina_adv_http_adapter.py +81 -64
  25. citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  26. citrascope/settings/citrascope_settings.py +25 -0
  27. citrascope/tasks/runner.py +105 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +13 -0
  31. citrascope/time/time_health.py +96 -0
  32. citrascope/time/time_monitor.py +164 -0
  33. citrascope/time/time_sources.py +62 -0
  34. citrascope/web/app.py +274 -51
  35. citrascope/web/static/app.js +379 -36
  36. citrascope/web/static/config.js +448 -108
  37. citrascope/web/static/filters.js +55 -0
  38. citrascope/web/static/style.css +39 -0
  39. citrascope/web/templates/dashboard.html +176 -36
  40. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  41. citrascope-0.8.0.dist-info/RECORD +62 -0
  42. citrascope-0.6.1.dist-info/RECORD +0 -41
  43. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  44. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  45. {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