citrascope 0.4.0__tar.gz → 0.5.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 (51) hide show
  1. {citrascope-0.4.0 → citrascope-0.5.0}/PKG-INFO +1 -1
  2. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/citra_scope_daemon.py +81 -0
  3. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/abstract_astro_hardware_adapter.py +61 -0
  4. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/nina_adv_http_adapter.py +104 -75
  5. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/settings/citrascope_settings.py +21 -27
  6. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/runner.py +30 -0
  7. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/app.py +120 -5
  8. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/app.js +243 -16
  9. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/config.js +186 -23
  10. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/templates/dashboard.html +99 -16
  11. {citrascope-0.4.0 → citrascope-0.5.0}/pyproject.toml +2 -2
  12. {citrascope-0.4.0 → citrascope-0.5.0}/.devcontainer/devcontainer.json +0 -0
  13. {citrascope-0.4.0 → citrascope-0.5.0}/.flake8 +0 -0
  14. {citrascope-0.4.0 → citrascope-0.5.0}/.github/copilot-instructions.md +0 -0
  15. {citrascope-0.4.0 → citrascope-0.5.0}/.github/dependabot.yml +0 -0
  16. {citrascope-0.4.0 → citrascope-0.5.0}/.github/workflows/pypi-publish.yml +0 -0
  17. {citrascope-0.4.0 → citrascope-0.5.0}/.github/workflows/pytest.yml +0 -0
  18. {citrascope-0.4.0 → citrascope-0.5.0}/.gitignore +0 -0
  19. {citrascope-0.4.0 → citrascope-0.5.0}/.pre-commit-config.yaml +0 -0
  20. {citrascope-0.4.0 → citrascope-0.5.0}/.python-version +0 -0
  21. {citrascope-0.4.0 → citrascope-0.5.0}/.vscode/launch.json +0 -0
  22. {citrascope-0.4.0 → citrascope-0.5.0}/README.md +0 -0
  23. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/__init__.py +0 -0
  24. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/__main__.py +0 -0
  25. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/api/abstract_api_client.py +0 -0
  26. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/api/citra_api_client.py +0 -0
  27. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/constants.py +0 -0
  28. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/adapter_registry.py +0 -0
  29. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/indi_adapter.py +0 -0
  30. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/kstars_dbus_adapter.py +0 -0
  31. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/nina_adv_http_survey_template.json +0 -0
  32. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/logging/__init__.py +0 -0
  33. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/logging/_citrascope_logger.py +0 -0
  34. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/logging/web_log_handler.py +0 -0
  35. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/settings/__init__.py +0 -0
  36. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/settings/settings_file_manager.py +0 -0
  37. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/scope/base_telescope_task.py +0 -0
  38. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/scope/static_telescope_task.py +0 -0
  39. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/scope/tracking_telescope_task.py +0 -0
  40. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/task.py +0 -0
  41. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/__init__.py +0 -0
  42. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/server.py +0 -0
  43. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/api.js +0 -0
  44. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/img/citra.png +0 -0
  45. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/img/favicon.png +0 -0
  46. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/style.css +0 -0
  47. {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/websocket.js +0 -0
  48. {citrascope-0.4.0 → citrascope-0.5.0}/tests/unit/test_api_client.py +0 -0
  49. {citrascope-0.4.0 → citrascope-0.5.0}/tests/unit/test_hardware_adapter.py +0 -0
  50. {citrascope-0.4.0 → citrascope-0.5.0}/tests/unit/test_task_manager.py +0 -0
  51. {citrascope-0.4.0 → citrascope-0.5.0}/tests/unit/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: citrascope
3
- Version: 0.4.0
3
+ Version: 0.5.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
  Author-email: Patrick McDavid <patrick@citra.space>
6
6
  License-Expression: MIT
@@ -35,6 +35,7 @@ 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
38
39
 
39
40
  # Create web server instance (always enabled)
40
41
  self.web_server = CitraScopeWebServer(daemon=self, host="0.0.0.0", port=self.settings.web_port)
@@ -177,6 +178,9 @@ class CitraScopeDaemon:
177
178
  f"Hardware connected. Slew rate: {self.hardware_adapter.scope_slew_rate_degrees_per_second} deg/sec"
178
179
  )
179
180
 
