citrascope 0.6.1__tar.gz → 0.7.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 (54) hide show
  1. {citrascope-0.6.1 → citrascope-0.7.0}/PKG-INFO +1 -1
  2. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/citra_scope_daemon.py +22 -38
  3. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/hardware/abstract_astro_hardware_adapter.py +64 -6
  4. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/hardware/kstars_dbus_adapter.py +29 -67
  5. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/hardware/nina_adv_http_adapter.py +74 -59
  6. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  7. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/settings/citrascope_settings.py +19 -0
  8. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/tasks/runner.py +103 -0
  9. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/app.py +82 -37
  10. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/static/app.js +83 -0
  11. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/static/config.js +244 -39
  12. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/templates/dashboard.html +62 -27
  13. {citrascope-0.6.1 → citrascope-0.7.0}/pyproject.toml +2 -2
  14. {citrascope-0.6.1 → citrascope-0.7.0}/.devcontainer/devcontainer.json +0 -0
  15. {citrascope-0.6.1 → citrascope-0.7.0}/.flake8 +0 -0
  16. {citrascope-0.6.1 → citrascope-0.7.0}/.github/copilot-instructions.md +0 -0
  17. {citrascope-0.6.1 → citrascope-0.7.0}/.github/dependabot.yml +0 -0
  18. {citrascope-0.6.1 → citrascope-0.7.0}/.github/workflows/pypi-publish.yml +0 -0
  19. {citrascope-0.6.1 → citrascope-0.7.0}/.github/workflows/pytest.yml +0 -0
  20. {citrascope-0.6.1 → citrascope-0.7.0}/.gitignore +0 -0
  21. {citrascope-0.6.1 → citrascope-0.7.0}/.pre-commit-config.yaml +0 -0
  22. {citrascope-0.6.1 → citrascope-0.7.0}/.python-version +0 -0
  23. {citrascope-0.6.1 → citrascope-0.7.0}/.vscode/launch.json +0 -0
  24. {citrascope-0.6.1 → citrascope-0.7.0}/LICENSE +0 -0
  25. {citrascope-0.6.1 → citrascope-0.7.0}/README.md +0 -0
  26. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/__init__.py +0 -0
  27. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/__main__.py +0 -0
  28. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/api/abstract_api_client.py +0 -0
  29. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/api/citra_api_client.py +0 -0
  30. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/constants.py +0 -0
  31. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/hardware/adapter_registry.py +0 -0
  32. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/hardware/indi_adapter.py +0 -0
  33. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/hardware/kstars_scheduler_template.esl +0 -0
  34. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/hardware/kstars_sequence_template.esq +0 -0
  35. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/logging/__init__.py +0 -0
  36. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/logging/_citrascope_logger.py +0 -0
  37. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/logging/web_log_handler.py +0 -0
  38. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/settings/__init__.py +0 -0
  39. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/settings/settings_file_manager.py +0 -0
  40. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/tasks/scope/base_telescope_task.py +0 -0
  41. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/tasks/scope/static_telescope_task.py +0 -0
  42. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/tasks/scope/tracking_telescope_task.py +0 -0
  43. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/tasks/task.py +0 -0
  44. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/__init__.py +0 -0
  45. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/server.py +0 -0
  46. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/static/api.js +0 -0
  47. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/static/img/citra.png +0 -0
  48. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/static/img/favicon.png +0 -0
  49. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/static/style.css +0 -0
  50. {citrascope-0.6.1 → citrascope-0.7.0}/citrascope/web/static/websocket.js +0 -0
  51. {citrascope-0.6.1 → citrascope-0.7.0}/tests/unit/test_api_client.py +0 -0
  52. {citrascope-0.6.1 → citrascope-0.7.0}/tests/unit/test_hardware_adapter.py +0 -0
  53. {citrascope-0.6.1 → citrascope-0.7.0}/tests/unit/test_task_manager.py +0 -0
  54. {citrascope-0.6.1 → citrascope-0.7.0}/tests/unit/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: citrascope
