citrascope 0.7.0__py3-none-any.whl → 0.9.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 (51) 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 +97 -2
  5. citrascope/hardware/adapter_registry.py +15 -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 +114 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +407 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +756 -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 +805 -0
  21. citrascope/hardware/dummy_adapter.py +202 -0
  22. citrascope/hardware/filter_sync.py +94 -0
  23. citrascope/hardware/indi_adapter.py +6 -2
  24. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  25. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  26. citrascope/settings/citrascope_settings.py +6 -0
  27. citrascope/tasks/runner.py +2 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +14 -0
  31. citrascope/time/time_health.py +103 -0
  32. citrascope/time/time_monitor.py +186 -0
  33. citrascope/time/time_sources.py +261 -0
  34. citrascope/web/app.py +260 -60
  35. citrascope/web/static/app.js +121 -731
  36. citrascope/web/static/components.js +136 -0
  37. citrascope/web/static/config.js +259 -420
  38. citrascope/web/static/filters.js +55 -0
  39. citrascope/web/static/formatters.js +129 -0
  40. citrascope/web/static/store-init.js +204 -0
  41. citrascope/web/static/style.css +44 -0
  42. citrascope/web/templates/_config.html +175 -0
  43. citrascope/web/templates/_config_hardware.html +208 -0
  44. citrascope/web/templates/_monitoring.html +242 -0
  45. citrascope/web/templates/dashboard.html +109 -377
  46. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
  47. citrascope-0.9.0.dist-info/RECORD +69 -0
  48. citrascope-0.7.0.dist-info/RECORD +0 -41
  49. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
  50. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
  51. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/licenses/LICENSE +0 -0
citrascope/web/app.py CHANGED
@@ -8,10 +8,11 @@ from importlib.metadata import PackageNotFoundError, version
8
8
  from pathlib import Path
9
9
  from typing import Any, Dict, List, Optional
10
10
 
11
- from fastapi import FastAPI, WebSocket, WebSocketDisconnect
11
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
12
12
  from fastapi.middleware.cors import CORSMiddleware
13
13
  from fastapi.responses import HTMLResponse, JSONResponse
14
14
  from fastapi.staticfiles import StaticFiles
15
+ from fastapi.templating import Jinja2Templates
15
16
  from pydantic import BaseModel
16
17
 
