citrascope 0.7.0__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 (44) 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 +80 -2
  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 +46 -37
  24. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  25. citrascope/settings/citrascope_settings.py +6 -0
  26. citrascope/tasks/runner.py +2 -0
  27. citrascope/tasks/scope/static_telescope_task.py +17 -12
  28. citrascope/tasks/task.py +3 -0
  29. citrascope/time/__init__.py +13 -0
  30. citrascope/time/time_health.py +96 -0
  31. citrascope/time/time_monitor.py +164 -0
  32. citrascope/time/time_sources.py +62 -0
  33. citrascope/web/app.py +229 -51
  34. citrascope/web/static/app.js +296 -36
  35. citrascope/web/static/config.js +216 -81
  36. citrascope/web/static/filters.js +55 -0
  37. citrascope/web/static/style.css +39 -0
  38. citrascope/web/templates/dashboard.html +114 -9
  39. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  40. citrascope-0.8.0.dist-info/RECORD +62 -0
  41. citrascope-0.7.0.dist-info/RECORD +0 -41
  42. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  43. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  44. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,273 @@
1
+ """Device adapter registry.
2
+
3
+ This module provides a centralized registry for all device adapters.
4
+ Similar to the hardware adapter registry, but for individual device types.
5
+ """
6
+
7
+ import importlib
8
+ from typing import Any, Dict, Type
9
+
10
+ from citrascope.hardware.devices.camera import AbstractCamera
11
+ from citrascope.hardware.devices.filter_wheel import AbstractFilterWheel
12
+ from citrascope.hardware.devices.focuser import AbstractFocuser
13
+ from citrascope.hardware.devices.mount import AbstractMount
14
+
15
+ # Registry of available camera devices
16
+ CAMERA_DEVICES: Dict[str, Dict[str, str]] = {
17
+ "ximea": {
18
+ "module": "citrascope.hardware.devices.camera.ximea_camera",
19
+ "class_name": "XimeaHyperspectralCamera",
20
+ "description": "Ximea Hyperspectral Camera (MQ series)",
21
+ },
22
+ "rpi_hq": {
23
+ "module": "citrascope.hardware.devices.camera.rpi_hq_camera",
24
+ "class_name": "RaspberryPiHQCamera",
25
+ "description": "Raspberry Pi High Quality Camera (IMX477)",
26
+ },
27
+ "usb_camera": {
28
+ "module": "citrascope.hardware.devices.camera.usb_camera",
29
+ "class_name": "UsbCamera",
30
+ "description": "USB Camera via OpenCV (guide cameras, planetary cameras, etc.)",
31
+ },
32
+ # Future cameras:
33
+ # "zwo": {...},
34
+ # "ascom": {...},
35
+ # "qhy": {...},
36
+ }
37
+
38
+ # Registry of available mount devices
39
+ MOUNT_DEVICES: Dict[str, Dict[str, str]] = {
40
+ # Future mounts:
41
+ # "celestron": {...},
42
+ # "skywatcher": {...},
43
+ # "ascom": {...},
44
+ }
45
+
46
+ # Registry of available filter wheel devices
47
+ FILTER_WHEEL_DEVICES: Dict[str, Dict[str, str]] = {
48
+ # Future filter wheels:
49
+ # "zwo": {...},
50
+ # "ascom": {...},
51
+ }
52
+
53
+ # Registry of available focuser devices
54
+ FOCUSER_DEVICES: Dict[str, Dict[str, str]] = {
55
+ # Future focusers:
56
+ # "moonlite": {...},
57
+ # "ascom": {...},
58
+ }
59
+
60
+
61
+ def get_camera_class(camera_type: str) -> Type[AbstractCamera]:
62
+ """Get the camera class for the given camera type.
63
+
64
+ Args:
65
+ camera_type: The type of camera (e.g., "ximea", "zwo")
66
+
67
+ Returns:
68
+ The camera adapter class
69
+
70
+ Raises:
71
+ ValueError: If the camera type is not registered
72
+ ImportError: If the camera module cannot be imported
73
+ """
74
+ if camera_type not in CAMERA_DEVICES:
75
+ available = ", ".join(f"'{name}'" for name in CAMERA_DEVICES.keys())
76
+ raise ValueError(f"Unknown camera type: '{camera_type}'. Valid options are: {available}")
77
+
78
+ device_info = CAMERA_DEVICES[camera_type]
79
+ module = importlib.import_module(device_info["module"])
80
+ device_class = getattr(module, device_info["class_name"])
81
+
82
+ return device_class
83
+
84
+
85
+ def get_mount_class(mount_type: str) -> Type[AbstractMount]:
86
+ """Get the mount class for the given mount type.
87
+
88
+ Args:
89
+ mount_type: The type of mount
90
+
91
+ Returns:
92
+ The mount adapter class
93
+
94
+ Raises:
95
+ ValueError: If the mount type is not registered
96
+ ImportError: If the mount module cannot be imported
97
+ """
98
+ if mount_type not in MOUNT_DEVICES:
99
+ available = ", ".join(f"'{name}'" for name in MOUNT_DEVICES.keys())
100
+ raise ValueError(f"Unknown mount type: '{mount_type}'. Valid options are: {available}")
101
+
102
+ device_info = MOUNT_DEVICES[mount_type]
103
+ module = importlib.import_module(device_info["module"])
104
+ device_class = getattr(module, device_info["class_name"])
105
+
106
+ return device_class
107
+
108
+
109
+ def get_filter_wheel_class(filter_wheel_type: str) -> Type[AbstractFilterWheel]:
110
+ """Get the filter wheel class for the given filter wheel type.
111
+
112
+ Args:
113
+ filter_wheel_type: The type of filter wheel
114
+
115
+ Returns:
116
+ The filter wheel adapter class
117
+
118
+ Raises:
119
+ ValueError: If the filter wheel type is not registered
120
+ ImportError: If the filter wheel module cannot be imported
121
+ """
122
+ if filter_wheel_type not in FILTER_WHEEL_DEVICES:
123
+ available = ", ".join(f"'{name}'" for name in FILTER_WHEEL_DEVICES.keys())
124
+ raise ValueError(f"Unknown filter wheel type: '{filter_wheel_type}'. Valid options are: {available}")
125
+
126
+ device_info = FILTER_WHEEL_DEVICES[filter_wheel_type]
127
+ module = importlib.import_module(device_info["module"])
128
+ device_class = getattr(module, device_info["class_name"])
129
+
130
+ return device_class
131
+
132
+
133
+ def get_focuser_class(focuser_type: str) -> Type[AbstractFocuser]:
134
+ """Get the focuser class for the given focuser type.
135
+
136
+ Args:
137
+ focuser_type: The type of focuser
138
+
139
+ Returns:
140
+ The focuser adapter class
141
+
142
+ Raises:
143
+ ValueError: If the focuser type is not registered
144
+ ImportError: If the focuser module cannot be imported
145
+ """
146
+ if focuser_type not in FOCUSER_DEVICES:
147
+ available = ", ".join(f"'{name}'" for name in FOCUSER_DEVICES.keys())
148
+ raise ValueError(f"Unknown focuser type: '{focuser_type}'. Valid options are: {available}")
149
+
150
+ device_info = FOCUSER_DEVICES[focuser_type]
151
+ module = importlib.import_module(device_info["module"])
152
+ device_class = getattr(module, device_info["class_name"])
153
+
154
+ return device_class
155
+
156
+
157
+ def list_devices(device_type: str) -> Dict[str, Dict[str, str]]:
158
+ """Get a dictionary of all registered devices of a specific type.
159
+
160
+ Args:
161
+ device_type: Type of device ("camera", "mount", "filter_wheel", "focuser")
162
+
163
+ Returns:
164
+ Dict mapping device names to their info including friendly_name
165
+ """
166
+ registries = {
167
+ "camera": CAMERA_DEVICES,
168
+ "mount": MOUNT_DEVICES,
169
+ "filter_wheel": FILTER_WHEEL_DEVICES,
170
+ "focuser": FOCUSER_DEVICES,
171
+ }
172
+
173
+ registry = registries.get(device_type, {})
174
+ result = {}
175
+
176
+ for name, info in registry.items():
177
+ # Try to get friendly name from device class
178
+ try:
179
+ if device_type == "camera":
180
+ device_class = get_camera_class(name)
181
+ elif device_type == "mount":
182
+ device_class = get_mount_class(name)
183
+ elif device_type == "filter_wheel":
184
+ device_class = get_filter_wheel_class(name)
185
+ elif device_type == "focuser":
186
+ device_class = get_focuser_class(name)
187
+ else:
188
+ continue
189
+
190
+ friendly_name = device_class.get_friendly_name()
191
+ except Exception:
192
+ # Fallback to description if friendly_name not available
193
+ friendly_name = info["description"]
194
+
195
+ result[name] = {
196
+ "friendly_name": friendly_name,
197
+ "description": info["description"],
198
+ "module": info["module"],
199
+ "class_name": info["class_name"],
200
+ }
201
+
202
+ return result
203
+
204
+
205
+ def get_device_schema(device_type: str, device_name: str) -> list:
206
+ """Get the configuration schema for a specific device.
207
+
208
+ Args:
209
+ device_type: Type of device ("camera", "mount", "filter_wheel", "focuser")
210
+ device_name: The name of the device (e.g., "ximea", "celestron")
211
+
212
+ Returns:
213
+ The device's settings schema
214
+
215
+ Raises:
216
+ ValueError: If the device type or name is not registered
217
+ ImportError: If the device module cannot be imported
218
+ """
219
+ if device_type == "camera":
220
+ device_class = get_camera_class(device_name)
221
+ elif device_type == "mount":
222
+ device_class = get_mount_class(device_name)
223
+ elif device_type == "filter_wheel":
224
+ device_class = get_filter_wheel_class(device_name)
225
+ elif device_type == "focuser":
226
+ device_class = get_focuser_class(device_name)
227
+ else:
228
+ raise ValueError(f"Unknown device type: '{device_type}'")
229
+
230
+ return device_class.get_settings_schema()
231
+
232
+
233
+ def check_dependencies(device_class: Type[Any]) -> dict[str, Any]:
234
+ """Check if dependencies for a device are available.
235
+
236
+ Args:
237
+ device_class: Device class to check
238
+
239
+ Returns:
240
+ Dict with keys:
241
+ - available (bool): True if all dependencies installed
242
+ - missing (list[str]): List of missing package names
243
+ - install_cmd (str): Command to install missing packages
244
+ """
245
+ import time
246
+
247
+ start_time = time.time()
248
+
249
+ deps = device_class.get_dependencies()
250
+ packages = deps.get("packages", [])
251
+ install_extra = deps.get("install_extra", "")
252
+
253
+ missing = []
254
+ for package in packages:
255
+ try:
256
+ importlib.import_module(package)
257
+ except ImportError:
258
+ missing.append(package)
259
+
260
+ available = len(missing) == 0
261
+ install_cmd = f"pip install citrascope[{install_extra}]" if install_extra else f"pip install {' '.join(missing)}"
262
+
263
+ elapsed = time.time() - start_time
264
+ if elapsed > 0.05: # Log if takes more than 50ms
265
+ from citrascope.logging import CITRASCOPE_LOGGER
266
+
267
+ CITRASCOPE_LOGGER.info(f"Dependency check for {device_class.__name__} took {elapsed:.3f}s")
268
+
269
+ return {
270
+ "available": available,
271
+ "missing": missing,
272
+ "install_cmd": install_cmd,
273
+ }
@@ -0,0 +1,7 @@
1
+ """Filter wheel device adapters."""
2
+
3
+ from citrascope.hardware.devices.filter_wheel.abstract_filter_wheel import AbstractFilterWheel
4
+
5
+ __all__ = [
6
+ "AbstractFilterWheel",
7
+ ]
@@ -0,0 +1,73 @@
1
+ """Abstract filter wheel device interface."""
2
+
3
+ from abc import abstractmethod
4
+ from typing import Optional
5
+
6
+ from citrascope.hardware.devices.abstract_hardware_device import AbstractHardwareDevice
7
+
8
+
9
+ class AbstractFilterWheel(AbstractHardwareDevice):
10
+ """Abstract base class for filter wheel devices.
11
+
12
+ Provides a common interface for controlling motorized filter wheels.
13
+ """
14
+
15
+ @abstractmethod
16
+ def set_filter_position(self, position: int) -> bool:
17
+ """Move to specified filter position.
18
+
19
+ Args:
20
+ position: Filter position (0-indexed)
21
+
22
+ Returns:
23
+ True if move initiated successfully, False otherwise
24
+ """
25
+ pass
26
+
27
+ @abstractmethod
28
+ def get_filter_position(self) -> Optional[int]:
29
+ """Get current filter position.
30
+
31
+ Returns:
32
+ Current filter position (0-indexed), or None if unavailable
33
+ """
34
+ pass
35
+
36
+ @abstractmethod
37
+ def is_moving(self) -> bool:
38
+ """Check if filter wheel is currently moving.
39
+
40
+ Returns:
41
+ True if moving, False if stationary
42
+ """
43
+ pass
44
+
45
+ @abstractmethod
46
+ def get_filter_count(self) -> int:
47
+ """Get the number of filter positions.
48
+
49
+ Returns:
50
+ Number of available filter positions
51
+ """
52
+ pass
53
+
54
+ @abstractmethod
55
+ def get_filter_names(self) -> list[str]:
56
+ """Get the names of all filters.
57
+
58
+ Returns:
59
+ List of filter names for each position
60
+ """
61
+ pass
62
+
63
+ @abstractmethod
64
+ def set_filter_names(self, names: list[str]) -> bool:
65
+ """Set the names for all filter positions.
66
+
67
+ Args:
68
+ names: List of filter names (must match filter count)
69
+
70
+ Returns:
71
+ True if names set successfully, False otherwise
72
+ """
73
+ pass
@@ -0,0 +1,7 @@
1
+ """Focuser device adapters."""
2
+
3
+ from citrascope.hardware.devices.focuser.abstract_focuser import AbstractFocuser
4
+
5
+ __all__ = [
6
+ "AbstractFocuser",
7
+ ]
@@ -0,0 +1,78 @@
1
+ """Abstract focuser device interface."""
2
+
3
+ from abc import abstractmethod
4
+ from typing import Optional
5
+
6
+ from citrascope.hardware.devices.abstract_hardware_device import AbstractHardwareDevice
7
+
8
+
9
+ class AbstractFocuser(AbstractHardwareDevice):
10
+ """Abstract base class for focuser devices.
11
+
12
+ Provides a common interface for controlling motorized focusers.
13
+ """
14
+
15
+ @abstractmethod
16
+ def move_absolute(self, position: int) -> bool:
17
+ """Move to absolute focuser position.
18
+
19
+ Args:
20
+ position: Target position in steps
21
+
22
+ Returns:
23
+ True if move initiated successfully, False otherwise
24
+ """
25
+ pass
26
+
27
+ @abstractmethod
28
+ def move_relative(self, steps: int) -> bool:
29
+ """Move focuser by relative number of steps.
30
+
31
+ Args:
32
+ steps: Number of steps to move (positive=outward, negative=inward)
33
+
34
+ Returns:
35
+ True if move initiated successfully, False otherwise
36
+ """
37
+ pass
38
+
39
+ @abstractmethod
40
+ def get_position(self) -> Optional[int]:
41
+ """Get current focuser position.
42
+
43
+ Returns:
44
+ Current position in steps, or None if unavailable
45
+ """
46
+ pass
47
+
48
+ @abstractmethod
49
+ def is_moving(self) -> bool:
50
+ """Check if focuser is currently moving.
51
+
52
+ Returns:
53
+ True if moving, False if stationary
54
+ """
55
+ pass
56
+
57
+ @abstractmethod
58
+ def abort_move(self):
59
+ """Stop the current focuser movement."""
60
+ pass
61
+
62
+ @abstractmethod
63
+ def get_max_position(self) -> Optional[int]:
64
+ """Get the maximum focuser position.
65
+
66
+ Returns:
67
+ Maximum position in steps, or None if unlimited/unknown
68
+ """
69
+ pass
70
+
71
+ @abstractmethod
72
+ def get_temperature(self) -> Optional[float]:
73
+ """Get focuser temperature reading if available.
74
+
75
+ Returns:
76
+ Temperature in degrees Celsius, or None if not available
77
+ """
78
+ pass
@@ -0,0 +1,7 @@
1
+ """Mount device adapters."""
2
+
3
+ from citrascope.hardware.devices.mount.abstract_mount import AbstractMount
4
+
5
+ __all__ = [
6
+ "AbstractMount",
7
+ ]
@@ -0,0 +1,115 @@
1
+ """Abstract mount device interface."""
2
+
3
+ from abc import abstractmethod
4
+ from typing import Optional, Tuple
5
+
6
+ from citrascope.hardware.devices.abstract_hardware_device import AbstractHardwareDevice
7
+
8
+
9
+ class AbstractMount(AbstractHardwareDevice):
10
+ """Abstract base class for telescope mount devices.
11
+
12
+ Provides a common interface for controlling equatorial and alt-az mounts.
13
+ """
14
+
15
+ @abstractmethod
16
+ def slew_to_radec(self, ra: float, dec: float) -> bool:
17
+ """Slew the mount to specified RA/Dec coordinates.
18
+
19
+ Args:
20
+ ra: Right Ascension in degrees
21
+ dec: Declination in degrees
22
+
23
+ Returns:
24
+ True if slew initiated successfully, False otherwise
25
+ """
26
+ pass
27
+
28
+ @abstractmethod
29
+ def is_slewing(self) -> bool:
30
+ """Check if mount is currently slewing.
31
+
32
+ Returns:
33
+ True if slewing, False if stationary or tracking
34
+ """
35
+ pass
36
+
37
+ @abstractmethod
38
+ def abort_slew(self):
39
+ """Stop the current slew operation."""
40
+ pass
41
+
42
+ @abstractmethod
43
+ def get_radec(self) -> Tuple[float, float]:
44
+ """Get current mount RA/Dec position.
45
+
46
+ Returns:
47
+ Tuple of (RA in degrees, Dec in degrees)
48
+ """
49
+ pass
50
+
51
+ @abstractmethod
52
+ def start_tracking(self, rate: Optional[str] = "sidereal") -> bool:
53
+ """Start tracking at specified rate.
54
+
55
+ Args:
56
+ rate: Tracking rate - "sidereal", "lunar", "solar", or device-specific
57
+
58
+ Returns:
59
+ True if tracking started successfully, False otherwise
60
+ """
61
+ pass
62
+
63
+ @abstractmethod
64
+ def stop_tracking(self) -> bool:
65
+ """Stop tracking.
66
+
67
+ Returns:
68
+ True if tracking stopped successfully, False otherwise
69
+ """
70
+ pass
71
+
72
+ @abstractmethod
73
+ def is_tracking(self) -> bool:
74
+ """Check if mount is currently tracking.
75
+
76
+ Returns:
77
+ True if tracking, False otherwise
78
+ """
79
+ pass
80
+
81
+ @abstractmethod
82
+ def park(self) -> bool:
83
+ """Park the mount to its home position.
84
+
85
+ Returns:
86
+ True if park initiated successfully, False otherwise
87
+ """
88
+ pass
89
+
90
+ @abstractmethod
91
+ def unpark(self) -> bool:
92
+ """Unpark the mount from its home position.
93
+
94
+ Returns:
95
+ True if unpark successful, False otherwise
96
+ """
97
+ pass
98
+
99
+ @abstractmethod
100
+ def is_parked(self) -> bool:
101
+ """Check if mount is parked.
102
+
103
+ Returns:
104
+ True if parked, False otherwise
105
+ """
106
+ pass
107
+
108
+ @abstractmethod
109
+ def get_mount_info(self) -> dict:
110
+ """Get mount capabilities and information.
111
+
112
+ Returns:
113
+ Dictionary containing mount specs and capabilities
114
+ """
115
+ pass