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
@@ -28,3 +28,17 @@ class AbstractCitraApiClient(ABC):
28
28
  PUT to /telescopes to report online status.
29
29
  """
30
30
  pass
31
+
32
+ @abstractmethod
33
+ def expand_filters(self, filter_names):
34
+ """
35
+ POST to /filters/expand to expand filter names to spectral specs.
36
+ """
37
+ pass
38
+
39
+ @abstractmethod
40
+ def update_telescope_spectral_config(self, telescope_id, spectral_config):
41
+ """
42
+ PATCH to /telescopes to update telescope's spectral configuration.
43
+ """
44
+ pass
@@ -140,3 +140,44 @@ class CitraApiClient(AbstractCitraApiClient):
140
140
  if self.logger:
141
141
  self.logger.error(f"Failed to mark task {task_id} as failed: {e}")
142
142
  return None
143
+
144
+ def expand_filters(self, filter_names):
145
+ """Expand filter names to full spectral specifications.
146
+
147
+ Args:
148
+ filter_names: List of filter name strings (e.g., ["Red", "Ha", "Clear"])
149
+
150
+ Returns:
151
+ Response dict with 'filters' array, or None on error
152
+ """
153
+ try:
154
+ body = {"filter_names": filter_names}
155
+ response = self._request("POST", "/filters/expand", json=body)
156
+ if self.logger:
157
+ self.logger.debug(f"POST /filters/expand: {response}")
158
+ return response
159
+ except Exception as e:
160
+ if self.logger:
161
+ self.logger.error(f"Failed to expand filters: {e}")
162
+ return None
163
+
164
+ def update_telescope_spectral_config(self, telescope_id, spectral_config):
165
+ """Update telescope's spectral configuration.
166
+
167
+ Args:
168
+ telescope_id: Telescope UUID string
169
+ spectral_config: Dict with spectral configuration (discrete filters, etc.)
170
+
171
+ Returns:
172
+ Response from PATCH request, or None on error
173
+ """
174
+ try:
175
+ body = [{"id": telescope_id, "spectralConfig": spectral_config}]
176
+ response = self._request("PATCH", "/telescopes", json=body)
177
+ if self.logger:
178
+ self.logger.debug(f"PATCH /telescopes spectral_config: {response}")
179
+ return response
180
+ except Exception as e:
181
+ if self.logger:
182
+ self.logger.error(f"Failed to update telescope spectral config: {e}")
183
+ return None
@@ -4,10 +4,13 @@ from typing import Optional
4
4
  from citrascope.api.citra_api_client import AbstractCitraApiClient, CitraApiClient
5
5
  from citrascope.hardware.abstract_astro_hardware_adapter import AbstractAstroHardwareAdapter
6
6
  from citrascope.hardware.adapter_registry import get_adapter_class
7
+ from citrascope.hardware.filter_sync import sync_filters_to_backend
7
8
  from citrascope.logging import CITRASCOPE_LOGGER
8
9
  from citrascope.logging._citrascope_logger import setup_file_logging
9
10
  from citrascope.settings.citrascope_settings import CitraScopeSettings
10
11
  from citrascope.tasks.runner import TaskManager
12
+ from citrascope.time.time_health import TimeHealth
13
+ from citrascope.time.time_monitor import TimeMonitor
11
14
  from citrascope.web.server import CitraScopeWebServer
12
15
 
13
16
 
@@ -32,6 +35,7 @@ class CitraScopeDaemon:
32
35
  self.hardware_adapter = hardware_adapter
33
36
  self.web_server = None
34
37
  self.task_manager = None
38
+ self.time_monitor = None
35
39
  self.ground_station = None
36
40
  self.telescope_record = None
37
41
  self.configuration_error: Optional[str] = None
@@ -89,6 +93,10 @@ class CitraScopeDaemon:
89
93
  self.task_manager.stop()
90
94
  self.task_manager = None
91
95
 
96
+ if self.time_monitor:
97
+ self.time_monitor.stop()
98
+ self.time_monitor = None
99
+
92
100
  if self.hardware_adapter:
93
101
  try:
94
102
  self.hardware_adapter.disconnect()
@@ -114,6 +122,16 @@ class CitraScopeDaemon:
114
122
  # Initialize hardware adapter
115
123
  self.hardware_adapter = self._create_hardware_adapter()
116
124
 
125
+ # Check for missing dependencies (non-fatal, just warn)
126
+ if hasattr(self.hardware_adapter, "get_missing_dependencies"):
127
+ missing_deps = self.hardware_adapter.get_missing_dependencies()
128
+ if missing_deps:
129
+ for dep in missing_deps:
130
+ CITRASCOPE_LOGGER.warning(
131
+ f"{dep['device_type']} '{dep['device_name']}' missing dependencies: {dep['missing_packages']}. "
132
+ f"Install with: {dep['install_cmd']}"
133
+ )
134
+
117
135
  # Initialize telescope
118
136
  success, error = self._initialize_telescope()
119
137
 
@@ -179,6 +197,8 @@ class CitraScopeDaemon:
179
197
 
180
198
  # Save filter configuration if adapter supports it
181
199
  self._save_filter_config()
200
+ # Sync discovered filters to backend on startup
201
+ self._sync_filters_to_backend()
182
202
 
183
203
  self.task_manager = TaskManager(
184
204
  self.api_client,
@@ -191,6 +211,15 @@ class CitraScopeDaemon:
191
211
  )
192
212
  self.task_manager.start()
193
213
 
214
+ # Initialize and start time monitor (always enabled)
215
+ self.time_monitor = TimeMonitor(
216
+ check_interval_minutes=self.settings.time_check_interval_minutes,
217
+ pause_threshold_ms=self.settings.time_offset_pause_ms,
218
+ pause_callback=self._on_time_drift_pause,
219
+ )
220
+ self.time_monitor.start()
221
+ CITRASCOPE_LOGGER.info("Time synchronization monitoring started")
222
+
194
223
  CITRASCOPE_LOGGER.info("Telescope initialized successfully!")
195
224
  return True, None
196
225
 
@@ -207,6 +236,9 @@ class CitraScopeDaemon:
207
236
  - After autofocus to save updated focus positions
208
237
  - After manual filter focus updates via web API
209
238
 
239
+ Note: This only saves locally. Call _sync_filters_to_backend() separately
240
+ when enabled filters change to update the backend.
241
+
210
242
  Thread safety: This modifies self.settings and writes to disk.
211
243
  Should be called from main daemon thread or properly synchronized.
212
244
  """