17
18
  from citrascope.constants import (
@@ -28,9 +29,11 @@ class SystemStatus(BaseModel):
28
29
 
29
30
  telescope_connected: bool = False
30
31
  camera_connected: bool = False
32
+ supports_direct_camera_control: bool = False
31
33
  current_task: Optional[str] = None
32
34
  tasks_pending: int = 0
33
35
  processing_active: bool = True
36
+ automated_scheduling: bool = False
34
37
  hardware_adapter: str = "unknown"
35
38
  telescope_ra: Optional[float] = None
36
39
  telescope_dec: Optional[float] = None
@@ -40,7 +43,9 @@ class SystemStatus(BaseModel):
40
43
  autofocus_requested: bool = False
41
44
  last_autofocus_timestamp: Optional[int] = None
42
45
  next_autofocus_minutes: Optional[int] = None
46
+ time_health: Optional[Dict[str, Any]] = None
43
47
  last_update: str = ""
48
+ missing_dependencies: List[Dict[str, str]] = [] # List of {device, packages, install_cmd}
44
49
 
45
50
 
46
51
  class HardwareConfig(BaseModel):
@@ -123,6 +128,16 @@ class CitraScopeWebApp:
123
128
  if static_dir.exists():
124
129
  self.app.mount("/static", StaticFiles(directory=str(static_dir)), name="static")
125
130
 
131
+ # Mount images directory for camera captures (read-only access)
132
+ if daemon and hasattr(daemon, "settings"):
133
+ images_dir = daemon.settings.get_images_dir()
134
+ if images_dir.exists():
135
+ self.app.mount("/images", StaticFiles(directory=str(images_dir)), name="images")
136
+
137
+ # Initialize Jinja2 templates
138
+ templates_dir = Path(__file__).parent / "templates"
139
+ self.templates = Jinja2Templates(directory=str(templates_dir))
140
+
126
141
  # Register routes
127
142
  self._setup_routes()
128
143
 
@@ -134,15 +149,9 @@ class CitraScopeWebApp:
134
149
  """Setup all API routes."""
135
150
 
136
151
  @self.app.get("/", response_class=HTMLResponse)
137
- async def root():
152
+ async def root(request: Request):
138
153
  """Serve the main dashboard page."""
139
- template_path = Path(__file__).parent / "templates" / "dashboard.html"
140
- if template_path.exists():
141
- return template_path.read_text()
142
- else:
143
- return HTMLResponse(
144
- content="<h1>CitraScope Dashboard</h1><p>Template file not found</p>", status_code=500
145
- )
154
+ return self.templates.TemplateResponse("dashboard.html", {"request": request})
146
155
 
147
156
  @self.app.get("/api/status")
148
157
  async def get_status():
@@ -179,15 +188,19 @@ class CitraScopeWebApp:
179
188
  "personal_access_token": settings.personal_access_token,
180
189
  "telescope_id": settings.telescope_id,
181
190
  "hardware_adapter": settings.hardware_adapter,
182
- "adapter_settings": settings.adapter_settings,
191
+ "adapter_settings": settings._all_adapter_settings,
183
192
  "log_level": settings.log_level,
184
193
  "keep_images": settings.keep_images,
194
+ "file_logging_enabled": settings.file_logging_enabled,
195
+ "log_retention_days": settings.log_retention_days,
185
196
  "max_task_retries": settings.max_task_retries,
186
197
  "initial_retry_delay_seconds": settings.initial_retry_delay_seconds,
187
198
  "max_retry_delay_seconds": settings.max_retry_delay_seconds,
188
199
  "scheduled_autofocus_enabled": settings.scheduled_autofocus_enabled,
189
200
  "autofocus_interval_minutes": settings.autofocus_interval_minutes,
190
201
  "last_autofocus_timestamp": settings.last_autofocus_timestamp,
202
+ "time_check_interval_minutes": settings.time_check_interval_minutes,
203
+ "time_offset_pause_ms": settings.time_offset_pause_ms,
191
204
  "app_url": app_url,
192
205
  "config_file_path": config_path,
193
206
  "log_file_path": log_file_path,
@@ -226,12 +239,27 @@ class CitraScopeWebApp:
226
239
  }
227
240
 