3
- Version: 0.6.1
3
+ Version: 0.7.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/
@@ -35,7 +35,6 @@ class CitraScopeDaemon:
35
35
  self.ground_station = None
36
36
  self.telescope_record = None
37
37
  self.configuration_error: Optional[str] = None
38
- self._autofocus_in_progress = False
39
38
 
40
39
  # Create web server instance (always enabled)
41
40
  self.web_server = CitraScopeWebServer(daemon=self, host="0.0.0.0", port=self.settings.web_port)
@@ -200,14 +199,6 @@ class CitraScopeDaemon:
200
199
  CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
201
200
  return False, error_msg
202
201
 
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
202
  def _save_filter_config(self):
212
203
  """Save filter configuration from adapter to settings if supported.
213
204
 
@@ -232,12 +223,7 @@ class CitraScopeDaemon:
232
223
  CITRASCOPE_LOGGER.warning(f"Failed to save filter configuration: {e}")
233
224
 
234
225
  def trigger_autofocus(self) -> tuple[bool, Optional[str]]:
235
- """Trigger autofocus routine on the hardware adapter.
236
-
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
226
+ """Request autofocus to run at next safe point between tasks.
241
227
 
242
228
  Returns:
243
229
  Tuple of (success, error_message)
@@ -248,34 +234,32 @@ class CitraScopeDaemon:
248
234
  if not self.hardware_adapter.supports_filter_management():
249
235
  return False, "Hardware adapter does not support filter management"
250
236
 
251
- # Prevent concurrent autofocus operations
252
- if self._autofocus_in_progress:
253
- return False, "Autofocus already in progress"
237
+ if not self.task_manager:
238
+ return False, "Task manager not initialized"
254
239
 
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"
240
+ # Request autofocus - will run between tasks
241
+ self.task_manager.request_autofocus()
242
+ return True, None
259
243
 
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"
244
+ def cancel_autofocus(self) -> bool:
245
+ """Cancel pending autofocus request if queued.
262
246
 
263
- self._autofocus_in_progress = True
264
- try:
265
- CITRASCOPE_LOGGER.info("Starting autofocus routine...")
266
- self.hardware_adapter.do_autofocus()
247
+ Returns:
248
+ bool: True if autofocus was cancelled, False if nothing to cancel.
249
+ """
250
+ if not self.task_manager:
251
+ return False
252
+ return self.task_manager.cancel_autofocus()
267
253
 
268
- # Save updated filter configuration after autofocus
269
- self._save_filter_config()
254
+ def is_autofocus_requested(self) -> bool:
255
+ """Check if autofocus is currently queued.
270
256
 
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
257
+ Returns:
258
+ bool: True if autofocus is queued, False otherwise.
259
+ """
260
+ if not self.task_manager:
261
+ return False
262
+ return self.task_manager.is_autofocus_requested()
279
263
 
280
264
  def run(self):
281
265
  # Start web server FIRST, so users can monitor/configure
@@ -26,10 +26,12 @@ class FilterConfig(TypedDict):
26
26
  Attributes:
27
27
  name: Human-readable filter name (e.g., 'Luminance', 'Red', 'Ha')
28
28
  focus_position: Focuser position for this filter in steps
29
+ enabled: Whether this filter is enabled for observations (default: True)
29
30
  """
30
31
 
31
32
  name: str
32
33
  focus_position: int
34
+ enabled: bool
33
35
 
34
36
 
35
37
  class ObservationStrategy(Enum):
@@ -43,14 +45,28 @@ class AbstractAstroHardwareAdapter(ABC):
43
45
 
44
46
  _slew_min_distance_deg: float = 2.0
45
47
  scope_slew_rate_degrees_per_second: float = 0.0
48
+ DEFAULT_FOCUS_POSITION: int = 0 # Default focus position, can be overridden by subclasses
46
49
 
47
- def __init__(self, images_dir: Path):
48
- """Initialize the adapter with images directory.
50
+ def __init__(self, images_dir: Path, **kwargs):
51
+ """Initialize the adapter with images directory and optional filter configuration.
49
52
 