181
+ # Save filter configuration if adapter supports it
182
+ self._save_filter_config()
183
+
180
184
  self.task_manager = TaskManager(
181
185
  self.api_client,
182
186
  citra_telescope_record,
@@ -196,6 +200,83 @@ class CitraScopeDaemon:
196
200
  CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
197
201
  return False, error_msg
198
202
 
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
+ def _save_filter_config(self):
212
+ """Save filter configuration from adapter to settings if supported.
213
+
214
+ This method is called:
215
+ - After hardware initialization to save discovered filters
216
+ - After autofocus to save updated focus positions
217
+ - After manual filter focus updates via web API
218
+
219
+ Thread safety: This modifies self.settings and writes to disk.
220
+ Should be called from main daemon thread or properly synchronized.
221
+ """
222
+ if not self.hardware_adapter or not self.hardware_adapter.supports_filter_management():
223
+ return
224
+
225
+ try:
226
+ filter_config = self.hardware_adapter.get_filter_config()
227
+ if filter_config:
228
+ self.settings.adapter_settings["filters"] = filter_config
229
+ self.settings.save()
230
+ CITRASCOPE_LOGGER.info(f"Saved filter configuration with {len(filter_config)} filters")
231
+ except Exception as e:
232
+ CITRASCOPE_LOGGER.warning(f"Failed to save filter configuration: {e}")
233
+
234
+ 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
241
+
242
+ Returns:
243
+ Tuple of (success, error_message)
244
+ """
245
+ if not self.hardware_adapter:
246
+ return False, "No hardware adapter initialized"
247
+
248
+ if not self.hardware_adapter.supports_filter_management():
249
+ return False, "Hardware adapter does not support filter management"
250
+
251
+ # Prevent concurrent autofocus operations
252
+ if self._autofocus_in_progress:
253
+ return False, "Autofocus already in progress"
254
+
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"
259
+
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"
262
+
263
+ self._autofocus_in_progress = True
264
+ try:
265
+ CITRASCOPE_LOGGER.info("Starting autofocus routine...")
266
+ self.hardware_adapter.do_autofocus()
267
+
268
+ # Save updated filter configuration after autofocus
269
+ self._save_filter_config()
270
+
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
279
+
199
280
  def run(self):
200
281
  # Start web server FIRST, so users can monitor/configure
201
282
  # The web interface will remain available even if configuration is incomplete
@@ -20,6 +20,18 @@ class SettingSchemaEntry(TypedDict, total=False):
20
20
  options: list[str] # List of valid options for select/dropdown inputs
21
21
 
22
22
 
23
+ class FilterConfig(TypedDict):
24
+ """Type definition for filter configuration.
25
+
26
+ Attributes:
27
+ name: Human-readable filter name (e.g., 'Luminance', 'Red', 'Ha')
28
+ focus_position: Focuser position for this filter in steps
29
+ """
30
+
31
+ name: str
32
+ focus_position: int
33
+
34
+
23
35
  class ObservationStrategy(Enum):
24
36
  MANUAL = 1
25
37
  SEQUENCE_TO_CONTROLLER = 2
@@ -176,3 +188,52 @@ class AbstractAstroHardwareAdapter(ABC):
176
188
  bool: True if alignment was successful, False otherwise.
177
189
  """
178
190
  pass
191
+
192
+ def do_autofocus(self) -> None:
193
+ """Perform autofocus routine for all filters.
194
+
195
+ This is an optional method for adapters that support filter management.
196
+ Default implementation raises NotImplementedError. Override in subclasses
197
+ that support autofocus.
198
+
199
+ Raises:
200
+ NotImplementedError: If the adapter doesn't support autofocus
201
+ """
202
+ raise NotImplementedError(f"{self.__class__.__name__} does not support autofocus")
203
+
204
+ def supports_filter_management(self) -> bool:
205
+ """Indicates whether this adapter supports filter/focus management.
206
+
207
+ Returns:
208
+ bool: True if the adapter manages filters and focus positions, False otherwise.
209
+ """
210
+ return False
211
+
212
+ def get_filter_config(self) -> dict[str, FilterConfig]:
213
+ """Get the current filter configuration including focus positions.
214
+
215
+ Returns:
216
+ dict: Dictionary mapping filter IDs (as strings) to FilterConfig.
217
+ Each FilterConfig contains:
218
+ - name (str): Filter name
219
+ - focus_position (int): Focuser position for this filter
220
+
221
+ Example:
222
+ {
223
+ "1": {"name": "Luminance", "focus_position": 9000},
224
+ "2": {"name": "Red", "focus_position": 9050}
225
+ }
226
+ """
227
+ return {}
228
+
229
+ def update_filter_focus(self, filter_id: str, focus_position: int) -> bool:
230
+ """Update the focus position for a specific filter.
231
+
232
+ Args:
233
+ filter_id: Filter ID as string
234
+ focus_position: New focus position in steps
235
+
236
+ Returns:
237
+ bool: True if update was successful, False otherwise
238
+ """
239
+ return False
@@ -5,16 +5,16 @@ import os
5
5
  import sys
6
6
  import time
7
7
  from pathlib import Path
8
+ from typing import Any
8
9
 
9
- import platformdirs
10
10
  import requests
11
11
 
12
12
  from citrascope.hardware.abstract_astro_hardware_adapter import (
13
13
  AbstractAstroHardwareAdapter,
14
+ FilterConfig,
14
15
  ObservationStrategy,
15
16
  SettingSchemaEntry,
16
17
  )
17
- from citrascope.settings.citrascope_settings import APP_AUTHOR, APP_NAME
18
18
 
19
19
 
20
20
  class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
@@ -32,47 +32,17 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
32
32
  def __init__(self, logger: logging.Logger, images_dir: Path, **kwargs):
33
33
  super().__init__(images_dir=images_dir)
34
34
  self.logger: logging.Logger = logger
35
- self._data_dir = Path(platformdirs.user_data_dir(APP_NAME, appauthor=APP_AUTHOR))
36
- self._focus_positions_file = self._data_dir / "nina_focus_positions.json"
37
35
  self.nina_api_path = kwargs.get("nina_api_path", "http://nina:1888/v2/api")
38
- self.bypass_autofocus = kwargs.get("bypass_autofocus", False)
39
36
 
40
37
  self.filter_map = {}
41
- self._load_focus_positions()
42
-
43
- def _load_focus_positions(self):
44
- """Load focus positions from file if available."""
45
- try:
46
- if self._focus_positions_file.exists():
47
- with open(self._focus_positions_file, "r") as f:
48
- focus_data = json.load(f)
49
- if self.logger:
50
- self.logger.info(f"Loaded focus positions from {self._focus_positions_file}")
51
- self._focus_positions_cache = focus_data
52
- else:
53
- self._focus_positions_cache = {}
54
- except Exception as e:
55
- if self.logger:
56
- self.logger.warning(f"Could not load focus positions file: {e}")
57
- self._focus_positions_cache = {}
58
-
59
- def _save_focus_positions(self):
60
- """Save current filter_map focus positions to file."""
61
- try:
62
- # Ensure data directory exists
63
- self._data_dir.mkdir(parents=True, exist_ok=True)
64
-
65
- focus_data = {
66
- str(fid): {"name": fdata["name"], "focus_position": fdata["focus_position"]}
67
- for fid, fdata in self.filter_map.items()
68
- }
69
- with open(self._focus_positions_file, "w") as f:
70
- json.dump(focus_data, f, indent=2)
71
- if self.logger:
72
- self.logger.info(f"Saved focus positions to {self._focus_positions_file}")
73
- except Exception as e:
74
- if self.logger:
75
- self.logger.warning(f"Could not save focus positions file: {e}")
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}")
76
46
 
