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.
@@ -9,6 +9,7 @@ const DEFAULT_API_PORT = 443;
9
9
 
10
10
  let currentAdapterSchema = [];
11
11
  export let currentConfig = {};
12
+ let savedAdapter = null; // Track the currently saved adapter
12
13
 
13
14
  export async function initConfig() {
14
15
  // Populate hardware adapter dropdown
@@ -21,8 +22,11 @@ export async function initConfig() {
21
22
  const adapter = e.target.value;
22
23
  if (adapter) {
23
24
  await loadAdapterSchema(adapter);
25
+ await loadFilterConfig();
24
26
  } else {
25
27
  document.getElementById('adapter-settings-container').innerHTML = '';
28
+ const filterSection = document.getElementById('filterConfigSection');
29
+ if (filterSection) filterSection.style.display = 'none';
26
30
  }
27
31
  });
28
32
  }
@@ -106,6 +110,7 @@ async function loadConfiguration() {
106
110
  try {
107
111
  const config = await getConfig();
108
112
  currentConfig = config; // Save for reuse when saving
113
+ savedAdapter = config.hardware_adapter; // Track saved adapter
109
114
 
110
115
  // Display config file path
111
116
  const configPathElement = document.getElementById('configFilePath');
@@ -158,6 +163,16 @@ async function loadConfiguration() {
158
163
  document.getElementById('keep_images').checked = config.keep_images || false;
159
164
  document.getElementById('file_logging_enabled').checked = config.file_logging_enabled !== undefined ? config.file_logging_enabled : true;
160
165
 
166
+ // Load autofocus settings (top-level)
167
+ const scheduledAutofocusEnabled = document.getElementById('scheduled_autofocus_enabled');
168
+ const autofocusInterval = document.getElementById('autofocus_interval_minutes');
169
+ if (scheduledAutofocusEnabled) {
170
+ scheduledAutofocusEnabled.checked = config.scheduled_autofocus_enabled || false;
171
+ }
172
+ if (autofocusInterval) {
173
+ autofocusInterval.value = config.autofocus_interval_minutes || 60;
174
+ }
175
+
161
176
  // Load adapter-specific settings if adapter is selected
162
177
  if (config.hardware_adapter) {
163
178
  await loadAdapterSchema(config.hardware_adapter);
@@ -196,6 +211,11 @@ function renderAdapterSettings(schema) {
196
211
  let html = '<h5 class="mb-3">Adapter Settings</h5><div class="row g-3 mb-4">';
197
212
 
198
213
  schema.forEach(field => {
214
+ // Skip readonly fields (handled elsewhere in UI)
215
+ if (field.readonly) {
216
+ return;
217
+ }
218
+
199
219
  const isRequired = field.required ? '<span class="text-danger">*</span>' : '';
200
220
  const placeholder = field.placeholder || '';
201
221
  const description = field.description || '';
@@ -275,16 +295,18 @@ function collectAdapterSettings() {
275
295
  value = input.value;
276
296
  }
277
297
 
298
+ // Skip empty values for non-checkbox fields (will use backend defaults)
299
+ if (input.type !== 'checkbox' && (value === '' || value === null || value === undefined)) {
300
+ return;
301
+ }
302
+
278
303
  // Type conversion
279
- if (value !== '' && value !== null) {
280
- if (fieldType === 'int') {
281
- value = parseInt(value, 10);
282
- } else if (fieldType === 'float') {
283
- value = parseFloat(value);
284
- } else if (fieldType === 'bool') {
285
- // Already handled above
286
- }
304
+ if (fieldType === 'int') {
305
+ value = parseInt(value, 10);
306
+ } else if (fieldType === 'float') {
307
+ value = parseFloat(value);
287
308
  }
309
+ // bool type already handled above
288
310
 
289
311
  settings[fieldName] = value;
290
312
  });
@@ -336,6 +358,9 @@ async function saveConfiguration(event) {
336
358
  log_level: document.getElementById('logLevel').value,
337
359
  keep_images: document.getElementById('keep_images').checked,
338
360
  file_logging_enabled: document.getElementById('file_logging_enabled').checked,
361
+ // Autofocus settings (top-level)
362
+ scheduled_autofocus_enabled: document.getElementById('scheduled_autofocus_enabled')?.checked || false,
363
+ autofocus_interval_minutes: parseInt(document.getElementById('autofocus_interval_minutes')?.value || 60, 10),
339
364
  // API settings from endpoint selector
340
365
  host: host,
341
366
  port: port,
@@ -345,12 +370,27 @@ async function saveConfiguration(event) {
345
370
  initial_retry_delay_seconds: currentConfig.initial_retry_delay_seconds || 30,
346
371
  max_retry_delay_seconds: currentConfig.max_retry_delay_seconds || 300,
347
372
  log_retention_days: currentConfig.log_retention_days || 30,
373
+ last_autofocus_timestamp: currentConfig.last_autofocus_timestamp, // Preserve timestamp
348
374
  };
349
375
 
350
376
  try {
377
+ // Validate filters BEFORE saving main config (belt and suspenders)
378
+ const inputs = document.querySelectorAll('.filter-focus-input');
379
+ if (inputs.length > 0) {
380
+ const checkboxes = document.querySelectorAll('.filter-enabled-checkbox');
381
+ const enabledCount = Array.from(checkboxes).filter(cb => cb.checked).length;
382
+ if (enabledCount === 0) {
383
+ showConfigMessage('At least one filter must be enabled', 'danger');
384
+ return; // Exit early without saving anything
385
+ }
386
+ }
387
+
351
388
  const result = await saveConfig(config);
352
389
 
353
390
  if (result.ok) {
391
+ // Update saved adapter to match newly saved config
392
+ savedAdapter = config.hardware_adapter;
393
+
354
394
  // After config saved successfully, save any modified filter focus positions
355
395
  const filterResults = await saveModifiedFilters();
356
396
 
@@ -364,6 +404,9 @@ async function saveConfiguration(event) {
364
404
  }
365
405
 
366
406
  showConfigSuccess(message);
407
+
408
+ // Reload filters to re-enable editing for the new adapter
409
+ await loadFilterConfig();
367
410
  } else {
368
411
  // Check for specific error codes
369
412
  const errorMsg = result.data.error || result.data.message || 'Failed to save configuration';
@@ -379,35 +422,82 @@ async function saveConfiguration(event) {
379
422
  }
380
423
  }
381
424
 
425
+ /**
426
+ * Create and show a Bootstrap toast notification
427
+ * @param {string} message - The message to display
428
+ * @param {string} type - 'danger' for errors, 'success' for success messages
429
+ * @param {boolean} autohide - Whether to auto-hide the toast
430
+ */
431
+ function createToast(message, type = 'danger', autohide = false) {
432
+ const toastContainer = document.getElementById('toastContainer');
433
+ if (!toastContainer) {
434
+ console.error('Toast container not found');
435
+ return;
436
+ }
437
+
438
+ // Create toast element
439
+ const toastId = `toast-${Date.now()}`;
440
+ const toastHTML = `
441
+ <div id="${toastId}" class="toast text-bg-${type}" role="alert" aria-live="assertive" aria-atomic="true">
442
+ <div class="toast-header text-bg-${type}">
443
+ <strong class="me-auto">CitraScope</strong>
444
+ <button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
445
+ </div>
446
+ <div class="toast-body">
447
+ ${message}
448
+ </div>
449
+ </div>
450
+ `;
451
+
452
+ // Insert toast into container
453
+ toastContainer.insertAdjacentHTML('beforeend', toastHTML);
454
+
455
+ // Get the toast element and initialize Bootstrap toast
456
+ const toastElement = document.getElementById(toastId);
457
+ const toast = new bootstrap.Toast(toastElement, {
458
+ autohide: autohide,
459
+ delay: 5000
460
+ });
461
+
462
+ // Remove toast element from DOM after it's hidden
463
+ toastElement.addEventListener('hidden.bs.toast', () => {
464
+ toastElement.remove();
465
+ });
466
+
467
+ // Show the toast
468
+ toast.show();
469
+ }
470
+
382
471
  /**
383
472
  * Show configuration error message
384
473
  */
385
474
  function showConfigError(message) {
386
- const errorDiv = document.getElementById('configError');
387
- errorDiv.textContent = message;
388
- errorDiv.style.display = 'block';
475
+ createToast(message, 'danger', false);
476
+ }
477
+
478
+ /**
479
+ * Show configuration message (can be error or success)
480
+ */
481
+ function showConfigMessage(message, type = 'danger') {
482
+ if (type === 'danger') {
483
+ showConfigError(message);
484
+ } else {
485
+ showConfigSuccess(message);
486
+ }
389
487
  }
390
488
 
391
489
  /**
392
490
  * Show configuration success message
393
491
  */
394
492
  function showConfigSuccess(message) {
395
- const successDiv = document.getElementById('configSuccess');
396
- successDiv.textContent = message;
397
- successDiv.style.display = 'block';
398
-
399
- // Auto-hide after 5 seconds
400
- setTimeout(() => {
401
- successDiv.style.display = 'none';
402
- }, 5000);
493
+ createToast(message, 'success', true);
403
494
  }
404
495
 
405
496
  /**
406
- * Hide all configuration messages
497
+ * Hide all configuration messages (no-op for toast compatibility)
407
498
  */
408
499
  function hideConfigMessages() {
409
- document.getElementById('configError').style.display = 'none';
410
- document.getElementById('configSuccess').style.display = 'none';
500
+ // No-op - toasts handle their own hiding
411
501
  }
412
502
 
413
503
  /**
@@ -432,6 +522,24 @@ export function showConfigSection() {
432
522
  */
433
523
  async function loadFilterConfig() {
434
524
  const filterSection = document.getElementById('filterConfigSection');
525
+ const changeMessage = document.getElementById('filterAdapterChangeMessage');
526
+ const tableContainer = document.getElementById('filterTableContainer');
527
+
528
+ // Check if selected adapter matches saved adapter
529
+ const adapterSelect = document.getElementById('hardwareAdapterSelect');
530
+ const selectedAdapter = adapterSelect ? adapterSelect.value : null;
531
+
532
+ if (selectedAdapter && savedAdapter && selectedAdapter !== savedAdapter) {
533
+ // Adapter has changed but not saved yet - show message and hide table
534
+ if (filterSection) filterSection.style.display = 'block';
535
+ if (changeMessage) changeMessage.style.display = 'block';
536
+ if (tableContainer) tableContainer.style.display = 'none';
537
+ return;
538
+ }
539
+
540
+ // Hide message and show table when adapters match
541
+ if (changeMessage) changeMessage.style.display = 'none';
542
+ if (tableContainer) tableContainer.style.display = 'block';
435
543
 
436
544
  try {
437
545
  const response = await fetch('/api/adapter/filters');
@@ -464,9 +572,15 @@ async function loadFilterConfig() {
464
572
 
465
573
  filterIds.forEach(filterId => {
466
574
  const filter = filters[filterId];
575
+ const isEnabled = filter.enabled !== undefined ? filter.enabled : true;
467
576
  const row = document.createElement('tr');
468
577
  row.innerHTML = `
469
- <td>${filterId}</td>
578
+ <td>
579
+ <input type="checkbox"
580
+ class="form-check-input filter-enabled-checkbox"
581
+ data-filter-id="${filterId}"
582
+ ${isEnabled ? 'checked' : ''}>
583
+ </td>
470
584
  <td>${filter.name}</td>
471
585
  <td>
472
586
  <input type="number"
@@ -491,13 +605,21 @@ async function loadFilterConfig() {
491
605
  }
492
606
 
493
607
  /**
494
- * Save all filter focus positions (called during main config save)
608
+ * Save all filter focus positions and enabled states (called during main config save)
495
609
  * Returns: Object with { success: number, failed: number }
496
610
  */
497
611
  async function saveModifiedFilters() {
498
612
  const inputs = document.querySelectorAll('.filter-focus-input');
499
613
  if (inputs.length === 0) return { success: 0, failed: 0 }; // No filters to save
500
614
 
615
+ // Belt and suspenders: Validate at least one filter is enabled before saving
616
+ const checkboxes = document.querySelectorAll('.filter-enabled-checkbox');
617
+ const enabledCount = Array.from(checkboxes).filter(cb => cb.checked).length;
618
+ if (enabledCount === 0) {
619
+ showConfigMessage('At least one filter must be enabled', 'danger');
620
+ return { success: 0, failed: inputs.length };
621
+ }
622
+
501
623
  let successCount = 0;
502
624
  let failedCount = 0;
503
625
 
@@ -511,20 +633,29 @@ async function saveModifiedFilters() {
511
633
  continue;
512
634
  }
513
635
 
636
+ // Get enabled state from corresponding checkbox
637
+ const checkbox = document.querySelector(`.filter-enabled-checkbox[data-filter-id="${filterId}"]`);
638
+ const enabled = checkbox ? checkbox.checked : true;
639
+
514
640
  try {
515
641
  const response = await fetch(`/api/adapter/filters/${filterId}`, {
516
642
  method: 'PATCH',
517
643
  headers: {
518
644
  'Content-Type': 'application/json'
519
645
  },
520
- body: JSON.stringify({ focus_position: focusPosition })
646
+ body: JSON.stringify({ focus_position: focusPosition, enabled: enabled })
521
647
  });
522
648
 
523
649
  if (response.ok) {
524
650
  successCount++;
525
651
  } else {
526
652
  failedCount++;
527
- console.error(`Failed to save filter ${filterId}: HTTP ${response.status}`);
653
+ const data = await response.json();
654
+ console.error(`Failed to save filter ${filterId}: ${data.error || 'Unknown error'}`);
655
+ // If it's the "last enabled filter" error, show it to the user
656
+ if (response.status === 400 && data.error && data.error.includes('last enabled filter')) {
657
+ showConfigMessage(data.error, 'danger');
658
+ }
528
659
  }
529
660
  } catch (error) {
530
661
  failedCount++;
@@ -536,7 +667,7 @@ async function saveModifiedFilters() {
536
667
  }
537
668
 
538
669
  /**
539
- * Trigger autofocus routine
670
+ * Trigger or cancel autofocus routine
540
671
  */
541
672
  async function triggerAutofocus() {
542
673
  const button = document.getElementById('runAutofocusButton');
@@ -545,12 +676,32 @@ async function triggerAutofocus() {
545
676
 
546
677
  if (!button || !buttonText || !buttonSpinner) return;
547
678
 
548
- // Clear any previous messages
549
- hideConfigMessages();
679
+ // Check if this is a cancel action
680
+ const isCancel = button.dataset.action === 'cancel';
550
681
 
551
- // Disable button and show spinner
682
+ if (isCancel) {
683
+ // Cancel autofocus
684
+ try {
685
+ const response = await fetch('/api/adapter/autofocus/cancel', {
686
+ method: 'POST'
687
+ });
688
+ const data = await response.json();
689
+
690
+ if (response.ok && data.success) {
691
+ showToast('Autofocus cancelled', 'info');
692
+ updateAutofocusButton(false);
693
+ } else {
694
+ showToast('Nothing to cancel', 'warning');
695
+ }
696
+ } catch (error) {
697
+ console.error('Error cancelling autofocus:', error);
698
+ showToast('Failed to cancel autofocus', 'error');
699
+ }
700
+ return;
701
+ }
702
+
703
+ // Request autofocus
552
704
  button.disabled = true;
553
- buttonText.textContent = 'Running Autofocus...';
554
705
  buttonSpinner.style.display = 'inline-block';
555
706
 
556
707
  try {
@@ -561,24 +712,78 @@ async function triggerAutofocus() {
561
712
  const data = await response.json();
562
713
 
563
714
  if (response.ok) {
564
- showConfigSuccess('Autofocus completed successfully');
565
- // Reload filter config to show updated focus positions
566
- await loadFilterConfig();
715
+ showToast('Autofocus queued', 'success');
716
+ updateAutofocusButton(true);
567
717
  } else {
568
- // Show clear error message (e.g., if autofocus already running or other conflict)
569
- showConfigError(data.error || 'Autofocus failed');
718
+ showToast(data.error || 'Autofocus request failed', 'error');
570
719
  }
571
720
  } catch (error) {
572
721
  console.error('Error triggering autofocus:', error);
573
- showConfigError('Failed to trigger autofocus');
722
+ showToast('Failed to trigger autofocus', 'error');
574
723
  } finally {
575
- // Re-enable button
576
724
  button.disabled = false;
577
- buttonText.textContent = 'Run Autofocus';
578
725
  buttonSpinner.style.display = 'none';
579
726
  }
580
727
  }
581
728
 
729
+ /**
730
+ * Update autofocus button state based on whether autofocus is queued
731
+ */
732
+ function updateAutofocusButton(isQueued) {
733
+ const button = document.getElementById('runAutofocusButton');
734
+ const buttonText = document.getElementById('autofocusButtonText');
735
+
736
+ if (!button || !buttonText) return;
737
+
738
+ if (isQueued) {
739
+ buttonText.textContent = 'Cancel Autofocus';
740
+ button.dataset.action = 'cancel';
741
+ button.classList.remove('btn-outline-primary');
742
+ button.classList.add('btn-outline-warning');
743
+ } else {
744
+ buttonText.textContent = 'Run Autofocus';
745
+ button.dataset.action = 'request';
746
+ button.classList.remove('btn-outline-warning');
747
+ button.classList.add('btn-outline-primary');
748
+ }
749
+ }
750
+
751
+ /**
752
+ * Show toast notification
753
+ */
754
+ function showToast(message, type = 'info') {
755
+ // Use Bootstrap toast if available, otherwise fallback to alert
756
+ const toastContainer = document.getElementById('toastContainer');
757
+ if (!toastContainer) {
758
+ console.log(`Toast (${type}): ${message}`);
759
+ return;
760
+ }
761
+
762
+ const toastId = `toast-${Date.now()}`;
763
+ const bgClass = type === 'success' ? 'bg-success' :
764
+ type === 'error' ? 'bg-danger' :
765
+ type === 'warning' ? 'bg-warning' : 'bg-info';
766
+
767
+ const toastHtml = `
768
+ <div id="${toastId}" class="toast align-items-center text-white ${bgClass} border-0" role="alert" aria-live="assertive" aria-atomic="true">
769
+ <div class="d-flex">
770
+ <div class="toast-body">${message}</div>
771
+ <button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
772
+ </div>
773
+ </div>
774
+ `;
775
+
776
+ toastContainer.insertAdjacentHTML('beforeend', toastHtml);
777
+ const toastElement = document.getElementById(toastId);
778
+ const toast = new bootstrap.Toast(toastElement, { delay: 3000 });
779
+ toast.show();
780
+
781
+ // Remove from DOM after hidden
782
+ toastElement.addEventListener('hidden.bs.toast', () => {
783
+ toastElement.remove();
784
+ });
785
+ }
786
+
582
787
  /**
583
788
  * Initialize filter configuration on page load
584
789
  */
@@ -166,9 +166,6 @@
166
166
  </div>
167
167
 
168
168
  <div class="container my-4" id="configSection" style="display: none;">
169
- <div id="configError" class="alert alert-danger" style="display: none;" role="alert"></div>
170
- <div id="configSuccess" class="alert alert-success" style="display: none;" role="alert"></div>
171
-
172
169
  <form id="configForm">
173
170
  <div class="row g-3 mb-3">
174
171
  <!-- API Configuration Card -->
@@ -242,31 +239,66 @@
242
239
  <!-- Filter Configuration Section (shown when adapter supports filters) -->
243
240
  <div id="filterConfigSection" style="display: none; margin-top: 1.5rem;">
244
241
  <hr class="border-secondary">
245
- <div class="d-flex justify-content-between align-items-center mb-3">
246
- <h5 class="mb-0">Filter Configuration</h5>
247
- <div class="text-end">
248
- <button type="button" class="btn btn-sm btn-outline-primary" id="runAutofocusButton">
249
- <span id="autofocusButtonText">Run Autofocus</span>
250
- <span id="autofocusButtonSpinner" class="spinner-border spinner-border-sm ms-2" style="display: none;" role="status"></span>
251
- </button>
252
- <div><small class="text-muted">Note: Pause task processing before running autofocus</small></div>
242
+ <h5 class="mb-3">Filter Configuration</h5>
243
+ <p id="filterAdapterChangeMessage" class="text-muted mb-3" style="display: none;">
244
+ Save configuration to edit filter settings for this adapter
245
+ </p>
246
+ <div class="row">
247
+ <!-- Filter List Column -->
248
+ <div class="col-12 col-md-6">
249
+ <h6 class="mb-2">Filters</h6>
250
+ <div id="filterTableContainer">
251
+ <table class="table table-dark table-sm">
252
+ <thead>
253
+ <tr>
254
+ <th>Enabled</th>
255
+ <th>Name</th>
256
+ <th>Focus Position</th>
257
+ </tr>
258
+ </thead>
259
+ <tbody id="filterTableBody">
260
+ <!-- Filter rows will be populated by JavaScript -->
261
+ </tbody>
262
+ </table>
263
+ <div id="noFiltersMessage" class="text-muted small" style="display: none;">
264
+ No filters configured. Connect to hardware to discover filters.
265
+ </div>
266
+ </div>
253
267
  </div>
254
- </div>
255
- <div id="filterTableContainer">
256
- <table class="table table-dark table-sm">
257
- <thead>
258
- <tr>
259
- <th>Filter ID</th>
260
- <th>Name</th>
261
- <th>Focus Position</th>
262
- </tr>
263
- </thead>
264
- <tbody id="filterTableBody">
265
- <!-- Filter rows will be populated by JavaScript -->
266
- </tbody>
267
- </table>
268
- <div id="noFiltersMessage" class="text-muted small" style="display: none;">
269
- No filters configured. Connect to hardware to discover filters.
268
+
269
+ <!-- Autofocus Column -->
270
+ <div class="col-12 col-md-6">
271
+ <h6 class="mb-2">Autofocus</h6>
272
+ <div class="mb-3">
273
+ <button type="button" class="btn btn-sm btn-outline-primary w-100" id="runAutofocusButton">
274
+ <span id="autofocusButtonText">Run Autofocus</span>
275
+ <span id="autofocusButtonSpinner" class="spinner-border spinner-border-sm ms-2" style="display: none;" role="status"></span>
276
+ </button>
277
+ </div>
278
+ <div class="mb-3">
279
+ <label class="form-label small text-muted">Last Autofocus</label>
280
+ <div id="lastAutofocusDisplay" class="text-light">Never</div>
281
+ </div>
282
+ <div class="mb-3">
283
+ <div class="form-check">
284
+ <input class="form-check-input" type="checkbox" id="scheduled_autofocus_enabled">
285
+ <label class="form-check-label" for="scheduled_autofocus_enabled">
286
+ Enable Scheduled Autofocus
287
+ </label>
288
+ </div>
289
+ </div>
290
+ <div class="mb-3">
291
+ <label for="autofocus_interval_minutes" class="form-label small">Autofocus Interval</label>
292
+ <select id="autofocus_interval_minutes" class="form-select form-select-sm">
293
+ <option value="30">30 minutes</option>
294
+ <option value="60" selected>60 minutes</option>
295
+ <option value="120">120 minutes (2 hours)</option>
296
+ <option value="180">180 minutes (3 hours)</option>
297
+ </select>
298
+ </div>
299
+ <div id="nextAutofocusDisplay" class="small text-muted" style="display: none;">
300
+ Next autofocus in: <span id="nextAutofocusTime">--</span>
301
+ </div>
270
302
  </div>
271
303
  </div>
272
304
  </div>
@@ -482,6 +514,9 @@
482
514
  </div>
483
515
  </div>
484
516
 
517
+ <!-- Toast Container for Notifications -->
518
+ <div class="toast-container position-fixed top-0 end-0 p-3" style="z-index: 9999;" id="toastContainer"></div>
519
+
485
520
  <script type="module" src="/static/app.js"></script>
486
521
 
487
522
 
@@ -1,10 +1,26 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: citrascope
3
- Version: 0.5.2
3
+ Version: 0.7.0
4
4
  Summary: Remotely control a telescope while it polls for tasks, collects and edge processes data, and delivers results and data for further processing.
5
+ Project-URL: Homepage, https://citra.space
6
+ Project-URL: Documentation, https://docs.citra.space/citrascope/
7
+ Project-URL: Repository, https://github.com/citra-space/citrascope
8
+ Project-URL: Issues, https://github.com/citra-space/citrascope/issues
9
+ Project-URL: Changelog, https://github.com/citra-space/citrascope/releases
5
10
  Author-email: Patrick McDavid <patrick@citra.space>
6
11
  License: MIT
7
12
  License-File: LICENSE
13
+ Keywords: INDI,KStars,NINA,astronomy,astrophotography,imaging,observatory,remote-telescope,telescope,telescope-control
14
+ Classifier: Development Status :: 4 - Beta
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
23
+ Classifier: Topic :: System :: Hardware :: Hardware Drivers
8
24
  Requires-Python: <3.13,>=3.10
9
25
  Requires-Dist: click
10
26
  Requires-Dist: fastapi>=0.104.0
@@ -51,6 +67,8 @@ Description-Content-Type: text/markdown
51
67
  # CitraScope
52
68
  [![Pytest](https://github.com/citra-space/citrascope/actions/workflows/pytest.yml/badge.svg)](https://github.com/citra-space/citrascope/actions/workflows/pytest.yml) [![Publish Python Package](https://github.com/citra-space/citrascope/actions/workflows/pypi-publish.yml/badge.svg)](https://github.com/citra-space/citrascope/actions/workflows/pypi-publish.yml) [![PyPI version](https://badge.fury.io/py/citrascope.svg)](https://pypi.org/project/citrascope/) [![PyPI - Downloads](https://img.shields.io/pypi/dm/citrascope)](https://pypi.org/project/citrascope/) [![License](https://img.shields.io/github/license/citra-space/citrascope)](https://github.com/citra-space/citrascope/blob/main/LICENSE)
53
69
 
70
+ **[GitHub Repository](https://github.com/citra-space/citrascope)** | **[Documentation](https://docs.citra.space/citrascope/)** | **[Citra.space](https://citra.space)**
71
+
54
72
  Remotely control a telescope while it polls for tasks, collects observations, and delivers data for further processing.
55
73
 
56
74
  ## Features