50
53
  Args:
51
54
  images_dir: Path to the images directory
55
+ **kwargs: Additional configuration including 'filters' dict
52
56
  """
53
57
  self.images_dir = images_dir
58
+ self.filter_map = {}
59
+
60
+ # Load filter configuration from settings if available
61
+ saved_filters = kwargs.get("filters", {})
62
+ for filter_id, filter_data in saved_filters.items():
63
+ try:
64
+ # Default enabled to True for backward compatibility
65
+ if "enabled" not in filter_data:
66
+ filter_data["enabled"] = True
67
+ self.filter_map[int(filter_id)] = filter_data
68
+ except (ValueError, TypeError):
69
+ pass # Skip invalid filter IDs
54
70
 
55
71
  @classmethod
56
72
  @abstractmethod
@@ -201,6 +217,14 @@ class AbstractAstroHardwareAdapter(ABC):
201
217
  """
202
218
  raise NotImplementedError(f"{self.__class__.__name__} does not support autofocus")
203
219
 
220
+ def supports_autofocus(self) -> bool:
221
+ """Indicates whether this adapter supports autofocus functionality.
222
+
223
+ Returns:
224
+ bool: True if the adapter can perform autofocus, False otherwise.
225
+ """
226
+ return False
227
+
204
228
  def supports_filter_management(self) -> bool:
205
229
  """Indicates whether this adapter supports filter/focus management.
206
230
 
@@ -217,14 +241,22 @@ class AbstractAstroHardwareAdapter(ABC):
217
241
  Each FilterConfig contains:
218
242
  - name (str): Filter name
219
243
  - focus_position (int): Focuser position for this filter
244
+ - enabled (bool): Whether filter is enabled for observations
220
245
 
221
246
  Example:
222
247
  {
223
- "1": {"name": "Luminance", "focus_position": 9000},
224
- "2": {"name": "Red", "focus_position": 9050}
248
+ "1": {"name": "Luminance", "focus_position": 9000, "enabled": True},
249
+ "2": {"name": "Red", "focus_position": 9050, "enabled": False}
225
250
  }
226
251
  """
227
- return {}
252
+ return {
253
+ str(filter_id): {
254
+ "name": filter_data["name"],
255
+ "focus_position": filter_data["focus_position"],
256
+ "enabled": filter_data.get("enabled", True),
257
+ }
258
+ for filter_id, filter_data in self.filter_map.items()
259
+ }
228
260
 
229
261
  def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
230
262
  """Update the focus position for a specific filter.
@@ -236,4 +268,30 @@ class AbstractAstroHardwareAdapter(ABC):
236
268
  Returns:
237
269
  bool: True if update was successful, False otherwise
238
270
  """
239
- return False
271
+ try:
272
+ filter_id_int = int(filter_id)
273
+ if filter_id_int in self.filter_map:
274
+ self.filter_map[filter_id_int]["focus_position"] = focus_position
275
+ return True
276
+ return False
277
+ except (ValueError, KeyError):
278
+ return False
279
+
280
+ def update_filter_enabled(self, filter_id: str, enabled: bool) -> bool:
281
+ """Update the enabled state for a specific filter.
282
+
283
+ Args:
284
+ filter_id: Filter ID as string
285
+ enabled: New enabled state
286
+
287
+ Returns:
288
+ bool: True if update was successful, False otherwise
289
+ """
290
+ try:
291
+ filter_id_int = int(filter_id)
292
+ if filter_id_int in self.filter_map:
293
+ self.filter_map[filter_id_int]["enabled"] = enabled
294
+ return True
295
+ return False
296
+ except (ValueError, KeyError):
297
+ return False
@@ -62,7 +62,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
62
62
  images_dir: Path to the images directory
63
63
  **kwargs: Configuration including bus_name, ccd_name, filter_wheel_name
