citrascope 0.7.0__py3-none-any.whl → 0.8.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/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +75 -0
- citrascope/hardware/abstract_astro_hardware_adapter.py +80 -2
- citrascope/hardware/adapter_registry.py +10 -3
- citrascope/hardware/devices/__init__.py +17 -0
- citrascope/hardware/devices/abstract_hardware_device.py +79 -0
- citrascope/hardware/devices/camera/__init__.py +13 -0
- citrascope/hardware/devices/camera/abstract_camera.py +102 -0
- citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope/hardware/devices/camera/usb_camera.py +402 -0
- citrascope/hardware/devices/camera/ximea_camera.py +744 -0
- citrascope/hardware/devices/device_registry.py +273 -0
- citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
- citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
- citrascope/hardware/devices/focuser/__init__.py +7 -0
- citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
- citrascope/hardware/devices/mount/__init__.py +7 -0
- citrascope/hardware/devices/mount/abstract_mount.py +115 -0
- citrascope/hardware/direct_hardware_adapter.py +787 -0
- citrascope/hardware/filter_sync.py +94 -0
- citrascope/hardware/indi_adapter.py +6 -2
- citrascope/hardware/kstars_dbus_adapter.py +46 -37
- citrascope/hardware/nina_adv_http_adapter.py +13 -11
- citrascope/settings/citrascope_settings.py +6 -0
- citrascope/tasks/runner.py +2 -0
- citrascope/tasks/scope/static_telescope_task.py +17 -12
- citrascope/tasks/task.py +3 -0
- citrascope/time/__init__.py +13 -0
- citrascope/time/time_health.py +96 -0
- citrascope/time/time_monitor.py +164 -0
- citrascope/time/time_sources.py +62 -0
- citrascope/web/app.py +229 -51
- citrascope/web/static/app.js +296 -36
- citrascope/web/static/config.js +216 -81
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/style.css +39 -0
- citrascope/web/templates/dashboard.html +114 -9
- {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
- citrascope-0.8.0.dist-info/RECORD +62 -0
- citrascope-0.7.0.dist-info/RECORD +0 -41
- {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
- {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"""Time synchronization monitoring thread for CitraScope."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import Callable, Optional
|
|
6
|
+
|
|
7
|
+
from citrascope.logging import CITRASCOPE_LOGGER
|
|
8
|
+
from citrascope.time.time_health import TimeHealth, TimeStatus
|
|
9
|
+
from citrascope.time.time_sources import AbstractTimeSource, NTPTimeSource
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TimeMonitor:
|
|
13
|
+
"""
|
|
14
|
+
Background thread that monitors system clock synchronization.
|
|
15
|
+
|
|
16
|
+
Periodically checks clock offset against NTP servers,
|
|
17
|
+
logs warnings/errors based on drift severity, and notifies callback
|
|
18
|
+
when critical drift requires pausing observations.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
check_interval_minutes: int = 5,
|
|
24
|
+
pause_threshold_ms: float = 500.0,
|
|
25
|
+
pause_callback: Optional[Callable[[TimeHealth], None]] = None,
|
|
26
|
+
):
|
|
27
|
+
"""
|
|
28
|
+
Initialize time monitor.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
check_interval_minutes: Minutes between time sync checks
|
|
32
|
+
pause_threshold_ms: Threshold in ms that triggers task pause
|
|
33
|
+
pause_callback: Callback function when threshold exceeded
|
|
34
|
+
"""
|
|
35
|
+
self.check_interval_minutes = check_interval_minutes
|
|
36
|
+
self.pause_threshold_ms = pause_threshold_ms
|
|
37
|
+
self.pause_callback = pause_callback
|
|
38
|
+
|
|
39
|
+
# Initialize NTP time source
|
|
40
|
+
self.time_source: AbstractTimeSource = NTPTimeSource()
|
|
41
|
+
CITRASCOPE_LOGGER.info("Time monitor initialized with NTP source")
|
|
42
|
+
|
|
43
|
+
# Thread control
|
|
44
|
+
self._stop_event = threading.Event()
|
|
45
|
+
self._thread: Optional[threading.Thread] = None
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
|
|
48
|
+
# Current health status
|
|
49
|
+
self._current_health: Optional[TimeHealth] = None
|
|
50
|
+
self._last_critical_notification = 0.0
|
|
51
|
+
|
|
52
|
+
def start(self) -> None:
|
|
53
|
+
"""Start the time monitoring thread."""
|
|
54
|
+
if self._thread is not None and self._thread.is_alive():
|
|
55
|
+
CITRASCOPE_LOGGER.warning("Time monitor already running")
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
self._stop_event.clear()
|
|
59
|
+
self._thread = threading.Thread(target=self._monitor_loop, daemon=True)
|
|
60
|
+
self._thread.start()
|
|
61
|
+
CITRASCOPE_LOGGER.info(f"Time monitor started (check interval: {self.check_interval_minutes} minutes)")
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
"""Stop the time monitoring thread."""
|
|
65
|
+
if self._thread is None:
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
CITRASCOPE_LOGGER.info("Stopping time monitor...")
|
|
69
|
+
self._stop_event.set()
|
|
70
|
+
self._thread.join(timeout=5.0)
|
|
71
|
+
self._thread = None
|
|
72
|
+
CITRASCOPE_LOGGER.info("Time monitor stopped")
|
|
73
|
+
|
|
74
|
+
def get_current_health(self) -> Optional[TimeHealth]:
|
|
75
|
+
"""Get the current time health status (thread-safe)."""
|
|
76
|
+
with self._lock:
|
|
77
|
+
return self._current_health
|
|
78
|
+
|
|
79
|
+
def _monitor_loop(self) -> None:
|
|
80
|
+
"""Main monitoring loop (runs in background thread)."""
|
|
81
|
+
# Perform initial check immediately
|
|
82
|
+
self._check_time_sync()
|
|
83
|
+
|
|
84
|
+
# Then check periodically
|
|
85
|
+
interval_seconds = self.check_interval_minutes * 60
|
|
86
|
+
|
|
87
|
+
while not self._stop_event.is_set():
|
|
88
|
+
# Wait for interval or stop signal
|
|
89
|
+
if self._stop_event.wait(timeout=interval_seconds):
|
|
90
|
+
break
|
|
91
|
+
|
|
92
|
+
self._check_time_sync()
|
|
93
|
+
|
|
94
|
+
def _check_time_sync(self) -> None:
|
|
95
|
+
"""Perform a single time synchronization check."""
|
|
96
|
+
try:
|
|
97
|
+
# Query NTP for offset
|
|
98
|
+
offset_ms = self.time_source.get_offset_ms()
|
|
99
|
+
|
|
100
|
+
# Calculate health status
|
|
101
|
+
health = TimeHealth.from_offset(
|
|
102
|
+
offset_ms=offset_ms,
|
|
103
|
+
source=self.time_source.get_source_name(),
|
|
104
|
+
pause_threshold=self.pause_threshold_ms,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
# Store current health (thread-safe)
|
|
108
|
+
with self._lock:
|
|
109
|
+
self._current_health = health
|
|
110
|
+
|
|
111
|
+
# Log based on status
|
|
112
|
+
self._log_health_status(health)
|
|
113
|
+
|
|
114
|
+
# Notify callback if critical
|
|
115
|
+
if health.should_pause_observations():
|
|
116
|
+
self._handle_critical_drift(health)
|
|
117
|
+
|
|
118
|
+
except Exception as e:
|
|
119
|
+
CITRASCOPE_LOGGER.error(f"Time sync check failed: {e}", exc_info=True)
|
|
120
|
+
# Create unknown status on error
|
|
121
|
+
health = TimeHealth.from_offset(
|
|
122
|
+
offset_ms=None,
|
|
123
|
+
source="unknown",
|
|
124
|
+
pause_threshold=self.pause_threshold_ms,
|
|
125
|
+
message=f"Check failed: {e}",
|
|
126
|
+
)
|
|
127
|
+
with self._lock:
|
|
128
|
+
self._current_health = health
|
|
129
|
+
|
|
130
|
+
def _log_health_status(self, health: TimeHealth) -> None:
|
|
131
|
+
"""Log time health status at appropriate level."""
|
|
132
|
+
if health.offset_ms is None:
|
|
133
|
+
CITRASCOPE_LOGGER.warning("Time sync check failed - offset unknown")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
offset_str = f"{health.offset_ms:+.1f}ms"
|
|
137
|
+
|
|
138
|
+
if health.status == TimeStatus.OK:
|
|
139
|
+
CITRASCOPE_LOGGER.info(f"Time sync OK: {offset_str}")
|
|
140
|
+
elif health.status == TimeStatus.CRITICAL:
|
|
141
|
+
CITRASCOPE_LOGGER.critical(
|
|
142
|
+
f"CRITICAL time drift: offset {offset_str} exceeds {self.pause_threshold_ms}ms threshold. "
|
|
143
|
+
"Task processing will be paused."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
def _handle_critical_drift(self, health: TimeHealth) -> None:
|
|
147
|
+
"""
|
|
148
|
+
Handle critical time drift by notifying callback.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
health: Current time health status
|
|
152
|
+
"""
|
|
153
|
+
# Rate-limit notifications (max once per 5 minutes)
|
|
154
|
+
now = time.time()
|
|
155
|
+
if now - self._last_critical_notification < 300:
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
self._last_critical_notification = now
|
|
159
|
+
|
|
160
|
+
if self.pause_callback is not None:
|
|
161
|
+
try:
|
|
162
|
+
self.pause_callback(health)
|
|
163
|
+
except Exception as e:
|
|
164
|
+
CITRASCOPE_LOGGER.error(f"Pause callback failed: {e}", exc_info=True)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Time source implementations for CitraScope."""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from abc import ABC, abstractmethod
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import ntplib
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class AbstractTimeSource(ABC):
|
|
11
|
+
"""Abstract base class for time sources."""
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
def get_offset_ms(self) -> Optional[float]:
|
|
15
|
+
"""
|
|
16
|
+
Get the clock offset in milliseconds.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Clock offset in milliseconds (positive = system ahead), or None if unavailable.
|
|
20
|
+
"""
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
@abstractmethod
|
|
24
|
+
def get_source_name(self) -> str:
|
|
25
|
+
"""Get the name of this time source."""
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class NTPTimeSource(AbstractTimeSource):
|
|
30
|
+
"""NTP-based time source using pool.ntp.org."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, ntp_server: str = "pool.ntp.org", timeout: int = 5):
|
|
33
|
+
"""
|
|
34
|
+
Initialize NTP time source.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
ntp_server: NTP server hostname (default: pool.ntp.org)
|
|
38
|
+
timeout: Query timeout in seconds
|
|
39
|
+
"""
|
|
40
|
+
self.ntp_server = ntp_server
|
|
41
|
+
self.timeout = timeout
|
|
42
|
+
self.client = ntplib.NTPClient()
|
|
43
|
+
|
|
44
|
+
def get_offset_ms(self) -> Optional[float]:
|
|
45
|
+
"""
|
|
46
|
+
Query NTP server for clock offset.
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
Clock offset in milliseconds, or None if query fails.
|
|
50
|
+
"""
|
|
51
|
+
try:
|
|
52
|
+
response = self.client.request(self.ntp_server, version=3, timeout=self.timeout)
|
|
53
|
+
# NTP offset is in seconds, convert to milliseconds
|
|
54
|
+
offset_ms = response.offset * 1000.0
|
|
55
|
+
return offset_ms
|
|
56
|
+
except Exception:
|
|
57
|
+
# Query failed - network issue, timeout, etc.
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
def get_source_name(self) -> str:
|
|
61
|
+
"""Get the name of this time source."""
|
|
62
|
+
return "ntp"
|
citrascope/web/app.py
CHANGED
|
@@ -31,6 +31,7 @@ class SystemStatus(BaseModel):
|
|
|
31
31
|
current_task: Optional[str] = None
|
|
32
32
|
tasks_pending: int = 0
|
|
33
33
|
processing_active: bool = True
|
|
34
|
+
automated_scheduling: bool = False
|
|
34
35
|
hardware_adapter: str = "unknown"
|
|
35
36
|
telescope_ra: Optional[float] = None
|
|
36
37
|
telescope_dec: Optional[float] = None
|
|
@@ -40,7 +41,9 @@ class SystemStatus(BaseModel):
|
|
|
40
41
|
autofocus_requested: bool = False
|
|
41
42
|
last_autofocus_timestamp: Optional[int] = None
|
|
42
43
|
next_autofocus_minutes: Optional[int] = None
|
|
44
|
+
time_health: Optional[Dict[str, Any]] = None
|
|
43
45
|
last_update: str = ""
|
|
46
|
+
missing_dependencies: List[Dict[str, str]] = [] # List of {device, packages, install_cmd}
|
|
44
47
|
|
|
45
48
|
|
|
46
49
|
class HardwareConfig(BaseModel):
|
|
@@ -179,7 +182,7 @@ class CitraScopeWebApp:
|
|
|
179
182
|
"personal_access_token": settings.personal_access_token,
|
|
180
183
|
"telescope_id": settings.telescope_id,
|
|
181
184
|
"hardware_adapter": settings.hardware_adapter,
|
|
182
|
-
"adapter_settings": settings.
|
|
185
|
+
"adapter_settings": settings._all_adapter_settings,
|
|
183
186
|
"log_level": settings.log_level,
|
|
184
187
|
"keep_images": settings.keep_images,
|
|
185
188
|
"max_task_retries": settings.max_task_retries,
|
|
@@ -188,6 +191,8 @@ class CitraScopeWebApp:
|
|
|
188
191
|
"scheduled_autofocus_enabled": settings.scheduled_autofocus_enabled,
|
|
189
192
|
"autofocus_interval_minutes": settings.autofocus_interval_minutes,
|
|
190
193
|
"last_autofocus_timestamp": settings.last_autofocus_timestamp,
|
|
194
|
+
"time_check_interval_minutes": settings.time_check_interval_minutes,
|
|
195
|
+
"time_offset_pause_ms": settings.time_offset_pause_ms,
|
|
191
196
|
"app_url": app_url,
|
|
192
197
|
"config_file_path": config_path,
|
|
193
198
|
"log_file_path": log_file_path,
|
|
@@ -226,12 +231,27 @@ class CitraScopeWebApp:
|
|
|
226
231
|
}
|
|
227
232
|
|
|
228
233
|
@self.app.get("/api/hardware-adapters/{adapter_name}/schema")
|
|
229
|
-
async def get_adapter_schema(adapter_name: str):
|
|
230
|
-
"""Get configuration schema for a specific hardware adapter.
|
|
234
|
+
async def get_adapter_schema(adapter_name: str, current_settings: str = ""):
|
|
235
|
+
"""Get configuration schema for a specific hardware adapter.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
adapter_name: Name of the adapter
|
|
239
|
+
current_settings: JSON string of current adapter_settings (for dynamic schemas)
|
|
240
|
+
"""
|
|
241
|
+
import json
|
|
242
|
+
|
|
231
243
|
from citrascope.hardware.adapter_registry import get_adapter_schema as get_schema
|
|
232
244
|
|
|
233
245
|
try:
|
|
234
|
-
|
|
246
|
+
# Parse current settings if provided
|
|
247
|
+
settings_kwargs = {}
|
|
248
|
+
if current_settings:
|
|
249
|
+
try:
|
|
250
|
+
settings_kwargs = json.loads(current_settings)
|
|
251
|
+
except json.JSONDecodeError:
|
|
252
|
+
pass # Ignore invalid JSON, use empty kwargs
|
|
253
|
+
|
|
254
|
+
schema = get_schema(adapter_name, **settings_kwargs)
|
|
235
255
|
return {"schema": schema}
|
|
236
256
|
except ValueError as e:
|
|
237
257
|
# Invalid adapter name
|
|
@@ -396,6 +416,39 @@ class CitraScopeWebApp:
|
|
|
396
416
|
|
|
397
417
|
return {"status": "active", "message": "Task processing resumed"}
|
|
398
418
|
|
|
419
|
+
@self.app.patch("/api/telescope/automated-scheduling")
|
|
420
|
+
async def update_automated_scheduling(request: Dict[str, bool]):
|
|
421
|
+
"""Toggle automated scheduling on/off."""
|
|
422
|
+
if not self.daemon or not self.daemon.task_manager:
|
|
423
|
+
return JSONResponse({"error": "Task manager not available"}, status_code=503)
|
|
424
|
+
|
|
425
|
+
if not self.daemon.api_client:
|
|
426
|
+
return JSONResponse({"error": "API client not available"}, status_code=503)
|
|
427
|
+
|
|
428
|
+
enabled = request.get("enabled")
|
|
429
|
+
if enabled is None:
|
|
430
|
+
return JSONResponse({"error": "Missing 'enabled' field in request body"}, status_code=400)
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
# Update server via Citra API
|
|
434
|
+
telescope_id = self.daemon.telescope_record["id"]
|
|
435
|
+
payload = [{"id": telescope_id, "automatedScheduling": enabled}]
|
|
436
|
+
|
|
437
|
+
response = self.daemon.api_client._request("PATCH", "/telescopes", json=payload)
|
|
438
|
+
|
|
439
|
+
if response:
|
|
440
|
+
# Update local cache
|
|
441
|
+
self.daemon.task_manager._automated_scheduling = enabled
|
|
442
|
+
CITRASCOPE_LOGGER.info(f"Automated scheduling set to {'enabled' if enabled else 'disabled'}")
|
|
443
|
+
await self.broadcast_status()
|
|
444
|
+
return {"status": "success", "enabled": enabled}
|
|
445
|
+
else:
|
|
446
|
+
return JSONResponse({"error": "Failed to update telescope on server"}, status_code=500)
|
|
447
|
+
|
|
448
|
+
except Exception as e:
|
|
449
|
+
CITRASCOPE_LOGGER.error(f"Error updating automated scheduling: {e}", exc_info=True)
|
|
450
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
451
|
+
|
|
399
452
|
@self.app.get("/api/adapter/filters")
|
|
400
453
|
async def get_filters():
|
|
401
454
|
"""Get current filter configuration."""
|
|
@@ -412,65 +465,131 @@ class CitraScopeWebApp:
|
|
|
412
465
|
CITRASCOPE_LOGGER.error(f"Error getting filter config: {e}", exc_info=True)
|
|
413
466
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
414
467
|
|
|
415
|
-
@self.app.
|
|
416
|
-
async def
|
|
417
|
-
"""Update
|
|
468
|
+
@self.app.post("/api/adapter/filters/batch")
|
|
469
|
+
async def update_filters_batch(updates: List[Dict[str, Any]]):
|
|
470
|
+
"""Update multiple filters atomically with single disk write.
|
|
418
471
|
|
|
472
|
+
Args:
|
|
473
|
+
updates: Array of filter updates, each containing:
|
|
474
|
+
- filter_id (str): Filter ID
|
|
475
|
+
- focus_position (int, optional): Focus position in steps
|
|
476
|
+
- enabled (bool, optional): Whether filter is enabled
|
|
477
|
+
|
|
478
|
+
Returns:
|
|
479
|
+
{"success": true, "updated_count": N} on success
|
|
480
|
+
{"error": "..."} on validation failure
|
|
481
|
+
"""
|
|
419
482
|
if not self.daemon or not self.daemon.hardware_adapter:
|
|
420
|
-
return JSONResponse({"error": "Hardware adapter not
|
|
483
|
+
return JSONResponse({"error": "Hardware adapter not initialized"}, status_code=503)
|
|
421
484
|
|
|
422
|
-
if not
|
|
423
|
-
return JSONResponse({"error": "
|
|
485
|
+
if not updates or not isinstance(updates, list):
|
|
486
|
+
return JSONResponse({"error": "Updates must be a non-empty array"}, status_code=400)
|
|
424
487
|
|
|
425
488
|
try:
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if not
|
|
435
|
-
return JSONResponse({"error": "
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if enabled_count <= 1:
|
|
489
|
+
filter_config = self.daemon.hardware_adapter.filter_map
|
|
490
|
+
|
|
491
|
+
# Phase 1: Validate ALL updates before applying ANY changes
|
|
492
|
+
validated_updates = []
|
|
493
|
+
for idx, update in enumerate(updates):
|
|
494
|
+
if not isinstance(update, dict):
|
|
495
|
+
return JSONResponse({"error": f"Update at index {idx} must be an object"}, status_code=400)
|
|
496
|
+
|
|
497
|
+
if "filter_id" not in update:
|
|
498
|
+
return JSONResponse({"error": f"Update at index {idx} missing filter_id"}, status_code=400)
|
|
499
|
+
|
|
500
|
+
filter_id = update["filter_id"]
|
|
501
|
+
try:
|
|
502
|
+
filter_id_int = int(filter_id)
|
|
503
|
+
except (ValueError, TypeError):
|
|
504
|
+
return JSONResponse(
|
|
505
|
+
{"error": f"Invalid filter_id at index {idx}: {filter_id}"}, status_code=400
|
|
506
|
+
)
|
|
507
|
+
|
|
508
|
+
if filter_id_int not in filter_config:
|
|
509
|
+
return JSONResponse({"error": f"Filter ID {filter_id} not found"}, status_code=404)
|
|
510
|
+
|
|
511
|
+
validated_update = {"filter_id_int": filter_id_int}
|
|
512
|
+
|
|
513
|
+
# Validate focus_position if provided
|
|
514
|
+
if "focus_position" in update:
|
|
515
|
+
focus_position = update["focus_position"]
|
|
516
|
+
if not isinstance(focus_position, int):
|
|
455
517
|
return JSONResponse(
|
|
456
|
-
{
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
518
|
+
{"error": f"focus_position at index {idx} must be an integer"}, status_code=400
|
|
519
|
+
)
|
|
520
|
+
if focus_position < 0 or focus_position > 65535:
|
|
521
|
+
return JSONResponse(
|
|
522
|
+
{"error": f"focus_position at index {idx} must be between 0 and 65535"}, status_code=400
|
|
460
523
|
)
|
|
524
|
+
validated_update["focus_position"] = focus_position
|
|
525
|
+
|
|
526
|
+
# Validate enabled if provided
|
|
527
|
+
if "enabled" in update:
|
|
528
|
+
enabled = update["enabled"]
|
|
529
|
+
if not isinstance(enabled, bool):
|
|
530
|
+
return JSONResponse({"error": f"enabled at index {idx} must be a boolean"}, status_code=400)
|
|
531
|
+
validated_update["enabled"] = enabled
|
|
532
|
+
|
|
533
|
+
validated_updates.append(validated_update)
|
|
534
|
+
|
|
535
|
+
# Validate at least one filter remains enabled
|
|
536
|
+
current_enabled = {fid for fid, fdata in filter_config.items() if fdata.get("enabled", True)}
|
|
537
|
+
for validated in validated_updates:
|
|
538
|
+
if "enabled" in validated:
|
|
539
|
+
if validated["enabled"]:
|
|
540
|
+
current_enabled.add(validated["filter_id_int"])
|
|
541
|
+
else:
|
|
542
|
+
current_enabled.discard(validated["filter_id_int"])
|
|
543
|
+
|
|
544
|
+
if not current_enabled:
|
|
545
|
+
return JSONResponse(
|
|
546
|
+
{"error": "Cannot disable all filters. At least one filter must remain enabled."},
|
|
547
|
+
status_code=400,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
# Phase 2: Apply all validated updates
|
|
551
|
+
for validated in validated_updates:
|
|
552
|
+
filter_id_int = validated["filter_id_int"]
|
|
461
553
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
554
|
+
if "focus_position" in validated:
|
|
555
|
+
if not self.daemon.hardware_adapter.update_filter_focus(
|
|
556
|
+
str(filter_id_int), validated["focus_position"]
|
|
557
|
+
):
|
|
558
|
+
return JSONResponse(
|
|
559
|
+
{"error": f"Failed to update filter {filter_id_int} focus"}, status_code=500
|
|
560
|
+
)
|
|
465
561
|
|
|
466
|
-
|
|
562
|
+
if "enabled" in validated:
|
|
563
|
+
if not self.daemon.hardware_adapter.update_filter_enabled(
|
|
564
|
+
str(filter_id_int), validated["enabled"]
|
|
565
|
+
):
|
|
566
|
+
return JSONResponse(
|
|
567
|
+
{"error": f"Failed to update filter {filter_id_int} enabled state"}, status_code=500
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
# Phase 3: Save once after all updates
|
|
467
571
|
self.daemon._save_filter_config()
|
|
468
572
|
|
|
469
|
-
return {"success": True, "
|
|
470
|
-
|
|
471
|
-
return JSONResponse({"error": "Invalid filter_id format"}, status_code=400)
|
|
573
|
+
return {"success": True, "updated_count": len(validated_updates)}
|
|
574
|
+
|
|
472
575
|
except Exception as e:
|
|
473
|
-
CITRASCOPE_LOGGER.error(f"Error
|
|
576
|
+
CITRASCOPE_LOGGER.error(f"Error in batch filter update: {e}", exc_info=True)
|
|
577
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
578
|
+
|
|
579
|
+
@self.app.post("/api/adapter/filters/sync")
|
|
580
|
+
async def sync_filters_to_backend():
|
|
581
|
+
"""Explicitly sync filter configuration to backend API.
|
|
582
|
+
|
|
583
|
+
Call this after batch filter updates to sync enabled filters to backend.
|
|
584
|
+
"""
|
|
585
|
+
if not self.daemon or not self.daemon.hardware_adapter:
|
|
586
|
+
return JSONResponse({"error": "Hardware adapter not initialized"}, status_code=503)
|
|
587
|
+
|
|
588
|
+
try:
|
|
589
|
+
self.daemon._sync_filters_to_backend()
|
|
590
|
+
return {"success": True, "message": "Filters synced to backend"}
|
|
591
|
+
except Exception as e:
|
|
592
|
+
CITRASCOPE_LOGGER.error(f"Error syncing filters to backend: {e}", exc_info=True)
|
|
474
593
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
475
594
|
|
|
476
595
|
@self.app.post("/api/adapter/autofocus")
|
|
@@ -505,6 +624,47 @@ class CitraScopeWebApp:
|
|
|
505
624
|
CITRASCOPE_LOGGER.error(f"Error cancelling autofocus: {e}", exc_info=True)
|
|
506
625
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
507
626
|
|
|
627
|
+
@self.app.post("/api/camera/capture")
|
|
628
|
+
async def camera_capture(request: Dict[str, Any]):
|
|
629
|
+
"""Trigger a test camera capture."""
|
|
630
|
+
if not self.daemon:
|
|
631
|
+
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
632
|
+
|
|
633
|
+
if not self.daemon.hardware_adapter:
|
|
634
|
+
return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
|
|
635
|
+
|
|
636
|
+
try:
|
|
637
|
+
duration = request.get("duration", 0.1)
|
|
638
|
+
|
|
639
|
+
# Validate exposure duration
|
|
640
|
+
if duration <= 0:
|
|
641
|
+
return JSONResponse({"error": "Exposure duration must be positive"}, status_code=400)
|
|
642
|
+
if duration > 300:
|
|
643
|
+
return JSONResponse({"error": "Exposure duration must be 300 seconds or less"}, status_code=400)
|
|
644
|
+
|
|
645
|
+
CITRASCOPE_LOGGER.info(f"Test capture requested: {duration}s exposure")
|
|
646
|
+
|
|
647
|
+
# Take exposure using hardware adapter
|
|
648
|
+
filepath = self.daemon.hardware_adapter.expose_camera(
|
|
649
|
+
exposure_time=duration, gain=None, offset=None, count=1
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
# Get file info
|
|
653
|
+
file_path = Path(filepath)
|
|
654
|
+
if not file_path.exists():
|
|
655
|
+
return JSONResponse({"error": "Capture completed but file not found"}, status_code=500)
|
|
656
|
+
|
|
657
|
+
filename = file_path.name
|
|
658
|
+
file_format = file_path.suffix.upper().lstrip(".")
|
|
659
|
+
|
|
660
|
+
CITRASCOPE_LOGGER.info(f"Test capture complete: {filename}")
|
|
661
|
+
|
|
662
|
+
return {"success": True, "filename": filename, "filepath": str(file_path), "format": file_format}
|
|
663
|
+
|
|
664
|
+
except Exception as e:
|
|
665
|
+
CITRASCOPE_LOGGER.error(f"Error during test capture: {e}", exc_info=True)
|
|
666
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
667
|
+
|
|
508
668
|
@self.app.websocket("/ws")
|
|
509
669
|
async def websocket_endpoint(websocket: WebSocket):
|
|
510
670
|
"""WebSocket endpoint for real-time updates."""
|
|
@@ -581,6 +741,14 @@ class CitraScopeWebApp:
|
|
|
581
741
|
else:
|
|
582
742
|
self.status.next_autofocus_minutes = None
|
|
583
743
|
|
|
744
|
+
# Get time sync status from time monitor
|
|
745
|
+
if hasattr(self.daemon, "time_monitor") and self.daemon.time_monitor:
|
|
746
|
+
health = self.daemon.time_monitor.get_current_health()
|
|
747
|
+
self.status.time_health = health.to_dict() if health else None
|
|
748
|
+
else:
|
|
749
|
+
# Time monitoring not initialized yet
|
|
750
|
+
self.status.time_health = None
|
|
751
|
+
|
|
584
752
|
# Get ground station information from daemon (available after API validation)
|
|
585
753
|
if hasattr(self.daemon, "ground_station") and self.daemon.ground_station:
|
|
586
754
|
gs_record = self.daemon.ground_station
|
|
@@ -598,6 +766,16 @@ class CitraScopeWebApp:
|
|
|
598
766
|
# Update task processing state
|
|
599
767
|
if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
|
|
600
768
|
self.status.processing_active = self.daemon.task_manager.is_processing_active()
|
|
769
|
+
self.status.automated_scheduling = self.daemon.task_manager._automated_scheduling or False
|
|
770
|
+
|
|
771
|
+
# Check for missing dependencies from adapter
|
|
772
|
+
self.status.missing_dependencies = []
|
|
773
|
+
if hasattr(self.daemon, "hardware_adapter") and self.daemon.hardware_adapter:
|
|
774
|
+
if hasattr(self.daemon.hardware_adapter, "get_missing_dependencies"):
|
|
775
|
+
try:
|
|
776
|
+
self.status.missing_dependencies = self.daemon.hardware_adapter.get_missing_dependencies()
|
|
777
|
+
except Exception as e:
|
|
778
|
+
CITRASCOPE_LOGGER.debug(f"Could not check missing dependencies: {e}")
|
|
601
779
|
|
|
602
780
|
self.status.last_update = datetime.now().isoformat()
|
|
603
781
|
|