citrascope 0.6.1__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.
@@ -65,6 +65,22 @@ class CitraScopeSettings:
65
65
  self.file_logging_enabled: bool = config.get("file_logging_enabled", True)
66
66
  self.log_retention_days: int = config.get("log_retention_days", 30)
67
67
 
68
+ # Autofocus configuration (top-level/global settings)
69
+ self.scheduled_autofocus_enabled: bool = config.get("scheduled_autofocus_enabled", False)
70
+ self.autofocus_interval_minutes: int = config.get("autofocus_interval_minutes", 60)
71
+ self.last_autofocus_timestamp: Optional[int] = config.get("last_autofocus_timestamp")
72
+
73
+ # Validate autofocus interval
74
+ if (
75
+ not isinstance(self.autofocus_interval_minutes, int)
76
+ or self.autofocus_interval_minutes < 1
77
+ or self.autofocus_interval_minutes > 1439
78
+ ):
79
+ CITRASCOPE_LOGGER.warning(
80
+ f"Invalid autofocus_interval_minutes ({self.autofocus_interval_minutes}). Setting to default 60 minutes."
81
+ )
82
+ self.autofocus_interval_minutes = 60
83
+
68
84
  def get_images_dir(self) -> Path:
69
85
  """Get the path to the images directory.
70
86
 
@@ -107,6 +123,9 @@ class CitraScopeSettings:
107
123
  "max_retry_delay_seconds": self.max_retry_delay_seconds,
108
124
  "file_logging_enabled": self.file_logging_enabled,
109
125
  "log_retention_days": self.log_retention_days,
126
+ "scheduled_autofocus_enabled": self.scheduled_autofocus_enabled,
127
+ "autofocus_interval_minutes": self.autofocus_interval_minutes,
128
+ "last_autofocus_timestamp": self.last_autofocus_timestamp,
110
129
  }
111
130
 
112
131
  def save(self) -> None:
@@ -43,6 +43,9 @@ class TaskManager:
43
43
  # Task processing control (always starts active)
44
44
  self._processing_active = True
45
45
  self._processing_lock = threading.Lock()
46
+ # Autofocus request flag (set by manual or scheduled triggers)
47
+ self._autofocus_requested = False
48
+ self._autofocus_lock = threading.Lock()
46
49
 
47
50
  def poll_tasks(self):
48
51
  while not self._stop_event.is_set():
@@ -219,6 +222,20 @@ class TaskManager:
219
222
  except Exception as e:
220
223
  self.logger.error(f"Exception in task_runner loop: {e}", exc_info=True)
221
224
  time.sleep(5) # avoid tight error loop
225
+
226
+ # Check for autofocus requests between tasks
227
+ with self._autofocus_lock:
228
+ should_autofocus = self._autofocus_requested
229
+ if should_autofocus:
230
+ self._autofocus_requested = False # Clear flag before execution
231
+ # Also check if scheduled autofocus should run (inside lock to prevent race condition)
232
+ elif self._should_run_scheduled_autofocus():
233
+ should_autofocus = True
234
+ self._autofocus_requested = False # Ensure flag is clear
235
+
236
+ if should_autofocus:
237
+ self._execute_autofocus()
238
+
222
239
  self._stop_event.wait(1)
223
240
 
224
241
  def _observe_satellite(self, task: Task):
@@ -278,6 +295,92 @@ class TaskManager:
278
295
  with self._processing_lock:
279
296
  return self._processing_active
280
297
 
298
+ def request_autofocus(self) -> bool:
299
+ """Request autofocus to run at next safe point between tasks.
300
+
301
+ Returns:
302
+ bool: True indicating request was queued.
303
+ """
304
+ with self._autofocus_lock:
305
+ self._autofocus_requested = True
306
+ self.logger.info("Autofocus requested - will run between tasks")
307
+ return True
308
+
309
+ def cancel_autofocus(self) -> bool:
310
+ """Cancel pending autofocus request if still queued.
311
+
312
+ Returns:
313
+ bool: True if autofocus was cancelled, False if nothing to cancel.
314
+ """
315
+ with self._autofocus_lock:
316
+ was_requested = self._autofocus_requested
317
+ self._autofocus_requested = False
318
+ if was_requested:
319
+ self.logger.info("Autofocus request cancelled")
320
+ return was_requested
321
+
322
+ def is_autofocus_requested(self) -> bool:
323
+ """Check if autofocus is currently requested/queued.
324
+
325
+ Returns:
326
+ bool: True if autofocus is queued, False otherwise.
327
+ """
328
+ with self._autofocus_lock:
329
+ return self._autofocus_requested
330
+
331
+ def _should_run_scheduled_autofocus(self) -> bool:
332
+ """Check if scheduled autofocus should run based on settings.
333
+
334
+ Returns:
335
+ bool: True if autofocus is enabled and interval has elapsed.
336
+ """
337
+ if not self.settings:
338
+ return False
339
+
340
+ # Check if scheduled autofocus is enabled (top-level setting)
341
+ if not self.settings.scheduled_autofocus_enabled:
342
+ return False
343
+
344
+ # Check if adapter supports autofocus
345
+ if not self.hardware_adapter.supports_autofocus():
346
+ return False
347
+
348
+ interval_minutes = self.settings.autofocus_interval_minutes
349
+ last_timestamp = self.settings.last_autofocus_timestamp
350
+
351
+ # If never run (None), treat as overdue and run immediately
352
+ if last_timestamp is None:
353
+ return True
354
+
355
+ # Check if interval has elapsed
356
+ elapsed_minutes = (int(time.time()) - last_timestamp) / 60
357
+ return elapsed_minutes >= interval_minutes
358
+
359
+ def _execute_autofocus(self) -> None:
360
+ """Execute autofocus routine and update timestamp on both success and failure."""
361
+ try:
362
+ self.logger.info("Starting autofocus routine...")
363
+ self.hardware_adapter.do_autofocus()
364
+
365
+ # Save updated filter configuration after autofocus
366
+ if self.hardware_adapter.supports_filter_management():
367
+ try:
368
+ filter_config = self.hardware_adapter.get_filter_config()
369
+ if filter_config and self.settings:
370
+ self.settings.adapter_settings["filters"] = filter_config
371
+ self.logger.info(f"Saved filter configuration with {len(filter_config)} filters")
372
+ except Exception as e:
373
+ self.logger.warning(f"Failed to save filter configuration after autofocus: {e}")
374
+
375
+ self.logger.info("Autofocus routine completed successfully")
376
+ except Exception as e:
377
+ self.logger.error(f"Autofocus failed: {str(e)}", exc_info=True)
378
+ finally:
379
+ # Always update timestamp to prevent retry spam
380
+ if self.settings:
381
+ self.settings.last_autofocus_timestamp = int(time.time())
382
+ self.settings.save()
383
+
281
384
  def start(self):
282
385
  self._stop_event.clear()
283
386
  self.poll_thread = threading.Thread(target=self.poll_tasks, daemon=True)
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) {