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.
Files changed (51) hide show
  1. citrascope/api/abstract_api_client.py +14 -0
  2. citrascope/api/citra_api_client.py +41 -0
  3. citrascope/citra_scope_daemon.py +75 -0
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +97 -2
  5. citrascope/hardware/adapter_registry.py +15 -3
  6. citrascope/hardware/devices/__init__.py +17 -0
  7. citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  8. citrascope/hardware/devices/camera/__init__.py +13 -0
  9. citrascope/hardware/devices/camera/abstract_camera.py +114 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +407 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +756 -0
  13. citrascope/hardware/devices/device_registry.py +273 -0
  14. citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  15. citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  16. citrascope/hardware/devices/focuser/__init__.py +7 -0
  17. citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  18. citrascope/hardware/devices/mount/__init__.py +7 -0
  19. citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  20. citrascope/hardware/direct_hardware_adapter.py +805 -0
  21. citrascope/hardware/dummy_adapter.py +202 -0
  22. citrascope/hardware/filter_sync.py +94 -0
  23. citrascope/hardware/indi_adapter.py +6 -2
  24. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  25. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  26. citrascope/settings/citrascope_settings.py +6 -0
  27. citrascope/tasks/runner.py +2 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +14 -0
  31. citrascope/time/time_health.py +103 -0
  32. citrascope/time/time_monitor.py +186 -0
  33. citrascope/time/time_sources.py +261 -0
  34. citrascope/web/app.py +260 -60
  35. citrascope/web/static/app.js +121 -731
  36. citrascope/web/static/components.js +136 -0
  37. citrascope/web/static/config.js +259 -420
  38. citrascope/web/static/filters.js +55 -0
  39. citrascope/web/static/formatters.js +129 -0
  40. citrascope/web/static/store-init.js +204 -0
  41. citrascope/web/static/style.css +44 -0
  42. citrascope/web/templates/_config.html +175 -0
  43. citrascope/web/templates/_config_hardware.html +208 -0
  44. citrascope/web/templates/_monitoring.html +242 -0
  45. citrascope/web/templates/dashboard.html +109 -377
  46. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
  47. citrascope-0.9.0.dist-info/RECORD +69 -0
  48. citrascope-0.7.0.dist-info/RECORD +0 -41
  49. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
  50. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
  51. {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, task_id, satellite_data) -> str:
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
- # Filter to only enabled filters
315
- enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
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 enabled_filters:
318
- # Multi-filter mode: create one job per enabled filter
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
- self.logger.info(f"Generating single job with filter: {filter_name}")
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, task_id: str, satellite_data: dict) -> list[str]:
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
- task_id: Unique task identifier
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 / task_id
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(task_id, satellite_data, task_output_dir)
564
- job_file = self._create_scheduler_job(task_id, satellite_data, sequence_file)
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(task_id, output_dir, sequence_file, job_file)
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(task_id, output_dir)
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 {task_id}")
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, task_id, satellite_data) -> str | list[str]:
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
- task_id: Unique identifier for this observation task
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: {task_id}"
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
- # Filter to only enabled filters for observation
500
- enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
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 enabled_filters.items():
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.info(f"Added filter {filter_name} (ID: {filter_id}) with focus position {focus_position}")
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(enabled_filters) # One image per enabled filter
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}"