citrascope 0.6.1__py3-none-any.whl → 0.8.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 (45) 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 +97 -38
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
  5. citrascope/hardware/adapter_registry.py +10 -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 +102 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +402 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +744 -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 +787 -0
  21. citrascope/hardware/filter_sync.py +94 -0
  22. citrascope/hardware/indi_adapter.py +6 -2
  23. citrascope/hardware/kstars_dbus_adapter.py +67 -96
  24. citrascope/hardware/nina_adv_http_adapter.py +81 -64
  25. citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  26. citrascope/settings/citrascope_settings.py +25 -0
  27. citrascope/tasks/runner.py +105 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +13 -0
  31. citrascope/time/time_health.py +96 -0
  32. citrascope/time/time_monitor.py +164 -0
  33. citrascope/time/time_sources.py +62 -0
  34. citrascope/web/app.py +274 -51
  35. citrascope/web/static/app.js +379 -36
  36. citrascope/web/static/config.js +448 -108
  37. citrascope/web/static/filters.js +55 -0
  38. citrascope/web/static/style.css +39 -0
  39. citrascope/web/templates/dashboard.html +176 -36
  40. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  41. citrascope-0.8.0.dist-info/RECORD +62 -0
  42. citrascope-0.6.1.dist-info/RECORD +0 -41
  43. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  44. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  45. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,6 @@
1
1
  // CitraScope Dashboard - Main Application
2
2
  import { connectWebSocket } from './websocket.js';
3
- import { initConfig, currentConfig, initFilterConfig, setupAutofocusButton } from './config.js';
3
+ import { initConfig, currentConfig, initFilterConfig, setupAutofocusButton, createToast } from './config.js';
4
4
  import { getTasks, getLogs } from './api.js';
5
5
 
6
6
  function updateAppUrlLinks() {
@@ -279,26 +279,53 @@ function initNavigation() {
279
279
  }
280
280
  }
281
281
 
282
- nav.addEventListener('click', function(e) {
283
- const link = e.target.closest('a[data-section]');
282
+ function navigateToSection(section) {
283
+ const link = nav.querySelector(`a[data-section="${section}"]`);
284
284
  if (link) {
285
- e.preventDefault();
286
- const section = link.getAttribute('data-section');
287
285
  activateNav(link);
288
286
  showSection(section);
287
+ window.location.hash = section;
289
288
 
290
289
  // Reload filter config when config section is shown
291
290
  if (section === 'config') {
292
291
  initFilterConfig();
293
292
  }
294
293
  }
294
+ }
295
+
296
+ nav.addEventListener('click', function(e) {
297
+ const link = e.target.closest('a[data-section]');
298
+ if (link) {
299
+ e.preventDefault();
300
+ const section = link.getAttribute('data-section');
301
+ navigateToSection(section);
302
+ }
303
+ });
304
+
305
+ // Handle hash changes (back/forward navigation)
306
+ window.addEventListener('hashchange', function() {
307
+ const hash = window.location.hash.substring(1);
308
+ if (hash && sections[hash]) {
309
+ const link = nav.querySelector(`a[data-section="${hash}"]`);
310
+ if (link) {
311
+ activateNav(link);
312
+ showSection(hash);
313
+ if (hash === 'config') {
314
+ initFilterConfig();
315
+ }
316
+ }
317
+ }
295
318
  });
296
319
 
297
- // Default to first nav item
298
- const first = nav.querySelector('a[data-section]');
299
- if (first) {
300
- activateNav(first);
301
- showSection(first.getAttribute('data-section'));
320
+ // Initialize from hash or default to first nav item
321
+ const hash = window.location.hash.substring(1);
322
+ if (hash && sections[hash]) {
323
+ navigateToSection(hash);
324
+ } else {
325
+ const first = nav.querySelector('a[data-section]');
326
+ if (first) {
327
+ navigateToSection(first.getAttribute('data-section'));
328
+ }
302
329
  }
303
330
  }
304
331
  }
@@ -335,15 +362,91 @@ function updateWSStatus(connected, reconnectInfo = '') {
335
362
  }
336
363
  }
337
364
 
