citrascope 0.7.0__tar.gz → 0.8.0__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.
- {citrascope-0.7.0 → citrascope-0.8.0}/.github/copilot-instructions.md +1 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/PKG-INFO +17 -1
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/api/abstract_api_client.py +14 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/api/citra_api_client.py +41 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/citra_scope_daemon.py +75 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/abstract_astro_hardware_adapter.py +80 -2
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/adapter_registry.py +10 -3
- citrascope-0.8.0/citrascope/hardware/devices/__init__.py +17 -0
- citrascope-0.8.0/citrascope/hardware/devices/abstract_hardware_device.py +79 -0
- citrascope-0.8.0/citrascope/hardware/devices/camera/__init__.py +13 -0
- citrascope-0.8.0/citrascope/hardware/devices/camera/abstract_camera.py +102 -0
- citrascope-0.8.0/citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope-0.8.0/citrascope/hardware/devices/camera/usb_camera.py +402 -0
- citrascope-0.8.0/citrascope/hardware/devices/camera/ximea_camera.py +744 -0
- citrascope-0.8.0/citrascope/hardware/devices/device_registry.py +273 -0
- citrascope-0.8.0/citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
- citrascope-0.8.0/citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
- citrascope-0.8.0/citrascope/hardware/devices/focuser/__init__.py +7 -0
- citrascope-0.8.0/citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
- citrascope-0.8.0/citrascope/hardware/devices/mount/__init__.py +7 -0
- citrascope-0.8.0/citrascope/hardware/devices/mount/abstract_mount.py +115 -0
- citrascope-0.8.0/citrascope/hardware/direct_hardware_adapter.py +787 -0
- citrascope-0.8.0/citrascope/hardware/filter_sync.py +94 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/indi_adapter.py +6 -2
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/kstars_dbus_adapter.py +46 -37
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/nina_adv_http_adapter.py +13 -11
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/settings/citrascope_settings.py +6 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/tasks/runner.py +2 -0
- citrascope-0.8.0/citrascope/tasks/scope/static_telescope_task.py +34 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/tasks/task.py +3 -0
- citrascope-0.8.0/citrascope/time/__init__.py +13 -0
- citrascope-0.8.0/citrascope/time/time_health.py +96 -0
- citrascope-0.8.0/citrascope/time/time_monitor.py +164 -0
- citrascope-0.8.0/citrascope/time/time_sources.py +62 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/app.py +229 -51
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/static/app.js +296 -36
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/static/config.js +216 -81
- citrascope-0.8.0/citrascope/web/static/filters.js +55 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/static/style.css +39 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/templates/dashboard.html +114 -9
- {citrascope-0.7.0 → citrascope-0.8.0}/pyproject.toml +22 -2
- citrascope-0.8.0/tests/unit/test_device_dependencies.py +232 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/tests/unit/utils.py +8 -0
- citrascope-0.7.0/citrascope/tasks/scope/static_telescope_task.py +0 -29
- {citrascope-0.7.0 → citrascope-0.8.0}/.devcontainer/devcontainer.json +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/.flake8 +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/.github/dependabot.yml +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/.github/workflows/pypi-publish.yml +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/.github/workflows/pytest.yml +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/.gitignore +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/.pre-commit-config.yaml +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/.python-version +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/.vscode/launch.json +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/LICENSE +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/README.md +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/__init__.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/__main__.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/constants.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/kstars_scheduler_template.esl +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/kstars_sequence_template.esq +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/nina_adv_http_survey_template.json +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/logging/__init__.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/logging/_citrascope_logger.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/logging/web_log_handler.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/settings/__init__.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/settings/settings_file_manager.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/tasks/scope/base_telescope_task.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/tasks/scope/tracking_telescope_task.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/__init__.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/server.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/static/api.js +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/static/img/citra.png +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/static/img/favicon.png +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/citrascope/web/static/websocket.js +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/tests/unit/test_api_client.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/tests/unit/test_hardware_adapter.py +0 -0
- {citrascope-0.7.0 → citrascope-0.8.0}/tests/unit/test_task_manager.py +0 -0
|
@@ -41,6 +41,7 @@ This project is a Python package for interacting with astronomical data and serv
|
|
|
41
41
|
|
|
42
42
|
## Common Tasks
|
|
43
43
|
- Add new API integrations in `citrascope/api/`.
|
|
44
|
+
- Reference the [DEV Citra.space API documentation](https://dev.api.citra.space/docs) for endpoint specifications and data models
|
|
44
45
|
- Extend or add hardware adapters:
|
|
45
46
|
- Create new adapter class implementing `AbstractAstroHardwareAdapter` in `citrascope/hardware/`
|
|
46
47
|
- Register it in `citrascope/hardware/adapter_registry.py` by adding an entry to `REGISTERED_ADAPTERS`
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: citrascope
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
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: ntplib>=0.4.0
|
|
28
29
|
Requires-Dist: platformdirs>=4.0.0
|
|
29
30
|
Requires-Dist: python-dateutil
|
|
30
31
|
Requires-Dist: requests
|
|
@@ -36,6 +37,14 @@ Requires-Dist: dbus-python; extra == 'all'
|
|
|
36
37
|
Requires-Dist: pixelemon; extra == 'all'
|
|
37
38
|
Requires-Dist: plotly; extra == 'all'
|
|
38
39
|
Requires-Dist: pyindi-client; extra == 'all'
|
|
40
|
+
Provides-Extra: all-hardware
|
|
41
|
+
Requires-Dist: cv2-enumerate-cameras>=1.0.0; extra == 'all-hardware'
|
|
42
|
+
Requires-Dist: opencv-python>=4.8.0; extra == 'all-hardware'
|
|
43
|
+
Requires-Dist: picamera2>=0.3.12; extra == 'all-hardware'
|
|
44
|
+
Requires-Dist: pixelemon; extra == 'all-hardware'
|
|
45
|
+
Requires-Dist: plotly; extra == 'all-hardware'
|
|
46
|
+
Requires-Dist: pyindi-client; extra == 'all-hardware'
|
|
47
|
+
Requires-Dist: ximea-api>=1.0.0; extra == 'all-hardware'
|
|
39
48
|
Provides-Extra: build
|
|
40
49
|
Requires-Dist: build; extra == 'build'
|
|
41
50
|
Provides-Extra: deploy
|
|
@@ -58,10 +67,17 @@ Requires-Dist: plotly; extra == 'indi'
|
|
|
58
67
|
Requires-Dist: pyindi-client; extra == 'indi'
|
|
59
68
|
Provides-Extra: kstars
|
|
60
69
|
Requires-Dist: dbus-python; extra == 'kstars'
|
|
70
|
+
Provides-Extra: rpi
|
|
71
|
+
Requires-Dist: picamera2>=0.3.12; extra == 'rpi'
|
|
61
72
|
Provides-Extra: test
|
|
62
73
|
Requires-Dist: mockito; extra == 'test'
|
|
63
74
|
Requires-Dist: pytest; extra == 'test'
|
|
64
75
|
Requires-Dist: pytest-cov; extra == 'test'
|
|
76
|
+
Provides-Extra: usb-camera
|
|
77
|
+
Requires-Dist: cv2-enumerate-cameras>=1.0.0; extra == 'usb-camera'
|
|
78
|
+
Requires-Dist: opencv-python>=4.8.0; extra == 'usb-camera'
|
|
79
|
+
Provides-Extra: ximea
|
|
80
|
+
Requires-Dist: ximea-api>=1.0.0; extra == 'ximea'
|
|
65
81
|
Description-Content-Type: text/markdown
|
|
66
82
|
|
|
67
83
|
# CitraScope
|
|
@@ -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:
|
{citrascope-0.7.0 → citrascope-0.8.0}/citrascope/hardware/abstract_astro_hardware_adapter.py
RENAMED
|
@@ -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,
|
|
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,83 @@ class AbstractAstroHardwareAdapter(ABC):
|
|
|
233
234
|
"""
|
|
234
235
|
return False
|
|
235
236
|
|
|
237
|
+
def is_hyperspectral(self) -> bool:
|
|
238
|
+
"""Indicates whether this adapter uses a hyperspectral camera.
|
|
239
|
+
|
|
240
|
+
Hyperspectral cameras capture multiple spectral bands simultaneously
|
|
241
|
+
(e.g., snapshot mosaic sensors) and do not require discrete filter changes.
|
|
242
|
+
|
|
243
|
+
Returns:
|
|
244
|
+
bool: True if using hyperspectral imaging, False otherwise (default)
|
|
245
|
+
"""
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
def select_filters_for_task(self, task, allow_no_filter: bool = False) -> dict | None:
|
|
249
|
+
"""Select which filters to use for a task based on assignment.
|
|
250
|
+
|
|
251
|
+
This method handles the common logic for filter selection:
|
|
252
|
+
- If task specifies assigned_filter_name, find and validate that filter
|
|
253
|
+
- If no filter specified, look for Clear/Luminance filter (case-insensitive)
|
|
254
|
+
- Fall back to first enabled filter, or None if allow_no_filter=True
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
task: Task object with optional assigned_filter_name field
|
|
258
|
+
allow_no_filter: If True, return None when no filters available (for KStars '--')
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
dict: Dictionary mapping filter IDs to filter info {id: {name, focus_position, enabled}}
|
|
262
|
+
Returns None only if allow_no_filter=True and no suitable filter found
|
|
263
|
+
|
|
264
|
+
Raises:
|
|
265
|
+
RuntimeError: If assigned filter not found, disabled, or no filters available when required
|
|
266
|
+
"""
|
|
267
|
+
# Task specifies a specific filter - find it
|
|
268
|
+
if task and task.assigned_filter_name:
|
|
269
|
+
target_filter_id = None
|
|
270
|
+
target_filter_info = None
|
|
271
|
+
for fid, fdata in self.filter_map.items():
|
|
272
|
+
# Case-insensitive comparison
|
|
273
|
+
if fdata["name"].lower() == task.assigned_filter_name.lower():
|
|
274
|
+
if not fdata.get("enabled", True):
|
|
275
|
+
raise RuntimeError(
|
|
276
|
+
f"Requested filter '{task.assigned_filter_name}' is disabled for task {task.id}"
|
|
277
|
+
)
|
|
278
|
+
target_filter_id = fid
|
|
279
|
+
target_filter_info = fdata
|
|
280
|
+
break
|
|
281
|
+
|
|
282
|
+
if target_filter_id is None:
|
|
283
|
+
raise RuntimeError(
|
|
284
|
+
f"Requested filter '{task.assigned_filter_name}' not found in filter map for task {task.id}"
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
task_id_str = task.id if task else "unknown"
|
|
288
|
+
self.logger.info(f"Using filter '{task.assigned_filter_name}' for task {task_id_str}")
|
|
289
|
+
return {target_filter_id: target_filter_info}
|
|
290
|
+
|
|
291
|
+
# No filter specified - look for Clear or Luminance (case-insensitive)
|
|
292
|
+
clear_filter_names = ["clear", "luminance", "lum", "l"]
|
|
293
|
+
for fid, fdata in self.filter_map.items():
|
|
294
|
+
if fdata.get("enabled", True) and fdata["name"].lower() in clear_filter_names:
|
|
295
|
+
task_id_str = task.id if task else "unknown"
|
|
296
|
+
self.logger.info(f"Using default filter '{fdata['name']}' for task {task_id_str}")
|
|
297
|
+
return {fid: fdata}
|
|
298
|
+
|
|
299
|
+
# No clear filter found - try first enabled filter
|
|
300
|
+
enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
|
|
301
|
+
if enabled_filters:
|
|
302
|
+
first_filter_id = next(iter(enabled_filters))
|
|
303
|
+
task_id_str = task.id if task else "unknown"
|
|
304
|
+
self.logger.info(
|
|
305
|
+
f"Using first available filter '{enabled_filters[first_filter_id]['name']}' for task {task_id_str}"
|
|
306
|
+
)
|
|
307
|
+
return {first_filter_id: enabled_filters[first_filter_id]}
|
|
308
|
+
|
|
309
|
+
# No enabled filters available
|
|
310
|
+
if allow_no_filter:
|
|
311
|
+
return None
|
|
312
|
+
raise RuntimeError("No enabled filters available for observation sequence")
|
|
313
|
+
|
|
236
314
|
def get_filter_config(self) -> dict[str, FilterConfig]:
|
|
237
315
|
"""Get the current filter configuration including focus positions.
|
|
238
316
|
|
|
@@ -33,6 +33,11 @@ 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
|
+
},
|
|
36
41
|
}
|
|
37
42
|
|
|
38
43
|
|
|
@@ -76,11 +81,13 @@ def list_adapters() -> Dict[str, Dict[str, str]]:
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
|
|
79
|
-
def get_adapter_schema(adapter_name: str) -> list:
|
|
84
|
+
def get_adapter_schema(adapter_name: str, **kwargs) -> list:
|
|
80
85
|
"""Get the configuration schema for a specific adapter.
|
|
81
86
|
|
|
82
87
|
Args:
|
|
83
88
|
adapter_name: The name of the adapter
|
|
89
|
+
**kwargs: Additional arguments to pass to the adapter's get_settings_schema method
|
|
90
|
+
(e.g., current settings for dynamic schema generation)
|
|
84
91
|
|
|
85
92
|
Returns:
|
|
86
93
|
The adapter's settings schema
|
|
@@ -90,5 +97,5 @@ def get_adapter_schema(adapter_name: str) -> list:
|
|
|
90
97
|
ImportError: If the adapter module cannot be imported
|
|
91
98
|
"""
|
|
92
99
|
adapter_class = get_adapter_class(adapter_name)
|
|
93
|
-
# Call classmethod
|
|
94
|
-
return adapter_class.get_settings_schema()
|
|
100
|
+
# Call classmethod, passing kwargs for dynamic schemas
|
|
101
|
+
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,102 @@
|
|
|
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
|