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.
Files changed (78) hide show
  1. {citrascope-0.6.1 → citrascope-0.8.0}/.github/copilot-instructions.md +1 -0
  2. {citrascope-0.6.1 → citrascope-0.8.0}/PKG-INFO +17 -1
  3. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/api/abstract_api_client.py +14 -0
  4. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/api/citra_api_client.py +41 -0
  5. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/citra_scope_daemon.py +97 -38
  6. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
  7. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/adapter_registry.py +10 -3
  8. citrascope-0.8.0/citrascope/hardware/devices/__init__.py +17 -0
  9. citrascope-0.8.0/citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  10. citrascope-0.8.0/citrascope/hardware/devices/camera/__init__.py +13 -0
  11. citrascope-0.8.0/citrascope/hardware/devices/camera/abstract_camera.py +102 -0
  12. citrascope-0.8.0/citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  13. citrascope-0.8.0/citrascope/hardware/devices/camera/usb_camera.py +402 -0
  14. citrascope-0.8.0/citrascope/hardware/devices/camera/ximea_camera.py +744 -0
  15. citrascope-0.8.0/citrascope/hardware/devices/device_registry.py +273 -0
  16. citrascope-0.8.0/citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  17. citrascope-0.8.0/citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  18. citrascope-0.8.0/citrascope/hardware/devices/focuser/__init__.py +7 -0
  19. citrascope-0.8.0/citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  20. citrascope-0.8.0/citrascope/hardware/devices/mount/__init__.py +7 -0
  21. citrascope-0.8.0/citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  22. citrascope-0.8.0/citrascope/hardware/direct_hardware_adapter.py +787 -0
  23. citrascope-0.8.0/citrascope/hardware/filter_sync.py +94 -0
  24. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/indi_adapter.py +6 -2
  25. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/kstars_dbus_adapter.py +67 -96
  26. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/nina_adv_http_adapter.py +81 -64
  27. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  28. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/settings/citrascope_settings.py +25 -0
  29. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/tasks/runner.py +105 -0
  30. citrascope-0.8.0/citrascope/tasks/scope/static_telescope_task.py +34 -0
  31. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/tasks/task.py +3 -0
  32. citrascope-0.8.0/citrascope/time/__init__.py +13 -0
  33. citrascope-0.8.0/citrascope/time/time_health.py +96 -0
  34. citrascope-0.8.0/citrascope/time/time_monitor.py +164 -0
  35. citrascope-0.8.0/citrascope/time/time_sources.py +62 -0
  36. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/app.py +274 -51
  37. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/app.js +379 -36
  38. citrascope-0.8.0/citrascope/web/static/config.js +941 -0
  39. citrascope-0.8.0/citrascope/web/static/filters.js +55 -0
  40. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/style.css +39 -0
  41. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/templates/dashboard.html +176 -36
  42. {citrascope-0.6.1 → citrascope-0.8.0}/pyproject.toml +22 -2
  43. citrascope-0.8.0/tests/unit/test_device_dependencies.py +232 -0
  44. {citrascope-0.6.1 → citrascope-0.8.0}/tests/unit/utils.py +8 -0
  45. citrascope-0.6.1/citrascope/tasks/scope/static_telescope_task.py +0 -29
  46. citrascope-0.6.1/citrascope/web/static/config.js +0 -601
  47. {citrascope-0.6.1 → citrascope-0.8.0}/.devcontainer/devcontainer.json +0 -0
  48. {citrascope-0.6.1 → citrascope-0.8.0}/.flake8 +0 -0
  49. {citrascope-0.6.1 → citrascope-0.8.0}/.github/dependabot.yml +0 -0
  50. {citrascope-0.6.1 → citrascope-0.8.0}/.github/workflows/pypi-publish.yml +0 -0
  51. {citrascope-0.6.1 → citrascope-0.8.0}/.github/workflows/pytest.yml +0 -0
  52. {citrascope-0.6.1 → citrascope-0.8.0}/.gitignore +0 -0
  53. {citrascope-0.6.1 → citrascope-0.8.0}/.pre-commit-config.yaml +0 -0
  54. {citrascope-0.6.1 → citrascope-0.8.0}/.python-version +0 -0
  55. {citrascope-0.6.1 → citrascope-0.8.0}/.vscode/launch.json +0 -0
  56. {citrascope-0.6.1 → citrascope-0.8.0}/LICENSE +0 -0
  57. {citrascope-0.6.1 → citrascope-0.8.0}/README.md +0 -0
  58. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/__init__.py +0 -0
  59. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/__main__.py +0 -0
  60. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/constants.py +0 -0
  61. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/kstars_scheduler_template.esl +0 -0
  62. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/hardware/kstars_sequence_template.esq +0 -0
  63. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/logging/__init__.py +0 -0
  64. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/logging/_citrascope_logger.py +0 -0
  65. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/logging/web_log_handler.py +0 -0
  66. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/settings/__init__.py +0 -0
  67. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/settings/settings_file_manager.py +0 -0
  68. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/tasks/scope/base_telescope_task.py +0 -0
  69. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/tasks/scope/tracking_telescope_task.py +0 -0
  70. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/__init__.py +0 -0
  71. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/server.py +0 -0
  72. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/api.js +0 -0
  73. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/img/citra.png +0 -0
  74. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/img/favicon.png +0 -0
  75. {citrascope-0.6.1 → citrascope-0.8.0}/citrascope/web/static/websocket.js +0 -0
  76. {citrascope-0.6.1 → citrascope-0.8.0}/tests/unit/test_api_client.py +0 -0
  77. {citrascope-0.6.1 → citrascope-0.8.0}/tests/unit/test_hardware_adapter.py +0 -0
  78. {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.6.1
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 trigger_autofocus(self) -> tuple[bool, Optional[str]]:
235
- """Trigger autofocus routine on the hardware adapter.
257
+ def _sync_filters_to_backend(self):
258
+ """Sync enabled filters to backend API.
236
259
 
237
- Requires task processing to be manually paused before running.
238
- Checks that both:
239
- 1. Task processing is paused
240
- 2. No task is currently in-flight
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
- # Prevent concurrent autofocus operations
252
- if self._autofocus_in_progress:
253
- return False, "Autofocus already in progress"
285
+ if not self.task_manager:
286
+ return False, "Task manager not initialized"
254
287
 
255
- # Require task processing to be manually paused
256
- if self.task_manager:
257
- if self.task_manager.is_processing_active():
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
- if self.task_manager.current_task_id is not None:
261
- return False, "A task is currently executing. Please wait for it to complete and try again"
292
+ def cancel_autofocus(self) -> bool:
293
+ """Cancel pending autofocus request if queued.
262
294
 
263
- self._autofocus_in_progress = True
264
- try:
265
- CITRASCOPE_LOGGER.info("Starting autofocus routine...")
266
- self.hardware_adapter.do_autofocus()
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
- # Save updated filter configuration after autofocus
269
- self._save_filter_config()
302
+ def is_autofocus_requested(self) -> bool:
303
+ """Check if autofocus is currently queued.
270
304
 
271
- CITRASCOPE_LOGGER.info("Autofocus routine completed successfully")
272
- return True, None
273
- except Exception as e:
274
- error_msg = f"Autofocus failed: {str(e)}"
275
- CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
276
- return False, error_msg
277
- finally:
278
- self._autofocus_in_progress = False
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:
@@ -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, task_id, satellite_data) -> str:
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
- return False
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 directly without instantiation
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
+ ]