citrascope 0.6.1__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.6.1 → citrascope-0.8.0}/.github/copilot-instructions.md +1 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/PKG-INFO +17 -1
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/api/abstract_api_client.py +14 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/api/citra_api_client.py +41 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/citra_scope_daemon.py +97 -38
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
- {citrascope-0.6.1 → 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.6.1 → citrascope-0.8.0}/citrascope/hardware/indi_adapter.py +6 -2
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/kstars_dbus_adapter.py +67 -96
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/nina_adv_http_adapter.py +81 -64
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/settings/citrascope_settings.py +25 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/tasks/runner.py +105 -0
- citrascope-0.8.0/citrascope/tasks/scope/static_telescope_task.py +34 -0
- {citrascope-0.6.1 → 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.6.1 → citrascope-0.8.0}/citrascope/web/app.py +274 -51
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/app.js +379 -36
- citrascope-0.8.0/citrascope/web/static/config.js +941 -0
- citrascope-0.8.0/citrascope/web/static/filters.js +55 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/style.css +39 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/templates/dashboard.html +176 -36
- {citrascope-0.6.1 → citrascope-0.8.0}/pyproject.toml +22 -2
- citrascope-0.8.0/tests/unit/test_device_dependencies.py +232 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/tests/unit/utils.py +8 -0
- citrascope-0.6.1/citrascope/tasks/scope/static_telescope_task.py +0 -29
- citrascope-0.6.1/citrascope/web/static/config.js +0 -601
- {citrascope-0.6.1 → citrascope-0.8.0}/.devcontainer/devcontainer.json +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/.flake8 +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/.github/dependabot.yml +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/.github/workflows/pypi-publish.yml +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/.github/workflows/pytest.yml +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/.gitignore +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/.pre-commit-config.yaml +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/.python-version +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/.vscode/launch.json +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/LICENSE +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/README.md +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/__init__.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/__main__.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/constants.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/kstars_scheduler_template.esl +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/kstars_sequence_template.esq +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/logging/__init__.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/logging/_citrascope_logger.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/logging/web_log_handler.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/settings/__init__.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/settings/settings_file_manager.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/tasks/scope/base_telescope_task.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/tasks/scope/tracking_telescope_task.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/__init__.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/server.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/api.js +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/img/citra.png +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/img/favicon.png +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/websocket.js +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/tests/unit/test_api_client.py +0 -0
- {citrascope-0.6.1 → citrascope-0.8.0}/tests/unit/test_hardware_adapter.py +0 -0
- {citrascope-0.6.1 → 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,10 +35,10 @@ 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
|
|
38
|
-
self._autofocus_in_progress = False
|
|
39
42
|
|
|
40
43
|
# Create web server instance (always enabled)
|
|
41
44
|
self.web_server = CitraScopeWebServer(daemon=self, host="0.0.0.0", port=self.settings.web_port)
|
|
@@ -90,6 +93,10 @@ class CitraScopeDaemon:
|
|
|
90
93
|
self.task_manager.stop()
|
|
91
94
|
self.task_manager = None
|
|
92
95
|
|
|
96
|
+
if self.time_monitor:
|
|
97
|
+
self.time_monitor.stop()
|
|
98
|
+
self.time_monitor = None
|
|
99
|
+
|
|
93
100
|
if self.hardware_adapter:
|
|
94
101
|
try:
|
|
95
102
|
self.hardware_adapter.disconnect()
|
|
@@ -115,6 +122,16 @@ class CitraScopeDaemon:
|
|
|
115
122
|
# Initialize hardware adapter
|
|
116
123
|
self.hardware_adapter = self._create_hardware_adapter()
|
|
117
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
|
+
|
|
118
135
|
# Initialize telescope
|
|
119
136
|
success, error = self._initialize_telescope()
|
|
120
137
|
|
|
@@ -180,6 +197,8 @@ class CitraScopeDaemon:
|
|
|
180
197
|
|
|
181
198
|
# Save filter configuration if adapter supports it
|
|
182
199
|
self._save_filter_config()
|
|
200
|
+
# Sync discovered filters to backend on startup
|
|
201
|
+
self._sync_filters_to_backend()
|
|
183
202
|
|
|
184
203
|
self.task_manager = TaskManager(
|
|
185
204
|
self.api_client,
|
|
@@ -192,6 +211,15 @@ class CitraScopeDaemon:
|
|
|
192
211
|
)
|
|
193
212
|
self.task_manager.start()
|
|
194
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
|
+
|
|
195
223
|
CITRASCOPE_LOGGER.info("Telescope initialized successfully!")
|
|
196
224
|
return True, None
|
|
197
225
|
|
|
@@ -200,14 +228,6 @@ class CitraScopeDaemon:
|
|
|
200
228
|
CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
|
|
201
229
|
return False, error_msg
|
|
202
230
|
|
|
203
|
-
def is_autofocus_in_progress(self) -> bool:
|
|
204
|
-
"""Check if autofocus routine is currently running.
|
|
205
|
-
|
|
206
|
-
Returns:
|
|
207
|
-
bool: True if autofocus is in progress, False otherwise
|
|
208
|
-
"""
|
|
209
|
-
return self._autofocus_in_progress
|
|
210
|
-
|
|
211
231
|
def _save_filter_config(self):
|
|
212
232
|
"""Save filter configuration from adapter to settings if supported.
|
|
213
233
|
|
|
@@ -216,6 +236,9 @@ class CitraScopeDaemon:
|
|
|
216
236
|
- After autofocus to save updated focus positions
|
|
217
237
|
- After manual filter focus updates via web API
|
|
218
238
|
|
|
239
|
+
Note: This only saves locally. Call _sync_filters_to_backend() separately
|
|
240
|
+
when enabled filters change to update the backend.
|
|
241
|
+
|
|
219
242
|
Thread safety: This modifies self.settings and writes to disk.
|
|
220
243
|
Should be called from main daemon thread or properly synchronized.
|
|
221
244
|
"""
|
|
@@ -231,13 +254,24 @@ class CitraScopeDaemon:
|
|
|
231
254
|
except Exception as e:
|
|
232
255
|
CITRASCOPE_LOGGER.warning(f"Failed to save filter configuration: {e}")
|
|
233
256
|
|
|
234
|
-
def
|
|
235
|
-
"""
|
|
257
|
+
def _sync_filters_to_backend(self):
|
|
258
|
+
"""Sync enabled filters to backend API.
|
|
236
259
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
+
|
|
273
|
+
def trigger_autofocus(self) -> tuple[bool, Optional[str]]:
|
|
274
|
+
"""Request autofocus to run at next safe point between tasks.
|
|
241
275
|
|
|
242
276
|
Returns:
|
|
243
277
|
Tuple of (success, error_message)
|
|
@@ -248,34 +282,57 @@ class CitraScopeDaemon:
|
|
|
248
282
|
if not self.hardware_adapter.supports_filter_management():
|
|
249
283
|
return False, "Hardware adapter does not support filter management"
|
|
250
284
|
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
return False, "Autofocus already in progress"
|
|
285
|
+
if not self.task_manager:
|
|
286
|
+
return False, "Task manager not initialized"
|
|
254
287
|
|
|
255
|
-
#
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
return False, "Task processing must be paused before running autofocus"
|
|
288
|
+
# Request autofocus - will run between tasks
|
|
289
|
+
self.task_manager.request_autofocus()
|
|
290
|
+
return True, None
|
|
259
291
|
|
|
260
|
-
|
|
261
|
-
|
|
292
|
+
def cancel_autofocus(self) -> bool:
|
|
293
|
+
"""Cancel pending autofocus request if queued.
|
|
262
294
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
295
|
+
Returns:
|
|
296
|
+
bool: True if autofocus was cancelled, False if nothing to cancel.
|
|
297
|
+
"""
|
|
298
|
+
if not self.task_manager:
|
|
299
|
+
return False
|
|
300
|
+
return self.task_manager.cancel_autofocus()
|
|
267
301
|
|
|
268
|
-
|
|
269
|
-
|
|
302
|
+
def is_autofocus_requested(self) -> bool:
|
|
303
|
+
"""Check if autofocus is currently queued.
|
|
270
304
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
305
|
+
Returns:
|
|
306
|
+
bool: True if autofocus is queued, False otherwise.
|
|
307
|
+
"""
|
|
308
|
+
if not self.task_manager:
|
|
309
|
+
return False
|
|
310
|
+
return self.task_manager.is_autofocus_requested()
|
|
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
|
+
)
|
|
279
336
|
|
|
280
337
|
def run(self):
|
|
281
338
|
# Start web server FIRST, so users can monitor/configure
|
|
@@ -309,6 +366,8 @@ class CitraScopeDaemon:
|
|
|
309
366
|
"""Clean up resources on shutdown."""
|
|
310
367
|
if self.task_manager:
|
|
311
368
|
self.task_manager.stop()
|
|
369
|
+
if self.time_monitor:
|
|
370
|
+
self.time_monitor.stop()
|
|
312
371
|
if self.web_server:
|
|
313
372
|
CITRASCOPE_LOGGER.info("Stopping web server...")
|
|
314
373
|
if self.web_server.web_log_handler:
|
{citrascope-0.6.1 → 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):
|
|
@@ -26,10 +27,12 @@ class FilterConfig(TypedDict):
|
|
|
26
27
|
Attributes:
|
|
27
28
|
name: Human-readable filter name (e.g., 'Luminance', 'Red', 'Ha')
|
|
28
29
|
focus_position: Focuser position for this filter in steps
|
|
30
|
+
enabled: Whether this filter is enabled for observations (default: True)
|
|
29
31
|
"""
|
|
30
32
|
|
|
31
33
|
name: str
|
|
32
34
|
focus_position: int
|
|
35
|
+
enabled: bool
|
|
33
36
|
|
|
34
37
|
|
|
35
38
|
class ObservationStrategy(Enum):
|
|
@@ -43,18 +46,32 @@ class AbstractAstroHardwareAdapter(ABC):
|
|
|
43
46
|
|
|
44
47
|
_slew_min_distance_deg: float = 2.0
|
|
45
48
|
scope_slew_rate_degrees_per_second: float = 0.0
|
|
49
|
+
DEFAULT_FOCUS_POSITION: int = 0 # Default focus position, can be overridden by subclasses
|
|
46
50
|
|
|
47
|
-
def __init__(self, images_dir: Path):
|
|
48
|
-
"""Initialize the adapter with images directory.
|
|
51
|
+
def __init__(self, images_dir: Path, **kwargs):
|
|
52
|
+
"""Initialize the adapter with images directory and optional filter configuration.
|
|
49
53
|
|
|
50
54
|
Args:
|
|
51
55
|
images_dir: Path to the images directory
|
|
56
|
+
**kwargs: Additional configuration including 'filters' dict
|
|
52
57
|
"""
|
|
53
58
|
self.images_dir = images_dir
|
|
59
|
+
self.filter_map = {}
|
|
60
|
+
|
|
61
|
+
# Load filter configuration from settings if available
|
|
62
|
+
saved_filters = kwargs.get("filters", {})
|
|
63
|
+
for filter_id, filter_data in saved_filters.items():
|
|
64
|
+
try:
|
|
65
|
+
# Default enabled to True for backward compatibility
|
|
66
|
+
if "enabled" not in filter_data:
|
|
67
|
+
filter_data["enabled"] = True
|
|
68
|
+
self.filter_map[int(filter_id)] = filter_data
|
|
69
|
+
except (ValueError, TypeError):
|
|
70
|
+
pass # Skip invalid filter IDs
|
|
54
71
|
|
|
55
72
|
@classmethod
|
|
56
73
|
@abstractmethod
|
|
57
|
-
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
74
|
+
def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
|
|
58
75
|
"""
|
|
59
76
|
Return a schema describing configurable settings for this hardware adapter.
|
|
60
77
|
|
|
@@ -111,7 +128,7 @@ class AbstractAstroHardwareAdapter(ABC):
|
|
|
111
128
|
pass
|
|
112
129
|
|
|
113
130
|
@abstractmethod
|
|
114
|
-
def perform_observation_sequence(self,
|
|
131
|
+
def perform_observation_sequence(self, task, satellite_data) -> str:
|
|
115
132
|
"""For hardware driven by sequences, perform the observation sequence and return image path."""
|
|
116
133
|
pass
|
|
117
134
|
|
|
@@ -201,6 +218,14 @@ class AbstractAstroHardwareAdapter(ABC):
|
|
|
201
218
|
"""
|
|
202
219
|
raise NotImplementedError(f"{self.__class__.__name__} does not support autofocus")
|
|
203
220
|
|
|
221
|
+
def supports_autofocus(self) -> bool:
|
|
222
|
+
"""Indicates whether this adapter supports autofocus functionality.
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
bool: True if the adapter can perform autofocus, False otherwise.
|
|
226
|
+
"""
|
|
227
|
+
return False
|
|
228
|
+
|
|
204
229
|
def supports_filter_management(self) -> bool:
|
|
205
230
|
"""Indicates whether this adapter supports filter/focus management.
|
|
206
231
|
|
|
@@ -209,6 +234,83 @@ class AbstractAstroHardwareAdapter(ABC):
|
|
|
209
234
|
"""
|
|
210
235
|
return False
|
|
211
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
|
+
|
|
212
314
|
def get_filter_config(self) -> dict[str, FilterConfig]:
|
|
213
315
|
"""Get the current filter configuration including focus positions.
|
|
214
316
|
|
|
@@ -217,14 +319,22 @@ class AbstractAstroHardwareAdapter(ABC):
|
|
|
217
319
|
Each FilterConfig contains:
|
|
218
320
|
- name (str): Filter name
|
|
219
321
|
- focus_position (int): Focuser position for this filter
|
|
322
|
+
- enabled (bool): Whether filter is enabled for observations
|
|
220
323
|
|
|
221
324
|
Example:
|
|
222
325
|
{
|
|
223
|
-
"1": {"name": "Luminance", "focus_position": 9000},
|
|
224
|
-
"2": {"name": "Red", "focus_position": 9050}
|
|
326
|
+
"1": {"name": "Luminance", "focus_position": 9000, "enabled": True},
|
|
327
|
+
"2": {"name": "Red", "focus_position": 9050, "enabled": False}
|
|
225
328
|
}
|
|
226
329
|
"""
|
|
227
|
-
return {
|
|
330
|
+
return {
|
|
331
|
+
str(filter_id): {
|
|
332
|
+
"name": filter_data["name"],
|
|
333
|
+
"focus_position": filter_data["focus_position"],
|
|
334
|
+
"enabled": filter_data.get("enabled", True),
|
|
335
|
+
}
|
|
336
|
+
for filter_id, filter_data in self.filter_map.items()
|
|
337
|
+
}
|
|
228
338
|
|
|
229
339
|
def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
|
|
230
340
|
"""Update the focus position for a specific filter.
|
|
@@ -236,4 +346,30 @@ class AbstractAstroHardwareAdapter(ABC):
|
|
|
236
346
|
Returns:
|
|
237
347
|
bool: True if update was successful, False otherwise
|
|
238
348
|
"""
|
|
239
|
-
|
|
349
|
+
try:
|
|
350
|
+
filter_id_int = int(filter_id)
|
|
351
|
+
if filter_id_int in self.filter_map:
|
|
352
|
+
self.filter_map[filter_id_int]["focus_position"] = focus_position
|
|
353
|
+
return True
|
|
354
|
+
return False
|
|
355
|
+
except (ValueError, KeyError):
|
|
356
|
+
return False
|
|
357
|
+
|
|
358
|
+
def update_filter_enabled(self, filter_id: str, enabled: bool) -> bool:
|
|
359
|
+
"""Update the enabled state for a specific filter.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
filter_id: Filter ID as string
|
|
363
|
+
enabled: New enabled state
|
|
364
|
+
|
|
365
|
+
Returns:
|
|
366
|
+
bool: True if update was successful, False otherwise
|
|
367
|
+
"""
|
|
368
|
+
try:
|
|
369
|
+
filter_id_int = int(filter_id)
|
|
370
|
+
if filter_id_int in self.filter_map:
|
|
371
|
+
self.filter_map[filter_id_int]["enabled"] = enabled
|
|
372
|
+
return True
|
|
373
|
+
return False
|
|
374
|
+
except (ValueError, KeyError):
|
|
375
|
+
return False
|
|
@@ -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
|
+
]
|