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,402 @@
1
+ """USB camera adapter using OpenCV."""
2
+
3
+ import logging
4
+ import time
5
+ from pathlib import Path
6
+ from typing import Optional, cast
7
+
8
+ from citrascope.hardware.abstract_astro_hardware_adapter import SettingSchemaEntry
9
+ from citrascope.hardware.devices.camera import AbstractCamera
10
+
11
+
12
+ class UsbCamera(AbstractCamera):
13
+ """Adapter for USB cameras accessible via OpenCV.
14
+
15
+ Supports USB cameras including guide cameras, planetary cameras, and standard webcams.
16
+ Note: Most USB cameras have limited exposure control compared to dedicated
17
+ astronomy cameras, but many are suitable for planetary imaging, guiding,
18
+ and testing.
19
+
20
+ Configuration:
21
+ camera_index (int): Camera device index (0 for first camera)
22
+ output_format (str): Output format - 'fits', 'png', 'jpg'
23
+ """
24
+
25
+ # Class-level cache for camera detection (shared across all instances)
26
+ _camera_cache: list[dict[str, str | int]] | None = None
27
+ _cache_timestamp: float = 0
28
+ _cache_ttl: float = float("inf") # Cache forever until daemon restart
29
+
30
+ @classmethod
31
+ def get_friendly_name(cls) -> str:
32
+ """Return human-readable name for this camera device.
33
+
34
+ Returns:
35
+ Friendly display name
36
+ """
37
+ return "USB Camera (OpenCV)"
38
+
39
+ @classmethod
40
+ def get_dependencies(cls) -> dict[str, str | list[str]]:
41
+ """Return required Python packages.
42
+
43
+ Returns:
44
+ Dict with packages and install extra
45
+ """
46
+ return {
47
+ "packages": ["cv2", "cv2_enumerate_cameras"],
48
+ "install_extra": "usb-camera",
49
+ }
50
+
51
+ @classmethod
52
+ def clear_camera_cache(cls):
53
+ """Clear cached camera list to force re-detection.
54
+
55
+ Call this from a "Scan Hardware" button or when hardware changes are expected.
56
+ """
57
+ cls._camera_cache = None
58
+ cls._cache_timestamp = 0
59
+
60
+ @classmethod
61
+ def get_settings_schema(cls) -> list[SettingSchemaEntry]:
62
+ """Return schema for USB camera settings.
63
+
64
+ Returns:
65
+ List of setting schema entries
66
+ """
67
+ # Detect available cameras
68
+ available_cameras = cls._detect_available_cameras()
69
+
70
+ schema = [
71
+ {
72
+ "name": "camera_index",
73
+ "friendly_name": "Camera",
74
+ "type": "int",
75
+ "default": 0,
76
+ "description": "Select which camera to use",
77
+ "required": False,
78
+ "options": available_cameras,
79
+ "group": "Camera",
80
+ },
81
+ {
82
+ "name": "output_format",
83
+ "friendly_name": "Output Format",
84
+ "type": "str",
85
+ "default": "fits",
86
+ "description": "Image output format",
87
+ "required": False,
88
+ "options": ["fits", "png", "jpg"],
89
+ "group": "Camera",
90
+ },
91
+ ]
92
+ return cast(list[SettingSchemaEntry], schema)
93
+
94
+ @classmethod
95
+ def _detect_available_cameras(cls) -> list[dict[str, str | int]]:
96
+ """Detect available USB cameras on the system.
97
+
98
+ Returns:
99
+ List of camera options as dicts with 'value' (index) and 'label' (name)
100
+ """
101
+ import time
102
+
103
+ # Check cache first
104
+ cache_age = time.time() - cls._cache_timestamp
105
+ if cls._camera_cache is not None and cache_age < cls._cache_ttl:
106
+ from citrascope.logging import CITRASCOPE_LOGGER
107
+
108
+ CITRASCOPE_LOGGER.debug(f"Using cached camera list (age: {cache_age:.1f}s)")
109
+ return cls._camera_cache
110
+
111
+ start_time = time.time()
112
+
113
+ cameras = []
114
+ try:
115
+ import cv2
116
+
117
+ # Try to use cv2-enumerate-cameras for rich camera names
118
+ try:
119
+ from cv2_enumerate_cameras import enumerate_cameras
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 ""
125
+
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()
132
+
133
+ backend_str = f" ({backend})" if backend else ""
134
+ cameras.append({"value": index, "label": f"{name} - {width}x{height}{backend_str}"})
135
+
136
+ except ImportError:
137
+ # cv2-enumerate-cameras not installed, use basic detection
138
+ for index in range(10):
139
+ cap = cv2.VideoCapture(index)
140
+ if cap.isOpened():
141
+ # Get camera resolution as identifier
142
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
143
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
144
+
145
+ # Try to get backend name
146
+ backend = cap.getBackendName() if hasattr(cap, "getBackendName") else ""
147
+ backend_str = f" ({backend})" if backend else ""
148
+
149
+ cameras.append({"value": index, "label": f"Camera {index} - {width}x{height}{backend_str}"})
150
+ cap.release()
151
+ else:
152
+ # Stop searching after first unavailable index
153
+ break
154
+
155
+ # If no cameras found, provide default option
156
+ if not cameras:
157
+ cameras.append({"value": 0, "label": "Camera 0 (default)"})
158
+
159
+ except ImportError:
160
+ # opencv not installed, provide default
161
+ cameras.append({"value": 0, "label": "Camera 0 (opencv-python not installed)"})
162
+ except Exception:
163
+ # Any other error, provide default
164
+ cameras.append({"value": 0, "label": "Camera 0 (default)"})
165
+
166
+ elapsed = time.time() - start_time
167
+ if elapsed > 0.1: # Log if takes more than 100ms
168
+ from citrascope.logging import CITRASCOPE_LOGGER
169
+
170
+ CITRASCOPE_LOGGER.info(f"Camera detection took {elapsed:.3f}s, found {len(cameras)} camera(s)")
171
+
172
+ # Cache the results
173
+ cls._camera_cache = cameras
174
+ cls._cache_timestamp = time.time()
175
+
176
+ return cameras
177
+
178
+ def __init__(self, logger: logging.Logger, **kwargs):
179
+ """Initialize the USB camera.
180
+
181
+ Args:
182
+ logger: Logger instance
183
+ **kwargs: Configuration parameters matching the schema
184
+ """
185
+ super().__init__(logger, **kwargs)
186
+
187
+ # Camera settings
188
+ self.camera_index = kwargs.get("camera_index", 0)
189
+ self.output_format = kwargs.get("output_format", "fits")
190
+
191
+ # Camera instance (lazy loaded)
192
+ self._camera = None
193
+ self._connected = False
194
+
195
+ # Lazy import opencv to avoid hard dependency
196
+ self._cv2_module = None
197
+
198
+ def connect(self) -> bool:
199
+ """Connect to the USB camera.
200
+
201
+ Returns:
202
+ True if connection successful, False otherwise
203
+ """
204
+ try:
205
+ # Lazy import
206
+ if self._cv2_module is None:
207
+ import cv2
208
+
209
+ self._cv2_module = cv2
210
+
211
+ self.logger.info(f"Connecting to USB camera at index {self.camera_index}...")
212
+
213
+ # Open camera
214
+ self._camera = self._cv2_module.VideoCapture(self.camera_index)
215
+
216
+ if not self._camera.isOpened():
217
+ self.logger.error(f"Failed to open camera at index {self.camera_index}")
218
+ return False
219
+
220
+ # Get camera properties
221
+ width = int(self._camera.get(self._cv2_module.CAP_PROP_FRAME_WIDTH))
222
+ height = int(self._camera.get(self._cv2_module.CAP_PROP_FRAME_HEIGHT))
223
+
224
+ self._connected = True
225
+ self.logger.info(f"USB camera connected: {width}x{height}")
226
+ return True
227
+
228
+ except ImportError:
229
+ self.logger.error("opencv-python library not found. Install with: pip install opencv-python")
230
+ return False
231
+ except Exception as e:
232
+ self.logger.error(f"Failed to connect to USB camera: {e}")
233
+ return False
234
+
235
+ def disconnect(self):
236
+ """Disconnect from the USB camera."""
237
+ if self._camera:
238
+ try:
239
+ self._camera.release()
240
+ self.logger.info("USB camera disconnected")
241
+ except Exception as e:
242
+ self.logger.error(f"Error disconnecting camera: {e}")
243
+
244
+ self._camera = None
245
+ self._connected = False
246
+
247
+ def is_connected(self) -> bool:
248
+ """Check if camera is connected and responsive.
249
+
250
+ Returns:
251
+ True if connected, False otherwise
252
+ """
253
+ return self._connected and self._camera is not None and self._camera.isOpened()
254
+
255
+ def take_exposure(
256
+ self,
257
+ duration: float,
258
+ gain: Optional[int] = None,
259
+ offset: Optional[int] = None,
260
+ binning: int = 1,
261
+ save_path: Optional[Path] = None,
262
+ ) -> Path:
263
+ """Capture an exposure (frame).
264
+
265
+ Args:
266
+ duration: Ignored for USB cameras (captures single frame)
267
+ gain: Not supported by most USB cameras via OpenCV
268
+ offset: Not supported by USB cameras via OpenCV
269
+ binning: Binning factor - applied via software resize
270
+ save_path: Path to save the image
271
+
272
+ Returns:
273
+ Path to the saved image
274
+
275
+ Raises:
276
+ RuntimeError: If camera not connected or capture fails
277
+ """
278
+ if not self.is_connected():
279
+ raise RuntimeError("Camera not connected")
280
+
281
+ if save_path is None:
282
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
283
+ save_path = Path(f"/tmp/usb_camera_{timestamp}.{self.output_format}")
284
+
285
+ try:
286
+ self.logger.info("Capturing USB camera frame...")
287
+
288
+ # Capture frame
289
+ ret, frame = self._camera.read()
290
+
291
+ if not ret or frame is None:
292
+ raise RuntimeError("Failed to capture frame from USB camera")
293
+
294
+ # Apply binning if requested
295
+ if binning > 1:
296
+ height, width = frame.shape[:2]
297
+ new_size = (width // binning, height // binning)
298
+ frame = self._cv2_module.resize(frame, new_size, interpolation=self._cv2_module.INTER_AREA)
299
+
300
+ # Save based on format
301
+ if self.output_format == "fits":
302
+ self._save_as_fits(frame, save_path)
303
+ elif self.output_format == "png":
304
+ self._cv2_module.imwrite(str(save_path), frame)
305
+ elif self.output_format == "jpg":
306
+ self._cv2_module.imwrite(str(save_path), frame, [self._cv2_module.IMWRITE_JPEG_QUALITY, 95])
307
+
308
+ self.logger.info(f"Image saved to {save_path}")
309
+ return save_path
310
+
311
+ except Exception as e:
312
+ self.logger.error(f"Failed to capture image: {e}")
313
+ raise RuntimeError(f"Image capture failed: {e}")
314
+
315
+ def abort_exposure(self):
316
+ """Abort current exposure.
317
+
318
+ Note: USB camera captures via OpenCV are instantaneous, nothing to abort.
319
+ """
320
+ self.logger.debug("USB camera captures are instantaneous - nothing to abort")
321
+
322
+ def get_temperature(self) -> Optional[float]:
323
+ """Get camera sensor temperature.
324
+
325
+ Returns:
326
+ None - USB cameras accessed via OpenCV don't expose temperature readings
327
+ """
328
+ return None
329
+
330
+ def set_temperature(self, temperature: float) -> bool:
331
+ """Set target camera sensor temperature.
332
+
333
+ Args:
334
+ temperature: Target temperature in degrees Celsius
335
+
336
+ Returns:
337
+ False - USB cameras accessed via OpenCV don't support temperature control
338
+ """
339
+ self.logger.debug("USB cameras do not support temperature control via OpenCV")
340
+ return False
341
+
342
+ def start_cooling(self) -> bool:
343
+ """Enable camera cooling system.
344
+
345
+ Returns:
346
+ False - USB cameras accessed via OpenCV don't have cooling systems
347
+ """
348
+ self.logger.debug("USB cameras do not have cooling systems via OpenCV")
349
+ return False
350
+
351
+ def stop_cooling(self) -> bool:
352
+ """Disable camera cooling system.
353
+
354
+ Returns:
355
+ False - USB cameras accessed via OpenCV don't have cooling systems
356
+ """
357
+ self.logger.debug("USB cameras do not have cooling systems via OpenCV")
358
+ return False
359
+
360
+ def get_camera_info(self) -> dict:
361
+ """Get camera capabilities and information.
362
+
363
+ Returns:
364
+ Dictionary containing camera specs
365
+ """
366
+ info = {
367
+ "name": f"USB Camera {self.camera_index}",
368
+ "type": "USB Camera",
369
+ "has_cooling": False,
370
+ "has_temperature_sensor": False,
371
+ "output_format": self.output_format,
372
+ }
373
+
374
+ if self.is_connected() and self._camera:
375
+ info["width"] = int(self._camera.get(self._cv2_module.CAP_PROP_FRAME_WIDTH))
376
+ info["height"] = int(self._camera.get(self._cv2_module.CAP_PROP_FRAME_HEIGHT))
377
+ info["fps"] = int(self._camera.get(self._cv2_module.CAP_PROP_FPS))
378
+
379
+ return info
380
+
381
+ def _save_as_fits(self, frame, save_path: Path):
382
+ """Save frame as FITS format.
383
+
384
+ Args:
385
+ frame: OpenCV BGR frame
386
+ save_path: Path to save FITS file
387
+ """
388
+ try:
389
+ import numpy as np
390
+ from astropy.io import fits
391
+
392
+ # Convert BGR to grayscale for astronomy (luminance)
393
+ gray = self._cv2_module.cvtColor(frame, self._cv2_module.COLOR_BGR2GRAY)
394
+
395
+ hdu = fits.PrimaryHDU(gray.astype(np.uint16))
396
+ hdu.header["INSTRUME"] = "USB Camera"
397
+ hdu.header["CAMERA"] = f"Index {self.camera_index}"
398
+ hdu.writeto(save_path, overwrite=True)
399
+
400
+ except ImportError:
401
+ self.logger.error("astropy not installed. Install with: pip install astropy")
402
+ raise