citrascope 0.8.0__tar.gz → 0.9.1__tar.gz

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 (87) hide show
  1. {citrascope-0.8.0 → citrascope-0.9.1}/.gitignore +2 -0
  2. {citrascope-0.8.0 → citrascope-0.9.1}/PKG-INFO +3 -2
  3. {citrascope-0.8.0 → citrascope-0.9.1}/README.md +1 -1
  4. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/abstract_astro_hardware_adapter.py +17 -0
  5. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/adapter_registry.py +5 -0
  6. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/abstract_camera.py +12 -0
  7. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/usb_camera.py +20 -15
  8. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/ximea_camera.py +22 -10
  9. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/direct_hardware_adapter.py +21 -3
  10. citrascope-0.9.1/citrascope/hardware/dummy_adapter.py +202 -0
  11. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/time/__init__.py +2 -1
  12. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/time/time_health.py +8 -1
  13. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/time/time_monitor.py +27 -5
  14. citrascope-0.9.1/citrascope/time/time_sources.py +261 -0
  15. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/app.py +31 -9
  16. citrascope-0.9.1/citrascope/web/static/app.js +208 -0
  17. citrascope-0.9.1/citrascope/web/static/components.js +136 -0
  18. citrascope-0.9.1/citrascope/web/static/config.js +645 -0
  19. citrascope-0.9.1/citrascope/web/static/formatters.js +129 -0
  20. citrascope-0.9.1/citrascope/web/static/store-init.js +216 -0
  21. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/style.css +5 -0
  22. citrascope-0.9.1/citrascope/web/templates/_config.html +175 -0
  23. citrascope-0.9.1/citrascope/web/templates/_config_hardware.html +208 -0
  24. citrascope-0.9.1/citrascope/web/templates/_monitoring.html +242 -0
  25. citrascope-0.9.1/citrascope/web/templates/dashboard.html +258 -0
  26. {citrascope-0.8.0 → citrascope-0.9.1}/pyproject.toml +3 -2
  27. citrascope-0.8.0/citrascope/time/time_sources.py +0 -62
  28. citrascope-0.8.0/citrascope/web/static/app.js +0 -1078
  29. citrascope-0.8.0/citrascope/web/static/config.js +0 -941
  30. citrascope-0.8.0/citrascope/web/templates/dashboard.html +0 -630
  31. {citrascope-0.8.0 → citrascope-0.9.1}/.devcontainer/devcontainer.json +0 -0
  32. {citrascope-0.8.0 → citrascope-0.9.1}/.flake8 +0 -0
  33. {citrascope-0.8.0 → citrascope-0.9.1}/.github/copilot-instructions.md +0 -0
  34. {citrascope-0.8.0 → citrascope-0.9.1}/.github/dependabot.yml +0 -0
  35. {citrascope-0.8.0 → citrascope-0.9.1}/.github/workflows/pypi-publish.yml +0 -0
  36. {citrascope-0.8.0 → citrascope-0.9.1}/.github/workflows/pytest.yml +0 -0
  37. {citrascope-0.8.0 → citrascope-0.9.1}/.pre-commit-config.yaml +0 -0
  38. {citrascope-0.8.0 → citrascope-0.9.1}/.python-version +0 -0
  39. {citrascope-0.8.0 → citrascope-0.9.1}/.vscode/launch.json +0 -0
  40. {citrascope-0.8.0 → citrascope-0.9.1}/LICENSE +0 -0
  41. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/__init__.py +0 -0
  42. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/__main__.py +0 -0
  43. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/api/abstract_api_client.py +0 -0
  44. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/api/citra_api_client.py +0 -0
  45. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/citra_scope_daemon.py +0 -0
  46. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/constants.py +0 -0
  47. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/__init__.py +0 -0
  48. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/abstract_hardware_device.py +0 -0
  49. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/__init__.py +0 -0
  50. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/camera/rpi_hq_camera.py +0 -0
  51. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/device_registry.py +0 -0
  52. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/filter_wheel/__init__.py +0 -0
  53. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +0 -0
  54. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/focuser/__init__.py +0 -0
  55. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/focuser/abstract_focuser.py +0 -0
  56. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/mount/__init__.py +0 -0
  57. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/devices/mount/abstract_mount.py +0 -0
  58. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/filter_sync.py +0 -0
  59. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/indi_adapter.py +0 -0
  60. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/kstars_dbus_adapter.py +0 -0
  61. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/kstars_scheduler_template.esl +0 -0
  62. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/kstars_sequence_template.esq +0 -0
  63. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/nina_adv_http_adapter.py +0 -0
  64. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/hardware/nina_adv_http_survey_template.json +0 -0
  65. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/logging/__init__.py +0 -0
  66. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/logging/_citrascope_logger.py +0 -0
  67. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/logging/web_log_handler.py +0 -0
  68. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/settings/__init__.py +0 -0
  69. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/settings/citrascope_settings.py +0 -0
  70. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/settings/settings_file_manager.py +0 -0
  71. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/runner.py +0 -0
  72. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/scope/base_telescope_task.py +0 -0
  73. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/scope/static_telescope_task.py +0 -0
  74. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/scope/tracking_telescope_task.py +0 -0
  75. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/tasks/task.py +0 -0
  76. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/__init__.py +0 -0
  77. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/server.py +0 -0
  78. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/api.js +0 -0
  79. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/filters.js +0 -0
  80. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/img/citra.png +0 -0
  81. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/img/favicon.png +0 -0
  82. {citrascope-0.8.0 → citrascope-0.9.1}/citrascope/web/static/websocket.js +0 -0
  83. {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/test_api_client.py +0 -0
  84. {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/test_device_dependencies.py +0 -0
  85. {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/test_hardware_adapter.py +0 -0
  86. {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/test_task_manager.py +0 -0
  87. {citrascope-0.8.0 → citrascope-0.9.1}/tests/unit/utils.py +0 -0
@@ -137,3 +137,5 @@ citra_image.fits
137
137
  hip_main.dat
138
138
  nina_sequence_*.json
139
139
  nina_focus_positions.json
140
+
141
+ citrascope-env/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: citrascope
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: Remotely control a telescope while it polls for tasks, collects and edge processes data, and delivers results and data for further processing.
5
5
  Project-URL: Homepage, https://citra.space
6
6
  Project-URL: Documentation, https://docs.citra.space/citrascope/
@@ -25,6 +25,7 @@ Requires-Python: <3.13,>=3.10
25
25
  Requires-Dist: click
26
26
  Requires-Dist: fastapi>=0.104.0
27
27
  Requires-Dist: httpx
28
+ Requires-Dist: jinja2>=3.1.0
28
29
  Requires-Dist: ntplib>=0.4.0
29
30
  Requires-Dist: platformdirs>=4.0.0
30
31
  Requires-Dist: python-dateutil
@@ -202,7 +203,7 @@ This ensures code style and quality checks are enforced for all contributors.
202
203
 
203
204
  ### Releasing a New Version
204
205
 
205
- To bump the version and create a release:
206
+ To bump the version and create a release from in the venv:
206
207
 
207
208
  ```sh
208
209
  bump-my-version bump patch # 0.1.3 → 0.1.4
@@ -120,7 +120,7 @@ This ensures code style and quality checks are enforced for all contributors.
120
120
 
121
121
  ### Releasing a New Version
122
122
 
123
- To bump the version and create a release:
123
+ To bump the version and create a release from in the venv:
124
124
 
125
125
  ```sh
126
126
  bump-my-version bump patch # 0.1.3 → 0.1.4
@@ -234,6 +234,23 @@ class AbstractAstroHardwareAdapter(ABC):
234
234
  """
235
235
  return False
236
236
 
237
+ def supports_direct_camera_control(self) -> bool:
238
+ """Indicates whether this adapter supports direct camera control.
239
+
240
+ Direct camera control allows manual test captures and camera operations
241
+ from the UI. Remote scheduler adapters (NINA, KStars) typically do not
242
+ support this, as camera control is managed by the external software.
243
+
244
+ Returns:
245
+ bool: True if the adapter supports expose_camera() and manual captures
246
+ """
247
+ # Remote schedulers never support direct control
248
+ if self.get_observation_strategy() == ObservationStrategy.SEQUENCE_TO_CONTROLLER:
249
+ return False
250
+
251
+ # MANUAL adapters might - check for the method
252
+ return hasattr(self, "expose_camera") and callable(getattr(self, "expose_camera"))
253
+
237
254
  def is_hyperspectral(self) -> bool:
238
255
  """Indicates whether this adapter uses a hyperspectral camera.
239
256
 
@@ -38,6 +38,11 @@ REGISTERED_ADAPTERS: Dict[str, Dict[str, str]] = {
38
38
  "class_name": "DirectHardwareAdapter",
39
39
  "description": "Direct Hardware Control - Composable device adapters for cameras, mounts, etc.",
40
40
  },
41
+ "dummy": {
42
+ "module": "citrascope.hardware.dummy_adapter",
43
+ "class_name": "DummyAdapter",
44
+ "description": "Dummy Adapter - Fake hardware for testing without real devices",
45
+ },
41
46
  }
42
47
 
43
48
 
@@ -100,3 +100,15 @@ class AbstractCamera(AbstractHardwareDevice):
100
100
  bool: True if hyperspectral camera, False otherwise (default)
101
101
  """
102
102
  return False
103
+
104
+ def get_preferred_file_extension(self) -> str:
105
+ """Get the preferred file extension for saved images.
106
+
107
+ This method allows each camera to define what file format it wants
108
+ to use, without the hardware adapter needing to know camera internals.
109
+
110
+ Returns:
111
+ File extension string without the dot (e.g., 'fits', 'png', 'jpg')
112
+ """
113
+ # Default implementation: use output_format if available, otherwise FITS
114
+ return getattr(self, "output_format", "fits")
@@ -118,20 +118,21 @@ class UsbCamera(AbstractCamera):
118
118
  try:
119
119
  from cv2_enumerate_cameras import enumerate_cameras
120
120
 
121
- for camera_info in enumerate_cameras():
122
- index = camera_info.index
123
- name = camera_info.name or f"Camera {index}"
124
- backend = camera_info.backend or ""
121
+ # Get fancy camera names from enumerate_cameras
122
+ # Assumption: enumerate_cameras() returns cameras in the same order as OpenCV detection
123
+ camera_infos = list(enumerate_cameras())
125
124
 
126
- # Get resolution
127
- cap = cv2.VideoCapture(index)
128
- if cap.isOpened():
129
- width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
130
- height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
131
- cap.release()
125
+ logging.debug(f"cv2_enumerate_cameras found {len(camera_infos)} cameras")
132
126
 
133
- backend_str = f" ({backend})" if backend else ""
134
- cameras.append({"value": index, "label": f"{name} - {width}x{height}{backend_str}"})
127
+ # Use the actual device index from camera_info for OpenCV compatibility
128
+ # Don't actually open cameras here - too wasteful
129
+ for camera_info in enumerate_cameras():
130
+ name = camera_info.name or f"Camera {camera_info.index}"
131
+
132
+ # Note: We're NOT opening cameras to verify or get resolution
133
+ # Use camera_info.index (the actual OpenCV device index) not enumerate position
134
+ cameras.append({"value": camera_info.index, "label": name})
135
+ logging.debug(f"Found camera with device_id {camera_info.index}: {name}")
135
136
 
136
137
  except ImportError:
137
138
  # cv2-enumerate-cameras not installed, use basic detection
@@ -297,12 +298,16 @@ class UsbCamera(AbstractCamera):
297
298
  new_size = (width // binning, height // binning)
298
299
  frame = self._cv2_module.resize(frame, new_size, interpolation=self._cv2_module.INTER_AREA)
299
300
 
301
+ # Determine format from file extension (if save_path provided) or configured output_format
302
+ file_extension = save_path.suffix.lower().lstrip(".")
303
+ format_to_use = file_extension if file_extension in ["fits", "png", "jpg", "jpeg"] else self.output_format
304
+
300
305
  # Save based on format
301
- if self.output_format == "fits":
306
+ if format_to_use == "fits":
302
307
  self._save_as_fits(frame, save_path)
303
- elif self.output_format == "png":
308
+ elif format_to_use == "png":
304
309
  self._cv2_module.imwrite(str(save_path), frame)
305
- elif self.output_format == "jpg":
310
+ elif format_to_use in ["jpg", "jpeg"]:
306
311
  self._cv2_module.imwrite(str(save_path), frame, [self._cv2_module.IMWRITE_JPEG_QUALITY, 95])
307
312
 
308
313
  self.logger.info(f"Image saved to {save_path}")
@@ -20,9 +20,11 @@ class XimeaHyperspectralCamera(AbstractCamera):
20
20
  default_gain (float): Default gain in dB
21
21
  default_exposure_ms (float): Default exposure time in milliseconds
22
22
  spectral_bands (int): Number of spectral bands (e.g., 16 for SM4X4, 25 for SM5X5)
23
- output_format (str): Output format - 'raw' (2D mosaic) or 'datacube' (3D bands)
23
+ data_mode (str): Data structure - 'raw' (2D mosaic) or 'datacube' (3D separated bands)
24
24
  vertical_flip (bool): Flip image vertically
25
25
  horizontal_flip (bool): Flip image horizontally
26
+
27
+ Note: Always outputs FITS format files regardless of data_mode.
26
28
  """
27
29
 
28
30
  @classmethod
@@ -98,11 +100,11 @@ class XimeaHyperspectralCamera(AbstractCamera):
98
100
  "group": "Camera",
99
101
  },
100
102
  {
101
- "name": "output_format",
102
- "friendly_name": "Output Format",
103
+ "name": "data_mode",
104
+ "friendly_name": "Data Mode",
103
105
  "type": "str",
104
106
  "default": "raw",
105
- "description": "Output format: raw (2D mosaic) or datacube (3D separated bands)",
107
+ "description": "Data structure: raw (2D mosaic) or datacube (3D separated bands)",
106
108
  "required": False,
107
109
  "options": ["raw", "datacube"],
108
110
  "group": "Camera",
@@ -151,7 +153,7 @@ class XimeaHyperspectralCamera(AbstractCamera):
151
153
  self.default_gain: float = kwargs.get("default_gain", 0.0)
152
154
  self.default_exposure_ms: float = kwargs.get("default_exposure_ms", 100.0)
153
155
  self.spectral_bands: int = kwargs.get("spectral_bands", 25)
154
- self.output_format: str = kwargs.get("output_format", "raw")
156
+ self.data_mode: str = kwargs.get("data_mode", "raw") # raw mosaic or datacube
155
157
  self.vertical_flip: bool = kwargs.get("vertical_flip", False)
156
158
  self.horizontal_flip: bool = kwargs.get("horizontal_flip", False)
157
159
 
@@ -375,7 +377,7 @@ class XimeaHyperspectralCamera(AbstractCamera):
375
377
  timestamp = time.strftime("%Y%m%d_%H%M%S")
376
378
  save_path = Path(f"ximea_hyperspectral_{timestamp}.tiff")
377
379
 
378
- # Save image (format depends on output_format setting)
380
+ # Save image (data structure depends on data_mode setting)
379
381
  self.logger.debug(f"Saving image to: {save_path}")
380
382
  self._save_hyperspectral_image(img, save_path)
381
383
 
@@ -458,6 +460,16 @@ class XimeaHyperspectralCamera(AbstractCamera):
458
460
  """
459
461
  return True
460
462
 
463
+ def get_preferred_file_extension(self) -> str:
464
+ """Get the preferred file extension for saved images.
465
+
466
+ Ximea hyperspectral cameras always output FITS format.
467
+
468
+ Returns:
469
+ 'fits' - FITS format with hyperspectral metadata
470
+ """
471
+ return "fits"
472
+
461
473
  def get_camera_info(self) -> dict:
462
474
  """Get camera capabilities and information.
463
475
 
@@ -552,8 +564,8 @@ class XimeaHyperspectralCamera(AbstractCamera):
552
564
  # Get image data as numpy array
553
565
  data = img.get_image_data_numpy()
554
566
 
555
- # Process based on output_format setting
556
- if self.output_format == "datacube":
567
+ # Process based on data_mode setting
568
+ if self.data_mode == "datacube":
557
569
  # Create 3D datacube by demosaicing the spectral mosaic
558
570
  datacube = self._demosaic_to_datacube(data)
559
571
 
@@ -568,7 +580,7 @@ class XimeaHyperspectralCamera(AbstractCamera):
568
580
  # Hyperspectral metadata
569
581
  primary_hdu.header["HIERARCH SPECTRAL_TYPE"] = "hyperspectral"
570
582
  primary_hdu.header["HIERARCH SPECTRAL_BANDS"] = self.spectral_bands
571
- primary_hdu.header["HIERARCH OUTPUT_FORMAT"] = "datacube"
583
+ primary_hdu.header["HIERARCH DATA_MODE"] = "datacube"
572
584
  primary_hdu.header["HIERARCH SENSOR_TYPE"] = "snapshot_mosaic"
573
585
 
574
586
  # Capture metadata
@@ -636,7 +648,7 @@ class XimeaHyperspectralCamera(AbstractCamera):
636
648
  # Hyperspectral metadata
637
649
  hdu.header["HIERARCH SPECTRAL_TYPE"] = "hyperspectral"
638
650
  hdu.header["HIERARCH SPECTRAL_BANDS"] = self.spectral_bands
639
- hdu.header["HIERARCH OUTPUT_FORMAT"] = "raw"
651
+ hdu.header["HIERARCH DATA_MODE"] = "raw"
640
652
  hdu.header["HIERARCH SENSOR_TYPE"] = "snapshot_mosaic"
641
653
 
642
654
  # Capture metadata
@@ -444,6 +444,21 @@ class DirectHardwareAdapter(AbstractAstroHardwareAdapter):
444
444
  return (0.0, 0.0)
445
445
  return self.mount.get_radec()
446
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
+
447
462
  def expose_camera(
448
463
  self,
449
464
  exposure_time: float,
@@ -473,9 +488,10 @@ class DirectHardwareAdapter(AbstractAstroHardwareAdapter):
473
488
  if count > 1:
474
489
  self.logger.info(f"Exposure {i+1}/{count}")
475
490
 
476
- # Generate save path
491
+ # Generate save path with camera's preferred file extension
477
492
  timestamp = time.strftime("%Y%m%d_%H%M%S")
478
- save_path = self.images_dir / f"direct_capture_{timestamp}_{i:03d}.fits"
493
+ output_ext = self._get_camera_file_extension()
494
+ save_path = self.images_dir / f"direct_capture_{timestamp}_{i:03d}.{output_ext}"
479
495
 
480
496
  # Take exposure
481
497
  image_path = self.camera.take_exposure(
@@ -720,7 +736,9 @@ class DirectHardwareAdapter(AbstractAstroHardwareAdapter):
720
736
 
721
737
  # Generate save path with task ID
722
738
  timestamp = time.strftime("%Y%m%d_%H%M%S")
723
- save_path = self.images_dir / f"task_{task_id}_{timestamp}.fits"
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}"
724
742
 
725
743
  return str(
726
744
  self.camera.take_exposure(
@@ -0,0 +1,202 @@
1
+ """Dummy hardware adapter for testing without real hardware."""
2
+
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+
7
+ from citrascope.hardware.abstract_astro_hardware_adapter import (
8
+ AbstractAstroHardwareAdapter,
9
+ ObservationStrategy,
10
+ SettingSchemaEntry,
11
+ )
12
+
13
+
14
+ class DummyAdapter(AbstractAstroHardwareAdapter):
15
+ """
16
+ Dummy hardware adapter that simulates hardware without requiring real devices.
17
+
18
+ Perfect for testing, development, and demonstrations. All operations are logged
19
+ and return realistic fake data.
20
+ """
21
+
22
+ def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
23
+ """Initialize dummy adapter.
24
+
25
+ Args:
26
+ logger: Logger instance
27
+ images_dir: Path to images directory
28
+ **kwargs: Additional settings including 'simulate_slow_operations'
29
+ """
30
+ super().__init__(images_dir, **kwargs)
31
+ self.logger = logger
32
+ self.simulate_slow = kwargs.get("simulate_slow_operations", False)
33
+ self.slow_delay = kwargs.get("slow_delay_seconds", 2.0)
34
+
35
+ # Fake hardware state
36
+ self._connected = False
37
+ self._telescope_connected = False
38
+ self._camera_connected = False
39
+ self._current_ra = 0.0 # degrees
40
+ self._current_dec = 0.0 # degrees
41
+ self._is_moving = False
42
+ self._tracking_rate = (15.041, 0.0) # arcsec/sec (sidereal rate)
43
+
44
+ self.logger.info("DummyAdapter initialized")
45
+
46
+ @classmethod
47
+ def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
48
+ """Return configuration schema for dummy adapter."""
49
+ return [
50
+ {
51
+ "name": "simulate_slow_operations",
52
+ "friendly_name": "Simulate Slow Operations",
53
+ "type": "bool",
54
+ "default": False,
55
+ "description": "Add artificial delays to simulate slow hardware responses",
56
+ "required": False,
57
+ "group": "Testing",
58
+ },
59
+ {
60
+ "name": "slow_delay_seconds",
61
+ "friendly_name": "Delay Duration (seconds)",
62
+ "type": "float",
63
+ "default": 2.0,
64
+ "min": 0.1,
65
+ "max": 10.0,
66
+ "description": "Duration of artificial delays when slow simulation is enabled",
67
+ "required": False,
68
+ "group": "Testing",
69
+ },
70
+ ]
71
+
72
+ def get_observation_strategy(self) -> ObservationStrategy:
73
+ """Dummy adapter uses manual strategy."""
74
+ return ObservationStrategy.MANUAL
75
+
76
+ def perform_observation_sequence(self, task, satellite_data) -> str:
77
+ """Not used for manual strategy."""
78
+ raise NotImplementedError("DummyAdapter uses MANUAL strategy")
79
+
80
+ def connect(self) -> bool:
81
+ """Simulate connection."""
82
+ self.logger.info("DummyAdapter: Connecting...")
83
+ self._simulate_delay()
84
+ self._connected = True
85
+ self._telescope_connected = True
86
+ self._camera_connected = True
87
+ self.logger.info("DummyAdapter: Connected successfully")
88
+ return True
89
+
90
+ def disconnect(self):
91
+ """Simulate disconnection."""
92
+ self.logger.info("DummyAdapter: Disconnecting...")
93
+ self._connected = False
94
+ self._telescope_connected = False
95
+ self._camera_connected = False
96
+ self.logger.info("DummyAdapter: Disconnected")
97
+
98
+ def is_telescope_connected(self) -> bool:
99
+ """Check fake telescope connection."""
100
+ return self._telescope_connected
101
+
102
+ def is_camera_connected(self) -> bool:
103
+ """Check fake camera connection."""
104
+ return self._camera_connected
105
+
106
+ def list_devices(self) -> list[str]:
107
+ """Return list of fake devices."""
108
+ return ["Dummy Telescope", "Dummy Camera", "Dummy Filter Wheel", "Dummy Focuser"]
109
+
110
+ def select_telescope(self, device_name: str) -> bool:
111
+ """Simulate telescope selection."""
112
+ self.logger.info(f"DummyAdapter: Selected telescope '{device_name}'")
113
+ self._telescope_connected = True
114
+ return True
115
+
116
+ def _do_point_telescope(self, ra: float, dec: float):
117
+ """Simulate telescope slew."""
118
+ self.logger.info(f"DummyAdapter: Slewing to RA={ra:.4f}°, Dec={dec:.4f}°")
119
+ self._is_moving = True
120
+ self._simulate_delay()
121
+ self._current_ra = ra
122
+ self._current_dec = dec
123
+ self._is_moving = False
124
+ self.logger.info("DummyAdapter: Slew complete")
125
+
126
+ def get_telescope_direction(self) -> tuple[float, float]:
127
+ """Return current fake telescope position."""
128
+ return (self._current_ra, self._current_dec)
129
+
130
+ def telescope_is_moving(self) -> bool:
131
+ """Check if fake telescope is moving."""
132
+ return self._is_moving
133
+
134
+ def select_camera(self, device_name: str) -> bool:
135
+ """Simulate camera selection."""
136
+ self.logger.info(f"DummyAdapter: Selected camera '{device_name}'")
137
+ self._camera_connected = True
138
+ return True
139
+
140
+ def take_image(self, task_id: str, exposure_duration_seconds=1.0) -> str:
141
+ """Simulate image capture."""
142
+ self.logger.info(f"DummyAdapter: Starting {exposure_duration_seconds}s exposure for task {task_id}")
143
+ self._simulate_delay(exposure_duration_seconds)
144
+
145
+ # Create dummy image file
146
+ timestamp = int(time.time())
147
+ filename = f"dummy_{task_id}_{timestamp}.fits"
148
+ filepath = self.images_dir / filename
149
+
150
+ # Create empty file to simulate image
151
+ filepath.parent.mkdir(parents=True, exist_ok=True)
152
+ filepath.write_text(f"DUMMY FITS IMAGE\nTask: {task_id}\nExposure: {exposure_duration_seconds}s\n")
153
+
154
+ self.logger.info(f"DummyAdapter: Image saved to {filepath}")
155
+ return str(filepath)
156
+
157
+ def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
158
+ """Simulate setting tracking rate."""
159
+ self.logger.info(f"DummyAdapter: Setting tracking rate RA={ra_rate} arcsec/s, Dec={dec_rate} arcsec/s")
160
+ self._tracking_rate = (ra_rate, dec_rate)
161
+
162
+ def get_tracking_rate(self) -> tuple[float, float]:
163
+ """Return current fake tracking rate."""
164
+ return self._tracking_rate
165
+
166
+ def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
167
+ """Simulate plate solving alignment."""
168
+ self.logger.info(f"DummyAdapter: Performing alignment to RA={target_ra}°, Dec={target_dec}°")
169
+ self._simulate_delay()
170
+ # Simulate small correction
171
+ self._current_ra = target_ra + 0.001
172
+ self._current_dec = target_dec + 0.001
173
+ self.logger.info("DummyAdapter: Alignment successful")
174
+ return True
175
+
176
+ def supports_autofocus(self) -> bool:
177
+ """Dummy adapter supports autofocus."""
178
+ return True
179
+
180
+ def do_autofocus(self) -> None:
181
+ """Simulate autofocus routine."""
182
+ self.logger.info("DummyAdapter: Starting autofocus...")
183
+ self._simulate_delay(3.0)
184
+ self.logger.info("DummyAdapter: Autofocus complete")
185
+
186
+ def supports_filter_management(self) -> bool:
187
+ """Dummy adapter supports filter management."""
188
+ return True
189
+
190
+ def supports_direct_camera_control(self) -> bool:
191
+ """Dummy adapter supports direct camera control."""
192
+ return True
193
+
194
+ def expose_camera(self, exposure_seconds: float = 1.0) -> str:
195
+ """Simulate manual camera exposure."""
196
+ return self.take_image("manual_test", exposure_seconds)
197
+
198
+ def _simulate_delay(self, override_delay: float = None):
199
+ """Add artificial delay if slow simulation is enabled."""
200
+ if self.simulate_slow:
201
+ delay = override_delay if override_delay is not None else self.slow_delay
202
+ time.sleep(delay)
@@ -2,12 +2,13 @@
2
2
 
3
3
  from citrascope.time.time_health import TimeHealth, TimeStatus
4
4
  from citrascope.time.time_monitor import TimeMonitor
5
- from citrascope.time.time_sources import AbstractTimeSource, NTPTimeSource
5
+ from citrascope.time.time_sources import AbstractTimeSource, ChronyTimeSource, NTPTimeSource
6
6
 
7
7
  __all__ = [
8
8
  "TimeHealth",
9
9
  "TimeStatus",
10
10
  "TimeMonitor",
11
11
  "AbstractTimeSource",
12
+ "ChronyTimeSource",
12
13
  "NTPTimeSource",
13
14
  ]
@@ -24,11 +24,14 @@ class TimeHealth:
24
24
  """Current time sync status level."""
25
25
 
26
26
  source: str
27
- """Time source used (ntp, unknown)."""
27
+ """Time source used (ntp, gps, chrony, unknown)."""
28
28
 
29
29
  message: Optional[str] = None
30
30
  """Optional status message or error description."""
31
31
 
32
+ metadata: Optional[dict] = None
33
+ """Optional metadata (e.g., GPS satellite count, fix mode)."""
34
+
32
35
  @staticmethod
33
36
  def calculate_status(
34
37
  offset_ms: Optional[float],
@@ -61,6 +64,7 @@ class TimeHealth:
61
64
  source: str,
62
65
  pause_threshold: float,
63
66
  message: Optional[str] = None,
67
+ metadata: Optional[dict] = None,
64
68
  ) -> "TimeHealth":
65
69
  """
66
70
  Create TimeHealth from offset and pause threshold.
@@ -70,6 +74,7 @@ class TimeHealth:
70
74
  source: Time source identifier
71
75
  pause_threshold: Threshold that triggers task pause
72
76
  message: Optional status message
77
+ metadata: Optional metadata dict
73
78
 
74
79
  Returns:
75
80
  TimeHealth instance
@@ -80,6 +85,7 @@ class TimeHealth:
80
85
  status=status,
81
86
  source=source,
82
87
  message=message,
88
+ metadata=metadata,
83
89
  )
84
90
 
85
91
  def should_pause_observations(self) -> bool:
@@ -93,4 +99,5 @@ class TimeHealth:
93
99
  "status": self.status.value,
94
100
  "source": self.source,
95
101
  "message": self.message,
102
+ "metadata": self.metadata,
96
103
  }
@@ -6,7 +6,7 @@ from typing import Callable, Optional
6
6
 
7
7
  from citrascope.logging import CITRASCOPE_LOGGER
8
8
  from citrascope.time.time_health import TimeHealth, TimeStatus
9
- from citrascope.time.time_sources import AbstractTimeSource, NTPTimeSource
9
+ from citrascope.time.time_sources import AbstractTimeSource, ChronyTimeSource, NTPTimeSource
10
10
 
11
11
 
12
12
  class TimeMonitor:
@@ -36,9 +36,8 @@ class TimeMonitor:
36
36
  self.pause_threshold_ms = pause_threshold_ms
37
37
  self.pause_callback = pause_callback
38
38
 
39
- # Initialize NTP time source
40
- self.time_source: AbstractTimeSource = NTPTimeSource()
41
- CITRASCOPE_LOGGER.info("Time monitor initialized with NTP source")
39
+ # Detect and initialize best available time source
40
+ self.time_source: AbstractTimeSource = self._detect_best_source()
42
41
 
43
42
  # Thread control
44
43
  self._stop_event = threading.Event()
@@ -76,6 +75,25 @@ class TimeMonitor:
76
75
  with self._lock:
77
76
  return self._current_health
78
77
 
78
+ def _detect_best_source(self) -> AbstractTimeSource:
79
+ """
80
+ Detect and return the best available time source.
81
+
82
+ Priority order: Chrony > NTP
83
+
84
+ Returns:
85
+ The best available time source instance.
86
+ """
87
+ # Try ChronyTimeSource
88
+ chrony_source = ChronyTimeSource()
89
+ if chrony_source.is_available():
90
+ CITRASCOPE_LOGGER.info("Time monitor initialized with Chrony source")
91
+ return chrony_source
92
+
93
+ # Fall back to NTP
94
+ CITRASCOPE_LOGGER.info("Time monitor initialized with NTP source")
95
+ return NTPTimeSource()
96
+
79
97
  def _monitor_loop(self) -> None:
80
98
  """Main monitoring loop (runs in background thread)."""
81
99
  # Perform initial check immediately
@@ -94,14 +112,18 @@ class TimeMonitor:
94
112
  def _check_time_sync(self) -> None:
95
113
  """Perform a single time synchronization check."""
96
114
  try:
97
- # Query NTP for offset
115
+ # Query time source for offset
98
116
  offset_ms = self.time_source.get_offset_ms()
99
117
 
118
+ # Get metadata if available (e.g., GPS satellite info)
119
+ metadata = self.time_source.get_metadata()
120
+
100
121
  # Calculate health status
101
122
  health = TimeHealth.from_offset(
102
123
  offset_ms=offset_ms,
103
124
  source=self.time_source.get_source_name(),
104
125
  pause_threshold=self.pause_threshold_ms,
126
+ metadata=metadata,
105
127
  )
106
128
 
107
129
  # Store current health (thread-safe)