citrascope 0.4.0__py3-none-any.whl → 0.5.1__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
@@ -30,6 +30,7 @@ class SystemStatus(BaseModel):
30
30
  camera_connected: bool = False
31
31
  current_task: Optional[str] = None
32
32
  tasks_pending: int = 0
33
+ processing_active: bool = True
33
34
  hardware_adapter: str = "unknown"
34
35
  telescope_ra: Optional[float] = None
35
36
  telescope_dec: Optional[float] = None
@@ -240,6 +241,13 @@ class CitraScopeWebApp:
240
241
  if not self.daemon:
241
242
  return JSONResponse({"error": "Daemon not available"}, status_code=503)
242
243
 
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
+
243
251
  # Validate required fields
244
252
  required_fields = ["personal_access_token", "telescope_id", "hardware_adapter"]
245
253
  for field in required_fields:
@@ -310,11 +318,7 @@ class CitraScopeWebApp:
310
318
  status_code=400,
311
319
  )
312
320
 
313
- # Save configuration to file
314
- from citrascope.settings.settings_file_manager import SettingsFileManager
315
-
316
- config_manager = SettingsFileManager()
317
- config_manager.save_config(config)
321
+ self.daemon.settings.update_and_save(config)
318
322
 
319
323
  # Trigger hot-reload
320
324
  success, error = self.daemon.reload_configuration()
@@ -371,6 +375,113 @@ class CitraScopeWebApp:
371
375
  return {"logs": logs}
372
376
  return {"logs": []}
373
377
 
378
+ @self.app.post("/api/tasks/pause")
379
+ async def pause_tasks():
380
+ """Pause task processing."""
381
+ if not self.daemon or not self.daemon.task_manager:
382
+ return JSONResponse({"error": "Task manager not available"}, status_code=503)
383
+
384
+ self.daemon.task_manager.pause()
385
+ await self.broadcast_status()
386
+
387
+ return {"status": "paused", "message": "Task processing paused"}
388
+
389
+ @self.app.post("/api/tasks/resume")
390
+ async def resume_tasks():
391
+ """Resume task processing."""
392
+ if not self.daemon or not self.daemon.task_manager:
393
+ return JSONResponse({"error": "Task manager not available"}, status_code=503)
394
+
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
+ self.daemon.task_manager.resume()
403
+ await self.broadcast_status()
404
+
405
+ return {"status": "active", "message": "Task processing resumed"}
406
+
407
+ @self.app.get("/api/adapter/filters")
408
+ async def get_filters():
409
+ """Get current filter configuration."""
410
+ if not self.daemon or not self.daemon.hardware_adapter:
411
+ return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
412
+
413
+ if not self.daemon.hardware_adapter.supports_filter_management():
414
+ return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
415
+
416
+ try:
417
+ filter_config = self.daemon.hardware_adapter.get_filter_config()
418
+ return {"filters": filter_config}
419
+ except Exception as e:
420
+ CITRASCOPE_LOGGER.error(f"Error getting filter config: {e}", exc_info=True)
421
+ return JSONResponse({"error": str(e)}, status_code=500)
422
+
423
+ @self.app.patch("/api/adapter/filters/{filter_id}")
424
+ async def update_filter(filter_id: str, update: dict):
425
+ """Update focus position for a specific filter."""
426
+ if not self.daemon or not self.daemon.hardware_adapter:
427
+ return JSONResponse({"error": "Hardware adapter not available"}, status_code=503)
428
+
429
+ if not self.daemon.hardware_adapter.supports_filter_management():
430
+ return JSONResponse({"error": "Adapter does not support filter management"}, status_code=404)
431
+
432
+ 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
+ # Get current filter config
446
+ filter_config = self.daemon.hardware_adapter.get_filter_config()
447
+ if filter_id not in filter_config:
448
+ return JSONResponse({"error": f"Filter {filter_id} not found"}, status_code=404)
449
+
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)
453
+
454
+ # Save to settings
455
+ self.daemon._save_filter_config()
456
+
457
+ return {"success": True, "filter_id": filter_id, "focus_position": focus_position}
458
+ except ValueError:
459
+ return JSONResponse({"error": "Invalid filter_id format"}, status_code=400)
460
+ except Exception as e:
461
+ CITRASCOPE_LOGGER.error(f"Error updating filter: {e}", exc_info=True)
462
+ return JSONResponse({"error": str(e)}, status_code=500)
463
+
464
+ @self.app.post("/api/adapter/autofocus")
465
+ async def trigger_autofocus():
466
+ """Trigger autofocus routine."""
467
+ if not self.daemon:
468
+ return JSONResponse({"error": "Daemon not available"}, status_code=503)
469
+
470
+ if not self.daemon.hardware_adapter or not self.daemon.hardware_adapter.supports_filter_management():
471
+ return JSONResponse({"error": "Filter management not supported"}, status_code=404)
472
+
473
+ 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)
477
+ if success:
478
+ return {"success": True, "message": "Autofocus completed successfully"}
479
+ else:
480
+ return JSONResponse({"error": error}, status_code=500)
481
+ except Exception as e:
482
+ CITRASCOPE_LOGGER.error(f"Error triggering autofocus: {e}", exc_info=True)
483
+ return JSONResponse({"error": str(e)}, status_code=500)
484
+
374
485
  @self.app.websocket("/ws")
