citrascope 0.7.0__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citrascope/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +75 -0
- citrascope/hardware/abstract_astro_hardware_adapter.py +97 -2
- citrascope/hardware/adapter_registry.py +15 -3
- citrascope/hardware/devices/__init__.py +17 -0
- citrascope/hardware/devices/abstract_hardware_device.py +79 -0
- citrascope/hardware/devices/camera/__init__.py +13 -0
- citrascope/hardware/devices/camera/abstract_camera.py +114 -0
- citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope/hardware/devices/camera/usb_camera.py +407 -0
- citrascope/hardware/devices/camera/ximea_camera.py +756 -0
- citrascope/hardware/devices/device_registry.py +273 -0
- citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
- citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
- citrascope/hardware/devices/focuser/__init__.py +7 -0
- citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
- citrascope/hardware/devices/mount/__init__.py +7 -0
- citrascope/hardware/devices/mount/abstract_mount.py +115 -0
- citrascope/hardware/direct_hardware_adapter.py +805 -0
- citrascope/hardware/dummy_adapter.py +202 -0
- citrascope/hardware/filter_sync.py +94 -0
- citrascope/hardware/indi_adapter.py +6 -2
- citrascope/hardware/kstars_dbus_adapter.py +46 -37
- citrascope/hardware/nina_adv_http_adapter.py +13 -11
- citrascope/settings/citrascope_settings.py +6 -0
- citrascope/tasks/runner.py +2 -0
- citrascope/tasks/scope/static_telescope_task.py +17 -12
- citrascope/tasks/task.py +3 -0
- citrascope/time/__init__.py +14 -0
- citrascope/time/time_health.py +103 -0
- citrascope/time/time_monitor.py +186 -0
- citrascope/time/time_sources.py +261 -0
- citrascope/web/app.py +260 -60
- citrascope/web/static/app.js +121 -731
- citrascope/web/static/components.js +136 -0
- citrascope/web/static/config.js +259 -420
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/formatters.js +129 -0
- citrascope/web/static/store-init.js +204 -0
- citrascope/web/static/style.css +44 -0
- citrascope/web/templates/_config.html +175 -0
- citrascope/web/templates/_config_hardware.html +208 -0
- citrascope/web/templates/_monitoring.html +242 -0
- citrascope/web/templates/dashboard.html +109 -377
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
- citrascope-0.9.0.dist-info/RECORD +69 -0
- citrascope-0.7.0.dist-info/RECORD +0 -41
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Dummy hardware adapter for testing without real hardware."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import time
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from citrascope.hardware.abstract_astro_hardware_adapter import (
|
|
8
|
+
AbstractAstroHardwareAdapter,
|
|
9
|
+
ObservationStrategy,
|
|
10
|
+
SettingSchemaEntry,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DummyAdapter(AbstractAstroHardwareAdapter):
|
|
15
|
+
"""
|
|
16
|
+
Dummy hardware adapter that simulates hardware without requiring real devices.
|
|
17
|
+
|
|
18
|
+
Perfect for testing, development, and demonstrations. All operations are logged
|
|
19
|
+
and return realistic fake data.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
|
|
23
|
+
"""Initialize dummy adapter.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
logger: Logger instance
|
|
27
|
+
images_dir: Path to images directory
|
|
28
|
+
**kwargs: Additional settings including 'simulate_slow_operations'
|
|
29
|
+
"""
|
|
30
|
+
super().__init__(images_dir, **kwargs)
|
|
31
|
+
self.logger = logger
|
|
32
|
+
self.simulate_slow = kwargs.get("simulate_slow_operations", False)
|
|
33
|
+
self.slow_delay = kwargs.get("slow_delay_seconds", 2.0)
|
|
34
|
+
|
|
35
|
+
# Fake hardware state
|
|
36
|
+
self._connected = False
|
|
37
|
+
self._telescope_connected = False
|
|
38
|
+
self._camera_connected = False
|
|
39
|
+
self._current_ra = 0.0 # degrees
|
|
40
|
+
self._current_dec = 0.0 # degrees
|
|
41
|
+
self._is_moving = False
|
|
42
|
+
self._tracking_rate = (15.041, 0.0) # arcsec/sec (sidereal rate)
|
|
43
|
+
|
|
44
|
+
self.logger.info("DummyAdapter initialized")
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
|
|
48
|
+
"""Return configuration schema for dummy adapter."""
|
|
49
|
+
return [
|
|
50
|
+
{
|
|
51
|
+
"name": "simulate_slow_operations",
|
|
52
|
+
"friendly_name": "Simulate Slow Operations",
|
|
53
|
+
"type": "bool",
|
|
54
|
+
"default": False,
|
|
55
|
+
"description": "Add artificial delays to simulate slow hardware responses",
|
|
56
|
+
"required": False,
|
|
57
|
+
"group": "Testing",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "slow_delay_seconds",
|
|
61
|
+
"friendly_name": "Delay Duration (seconds)",
|
|
62
|
+
"type": "float",
|
|
63
|
+
"default": 2.0,
|
|
64
|
+
"min": 0.1,
|
|
65
|
+
"max": 10.0,
|
|
66
|
+
"description": "Duration of artificial delays when slow simulation is enabled",
|
|
67
|
+
"required": False,
|
|
68
|
+
"group": "Testing",
|
|
69
|
+
},
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def get_observation_strategy(self) -> ObservationStrategy:
|
|
73
|
+
"""Dummy adapter uses manual strategy."""
|
|
74
|
+
return ObservationStrategy.MANUAL
|
|
75
|
+
|
|
76
|
+
def perform_observation_sequence(self, task, satellite_data) -> str:
|
|
77
|
+
"""Not used for manual strategy."""
|
|
78
|
+
raise NotImplementedError("DummyAdapter uses MANUAL strategy")
|
|
79
|
+
|
|
80
|
+
def connect(self) -> bool:
|
|
81
|
+
"""Simulate connection."""
|
|
82
|
+
self.logger.info("DummyAdapter: Connecting...")
|
|
83
|
+
self._simulate_delay()
|
|
84
|
+
self._connected = True
|
|
85
|
+
self._telescope_connected = True
|
|
86
|
+
self._camera_connected = True
|
|
87
|
+
self.logger.info("DummyAdapter: Connected successfully")
|
|
88
|
+
return True
|
|
89
|
+
|
|
90
|
+
def disconnect(self):
|
|
91
|
+
"""Simulate disconnection."""
|
|
92
|
+
self.logger.info("DummyAdapter: Disconnecting...")
|
|
93
|
+
self._connected = False
|
|
94
|
+
self._telescope_connected = False
|
|
95
|
+
self._camera_connected = False
|
|
96
|
+
self.logger.info("DummyAdapter: Disconnected")
|
|
97
|
+
|
|
98
|
+
def is_telescope_connected(self) -> bool:
|
|
99
|
+
"""Check fake telescope connection."""
|
|
100
|
+
return self._telescope_connected
|
|
101
|
+
|
|
102
|
+
def is_camera_connected(self) -> bool:
|
|
103
|
+
"""Check fake camera connection."""
|
|
104
|
+
return self._camera_connected
|
|
105
|
+
|
|
106
|
+
def list_devices(self) -> list[str]:
|
|
107
|
+
"""Return list of fake devices."""
|
|
108
|
+
return ["Dummy Telescope", "Dummy Camera", "Dummy Filter Wheel", "Dummy Focuser"]
|
|
109
|
+
|
|
110
|
+
def select_telescope(self, device_name: str) -> bool:
|
|
111
|
+
"""Simulate telescope selection."""
|
|
112
|
+
self.logger.info(f"DummyAdapter: Selected telescope '{device_name}'")
|
|
113
|
+
self._telescope_connected = True
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
def _do_point_telescope(self, ra: float, dec: float):
|
|
117
|
+
"""Simulate telescope slew."""
|
|
118
|
+
self.logger.info(f"DummyAdapter: Slewing to RA={ra:.4f}°, Dec={dec:.4f}°")
|
|
119
|
+
self._is_moving = True
|
|
120
|
+
self._simulate_delay()
|
|
121
|
+
self._current_ra = ra
|
|
122
|
+
self._current_dec = dec
|
|
123
|
+
self._is_moving = False
|
|
124
|
+
self.logger.info("DummyAdapter: Slew complete")
|
|
125
|
+
|
|
126
|
+
def get_telescope_direction(self) -> tuple[float, float]:
|
|
127
|
+
"""Return current fake telescope position."""
|
|
128
|
+
return (self._current_ra, self._current_dec)
|
|
129
|
+
|
|
130
|
+
def telescope_is_moving(self) -> bool:
|
|
131
|
+
"""Check if fake telescope is moving."""
|
|
132
|
+
return self._is_moving
|
|
133
|
+
|
|
134
|
+
def select_camera(self, device_name: str) -> bool:
|
|
135
|
+
"""Simulate camera selection."""
|
|
136
|
+
self.logger.info(f"DummyAdapter: Selected camera '{device_name}'")
|
|
137
|
+
self._camera_connected = True
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
def take_image(self, task_id: str, exposure_duration_seconds=1.0) -> str:
|
|
141
|
+
"""Simulate image capture."""
|
|
142
|
+
self.logger.info(f"DummyAdapter: Starting {exposure_duration_seconds}s exposure for task {task_id}")
|
|
143
|
+
self._simulate_delay(exposure_duration_seconds)
|
|
144
|
+
|
|
145
|
+
# Create dummy image file
|
|
146
|
+
timestamp = int(time.time())
|
|
147
|
+
filename = f"dummy_{task_id}_{timestamp}.fits"
|
|
148
|
+
filepath = self.images_dir / filename
|
|
149
|
+
|
|
150
|
+
# Create empty file to simulate image
|
|
151
|
+
filepath.parent.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
filepath.write_text(f"DUMMY FITS IMAGE\nTask: {task_id}\nExposure: {exposure_duration_seconds}s\n")
|
|
153
|
+
|
|
154
|
+
self.logger.info(f"DummyAdapter: Image saved to {filepath}")
|
|
155
|
+
return str(filepath)
|
|
156
|
+
|
|
157
|
+
def set_custom_tracking_rate(self, ra_rate: float, dec_rate: float):
|
|
158
|
+
"""Simulate setting tracking rate."""
|
|
159
|
+
self.logger.info(f"DummyAdapter: Setting tracking rate RA={ra_rate} arcsec/s, Dec={dec_rate} arcsec/s")
|
|
160
|
+
self._tracking_rate = (ra_rate, dec_rate)
|
|
161
|
+
|
|
162
|
+
def get_tracking_rate(self) -> tuple[float, float]:
|
|
163
|
+
"""Return current fake tracking rate."""
|
|
164
|
+
return self._tracking_rate
|
|
165
|
+
|
|
166
|
+
def perform_alignment(self, target_ra: float, target_dec: float) -> bool:
|
|
167
|
+
"""Simulate plate solving alignment."""
|
|
168
|
+
self.logger.info(f"DummyAdapter: Performing alignment to RA={target_ra}°, Dec={target_dec}°")
|
|
169
|
+
self._simulate_delay()
|
|
170
|
+
# Simulate small correction
|
|
171
|
+
self._current_ra = target_ra + 0.001
|
|
172
|
+
self._current_dec = target_dec + 0.001
|
|
173
|
+
self.logger.info("DummyAdapter: Alignment successful")
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
def supports_autofocus(self) -> bool:
|
|
177
|
+
"""Dummy adapter supports autofocus."""
|
|
178
|
+
return True
|
|
179
|
+
|
|
180
|
+
def do_autofocus(self) -> None:
|
|
181
|
+
"""Simulate autofocus routine."""
|
|
182
|
+
self.logger.info("DummyAdapter: Starting autofocus...")
|
|
183
|
+
self._simulate_delay(3.0)
|
|
184
|
+
self.logger.info("DummyAdapter: Autofocus complete")
|
|
185
|
+
|
|
186
|
+
def supports_filter_management(self) -> bool:
|
|
187
|
+
"""Dummy adapter supports filter management."""
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
def supports_direct_camera_control(self) -> bool:
|
|
191
|
+
"""Dummy adapter supports direct camera control."""
|
|
192
|
+
return True
|
|
193
|
+
|
|
194
|
+
def expose_camera(self, exposure_seconds: float = 1.0) -> str:
|
|
195
|
+
"""Simulate manual camera exposure."""
|
|
196
|
+
return self.take_image("manual_test", exposure_seconds)
|
|
197
|
+
|
|
198
|
+
def _simulate_delay(self, override_delay: float = None):
|
|
199
|
+
"""Add artificial delay if slow simulation is enabled."""
|
|
200
|
+
if self.simulate_slow:
|
|
201
|
+
delay = override_delay if override_delay is not None else self.slow_delay
|
|
202
|
+
time.sleep(delay)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Filter synchronization utilities for syncing hardware filters to backend API."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def extract_enabled_filter_names(filter_config: dict) -> list[str]:
|
|
5
|
+
"""Extract names of enabled filters from hardware configuration.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
filter_config: Dict mapping filter IDs to config dicts with 'name' and 'enabled' keys
|
|
9
|
+
|
|
10
|
+
Returns:
|
|
11
|
+
List of filter names where enabled=True
|
|
12
|
+
"""
|
|
13
|
+
enabled_names = []
|
|
14
|
+
for filter_id, config in filter_config.items():
|
|
15
|
+
if config.get("enabled", False):
|
|
16
|
+
enabled_names.append(config["name"])
|
|
17
|
+
return enabled_names
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_spectral_config_from_expanded(expanded_filters: list[dict]) -> tuple[dict, list[str]]:
|
|
21
|
+
"""Build discrete spectral_config from API expanded filter response.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
expanded_filters: List of filter dicts from /filters/expand API response,
|
|
25
|
+
each with 'name', 'central_wavelength_nm', 'bandwidth_nm', 'is_known'
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
Tuple of (spectral_config dict, list of unknown filter names)
|
|
29
|
+
"""
|
|
30
|
+
filter_specs = []
|
|
31
|
+
unknown_filters = []
|
|
32
|
+
|
|
33
|
+
for f in expanded_filters:
|
|
34
|
+
filter_specs.append(
|
|
35
|
+
{"name": f["name"], "central_wavelength_nm": f["central_wavelength_nm"], "bandwidth_nm": f["bandwidth_nm"]}
|
|
36
|
+
)
|
|
37
|
+
if not f.get("is_known", True):
|
|
38
|
+
unknown_filters.append(f["name"])
|
|
39
|
+
|
|
40
|
+
spectral_config = {"type": "discrete", "filters": filter_specs}
|
|
41
|
+
|
|
42
|
+
return spectral_config, unknown_filters
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def sync_filters_to_backend(api_client, telescope_id: str, filter_config: dict, logger) -> bool:
|
|
46
|
+
"""Sync enabled filters from hardware to backend API.
|
|
47
|
+
|
|
48
|
+
Extracts enabled filter names, expands them via filter library API,
|
|
49
|
+
builds spectral_config, and updates telescope record.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
api_client: CitraApiClient instance
|
|
53
|
+
telescope_id: UUID string of telescope to update
|
|
54
|
+
filter_config: Hardware filter configuration dict
|
|
55
|
+
logger: Logger instance for output
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
True if sync succeeded, False otherwise
|
|
59
|
+
"""
|
|
60
|
+
if not filter_config:
|
|
61
|
+
logger.debug("No filter configuration to sync")
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
# Extract enabled filter names
|
|
65
|
+
enabled_filter_names = extract_enabled_filter_names(filter_config)
|
|
66
|
+
|
|
67
|
+
if not enabled_filter_names:
|
|
68
|
+
logger.debug("No enabled filters to sync")
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
logger.info(f"Syncing {len(enabled_filter_names)} enabled filters to backend: {enabled_filter_names}")
|
|
72
|
+
|
|
73
|
+
# Expand filter names to full spectral specs via API
|
|
74
|
+
expand_response = api_client.expand_filters(enabled_filter_names)
|
|
75
|
+
if not expand_response or "filters" not in expand_response:
|
|
76
|
+
logger.warning("Failed to expand filter names - API returned no data")
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# Build spectral_config from expanded filters
|
|
80
|
+
expanded_filters = expand_response["filters"]
|
|
81
|
+
spectral_config, unknown_filters = build_spectral_config_from_expanded(expanded_filters)
|
|
82
|
+
|
|
83
|
+
if unknown_filters:
|
|
84
|
+
logger.warning(f"Unknown filters (using defaults): {unknown_filters}")
|
|
85
|
+
|
|
86
|
+
# Update telescope spectral_config via PATCH
|
|
87
|
+
update_response = api_client.update_telescope_spectral_config(telescope_id, spectral_config)
|
|
88
|
+
|
|
89
|
+
if update_response:
|
|
90
|
+
logger.info(f"Successfully synced {len(spectral_config['filters'])} filters to backend")
|
|
91
|
+
return True
|
|
92
|
+
else:
|
|
93
|
+
logger.warning("Failed to update telescope spectral_config on backend")
|
|
94
|
+
return False
|
|
@@ -47,7 +47,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
|
|
|
47
47
|
# TetraSolver.high_memory()
|
|
48
48
|
|
|
49
49
|
@classmethod
|
|
50
|
-
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
50
|
+
def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
|
|
51
51
|
"""
|
|
52
52
|
Return a schema describing configurable settings for the INDI adapter.
|
|
53
53
|
"""
|
|
@@ -60,6 +60,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
|
|
|
60
60
|
"description": "INDI server hostname or IP address",
|
|
61
61
|
"required": True,
|
|
62
62
|
"placeholder": "localhost or 192.168.1.100",
|
|
63
|
+
"group": "Connection",
|
|
63
64
|
},
|
|
64
65
|
{
|
|
65
66
|
"name": "port",
|
|
@@ -71,6 +72,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
|
|
|
71
72
|
"placeholder": "7624",
|
|
72
73
|
"min": 1,
|
|
73
74
|
"max": 65535,
|
|
75
|
+
"group": "Connection",
|
|
74
76
|
},
|
|
75
77
|
{
|
|
76
78
|
"name": "telescope_name",
|
|
@@ -80,6 +82,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
|
|
|
80
82
|
"description": "Name of the telescope device (leave empty to auto-detect)",
|
|
81
83
|
"required": False,
|
|
82
84
|
"placeholder": "Telescope Simulator",
|
|
85
|
+
"group": "Devices",
|
|
83
86
|
},
|
|
84
87
|
{
|
|
85
88
|
"name": "camera_name",
|
|
@@ -89,6 +92,7 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
|
|
|
89
92
|
"description": "Name of the camera device (leave empty to auto-detect)",
|
|
90
93
|
"required": False,
|
|
91
94
|
"placeholder": "CCD Simulator",
|
|
95
|
+
"group": "Devices",
|
|
92
96
|
},
|
|
93
97
|
]
|
|
94
98
|
|
|
@@ -726,5 +730,5 @@ class IndiAdapter(PyIndi.BaseClient, AbstractAstroHardwareAdapter):
|
|
|
726
730
|
def get_observation_strategy(self) -> ObservationStrategy:
|
|
727
731
|
return ObservationStrategy.MANUAL
|
|
728
732
|
|
|
729
|
-
def perform_observation_sequence(self,
|
|
733
|
+
def perform_observation_sequence(self, task, satellite_data) -> str:
|
|
730
734
|
raise NotImplementedError
|
|
@@ -84,7 +84,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
84
84
|
self.scheduler: dbus.Interface | None = None
|
|
85
85
|
|
|
86
86
|
@classmethod
|
|
87
|
-
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
87
|
+
def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
|
|
88
88
|
"""
|
|
89
89
|
Return a schema describing configurable settings for the KStars DBus adapter.
|
|
90
90
|
"""
|
|
@@ -97,6 +97,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
97
97
|
"description": "D-Bus service name for KStars (default: org.kde.kstars)",
|
|
98
98
|
"required": False,
|
|
99
99
|
"placeholder": "org.kde.kstars",
|
|
100
|
+
"group": "Connection",
|
|
100
101
|
},
|
|
101
102
|
{
|
|
102
103
|
"name": "ccd_name",
|
|
@@ -106,6 +107,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
106
107
|
"description": "Name of the camera device in your Ekos profile (check Ekos logs on connect for available devices)",
|
|
107
108
|
"required": False,
|
|
108
109
|
"placeholder": "CCD Simulator",
|
|
110
|
+
"group": "Devices",
|
|
109
111
|
},
|
|
110
112
|
{
|
|
111
113
|
"name": "filter_wheel_name",
|
|
@@ -115,6 +117,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
115
117
|
"description": "Name of the filter wheel device (leave empty if no filter wheel)",
|
|
116
118
|
"required": False,
|
|
117
119
|
"placeholder": "Filter Simulator",
|
|
120
|
+
"group": "Devices",
|
|
118
121
|
},
|
|
119
122
|
{
|
|
120
123
|
"name": "optical_train_name",
|
|
@@ -124,6 +127,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
124
127
|
"description": "Name of the optical train in your Ekos profile (check Ekos logs on connect for available trains)",
|
|
125
128
|
"required": False,
|
|
126
129
|
"placeholder": "Primary",
|
|
130
|
+
"group": "Devices",
|
|
127
131
|
},
|
|
128
132
|
{
|
|
129
133
|
"name": "exposure_time",
|
|
@@ -135,6 +139,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
135
139
|
"placeholder": "1.0",
|
|
136
140
|
"min": 0.001,
|
|
137
141
|
"max": 300.0,
|
|
142
|
+
"group": "Imaging",
|
|
138
143
|
},
|
|
139
144
|
{
|
|
140
145
|
"name": "frame_count",
|
|
@@ -146,6 +151,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
146
151
|
"placeholder": "1",
|
|
147
152
|
"min": 1,
|
|
148
153
|
"max": 100,
|
|
154
|
+
"group": "Imaging",
|
|
149
155
|
},
|
|
150
156
|
{
|
|
151
157
|
"name": "binning_x",
|
|
@@ -157,6 +163,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
157
163
|
"placeholder": "1",
|
|
158
164
|
"min": 1,
|
|
159
165
|
"max": 4,
|
|
166
|
+
"group": "Imaging",
|
|
160
167
|
},
|
|
161
168
|
{
|
|
162
169
|
"name": "binning_y",
|
|
@@ -168,6 +175,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
168
175
|
"placeholder": "1",
|
|
169
176
|
"min": 1,
|
|
170
177
|
"max": 4,
|
|
178
|
+
"group": "Imaging",
|
|
171
179
|
},
|
|
172
180
|
{
|
|
173
181
|
"name": "image_format",
|
|
@@ -178,6 +186,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
178
186
|
"required": False,
|
|
179
187
|
"placeholder": "Mono",
|
|
180
188
|
"options": ["Mono", "RGGB", "RGB"],
|
|
189
|
+
"group": "Imaging",
|
|
181
190
|
},
|
|
182
191
|
]
|
|
183
192
|
|
|
@@ -223,7 +232,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
223
232
|
raise FileNotFoundError(f"Template not found: {template_path}")
|
|
224
233
|
return template_path.read_text()
|
|
225
234
|
|
|
226
|
-
def _create_sequence_file(self, task_id: str, satellite_data: dict, output_dir: Path) -> Path:
|
|
235
|
+
def _create_sequence_file(self, task_id: str, satellite_data: dict, output_dir: Path, task=None) -> Path:
|
|
227
236
|
"""
|
|
228
237
|
Create an ESQ sequence file from template.
|
|
229
238
|
|
|
@@ -231,6 +240,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
231
240
|
task_id: Unique task identifier
|
|
232
241
|
satellite_data: Dictionary containing target information
|
|
233
242
|
output_dir: Base output directory for captures
|
|
243
|
+
task: Optional task object containing filter assignment
|
|
234
244
|
|
|
235
245
|
Returns:
|
|
236
246
|
Path to the created sequence file
|
|
@@ -241,7 +251,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
241
251
|
target_name = satellite_data.get("name", "Unknown").replace(" ", "_")
|
|
242
252
|
|
|
243
253
|
# Generate job blocks based on filter configuration
|
|
244
|
-
jobs_xml = self._generate_job_blocks(output_dir)
|
|
254
|
+
jobs_xml = self._generate_job_blocks(output_dir, task)
|
|
245
255
|
|
|
246
256
|
# Replace placeholders
|
|
247
257
|
sequence_content = template.replace("{{JOBS}}", jobs_xml)
|
|
@@ -261,13 +271,14 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
261
271
|
self.logger.info(f"Created sequence file: {sequence_file}")
|
|
262
272
|
return sequence_file
|
|
263
273
|
|
|
264
|
-
def _generate_job_blocks(self, output_dir: Path) -> str:
|
|
274
|
+
def _generate_job_blocks(self, output_dir: Path, task=None) -> str:
|
|
265
275
|
"""
|
|
266
276
|
Generate XML job blocks for each filter in filter_map.
|
|
267
277
|
If no filters discovered, generates single job with no filter.
|
|
268
278
|
|
|
269
279
|
Args:
|
|
270
280
|
output_dir: Base output directory for captures
|
|
281
|
+
task: Optional task object containing filter assignment
|
|
271
282
|
|
|
272
283
|
Returns:
|
|
273
284
|
XML string containing one or more <Job> blocks
|
|
@@ -311,33 +322,31 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
311
322
|
|
|
312
323
|
jobs = []
|
|
313
324
|
|
|
314
|
-
#
|
|
315
|
-
|
|
325
|
+
# Select filters to use for this task (allow_no_filter for KStars '--' fallback)
|
|
326
|
+
filters_to_use = self.select_filters_for_task(task, allow_no_filter=True)
|
|
316
327
|
|
|
317
|
-
if
|
|
318
|
-
#
|
|
319
|
-
self.logger.info(
|
|
320
|
-
f"Generating {len(enabled_filters)} jobs for enabled filters: "
|
|
321
|
-
f"{[f['name'] for f in enabled_filters.values()]}"
|
|
322
|
-
)
|
|
323
|
-
for filter_idx in sorted(enabled_filters.keys()):
|
|
324
|
-
filter_info = enabled_filters[filter_idx]
|
|
325
|
-
filter_name = filter_info["name"]
|
|
326
|
-
|
|
327
|
-
job_xml = job_template.format(
|
|
328
|
-
exposure=self.exposure_time,
|
|
329
|
-
format=self.image_format,
|
|
330
|
-
binning_x=self.binning_x,
|
|
331
|
-
binning_y=self.binning_y,
|
|
332
|
-
filter_name=filter_name,
|
|
333
|
-
count=self.frame_count,
|
|
334
|
-
output_dir=str(output_dir),
|
|
335
|
-
)
|
|
336
|
-
jobs.append(job_xml)
|
|
337
|
-
else:
|
|
338
|
-
# Single-filter mode: use '--' for no filter
|
|
328
|
+
if filters_to_use is None:
|
|
329
|
+
# No filters available - use '--' for no filter wheel
|
|
339
330
|
filter_name = "--" if not self.filter_wheel_name else "Luminance"
|
|
340
|
-
|
|
331
|
+
task_id_str = task.id if task else "unknown"
|
|
332
|
+
self.logger.info(f"Using fallback filter '{filter_name}' for task {task_id_str}")
|
|
333
|
+
|
|
334
|
+
job_xml = job_template.format(
|
|
335
|
+
exposure=self.exposure_time,
|
|
336
|
+
format=self.image_format,
|
|
337
|
+
binning_x=self.binning_x,
|
|
338
|
+
binning_y=self.binning_y,
|
|
339
|
+
filter_name=filter_name,
|
|
340
|
+
count=self.frame_count,
|
|
341
|
+
output_dir=str(output_dir),
|
|
342
|
+
)
|
|
343
|
+
jobs.append(job_xml)
|
|
344
|
+
return "\n".join(jobs)
|
|
345
|
+
|
|
346
|
+
# Generate jobs for selected filters
|
|
347
|
+
for filter_idx in sorted(filters_to_use.keys()):
|
|
348
|
+
filter_info = filters_to_use[filter_idx]
|
|
349
|
+
filter_name = filter_info["name"]
|
|
341
350
|
|
|
342
351
|
job_xml = job_template.format(
|
|
343
352
|
exposure=self.exposure_time,
|
|
@@ -521,12 +530,12 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
521
530
|
|
|
522
531
|
return matching_files
|
|
523
532
|
|
|
524
|
-
def perform_observation_sequence(self,
|
|
533
|
+
def perform_observation_sequence(self, task, satellite_data: dict) -> list[str]:
|
|
525
534
|
"""
|
|
526
535
|
Execute a complete observation sequence using Ekos Scheduler.
|
|
527
536
|
|
|
528
537
|
Args:
|
|
529
|
-
|
|
538
|
+
task: Task object containing id and filter assignment
|
|
530
539
|
satellite_data: Dictionary with keys: 'name', and either 'ra'/'dec' or TLE data
|
|
531
540
|
|
|
532
541
|
Returns:
|
|
@@ -550,7 +559,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
550
559
|
output_dir.mkdir(exist_ok=True, parents=True)
|
|
551
560
|
|
|
552
561
|
# Clear task-specific directory to prevent Ekos from thinking job is already done
|
|
553
|
-
task_output_dir = output_dir /
|
|
562
|
+
task_output_dir = output_dir / task.id
|
|
554
563
|
if task_output_dir.exists():
|
|
555
564
|
shutil.rmtree(task_output_dir)
|
|
556
565
|
self.logger.info(f"Cleared existing output directory: {task_output_dir}")
|
|
@@ -560,20 +569,20 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
|
|
|
560
569
|
self.logger.info(f"Output directory: {task_output_dir}")
|
|
561
570
|
|
|
562
571
|
# Create sequence and scheduler job files (use task-specific directory)
|
|
563
|
-
sequence_file = self._create_sequence_file(
|
|
564
|
-
job_file = self._create_scheduler_job(
|
|
572
|
+
sequence_file = self._create_sequence_file(task.id, satellite_data, task_output_dir, task)
|
|
573
|
+
job_file = self._create_scheduler_job(task.id, satellite_data, sequence_file)
|
|
565
574
|
|
|
566
575
|
# Ensure temp files are cleaned up even on failure
|
|
567
576
|
try:
|
|
568
|
-
self._execute_observation(
|
|
577
|
+
self._execute_observation(task.id, output_dir, sequence_file, job_file)
|
|
569
578
|
finally:
|
|
570
579
|
# Cleanup temp files
|
|
571
580
|
self._cleanup_temp_files(sequence_file, job_file)
|
|
572
581
|
|
|
573
582
|
# Retrieve and return captured images
|
|
574
|
-
image_paths = self._retrieve_captured_images(
|
|
583
|
+
image_paths = self._retrieve_captured_images(task.id, output_dir)
|
|
575
584
|
if not image_paths:
|
|
576
|
-
raise RuntimeError(f"No images captured for task {
|
|
585
|
+
raise RuntimeError(f"No images captured for task {task.id}")
|
|
577
586
|
|
|
578
587
|
self.logger.info(f"Observation sequence complete: {len(image_paths)} images captured")
|
|
579
588
|
return image_paths
|
|
@@ -39,7 +39,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
39
39
|
self.autofocus_binning = kwargs.get("autofocus_binning", 1)
|
|
40
40
|
|
|
41
41
|
@classmethod
|
|
42
|
-
def get_settings_schema(cls) -> list[SettingSchemaEntry]:
|
|
42
|
+
def get_settings_schema(cls, **kwargs) -> list[SettingSchemaEntry]:
|
|
43
43
|
"""
|
|
44
44
|
Return a schema describing configurable settings for the NINA Advanced HTTP adapter.
|
|
45
45
|
"""
|
|
@@ -53,6 +53,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
53
53
|
"required": True,
|
|
54
54
|
"placeholder": "http://localhost:1888/v2/api",
|
|
55
55
|
"pattern": r"^https?://.*",
|
|
56
|
+
"group": "Connection",
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
"name": "autofocus_binning",
|
|
@@ -64,6 +65,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
64
65
|
"placeholder": "1",
|
|
65
66
|
"min": 1,
|
|
66
67
|
"max": 4,
|
|
68
|
+
"group": "Imaging",
|
|
67
69
|
},
|
|
68
70
|
{
|
|
69
71
|
"name": "binning_x",
|
|
@@ -75,6 +77,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
75
77
|
"placeholder": "1",
|
|
76
78
|
"min": 1,
|
|
77
79
|
"max": 4,
|
|
80
|
+
"group": "Imaging",
|
|
78
81
|
},
|
|
79
82
|
{
|
|
80
83
|
"name": "binning_y",
|
|
@@ -86,6 +89,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
86
89
|
"placeholder": "1",
|
|
87
90
|
"min": 1,
|
|
88
91
|
"max": 4,
|
|
92
|
+
"group": "Imaging",
|
|
89
93
|
},
|
|
90
94
|
]
|
|
91
95
|
|
|
@@ -440,11 +444,11 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
440
444
|
for item in data:
|
|
441
445
|
self._update_all_ids(item, id_counter)
|
|
442
446
|
|
|
443
|
-
def perform_observation_sequence(self,
|
|
447
|
+
def perform_observation_sequence(self, task, satellite_data) -> str | list[str]:
|
|
444
448
|
"""Create and execute a NINA sequence for the given satellite.
|
|
445
449
|
|
|
446
450
|
Args:
|
|
447
|
-
|
|
451
|
+
task: Task object containing id and filter assignment
|
|
448
452
|
satellite_data: Satellite data including TLE information
|
|
449
453
|
|
|
450
454
|
Returns:
|
|
@@ -459,7 +463,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
459
463
|
template_str = template_str.replace("{autofocus_binning}", str(self.autofocus_binning))
|
|
460
464
|
sequence_json = json.loads(template_str)
|
|
461
465
|
|
|
462
|
-
nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {
|
|
466
|
+
nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {task.id}"
|
|
463
467
|
|
|
464
468
|
# Replace basic placeholders (use \r\n for Windows NINA compatibility)
|
|
465
469
|
tle_data = f"{elset['tle'][0]}\r\n{elset['tle'][1]}"
|
|
@@ -496,12 +500,10 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
496
500
|
|
|
497
501
|
id_counter = [base_id] # Use list so it can be modified in nested function
|
|
498
502
|
|
|
499
|
-
#
|
|
500
|
-
|
|
501
|
-
if not enabled_filters:
|
|
502
|
-
raise RuntimeError("No enabled filters available for observation sequence")
|
|
503
|
+
# Select filters to use for this task
|
|
504
|
+
filters_to_use = self.select_filters_for_task(task, allow_no_filter=False)
|
|
503
505
|
|
|
504
|
-
for filter_id, filter_info in
|
|
506
|
+
for filter_id, filter_info in filters_to_use.items():
|
|
505
507
|
filter_name = filter_info["name"]
|
|
506
508
|
focus_position = filter_info["focus_position"]
|
|
507
509
|
|
|
@@ -521,7 +523,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
521
523
|
# Add this triplet to the sequence
|
|
522
524
|
new_items.extend(filter_triplet)
|
|
523
525
|
|
|
524
|
-
self.logger.
|
|
526
|
+
self.logger.debug(f"Added filter {filter_name} (ID: {filter_id}) with focus position {focus_position}")
|
|
525
527
|
|
|
526
528
|
# Update the items list
|
|
527
529
|
tle_items.clear()
|
|
@@ -580,7 +582,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
580
582
|
raise RuntimeError("Failed to get images list from NINA")
|
|
581
583
|
|
|
582
584
|
images_to_download = []
|
|
583
|
-
expected_image_count = len(
|
|
585
|
+
expected_image_count = len(filters_to_use) # One image per filter in sequence
|
|
584
586
|
images_found = len(images_response["Response"])
|
|
585
587
|
self.logger.info(
|
|
586
588
|
f"Found {images_found} images in NINA image history, considering the last {expected_image_count}"
|