77
47
  @classmethod
78
48
  def get_settings_schema(cls) -> list[SettingSchemaEntry]:
@@ -90,17 +60,20 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
90
60
  "placeholder": "http://localhost:1888/v2/api",
91
61
  "pattern": r"^https?://.*",
92
62
  },
93
- {
94
- "name": "bypass_autofocus",
95
- "friendly_name": "Bypass Autofocus",
96
- "type": "bool",
97
- "default": False,
98
- "description": "Skip autofocus routine when initializing, will use cached focus positions if available",
99
- "required": False,
100
- },
101
63
  ]
102
64
 
103
65
  def do_autofocus(self):
66
+ """Perform autofocus routine for all filters.
67
+
68
+ Slews telescope to Mirach (bright reference star) and runs autofocus
69
+ for each filter in the filter map, updating focus positions.
70
+
71
+ Raises:
72
+ RuntimeError: If no filters discovered or network requests fail
73
+ """
74
+ if not self.filter_map:
75
+ raise RuntimeError("No filters discovered. Cannot perform autofocus.")
76
+
104
77
  self.logger.info("Performing autofocus routine ...")
105
78
  # move telescope to bright star and start autofocus
106
79
  # Mirach ra=(1+9/60.+47.45/3600.)*15 dec=(35+37/60.+11.1/3600.)
