citrascope 0.3.0__py3-none-any.whl → 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citrascope/__main__.py +12 -15
- citrascope/api/citra_api_client.py +13 -1
- citrascope/citra_scope_daemon.py +91 -19
- citrascope/constants.py +23 -0
- citrascope/hardware/abstract_astro_hardware_adapter.py +61 -0
- citrascope/hardware/nina_adv_http_adapter.py +106 -77
- citrascope/logging/web_log_handler.py +9 -8
- citrascope/settings/citrascope_settings.py +34 -45
- citrascope/tasks/runner.py +36 -2
- citrascope/web/app.py +137 -13
- citrascope/web/server.py +10 -1
- citrascope/web/static/app.js +246 -17
- citrascope/web/static/config.js +248 -9
- citrascope/web/static/style.css +32 -0
- citrascope/web/templates/dashboard.html +143 -7
- {citrascope-0.3.0.dist-info → citrascope-0.5.0.dist-info}/METADATA +40 -32
- {citrascope-0.3.0.dist-info → citrascope-0.5.0.dist-info}/RECORD +19 -19
- docs/index.md +0 -47
- {citrascope-0.3.0.dist-info → citrascope-0.5.0.dist-info}/WHEEL +0 -0
- {citrascope-0.3.0.dist-info → citrascope-0.5.0.dist-info}/entry_points.txt +0 -0
citrascope/__main__.py
CHANGED
|
@@ -1,24 +1,21 @@
|
|
|
1
1
|
import click
|
|
2
2
|
|
|
3
3
|
from citrascope.citra_scope_daemon import CitraScopeDaemon
|
|
4
|
+
from citrascope.constants import DEFAULT_WEB_PORT
|
|
4
5
|
from citrascope.settings.citrascope_settings import CitraScopeSettings
|
|
5
6
|
|
|
6
7
|
|
|
7
|
-
@click.
|
|
8
|
-
@click.option(
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
@click.option("--web-port", default=24872, type=int, help="Web server port (default: 24872)")
|
|
19
|
-
@click.pass_context
|
|
20
|
-
def start(ctx, web_host, web_port):
|
|
21
|
-
daemon = CitraScopeDaemon(ctx.obj["settings"], enable_web=True, web_host=web_host, web_port=web_port)
|
|
8
|
+
@click.command()
|
|
9
|
+
@click.option(
|
|
10
|
+
"--web-port",
|
|
11
|
+
default=DEFAULT_WEB_PORT,
|
|
12
|
+
type=int,
|
|
13
|
+
help=f"Web server port (default: {DEFAULT_WEB_PORT})",
|
|
14
|
+
)
|
|
15
|
+
def cli(web_port):
|
|
16
|
+
"""CitraScope daemon - configure via web UI at http://localhost:24872"""
|
|
17
|
+
settings = CitraScopeSettings(web_port=web_port)
|
|
18
|
+
daemon = CitraScopeDaemon(settings)
|
|
22
19
|
daemon.run()
|
|
23
20
|
|
|
24
21
|
|
|
@@ -41,7 +41,19 @@ class CitraApiClient(AbstractCitraApiClient):
|
|
|
41
41
|
return resp.json()
|
|
42
42
|
except httpx.HTTPStatusError as e:
|
|
43
43
|
if self.logger:
|
|
44
|
-
|
|
44
|
+
# Check if response is HTML (e.g., Cloudflare error pages)
|
|
45
|
+
content_type = e.response.headers.get("content-type", "")
|
|
46
|
+
response_text = e.response.text
|
|
47
|
+
|
|
48
|
+
if "text/html" in content_type or response_text.strip().startswith("<"):
|
|
49
|
+
# Log only status and a brief message for HTML responses, sometimes we get Cloudflare error pages
|
|
50
|
+
self.logger.error(
|
|
51
|
+
f"HTTP error: {e.response.status_code} - "
|
|
52
|
+
f"Received HTML error page (likely Cloudflare or server error) for {method} {endpoint}"
|
|
53
|
+
)
|
|
54
|
+
else:
|
|
55
|
+
# Log full response for non-HTML errors (JSON, plain text, etc.)
|
|
56
|
+
self.logger.error(f"HTTP error: {e.response.status_code} {response_text}")
|
|
45
57
|
return None
|
|
46
58
|
except Exception as e:
|
|
47
59
|
if self.logger:
|
citrascope/citra_scope_daemon.py
CHANGED
|
@@ -17,9 +17,6 @@ class CitraScopeDaemon:
|
|
|
17
17
|
settings: CitraScopeSettings,
|
|
18
18
|
api_client: Optional[AbstractCitraApiClient] = None,
|
|
19
19
|
hardware_adapter: Optional[AbstractAstroHardwareAdapter] = None,
|
|
20
|
-
enable_web: bool = True,
|
|
21
|
-
web_host: str = "0.0.0.0",
|
|
22
|
-
web_port: int = 24872,
|
|
23
20
|
):
|
|
24
21
|
self.settings = settings
|
|
25
22
|
CITRASCOPE_LOGGER.setLevel(self.settings.log_level)
|
|
@@ -33,16 +30,15 @@ class CitraScopeDaemon:
|
|
|
33
30
|
|
|
34
31
|
self.api_client = api_client
|
|
35
32
|
self.hardware_adapter = hardware_adapter
|
|
36
|
-
self.enable_web = enable_web
|
|
37
33
|
self.web_server = None
|
|
38
34
|
self.task_manager = None
|
|
39
35
|
self.ground_station = None
|
|
40
36
|
self.telescope_record = None
|
|
41
37
|
self.configuration_error: Optional[str] = None
|
|
38
|
+
self._autofocus_in_progress = False
|
|
42
39
|
|
|
43
|
-
# Create web server instance
|
|
44
|
-
|
|
45
|
-
self.web_server = CitraScopeWebServer(daemon=self, host=web_host, port=web_port)
|
|
40
|
+
# Create web server instance (always enabled)
|
|
41
|
+
self.web_server = CitraScopeWebServer(daemon=self, host="0.0.0.0", port=self.settings.web_port)
|
|
46
42
|
|
|
47
43
|
def _create_hardware_adapter(self) -> AbstractAstroHardwareAdapter:
|
|
48
44
|
"""Factory method to create the appropriate hardware adapter based on settings."""
|
|
@@ -73,12 +69,8 @@ class CitraScopeDaemon:
|
|
|
73
69
|
try:
|
|
74
70
|
if reload_settings:
|
|
75
71
|
CITRASCOPE_LOGGER.info("Reloading configuration...")
|
|
76
|
-
# Reload settings from file
|
|
77
|
-
new_settings = CitraScopeSettings(
|
|
78
|
-
dev=("dev.api" in self.settings.host),
|
|
79
|
-
log_level=self.settings.log_level,
|
|
80
|
-
keep_images=self.settings.keep_images,
|
|
81
|
-
)
|
|
72
|
+
# Reload settings from file (preserving web_port)
|
|
73
|
+
new_settings = CitraScopeSettings(web_port=self.settings.web_port)
|
|
82
74
|
self.settings = new_settings
|
|
83
75
|
CITRASCOPE_LOGGER.setLevel(self.settings.log_level)
|
|
84
76
|
|
|
@@ -186,6 +178,9 @@ class CitraScopeDaemon:
|
|
|
186
178
|
f"Hardware connected. Slew rate: {self.hardware_adapter.scope_slew_rate_degrees_per_second} deg/sec"
|
|
187
179
|
)
|
|
188
180
|
|
|
181
|
+
# Save filter configuration if adapter supports it
|
|
182
|
+
self._save_filter_config()
|
|
183
|
+
|
|
189
184
|
self.task_manager = TaskManager(
|
|
190
185
|
self.api_client,
|
|
191
186
|
citra_telescope_record,
|
|
@@ -205,12 +200,88 @@ class CitraScopeDaemon:
|
|
|
205
200
|
CITRASCOPE_LOGGER.error(error_msg, exc_info=True)
|
|
206
201
|
return False, error_msg
|
|
207
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
|
+
|
|
208
280
|
def run(self):
|
|
209
|
-
# Start web server FIRST
|
|
281
|
+
# Start web server FIRST, so users can monitor/configure
|
|
210
282
|
# The web interface will remain available even if configuration is incomplete
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
CITRASCOPE_LOGGER.info(f"Web interface available at http://{self.web_server.host}:{self.web_server.port}")
|
|
283
|
+
self.web_server.start()
|
|
284
|
+
CITRASCOPE_LOGGER.info(f"Web interface available at http://{self.web_server.host}:{self.web_server.port}")
|
|
214
285
|
|
|
215
286
|
try:
|
|
216
287
|
# Try to initialize components
|
|
@@ -238,6 +309,7 @@ class CitraScopeDaemon:
|
|
|
238
309
|
"""Clean up resources on shutdown."""
|
|
239
310
|
if self.task_manager:
|
|
240
311
|
self.task_manager.stop()
|
|
241
|
-
if self.
|
|
312
|
+
if self.web_server:
|
|
242
313
|
CITRASCOPE_LOGGER.info("Stopping web server...")
|
|
243
|
-
self.web_server.
|
|
314
|
+
if self.web_server.web_log_handler:
|
|
315
|
+
CITRASCOPE_LOGGER.removeHandler(self.web_server.web_log_handler)
|
citrascope/constants.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Application-wide constants for CitraScope.
|
|
2
|
+
|
|
3
|
+
This module contains all shared constants used across different parts of the application.
|
|
4
|
+
Centralizing these values prevents duplication and circular import issues.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# ============================================================================
|
|
8
|
+
# API ENDPOINTS
|
|
9
|
+
# ============================================================================
|
|
10
|
+
PROD_API_HOST = "api.citra.space"
|
|
11
|
+
DEV_API_HOST = "dev.api.citra.space"
|
|
12
|
+
DEFAULT_API_PORT = 443
|
|
13
|
+
|
|
14
|
+
# ============================================================================
|
|
15
|
+
# WEB APP URLs
|
|
16
|
+
# ============================================================================
|
|
17
|
+
PROD_APP_URL = "https://app.citra.space"
|
|
18
|
+
DEV_APP_URL = "https://dev.app.citra.space"
|
|
19
|
+
|
|
20
|
+
# ============================================================================
|
|
21
|
+
# WEB SERVER
|
|
22
|
+
# ============================================================================
|
|
23
|
+
DEFAULT_WEB_PORT = 24872 # "CITRA" on phone keypad
|
|
@@ -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:
|
|
@@ -422,8 +451,8 @@ class NinaAdvancedHttpAdapter(AbstractAstroHardwareAdapter):
|
|
|
422
451
|
|
|
423
452
|
nina_sequence_name = f"Citra Target: {satellite_data['name']}, Task: {task_id}"
|
|
424
453
|
|
|
425
|
-
# Replace basic placeholders
|
|
426
|
-
tle_data = f"{elset['tle'][0]}\n{elset['tle'][1]}"
|
|
454
|
+
# Replace basic placeholders (use \r\n for Windows NINA compatibility)
|
|
455
|
+
tle_data = f"{elset['tle'][0]}\r\n{elset['tle'][1]}"
|
|
427
456
|
sequence_json["Name"] = nina_sequence_name
|
|
428
457
|
|
|
429
458
|
# Navigate to the TLE container (ID 20 in the template)
|
|
@@ -23,14 +23,15 @@ class WebLogHandler(logging.Handler):
|
|
|
23
23
|
def emit(self, record):
|
|
24
24
|
"""Emit a log record."""
|
|
25
25
|
try:
|
|
26
|
-
# Filter out web-related logs from the web UI
|
|
27
|
-
#
|
|
28
|
-
if
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
# Filter out routine web-related logs from the web UI
|
|
27
|
+
# but keep ERROR and CRITICAL level messages
|
|
28
|
+
if record.levelno < logging.ERROR:
|
|
29
|
+
if (
|
|
30
|
+
record.name.startswith("uvicorn")
|
|
31
|
+
or "WebSocket" in record.getMessage()
|
|
32
|
+
or "HTTP Request:" in record.getMessage()
|
|
33
|
+
):
|
|
34
|
+
return
|
|
34
35
|
|
|
35
36
|
# Get the original levelname without color codes
|
|
36
37
|
# Record.levelname might have ANSI codes from ColoredFormatter
|