citrascope 0.5.2__py3-none-any.whl → 0.7.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/citra_scope_daemon.py +22 -38
- citrascope/hardware/abstract_astro_hardware_adapter.py +64 -6
- citrascope/hardware/kstars_dbus_adapter.py +875 -30
- citrascope/hardware/kstars_scheduler_template.esl +30 -0
- citrascope/hardware/kstars_sequence_template.esq +16 -0
- citrascope/hardware/nina_adv_http_adapter.py +74 -59
- citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- citrascope/settings/citrascope_settings.py +25 -4
- citrascope/tasks/runner.py +103 -0
- citrascope/tasks/scope/static_telescope_task.py +6 -1
- citrascope/web/app.py +82 -37
- citrascope/web/static/app.js +83 -0
- citrascope/web/static/config.js +244 -39
- citrascope/web/templates/dashboard.html +62 -27
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/METADATA +19 -1
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/RECORD +19 -17
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/WHEEL +0 -0
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.5.2.dist-info → citrascope-0.7.0.dist-info}/licenses/LICENSE +0 -0
citrascope/web/app.py
CHANGED
|
@@ -37,6 +37,9 @@ class SystemStatus(BaseModel):
|
|
|
37
37
|
ground_station_id: Optional[str] = None
|
|
38
38
|
ground_station_name: Optional[str] = None
|
|
39
39
|
ground_station_url: Optional[str] = None
|
|
40
|
+
autofocus_requested: bool = False
|
|
41
|
+
last_autofocus_timestamp: Optional[int] = None
|
|
42
|
+
next_autofocus_minutes: Optional[int] = None
|
|
40
43
|
last_update: str = ""
|
|
41
44
|
|
|
42
45
|
|
|
@@ -182,6 +185,9 @@ class CitraScopeWebApp:
|
|
|
182
185
|
"max_task_retries": settings.max_task_retries,
|
|
183
186
|
"initial_retry_delay_seconds": settings.initial_retry_delay_seconds,
|
|
184
187
|
"max_retry_delay_seconds": settings.max_retry_delay_seconds,
|
|
188
|
+
"scheduled_autofocus_enabled": settings.scheduled_autofocus_enabled,
|
|
189
|
+
"autofocus_interval_minutes": settings.autofocus_interval_minutes,
|
|
190
|
+
"last_autofocus_timestamp": settings.last_autofocus_timestamp,
|
|
185
191
|
"app_url": app_url,
|
|
186
192
|
"config_file_path": config_path,
|
|
187
193
|
"log_file_path": log_file_path,
|
|
@@ -241,13 +247,6 @@ class CitraScopeWebApp:
|
|
|
241
247
|
if not self.daemon:
|
|
242
248
|
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
243
249
|
|
|
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
250
|
# Validate required fields
|
|
252
251
|
required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
|
|
253
252
|
for field in required_fields:
|
|
@@ -392,13 +391,6 @@ class CitraScopeWebApp:
|
|
|
392
391
|
if not self.daemon or not self.daemon.task_manager:
|
|
393
392
|
return JSONResponse({"error": "Task manager not available"}, status_code=503)
|
|
394
393
|
|
|
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
394
|
self.daemon.task_manager.resume()
|
|
403
395
|
await self.broadcast_status()
|
|
404
396
|
|
|
@@ -422,7 +414,8 @@ class CitraScopeWebApp:
|
|
|
422
414
|
|
|
423
415
|
@self.app.patch("/api/adapter/filters/{filter_id}")
|
|
424
416
|
async def update_filter(filter_id: str, update: dict):
|
|
425
|
-
"""Update focus position for a specific filter."""
|
|
417
|
+
"""Update focus position and/or enabled state for a specific filter."""
|
|
418
|
+
|
|
426
419
|
if not self.daemon or not self.daemon.hardware_adapter:
|
|
427
420
|
return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
|
|
428
421
|
|
|
@@ -430,31 +423,50 @@ class CitraScopeWebApp:
|
|
|
430
423
|
return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
|
|
431
424
|
|
|
432
425
|
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)
|
|
436
|
-
|
|
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)
|
|
440
|
-
|
|
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)
|
|
444
|
-
|
|
445
426
|
# Get current filter config
|
|
446
427
|
filter_config = self.daemon.hardware_adapter.get_filter_config()
|
|
447
428
|
if filter_id not in filter_config:
|
|
448
429
|
return JSONResponse({"error": f"Filter {filter_id} not found"}, status_code=404)
|
|
449
430
|
|
|
450
|
-
#
|
|
451
|
-
if
|
|
452
|
-
|
|
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:
|
|
455
|
+
return JSONResponse(
|
|
456
|
+
{
|
|
457
|
+
"error": "Cannot disable last enabled filter. At least one filter must remain enabled."
|
|
458
|
+
},
|
|
459
|
+
status_code=400,
|
|
460
|
+
)
|
|
461
|
+
|
|
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)
|
|
453
465
|
|
|
454
466
|
# Save to settings
|
|
455
467
|
self.daemon._save_filter_config()
|
|
456
468
|
|
|
457
|
-
return {"success": True, "filter_id": filter_id, "
|
|
469
|
+
return {"success": True, "filter_id": filter_id, "updated": update}
|
|
458
470
|
except ValueError:
|
|
459
471
|
return JSONResponse({"error": "Invalid filter_id format"}, status_code=400)
|
|
460
472
|
except Exception as e:
|
|
@@ -463,7 +475,7 @@ class CitraScopeWebApp:
|
|
|
463
475
|
|
|
464
476
|
@self.app.post("/api/adapter/autofocus")
|
|
465
477
|
async def trigger_autofocus():
|
|
466
|
-
"""
|
|
478
|
+
"""Request autofocus to run between tasks."""
|
|
467
479
|
if not self.daemon:
|
|
468
480
|
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
469
481
|
|
|
@@ -471,15 +483,26 @@ class CitraScopeWebApp:
|
|
|
471
483
|
return JSONResponse({"error": "Filter management not supported"}, status_code=404)
|
|
472
484
|
|
|
473
485
|
try:
|
|
474
|
-
|
|
475
|
-
# Autofocus can take several minutes (slewing + focusing multiple filters)
|
|
476
|
-
success, error = await asyncio.to_thread(self.daemon.trigger_autofocus)
|
|
486
|
+
success, error = self.daemon.trigger_autofocus()
|
|
477
487
|
if success:
|
|
478
|
-
return {"success": True, "message": "Autofocus
|
|
488
|
+
return {"success": True, "message": "Autofocus queued - will run between tasks"}
|
|
479
489
|
else:
|
|
480
490
|
return JSONResponse({"error": error}, status_code=500)
|
|
481
491
|
except Exception as e:
|
|
482
|
-
CITRASCOPE_LOGGER.error(f"Error
|
|
492
|
+
CITRASCOPE_LOGGER.error(f"Error queueing autofocus: {e}", exc_info=True)
|
|
493
|
+
return JSONResponse({"error": str(e)}, status_code=500)
|
|
494
|
+
|
|
495
|
+
@self.app.post("/api/adapter/autofocus/cancel")
|
|
496
|
+
async def cancel_autofocus():
|
|
497
|
+
"""Cancel pending autofocus request."""
|
|
498
|
+
if not self.daemon:
|
|
499
|
+
return JSONResponse({"error": "Daemon not available"}, status_code=503)
|
|
500
|
+
|
|
501
|
+
try:
|
|
502
|
+
was_cancelled = self.daemon.cancel_autofocus()
|
|
503
|
+
return {"success": was_cancelled}
|
|
504
|
+
except Exception as e:
|
|
505
|
+
CITRASCOPE_LOGGER.error(f"Error cancelling autofocus: {e}", exc_info=True)
|
|
483
506
|
return JSONResponse({"error": str(e)}, status_code=500)
|
|
484
507
|
|
|
485
508
|
@self.app.websocket("/ws")
|
|
@@ -533,9 +556,31 @@ class CitraScopeWebApp:
|
|
|
533
556
|
if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
|
|
534
557
|
task_manager = self.daemon.task_manager
|
|
535
558
|
self.status.current_task = task_manager.current_task_id
|
|
559
|
+
self.status.autofocus_requested = task_manager.is_autofocus_requested()
|
|
536
560
|
with task_manager.heap_lock:
|
|
537
561
|
self.status.tasks_pending = len(task_manager.task_heap)
|
|
538
562
|
|
|
563
|
+
# Get autofocus timing information
|
|
564
|
+
if self.daemon.settings:
|
|
565
|
+
settings = self.daemon.settings
|
|
566
|
+
self.status.last_autofocus_timestamp = settings.last_autofocus_timestamp
|
|
567
|
+
|
|
568
|
+
# Calculate next autofocus time if scheduled is enabled
|
|
569
|
+
if settings.scheduled_autofocus_enabled:
|
|
570
|
+
last_ts = settings.last_autofocus_timestamp
|
|
571
|
+
interval_minutes = settings.autofocus_interval_minutes
|
|
572
|
+
if last_ts is not None:
|
|
573
|
+
import time
|
|
574
|
+
|
|
575
|
+
elapsed_minutes = (int(time.time()) - last_ts) / 60
|
|
576
|
+
remaining = max(0, interval_minutes - elapsed_minutes)
|
|
577
|
+
self.status.next_autofocus_minutes = int(remaining)
|
|
578
|
+
else:
|
|
579
|
+
# Never run - will trigger immediately
|
|
580
|
+
self.status.next_autofocus_minutes = 0
|
|
581
|
+
else:
|
|
582
|
+
self.status.next_autofocus_minutes = None
|
|
583
|
+
|
|
539
584
|
# Get ground station information from daemon (available after API validation)
|
|
540
585
|
if hasattr(self.daemon, "ground_station") and self.daemon.ground_station:
|
|
541
586
|
gs_record = self.daemon.ground_station
|
citrascope/web/static/app.js
CHANGED
|
@@ -393,6 +393,89 @@ function updateStatus(status) {
|
|
|
393
393
|
if (status.processing_active !== undefined) {
|
|
394
394
|
updateProcessingState(status.processing_active);
|
|
395
395
|
}
|
|
396
|
+
|
|
397
|
+
// Update autofocus status
|
|
398
|
+
updateAutofocusStatus(status);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function updateAutofocusStatus(status) {
|
|
402
|
+
// Update autofocus button state
|
|
403
|
+
const button = document.getElementById('runAutofocusButton');
|
|
404
|
+
const buttonText = document.getElementById('autofocusButtonText');
|
|
405
|
+
|
|
406
|
+
if (button && buttonText && status.autofocus_requested !== undefined) {
|
|
407
|
+
if (status.autofocus_requested) {
|
|
408
|
+
buttonText.textContent = 'Cancel Autofocus';
|
|
409
|
+
button.dataset.action = 'cancel';
|
|
410
|
+
button.classList.remove('btn-outline-primary');
|
|
411
|
+
button.classList.add('btn-outline-warning');
|
|
412
|
+
} else {
|
|
413
|
+
buttonText.textContent = 'Run Autofocus';
|
|
414
|
+
button.dataset.action = 'request';
|
|
415
|
+
button.classList.remove('btn-outline-warning');
|
|
416
|
+
button.classList.add('btn-outline-primary');
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Update last autofocus display
|
|
421
|
+
const lastAutofocusDisplay = document.getElementById('lastAutofocusDisplay');
|
|
422
|
+
if (lastAutofocusDisplay) {
|
|
423
|
+
if (status.last_autofocus_timestamp) {
|
|
424
|
+
const timestamp = status.last_autofocus_timestamp * 1000; // Convert to milliseconds
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
const elapsed = now - timestamp;
|
|
427
|
+
lastAutofocusDisplay.textContent = formatElapsedTime(elapsed);
|
|
428
|
+
} else {
|
|
429
|
+
lastAutofocusDisplay.textContent = 'Never';
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Update next autofocus display
|
|
434
|
+
const nextAutofocusDisplay = document.getElementById('nextAutofocusDisplay');
|
|
435
|
+
const nextAutofocusTime = document.getElementById('nextAutofocusTime');
|
|
436
|
+
|
|
437
|
+
if (nextAutofocusDisplay && nextAutofocusTime) {
|
|
438
|
+
if (status.next_autofocus_minutes !== null && status.next_autofocus_minutes !== undefined) {
|
|
439
|
+
nextAutofocusDisplay.style.display = 'block';
|
|
440
|
+
if (status.next_autofocus_minutes === 0) {
|
|
441
|
+
nextAutofocusTime.textContent = 'now (overdue)';
|
|
442
|
+
} else {
|
|
443
|
+
nextAutofocusTime.textContent = formatMinutes(status.next_autofocus_minutes);
|
|
444
|
+
}
|
|
445
|
+
} else {
|
|
446
|
+
nextAutofocusDisplay.style.display = 'none';
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function formatElapsedTime(milliseconds) {
|
|
452
|
+
const seconds = Math.floor(milliseconds / 1000);
|
|
453
|
+
const minutes = Math.floor(seconds / 60);
|
|
454
|
+
const hours = Math.floor(minutes / 60);
|
|
455
|
+
const days = Math.floor(hours / 24);
|
|
456
|
+
|
|
457
|
+
if (days > 0) {
|
|
458
|
+
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
|
459
|
+
} else if (hours > 0) {
|
|
460
|
+
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
|
461
|
+
} else if (minutes > 0) {
|
|
462
|
+
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
|
463
|
+
} else {
|
|
464
|
+
return 'just now';
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function formatMinutes(minutes) {
|
|
469
|
+
const hours = Math.floor(minutes / 60);
|
|
470
|
+
const mins = Math.floor(minutes % 60);
|
|
471
|
+
|
|
472
|
+
if (hours > 0) {
|
|
473
|
+
if (mins > 0) {
|
|
474
|
+
return `${hours}h ${mins}m`;
|
|
475
|
+
}
|
|
476
|
+
return `${hours}h`;
|
|
477
|
+
}
|
|
478
|
+
return `${mins}m`;
|
|
396
479
|
}
|
|
397
480
|
|
|
398
481
|
function updateProcessingState(isActive) {
|