@@ -108,10 +81,15 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
108
81
  dec = 35 + 37 / 60.0 + 11.1 / 3600.0
109
82
 
110
83
  self.logger.info("Slewing to Mirach ...")
111
- mount_status = requests.get(
112
- self.nina_api_path + self.MOUNT_URL + "slew?ra=" + str(ra) + "&dec=" + str(dec)
113
- ).json()
114
- self.logger.info(f"Mount {mount_status['Response']}")
84
+ try:
85
+ response = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}slew?ra={ra}&dec={dec}", timeout=30)
86
+ response.raise_for_status()
87
+ mount_status = response.json()
88
+ self.logger.info(f"Mount {mount_status['Response']}")
89
+ except requests.Timeout:
90
+ raise RuntimeError("Mount slew request timed out")
91
+ except requests.RequestException as e:
92
+ raise RuntimeError(f"Mount slew failed: {e}")
115
93
 
116
94
  # wait for slew to complete
117
95
  while self.telescope_is_moving():
@@ -120,14 +98,13 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
120
98
 
121
99
  for id, filter in self.filter_map.items():
122
100
  self.logger.info(f"Focusing Filter ID: {id}, Name: {filter['name']}")
123
- focus_value = self._auto_focus_one_filter(id, filter["name"])
101
+ # Pass existing focus position to preserve it if autofocus fails
102
+ existing_focus = filter.get("focus_position", self.DEFAULT_FOCUS_POSITION)
103
+ focus_value = self._auto_focus_one_filter(id, filter["name"], existing_focus)
124
104
  self.filter_map[id]["focus_position"] = focus_value
125
105
 
126
- # Save focus positions after autofocus
127
- self._save_focus_positions()
128
-
129
106
  # autofocus routine
130
- def _auto_focus_one_filter(self, filter_id, filter_name) -> int:
107
+ def _auto_focus_one_filter(self, filter_id: int, filter_name: str, existing_focus_position: int) -> int:
131
108
 
132
109
  # change to the requested filter
133
110
  correct_filter_in_place = False
@@ -165,8 +142,13 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
165
142
 
166
143
  if not last_completed_autofocus.get("Success"):
167
144
  self.logger.error(f"Failed to start autofocus: {last_completed_autofocus.get('Error')}")
168
- self.logger.warning("Using default focus position")
169
- return starting_focus_position
145
+ # Preserve existing focus position if it's not the default, otherwise use default
146
+ if existing_focus_position != self.DEFAULT_FOCUS_POSITION:
147
+ self.logger.warning(f"Preserving existing focus position: {existing_focus_position}")
148
+ return existing_focus_position
149
+ else:
150
+ self.logger.warning(f"Using default focus position: {self.DEFAULT_FOCUS_POSITION}")
151
+ return self.DEFAULT_FOCUS_POSITION
170
152
 
171
153
  while (
172
154
  last_completed_autofocus["Response"]["Filter"] != filter_name
@@ -186,13 +168,22 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
186
168
 
187
169
  def _do_point_telescope(self, ra: float, dec: float):
188
170
  self.logger.info(f"Slewing to RA: {ra}, Dec: {dec}")
189
- slew_response = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}slew?ra={ra}&dec={dec}").json()
171
+ try:
172
+ response = requests.get(f"{self.nina_api_path}{self.MOUNT_URL}slew?ra={ra}&dec={dec}", timeout=30)
173
+ response.raise_for_status()
174
+ slew_response = response.json()
190
175
 