228
241
  @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."""
242
+ async def get_adapter_schema(adapter_name: str, current_settings: str = ""):
243
+ """Get configuration schema for a specific hardware adapter.
244
+
245
+ Args:
246
+ adapter_name: Name of the adapter
247
+ current_settings: JSON string of current adapter_settings (for dynamic schemas)
248
+ """
249
+ import json
250
+
231
251
  from citrascope.hardware.adapter_registry import get_adapter_schema as get_schema
232
252
 
233
253
  try:
234
- schema = get_schema(adapter_name)
254
+ # Parse current settings if provided
255
+ settings_kwargs = {}
256
+ if current_settings:
257
+ try:
258
+ settings_kwargs = json.loads(current_settings)
259
+ except json.JSONDecodeError:
260
+ pass # Ignore invalid JSON, use empty kwargs
261
+
262
+ schema = get_schema(adapter_name, **settings_kwargs)
235
263
  return {"schema": schema}
236
264
  except ValueError as e:
237
265
  # Invalid adapter name
@@ -396,6 +424,39 @@ class CitraScopeWebApp:
396
424
 
397
425
  return {"status": "active", "message": "Task processing resumed"}
398
426
 
427
+ @self.app.patch("/api/telescope/automated-scheduling")
428
+ async def update_automated_scheduling(request: Dict[str, bool]):
429
+ """Toggle automated scheduling on/off."""
430
+ if not self.daemon or not self.daemon.task_manager:
431
+ return JSONResponse({"error": "Task manager not available"}, status_code=503)
432
+
433
+ if not self.daemon.api_client:
434
+ return JSONResponse({"error": "API client not available"}, status_code=503)
435
+
436
+ enabled = request.get("enabled")
437
+ if enabled is None:
438
+ return JSONResponse({"error": "Missing 'enabled' field in request body"}, status_code=400)
439
+
440
+ try:
441
+ # Update server via Citra API
442
+ telescope_id = self.daemon.telescope_record["id"]
443
+ payload = [{"id": telescope_id, "automatedScheduling": enabled}]
444
+
445
+ response = self.daemon.api_client._request("PATCH", "/telescopes", json=payload)
446
+
447
+ if response:
448
+ # Update local cache
449
+ self.daemon.task_manager._automated_scheduling = enabled
450
+ CITRASCOPE_LOGGER.info(f"Automated scheduling set to {'enabled' if enabled else 'disabled'}")
451
+ await self.broadcast_status()
452
+ return {"status": "success", "enabled": enabled}
453
+ else:
454
+ return JSONResponse({"error": "Failed to update telescope on server"}, status_code=500)
455
+
456
+ except Exception as e:
457
+ CITRASCOPE_LOGGER.error(f"Error updating automated scheduling: {e}", exc_info=True)
458
+ return JSONResponse({"error": str(e)}, status_code=500)
459
+
399
460
  @self.app.get("/api/adapter/filters")
400
461
  async def get_filters():
401
462
  """Get current filter configuration."""
@@ -412,65 +473,131 @@ class CitraScopeWebApp:
412
473
  CITRASCOPE_LOGGER.error(f"Error getting filter config: {e}", exc_info=True)
413
474
  return JSONResponse({"error": str(e)}, status_code=500)
414
475
 
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."""
476
+ @self.app.post("/api/adapter/filters/batch")
477
+ async def update_filters_batch(updates: List[Dict[str, Any]]):
478
+ """Update multiple filters atomically with single disk write.
479
+
480
+ Args:
481
+ updates: Array of filter updates, each containing:
482
+ - filter_id (str): Filter ID
483
+ - focus_position (int, optional): Focus position in steps
484
+ - enabled (bool, optional): Whether filter is enabled
418
485
 
486
+ Returns:
487
+ {"success": true, "updated_count": N} on success
488
+ {"error": "..."} on validation failure
489
+ """
419
490
  if not self.daemon or not self.daemon.hardware_adapter:
420
- return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
491
+ return JSONResponse({"error": "Hardware adapter not initialized"}, status_code=503)
421
492
 
422
- if not self.daemon.hardware_adapter.supports_filter_management():
423
- return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
493
+ if not updates or not isinstance(updates, list):
494
+ return JSONResponse({"error": "Updates must be a non-empty array"}, status_code=400)
424
495
 
425
496
  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:
497
+ filter_config = self.daemon.hardware_adapter.filter_map
498
+
499
+ # Phase 1: Validate ALL updates before applying ANY changes
500
+ validated_updates = []
501
+ for idx, update in enumerate(updates):
502
+ if not isinstance(update, dict):
503
+ return JSONResponse({"error": f"Update at index {idx} must be an object"}, status_code=400)
504
+
505
+ if "filter_id" not in update:
506
+ return JSONResponse({"error": f"Update at index {idx} missing filter_id"}, status_code=400)
507
+
508
+ filter_id = update["filter_id"]
509
+ try:
510
+ filter_id_int = int(filter_id)
511
+ except (ValueError, TypeError):
512
+ return JSONResponse(
513
+ {"error": f"Invalid filter_id at index {idx}: {filter_id}"}, status_code=400
514
+ )
515
+
516
+ if filter_id_int not in filter_config:
517
+ return JSONResponse({"error": f"Filter ID {filter_id} not found"}, status_code=404)
518
+
519
+ validated_update = {"filter_id_int": filter_id_int}
520
+
521
+ # Validate focus_position if provided
522
+ if "focus_position" in update:
523
+ focus_position = update["focus_position"]
524
+ if not isinstance(focus_position, int):
455
525
  return JSONResponse(
456
- {
457
- "error": "Cannot disable last enabled filter. At least one filter must remain enabled."
458
- },
459
- status_code=400,
526
+ {"error": f"focus_position at index {idx} must be an integer"}, status_code=400
527
+ )
528
+ if focus_position < 0 or focus_position > 65535:
529
+ return JSONResponse(
530
+ {"error": f"focus_position at index {idx} must be between 0 and 65535"}, status_code=400
460
531
  )
