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
|
@@ -10,6 +10,7 @@ import platformdirs
|
|
|
10
10
|
APP_NAME = "citrascope"
|
|
11
11
|
APP_AUTHOR = "citra-space"
|
|
12
12
|
|
|
13
|
+
from citrascope.constants import DEFAULT_API_PORT, DEFAULT_WEB_PORT, PROD_API_HOST
|
|
13
14
|
from citrascope.logging import CITRASCOPE_LOGGER
|
|
14
15
|
from citrascope.settings.settings_file_manager import SettingsFileManager
|
|
15
16
|
|
|
@@ -17,18 +18,11 @@ from citrascope.settings.settings_file_manager import SettingsFileManager
|
|
|
17
18
|
class CitraScopeSettings:
|
|
18
19
|
"""Settings for CitraScope loaded from JSON configuration file."""
|
|
19
20
|
|
|
20
|
-
def __init__(
|
|
21
|
-
self,
|
|
22
|
-
dev: bool = False,
|
|
23
|
-
log_level: str = "INFO",
|
|
24
|
-
keep_images: bool = False,
|
|
25
|
-
):
|
|
21
|
+
def __init__(self, web_port: int = DEFAULT_WEB_PORT):
|
|
26
22
|
"""Initialize settings from JSON config file.
|
|
27
23
|
|
|
28
24
|
Args:
|
|
29
|
-
|
|
30
|
-
log_level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
|
31
|
-
keep_images: If True, preserve captured images
|
|
25
|
+
web_port: Port for web interface (default: 24872) - bootstrap option only
|
|
32
26
|
"""
|
|
33
27
|
self.config_manager = SettingsFileManager()
|
|
34
28
|
|
|
@@ -38,9 +32,9 @@ class CitraScopeSettings:
|
|
|
38
32
|
# Application data directories
|
|
39
33
|
self._images_dir = Path(platformdirs.user_data_dir(APP_NAME, appauthor=APP_AUTHOR)) / "images"
|
|
40
34
|
|
|
41
|
-
# API Settings
|
|
42
|
-
self.host: str = config.get("host",
|
|
43
|
-
self.port: int = config.get("port",
|
|
35
|
+
# API Settings (all loaded from config file)
|
|
36
|
+
self.host: str = config.get("host", PROD_API_HOST)
|
|
37
|
+
self.port: int = config.get("port", DEFAULT_API_PORT)
|
|
44
38
|
self.use_ssl: bool = config.get("use_ssl", True)
|
|
45
39
|
self.personal_access_token: str = config.get("personal_access_token", "")
|
|
46
40
|
self.telescope_id: str = config.get("telescope_id", "")
|
|
@@ -48,12 +42,19 @@ class CitraScopeSettings:
|
|
|
48
42
|
# Hardware adapter selection
|
|
49
43
|
self.hardware_adapter: str = config.get("hardware_adapter", "")
|
|
50
44
|
|
|
51
|
-
# Hardware adapter-specific settings stored as dict
|
|
52
|
-
|
|
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", {})
|
|
53
48
|
|
|
54
|
-
#
|
|
55
|
-
self.
|
|
56
|
-
|
|
49
|
+
# Current adapter's settings slice
|
|
50
|
+
self.adapter_settings: Dict[str, Any] = self._all_adapter_settings.get(self.hardware_adapter, {})
|
|
51
|
+
|
|
52
|
+
# Runtime settings (all loaded from config file, configurable via web UI)
|
|
53
|
+
self.log_level: str = config.get("log_level", "INFO")
|
|
54
|
+
self.keep_images: bool = config.get("keep_images", False)
|
|
55
|
+
|
|
56
|
+
# Web port: CLI override if non-default, otherwise use config file
|
|
57
|
+
self.web_port: int = web_port if web_port != DEFAULT_WEB_PORT else config.get("web_port", DEFAULT_WEB_PORT)
|
|
57
58
|
|
|
58
59
|
# Task retry configuration
|
|
59
60
|
self.max_task_retries: int = config.get("max_task_retries", 3)
|
|
@@ -64,10 +65,6 @@ class CitraScopeSettings:
|
|
|
64
65
|
self.file_logging_enabled: bool = config.get("file_logging_enabled", True)
|
|
65
66
|
self.log_retention_days: int = config.get("log_retention_days", 30)
|
|
66
67
|
|
|
67
|
-
if dev:
|
|
68
|
-
self.host = "dev.api.citra.space"
|
|
69
|
-
CITRASCOPE_LOGGER.info("Using development API endpoint.")
|
|
70
|
-
|
|
71
68
|
def get_images_dir(self) -> Path:
|
|
72
69
|
"""Get the path to the images directory.
|
|
73
70
|
|
|
@@ -102,9 +99,10 @@ class CitraScopeSettings:
|
|
|
102
99
|
"personal_access_token": self.personal_access_token,
|
|
103
100
|
"telescope_id": self.telescope_id,
|
|
104
101
|
"hardware_adapter": self.hardware_adapter,
|
|
105
|
-
"adapter_settings": self.
|
|
102
|
+
"adapter_settings": self._all_adapter_settings,
|
|
106
103
|
"log_level": self.log_level,
|
|
107
104
|
"keep_images": self.keep_images,
|
|
105
|
+
"web_port": self.web_port,
|
|
108
106
|
"max_task_retries": self.max_task_retries,
|
|
109
107
|
"initial_retry_delay_seconds": self.initial_retry_delay_seconds,
|
|
110
108
|
"max_retry_delay_seconds": self.max_retry_delay_seconds,
|
|
@@ -114,32 +112,23 @@ class CitraScopeSettings:
|
|
|
114
112
|
|
|
115
113
|
def save(self) -> None:
|
|
116
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
|
+
|
|
117
119
|
self.config_manager.save_config(self.to_dict())
|
|
118
120
|
CITRASCOPE_LOGGER.info(f"Configuration saved to {self.config_manager.get_config_path()}")
|
|
119
121
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
"""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.
|
|
123
124
|
|
|
124
125
|
Args:
|
|
125
|
-
config:
|
|
126
|
-
|
|
127
|
-
Returns:
|
|
128
|
-
New CitraScopeSettings instance.
|
|
126
|
+
config: Configuration dict with flat adapter_settings for current adapter.
|
|
129
127
|
"""
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
settings.adapter_settings = config.get("adapter_settings", {})
|
|
138
|
-
settings.log_level = config.get("log_level", "INFO")
|
|
139
|
-
settings.keep_images = config.get("keep_images", False)
|
|
140
|
-
settings.max_task_retries = config.get("max_task_retries", 3)
|
|
141
|
-
settings.initial_retry_delay_seconds = config.get("initial_retry_delay_seconds", 30)
|
|
142
|
-
settings.max_retry_delay_seconds = config.get("max_retry_delay_seconds", 300)
|
|
143
|
-
settings.file_logging_enabled = config.get("file_logging_enabled", True)
|
|
144
|
-
settings.log_retention_days = config.get("log_retention_days", 30)
|
|
145
|
-
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)
|
citrascope/tasks/runner.py
CHANGED
|
@@ -11,6 +11,9 @@ from citrascope.tasks.scope.static_telescope_task import StaticTelescopeTask
|
|
|
11
11
|
from citrascope.tasks.scope.tracking_telescope_task import TrackingTelescopeTask
|
|
12
12
|
from citrascope.tasks.task import Task
|
|
13
13
|
|
|
14
|
+
# Task polling interval in seconds
|
|
15
|
+
TASK_POLL_INTERVAL_SECONDS = 15
|
|
16
|
+
|
|
14
17
|
|
|
15
18
|
class TaskManager:
|
|
16
19
|
def __init__(
|
|
@@ -37,6 +40,9 @@ class TaskManager:
|
|
|
37
40
|
self.keep_images = keep_images
|
|
38
41
|
self.task_retry_counts = {} # Track retry attempts per task ID
|
|
39
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()
|
|
40
46
|
|
|
41
47
|
def poll_tasks(self):
|
|
42
48
|
while not self._stop_event.is_set():
|
|
@@ -44,8 +50,9 @@ class TaskManager:
|
|
|
44
50
|
self._report_online()
|
|
45
51
|
tasks = self.api_client.get_telescope_tasks(self.telescope_record["id"])
|
|
46
52
|
|
|
47
|
-
# If API call failed (timeout, network error, etc.),
|
|
53
|
+
# If API call failed (timeout, network error, etc.), wait before retrying
|
|
48
54
|
if tasks is None:
|
|
55
|
+
self._stop_event.wait(TASK_POLL_INTERVAL_SECONDS)
|
|
49
56
|
continue
|
|
50
57
|
|
|
51
58
|
added = 0
|
|
@@ -113,7 +120,7 @@ class TaskManager:
|
|
|
113
120
|
except Exception as e:
|
|
114
121
|
self.logger.error(f"Exception in poll_tasks loop: {e}", exc_info=True)
|
|
115
122
|
time.sleep(5) # avoid tight error loop
|
|
116
|
-
self._stop_event.wait(
|
|
123
|
+
self._stop_event.wait(TASK_POLL_INTERVAL_SECONDS)
|
|
117
124
|
|
|
118
125
|
def _report_online(self):
|
|
119
126
|
"""
|
|
@@ -126,6 +133,14 @@ class TaskManager:
|
|
|
126
133
|
|
|
127
134
|
def task_runner(self):
|
|
128
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
|
+
|
|
129
144
|
try:
|
|
130
145
|
now = int(time.time())
|
|
131
146
|
completed = 0
|
|
@@ -239,6 +254,25 @@ class TaskManager:
|
|
|
239
254
|
summary += "No tasks scheduled."
|
|
240
255
|
return summary
|
|
241
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
|
+
|
|
242
276
|
def start(self):
|
|
243
277
|
self._stop_event.clear()
|
|
244
278
|
self.poll_thread = threading.Thread(target=self.poll_tasks, daemon=True)
|
citrascope/web/app.py
CHANGED
|
@@ -4,6 +4,7 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import os
|
|
6
6
|
from datetime import datetime, timezone
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any, Dict, List, Optional
|
|
9
10
|
|
|
@@ -13,15 +14,14 @@ from fastapi.responses import HTMLResponse, JSONResponse
|
|
|
13
14
|
from fastapi.staticfiles import StaticFiles
|
|
14
15
|
from pydantic import BaseModel
|
|
15
16
|
|
|
17
|
+
from citrascope.constants import (
|
|
18
|
+
DEV_API_HOST,
|
|
19
|
+
DEV_APP_URL,
|
|
20
|
+
PROD_API_HOST,
|
|
21
|
+
PROD_APP_URL,
|
|
22
|
+
)
|
|
16
23
|
from citrascope.logging import CITRASCOPE_LOGGER
|
|
17
24
|
|
|
18
|
-
# ============================================================================
|
|
19
|
-
# URL CONSTANTS - Single source of truth for Citra web URLs
|
|
20
|
-
# ============================================================================
|
|
21
|
-
PROD_APP_URL = "https://app.citra.space"
|
|
22
|
-
DEV_APP_URL = "https://dev.app.citra.space"
|
|
23
|
-
# ============================================================================
|
|
24
|
-
|
|
25
25
|
|
|
26
26
|
class SystemStatus(BaseModel):
|
|
27
27
|
"""Current system status."""
|
|
@@ -30,6 +30,7 @@ class SystemStatus(BaseModel):
|
|
|
30
30
|
camera_connected: bool = False
|
|
31
31
|
current_task: Optional[str] = None
|
|
32
32
|
tasks_pending: int = 0
|
|
33
|
+
processing_active: bool = True
|
|
33
34
|
hardware_adapter: str = "unknown"
|
|
34
35
|
telescope_ra: Optional[float] = None
|
|
35
36
|
telescope_dec: Optional[float] = None
|
|
@@ -155,7 +156,7 @@ class CitraScopeWebApp:
|
|
|
155
156
|
|
|
156
157
|
settings = self.daemon.settings
|
|
157
158
|
# Determine app URL based on API host
|
|
158
|
-
app_url = DEV_APP_URL if
|
|
159
|
+
app_url = DEV_APP_URL if settings.host == DEV_API_HOST else PROD_APP_URL
|
|
159
160
|
|
|
160
161
|
# Get config file path
|
|
161
162
|
config_path = str(settings.config_manager.get_config_path())
|
|
@@ -198,6 +199,15 @@ class CitraScopeWebApp:
|
|
|
198
199
|
"error": getattr(self.daemon, "configuration_error", None),
|
|
199
200
|
}
|
|
200
201
|
|
|
202
|
+
@self.app.get("/api/version")
|
|
203
|
+
async def get_version():
|
|
204
|
+
"""Get CitraScope version."""
|
|
205
|
+
try:
|
|
206
|
+
pkg_version = version("citrascope")
|
|
207
|
+
return {"version": pkg_version}
|
|
208
|
+
except PackageNotFoundError:
|
|
209
|
+
return {"version": "development"}
|
|
210
|
+
|
|
201
211
|
@self.app.get("/api/hardware-adapters")
|
|
202
212
|
async def get_hardware_adapters():
|
|
203
213
|
"""Get list of available hardware adapters."""
|
|
@@ -231,6 +241,13 @@ class CitraScopeWebApp:
|
|
|
231
241
|
if not self.daemon:
|
|
232
242
|
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
233
243
|
|
|
244
|
+
# Block config changes during autofocus
|
|
245
|
+
if self.daemon.is_autofocus_in_progress():
|
|
246
|
+
return JSONResponse(
|
|
247
|
+
{"error": "Cannot save configuration while autofocus is running. Check logs for progress."},
|
|
248
|
+
status_code=409,
|
|
249
|
+
)
|
|
250
|
+
|
|
234
251
|
# Validate required fields
|
|
235
252
|
required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
|
|
236
253
|
for field in required_fields:
|
|
@@ -301,11 +318,7 @@ class CitraScopeWebApp:
|
|
|
301
318
|
status_code=400,
|
|
302
319
|
)
|
|
303
320
|
|
|
304
|
-
|
|
305
|
-
from citrascope.settings.settings_file_manager import SettingsFileManager
|
|
306
|
-
|
|
307
|
-
config_manager = SettingsFileManager()
|
|
308
|
-
config_manager.save_config(config)
|
|
321
|
+
self.daemon.settings.update_and_save(config)
|
|
309
322
|
|
|
310
323
|
# Trigger hot-reload
|
|
311
324
|
success, error = self.daemon.reload_configuration()
|
|
@@ -362,6 +375,113 @@ class CitraScopeWebApp:
|
|
|
362
375
|
return {"logs": logs}
|
|
363
376
|
return {"logs": []}
|
|
364
377
|
|
|
378
|
+
@self.app.post("/api/tasks/pause")
|
|
379
|
+
async def pause_tasks():
|
|
380
|
+
"""Pause task processing."""
|
|
381
|
+
if not self.daemon or not self.daemon.task_manager:
|
|
382
|
+
return JSONResponse({"error": "Task manager not available"}, status_code=503)
|
|
383
|
+
|
|
384
|
+
self.daemon.task_manager.pause()
|
|
385
|
+
await self.broadcast_status()
|
|
386
|
+
|
|
387
|
+
return {"status": "paused", "message": "Task processing paused"}
|
|
388
|
+
|
|
389
|
+
@self.app.post("/api/tasks/resume")
|
|
390
|
+
async def resume_tasks():
|
|
391
|
+
"""Resume task processing."""
|
|
392
|
+
if not self.daemon or not self.daemon.task_manager:
|
|
393
|
+
return JSONResponse({"error": "Task manager not available"}, status_code=503)
|
|
394
|
+
|
|
395
|
+
# Block resume during autofocus
|
|
396
|
+
if self.daemon.is_autofocus_in_progress():
|
|
397
|
+
return JSONResponse(
|
|
398
|
+
{"error": "Cannot resume task processing during autofocus. Check logs for progress."},
|
|
399
|
+
status_code=409,
|
|
400
|
+
)
|
|
401
|
+
|
|
402
|
+
self.daemon.task_manager.resume()
|
|
403
|
+
await self.broadcast_status()
|
|
404
|
+
|
|
405
|
+
return {"status": "active", "message": "Task processing resumed"}
|
|
406
|
+
|
|
407
|
+
@self.app.get("/api/adapter/filters")
|
|
408
|
+
async def get_filters():
|
|
409
|
+
"""Get current filter configuration."""
|
|
410
|
+
if not self.daemon or not self.daemon.hardware_adapter:
|
|
411
|
+
return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
|
|
412
|
+
|
|
413
|
+
if not self.daemon.hardware_adapter.supports_filter_management():
|
|
414
|
+
return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
|
|
415
|
+
|
|
416
|
+
try:
|
|
417
|
+
filter_config = self.daemon.hardware_adapter.get_filter_config()
|
|
418
|
+
return {"filters": filter_config}
|
|
419
|
+
except Exception as e:
|
|
420
|
+
CITRASCOPE_LOGGER.error(f"Error getting filter config: {e}", exc_info=True)
|
|
421
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
422
|
+
|
|
423
|
+
@self.app.patch("/api/adapter/filters/{filter_id}")
|
|
424
|
+
async def update_filter(filter_id: str, update: dict):
|
|
425
|
+
"""Update focus position for a specific filter."""
|
|
426
|
+
if not self.daemon or not self.daemon.hardware_adapter:
|
|
427
|
+
return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
|
|
428
|
+
|
|
429
|
+
if not self.daemon.hardware_adapter.supports_filter_management():
|
|
430
|
+
return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
# Validate focus_position is provided and is a valid integer within hardware limits
|
|
434
|
+
if "focus_position" not in update:
|
|
435
|
+
return JSONResponse({"error": "focus_position is required"}, status_code=400)
|
|
436
|
+
|
|
437
|
+
focus_position = update["focus_position"]
|
|
438
|
+
if not isinstance(focus_position, int):
|
|
439
|
+
return JSONResponse({"error": "focus_position must be an integer"}, status_code=400)
|
|
440
|
+
|
|
441
|
+
# Typical focuser range is 0-65535 (16-bit unsigned)
|
|
442
|
+
if focus_position < 0 or focus_position > 65535:
|
|
443
|
+
return JSONResponse({"error": "focus_position must be between 0 and 65535"}, status_code=400)
|
|
444
|
+
|
|
445
|
+
# Get current filter config
|
|
446
|
+
filter_config = self.daemon.hardware_adapter.get_filter_config()
|
|
447
|
+
if filter_id not in filter_config:
|
|
448
|
+
return JSONResponse({"error": f"Filter {filter_id} not found"}, status_code=404)
|
|
449
|
+
|
|
450
|
+
# Update via adapter interface
|
|
451
|
+
if not self.daemon.hardware_adapter.update_filter_focus(filter_id, focus_position):
|
|
452
|
+
return JSONResponse({"error": "Failed to update filter in adapter"}, status_code=500)
|
|
453
|
+
|
|
454
|
+
# Save to settings
|
|
455
|
+
self.daemon._save_filter_config()
|
|
456
|
+
|
|
457
|
+
return {"success": True, "filter_id": filter_id, "focus_position": focus_position}
|
|
458
|
+
except ValueError:
|
|
459
|
+
return JSONResponse({"error": "Invalid filter_id format"}, status_code=400)
|
|
460
|
+
except Exception as e:
|
|
461
|
+
CITRASCOPE_LOGGER.error(f"Error updating filter: {e}", exc_info=True)
|
|
462
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
463
|
+
|
|
464
|
+
@self.app.post("/api/adapter/autofocus")
|
|
465
|
+
async def trigger_autofocus():
|
|
466
|
+
"""Trigger autofocus routine."""
|
|
467
|
+
if not self.daemon:
|
|
468
|
+
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
469
|
+
|
|
470
|
+
if not self.daemon.hardware_adapter or not self.daemon.hardware_adapter.supports_filter_management():
|
|
471
|
+
return JSONResponse({"error": "Filter management not supported"}, status_code=404)
|
|
472
|
+
|
|
473
|
+
try:
|
|
474
|
+
# Run autofocus in a separate thread to avoid blocking the async event loop
|
|
475
|
+
# Autofocus can take several minutes (slewing + focusing multiple filters)
|
|
476
|
+
success, error = await asyncio.to_thread(self.daemon.trigger_autofocus)
|
|
477
|
+
if success:
|
|
478
|
+
return {"success": True, "message": "Autofocus completed successfully"}
|
|
479
|
+
else:
|
|
480
|
+
return JSONResponse({"error": error}, status_code=500)
|
|
481
|
+
except Exception as e:
|
|
482
|
+
CITRASCOPE_LOGGER.error(f"Error triggering autofocus: {e}", exc_info=True)
|
|
483
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
484
|
+
|
|
365
485
|
@self.app.websocket("/ws")
|
|
366
486
|
async def websocket_endpoint(websocket: WebSocket):
|
|
367
487
|
"""WebSocket endpoint for real-time updates."""
|
|
@@ -430,6 +550,10 @@ class CitraScopeWebApp:
|
|
|
430
550
|
self.status.ground_station_name = gs_name
|
|
431
551
|
self.status.ground_station_url = f"{base_url}/ground-stations/{gs_id}" if gs_id else None
|
|
432
552
|
|
|
553
|
+
# Update task processing state
|
|
554
|
+
if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
|
|
555
|
+
self.status.processing_active = self.daemon.task_manager.is_processing_active()
|
|
556
|
+
|
|
433
557
|
self.status.last_update = datetime.now().isoformat()
|
|
434
558
|
|
|
435
559
|
except Exception as e:
|
citrascope/web/server.py
CHANGED
|
@@ -7,13 +7,14 @@ import time
|
|
|
7
7
|
|
|
8
8
|
import uvicorn
|
|
9
9
|
|
|
10
|
+
from citrascope.constants import DEFAULT_WEB_PORT
|
|
10
11
|
from citrascope.logging import CITRASCOPE_LOGGER, WebLogHandler
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
class CitraScopeWebServer:
|
|
14
15
|
"""Manages the web server and its configuration."""
|
|
15
16
|
|
|
16
|
-
def __init__(self, daemon, host: str = "0.0.0.0", port: int =
|
|
17
|
+
def __init__(self, daemon, host: str = "0.0.0.0", port: int = DEFAULT_WEB_PORT):
|
|
17
18
|
self.daemon = daemon
|
|
18
19
|
self.host = host
|
|
19
20
|
self.port = port
|
|
@@ -101,6 +102,14 @@ class CitraScopeWebServer:
|
|
|
101
102
|
asyncio.create_task(self._status_broadcast_loop())
|
|
102
103
|
|
|
103
104
|
await server.serve()
|
|
105
|
+
except OSError as e:
|
|
106
|
+
if e.errno == 48: # Address already in use
|
|
107
|
+
CITRASCOPE_LOGGER.error(
|
|
108
|
+
f"Port {self.port} is already in use. Please stop any other services using this port "
|
|
109
|
+
f"or use --web-port to specify a different port."
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
CITRASCOPE_LOGGER.error(f"Web server OS error: {e}", exc_info=True)
|
|
104
113
|
except Exception as e:
|
|
105
114
|
CITRASCOPE_LOGGER.error(f"Web server error: {e}", exc_info=True)
|
|
106
115
|
|