64
64
  """
65
- super().__init__(images_dir=images_dir)
65
+ super().__init__(images_dir=images_dir, **kwargs)
66
66
  self.logger: logging.Logger = logger
67
67
  self.bus_name = kwargs.get("bus_name") or "org.kde.kstars"
68
68
  self.ccd_name = kwargs.get("ccd_name") or "CCD Simulator"
@@ -76,19 +76,6 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
76
76
  self.binning_y = kwargs.get("binning_y", 1)
77
77
  self.image_format = kwargs.get("image_format", "Mono")
78
78
 
79
- # Filter management
80
- self.filter_map: Dict[int, Dict[str, Any]] = {}
81
-
82
- # Pre-populate filter_map from saved settings (if any)
83
- # This will be merged with discovered filters in discover_filters()
84
- saved_filters = kwargs.get("filters", {})
85
- for filter_id, filter_data in saved_filters.items():
86
- # Convert string keys back to int for internal use
87
- try:
88
- self.filter_map[int(filter_id)] = filter_data
89
- except (ValueError, TypeError) as e:
90
- self.logger.warning(f"Invalid filter ID '{filter_id}' in settings, skipping: {e}")
91
-
92
79
  self.bus: dbus.SessionBus | None = None
93
80
  self.kstars: dbus.Interface | None = None
94
81
  self.ekos: dbus.Interface | None = None
@@ -324,14 +311,17 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
324
311
 
325
312
  jobs = []
326
313
 
327
- if self.filter_map:
328
- # Multi-filter mode: create one job per discovered filter
314
+ # Filter to only enabled filters
315
+ enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
316
+
317
+ if enabled_filters:
318
+ # Multi-filter mode: create one job per enabled filter
329
319
  self.logger.info(
330
- f"Generating {len(self.filter_map)} jobs for filters: "
331
- f"{[f['name'] for f in self.filter_map.values()]}"
320
+ f"Generating {len(enabled_filters)} jobs for enabled filters: "
321
+ f"{[f['name'] for f in enabled_filters.values()]}"
332
322
  )
333
- for filter_idx in sorted(self.filter_map.keys()):
334
- filter_info = self.filter_map[filter_idx]
323
+ for filter_idx in sorted(enabled_filters.keys()):
324
+ filter_info = enabled_filters[filter_idx]
335
325
  filter_name = filter_info["name"]
336
326
 
337
327
  job_xml = job_template.format(
@@ -424,13 +414,15 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
424
414
 
425
415
  assert self.bus is not None
426
416
 
427
- # Calculate expected number of images based on filters
428
- expected_filter_count = len(self.filter_map) if self.filter_map else 1
429
- expected_total_images = expected_filter_count * self.frame_count
417
+ # Calculate expected number of images based on enabled filters
418
+ enabled_filter_count = (
419
+ sum(1 for f in self.filter_map.values() if f.get("enabled", True)) if self.filter_map else 1
420
+ )
421
+ expected_total_images = enabled_filter_count * self.frame_count
430
422
 
431
423
  self.logger.info(
432
424
  f"Waiting for scheduler job completion (timeout: {timeout}s, "
433
- f"expecting {expected_total_images} images across {expected_filter_count} filters)..."
425
+ f"expecting {expected_total_images} images across {enabled_filter_count} filters)..."
434
426
  )
435
427
  start_time = time.time()
436
428
 
@@ -858,14 +850,16 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
858
850
  # Use 0-based indexing for filter_map (slot 1 -> index 0)
859
851
  filter_idx = slot_num - 1
860
852
 
861
- # If filter already in map (from saved settings), preserve focus position
853
+ # If filter already in map (from saved settings), preserve focus position and enabled state
862
854
  if filter_idx in self.filter_map:
863
855
  focus_position = self.filter_map[filter_idx].get("focus_position", 0)
856
+ enabled = self.filter_map[filter_idx].get("enabled", True)
864
857
  self.logger.debug(
865
- f"Filter slot {slot_num} ({filter_name}): using saved focus position {focus_position}"
858
+ f"Filter slot {slot_num} ({filter_name}): using saved focus position {focus_position}, enabled: {enabled}"
866
859
  )
867
860
  else:
868
861
  focus_position = 0
862
+ enabled = True # Default new filters to enabled
869
863
  self.logger.debug(
870
864
  f"Filter slot {slot_num} ({filter_name}): new filter, using default focus position"
871
865
  )
@@ -873,6 +867,7 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
873
867
  self.filter_map[filter_idx] = {
874
868
  "name": filter_name,
875
869
  "focus_position": focus_position,
870
+ "enabled": enabled,
876
871
  }
877
872
  except Exception as e:
878
873
  self.logger.warning(f"Could not read filter slot {slot_num}: {e}")
@@ -888,54 +883,21 @@ class KStarsDBusAdapter(AbstractAstroHardwareAdapter):
888
883
  self.logger.info(f"Filter discovery failed (non-fatal): {e}")
889
884
  # Leave filter_map empty, use single-filter mode
890
885
 
891
- def supports_filter_management(self) -> bool:
892
- """Indicates whether this adapter supports filter/focus management.
893
-
894
- Returns:
895
- bool: True if filters were discovered, False otherwise.
896
- """
897
- return bool(self.filter_map)
898
-
899
- def get_filter_config(self) -> Dict[str, Dict[str, Any]]:
900
- """Get the current filter configuration including focus positions.
886
+ def supports_autofocus(self) -> bool:
887
+ """Indicates that KStars adapter does not support autofocus yet.
901
888
 
902
889
  Returns:
903
- dict: Dictionary mapping filter IDs (as strings) to FilterConfig.
904
- Each FilterConfig contains:
905
- - name (str): Filter name
906
- - focus_position (int): Focuser position for this filter
907
-
908
- Example:
909
- {
910
- "0": {"name": "Red", "focus_position": 9000},
911
- "1": {"name": "Green", "focus_position": 9050}
912
- }
890
+ bool: False (autofocus not implemented).
913
891
  """