191
- if slew_response.get("Success"):
192
- self.logger.info(f"Mount slew initiated: {slew_response['Response']}")
193
- return True
194
- else:
195
- self.logger.error(f"Failed to slew mount: {slew_response.get('Error')}")
176
+ if slew_response.get("Success"):
177
+ self.logger.info(f"Mount slew initiated: {slew_response['Response']}")
178
+ return True
179
+ else:
180
+ self.logger.error(f"Failed to slew mount: {slew_response.get('Error')}")
181
+ return False
182
+ except requests.Timeout:
183
+ self.logger.error("Mount slew request timed out")
184
+ return False
185
+ except requests.RequestException as e:
186
+ self.logger.error(f"Mount slew request failed: {e}")
196
187
  return False
197
188
 
198
189
  def connect(self) -> bool:
@@ -240,12 +231,8 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
240
231
  return False
241
232
  self.logger.info(f"Mount Unparked!")
242
233
 
243
- # make a map of available filters and their focus positions
234
+ # Discover available filters (focus positions loaded from saved settings)
244
235
  self.discover_filters()
245
- if not self.bypass_autofocus:
246
- self.do_autofocus()
247
- else:
248
- self.logger.info("Bypassing autofocus routine as requested")
249
236
 
250
237
  return True
251
238
  except Exception as e:
@@ -263,16 +250,58 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
263
250
  for filter in filters:
264
251
  filter_id = filter["Id"]
265
252
  filter_name = filter["Name"]
266
- # Try to load focus position from cache, fallback to default
267
- focus_position = self._focus_positions_cache.get(str(filter_id), {}).get(
268
- "focus_position", self.DEFAULT_FOCUS_POSITION
269
- )
253
+ # Use existing focus position if filter already in map, otherwise use default
254
+ if filter_id in self.filter_map:
255
+ focus_position = self.filter_map[filter_id].get("focus_position", self.DEFAULT_FOCUS_POSITION)
256
+ self.logger.info(
257
+ f"Discovered filter: {filter_name} with ID: {filter_id}, using saved focus position: {focus_position}"
258
+ )
259
+ else:
260
+ focus_position = self.DEFAULT_FOCUS_POSITION
261
+ self.logger.info(
262
+ f"Discovered new filter: {filter_name} with ID: {filter_id}, using default focus position: {focus_position}"
263
+ )
264
+
270
265
  self.filter_map[filter_id] = {"name": filter_name, "focus_position": focus_position}
271
- self.logger.info(f"Discovered filter: {filter_name} with ID: {filter_id}, focus position: {focus_position}")
272
266
 
273
267
  def disconnect(self):
274
268
  pass
275
269
 
270
+ def supports_filter_management(self) -> bool:
271
+ """Indicates that NINA adapter supports filter/focus management."""
272
+ return True
273
+
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
+
276
305
  def is_telescope_connected(self) -> bool:
277
306
  """Check if telescope is connected and responsive."""
278
307
  try:
@@ -42,8 +42,12 @@ class CitraScopeSettings:
42
42
  # Hardware adapter selection
43
43
  self.hardware_adapter: str = config.get("hardware_adapter", "")
44
44
 
45
- # Hardware adapter-specific settings stored as dict
46
- self.adapter_settings: Dict[str, Any] = config.get("adapter_settings", {})
45
+ # Hardware adapter-specific settings stored as nested dict per adapter
46
+ # Format: {"adapter_name": {"setting_key": value, ...}, ...}
47
+ self._all_adapter_settings: Dict[str, Dict[str, Any]] = config.get("adapter_settings", {})
48
+
49
+ # Current adapter's settings slice
50
+ self.adapter_settings: Dict[str, Any] = self._all_adapter_settings.get(self.hardware_adapter, {})
47
51
 
48
52
  # Runtime settings (all loaded from config file, configurable via web UI)
49
53
  self.log_level: str = config.get("log_level", "INFO")
@@ -95,7 +99,7 @@ class CitraScopeSettings:
95
99
  "personal_access_token": self.personal_access_token,
96
100
  "telescope_id": self.telescope_id,
97
101
  "hardware_adapter": self.hardware_adapter,
98
- "adapter_settings": self.adapter_settings,
102
+ "adapter_settings": self._all_adapter_settings,
99
103
  "log_level": self.log_level,
100
104
  "keep_images": self.keep_images,
101
105
  "web_port": self.web_port,
@@ -108,33 +112,23 @@ class CitraScopeSettings:
108
112
 
109
113
  def save(self) -> None:
110
114
  """Save current settings to JSON config file."""