@@ -222,6 +254,22 @@ class CitraScopeDaemon:
222
254
  except Exception as e:
223
255
  CITRASCOPE_LOGGER.warning(f"Failed to save filter configuration: {e}")
224
256
 
257
+ def _sync_filters_to_backend(self):
258
+ """Sync enabled filters to backend API.
259
+
260
+ Extracts enabled filter names from hardware adapter, expands them via
261
+ the filter library API, then updates the telescope's spectral_config.
262
+ Logs warnings on failure without blocking daemon operations.
263
+ """
264
+ if not self.hardware_adapter or not self.api_client or not self.telescope_record:
265
+ return
266
+
267
+ try:
268
+ filter_config = self.hardware_adapter.get_filter_config()
269
+ sync_filters_to_backend(self.api_client, self.telescope_record["id"], filter_config, CITRASCOPE_LOGGER)
270
+ except Exception as e:
271
+ CITRASCOPE_LOGGER.warning(f"Failed to sync filters to backend: {e}", exc_info=True)
272
+
225
273
  def trigger_autofocus(self) -> tuple[bool, Optional[str]]:
226
274
  """Request autofocus to run at next safe point between tasks.
227
275
 
@@ -261,6 +309,31 @@ class CitraScopeDaemon:
261
309
  return False
262
310
  return self.task_manager.is_autofocus_requested()
263
311
 
312
+ def _on_time_drift_pause(self, health: TimeHealth) -> None:
313
+ """
314
+ Callback invoked when time drift exceeds pause threshold.
315
+
316
+ Automatically pauses task processing to prevent observations with
317
+ inaccurate timestamps. User must manually resume after fixing time sync.
318
+
319
+ Args:
320
+ health: Current time health status
321
+ """
322
+ if not self.task_manager:
323
+ return
324
+
325
+ CITRASCOPE_LOGGER.critical(
326
+ f"Time drift exceeded threshold: {health.offset_ms:+.1f}ms. "
327
+ "Pausing task processing to prevent inaccurate observations."
328
+ )
329
+
330
+ # Pause task processing
331
+ self.task_manager.pause()
332
+ CITRASCOPE_LOGGER.warning(
333
+ "Task processing paused due to time sync issues. "
334
+ "Fix NTP configuration and manually resume via web interface."
335
+ )
336
+
264
337
  def run(self):
265
338
  # Start web server FIRST, so users can monitor/configure
266
339
  # The web interface will remain available even if configuration is incomplete
@@ -293,6 +366,8 @@ class CitraScopeDaemon:
293
366
  """Clean up resources on shutdown."""
294
367
  if self.task_manager:
295
368
  self.task_manager.stop()
369
+ if self.time_monitor:
370
+ self.time_monitor.stop()
296
371
  if self.web_server:
297
372
  CITRASCOPE_LOGGER.info("Stopping web server...")
298
373
  if self.web_server.web_log_handler:
@@ -18,6 +18,7 @@ class SettingSchemaEntry(TypedDict, total=False):
18
18
  max: float # Maximum value for numeric types
19
19
  pattern: str # Regex pattern for string validation
20
20
  options: list[str] # List of valid options for select/dropdown inputs
21
+ group: str # Group name for organizing settings in UI (e.g., 'Camera', 'Mount', 'Advanced')
21
22
 
22
23
 
23
24
  class FilterConfig(TypedDict):
@@ -70,7 +71,7 @@ class AbstractAstroHardwareAdapter(ABC):
70
71
 
71
72
  @classmethod
72
73
  @abstractmethod
73
- def get_settings_schema(cls) -> list[SettingSchemaEntry]:
74
+ def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
74
75
  """