914
- # Convert 0-based integer keys to strings for the web interface
915
- return {str(k): v for k, v in self.filter_map.items()}
916
-
917
- def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
918
- """Update the focus position for a specific filter.
892
+ return False
919
893
 
920
- Args:
921
- filter_id: Filter ID as string (0-based index)
922
- focus_position: New focus position in steps
894
+ def supports_filter_management(self) -> bool:
895
+ """Indicates whether this adapter supports filter/focus management.
923
896
 
924
897
  Returns:
925
- bool: True if update was successful, False otherwise
898
+ bool: True if filters were discovered, False otherwise.
926
899
  """
927
- try:
928
- idx = int(filter_id)
929
- if idx in self.filter_map:
930
- self.filter_map[idx]["focus_position"] = focus_position
931
- self.logger.info(f"Updated filter '{self.filter_map[idx]['name']}' focus position to {focus_position}")
932
- return True
933
- else:
934
- self.logger.warning(f"Filter ID {filter_id} not found in filter_map")
935
- return False
936
- except (ValueError, KeyError) as e:
937
- self.logger.error(f"Failed to update filter focus: {e}")
938
- return False
900
+ return bool(self.filter_map)
939
901
 
940
902
  def disconnect(self):
941
903
  raise NotImplementedError
@@ -30,19 +30,13 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
30
30
  SEQUENCE_URL = "/sequence/"
31
31
 
32
32
  def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
33
- super().__init__(images_dir=images_dir)
33
+ super().__init__(images_dir=images_dir, **kwargs)
34
34
  self.logger: logging.Logger = logger
35
35
  self.nina_api_path = kwargs.get("nina_api_path", "http://nina:1888/v2/api")
36
36
 
37
- self.filter_map = {}
38
- # Load filter configuration from settings if available
39
- saved_filters = kwargs.get("filters", {})
40
- for filter_id, filter_data in saved_filters.items():
41
- # Convert string keys back to int for internal use
42
- try:
43
- self.filter_map[int(filter_id)] = filter_data
44
- except (ValueError, TypeError) as e:
45
- self.logger.warning(f"Invalid filter ID '{filter_id}' in settings, skipping: {e}")
37
+ self.binning_x = kwargs.get("binning_x", 1)
38
+ self.binning_y = kwargs.get("binning_y", 1)
39
+ self.autofocus_binning = kwargs.get("autofocus_binning", 1)
46
40
 
47
41
  @classmethod
48
42
  def get_settings_schema(cls) -> list[SettingSchemaEntry]:
@@ -60,21 +54,59 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
60
54
  "placeholder": "http://localhost:1888/v2/api",
61
55
  "pattern": r"^https?://.*",
62
56
  },
57
+ {
58
+ "name": "autofocus_binning",
59
+ "friendly_name": "Autofocus Binning",
60
+ "type": "int",
61
+ "default": 1,
62
+ "description": "Pixel binning for autofocus (1=no binning, 2=2x2, etc.)",
63
+ "required": False,
64
+ "placeholder": "1",
65
+ "min": 1,
66
+ "max": 4,
67
+ },
68
+ {
69
+ "name": "binning_x",
70
+ "friendly_name": "Binning X",
71
+ "type": "int",
72
+ "default": 1,
73
+ "description": "Horizontal pixel binning for observations (1=no binning, 2=2x2, etc.)",
74
+ "required": False,
75
+ "placeholder": "1",
76
+ "min": 1,
77
+ "max": 4,
78
+ },
79
+ {
80
+ "name": "binning_y",
81
+ "friendly_name": "Binning Y",
82
+ "type": "int",
83
+ "default": 1,
84
+ "description": "Vertical pixel binning for observations (1=no binning, 2=2x2, etc.)",
85
+ "required": False,
86
+ "placeholder": "1",
87
+ "min": 1,
88
+ "max": 4,
89
+ },
63
90
  ]
64
91
 
65
92
  def do_autofocus(self):
66
- """Perform autofocus routine for all filters.
93
+ """Perform autofocus routine for all enabled filters.
67
94
 
68
95
  Slews telescope to Mirach (bright reference star) and runs autofocus
69
- for each filter in the filter map, updating focus positions.
96
+ for each enabled filter in the filter map, updating focus positions.
70
97
 
71
98
  Raises:
72
- RuntimeError: If no filters discovered or network requests fail
99
+ RuntimeError: If no filters discovered or no enabled filters
73
100
  """
