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.
- citrascope/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +75 -0
- citrascope/hardware/abstract_astro_hardware_adapter.py +97 -2
- citrascope/hardware/adapter_registry.py +15 -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 +114 -0
- citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope/hardware/devices/camera/usb_camera.py +407 -0
- citrascope/hardware/devices/camera/ximea_camera.py +756 -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 +805 -0
- citrascope/hardware/dummy_adapter.py +202 -0
- citrascope/hardware/filter_sync.py +94 -0
- citrascope/hardware/indi_adapter.py +6 -2
- citrascope/hardware/kstars_dbus_adapter.py +46 -37
- citrascope/hardware/nina_adv_http_adapter.py +13 -11
- citrascope/settings/citrascope_settings.py +6 -0
- citrascope/tasks/runner.py +2 -0
- citrascope/tasks/scope/static_telescope_task.py +17 -12
- citrascope/tasks/task.py +3 -0
- citrascope/time/__init__.py +14 -0
- citrascope/time/time_health.py +103 -0
- citrascope/time/time_monitor.py +186 -0
- citrascope/time/time_sources.py +261 -0
- citrascope/web/app.py +260 -60
- citrascope/web/static/app.js +121 -731
- citrascope/web/static/components.js +136 -0
- citrascope/web/static/config.js +259 -420
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/formatters.js +129 -0
- citrascope/web/static/store-init.js +204 -0
- citrascope/web/static/style.css +44 -0
- citrascope/web/templates/_config.html +175 -0
- citrascope/web/templates/_config_hardware.html +208 -0
- citrascope/web/templates/_monitoring.html +242 -0
- citrascope/web/templates/dashboard.html +109 -377
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
- citrascope-0.9.0.dist-info/RECORD +69 -0
- citrascope-0.7.0.dist-info/RECORD +0 -41
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
- {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
- {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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
416
|
-
async def
|
|
417
|
-
"""Update
|
|
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
|
|
491
|
+
return JSONResponse({"error": "Hardware adapter not initialized"}, status_code=503)
|
|
421
492
|
|
|
422
|
-
if not
|
|
423
|
-
return JSONResponse({"error": "
|
|
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
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
if not
|
|
435
|
-
return JSONResponse({"error": "
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if enabled_count <= 1:
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
558
|
+
# Phase 2: Apply all validated updates
|
|
559
|
+
for validated in validated_updates:
|
|
560
|
+
filter_id_int = validated["filter_id_int"]
|
|
465
561
|
|
|
466
|
-
|
|
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, "
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
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
|
|