75
76
  Return a schema describing configurable settings for this hardware adapter.
76
77
 
@@ -127,7 +128,7 @@ class AbstractAstroHardwareAdapter(ABC):
127
128
  pass
128
129
 
129
130
  @abstractmethod
130
- def perform_observation_sequence(self, task_id, satellite_data) -> str:
131
+ def perform_observation_sequence(self, task, satellite_data) -> str:
131
132
  """For hardware driven by sequences, perform the observation sequence and return image path."""
132
133
  pass
133
134
 
@@ -233,6 +234,100 @@ class AbstractAstroHardwareAdapter(ABC):
233
234
  """
234
235
  return False
235
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
+
254
+ def is_hyperspectral(self) -> bool:
255
+ """Indicates whether this adapter uses a hyperspectral camera.
256
+
257
+ Hyperspectral cameras capture multiple spectral bands simultaneously
258
+ (e.g., snapshot mosaic sensors) and do not require discrete filter changes.
259
+
260
+ Returns:
261
+ bool: True if using hyperspectral imaging, False otherwise (default)
262
+ """
263
+ return False
264
+
265
+ def select_filters_for_task(self, task, allow_no_filter: bool = False) -> dict | None:
266
+ """Select which filters to use for a task based on assignment.
267
+
268
+ This method handles the common logic for filter selection:
269
+ - If task specifies assigned_filter_name, find and validate that filter
270
+ - If no filter specified, look for Clear/Luminance filter (case-insensitive)
271
+ - Fall back to first enabled filter, or None if allow_no_filter=True
272
+
273
+ Args:
274
+ task: Task object with optional assigned_filter_name field
275
+ allow_no_filter: If True, return None when no filters available (for KStars '--')
276
+
277
+ Returns:
278
+ dict: Dictionary mapping filter IDs to filter info {id: {name, focus_position, enabled}}
279
+ Returns None only if allow_no_filter=True and no suitable filter found
280
+
281
+ Raises:
282
+ RuntimeError: If assigned filter not found, disabled, or no filters available when required
283
+ """
284
+ # Task specifies a specific filter - find it
285
+ if task and task.assigned_filter_name:
286
+ target_filter_id = None
287
+ target_filter_info = None
288
+ for fid, fdata in self.filter_map.items():
289
+ # Case-insensitive comparison
290
+ if fdata["name"].lower() == task.assigned_filter_name.lower():
291
+ if not fdata.get("enabled", True):
292
+ raise RuntimeError(
293
+ f"Requested filter '{task.assigned_filter_name}' is disabled for task {task.id}"
294
+ )
295
+ target_filter_id = fid
296
+ target_filter_info = fdata
297
+ break
298
+
299
+ if target_filter_id is None:
300
+ raise RuntimeError(
301
+ f"Requested filter '{task.assigned_filter_name}' not found in filter map for task {task.id}"
302
+ )
303
+
304
+ task_id_str = task.id if task else "unknown"
305
+ self.logger.info(f"Using filter '{task.assigned_filter_name}' for task {task_id_str}")
306
+ return {target_filter_id: target_filter_info}
307
+
308
+ # No filter specified - look for Clear or Luminance (case-insensitive)
309
+ clear_filter_names = ["clear", "luminance", "lum", "l"]
310
+ for fid, fdata in self.filter_map.items():
311
+ if fdata.get("enabled", True) and fdata["name"].lower() in clear_filter_names:
312
+ task_id_str = task.id if task else "unknown"
313
+ self.logger.info(f"Using default filter '{fdata['name']}' for task {task_id_str}")
314
+ return {fid: fdata}
315
+
316
+ # No clear filter found - try first enabled filter
317
+ enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
318
+ if enabled_filters:
319
+ first_filter_id = next(iter(enabled_filters))
320
+ task_id_str = task.id if task else "unknown"
321
+ self.logger.info(
322
+ f"Using first available filter '{enabled_filters[first_filter_id]['name']}' for task {task_id_str}"
323
+ )
324
+ return {first_filter_id: enabled_filters[first_filter_id]}
325
+
326
+ # No enabled filters available
327
+ if allow_no_filter:
328
+ return None
329
+ raise RuntimeError("No enabled filters available for observation sequence")
330
+
236
331
  def get_filter_config(self) -> dict[str, FilterConfig]:
237
332
  """Get the current filter configuration including focus positions.