532
+ validated_update["focus_position"] = focus_position
533
+
534
+ # Validate enabled if provided
535
+ if "enabled" in update:
536
+ enabled = update["enabled"]
537
+ if not isinstance(enabled, bool):
538
+ return JSONResponse({"error": f"enabled at index {idx} must be a boolean"}, status_code=400)
539
+ validated_update["enabled"] = enabled
540
+
541
+ validated_updates.append(validated_update)
542
+
543
+ # Validate at least one filter remains enabled
544
+ current_enabled = {fid for fid, fdata in filter_config.items() if fdata.get("enabled", True)}
545
+ for validated in validated_updates:
546
+ if "enabled" in validated:
547
+ if validated["enabled"]:
548
+ current_enabled.add(validated["filter_id_int"])
549
+ else:
550
+ current_enabled.discard(validated["filter_id_int"])
551
+
552
+ if not current_enabled:
553
+ return JSONResponse(
554
+ {"error": "Cannot disable all filters. At least one filter must remain enabled."},
555
+ status_code=400,
556
+ )
461
557
 
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)
558
+ # Phase 2: Apply all validated updates
559
+ for validated in validated_updates:
560
+ filter_id_int = validated["filter_id_int"]
465
561
 
466
- # Save to settings
562
+ if "focus_position" in validated:
563
+ if not self.daemon.hardware_adapter.update_filter_focus(
564
+ str(filter_id_int), validated["focus_position"]
565
+ ):
566
+ return JSONResponse(
567
+ {"error": f"Failed to update filter {filter_id_int} focus"}, status_code=500
568
+ )
569
+
570
+ if "enabled" in validated:
571
+ if not self.daemon.hardware_adapter.update_filter_enabled(
572
+ str(filter_id_int), validated["enabled"]
573
+ ):
574
+ return JSONResponse(
575
+ {"error": f"Failed to update filter {filter_id_int} enabled state"}, status_code=500
576
+ )
577
+
578
+ # Phase 3: Save once after all updates
467
579
  self.daemon._save_filter_config()
468
580
 
469
- return {"success": True, "filter_id": filter_id, "updated": update}
470
- except ValueError:
471
- return JSONResponse({"error": "Invalid filter_id format"}, status_code=400)
581
+ return {"success": True, "updated_count": len(validated_updates)}
582
+
583
+ except Exception as e:
584
+ CITRASCOPE_LOGGER.error(f"Error in batch filter update: {e}", exc_info=True)
585
+ return JSONResponse({"error": str(e)}, status_code=500)
586
+
587
+ @self.app.post("/api/adapter/filters/sync")
588
+ async def sync_filters_to_backend():
589
+ """Explicitly sync filter configuration to backend API.
590
+
591
+ Call this after batch filter updates to sync enabled filters to backend.
592
+ """
593
+ if not self.daemon or not self.daemon.hardware_adapter:
594
+ return JSONResponse({"error": "Hardware adapter not initialized"}, status_code=503)
595
+
596
+ try:
597
+ self.daemon._sync_filters_to_backend()
598
+ return {"success": True, "message": "Filters synced to backend"}
472
599
  except Exception as e:
473
- CITRASCOPE_LOGGER.error(f"Error updating filter: {e}", exc_info=True)
600
+ CITRASCOPE_LOGGER.error(f"Error syncing filters to backend: {e}", exc_info=True)
474
601
  return JSONResponse({"error": str(e)}, status_code=500)
475
602
 
476
603
  @self.app.post("/api/adapter/autofocus")
@@ -505,6 +632,53 @@ class CitraScopeWebApp:
505
632
  CITRASCOPE_LOGGER.error(f"Error cancelling autofocus: {e}", exc_info=True)
506
633
  return JSONResponse({"error": str(e)}, status_code=500)
507
634
 