74
101
  if not self.filter_map:
75
102
  raise RuntimeError("No filters discovered. Cannot perform autofocus.")
76
103
 
77
- self.logger.info("Performing autofocus routine ...")
104
+ # Filter to only enabled filters
105
+ enabled_filters = {fid: fdata for fid, fdata in self.filter_map.items() if fdata.get("enabled", True)}
106
+ if not enabled_filters:
107
+ raise RuntimeError("No enabled filters. Cannot perform autofocus.")
108
+
109
+ self.logger.info(f"Performing autofocus routine on {len(enabled_filters)} enabled filter(s) ...")
78
110
  # move telescope to bright star and start autofocus
79
111
  # Mirach ra=(1+9/60.+47.45/3600.)*15 dec=(35+37/60.+11.1/3600.)
80
112
  ra = (1 + 9 / 60.0 + 47.45 / 3600.0) * 15
@@ -96,7 +128,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
96
128
  self.logger.info("Waiting for mount to finish slewing...")
97
129
  time.sleep(5)
98
130
 
99
- for id, filter in self.filter_map.items():
131
+ for id, filter in enabled_filters.items():
100
132
  self.logger.info(f"Focusing Filter ID: {id}, Name: {filter['name']}")
101
133
  # Pass existing focus position to preserve it if autofocus fails
102
134
  existing_focus = filter.get("focus_position", self.DEFAULT_FOCUS_POSITION)
@@ -190,42 +222,42 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
190
222
  try:
191
223
  # start connection to all equipments
192
224
  self.logger.info("Connecting camera ...")
193
- cam_status = requests.get(self.nina_api_path + self.CAM_URL + "connect").json()
225
+ cam_status = requests.get(self.nina_api_path + self.CAM_URL + "connect", timeout=5).json()
194
226
  if not cam_status["Success"]:
