citrascope 0.6.1__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 +97 -38
- citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
- 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 +67 -96
- citrascope/hardware/nina_adv_http_adapter.py +81 -64
- citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- citrascope/settings/citrascope_settings.py +25 -0
- citrascope/tasks/runner.py +105 -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 +274 -51
- citrascope/web/static/app.js +379 -36
- citrascope/web/static/config.js +448 -108
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/style.css +39 -0
- citrascope/web/templates/dashboard.html +176 -36
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
- citrascope-0.8.0.dist-info/RECORD +62 -0
- citrascope-0.6.1.dist-info/RECORD +0 -41
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.6.1.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,13 +31,19 @@ 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
|
|
37
38
|
ground_station_id: Optional[str] = None
|
|
38
39
|
ground_station_name: Optional[str] = None
|
|
39
40
|
ground_station_url: Optional[str] = None
|
|
41
|
+
autofocus_requested: bool = False
|
|
42
|
+
last_autofocus_timestamp: Optional[int] = None
|
|
43
|
+
next_autofocus_minutes: Optional[int] = None
|
|
44
|
+
time_health: Optional[Dict[str, Any]] = None
|
|
40
45
|
last_update: str = ""
|
|
46
|
+
missing_dependencies: List[Dict[str, str]] = [] # List of {device, packages, install_cmd}
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
class HardwareConfig(BaseModel):
|
|
@@ -176,12 +182,17 @@ class CitraScopeWebApp:
|
|
|
176
182
|
"personal_access_token": settings.personal_access_token,
|
|
177
183
|
"telescope_id": settings.telescope_id,
|
|
178
184
|
"hardware_adapter": settings.hardware_adapter,
|
|
179
|
-
"adapter_settings": settings.
|
|
185
|
+
"adapter_settings": settings._all_adapter_settings,
|
|
180
186
|
"log_level": settings.log_level,
|
|
181
187
|
"keep_images": settings.keep_images,
|
|
182
188
|
"max_task_retries": settings.max_task_retries,
|
|
183
189
|
"initial_retry_delay_seconds": settings.initial_retry_delay_seconds,
|
|
184
190
|
"max_retry_delay_seconds": settings.max_retry_delay_seconds,
|
|
191
|
+
"scheduled_autofocus_enabled": settings.scheduled_autofocus_enabled,
|
|
192
|
+
"autofocus_interval_minutes": settings.autofocus_interval_minutes,
|
|
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,
|
|
185
196
|
"app_url": app_url,
|
|
186
197
|
"config_file_path": config_path,
|
|
187
198
|
"log_file_path": log_file_path,
|
|
@@ -220,12 +231,27 @@ class CitraScopeWebApp:
|
|
|
220
231
|
}
|
|
221
232
|
|
|
222
233
|
@self.app.get("/api/hardware-adapters/{adapter_name}/schema")
|
|
223
|
-
async def get_adapter_schema(adapter_name: str):
|
|
224
|
-
"""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
|
+
|
|
225
243
|
from citrascope.hardware.adapter_registry import get_adapter_schema as get_schema
|
|
226
244
|
|
|
227
245
|
try:
|
|
228
|
-
|
|
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)
|
|
229
255
|
return {"schema": schema}
|
|
230
256
|
except ValueError as e:
|
|
231
257
|
# Invalid adapter name
|
|
@@ -241,13 +267,6 @@ class CitraScopeWebApp:
|
|
|
241
267
|
if not self.daemon:
|
|
242
268
|
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
243
269
|
|
|
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
|
-
|
|
251
270
|
# Validate required fields
|
|
252
271
|
required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
|
|
253
272
|
for field in required_fields:
|
|
@@ -392,18 +411,44 @@ class CitraScopeWebApp:
|
|
|
392
411
|
if not self.daemon or not self.daemon.task_manager:
|
|
393
412
|
return JSONResponse({"error": "Task manager not available"}, status_code=503)
|
|
394
413
|
|
|
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
414
|
self.daemon.task_manager.resume()
|
|
403
415
|
await self.broadcast_status()
|
|
404
416
|
|
|
405
417
|
return {"status": "active", "message": "Task processing resumed"}
|
|
406
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
|
+
|
|
407
452
|
@self.app.get("/api/adapter/filters")
|
|
408
453
|
async def get_filters():
|
|
409
454
|
"""Get current filter configuration."""
|
|
@@ -420,50 +465,136 @@ class CitraScopeWebApp:
|
|
|
420
465
|
CITRASCOPE_LOGGER.error(f"Error getting filter config: {e}", exc_info=True)
|
|
421
466
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
422
467
|
|
|
423
|
-
@self.app.
|
|
424
|
-
async def
|
|
425
|
-
"""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.
|
|
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
|
+
"""
|
|
426
482
|
if not self.daemon or not self.daemon.hardware_adapter:
|
|
427
|
-
return JSONResponse({"error": "Hardware adapter not
|
|
483
|
+
return JSONResponse({"error": "Hardware adapter not initialized"}, status_code=503)
|
|
428
484
|
|
|
429
|
-
if not
|
|
430
|
-
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)
|
|
431
487
|
|
|
432
488
|
try:
|
|
433
|
-
|
|
434
|
-
if "focus_position" not in update:
|
|
435
|
-
return JSONResponse({"error": "focus_position is required"}, status_code=400)
|
|
489
|
+
filter_config = self.daemon.hardware_adapter.filter_map
|
|
436
490
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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)
|
|
440
496
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
return JSONResponse({"error": "focus_position must be between 0 and 65535"}, status_code=400)
|
|
497
|
+
if "filter_id" not in update:
|
|
498
|
+
return JSONResponse({"error": f"Update at index {idx} missing filter_id"}, status_code=400)
|
|
444
499
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
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):
|
|
517
|
+
return JSONResponse(
|
|
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
|
|
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
|
+
)
|
|
449
549
|
|
|
450
|
-
#
|
|
451
|
-
|
|
452
|
-
|
|
550
|
+
# Phase 2: Apply all validated updates
|
|
551
|
+
for validated in validated_updates:
|
|
552
|
+
filter_id_int = validated["filter_id_int"]
|
|
553
|
+
|
|
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
|
+
)
|
|
561
|
+
|
|
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
|
+
)
|
|
453
569
|
|
|
454
|
-
# Save
|
|
570
|
+
# Phase 3: Save once after all updates
|
|
455
571
|
self.daemon._save_filter_config()
|
|
456
572
|
|
|
457
|
-
return {"success": True, "
|
|
458
|
-
|
|
459
|
-
|
|
573
|
+
return {"success": True, "updated_count": len(validated_updates)}
|
|
574
|
+
|
|
575
|
+
except Exception as e:
|
|
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"}
|
|
460
591
|
except Exception as e:
|
|
461
|
-
CITRASCOPE_LOGGER.error(f"Error
|
|
592
|
+
CITRASCOPE_LOGGER.error(f"Error syncing filters to backend: {e}", exc_info=True)
|
|
462
593
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
463
594
|
|
|
464
595
|
@self.app.post("/api/adapter/autofocus")
|
|
465
596
|
async def trigger_autofocus():
|
|
466
|
-
"""
|
|
597
|
+
"""Request autofocus to run between tasks."""
|
|
467
598
|
if not self.daemon:
|
|
468
599
|
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
469
600
|
|
|
@@ -471,15 +602,67 @@ class CitraScopeWebApp:
|
|
|
471
602
|
return JSONResponse({"error": "Filter management not supported"}, status_code=404)
|
|
472
603
|
|
|
473
604
|
try:
|
|
474
|
-
|
|
475
|
-
# Autofocus can take several minutes (slewing + focusing multiple filters)
|
|
476
|
-
success, error = await asyncio.to_thread(self.daemon.trigger_autofocus)
|
|
605
|
+
success, error = self.daemon.trigger_autofocus()
|
|
477
606
|
if success:
|
|
478
|
-
return {"success": True, "message": "Autofocus
|
|
607
|
+
return {"success": True, "message": "Autofocus queued - will run between tasks"}
|
|
479
608
|
else:
|
|
480
609
|
return JSONResponse({"error": error}, status_code=500)
|
|
481
610
|
except Exception as e:
|
|
482
|
-
CITRASCOPE_LOGGER.error(f"Error
|
|
611
|
+
CITRASCOPE_LOGGER.error(f"Error queueing autofocus: {e}", exc_info=True)
|
|
612
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
613
|
+
|
|
614
|
+
@self.app.post("/api/adapter/autofocus/cancel")
|
|
615
|
+
async def cancel_autofocus():
|
|
616
|
+
"""Cancel pending autofocus request."""
|
|
617
|
+
if not self.daemon:
|
|
618
|
+
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
619
|
+
|
|
620
|
+
try:
|
|
621
|
+
was_cancelled = self.daemon.cancel_autofocus()
|
|
622
|
+
return {"success": was_cancelled}
|
|
623
|
+
except Exception as e:
|
|
624
|
+
CITRASCOPE_LOGGER.error(f"Error cancelling autofocus: {e}", exc_info=True)
|
|
625
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
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)
|
|
483
666
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
484
667
|
|
|
485
668
|
@self.app.websocket("/ws")
|
|
@@ -533,9 +716,39 @@ class CitraScopeWebApp:
|
|
|
533
716
|
if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
|
|
534
717
|
task_manager = self.daemon.task_manager
|
|
535
718
|
self.status.current_task = task_manager.current_task_id
|
|
719
|
+
self.status.autofocus_requested = task_manager.is_autofocus_requested()
|
|
536
720
|
with task_manager.heap_lock:
|
|
537
721
|
self.status.tasks_pending = len(task_manager.task_heap)
|
|
538
722
|
|
|
723
|
+
# Get autofocus timing information
|
|
724
|
+
if self.daemon.settings:
|
|
725
|
+
settings = self.daemon.settings
|
|
726
|
+
self.status.last_autofocus_timestamp = settings.last_autofocus_timestamp
|
|
727
|
+
|
|
728
|
+
# Calculate next autofocus time if scheduled is enabled
|
|
729
|
+
if settings.scheduled_autofocus_enabled:
|
|
730
|
+
last_ts = settings.last_autofocus_timestamp
|
|
731
|
+
interval_minutes = settings.autofocus_interval_minutes
|
|
732
|
+
if last_ts is not None:
|
|
733
|
+
import time
|
|
734
|
+
|
|
735
|
+
elapsed_minutes = (int(time.time()) - last_ts) / 60
|
|
736
|
+
remaining = max(0, interval_minutes - elapsed_minutes)
|
|
737
|
+
self.status.next_autofocus_minutes = int(remaining)
|
|
738
|
+
else:
|
|
739
|
+
# Never run - will trigger immediately
|
|
740
|
+
self.status.next_autofocus_minutes = 0
|
|
741
|
+
else:
|
|
742
|
+
self.status.next_autofocus_minutes = None
|
|
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
|
+
|
|
539
752
|
# Get ground station information from daemon (available after API validation)
|
|
540
753
|
if hasattr(self.daemon, "ground_station") and self.daemon.ground_station:
|
|
541
754
|
gs_record = self.daemon.ground_station
|
|
@@ -553,6 +766,16 @@ class CitraScopeWebApp:
|
|
|
553
766
|
# Update task processing state
|
|
554
767
|
if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
|
|
555
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}")
|
|
556
779
|
|
|
557
780
|
self.status.last_update = datetime.now().isoformat()
|
|
558
781
|
|