115
+ # Update nested dict with current adapter's settings before saving
116
+ if self.hardware_adapter:
117
+ self._all_adapter_settings[self.hardware_adapter] = self.adapter_settings
118
+
111
119
  self.config_manager.save_config(self.to_dict())
112
120
  CITRASCOPE_LOGGER.info(f"Configuration saved to {self.config_manager.get_config_path()}")
113
121
 
114
- @classmethod
115
- def from_dict(cls, config: Dict[str, Any]) -> "CitraScopeSettings":
116
- """Create settings instance from dictionary.
122
+ def update_and_save(self, config: Dict[str, Any]) -> None:
123
+ """Update settings from dict and save, preserving other adapters' settings.
117
124
 
118
125
  Args:
119
- config: Dictionary of configuration values.
120
-
121
- Returns:
122
- New CitraScopeSettings instance.
126
+ config: Configuration dict with flat adapter_settings for current adapter.
123
127
  """
124
- settings = cls()
125
- settings.host = config.get("host", settings.host)
126
- settings.port = config.get("port", settings.port)
127
- settings.use_ssl = config.get("use_ssl", settings.use_ssl)
128
- settings.personal_access_token = config.get("personal_access_token", "")
129
- settings.telescope_id = config.get("telescope_id", "")
130
- settings.hardware_adapter = config.get("hardware_adapter", "")
131
- settings.adapter_settings = config.get("adapter_settings", {})
132
- settings.log_level = config.get("log_level", "INFO")
133
- settings.keep_images = config.get("keep_images", False)
134
- settings.web_port = config.get("web_port", DEFAULT_WEB_PORT)
135
- settings.max_task_retries = config.get("max_task_retries", 3)
136
- settings.initial_retry_delay_seconds = config.get("initial_retry_delay_seconds", 30)
137
- settings.max_retry_delay_seconds = config.get("max_retry_delay_seconds", 300)
138
- settings.file_logging_enabled = config.get("file_logging_enabled", True)
139
- settings.log_retention_days = config.get("log_retention_days", 30)
140
- return settings
128
+ # Nest incoming adapter_settings under hardware_adapter key
129
+ adapter = config.get("hardware_adapter", self.hardware_adapter)
130
+ if adapter:
131
+ self._all_adapter_settings[adapter] = config.get("adapter_settings", {})
132
+ config["adapter_settings"] = self._all_adapter_settings
133
+
134
+ self.config_manager.save_config(config)
@@ -40,6 +40,9 @@ class TaskManager:
40
40
  self.keep_images = keep_images
41
41
  self.task_retry_counts = {} # Track retry attempts per task ID
42
42
  self.task_last_failure = {} # Track last failure timestamp per task ID
43
+ # Task processing control (always starts active)
44
+ self._processing_active = True
45
+ self._processing_lock = threading.Lock()
43
46
 
44
47
  def poll_tasks(self):
45
48
  while not self._stop_event.is_set():
@@ -130,6 +133,14 @@ class TaskManager:
130
133
 
131
134
  def task_runner(self):
132
135
  while not self._stop_event.is_set():
136
+ # Check if task processing is paused
137
+ with self._processing_lock:
138
+ is_paused = not self._processing_active
139
+
140
+ if is_paused:
141
+ self._stop_event.wait(1)
142
+ continue
143
+
133
144
  try:
134
145
  now = int(time.time())
135
146
  completed = 0
@@ -243,6 +254,25 @@ class TaskManager:
243
254
  summary += "No tasks scheduled."
244
255
  return summary
245
256
 
257
+ def pause(self) -> bool:
258
+ """Pause task processing. Returns new state (False)."""
259
+ with self._processing_lock:
260
+ self._processing_active = False
261
+ self.logger.info("Task processing paused")
262
+ return self._processing_active
263
+
264
+ def resume(self) -> bool:
265
+ """Resume task processing. Returns new state (True)."""
266
+ with self._processing_lock:
267
+ self._processing_active = True
268
+ self.logger.info("Task processing resumed")
269
+ return self._processing_active
270
+
271
+ def is_processing_active(self) -> bool:
272
+ """Check if task processing is currently active."""
273
+ with self._processing_lock:
274
+ return self._processing_active
275
+
246
276
  def start(self):
247
277
  self._stop_event.clear()
248
278
  self.poll_thread = threading.Thread(target=self.poll_tasks, daemon=True)