195
227
  self.logger.error(f"Failed to connect camera: {cam_status.get('Error')}")
196
228
  return False
197
229
  self.logger.info(f"Camera Connected!")
198
230
 
199
231
  self.logger.info("Starting camera cooling ...")
200
- cool_status = requests.get(self.nina_api_path + self.CAM_URL + "cool").json()
232
+ cool_status = requests.get(self.nina_api_path + self.CAM_URL + "cool", timeout=5).json()
201
233
  if not cool_status["Success"]:
202
234
  self.logger.warning(f"Failed to start camera cooling: {cool_status.get('Error')}")
203
235
  else:
204
236
  self.logger.info("Cooler started!")
205
237
 
206
238
  self.logger.info("Connecting filterwheel ...")
207
- filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "connect").json()
239
+ filterwheel_status = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "connect", timeout=5).json()
208
240
  if not filterwheel_status["Success"]:
209
241
  self.logger.warning(f"Failed to connect filterwheel: {filterwheel_status.get('Error')}")
210
242
  else:
211
243
  self.logger.info(f"Filterwheel Connected!")
212
244
 
213
245
  self.logger.info("Connecting focuser ...")
214
- focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "connect").json()
246
+ focuser_status = requests.get(self.nina_api_path + self.FOCUSER_URL + "connect", timeout=5).json()
215
247
  if not focuser_status["Success"]:
216
248
  self.logger.warning(f"Failed to connect focuser: {focuser_status.get('Error')}")
217
249
  else:
218
250
  self.logger.info(f"Focuser Connected!")
219
251
 
220
252
  self.logger.info("Connecting mount ...")
221
- mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "connect").json()
253
+ mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "connect", timeout=5).json()
222
254
  if not mount_status["Success"]:
223
255
  self.logger.error(f"Failed to connect mount: {mount_status.get('Error')}")
224
256
  return False
225
257
  self.logger.info(f"Mount Connected!")
226
258
 
227
259
  self.logger.info("Unparking mount ...")
228
- mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "unpark").json()
260
+ mount_status = requests.get(self.nina_api_path + self.MOUNT_URL + "unpark", timeout=5).json()
229
261
  if not mount_status["Success"]:
230
262
  self.logger.error(f"Failed to unpark mount: {mount_status.get('Error')}")
231
263
  return False
@@ -241,7 +273,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
241
273
 
242
274
  def discover_filters(self):
243
275
  self.logger.info("Discovering filters ...")
244
- filterwheel_info = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info").json()
276
+ filterwheel_info = requests.get(self.nina_api_path + self.FILTERWHEEL_URL + "info", timeout=5).json()
245
277
  if not filterwheel_info.get("Success"):
246
278
  self.logger.error(f"Failed to get filterwheel info: {filterwheel_info.get('Error')}")
247
279
  raise RuntimeError("Failed to get filterwheel info")
@@ -250,58 +282,33 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
250
282
  for filter in filters:
251
283
  filter_id = filter["Id"]
252
284
  filter_name = filter["Name"]
253
- # Use existing focus position if filter already in map, otherwise use default
285
+ # Use existing focus position and enabled state if filter already in map
254
286
  if filter_id in self.filter_map:
255
287
  focus_position = self.filter_map[filter_id].get("focus_position", self.DEFAULT_FOCUS_POSITION)
288
+ enabled = self.filter_map[filter_id].get("enabled", True)
256
289
  self.logger.info(
257
- f"Discovered filter: {filter_name} with ID: {filter_id}, using saved focus position: {focus_position}"
290
+ f"Discovered filter: {filter_name} with ID: {filter_id}, using saved focus position: {focus_position}, enabled: {enabled}"
258
291
  )
259
292
  else:
260
293
  focus_position = self.DEFAULT_FOCUS_POSITION
294
+ enabled = True # Default new filters to enabled
261
295
  self.logger.info(
262
296
  f"Discovered new filter: {filter_name} with ID: {filter_id}, using default focus position: {focus_position}"
263
297
  )
