citrascope 0.7.0__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 (44) 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 +75 -0
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +80 -2
  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 +46 -37
  24. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  25. citrascope/settings/citrascope_settings.py +6 -0
  26. citrascope/tasks/runner.py +2 -0
  27. citrascope/tasks/scope/static_telescope_task.py +17 -12
  28. citrascope/tasks/task.py +3 -0
  29. citrascope/time/__init__.py +13 -0
  30. citrascope/time/time_health.py +96 -0
  31. citrascope/time/time_monitor.py +164 -0
  32. citrascope/time/time_sources.py +62 -0
  33. citrascope/web/app.py +229 -51
  34. citrascope/web/static/app.js +296 -36
  35. citrascope/web/static/config.js +216 -81
  36. citrascope/web/static/filters.js +55 -0
  37. citrascope/web/static/style.css +39 -0
  38. citrascope/web/templates/dashboard.html +114 -9
  39. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  40. citrascope-0.8.0.dist-info/RECORD +62 -0
  41. citrascope-0.7.0.dist-info/RECORD +0 -41
  42. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  43. {citrascope-0.7.0.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  44. {citrascope-0.7.0.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
+ }
295
303
  });
296
304
 
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'));
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
+ }
318
+ });
319
+
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
@@ -394,8 +495,43 @@ function updateStatus(status) {
394
495
  updateProcessingState(status.processing_active);
395
496
  }
396
497
 
498
+ // Update automated scheduling state
499
+ if (status.automated_scheduling !== undefined) {
500
+ updateAutomatedSchedulingState(status.automated_scheduling);
501
+ }
502
+
397
503
  // Update autofocus status
398
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
+ });
399
535
  }
400
536
 
401
537
  function updateAutofocusStatus(status) {
@@ -448,6 +584,78 @@ function updateAutofocusStatus(status) {
448
584
  }
449
585
  }
450
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
+
451
659
  function formatElapsedTime(milliseconds) {
452
660
  const seconds = Math.floor(milliseconds / 1000);
453
661
  const minutes = Math.floor(seconds / 60);
@@ -480,19 +688,31 @@ function formatMinutes(minutes) {
480
688
 
481
689
  function updateProcessingState(isActive) {
482
690
  const statusEl = document.getElementById('processingStatus');
483
- const button = document.getElementById('toggleProcessingButton');
484
- const icon = document.getElementById('processingButtonIcon');
691
+ const switchEl = document.getElementById('toggleProcessingSwitch');
485
692
 
486
- if (!statusEl || !button || !icon) return;
693
+ if (!statusEl || !switchEl) return;
487
694
 
488
695
  if (isActive) {
489
- statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Active</span>';
490
- icon.textContent = 'Pause';
491
- button.title = 'Pause task processing';
696
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Enabled</span>';
697
+ switchEl.checked = true;
492
698
  } else {
493
- statusEl.innerHTML = '<span class="badge rounded-pill bg-warning text-dark">Paused</span>';
494
- icon.textContent = 'Resume';
495
- 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;
496
716
  }
497
717
  }
498
718
 
@@ -787,16 +1007,19 @@ document.addEventListener('DOMContentLoaded', async function() {
787
1007
  loadTasks();
788
1008
  loadLogs();
789
1009
 
790
- // Add pause/resume button handler
791
- const toggleButton = document.getElementById('toggleProcessingButton');
792
- if (toggleButton) {
793
- toggleButton.addEventListener('click', async () => {
794
- const icon = document.getElementById('processingButtonIcon');
795
- const currentlyPaused = icon && icon.textContent === 'Resume';
796
- 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';
797
1020
 
798
1021
  try {
799
- toggleButton.disabled = true;
1022
+ toggleSwitch.disabled = true;
800
1023
  const response = await fetch(endpoint, { method: 'POST' });
801
1024
  const result = await response.json();
802
1025
 
@@ -805,13 +1028,50 @@ document.addEventListener('DOMContentLoaded', async function() {
805
1028
  // Show specific error message (e.g., "Cannot resume during autofocus")
806
1029
  alert((result.error || 'Failed to toggle task processing') +
807
1030
  (response.status === 409 ? '' : ' - Unknown error'));
1031
+ // Revert switch state on error
1032
+ toggleSwitch.checked = !isChecked;
808
1033
  }
809
1034
  // State will be updated via WebSocket broadcast within 2 seconds
810
1035
  } catch (error) {
811
1036
  console.error('Error toggling processing:', error);
812
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;
813
1073
  } finally {
814
- toggleButton.disabled = false;
1074
+ automatedSchedulingSwitch.disabled = false;
815
1075
  }
816
1076
  });
817
1077
  }