365
+ // --- Camera Control ---
366
+ window.showCameraControl = function() {
367
+ const modal = new bootstrap.Modal(document.getElementById('cameraControlModal'));
368
+
369
+ // Populate images directory link from config
370
+ const imagesDirLink = document.getElementById('imagesDirLink');
371
+ if (imagesDirLink && currentConfig.images_dir_path) {
372
+ imagesDirLink.textContent = currentConfig.images_dir_path;
373
+ imagesDirLink.href = `file://${currentConfig.images_dir_path}`;
374
+ }
375
+
376
+ // Clear previous capture result
377
+ document.getElementById('captureResult').style.display = 'none';
378
+
379
+ modal.show();
380
+ };
381
+
382
+ window.captureImage = async function() {
383
+ const captureButton = document.getElementById('captureButton');
384
+ const buttonText = document.getElementById('captureButtonText');
385
+ const spinner = document.getElementById('captureButtonSpinner');
386
+ const exposureDuration = parseFloat(document.getElementById('exposureDuration').value);
387
+
388
+ // Validate exposure duration
389
+ if (isNaN(exposureDuration) || exposureDuration <= 0) {
390
+ createToast('Invalid exposure duration', 'danger', false);
391
+ return;
392
+ }
393
+
394
+ // Show loading state
395
+ captureButton.disabled = true;
396
+ spinner.style.display = 'inline-block';
397
+ buttonText.textContent = 'Capturing...';
398
+
399
+ try {
400
+ const response = await fetch('/api/camera/capture', {
401
+ method: 'POST',
402
+ headers: {
403
+ 'Content-Type': 'application/json'
404
+ },
405
+ body: JSON.stringify({ duration: exposureDuration })
406
+ });
407
+
408
+ const data = await response.json();
409
+
410
+ if (response.ok && data.success) {
411
+ // Show capture result
412
+ document.getElementById('captureFilename').textContent = data.filename;
413
+ document.getElementById('captureFormat').textContent = data.format || 'Unknown';
414
+ document.getElementById('captureResult').style.display = 'block';
415
+
416
+ createToast('Image captured successfully', 'success', true);
417
+ } else {
418
+ const errorMsg = data.error || 'Failed to capture image';
419
+ createToast(errorMsg, 'danger', false);
420
+ }
421
+ } catch (error) {
422
+ console.error('Capture error:', error);
423
+ createToast('Failed to capture image: ' + error.message, 'danger', false);
424
+ } finally {
425
+ // Reset button state
426
+ captureButton.disabled = false;
427
+ spinner.style.display = 'none';
428
+ buttonText.textContent = 'Capture';
429
+ }
430
+ };
431
+
338
432
  // --- Status Updates ---