238
333
 
@@ -33,6 +33,16 @@ REGISTERED_ADAPTERS: Dict[str, Dict[str, str]] = {
33
33
  "class_name": "KStarsDBusAdapter",
34
34
  "description": "KStars/Ekos via D-Bus - Linux astronomy suite",
35
35
  },
36
+ "direct": {
37
+ "module": "citrascope.hardware.direct_hardware_adapter",
38
+ "class_name": "DirectHardwareAdapter",
39
+ "description": "Direct Hardware Control - Composable device adapters for cameras, mounts, etc.",
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
+ },
36
46
  }
37
47
 
38
48
 
@@ -76,11 +86,13 @@ def list_adapters() -> Dict[str, Dict[str, str]]:
76
86
  }
77
87
 
78
88
 
79
- def get_adapter_schema(adapter_name: str) -> list:
89
+ def get_adapter_schema(adapter_name: str, **kwargs) -> list:
80
90
  """Get the configuration schema for a specific adapter.
81
91
 
82
92
  Args:
83
93
  adapter_name: The name of the adapter
94
+ **kwargs: Additional arguments to pass to the adapter's get_settings_schema method
95
+ (e.g., current settings for dynamic schema generation)
84
96
 
85
97
  Returns:
86
98
  The adapter's settings schema
@@ -90,5 +102,5 @@ def get_adapter_schema(adapter_name: str) -> list:
90
102
  ImportError: If the adapter module cannot be imported
91
103
  """
