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,407 @@
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
+ # 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())
124
+
125
+ logging.debug(f"cv2_enumerate_cameras found {len(camera_infos)} cameras")
126
+
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}")
136
+
137
+ except ImportError:
138
+ # cv2-enumerate-cameras not installed, use basic detection
139
+ for index in range(10):
140
+ cap = cv2.VideoCapture(index)
141
+ if cap.isOpened():
142
+ # Get camera resolution as identifier
143
+ width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
144
+ height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
145
+
146
+ # Try to get backend name
147
+ backend = cap.getBackendName() if hasattr(cap, "getBackendName") else ""
148
+ backend_str = f" ({backend})" if backend else ""
149
+
150
+ cameras.append({"value": index, "label": f"Camera {index} - {width}x{height}{backend_str}"})
151
+ cap.release()
152
+ else:
153
+ # Stop searching after first unavailable index
154
+ break
155
+
156
+ # If no cameras found, provide default option
157
+ if not cameras:
158
+ cameras.append({"value": 0, "label": "Camera 0 (default)"})
159
+
160
+ except ImportError:
161
+ # opencv not installed, provide default
162
+ cameras.append({"value": 0, "label": "Camera 0 (opencv-python not installed)"})
163
+ except Exception:
164
+ # Any other error, provide default
165
+ cameras.append({"value": 0, "label": "Camera 0 (default)"})
166
+
167
+ elapsed = time.time() - start_time
168
+ if elapsed > 0.1: # Log if takes more than 100ms
169
+ from citrascope.logging import CITRASCOPE_LOGGER
170
+
171
+ CITRASCOPE_LOGGER.info(f"Camera detection took {elapsed:.3f}s, found {len(cameras)} camera(s)")
172
+
173
+ # Cache the results
174
+ cls._camera_cache = cameras
175
+ cls._cache_timestamp = time.time()
176
+
177
+ return cameras
178
+
179
+ def __init__(self, logger: logging.Logger, **kwargs):
180
+ """Initialize the USB camera.
181
+
182
+ Args:
183
+ logger: Logger instance
184
+ **kwargs: Configuration parameters matching the schema
185
+ """
186
+ super().__init__(logger, **kwargs)
187
+
188
+ # Camera settings
189
+ self.camera_index = kwargs.get("camera_index", 0)
190
+ self.output_format = kwargs.get("output_format", "fits")
191
+
192
+ # Camera instance (lazy loaded)
193
+ self._camera = None
194
+ self._connected = False
195
+
196
+ # Lazy import opencv to avoid hard dependency
197
+ self._cv2_module = None
198
+
199
+ def connect(self) -> bool:
200
+ """Connect to the USB camera.
201
+
202
+ Returns:
203
+ True if connection successful, False otherwise
204
+ """
205
+ try:
206
+ # Lazy import
207
+ if self._cv2_module is None:
208
+ import cv2
209
+
210
+ self._cv2_module = cv2
211
+
212
+ self.logger.info(f"Connecting to USB camera at index {self.camera_index}...")
213
+
214
+ # Open camera
215
+ self._camera = self._cv2_module.VideoCapture(self.camera_index)
216
+
217
+ if not self._camera.isOpened():
218
+ self.logger.error(f"Failed to open camera at index {self.camera_index}")
219
+ return False
220
+
221
+ # Get camera properties
222
+ width = int(self._camera.get(self._cv2_module.CAP_PROP_FRAME_WIDTH))
223
+ height = int(self._camera.get(self._cv2_module.CAP_PROP_FRAME_HEIGHT))
224
+
225
+ self._connected = True
226
+ self.logger.info(f"USB camera connected: {width}x{height}")
227
+ return True
228
+
229
+ except ImportError:
230
+ self.logger.error("opencv-python library not found. Install with: pip install opencv-python")
231
+ return False
232
+ except Exception as e:
233
+ self.logger.error(f"Failed to connect to USB camera: {e}")
234
+ return False
235
+
236
+ def disconnect(self):
237
+ """Disconnect from the USB camera."""
238
+ if self._camera:
239
+ try:
240
+ self._camera.release()
241
+ self.logger.info("USB camera disconnected")
242
+ except Exception as e:
243
+ self.logger.error(f"Error disconnecting camera: {e}")
244
+
245
+ self._camera = None
246
+ self._connected = False
247
+
248
+ def is_connected(self) -> bool:
249
+ """Check if camera is connected and responsive.
250
+
251
+ Returns:
252
+ True if connected, False otherwise
253
+ """
254
+ return self._connected and self._camera is not None and self._camera.isOpened()
255
+
256
+ def take_exposure(
257
+ self,
258
+ duration: float,
259
+ gain: Optional[int] = None,
260
+ offset: Optional[int] = None,
261
+ binning: int = 1,
262
+ save_path: Optional[Path] = None,
263
+ ) -> Path:
264
+ """Capture an exposure (frame).
265
+
266
+ Args:
267
+ duration: Ignored for USB cameras (captures single frame)
268
+ gain: Not supported by most USB cameras via OpenCV
269
+ offset: Not supported by USB cameras via OpenCV
270
+ binning: Binning factor - applied via software resize
271
+ save_path: Path to save the image
272
+
273
+ Returns:
274
+ Path to the saved image
275
+
276
+ Raises:
277
+ RuntimeError: If camera not connected or capture fails
278
+ """
279
+ if not self.is_connected():
280
+ raise RuntimeError("Camera not connected")
281
+
282
+ if save_path is None:
283
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
284
+ save_path = Path(f"/tmp/usb_camera_{timestamp}.{self.output_format}")
285
+
286
+ try:
287
+ self.logger.info("Capturing USB camera frame...")
288
+
289
+ # Capture frame
290
+ ret, frame = self._camera.read()
291
+
292
+ if not ret or frame is None:
293
+ raise RuntimeError("Failed to capture frame from USB camera")
294
+
295
+ # Apply binning if requested
296
+ if binning > 1:
297
+ height, width = frame.shape[:2]
298
+ new_size = (width // binning, height // binning)
299
+ frame = self._cv2_module.resize(frame, new_size, interpolation=self._cv2_module.INTER_AREA)
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
+
305
+ # Save based on format
306
+ if format_to_use == "fits":
307
+ self._save_as_fits(frame, save_path)
308
+ elif format_to_use == "png":
309
+ self._cv2_module.imwrite(str(save_path), frame)
310
+ elif format_to_use in ["jpg", "jpeg"]:
311
+ self._cv2_module.imwrite(str(save_path), frame, [self._cv2_module.IMWRITE_JPEG_QUALITY, 95])
312
+
313
+ self.logger.info(f"Image saved to {save_path}")
314
+ return save_path
315
+
316
+ except Exception as e:
317
+ self.logger.error(f"Failed to capture image: {e}")
318
+ raise RuntimeError(f"Image capture failed: {e}")
319
+
320
+ def abort_exposure(self):
321
+ """Abort current exposure.
322
+
323
+ Note: USB camera captures via OpenCV are instantaneous, nothing to abort.
324
+ """
325
+ self.logger.debug("USB camera captures are instantaneous - nothing to abort")
326
+
327
+ def get_temperature(self) -> Optional[float]:
328
+ """Get camera sensor temperature.
329
+
330
+ Returns:
331
+ None - USB cameras accessed via OpenCV don't expose temperature readings
332
+ """
333
+ return None
334
+
335
+ def set_temperature(self, temperature: float) -> bool:
336
+ """Set target camera sensor temperature.
337
+
338
+ Args:
339
+ temperature: Target temperature in degrees Celsius
340
+
341
+ Returns:
342
+ False - USB cameras accessed via OpenCV don't support temperature control
343
+ """
344
+ self.logger.debug("USB cameras do not support temperature control via OpenCV")
345
+ return False
346
+
347
+ def start_cooling(self) -> bool:
348
+ """Enable camera cooling system.
349
+
350
+ Returns:
351
+ False - USB cameras accessed via OpenCV don't have cooling systems
352
+ """
353
+ self.logger.debug("USB cameras do not have cooling systems via OpenCV")
354
+ return False
355
+
356
+ def stop_cooling(self) -> bool:
357
+ """Disable camera cooling system.
358
+
359
+ Returns:
360
+ False - USB cameras accessed via OpenCV don't have cooling systems
361
+ """
362
+ self.logger.debug("USB cameras do not have cooling systems via OpenCV")
363
+ return False
364
+
365
+ def get_camera_info(self) -> dict:
366
+ """Get camera capabilities and information.
367
+
368
+ Returns:
369
+ Dictionary containing camera specs
370
+ """
371
+ info = {
372
+ "name": f"USB Camera {self.camera_index}",
373
+ "type": "USB Camera",
374
+ "has_cooling": False,
375
+ "has_temperature_sensor": False,
376
+ "output_format": self.output_format,
377
+ }
378
+
379
+ if self.is_connected() and self._camera:
380
+ info["width"] = int(self._camera.get(self._cv2_module.CAP_PROP_FRAME_WIDTH))
381
+ info["height"] = int(self._camera.get(self._cv2_module.CAP_PROP_FRAME_HEIGHT))
382
+ info["fps"] = int(self._camera.get(self._cv2_module.CAP_PROP_FPS))
383
+
384
+ return info
385
+
386
+ def _save_as_fits(self, frame, save_path: Path):
387
+ """Save frame as FITS format.
388
+
389
+ Args:
390
+ frame: OpenCV BGR frame
391
+ save_path: Path to save FITS file
392
+ """
393
+ try:
394
+ import numpy as np
395
+ from astropy.io import fits
396
+
397
+ # Convert BGR to grayscale for astronomy (luminance)
398
+ gray = self._cv2_module.cvtColor(frame, self._cv2_module.COLOR_BGR2GRAY)
399
+
400
+ hdu = fits.PrimaryHDU(gray.astype(np.uint16))
401
+ hdu.header["INSTRUME"] = "USB Camera"
402
+ hdu.header["CAMERA"] = f"Index {self.camera_index}"
403
+ hdu.writeto(save_path, overwrite=True)
404
+
405
+ except ImportError:
406
+ self.logger.error("astropy not installed. Install with: pip install astropy")
407
+ raise