citrascope 0.7.0__py3-none-any.whl → 0.9.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 (51) 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 +75 -0
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +97 -2
  5. citrascope/hardware/adapter_registry.py +15 -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 +114 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +407 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +756 -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 +805 -0
  21. citrascope/hardware/dummy_adapter.py +202 -0
  22. citrascope/hardware/filter_sync.py +94 -0
  23. citrascope/hardware/indi_adapter.py +6 -2
  24. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  25. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  26. citrascope/settings/citrascope_settings.py +6 -0
  27. citrascope/tasks/runner.py +2 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +14 -0
  31. citrascope/time/time_health.py +103 -0
  32. citrascope/time/time_monitor.py +186 -0
  33. citrascope/time/time_sources.py +261 -0
  34. citrascope/web/app.py +260 -60
  35. citrascope/web/static/app.js +121 -731
  36. citrascope/web/static/components.js +136 -0
  37. citrascope/web/static/config.js +259 -420
  38. citrascope/web/static/filters.js +55 -0
  39. citrascope/web/static/formatters.js +129 -0
  40. citrascope/web/static/store-init.js +204 -0
  41. citrascope/web/static/style.css +44 -0
  42. citrascope/web/templates/_config.html +175 -0
  43. citrascope/web/templates/_config_hardware.html +208 -0
  44. citrascope/web/templates/_monitoring.html +242 -0
  45. citrascope/web/templates/dashboard.html +109 -377
  46. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
  47. citrascope-0.9.0.dist-info/RECORD +69 -0
  48. citrascope-0.7.0.dist-info/RECORD +0 -41
  49. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
  50. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
  51. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,805 @@
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 _get_camera_file_extension(self) -> str:
448
+ """Get the preferred file extension from the camera.
449
+
450
+ Delegates to the camera's get_preferred_file_extension() method,
451
+ which allows each camera type to define its own file format logic.
452
+
453
+ Returns:
454
+ File extension string (e.g., 'fits', 'png', 'jpg')
455
+ """
456
+ if not self.camera:
457
+ return "fits"
458
+
459
+ # Let the camera decide its preferred file extension
460
+ return self.camera.get_preferred_file_extension()
461
+
462
+ def expose_camera(
463
+ self,
464
+ exposure_time: float,
465
+ gain: Optional[int] = None,
466
+ offset: Optional[int] = None,
467
+ count: int = 1,
468
+ ) -> str:
469
+ """Take camera exposure(s).
470
+
471
+ Args:
472
+ exposure_time: Exposure duration in seconds
473
+ gain: Camera gain setting
474
+ offset: Camera offset setting
475
+ count: Number of exposures to take
476
+
477
+ Returns:
478
+ Path to the last saved image
479
+ """
480
+ if not self.camera:
481
+ raise RuntimeError("Camera not initialized (missing dependencies)")
482
+
483
+ self.logger.info(f"Taking {count} exposure(s): {exposure_time}s, " f"gain={gain}, offset={offset}")
484
+
485
+ last_image_path = ""
486
+
487
+ for i in range(count):
488
+ if count > 1:
489
+ self.logger.info(f"Exposure {i+1}/{count}")
490
+
491
+ # Generate save path with camera's preferred file extension
492
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
493
+ output_ext = self._get_camera_file_extension()
494
+ save_path = self.images_dir / f"direct_capture_{timestamp}_{i:03d}.{output_ext}"
495
+
496
+ # Take exposure
497
+ image_path = self.camera.take_exposure(
498
+ duration=exposure_time,
499
+ gain=gain,
500
+ offset=offset,
501
+ binning=1,
502
+ save_path=save_path,
503
+ )
504
+
505
+ last_image_path = str(image_path)
506
+
507
+ return last_image_path
508
+
509
+ def set_filter(self, filter_position: int) -> bool:
510
+ """Change to specified filter.
511
+
512
+ Args:
513
+ filter_position: Filter position (0-indexed)
514
+
515
+ Returns:
516
+ True if filter change successful
517
+ """
518
+ if not self.filter_wheel:
519
+ self.logger.warning("No filter wheel available")
520
+ return False
521
+
522
+ self.logger.info(f"Changing to filter position {filter_position}")
523
+
524
+ if not self.filter_wheel.set_filter_position(filter_position):
525
+ self.logger.error(f"Failed to set filter position {filter_position}")
526
+ return False
527
+
528
+ # Wait for filter wheel to finish moving
529
+ timeout = 30
530
+ start_time = time.time()
531
+
532
+ while self.filter_wheel.is_moving():
533
+ if time.time() - start_time > timeout:
534
+ self.logger.error("Filter wheel movement timeout")
535
+ return False
536
+ time.sleep(0.1)
537
+
538
+ self._current_filter_position = filter_position
539
+
540
+ # Adjust focus if configured
541
+ if self.focuser and filter_position in self.filter_map:
542
+ focus_position = self.filter_map[filter_position].get("focus_position")
543
+ if focus_position is not None:
544
+ self.logger.info(f"Adjusting focus to {focus_position} for filter {filter_position}")
545
+ self.set_focus(focus_position)
546
+
547
+ self.logger.info(f"Filter change complete: position {filter_position}")
548
+ return True
549
+
550
+ def get_filter_position(self) -> Optional[int]:
551
+ """Get current filter position.
552
+
553
+ Returns:
554
+ Current filter position (0-indexed), or None if unavailable
555
+ """
556
+ if not self.filter_wheel:
557
+ return None
558
+ return self.filter_wheel.get_filter_position()
559
+
560
+ def set_focus(self, position: int) -> bool:
561
+ """Move focuser to absolute position.
562
+
563
+ Args:
564
+ position: Target focus position in steps
565
+
566
+ Returns:
567
+ True if focus move successful
568
+ """
569
+ if not self.focuser:
570
+ self.logger.warning("No focuser available")
571
+ return False
572
+
573
+ self.logger.info(f"Moving focuser to position {position}")
574
+
575
+ if not self.focuser.move_absolute(position):
576
+ self.logger.error(f"Failed to move focuser to {position}")
577
+ return False
578
+
579
+ # Wait for focuser to finish moving
580
+ timeout = 60
581
+ start_time = time.time()
582
+
583
+ while self.focuser.is_moving():
584
+ if time.time() - start_time > timeout:
585
+ self.logger.error("Focuser movement timeout")
586
+ return False
587
+ time.sleep(0.1)
588
+
589
+ self._current_focus_position = position
590
+ self.logger.info(f"Focus move complete: position {position}")
591
+ return True
592
+
593
+ def get_focus_position(self) -> Optional[int]:
594
+ """Get current focuser position.
595
+
596
+ Returns:
597
+ Current focus position in steps, or None if unavailable
598
+ """
599
+ if not self.focuser:
600
+ return None
601
+ return self.focuser.get_position()
602
+
603
+ def get_sensor_temperature(self) -> Optional[float]:
604
+ """Get camera sensor temperature.
605
+
606
+ Returns:
607
+ Temperature in Celsius, or None if unavailable
608
+ """
609
+ if not self.camera:
610
+ return None
611
+ return self.camera.get_temperature()
612
+
613
+ def is_hyperspectral(self) -> bool:
614
+ """Indicates whether this adapter uses a hyperspectral camera.
615
+
616
+ Returns:
617
+ bool: True if camera is hyperspectral, False otherwise
618
+ """
619
+ if self.camera:
620
+ return self.camera.is_hyperspectral()
621
+ return False
622
+
623
+ def get_missing_dependencies(self) -> list[dict[str, str]]:
624
+ """Check for missing dependencies on all configured devices.
625
+
626
+ Returns:
627
+ List of dicts with keys: device_type, device_name, missing_packages, install_cmd
628
+ """
629
+ return self._dependency_issues
630
+
631
+ def abort_current_operation(self):
632
+ """Abort any ongoing operations."""
633
+ self.logger.warning("Aborting all operations")
634
+
635
+ # Abort camera exposure if running
636
+ if self.camera:
637
+ self.camera.abort_exposure()
638
+
639
+ # Stop mount slew if running
640
+ if self.mount and self.mount.is_slewing():
641
+ self.mount.abort_slew()
642
+
643
+ # Stop focuser if moving
644
+ if self.focuser and self.focuser.is_moving():
645
+ self.focuser.abort_move()
646
+
647
+ # Required abstract method implementations
648
+
649
+ def list_devices(self) -> list[str]:
650
+ """List all connected devices.
651
+
652
+ Returns:
653
+ List of device names/descriptions
654
+ """
655
+ devices = []
656
+
657
+ if self.camera:
658
+ devices.append(f"Camera: {self.camera.get_friendly_name()}")
659
+ else:
660
+ devices.append("Camera: Not initialized (missing dependencies)")
661
+
662
+ if self.mount:
663
+ devices.append(f"Mount: {self.mount.get_friendly_name()}")
664
+ else:
665
+ devices.append("Mount: None (static camera mode)")
666
+
667
+ if self.filter_wheel:
668
+ devices.append(f"Filter Wheel: {self.filter_wheel.get_friendly_name()}")
669
+
670
+ if self.focuser:
671
+ devices.append(f"Focuser: {self.focuser.get_friendly_name()}")
672
+
673
+ return devices
674
+
675
+ def select_telescope(self, device_name: str) -> bool:
676
+ """Select telescope device (not applicable for direct control).
677
+
678
+ Direct hardware adapter has mount pre-configured at initialization.
679
+
680
+ Args:
681
+ device_name: Ignored
682
+
683
+ Returns:
684
+ True if mount is configured and connected
685
+ """
686
+ if not self.mount:
687
+ self.logger.warning("No mount configured")
688
+ return False
689
+ return self.mount.is_connected()
690
+
691
+ def get_telescope_direction(self) -> tuple[float, float]:
692
+ """Get current telescope RA/Dec position.
693
+
694
+ Returns:
695
+ Tuple of (RA in degrees, Dec in degrees)
696
+ """
697
+ return self.get_scope_radec()
698
+
699
+ def telescope_is_moving(self) -> bool:
700
+ """Check if telescope is currently moving.
701
+
702
+ Returns:
703
+ True if mount is slewing, False otherwise
704
+ """
705
+ if not self.mount:
706
+ return False
707
+ return self.mount.is_slewing()
708
+
709
+ def select_camera(self, device_name: str) -> bool:
710
+ """Select camera device (not applicable for direct control).
711
+
712
+ Direct hardware adapter has camera pre-configured at initialization.
713
+
714
+ Args:
715
+ device_name: Ignored
716
+
717
+ Returns:
718
+ True if camera is connected
719
+ """
720
+ if not self.camera:
721
+ return False
722
+ return self.camera.is_connected()
723
+
724
+ def take_image(self, task_id: str, exposure_duration_seconds: float = 1.0) -> str:
725
+ """Capture an image with the camera.
726
+
727
+ Args:
728
+ task_id: Task ID for organizing images
729
+ exposure_duration_seconds: Exposure time in seconds
730
+
731
+ Returns:
732
+ Path to the saved image
733
+ """
734
+ if not self.camera:
735
+ raise RuntimeError("Camera not initialized (missing dependencies)")
736
+
737
+ # Generate save path with task ID
738
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
739
+ # Use camera's preferred file extension
740
+ output_ext = self._get_camera_file_extension()
741
+ save_path = self.images_dir / f"task_{task_id}_{timestamp}.{output_ext}"
742
+
743
+ return str(
744
+ self.camera.take_exposure(
745
+ duration=exposure_duration_seconds,
746
+ save_path=save_path,
747
+ )
748
+ )
749
+
750
+ def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
751
+ """Set custom tracking rate for telescope.
752
+
753
+ Args:
754
+ ra_rate: RA tracking rate in arcseconds/second
755
+ dec_rate: Dec tracking rate in arcseconds/second
756
+ """
757
+ if not self.mount:
758
+ self.logger.warning("No mount configured - cannot set tracking rate")
759
+ return
760
+
761
+ self.logger.info(f'Setting custom tracking rate: RA={ra_rate}"/s, Dec={dec_rate}"/s')
762
+ if hasattr(self.mount, "set_tracking_rate"):
763
+ self.mount.set_tracking_rate(ra_rate, dec_rate) # type: ignore
764
+ else:
765
+ self.logger.warning("Mount does not support custom tracking rates")
766
+
767
+ def get_tracking_rate(self) -> tuple[float, float]:
768
+ """Get current telescope tracking rate.
769
+
770
+ Returns:
771
+ Tuple of (RA rate in arcsec/s, Dec rate in arcsec/s), or (0.0, 0.0) if no mount
772
+ """
773
+ if not self.mount:
774
+ return (0.0, 0.0)
775
+ if hasattr(self.mount, "get_tracking_rate"):
776
+ return self.mount.get_tracking_rate() # type: ignore
777
+ return (0.0, 0.0)
778
+
779
+ def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
780
+ """Perform plate-solving alignment.
781
+
782
+ Args:
783
+ target_ra: Target RA in degrees
784
+ target_dec: Target Dec in degrees
785
+
786
+ Returns:
787
+ True if alignment successful
788
+
789
+ Note:
790
+ Basic implementation - subclasses can override with plate-solving
791
+ """
792
+ if not self.mount:
793
+ self.logger.warning("No mount configured - cannot perform alignment")
794
+ return False
795
+
796
+ # Basic alignment: just slew to position
797
+ # TODO: Add plate-solving support
798
+ self.logger.info(f"Performing basic alignment to RA={target_ra:.4f}°, Dec={target_dec:.4f}°")
799
+
800
+ try:
801
+ self._do_point_telescope(target_ra, target_dec)
802
+ return True
803
+ except Exception as e:
804
+ self.logger.error(f"Alignment failed: {e}")
805
+ return False