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/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
- # 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)
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, "focus_position": focus_position}
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
- """Trigger autofocus routine."""
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
- # 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)
486
+ success, error = self.daemon.trigger_autofocus()
477
487
  if success:
478
- return {"success": True, "message": "Autofocus completed successfully"}
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 triggering autofocus: {e}", exc_info=True)
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
@@ -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) {