264
298
 
265
- self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position}
299
+ self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position, "enabled": enabled}
266
300
 
267
301
  def disconnect(self):
268
302
  pass
269
303
 
304
+ def supports_autofocus(self) -> bool:
305
+ """Indicates that NINA adapter supports autofocus."""
306
+ return True
307
+
270
308
  def supports_filter_management(self) -> bool:
271
309
  """Indicates that NINA adapter supports filter/focus management."""
272
310
  return True
273
311
 
274
- def get_filter_config(self) -> dict[str, FilterConfig]:
275
- """Get current filter configuration with focus positions.
276
-
277
- Returns:
278
- dict: Filter configuration mapping filter ID strings to FilterConfig
279
- """
280
- return {
281
- str(filter_id): {"name": filter_data["name"], "focus_position": filter_data["focus_position"]}
282
- for filter_id, filter_data in self.filter_map.items()
283
- }
284
-
285
- def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
286
- """Update the focus position for a specific filter.
287
-
288
- Args:
289
- filter_id: Filter ID as string
290
- focus_position: New focus position in steps
291
-
292
- Returns:
293
- bool: True if update was successful, False otherwise
294
- """
295
- try:
296
- filter_id_int = int(filter_id)
297
- if filter_id_int in self.filter_map:
298
- self.filter_map[filter_id_int]["focus_position"] = focus_position
299
- self.logger.info(f"Updated filter {filter_id} focus position to {focus_position}")
300
- return True
301
- return False
302
- except (ValueError, KeyError):
303
- return False
304
-
305
312
  def is_telescope_connected(self) -> bool:
306
313
  """Check if telescope is connected and responsive."""
307
314
  try:
@@ -445,8 +452,11 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
445
452
  """
446
453
  elset = satellite_data["most_recent_elset"]
447
454
 
448
- # Load template as JSON
455
+ # Load template as JSON and replace binning placeholders
449
456
  template_str = self._get_sequence_template()
457
+ template_str = template_str.replace("{binning_x}", str(self.binning_x))
458
+ template_str = template_str.replace("{binning_y}", str(self.binning_y))
459
+ template_str = template_str.replace("{autofocus_binning}", str(self.autofocus_binning))
450
460
  sequence_json = json.loads(template_str)
451
461
 
452
462
  nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {task_id}"
@@ -486,7 +496,12 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
486
496
 
487
497
  id_counter = [base_id] # Use list so it can be modified in nested function
488
498
 
489
- for filter_id, filter_info in self.filter_map.items():
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
+
504
+ for filter_id, filter_info in enabled_filters.items():
490
505
  filter_name = filter_info["name"]
491
506
  focus_position = filter_info["focus_position"]
492
507
 
@@ -565,7 +580,7 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
565
580
  raise RuntimeError("Failed to get images list from NINA")
566
581
 
567
582
  images_to_download = []
568
- expected_image_count = len(self.filter_map) # One image per filter
583
+ expected_image_count = len(enabled_filters) # One image per enabled filter
569
584
  images_found = len(images_response["Response"])
570
585
  self.logger.info(
571
586
  f"Found {images_found} images in NINA image history, considering the last {expected_image_count}"
@@ -174,8 +174,8 @@
174
174
  "_autoFocusBinning": {
175
175
  "$id": "31",
176
176
  "$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
177
- "X": 1,
178
- "Y": 1
177
+ "X": {autofocus_binning},
178
+ "Y": {autofocus_binning}
179
179
  },
180
180
  "_autoFocusGain": -1,
181
181
  "_autoFocusOffset": -1
@@ -205,8 +205,8 @@
205
205
  "Binning": {
206
206
  "$id": "34",
207
207
  "$type": "NINA.Core.Model.Equipment.BinningMode, NINA.Core",
208
- "X": 4,
209
- "Y": 4
208
+ "X": {binning_x},
209
+ "Y": {binning_y}
210
210
  },
211
211
  "ImageType": "LIGHT",
212
212
  "ExposureCount": 130,