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.
Files changed (45) 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 +97 -38
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
  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 +67 -96
  24. citrascope/hardware/nina_adv_http_adapter.py +81 -64
  25. citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  26. citrascope/settings/citrascope_settings.py +25 -0
  27. citrascope/tasks/runner.py +105 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +13 -0
  31. citrascope/time/time_health.py +96 -0
  32. citrascope/time/time_monitor.py +164 -0
  33. citrascope/time/time_sources.py +62 -0
  34. citrascope/web/app.py +274 -51
  35. citrascope/web/static/app.js +379 -36
  36. citrascope/web/static/config.js +448 -108
  37. citrascope/web/static/filters.js +55 -0
  38. citrascope/web/static/style.css +39 -0
  39. citrascope/web/templates/dashboard.html +176 -36
  40. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  41. citrascope-0.8.0.dist-info/RECORD +62 -0
  42. citrascope-0.6.1.dist-info/RECORD +0 -41
  43. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  44. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  45. {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.adapter_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
- 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)
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.patch("/api/adapter/filters/{filter_id}")
424
- async def update_filter(filter_id: str, update: dict):
425
- """Update focus position 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.
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 available"}, status_code=503)
483
+ return JSONResponse({"error": "Hardware adapter not initialized"}, status_code=503)
428
484
 
429
- if not self.daemon.hardware_adapter.supports_filter_management():
430
- 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)
431
487
 
432
488
  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)
489
+ filter_config = self.daemon.hardware_adapter.filter_map
436
490
 
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)
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
- # 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)
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
- # 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)
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
- # 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)
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 to settings
570
+ # Phase 3: Save once after all updates
455
571
  self.daemon._save_filter_config()
456
572
 
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)
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 updating filter: {e}", exc_info=True)
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
- """Trigger autofocus routine."""
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
- # 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)
605
+ success, error = self.daemon.trigger_autofocus()
477
606
  if success:
478
- return {"success": True, "message": "Autofocus completed successfully"}
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 triggering autofocus: {e}", exc_info=True)
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