citrascope 0.1.0__py3-none-any.whl → 0.3.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 (35) hide show
  1. citrascope/__main__.py +8 -5
  2. citrascope/api/abstract_api_client.py +7 -0
  3. citrascope/api/citra_api_client.py +30 -1
  4. citrascope/citra_scope_daemon.py +214 -61
  5. citrascope/hardware/abstract_astro_hardware_adapter.py +70 -2
  6. citrascope/hardware/adapter_registry.py +94 -0
  7. citrascope/hardware/indi_adapter.py +456 -16
  8. citrascope/hardware/kstars_dbus_adapter.py +179 -0
  9. citrascope/hardware/nina_adv_http_adapter.py +593 -0
  10. citrascope/hardware/nina_adv_http_survey_template.json +328 -0
  11. citrascope/logging/__init__.py +2 -1
  12. citrascope/logging/_citrascope_logger.py +80 -1
  13. citrascope/logging/web_log_handler.py +74 -0
  14. citrascope/settings/citrascope_settings.py +145 -0
  15. citrascope/settings/settings_file_manager.py +126 -0
  16. citrascope/tasks/runner.py +124 -28
  17. citrascope/tasks/scope/base_telescope_task.py +25 -10
  18. citrascope/tasks/scope/static_telescope_task.py +11 -3
  19. citrascope/web/__init__.py +1 -0
  20. citrascope/web/app.py +470 -0
  21. citrascope/web/server.py +123 -0
  22. citrascope/web/static/api.js +82 -0
  23. citrascope/web/static/app.js +500 -0
  24. citrascope/web/static/config.js +362 -0
  25. citrascope/web/static/img/citra.png +0 -0
  26. citrascope/web/static/img/favicon.png +0 -0
  27. citrascope/web/static/style.css +120 -0
  28. citrascope/web/static/websocket.js +127 -0
  29. citrascope/web/templates/dashboard.html +354 -0
  30. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/METADATA +68 -36
  31. citrascope-0.3.0.dist-info/RECORD +38 -0
  32. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/WHEEL +1 -1
  33. citrascope/settings/_citrascope_settings.py +0 -42
  34. citrascope-0.1.0.dist-info/RECORD +0 -21
  35. {citrascope-0.1.0.dist-info → citrascope-0.3.0.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,500 @@
1
+ // CitraScope Dashboard - Main Application
2
+ import { connectWebSocket } from './websocket.js';
3
+ import { initConfig, currentConfig } from './config.js';
4
+ import { getTasks, getLogs } from './api.js';
5
+
6
+ function updateAppUrlLinks() {
7
+ const appUrl = currentConfig.app_url;
8
+ [document.getElementById('appUrlLink'), document.getElementById('setupAppUrlLink')].forEach(link => {
9
+ if (link && appUrl) {
10
+ link.href = appUrl;
11
+ link.textContent = appUrl.replace('https://', '');
12
+ }
13
+ });
14
+ }
15
+
16
+ // Global state for countdown
17
+ let nextTaskStartTime = null;
18
+ let countdownInterval = null;
19
+ let isTaskActive = false;
20
+ let currentTaskId = null;
21
+ let currentTasks = []; // Store tasks for lookup
22
+
23
+ // --- Utility Functions ---
24
+ function stripAnsiCodes(text) {
25
+ // Remove ANSI color codes (e.g., [92m, [0m, etc.)
26
+ return text.replace(/\x1B\[\d+m/g, '').replace(/\[\d+m/g, '');
27
+ }
28
+
29
+ function formatLocalTime(isoString) {
30
+ const date = new Date(isoString);
31
+ return date.toLocaleString(undefined, {
32
+ month: 'short',
33
+ day: 'numeric',
34
+ hour: '2-digit',
35
+ minute: '2-digit',
36
+ second: '2-digit',
37
+ hour12: true
38
+ });
39
+ }
40
+
41
+ function formatCountdown(milliseconds) {
42
+ const totalSeconds = Math.floor(milliseconds / 1000);
43
+
44
+ if (totalSeconds < 0) return 'Starting soon...';
45
+
46
+ const hours = Math.floor(totalSeconds / 3600);
47
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
48
+ const seconds = totalSeconds % 60;
49
+
50
+ if (hours > 0) {
51
+ return `${hours}h ${minutes}m ${seconds}s`;
52
+ } else if (minutes > 0) {
53
+ return `${minutes}m ${seconds}s`;
54
+ } else {
55
+ return `${seconds}s`;
56
+ }
57
+ }
58
+
59
+ function updateCountdown() {
60
+ if (!nextTaskStartTime || isTaskActive) return;
61
+
62
+ const now = new Date();
63
+ const timeUntil = nextTaskStartTime - now;
64
+
65
+ const currentTaskDisplay = document.getElementById('currentTaskDisplay');
66
+ if (currentTaskDisplay && timeUntil > 0) {
67
+ const countdown = formatCountdown(timeUntil);
68
+ currentTaskDisplay.innerHTML = `<p class="no-task-message">No active task - next task in ${countdown}</p>`;
69
+ }
70
+ }
71
+
72
+ function startCountdown(startTime) {
73
+ nextTaskStartTime = new Date(startTime);
74
+
75
+ // Clear any existing interval
76
+ if (countdownInterval) {
77
+ clearInterval(countdownInterval);
78
+ }
79
+
80
+ // Update immediately
81
+ updateCountdown();
82
+
83
+ // Update every second
84
+ countdownInterval = setInterval(updateCountdown, 1000);
85
+ }
86
+
87
+ function stopCountdown() {
88
+ nextTaskStartTime = null;
89
+ if (countdownInterval) {
90
+ clearInterval(countdownInterval);
91
+ countdownInterval = null;
92
+ }
93
+ }
94
+
95
+ // --- Navigation Logic ---
96
+ function initNavigation() {
97
+ // Initialize Bootstrap tooltips
98
+ const tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
99
+ tooltipTriggerList.forEach(function (tooltipTriggerEl) {
100
+ new bootstrap.Tooltip(tooltipTriggerEl);
101
+ });
102
+
103
+ const nav = document.getElementById('mainNav');
104
+ if (nav) {
105
+ // Find all nav links and all dashboard sections with id ending in 'Section'
106
+ const navLinks = nav.querySelectorAll('a[data-section]');
107
+ const sections = {};
108
+ navLinks.forEach(link => {
109
+ const section = link.getAttribute('data-section');
110
+ const sectionEl = document.getElementById(section + 'Section');
111
+ if (sectionEl) {
112
+ sections[section] = sectionEl
113
+ }
114
+ else {
115
+ console.log(`No section element found for section: ${section}`);
116
+ }
117
+ });
118
+
119
+ function activateNav(link) {
120
+ navLinks.forEach(a => {
121
+ a.classList.remove('text-white');
122
+ a.removeAttribute('aria-current');
123
+ });
124
+ link.classList.add('text-white');
125
+ link.setAttribute('aria-current', 'page');
126
+ }
127
+
128
+ function showSection(section) {
129
+ Object.values(sections).forEach(sec => sec.style.display = 'none');
130
+ if (sections[section]) {sections[section].style.display = '';} else {
131
+ console.log(`No section found to show for section: ${section}`);
132
+ }
133
+ }
134
+
135
+ nav.addEventListener('click', function(e) {
136
+ const link = e.target.closest('a[data-section]');
137
+ if (link) {
138
+ e.preventDefault();
139
+ const section = link.getAttribute('data-section');
140
+ activateNav(link);
141
+ showSection(section);
142
+ }
143
+ });
144
+
145
+ // Default to first nav item
146
+ const first = nav.querySelector('a[data-section]');
147
+ if (first) {
148
+ activateNav(first);
149
+ showSection(first.getAttribute('data-section'));
150
+ }
151
+ }
152
+ }
153
+
154
+ // --- WebSocket Status Display ---
155
+ function updateWSStatus(connected, reconnectInfo = '') {
156
+ const statusEl = document.getElementById('wsStatus');
157
+ const template = document.getElementById('connectionStatusTemplate');
158
+ const content = template.content.cloneNode(true);
159
+ const badge = content.querySelector('.connection-status-badge');
160
+ const statusText = content.querySelector('.status-text');
161
+
162
+ if (connected) {
163
+ badge.classList.add('bg-success');
164
+ badge.setAttribute('title', 'Dashboard connected - receiving live updates');
165
+ statusText.textContent = 'Connected';
166
+ } else if (reconnectInfo) {
167
+ badge.classList.add('bg-warning', 'text-dark');
168
+ badge.setAttribute('title', 'Dashboard reconnecting - attempting to restore connection');
169
+ statusText.textContent = 'Reconnecting';
170
+ } else {
171
+ badge.classList.add('bg-danger');
172
+ badge.setAttribute('title', 'Dashboard disconnected - no live updates');
173
+ statusText.textContent = 'Disconnected';
174
+ }
175
+
176
+ statusEl.innerHTML = '';
177
+ statusEl.appendChild(content);
178
+
179
+ // Reinitialize tooltips after updating the DOM
180
+ const tooltipTrigger = statusEl.querySelector('[data-bs-toggle="tooltip"]');
181
+ if (tooltipTrigger) {
182
+ new bootstrap.Tooltip(tooltipTrigger);
183
+ }
184
+ }
185
+
186
+ // --- Status Updates ---
187
+ function updateStatus(status) {
188
+ document.getElementById('hardwareAdapter').textContent = status.hardware_adapter || '-';
189
+ document.getElementById('telescopeConnected').innerHTML = status.telescope_connected
190
+ ? '<span class="badge rounded-pill bg-success">Connected</span>'
191
+ : '<span class="badge rounded-pill bg-danger">Disconnected</span>';
192
+ document.getElementById('cameraConnected').innerHTML = status.camera_connected
193
+ ? '<span class="badge rounded-pill bg-success">Connected</span>'
194
+ : '<span class="badge rounded-pill bg-danger">Disconnected</span>';
195
+
196
+ // Update current task display
197
+ if (status.current_task && status.current_task !== 'None') {
198
+ isTaskActive = true;
199
+ currentTaskId = status.current_task;
200
+ stopCountdown();
201
+ updateCurrentTaskDisplay();
202
+ } else if (isTaskActive) {
203
+ // Task just finished, set to idle state
204
+ isTaskActive = false;
205
+ currentTaskId = null;
206
+ updateCurrentTaskDisplay();
207
+ }
208
+ // If isTaskActive is already false, don't touch the display (countdown is updating it)
209
+
210
+ document.getElementById('tasksPending').textContent = status.tasks_pending || '0';
211
+
212
+ if (status.telescope_ra !== null) {
213
+ document.getElementById('telescopeRA').textContent = status.telescope_ra.toFixed(4) + '°';
214
+ }
215
+ if (status.telescope_dec !== null) {
216
+ document.getElementById('telescopeDEC').textContent = status.telescope_dec.toFixed(4) + '°';
217
+ }
218
+
219
+ // 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';
231
+ } else {
232
+ gsNameEl.textContent = '-';
233
+ taskScopeButton.style.display = 'none';
234
+ }
235
+ }
236
+
237
+ // --- Task Management ---
238
+ function getCurrentTaskDetails() {
239
+ if (!currentTaskId) return null;
240
+ return currentTasks.find(task => task.id === currentTaskId);
241
+ }
242
+
243
+ function updateCurrentTaskDisplay() {
244
+ const currentTaskDisplay = document.getElementById('currentTaskDisplay');
245
+ if (!currentTaskDisplay) return;
246
+
247
+ if (currentTaskId) {
248
+ const taskDetails = getCurrentTaskDetails();
249
+ if (taskDetails) {
250
+ currentTaskDisplay.innerHTML = `
251
+ <div class="d-flex align-items-center gap-2 mb-2">
252
+ <div class="spinner-border spinner-border-sm text-success" role="status">
253
+ <span class="visually-hidden">Loading...</span>
254
+ </div>
255
+ <div class="fw-bold" style="font-size: 1.3em;">${taskDetails.target}</div>
256
+ </div>
257
+ <div class="text-secondary small">
258
+ <span>Task ID: ${currentTaskId}</span>
259
+ </div>
260
+ `;
261
+ }
262
+ // Don't show fallback - just wait for task details to arrive
263
+ } else if (!isTaskActive && !nextTaskStartTime) {
264
+ // Only show "No active task" if we're not in countdown mode
265
+ currentTaskDisplay.innerHTML = '<p class="no-task-message">No active task</p>';
266
+ }
267
+ }
268
+
269
+ function updateTasks(tasks) {
270
+ currentTasks = tasks;
271
+ renderTasks(tasks);
272
+ // Re-render current task display with updated task info
273
+ updateCurrentTaskDisplay();
274
+ }
275
+
276
+ async function loadTasks() {
277
+ try {
278
+ const tasks = await getTasks();
279
+ renderTasks(tasks);
280
+ } catch (error) {
281
+ console.error('Failed to load tasks:', error);
282
+ }
283
+ }
284
+
285
+ function renderTasks(tasks) {
286
+ try {
287
+ const taskList = document.getElementById('taskList');
288
+
289
+ if (tasks.length === 0) {
290
+ taskList.innerHTML = '<p class="p-3 text-muted-dark">No pending tasks</p>';
291
+ stopCountdown();
292
+ } else {
293
+ // Sort tasks by start time (earliest first)
294
+ const sortedTasks = tasks.sort((a, b) => new Date(a.start_time) - new Date(b.start_time));
295
+
296
+ // Start countdown for next task if no current task is active
297
+ if (!isTaskActive && sortedTasks.length > 0) {
298
+ startCountdown(sortedTasks[0].start_time);
299
+ }
300
+
301
+ // Create table structure
302
+ const table = document.createElement('table');
303
+ table.className = 'table table-dark table-hover mb-0';
304
+
305
+ const thead = document.createElement('thead');
306
+ thead.innerHTML = `
307
+ <tr>
308
+ <th>Target</th>
309
+ <th>Start Time</th>
310
+ <th>End Time</th>
311
+ <th>Status</th>
312
+ </tr>
313
+ `;
314
+ table.appendChild(thead);
315
+
316
+ const tbody = document.createElement('tbody');
317
+ const template = document.getElementById('taskRowTemplate');
318
+
319
+ sortedTasks.forEach(task => {
320
+ const isActive = task.id === currentTaskId;
321
+ const row = template.content.cloneNode(true);
322
+ const tr = row.querySelector('.task-row');
323
+
324
+ if (isActive) {
325
+ tr.classList.add('table-active');
326
+ }
327
+
328
+ row.querySelector('.task-target').textContent = task.target;
329
+ row.querySelector('.task-start').textContent = formatLocalTime(task.start_time);
330
+ row.querySelector('.task-end').textContent = task.stop_time ? formatLocalTime(task.stop_time) : '-';
331
+
332
+ const badge = row.querySelector('.task-status');
333
+ badge.classList.add(isActive ? 'bg-success' : 'bg-info');
334
+ badge.textContent = isActive ? 'Active' : task.status;
335
+
336
+ tbody.appendChild(row);
337
+ });
338
+
339
+ table.appendChild(tbody);
340
+ taskList.innerHTML = '';
341
+ taskList.appendChild(table);
342
+ }
343
+ } catch (error) {
344
+ console.error('Failed to render tasks:', error);
345
+ }
346
+ }
347
+
348
+ // --- Log Display ---
349
+ async function loadLogs() {
350
+ try {
351
+ const data = await getLogs(100);
352
+ const logContainer = document.getElementById('logContainer');
353
+
354
+ if (data.logs.length === 0) {
355
+ logContainer.innerHTML = '<p class="text-muted-dark">No logs available</p>';
356
+ } else {
357
+ logContainer.innerHTML = '';
358
+ data.logs.forEach(log => {
359
+ appendLog(log);
360
+ });
361
+ // Scroll to bottom
362
+ logContainer.scrollTop = logContainer.scrollHeight;
363
+ }
364
+ } catch (error) {
365
+ console.error('Failed to load logs:', error);
366
+ }
367
+ }
368
+
369
+ function appendLog(log) {
370
+ const logContainer = document.getElementById('logContainer');
371
+ const template = document.getElementById('logEntryTemplate');
372
+ const entry = template.content.cloneNode(true);
373
+
374
+ const timestamp = new Date(log.timestamp).toLocaleTimeString();
375
+ const cleanMessage = stripAnsiCodes(log.message);
376
+
377
+ entry.querySelector('.log-timestamp').textContent = timestamp;
378
+ const levelSpan = entry.querySelector('.log-level');
379
+ levelSpan.classList.add(`log-level-${log.level}`);
380
+ levelSpan.textContent = log.level;
381
+ entry.querySelector('.log-message').textContent = cleanMessage;
382
+
383
+ const logEntryElement = logContainer.appendChild(entry);
384
+
385
+ const scrollParent = logContainer.closest('.accordion-body');
386
+ if (scrollParent) {
387
+ const isNearBottom = (scrollParent.scrollHeight - scrollParent.scrollTop - scrollParent.clientHeight) < 100;
388
+ if (isNearBottom) {
389
+ // Get the actual appended element (first child of the DocumentFragment)
390
+ const lastEntry = logContainer.lastElementChild;
391
+ if (lastEntry) {
392
+ lastEntry.scrollIntoView({ behavior: 'smooth', block: 'end' });
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ // --- Roll-up Terminal Overlay Logic (Bootstrap Accordion) ---
399
+ let isLogExpanded = false;
400
+ let latestLog = null;
401
+
402
+ function updateLatestLogLine() {
403
+ const latestLogLine = document.getElementById('latestLogLine');
404
+ if (!latestLogLine) return;
405
+ if (isLogExpanded) {
406
+ latestLogLine.textContent = 'Activity';
407
+ return;
408
+ }
409
+ if (latestLog) {
410
+ const template = document.getElementById('latestLogLineTemplate');
411
+ const content = template.content.cloneNode(true);
412
+
413
+ const timestamp = new Date(latestLog.timestamp).toLocaleTimeString();
414
+ const cleanMessage = stripAnsiCodes(latestLog.message);
415
+
416
+ content.querySelector('.log-timestamp').textContent = timestamp;
417
+ const levelSpan = content.querySelector('.log-level');
418
+ levelSpan.classList.add(`log-level-${latestLog.level}`);
419
+ levelSpan.textContent = latestLog.level;
420
+ content.querySelector('.log-message').textContent = cleanMessage;
421
+
422
+ latestLogLine.innerHTML = '';
423
+ latestLogLine.appendChild(content);
424
+ } else {
425
+ latestLogLine.textContent = '';
426
+ }
427
+ }
428
+
429
+ window.addEventListener('DOMContentLoaded', () => {
430
+ // Bootstrap accordion events for log terminal
431
+ const logAccordionCollapse = document.getElementById('logAccordionCollapse');
432
+ if (logAccordionCollapse) {
433
+ logAccordionCollapse.addEventListener('shown.bs.collapse', () => {
434
+ isLogExpanded = true;
435
+ updateLatestLogLine();
436
+ const logContainer = document.getElementById('logContainer');
437
+ if (logContainer) {
438
+ setTimeout(() => {
439
+ const lastLog = logContainer.lastElementChild;
440
+ if (lastLog) {
441
+ lastLog.scrollIntoView({ behavior: 'smooth', block: 'end' });
442
+ } else {
443
+ logContainer.scrollTop = logContainer.scrollHeight;
444
+ }
445
+ }, 100);
446
+ }
447
+ });
448
+ logAccordionCollapse.addEventListener('hide.bs.collapse', () => {
449
+ isLogExpanded = false;
450
+ updateLatestLogLine();
451
+ });
452
+ }
453
+ // Start collapsed by default
454
+ isLogExpanded = false;
455
+ updateLatestLogLine();
456
+ });
457
+ // --- End Roll-up Terminal Overlay Logic ---
458
+
459
+ // Patch appendLog to update latestLog and handle collapsed state
460
+ const origAppendLog = appendLog;
461
+ appendLog = function(log) {
462
+ latestLog = log;
463
+ if (!isLogExpanded) {
464
+ updateLatestLogLine();
465
+ }
466
+ origAppendLog(log);
467
+ };
468
+
469
+ // Patch loadLogs to only show latest log in collapsed mode
470
+ const origLoadLogs = loadLogs;
471
+ loadLogs = async function() {
472
+ await origLoadLogs();
473
+ if (!isLogExpanded) {
474
+ updateLatestLogLine();
475
+ }
476
+ };
477
+
478
+ // --- Initialize Application ---
479
+ document.addEventListener('DOMContentLoaded', async function() {
480
+ // Initialize UI navigation
481
+ initNavigation();
482
+
483
+ // Initialize configuration management (loads config)
484
+ await initConfig();
485
+
486
+ // Update app URL links from loaded config
487
+ updateAppUrlLinks();
488
+
489
+ // Connect WebSocket with handlers
490
+ connectWebSocket({
491
+ onStatus: updateStatus,
492
+ onLog: appendLog,
493
+ onTasks: updateTasks,
494
+ onConnectionChange: updateWSStatus
495
+ });
496
+
497
+ // Load initial data
498
+ loadTasks();
499
+ loadLogs();
500
+ });