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.
Files changed (44) hide show
  1. citrascope/api/abstract_api_client.py +14 -0
  2. citrascope/api/citra_api_client.py +41 -0
  3. citrascope/citra_scope_daemon.py +75 -0
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +80 -2
  5. citrascope/hardware/adapter_registry.py +10 -3
  6. citrascope/hardware/devices/__init__.py +17 -0
  7. citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  8. citrascope/hardware/devices/camera/__init__.py +13 -0
  9. citrascope/hardware/devices/camera/abstract_camera.py +102 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +402 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +744 -0
  13. citrascope/hardware/devices/device_registry.py +273 -0
  14. citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  15. citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  16. citrascope/hardware/devices/focuser/__init__.py +7 -0
  17. citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  18. citrascope/hardware/devices/mount/__init__.py +7 -0
  19. citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  20. citrascope/hardware/direct_hardware_adapter.py +787 -0
  21. citrascope/hardware/filter_sync.py +94 -0
  22. citrascope/hardware/indi_adapter.py +6 -2
  23. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  24. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  25. citrascope/settings/citrascope_settings.py +6 -0
  26. citrascope/tasks/runner.py +2 -0
  27. citrascope/tasks/scope/static_telescope_task.py +17 -12
  28. citrascope/tasks/task.py +3 -0
  29. citrascope/time/__init__.py +13 -0
  30. citrascope/time/time_health.py +96 -0
  31. citrascope/time/time_monitor.py +164 -0
  32. citrascope/time/time_sources.py +62 -0
  33. citrascope/web/app.py +229 -51
  34. citrascope/web/static/app.js +296 -36
  35. citrascope/web/static/config.js +216 -81
  36. citrascope/web/static/filters.js +55 -0
  37. citrascope/web/static/style.css +39 -0
  38. citrascope/web/templates/dashboard.html +114 -9
  39. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  40. citrascope-0.8.0.dist-info/RECORD +62 -0
  41. citrascope-0.7.0.dist-info/RECORD +0 -41
  42. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  43. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  44. {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.adapter_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
- schema = get_schema(adapter_name)
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.patch("/api/adapter/filters/{filter_id}")
416
- async def update_filter(filter_id: str, update: dict):
417
- """Update focus position and/or enabled state for a specific filter."""
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 available"}, status_code=503)
483
+ return JSONResponse({"error": "Hardware adapter not initialized"}, status_code=503)
421
484
 
422
- if not self.daemon.hardware_adapter.supports_filter_management():
423
- return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
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
- # Get current filter config
427
- filter_config = self.daemon.hardware_adapter.get_filter_config()
428
- if filter_id not in filter_config:
429
- return JSONResponse({"error": f"Filter {filter_id} not found"}, status_code=404)
430
-
431
- # Validate focus_position if provided
432
- if "focus_position" in update:
433
- focus_position = update["focus_position"]
434
- if not isinstance(focus_position, int):
435
- return JSONResponse({"error": "focus_position must be an integer"}, status_code=400)
436
-
437
- # Typical focuser range is 0-65535 (16-bit unsigned)
438
- if focus_position < 0 or focus_position > 65535:
439
- return JSONResponse({"error": "focus_position must be between 0 and 65535"}, status_code=400)
440
-
441
- # Update focus position via adapter interface
442
- if not self.daemon.hardware_adapter.update_filter_focus(filter_id, focus_position):
443
- return JSONResponse({"error": "Failed to update filter focus in adapter"}, status_code=500)
444
-
445
- # Validate and update enabled state if provided
446
- if "enabled" in update:
447
- enabled = update["enabled"]
448
- if not isinstance(enabled, bool):
449
- return JSONResponse({"error": "enabled must be a boolean"}, status_code=400)
450
-
451
- # Belt and suspenders: Ensure at least one filter remains enabled
452
- if not enabled:
453
- enabled_count = sum(1 for f in filter_config.values() if f.get("enabled", True))
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
- "error": "Cannot disable last enabled filter. At least one filter must remain enabled."
458
- },
459
- status_code=400,
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
- # Update enabled state via adapter interface
463
- if not self.daemon.hardware_adapter.update_filter_enabled(filter_id, enabled):
464
- return JSONResponse({"error": "Failed to update filter enabled state"}, status_code=500)
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
- # Save to settings
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, "filter_id": filter_id, "updated": update}
470
- except ValueError:
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 updating filter: {e}", exc_info=True)
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