375
486
  async def websocket_endpoint(websocket: WebSocket):
376
487
  """WebSocket endpoint for real-time updates."""
@@ -439,6 +550,10 @@ class CitraScopeWebApp:
439
550
  self.status.ground_station_name = gs_name
440
551
  self.status.ground_station_url = f"{base_url}/ground-stations/{gs_id}" if gs_id else None
441
552
 
553
+ # Update task processing state
554
+ if hasattr(self.daemon, "task_manager") and self.daemon.task_manager:
555
+ self.status.processing_active = self.daemon.task_manager.is_processing_active()
556
+
442
557
  self.status.last_update = datetime.now().isoformat()
443
558
 
444
559
  except Exception as e:
@@ -1,6 +1,6 @@
1
1
  // CitraScope Dashboard - Main Application
2
2
  import { connectWebSocket } from './websocket.js';
3
- import { initConfig, currentConfig } from './config.js';
3
+ import { initConfig, currentConfig, initFilterConfig, setupAutofocusButton } from './config.js';
4
4
  import { getTasks, getLogs } from './api.js';
5
5
 
6
6
  function updateAppUrlLinks() {
@@ -13,7 +13,154 @@ function updateAppUrlLinks() {
13
13
  });
14
14
  }
15
15
 
16
- // Global state for countdown
16
+ // --- Version Checking ---
17
+
18
+ /**
19
+ * Compare two semantic version strings
20
+ * Returns: 1 if v1 > v2, -1 if v1 < v2, 0 if equal
21
+ */
22
+ function compareVersions(v1, v2) {
23
+ // Strip 'v' prefix if present
24
+ v1 = v1.replace(/^v/, '');
25
+ v2 = v2.replace(/^v/, '');
26
+
27
+ const parts1 = v1.split('.').map(n => parseInt(n) || 0);
28
+ const parts2 = v2.split('.').map(n => parseInt(n) || 0);
29
+
30
+ const maxLen = Math.max(parts1.length, parts2.length);
31
+
32
+ for (let i = 0; i < maxLen; i++) {
33
+ const num1 = parts1[i] || 0;
34
+ const num2 = parts2[i] || 0;
35
+
36
+ if (num1 > num2) return 1;
37
+ if (num1 < num2) return -1;
38
+ }
39
+
40
+ return 0;
41
+ }
42
+
43
+ /**
44
+ * Fetch and display current version
45
+ */
46
+ async function fetchVersion() {
47
+ try {
48
+ const response = await fetch('/api/version');
49
+ const data = await response.json();
50
+
51
+ // Update header version
52
+ const headerVersionEl = document.getElementById('headerVersion');
53
+
54
+ if (headerVersionEl && data.version) {
55
+ // Show "dev" for development, "v" prefix for releases
56
+ if (data.version === 'development') {
57
+ headerVersionEl.textContent = 'dev';
58
+ } else {
59
+ headerVersionEl.textContent = 'v' + data.version;
60
+ }
61
+ }
62
+ } catch (error) {
63
+ console.error('Error fetching version:', error);
64
+ const headerVersionEl = document.getElementById('headerVersion');
65
+
66
+ if (headerVersionEl) {
67
+ headerVersionEl.textContent = 'v?';
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check for available updates from GitHub
74
+ * Returns the check result for modal display
75
+ */
76
+ async function checkForUpdates() {
77
+ try {
78
+ // Get current version
79
+ const versionResponse = await fetch('/api/version');
80
+ const versionData = await versionResponse.json();
81
+ const currentVersion = versionData.version;
82
+
83
+ // Check GitHub for latest release
84
+ const githubResponse = await fetch('https://api.github.com/repos/citra-space/citrascope/releases/latest');
85
+ if (!githubResponse.ok) {
86
+ return { status: 'error', currentVersion };
87
+ }
88
+
89
+ const releaseData = await githubResponse.json();
90
+ const latestVersion = releaseData.tag_name.replace(/^v/, '');
91
+ const releaseUrl = releaseData.html_url;
92
+
93
+ // Skip comparison for development versions
94
+ if (currentVersion === 'development' || currentVersion === 'unknown') {
95
+ return { status: 'up-to-date', currentVersion };
96
+ }
97
+
98
+ // Compare versions
99
+ if (compareVersions(latestVersion, currentVersion) > 0) {
100
+ // Update available - show indicator badge with version
101
+ const indicator = document.getElementById('updateIndicator');
102
+ if (indicator) {
103
+ indicator.textContent = `${latestVersion} Available!`;
104
+ indicator.style.display = 'inline-block';
105
+ }
106
+
107
+ return {
108
+ status: 'update-available',
109
+ currentVersion,
110
+ latestVersion,
111
+ releaseUrl
112
+ };
113
+ } else {
114
+ // Up to date - hide indicator badge
115
+ const indicator = document.getElementById('updateIndicator');
116
+ if (indicator) {
117
+ indicator.style.display = 'none';
118
+ }
119
+
120
+ return { status: 'up-to-date', currentVersion };
121
+ }
122
+ } catch (error) {
123
+ // Network error
124
+ console.debug('Update check failed:', error);
125
+ return { status: 'error', currentVersion: 'unknown' };
126
+ }
127
+ }
128
+
129
+ /**
130
+ * Show version check modal with results
131
+ */
132
+ async function showVersionModal() {
133
+ const modal = new bootstrap.Modal(document.getElementById('versionModal'));
134
+ modal.show();
135
+
136
+ // Show loading state
137
+ document.getElementById('versionCheckLoading').style.display = 'block';
138
+ document.getElementById('versionCheckUpToDate').style.display = 'none';
139
+ document.getElementById('versionCheckUpdateAvailable').style.display = 'none';
140
+ document.getElementById('versionCheckError').style.display = 'none';
141
+
142
+ // Perform check
143
+ const result = await checkForUpdates();
144
+
145
+ // Hide loading
146
+ document.getElementById('versionCheckLoading').style.display = 'none';
147
+
148
+ // Show appropriate result
149
+ if (result.status === 'update-available') {
150
+ document.getElementById('modalCurrentVersion').textContent = 'v' + result.currentVersion;
151
+ document.getElementById('modalLatestVersion').textContent = 'v' + result.latestVersion;
152
+ document.getElementById('releaseNotesLink').href = result.releaseUrl;
153
+ document.getElementById('versionCheckUpdateAvailable').style.display = 'block';
154
+ } else if (result.status === 'up-to-date') {
155
+ document.getElementById('modalCurrentVersionUpToDate').textContent = result.currentVersion === 'development' ? 'development' : 'v' + result.currentVersion;
156
+ document.getElementById('versionCheckUpToDate').style.display = 'block';
157
+ } else {
158
+ document.getElementById('modalCurrentVersionError').textContent = result.currentVersion === 'development' ? 'development' : result.currentVersion;
159
+ document.getElementById('versionCheckError').style.display = 'block';
160
+ }
161
+ }
162
+
163
+ // --- Task Management ---
17
164
  let nextTaskStartTime = null;
18
165
  let countdownInterval = null;
19
166
  let isTaskActive = false;
@@ -139,6 +286,11 @@ function initNavigation() {
139
286
  const section = link.getAttribute('data-section');
140
287
  activateNav(link);
141
288
  showSection(section);
289
+
290
+ // Reload filter config when config section is shown
291
+ if (section === 'config') {
292
+ initFilterConfig();
293
+ }
142
294
  }
143
295
  });
144
296
 
@@ -207,7 +359,9 @@ function updateStatus(status) {
207
359
  }
208
360
  // If isTaskActive is already false, don't touch the display (countdown is updating it)
209
361
 
210
- document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
362
+ if (status.tasks_pending !== undefined) {
363
+ document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
364
+ }
211
365
 
212
366
  if (status.telescope_ra !== null) {
213
367
  document.getElementById('telescopeRA').textContent = status.telescope_ra.toFixed(4) + '°';
@@ -217,20 +371,45 @@ function updateStatus(status) {
217
371
  }
218
372
 
219
373
  // Update ground station information
220
- const gsNameEl = document.getElementById('groundStationName');
221
- const taskScopeButton = document.getElementById('taskScopeButton');
222
-
223
- if (status.ground_station_name && status.ground_station_url) {
224
- gsNameEl.innerHTML = `<a href="${status.ground_station_url}" target="_blank" class="ground-station-link">${status.ground_station_name} ↗</a>`;
225
- // Update the Task My Scope button
226
- taskScopeButton.href = status.ground_station_url;
227
- taskScopeButton.style.display = 'inline-block';
228
- } else if (status.ground_station_name) {
229
- gsNameEl.textContent = status.ground_station_name;
230
- taskScopeButton.style.display = 'none';
374
+ if (status.ground_station_name !== undefined || status.ground_station_url !== undefined) {
375
+ const gsNameEl = document.getElementById('groundStationName');
376
+ const taskScopeButton = document.getElementById('taskScopeButton');
377
+
378
+ if (status.ground_station_name && status.ground_station_url) {
379
+ gsNameEl.innerHTML = `<a href="${status.ground_station_url}" target="_blank" class="ground-station-link">${status.ground_station_name} ↗</a>`;
380
+ // Update the Task My Scope button
381
+ taskScopeButton.href = status.ground_station_url;
382
+ taskScopeButton.style.display = 'inline-block';
383
+ } else if (status.ground_station_name) {
384
+ gsNameEl.textContent = status.ground_station_name;
385
+ taskScopeButton.style.display = 'none';
386
+ } else {
387
+ gsNameEl.textContent = '-';
388
+ taskScopeButton.style.display = 'none';
389
+ }
390
+ }
391
+
392
+ // Update task processing state
393
+ if (status.processing_active !== undefined) {
394
+ updateProcessingState(status.processing_active);
395
+ }
396
+ }
397
+
398
+ function updateProcessingState(isActive) {
399
+ const statusEl = document.getElementById('processingStatus');
400
+ const button = document.getElementById('toggleProcessingButton');
401
+ const icon = document.getElementById('processingButtonIcon');
402
+
403
+ if (!statusEl || !button || !icon) return;
404
+
405
+ if (isActive) {
406
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-success">Active</span>';
407
+ icon.textContent = 'Pause';
408
+ button.title = 'Pause task processing';
231
409
  } else {
232
- gsNameEl.textContent = '-';
233
- taskScopeButton.style.display = 'none';
410
+ statusEl.innerHTML = '<span class="badge rounded-pill bg-warning text-dark">Paused</span>';
411
+ icon.textContent = 'Resume';
412
+ button.title = 'Resume task processing';
234
413
  }
235
414
  }
236
415
 
@@ -485,9 +664,28 @@ document.addEventListener('DOMContentLoaded', async function() {
485
664
  // Initialize configuration management (loads config)
486
665
  await initConfig();
487
666
 
667
+ // Initialize filter configuration
668
+ await initFilterConfig();
669
+
670
+ // Setup autofocus button (only once)
671
+ setupAutofocusButton();
672
+
488
673
  // Update app URL links from loaded config
489
674
  updateAppUrlLinks();
490
675
 
676
+ // Fetch and display version
677
+ fetchVersion();
678
+
679
+ // Check for updates on load and every hour
680
+ checkForUpdates();
681
+ setInterval(checkForUpdates, 3600000); // Check every hour
682
+
683
+ // Wire up version click to open modal
684
+ const headerVersion = document.getElementById('headerVersion');
685
+ if (headerVersion) {
686
+ headerVersion.addEventListener('click', showVersionModal);
687
+ }
688
+
491
689
  // Connect WebSocket with handlers
492
690
  connectWebSocket({
493
691
  onStatus: updateStatus,
@@ -499,4 +697,33 @@ document.addEventListener('DOMContentLoaded', async function() {
499
697
  // Load initial data
500
698
  loadTasks();
501
699
  loadLogs();
700
+
701
+ // Add pause/resume button handler
702
+ const toggleButton = document.getElementById('toggleProcessingButton');
703
+ if (toggleButton) {
704
+ toggleButton.addEventListener('click', async () => {
705
+ const icon = document.getElementById('processingButtonIcon');
706
+ const currentlyPaused = icon && icon.textContent === 'Resume';
707
+ const endpoint = currentlyPaused ? '/api/tasks/resume' : '/api/tasks/pause';
708
+
709
+ try {
710
+ toggleButton.disabled = true;
711
+ const response = await fetch(endpoint, { method: 'POST' });
712
+ const result = await response.json();
713
+
714
+ if (!response.ok) {
715
+ console.error('Failed to toggle processing:', result);
716
+ // Show specific error message (e.g., "Cannot resume during autofocus")
717
+ alert((result.error || 'Failed to toggle task processing') +
718
+ (response.status === 409 ? '' : ' - Unknown error'));
719
+ }
720
+ // State will be updated via WebSocket broadcast within 2 seconds
721
+ } catch (error) {
722
+ console.error('Error toggling processing:', error);
723
+ alert('Error toggling task processing');
724
+ } finally {
725
+ toggleButton.disabled = false;
726
+ }
727
+ });
728
+ }
502
729
  });