635
+ @self.app.post("/api/camera/capture")
636
+ async def camera_capture(request: Dict[str, Any]):
637
+ """Trigger a test camera capture."""
638
+ if not self.daemon:
639
+ return JSONResponse({"error": "Daemon not available"}, status_code=503)
640
+
641
+ if not self.daemon.hardware_adapter:
642
+ return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
643
+
644
+ # Check if adapter supports direct camera control
645
+ if not self.daemon.hardware_adapter.supports_direct_camera_control():
646
+ return JSONResponse(
647
+ {"error": "Hardware adapter does not support direct camera control"}, status_code=400
648
+ )
649
+
650
+ try:
651
+ duration = request.get("duration", 0.1)
652
+
653
+ # Validate exposure duration
654
+ if duration <= 0:
655
+ return JSONResponse({"error": "Exposure duration must be positive"}, status_code=400)
656
+ if duration > 300:
657
+ return JSONResponse({"error": "Exposure duration must be 300 seconds or less"}, status_code=400)
658
+
659
+ CITRASCOPE_LOGGER.info(f"Test capture requested: {duration}s exposure")
660
+
661
+ # Take exposure using hardware adapter
662
+ filepath = self.daemon.hardware_adapter.expose_camera(
663
+ exposure_time=duration, gain=None, offset=None, count=1
664
+ )
665
+
666
+ # Get file info
667
+ file_path = Path(filepath)
668
+ if not file_path.exists():
669
+ return JSONResponse({"error": "Capture completed but file not found"}, status_code=500)
670
+
671
+ filename = file_path.name
672
+ file_format = file_path.suffix.upper().lstrip(".")
673
+
674
+ CITRASCOPE_LOGGER.info(f"Test capture complete: {filename}")
675
+
676
+ return {"success": True, "filename": filename, "filepath": str(file_path), "format": file_format}
677
+
678
+ except Exception as e:
679
+ CITRASCOPE_LOGGER.error(f"Error during test capture: {e}", exc_info=True)
680
+ return JSONResponse({"error": str(e)}, status_code=500)
681
+
508
682
  @self.app.websocket("/ws")
509
683
  async def websocket_endpoint(websocket: WebSocket):
510
684
  """WebSocket endpoint for real-time updates."""
@@ -553,6 +727,14 @@ class CitraScopeWebApp:
553
727
  except Exception:
554
728
  self.status.camera_connected = False
555
729
 
730
+ # Check adapter capabilities
731
+ try:
732
+ self.status.supports_direct_camera_control = (
733
+ self.daemon.hardware_adapter.supports_direct_camera_control()
734
+ )
735
+ except Exception:
736
+ self.status.supports_direct_camera_control = False
737
+
556
738
  if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
557
739
  task_manager = self.daemon.task_manager
558
740
  self.status.current_task = task_manager.current_task_id
@@ -581,6 +763,14 @@ class CitraScopeWebApp:
581
763
  else:
582
764
  self.status.next_autofocus_minutes = None
583
765
 
766
+ # Get time sync status from time monitor
767
+ if hasattr(self.daemon, "time_monitor") and self.daemon.time_monitor:
768
+ health = self.daemon.time_monitor.get_current_health()
769
+ self.status.time_health = health.to_dict() if health else None
770
+ else:
771
+ # Time monitoring not initialized yet
772
+ self.status.time_health = None
773
+
584
774
  # Get ground station information from daemon (available after API validation)
585
775
  if hasattr(self.daemon, "ground_station") and self.daemon.ground_station:
586
776
  gs_record = self.daemon.ground_station
@@ -598,6 +788,16 @@ class CitraScopeWebApp:
598
788
  # Update task processing state
599
789
  if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
600
790
  self.status.processing_active = self.daemon.task_manager.is_processing_active()
791
+ self.status.automated_scheduling = self.daemon.task_manager._automated_scheduling or False
792
+
793
+ # Check for missing dependencies from adapter
794
+ self.status.missing_dependencies = []
795
+ if hasattr(self.daemon, "hardware_adapter") and self.daemon.hardware_adapter:
796
+ if hasattr(self.daemon.hardware_adapter, "get_missing_dependencies"):
797
+ try:
798
+ self.status.missing_dependencies = self.daemon.hardware_adapter.get_missing_dependencies()
799
+ except Exception as e:
800
+ CITRASCOPE_LOGGER.debug(f"Could not check missing dependencies: {e}")
601
801
 
602
802
  self.status.last_update = datetime.now().isoformat()
603
803