339
433
  function updateStatus(status) {
340
434
  document.getElementById('hardwareAdapter').textContent = status.hardware_adapter || '-';
341
435
  document.getElementById('telescopeConnected').innerHTML = status.telescope_connected
342
436
  ? '<span class="badge rounded-pill bg-success">Connected</span>'
343
437
  : '<span class="badge rounded-pill bg-danger">Disconnected</span>';
344
- document.getElementById('cameraConnected').innerHTML = status.camera_connected
345
- ? '<span class="badge rounded-pill bg-success">Connected</span>'
346
- : '<span class="badge rounded-pill bg-danger">Disconnected</span>';
438
+ // Update camera status with control button when connected
439
+ const cameraEl = document.getElementById('cameraConnected');
440
+ if (status.camera_connected) {
441
+ cameraEl.innerHTML = `
442
+ <span class="badge rounded-pill bg-success">Connected</span>
443
+ <button class="btn btn-sm btn-outline-light" onclick="showCameraControl()">
444
+ <i class="bi bi-camera"></i> Control
445
+ </button>
446
+ `;
447
+ } else {
448
+ cameraEl.innerHTML = '<span class="badge rounded-pill bg-danger">Disconnected</span>';
449
+ }
347
450
 
348
451
  // Update current task display
349
452
  if (status.current_task && status.current_task !== 'None') {
@@ -363,11 +466,9 @@ function updateStatus(status) {
363
466
  document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
364
467
  }
365
468
 
366
- if (status.telescope_ra !== null) {
367
- document.getElementById('telescopeRA').textContent = status.telescope_ra.toFixed(4) + '°';
368
- }
369
- if (status.telescope_dec !== null) {
370
- document.getElementById('telescopeDEC').textContent = status.telescope_dec.toFixed(4) + '°';
469
+ if (status.telescope_ra !== null && status.telescope_dec !== null) {
470
+ document.getElementById('telescopeCoords').textContent =
471
+ status.telescope_ra.toFixed(3) + '° / ' + status.telescope_dec.toFixed(3) + '°';
371
472
  }
372
473
 
373
474
  // Update ground station information
@@ -393,23 +494,225 @@ function updateStatus(status) {
393
494
  if (status.processing_active !== undefined) {
394
495
  updateProcessingState(status.processing_active);
395
496
  }
497
+
498
+ // Update automated scheduling state
499
+ if (status.automated_scheduling !== undefined) {
500
+ updateAutomatedSchedulingState(status.automated_scheduling);
501
+ }
502
+
503
+ // Update autofocus status
504
+ updateAutofocusStatus(status);
505
+
506
+ // Update time sync status
507
+ updateTimeSyncStatus(status);
508
+
509
+ // Update missing dependencies display
510
+ updateMissingDependencies(status.missing_dependencies || []);
511
+ }
512
+
513
+ function updateMissingDependencies(missingDeps) {
514
+ const dashboardContainer = document.getElementById('missingDependenciesAlert');
515
+ const configContainer = document.getElementById('configMissingDependenciesAlert');
516
+
517
+ const containers = [dashboardContainer, configContainer].filter(c => c);
518
+
519
+ if (missingDeps.length === 0) {
520
+ containers.forEach(container => container.style.display = 'none');
521
+ return;
522
+ }
523
+
524
+ let html = '<strong><i class="bi bi-exclamation-triangle-fill me-2"></i>Missing Dependencies:</strong><ul class="mb-0 mt-2">';
525
+ missingDeps.forEach(dep => {
526
+ html += `<li><strong>${dep.device_name}</strong>: Missing ${dep.missing_packages}<br>`;
527
+ html += `<code class="small">${dep.install_cmd}</code></li>`;
528
+ });
529
+ html += '</ul>';
530
+
531
+ containers.forEach(container => {
532
+ container.innerHTML = html;
533
+ container.style.display = 'block';
534
+ });
535
+ }
536
+
537
+ function updateAutofocusStatus(status) {
538
+ // Update autofocus button state
539
+ const button = document.getElementById('runAutofocusButton');
540
+ const buttonText = document.getElementById('autofocusButtonText');
541
+
542
+ if (button && buttonText && status.autofocus_requested !== undefined) {
543
+ if (status.autofocus_requested) {
544
+ buttonText.textContent = 'Cancel Autofocus';
545
+ button.dataset.action = 'cancel';
546
+ button.classList.remove('btn-outline-primary');
547
+ button.classList.add('btn-outline-warning');
548
+ } else {
549
+ buttonText.textContent = 'Run Autofocus';
550
+ button.dataset.action = 'request';
551
+ button.classList.remove('btn-outline-warning');
552
+ button.classList.add('btn-outline-primary');
553
+ }
554
+ }
555
+
556
+ // Update last autofocus display
557
+ const lastAutofocusDisplay = document.getElementById('lastAutofocusDisplay');
558
+ if (lastAutofocusDisplay) {
559
+ if (status.last_autofocus_timestamp) {
560
+ const timestamp = status.last_autofocus_timestamp * 1000; // Convert to milliseconds
561
+ const now = Date.now();
562
+ const elapsed = now - timestamp;
563
+ lastAutofocusDisplay.textContent = formatElapsedTime(elapsed);
564
+ } else {
565
+ lastAutofocusDisplay.textContent = 'Never';
566
+ }
567
+ }
568
+
569
+ // Update next autofocus display
570
+ const nextAutofocusDisplay = document.getElementById('nextAutofocusDisplay');
571
+ const nextAutofocusTime = document.getElementById('nextAutofocusTime');
572
+
573
+ if (nextAutofocusDisplay && nextAutofocusTime) {
574
+ if (status.next_autofocus_minutes !== null && status.next_autofocus_minutes !== undefined) {
575
+ nextAutofocusDisplay.style.display = 'block';
576
+ if (status.next_autofocus_minutes === 0) {
577
+ nextAutofocusTime.textContent = 'now (overdue)';
578
+ } else {
579
+ nextAutofocusTime.textContent = formatMinutes(status.next_autofocus_minutes);
580
+ }
581
+ } else {
582
+ nextAutofocusDisplay.style.display = 'none';
583
+ }
584
+ }
585
+ }
586
+
587
+ function updateTimeSyncStatus(status) {
588
+ const statusEl = document.getElementById('timeSyncStatus');
589
+ const offsetEl = document.getElementById('timeOffsetDisplay');
590
+
591
+ if (!statusEl || !offsetEl) {
592
+ return;
593
+ }
594
+
595
+ const timeHealth = status.time_health;
596
+
597
+ // Handle missing health data
598
+ if (!timeHealth) {
599
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-secondary" data-bs-toggle="tooltip" title="Time sync status unknown">Unknown</span>';
600
+ offsetEl.textContent = '-';
601
+ return;
602
+ }
603
+
604
+ // Determine badge color based on status
605
+ let badgeClass = 'bg-secondary';
606
+ let badgeText = 'Unknown';
607
+ let tooltipText = 'Time sync status unknown';
608
+
609
+ switch (timeHealth.status) {
610
+ case 'ok':
611
+ badgeClass = 'bg-success';
612
+ badgeText = 'OK';
613
+ tooltipText = 'Time sync within threshold';
614
+ break;
615
+ case 'critical':
616
+ badgeClass = 'bg-danger';
617
+ badgeText = 'Paused';
618
+ tooltipText = 'Time drift exceeded threshold - tasks paused';
619
+ break;
620
+ case 'unknown':
621
+ default:
622
+ badgeClass = 'bg-secondary';
623
+ badgeText = 'Unknown';
624
+ tooltipText = 'Unable to check time sync';
625
+ break;
626
+ }
627
+
628
+ statusEl.innerHTML = `<span class="badge rounded-pill ${badgeClass}" data-bs-toggle="tooltip" title="${tooltipText}">${badgeText}</span>`;
629
+
630
+ // Format offset display
631
+ if (timeHealth.offset_ms !== null && timeHealth.offset_ms !== undefined) {
632
+ const offset = timeHealth.offset_ms;
633
+ const absOffset = Math.abs(offset);
634
+ const sign = offset >= 0 ? '+' : '-';
635
+
636
+ if (absOffset < 1) {
637
+ offsetEl.textContent = `${sign}${absOffset.toFixed(2)}ms`;
638
+ } else if (absOffset < 1000) {
639
+ offsetEl.textContent = `${sign}${absOffset.toFixed(0)}ms`;
640
+ } else {
641
+ offsetEl.textContent = `${sign}${(absOffset / 1000).toFixed(2)}s`;
642
+ }
643
+
644
+ // Add source indicator
645
+ if (timeHealth.source && timeHealth.source !== 'unknown') {
646
+ offsetEl.textContent += ` (${timeHealth.source})`;
647
+ }
648
+ } else {
649
+ offsetEl.textContent = '-';
650
+ }
651
+
652
+ // Reinitialize tooltips
653
+ const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
654
+ tooltipTriggerList.map(function (tooltipTriggerEl) {
655
+ return new bootstrap.Tooltip(tooltipTriggerEl);
656
+ });
657
+ }
658
+
659
+ function formatElapsedTime(milliseconds) {
660
+ const seconds = Math.floor(milliseconds / 1000);
661
+ const minutes = Math.floor(seconds / 60);
662
+ const hours = Math.floor(minutes / 60);
663
+ const days = Math.floor(hours / 24);
664
+
665
+ if (days > 0) {
666
+ return `${days} day${days !== 1 ? 's' : ''} ago`;
667
+ } else if (hours > 0) {
668
+ return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
669
+ } else if (minutes > 0) {
670
+ return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
671
+ } else {
672
+ return 'just now';
673
+ }
674
+ }
675
+
676
+ function formatMinutes(minutes) {
677
+ const hours = Math.floor(minutes / 60);
678
+ const mins = Math.floor(minutes % 60);
679
+
680
+ if (hours > 0) {
681
+ if (mins > 0) {
682
+ return `${hours}h ${mins}m`;
683
+ }
684
+ return `${hours}h`;
685
+ }
686
+ return `${mins}m`;
396
687
  }
397
688
 
398
689
  function updateProcessingState(isActive) {
399
690
  const statusEl = document.getElementById('processingStatus');
400
- const button = document.getElementById('toggleProcessingButton');
401
- const icon = document.getElementById('processingButtonIcon');
691
+ const switchEl = document.getElementById('toggleProcessingSwitch');
402
692
 
403
- if (!statusEl || !button || !icon) return;
693
+ if (!statusEl || !switchEl) return;
404
694
 
405
695
  if (isActive) {
406
- statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Active</span>';
407
- icon.textContent = 'Pause';
408
- button.title = 'Pause task processing';
696
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Enabled</span>';
697
+ switchEl.checked = true;
409
698
  } else {
410
- statusEl.innerHTML = '<span class="badge rounded-pill bg-warning text-dark">Paused</span>';
411
- icon.textContent = 'Resume';
412
- button.title = 'Resume task processing';
699
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-secondary">Disabled</span>';
700
+ switchEl.checked = false;
701
+ }
702
+ }
703
+
704
+ function updateAutomatedSchedulingState(isEnabled) {
705
+ const statusEl = document.getElementById('automatedSchedulingStatus');
706
+ const switchEl = document.getElementById('toggleAutomatedSchedulingSwitch');
707
+
708
+ if (!statusEl || !switchEl) return;
709
+
710
+ if (isEnabled) {
711
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Enabled</span>';
712
+ switchEl.checked = true;
713
+ } else {
714
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-secondary">Disabled</span>';
715
+ switchEl.checked = false;
413
716
  }
414
717
  }
415
718
 
@@ -704,16 +1007,19 @@ document.addEventListener('DOMContentLoaded', async function() {
704
1007
  loadTasks();
705
1008
  loadLogs();
706
1009
 
707
- // Add pause/resume button handler
708
- const toggleButton = document.getElementById('toggleProcessingButton');
709
- if (toggleButton) {
710
- toggleButton.addEventListener('click', async () => {
711
- const icon = document.getElementById('processingButtonIcon');
712
- const currentlyPaused = icon && icon.textContent === 'Resume';
713
- const endpoint = currentlyPaused ? '/api/tasks/resume' : '/api/tasks/pause';
1010
+ // Initialize Bootstrap tooltips
1011
+ const tooltipTriggerList = document.querySelectorAll('[data-bs-toggle="tooltip"]');
1012
+ const tooltipList = [...tooltipTriggerList].map(tooltipTriggerEl => new bootstrap.Tooltip(tooltipTriggerEl));
1013
+
1014
+ // Add pause/resume switch handler
1015
+ const toggleSwitch = document.getElementById('toggleProcessingSwitch');
1016
+ if (toggleSwitch) {
1017
+ toggleSwitch.addEventListener('change', async (e) => {
1018
+ const isChecked = e.target.checked;
1019
+ const endpoint = isChecked ? '/api/tasks/resume' : '/api/tasks/pause';
714
1020
 
715
1021
  try {
716
- toggleButton.disabled = true;
1022
+ toggleSwitch.disabled = true;
717
1023
  const response = await fetch(endpoint, { method: 'POST' });
718
1024
  const result = await response.json();
719
1025
 
@@ -722,13 +1028,50 @@ document.addEventListener('DOMContentLoaded', async function() {
722
1028
  // Show specific error message (e.g., "Cannot resume during autofocus")
723
1029
  alert((result.error || 'Failed to toggle task processing') +
724
1030
  (response.status === 409 ? '' : ' - Unknown error'));
1031
+ // Revert switch state on error
1032
+ toggleSwitch.checked = !isChecked;
725
1033
  }
726
1034
  // State will be updated via WebSocket broadcast within 2 seconds
727
1035
  } catch (error) {
728
1036
  console.error('Error toggling processing:', error);
729
1037
  alert('Error toggling task processing');
1038
+ // Revert switch state on error
1039
+ toggleSwitch.checked = !isChecked;
1040
+ } finally {
1041
+ toggleSwitch.disabled = false;
1042
+ }
1043
+ });
1044
+ }
1045
+
1046
+ // Add automated scheduling switch handler
1047
+ const automatedSchedulingSwitch = document.getElementById('toggleAutomatedSchedulingSwitch');
1048
+ if (automatedSchedulingSwitch) {
1049
+ automatedSchedulingSwitch.addEventListener('change', async (e) => {
1050
+ const isChecked = e.target.checked;
1051
+
1052
+ try {
1053
+ automatedSchedulingSwitch.disabled = true;
1054
+ const response = await fetch('/api/telescope/automated-scheduling', {
1055
+ method: 'PATCH',
1056
+ headers: { 'Content-Type': 'application/json' },
1057
+ body: JSON.stringify({ enabled: isChecked })
1058
+ });
1059
+ const result = await response.json();
1060
+
1061
+ if (!response.ok) {
1062
+ console.error('Failed to toggle automated scheduling:', result);
1063
+ alert(result.error || 'Failed to toggle automated scheduling');
1064
+ // Revert switch state on error
1065
+ automatedSchedulingSwitch.checked = !isChecked;
1066
+ }
1067
+ // State will be updated via WebSocket broadcast
1068
+ } catch (error) {
1069
+ console.error('Error toggling automated scheduling:', error);
1070
+ alert('Error toggling automated scheduling');
1071
+ // Revert switch state on error
1072
+ automatedSchedulingSwitch.checked = !isChecked;
730
1073
  } finally {
731
- toggleButton.disabled = false;
1074
+ automatedSchedulingSwitch.disabled = false;
732
1075
  }
733
1076
  });
734
1077
  }