92
104
  adapter_class = get_adapter_class(adapter_name)
93
- # Call classmethod directly without instantiation
94
- return adapter_class.get_settings_schema()
105
+ # Call classmethod, passing kwargs for dynamic schemas
106
+ return adapter_class.get_settings_schema(**kwargs)
@@ -0,0 +1,17 @@
1
+ """Device-level hardware abstractions.
2
+
3
+ This module provides low-level device abstractions for direct hardware control.
4
+ Device adapters can be composed into hardware adapters for complete system control.
5
+ """
6
+
7
+ from citrascope.hardware.devices.camera import AbstractCamera
8
+ from citrascope.hardware.devices.filter_wheel import AbstractFilterWheel
9
+ from citrascope.hardware.devices.focuser import AbstractFocuser
10
+ from citrascope.hardware.devices.mount import AbstractMount
11
+
12
+ __all__ = [
13
+ "AbstractCamera",
14
+ "AbstractMount",
15
+ "AbstractFilterWheel",
16
+ "AbstractFocuser",
17
+ ]
@@ -0,0 +1,79 @@
1
+ """Abstract base class for all hardware device types."""
2
+
3
+ import logging
4
+ from abc import ABC, abstractmethod
5
+
6
+ from citrascope.hardware.abstract_astro_hardware_adapter import SettingSchemaEntry
7
+
8
+
9
+ class AbstractHardwareDevice(ABC):
10
+ """Base class for all hardware devices (cameras, mounts, filter wheels, focusers).
11
+
12
+ Provides common interface elements shared by all device types.
13
+ """
14
+
15
+ logger: logging.Logger
16
+
17
+ def __init__(self, logger: logging.Logger, **kwargs):
18
+ """Initialize the hardware device.
19
+
20
+ Args:
21
+ logger: Logger instance for this device
22
+ **kwargs: Device-specific configuration parameters
23
+ """
24
+ self.logger = logger
25
+
26
+ @classmethod
27
+ @abstractmethod
28
+ def get_friendly_name(cls) -> str:
29
+ """Return human-readable name for this device.
30
+
31
+ Returns:
32
+ Friendly display name (e.g., 'ZWO ASI294MC Pro', 'Celestron CGX')
33
+ """
34
+ pass
35
+
36
+ @classmethod
37
+ @abstractmethod
38
+ def get_dependencies(cls) -> dict[str, str | list[str]]:
39
+ """Return required Python packages and installation info.
40
+
41
+ Returns:
42
+ Dict with keys:
43
+ - packages: list of required package names
44
+ - install_extra: pyproject.toml extra name for pip install
45
+ """
46
+ pass
47
+
48
+ @classmethod
49
+ @abstractmethod
50
+ def get_settings_schema(cls) -> list[SettingSchemaEntry]:
51
+ """Return schema describing configurable settings for this device.
52
+
53
+ Returns:
54
+ List of setting schema entries (without device-type prefix)
55
+ """
56
+ pass
57
+
58
+ @abstractmethod
59
+ def connect(self) -> bool:
60
+ """Connect to the hardware device.
61
+
62
+ Returns:
63
+ True if connection successful, False otherwise
64
+ """
65
+ pass
66
+
67
+ @abstractmethod
68
+ def disconnect(self):
69
+ """Disconnect from the hardware device."""
70
+ pass
71
+
72
+ @abstractmethod
73
+ def is_connected(self) -> bool:
74
+ """Check if device is connected and responsive.
75
+
76
+ Returns:
77
+ True if connected, False otherwise
78
+ """
79
+ pass
@@ -0,0 +1,13 @@
1
+ """Camera device adapters."""
2
+
3
+ from citrascope.hardware.devices.camera.abstract_camera import AbstractCamera
4
+ from citrascope.hardware.devices.camera.rpi_hq_camera import RaspberryPiHQCamera
5
+ from citrascope.hardware.devices.camera.usb_camera import UsbCamera
6
+ from citrascope.hardware.devices.camera.ximea_camera import XimeaHyperspectralCamera
7
+
8
+ __all__ = [
9
+ "AbstractCamera",
10
+ "RaspberryPiHQCamera",
11
+ "UsbCamera",
12
+ "XimeaHyperspectralCamera",
13
+ ]
@@ -0,0 +1,114 @@
1
+ """Abstract camera device interface."""
2
+
3
+ from abc import abstractmethod
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from citrascope.hardware.devices.abstract_hardware_device import AbstractHardwareDevice
8
+
9
+
10
+ class AbstractCamera(AbstractHardwareDevice):
11
+ """Abstract base class for camera devices.
12
+
13
+ Provides a common interface for controlling imaging cameras including
14
+ CCDs, CMOS sensors, and hyperspectral cameras.
15
+ """
16
+
17
+ @abstractmethod
18
+ def take_exposure(
19
+ self,
20
+ duration: float,
21
+ gain: Optional[int] = None,
22
+ offset: Optional[int] = None,
23
+ binning: int = 1,
24
+ save_path: Optional[Path] = None,
25
+ ) -> Path:
26
+ """Capture an image exposure.
27
+
28
+ Args:
29
+ duration: Exposure duration in seconds
30
+ gain: Camera gain setting (device-specific units)
31
+ offset: Camera offset/black level setting
32
+ binning: Pixel binning factor (1=no binning, 2=2x2, etc.)
33
+ save_path: Optional path to save the image (if None, use default)
34
+
35
+ Returns:
36
+ Path to the saved image file
37
+ """
38
+ pass
39
+
40
+ @abstractmethod
41
+ def abort_exposure(self):
42
+ """Abort the current exposure if one is in progress."""
43
+ pass
44
+
45
+ @abstractmethod
46
+ def get_temperature(self) -> Optional[float]:
47
+ """Get the current camera sensor temperature.
48
+
49
+ Returns:
50
+ Temperature in degrees Celsius, or None if not available
51
+ """
52
+ pass
53
+
54
+ @abstractmethod
55
+ def set_temperature(self, temperature: float) -> bool:
56
+ """Set the target camera sensor temperature.
57
+
58
+ Args:
59
+ temperature: Target temperature in degrees Celsius
60
+
61
+ Returns:
62
+ True if temperature setpoint accepted, False otherwise
63
+ """
64
+ pass
65
+
66
+ @abstractmethod
67
+ def start_cooling(self) -> bool:
68
+ """Enable camera cooling system.
69
+
70
+ Returns:
71
+ True if cooling started successfully, False otherwise
72
+ """
73
+ pass
74
+
75
+ @abstractmethod
76
+ def stop_cooling(self) -> bool:
77
+ """Disable camera cooling system.
78
+
79
+ Returns:
80
+ True if cooling stopped successfully, False otherwise
81
+ """
82
+ pass
83
+
84
+ @abstractmethod
85
+ def get_camera_info(self) -> dict:
86
+ """Get camera capabilities and information.
87
+
88
+ Returns:
89
+ Dictionary containing camera specs (resolution, pixel size, bit depth, etc.)
90
+ """
91
+ pass
92
+
93
+ def is_hyperspectral(self) -> bool:
94
+ """Indicates whether this camera captures hyperspectral data.
95
+
96
+ Hyperspectral cameras capture multiple spectral bands simultaneously
97
+ (e.g., snapshot mosaic sensors like Ximea MQ series).
98
+
99
+ Returns:
100
+ bool: True if hyperspectral camera, False otherwise (default)
101
+ """
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")