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.
- {citrascope-0.4.0 → citrascope-0.5.0}/PKG-INFO +1 -1
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/citra_scope_daemon.py +81 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/abstract_astro_hardware_adapter.py +61 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/nina_adv_http_adapter.py +104 -75
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/settings/citrascope_settings.py +21 -27
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/runner.py +30 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/app.py +120 -5
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/app.js +243 -16
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/config.js +186 -23
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/templates/dashboard.html +99 -16
- {citrascope-0.4.0 → citrascope-0.5.0}/pyproject.toml +2 -2
- {citrascope-0.4.0 → citrascope-0.5.0}/.devcontainer/devcontainer.json +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.flake8 +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.github/copilot-instructions.md +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.github/dependabot.yml +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.github/workflows/pypi-publish.yml +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.github/workflows/pytest.yml +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.gitignore +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.pre-commit-config.yaml +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.python-version +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/.vscode/launch.json +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/README.md +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/__init__.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/__main__.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/api/abstract_api_client.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/api/citra_api_client.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/constants.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/adapter_registry.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/indi_adapter.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/kstars_dbus_adapter.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/nina_adv_http_survey_template.json +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/logging/__init__.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/logging/_citrascope_logger.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/logging/web_log_handler.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/settings/__init__.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/settings/settings_file_manager.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/scope/base_telescope_task.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/scope/static_telescope_task.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/scope/tracking_telescope_task.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/tasks/task.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/__init__.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/server.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/api.js +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/img/citra.png +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/img/favicon.png +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/style.css +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/citrascope/web/static/websocket.js +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/tests/unit/test_api_client.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/tests/unit/test_hardware_adapter.py +0 -0
- {citrascope-0.4.0 → citrascope-0.5.0}/tests/unit/test_task_manager.py +0 -0
- {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.
|
|
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
|
{citrascope-0.4.0 → citrascope-0.5.0}/citrascope/hardware/abstract_astro_hardware_adapter.py
RENAMED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
112
|
-
self.nina_api_path
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
267
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
115
|
-
|
|
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:
|
|
120
|
-
|
|
121
|
-
Returns:
|
|
122
|
-
New CitraScopeSettings instance.
|
|
126
|
+
config: Configuration dict with flat adapter_settings for current adapter.
|
|
123
127
|
"""
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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)
|