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,362 @@
1
+ // Configuration management for CitraScope
2
+
3
+ import { getConfig, saveConfig, getConfigStatus, getHardwareAdapters, getAdapterSchema } from './api.js';
4
+
5
+ let currentAdapterSchema = [];
6
+ export let currentConfig = {};
7
+
8
+ /**
9
+ * Initialize configuration management
10
+ */
11
+ export async function initConfig() {
12
+ // Populate hardware adapter dropdown
13
+ await loadAdapterOptions();
14
+
15
+ // Hardware adapter selection change
16
+ const adapterSelect = document.getElementById('hardwareAdapterSelect');
17
+ if (adapterSelect) {
18
+ adapterSelect.addEventListener('change', async function(e) {
19
+ const adapter = e.target.value;
20
+ if (adapter) {
21
+ await loadAdapterSchema(adapter);
22
+ } else {
23
+ document.getElementById('adapter-settings-container').innerHTML = '';
24
+ }
25
+ });
26
+ }
27
+
28
+ // Config form submission
29
+ const configForm = document.getElementById('configForm');
30
+ if (configForm) {
31
+ configForm.addEventListener('submit', saveConfiguration);
32
+ }
33
+
34
+ // Load initial config
35
+ await loadConfiguration();
36
+ checkConfigStatus();
37
+ }
38
+
39
+ /**
40
+ * Check if configuration is needed and show setup wizard if not configured
41
+ */
42
+ async function checkConfigStatus() {
43
+ try {
44
+ const status = await getConfigStatus();
45
+
46
+ if (!status.configured) {
47
+ // Show setup wizard if not configured
48
+ const wizardModal = new bootstrap.Modal(document.getElementById('setupWizard'));
49
+ wizardModal.show();
50
+ }
51
+
52
+ if (status.error) {
53
+ showConfigError(status.error);
54
+ }
55
+ } catch (error) {
56
+ console.error('Failed to check config status:', error);
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Load available hardware adapters and populate dropdown
62
+ */
63
+ async function loadAdapterOptions() {
64
+ try {
65
+ const data = await getHardwareAdapters();
66
+ const adapterSelect = document.getElementById('hardwareAdapterSelect');
67
+
68
+ if (adapterSelect && data.adapters) {
69
+ // Clear existing options except the first placeholder
70
+ while (adapterSelect.options.length > 1) {
71
+ adapterSelect.remove(1);
72
+ }
73
+
74
+ // Add options from API
75
+ data.adapters.forEach(adapterName => {
76
+ const option = document.createElement('option');
77
+ option.value = adapterName;
78
+ option.textContent = data.descriptions[adapterName] || adapterName;
79
+ adapterSelect.appendChild(option);
80
+ });
81
+ }
82
+ } catch (error) {
83
+ console.error('Failed to load hardware adapters:', error);
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Load configuration from API and populate form
89
+ */
90
+ async function loadConfiguration() {
91
+ try {
92
+ const config = await getConfig();
93
+ currentConfig = config; // Save for reuse when saving
94
+
95
+ // Display config file path
96
+ const configPathElement = document.getElementById('configFilePath');
97
+ if (configPathElement && config.config_file_path) {
98
+ configPathElement.textContent = config.config_file_path;
99
+ }
100
+
101
+ // Display log file path
102
+ const logPathElement = document.getElementById('logFilePath');
103
+ if (logPathElement) {
104
+ if (config.log_file_path) {
105
+ logPathElement.textContent = config.log_file_path;
106
+ } else {
107
+ logPathElement.textContent = 'Disabled';
108
+ }
109
+ }
110
+
111
+ // Display images directory path
112
+ const imagesDirElement = document.getElementById('imagesDirPath');
113
+ if (imagesDirElement && config.images_dir_path) {
114
+ imagesDirElement.textContent = config.images_dir_path;
115
+ }
116
+
117
+ // Core fields
118
+ document.getElementById('personal_access_token').value = config.personal_access_token || '';
119
+ document.getElementById('telescopeId').value = config.telescope_id || '';
120
+ document.getElementById('hardwareAdapterSelect').value = config.hardware_adapter || '';
121
+ document.getElementById('logLevel').value = config.log_level || 'INFO';
122
+ document.getElementById('keep_images').checked = config.keep_images || false;
123
+ document.getElementById('file_logging_enabled').checked = config.file_logging_enabled !== undefined ? config.file_logging_enabled : true;
124
+
125
+ // Load adapter-specific settings if adapter is selected
126
+ if (config.hardware_adapter) {
127
+ await loadAdapterSchema(config.hardware_adapter);
128
+ populateAdapterSettings(config.adapter_settings || {});
129
+ }
130
+ } catch (error) {
131
+ console.error('Failed to load config:', error);
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Load adapter schema and render settings form
137
+ */
138
+ async function loadAdapterSchema(adapterName) {
139
+ try {
140
+ const data = await getAdapterSchema(adapterName);
141
+ currentAdapterSchema = data.schema || [];
142
+ renderAdapterSettings(currentAdapterSchema);
143
+ } catch (error) {
144
+ console.error('Failed to load adapter schema:', error);
145
+ showConfigError(`Failed to load settings for ${adapterName}`);
146
+ }
147
+ }
148
+
149
+ /**
150
+ * Render adapter-specific settings form
151
+ */
152
+ function renderAdapterSettings(schema) {
153
+ const container = document.getElementById('adapter-settings-container');
154
+
155
+ if (!schema || schema.length === 0) {
156
+ container.innerHTML = '';
157
+ return;
158
+ }
159
+
160
+ let html = '<h5 class="mb-3">Adapter Settings</h5><div class="row g-3 mb-4">';
161
+
162
+ schema.forEach(field => {
163
+ const isRequired = field.required ? '<span class="text-danger">*</span>' : '';
164
+ const placeholder = field.placeholder || '';
165
+ const description = field.description || '';
166
+ const displayName = field.friendly_name || field.name;
167
+
168
+ html += '<div class="col-12 col-md-6">';
169
+ html += `<label for="adapter_${field.name}" class="form-label">${displayName} ${isRequired}</label>`;
170
+
171
+ if (field.type === 'bool') {
172
+ html += `<div class="form-check mt-2">`;
173
+ html += `<input class="form-check-input adapter-setting" type="checkbox" id="adapter_${field.name}" data-field="${field.name}" data-type="${field.type}">`;
174
+ html += `<label class="form-check-label" for="adapter_${field.name}">${description}</label>`;
175
+ html += `</div>`;
176
+ } else if (field.options && field.options.length > 0) {
177
+ const displayName = field.friendly_name || field.name;
178
+ html += `<select id="adapter_${field.name}" class="form-select adapter-setting" data-field="${field.name}" data-type="${field.type}" ${field.required ? 'required' : ''}>`;
179
+ html += `<option value="">-- Select ${displayName} --</option>`;
180
+ field.options.forEach(opt => {
181
+ html += `<option value="${opt}">${opt}</option>`;
182
+ });
183
+ html += `</select>`;
184
+ } else if (field.type === 'int' || field.type === 'float') {
185
+ const min = field.min !== undefined ? `min="${field.min}"` : '';
186
+ const max = field.max !== undefined ? `max="${field.max}"` : '';
187
+ html += `<input type="number" id="adapter_${field.name}" class="form-control adapter-setting" `;
188
+ html += `data-field="${field.name}" data-type="${field.type}" `;
189
+ html += `placeholder="${placeholder}" ${min} ${max} ${field.required ? 'required' : ''}>`;
190
+ } else {
191
+ // Default to text input
192
+ const pattern = field.pattern ? `pattern="${field.pattern}"` : '';
193
+ html += `<input type="text" id="adapter_${field.name}" class="form-control adapter-setting" `;
194
+ html += `data-field="${field.name}" data-type="${field.type}" `;
195
+ html += `placeholder="${placeholder}" ${pattern} ${field.required ? 'required' : ''}>`;
196
+ }
197
+
198
+ if (description && field.type !== 'bool') {
199
+ html += `<small class="text-muted">${description}</small>`;
200
+ }
201
+ html += '</div>';
202
+ });
203
+
204
+ html += '</div>';
205
+ container.innerHTML = html;
206
+ }
207
+
208
+ /**
209
+ * Populate adapter settings with values
210
+ */
211
+ function populateAdapterSettings(adapterSettings) {
212
+ Object.entries(adapterSettings).forEach(([key, value]) => {
213
+ const input = document.getElementById(`adapter_${key}`);
214
+ if (input) {
215
+ if (input.type === 'checkbox') {
216
+ input.checked = value;
217
+ } else {
218
+ input.value = value;
219
+ }
220
+ }
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Collect adapter settings from form
226
+ */
227
+ function collectAdapterSettings() {
228
+ const settings = {};
229
+ const inputs = document.querySelectorAll('.adapter-setting');
230
+
231
+ inputs.forEach(input => {
232
+ const fieldName = input.dataset.field;
233
+ const fieldType = input.dataset.type;
234
+ let value;
235
+
236
+ if (input.type === 'checkbox') {
237
+ value = input.checked;
238
+ } else {
239
+ value = input.value;
240
+ }
241
+
242
+ // Type conversion
243
+ if (value !== '' && value !== null) {
244
+ if (fieldType === 'int') {
245
+ value = parseInt(value, 10);
246
+ } else if (fieldType === 'float') {
247
+ value = parseFloat(value);
248
+ } else if (fieldType === 'bool') {
249
+ // Already handled above
250
+ }
251
+ }
252
+
253
+ settings[fieldName] = value;
254
+ });
255
+
256
+ return settings;
257
+ }
258
+
259
+ /**
260
+ * Save configuration form handler
261
+ */
262
+ async function saveConfiguration(event) {
263
+ event.preventDefault();
264
+
265
+ const saveButton = document.getElementById('saveConfigButton');
266
+ const buttonText = document.getElementById('saveButtonText');
267
+ const spinner = document.getElementById('saveButtonSpinner');
268
+
269
+ // Show loading state
270
+ saveButton.disabled = true;
271
+ spinner.style.display = 'inline-block';
272
+ buttonText.textContent = 'Saving...';
273
+
274
+ // Hide previous messages
275
+ hideConfigMessages();
276
+
277
+ const config = {
278
+ personal_access_token: document.getElementById('personal_access_token').value,
279
+ telescope_id: document.getElementById('telescopeId').value,
280
+ hardware_adapter: document.getElementById('hardwareAdapterSelect').value,
281
+ adapter_settings: collectAdapterSettings(),
282
+ log_level: document.getElementById('logLevel').value,
283
+ keep_images: document.getElementById('keep_images').checked,
284
+ file_logging_enabled: document.getElementById('file_logging_enabled').checked,
285
+ // Preserve API settings from loaded config
286
+ host: currentConfig.host || 'api.citra.space',
287
+ port: currentConfig.port || 443,
288
+ use_ssl: currentConfig.use_ssl !== undefined ? currentConfig.use_ssl : true,
289
+ max_task_retries: currentConfig.max_task_retries || 3,
290
+ initial_retry_delay_seconds: currentConfig.initial_retry_delay_seconds || 30,
291
+ max_retry_delay_seconds: currentConfig.max_retry_delay_seconds || 300,
292
+ log_retention_days: currentConfig.log_retention_days || 30,
293
+ };
294
+
295
+ try {
296
+ const result = await saveConfig(config);
297
+
298
+ if (result.ok) {
299
+ showConfigSuccess(result.data.message || 'Configuration saved and applied successfully!');
300
+ } else {
301
+ showConfigError(result.data.error || result.data.message || 'Failed to save configuration');
302
+ }
303
+ } catch (error) {
304
+ showConfigError('Failed to save configuration: ' + error.message);
305
+ } finally {
306
+ // Reset button state
307
+ saveButton.disabled = false;
308
+ spinner.style.display = 'none';
309
+ buttonText.textContent = 'Save Configuration';
310
+ }
311
+ }
312
+
313
+ /**
314
+ * Show configuration error message
315
+ */
316
+ function showConfigError(message) {
317
+ const errorDiv = document.getElementById('configError');
318
+ errorDiv.textContent = message;
319
+ errorDiv.style.display = 'block';
320
+ }
321
+
322
+ /**
323
+ * Show configuration success message
324
+ */
325
+ function showConfigSuccess(message) {
326
+ const successDiv = document.getElementById('configSuccess');
327
+ successDiv.textContent = message;
328
+ successDiv.style.display = 'block';
329
+
330
+ // Auto-hide after 5 seconds
331
+ setTimeout(() => {
332
+ successDiv.style.display = 'none';
333
+ }, 5000);
334
+ }
335
+
336
+ /**
337
+ * Hide all configuration messages
338
+ */
339
+ function hideConfigMessages() {
340
+ document.getElementById('configError').style.display = 'none';
341
+ document.getElementById('configSuccess').style.display = 'none';
342
+ }
343
+
344
+ /**
345
+ * Show configuration section (called from setup wizard)
346
+ */
347
+ export function showConfigSection() {
348
+ // Close setup wizard modal
349
+ const wizardModal = bootstrap.Modal.getInstance(document.getElementById('setupWizard'));
350
+ if (wizardModal) {
351
+ wizardModal.hide();
352
+ }
353
+
354
+ // Show config section
355
+ const configLink = document.querySelector('a[data-section="config"]');
356
+ if (configLink) {
357
+ configLink.click();
358
+ }
359
+ }
360
+
361
+ // Make showConfigSection available globally for onclick handlers in HTML
362
+ window.showConfigSection = showConfigSection;
Binary file
Binary file
@@ -0,0 +1,120 @@
1
+ /* CitraScope Web Interface - Custom Styles
2
+ * Bootstrap 5 handles most styling; these are overrides and additions only
3
+ */
4
+
5
+ /* Log terminal container - custom scrollbar and background watermark */
6
+ .log-container {
7
+ background: #0d1117 url('/static/img/citra.png') no-repeat 85% center;
8
+ background-size: auto 70%;
9
+ background-blend-mode: soft-light;
10
+ opacity: 1;
11
+ font-family: monospace;
12
+ line-height: 1.5;
13
+ }
14
+
15
+ /* Dark scrollbar for log container */
16
+ .log-container::-webkit-scrollbar {
17
+ width: 12px;
18
+ }
19
+
20
+ .log-container::-webkit-scrollbar-track {
21
+ background: #0d1117;
22
+ border-radius: 5px;
23
+ }
24
+
25
+ .log-container::-webkit-scrollbar-thumb {
26
+ background: #30363d;
27
+ border-radius: 5px;
28
+ }
29
+
30
+ .log-container::-webkit-scrollbar-thumb:hover {
31
+ background: #484f58;
32
+ }
33
+
34
+ /* Body padding for fixed bottom log terminal */
35
+ body {
36
+ padding-bottom: 100px;
37
+ }
38
+
39
+ /* Logo styling */
40
+ .logo-img {
41
+ height: 1.5em;
42
+ width: auto;
43
+ vertical-align: middle;
44
+ display: inline-block;
45
+ }
46
+
47
+ /* Status badge container */
48
+ .status-badge-container {
49
+ gap: 0.5em;
50
+ }
51
+
52
+ /* Muted text color for placeholders */
53
+ .text-muted-dark {
54
+ color: #a0aec0;
55
+ }
56
+
57
+ /* Log accordion customization */
58
+ .log-accordion-button {
59
+ border-bottom: 1px solid #444 !important;
60
+ }
61
+
62
+ .log-accordion-body {
63
+ max-height: 40vh;
64
+ overflow: hidden;
65
+ }
66
+
67
+ .log-container {
68
+ max-height: 40vh;
69
+ overflow-y: auto;
70
+ }
71
+
72
+ .log-latest-line {
73
+ font-family: monospace;
74
+ color: #e2e8f0;
75
+ }
76
+
77
+ /* Task display */
78
+ .task-title {
79
+ font-size: 1.1em;
80
+ }
81
+
82
+ .no-task-message {
83
+ color: #a0aec0;
84
+ margin: 0;
85
+ }
86
+
87
+ /* Ground station link */
88
+ .ground-station-link {
89
+ color: #4299e1;
90
+ text-decoration: none;
91
+ }
92
+
93
+ .ground-station-link:hover {
94
+ color: #63b3ed;
95
+ text-decoration: underline;
96
+ }
97
+
98
+ /* Log entry components */
99
+ .log-entry {
100
+ margin-bottom: 4px;
101
+ }
102
+
103
+ .log-timestamp {
104
+ color: #a0aec0;
105
+ }
106
+
107
+ .log-level {
108
+ font-weight: bold;
109
+ margin: 0 8px;
110
+ }
111
+
112
+ .log-level-DEBUG { color: #a0aec0; }
113
+ .log-level-INFO { color: #48bb78; }
114
+ .log-level-WARNING { color: #f6ad55; }
115
+ .log-level-ERROR { color: #f56565; }
116
+ .log-level-CRITICAL { color: #c53030; }
117
+
118
+ .log-message {
119
+ color: #e2e8f0;
120
+ }
@@ -0,0 +1,127 @@
1
+ // WebSocket connection management for CitraScope
2
+
3
+ let ws = null;
4
+ let reconnectAttempts = 0;
5
+ let reconnectTimer = null;
6
+ let connectionTimer = null;
7
+ const reconnectDelay = 5000; // Fixed 5 second delay between reconnect attempts
8
+ const connectionTimeout = 5000; // 5 second timeout for connection attempts
9
+
10
+ // Callbacks for handling messages
11
+ let onStatusUpdate = null;
12
+ let onLogMessage = null;
13
+ let onTasksUpdate = null;
14
+ let onConnectionChange = null;
15
+
16
+ /**
17
+ * Initialize WebSocket connection
18
+ * @param {object} handlers - Event handlers {onStatus, onLog, onTasks, onConnectionChange}
19
+ */
20
+ export function connectWebSocket(handlers = {}) {
21
+ onStatusUpdate = handlers.onStatus || null;
22
+ onLogMessage = handlers.onLog || null;
23
+ onTasksUpdate = handlers.onTasks || null;
24
+ onConnectionChange = handlers.onConnectionChange || null;
25
+
26
+ connect();
27
+ }
28
+
29
+ function connect() {
30
+ // Clear any existing reconnect timer
31
+ if (reconnectTimer) {
32
+ clearTimeout(reconnectTimer);
33
+ reconnectTimer = null;
34
+ }
35
+
36
+ // Clear any existing connection timeout
37
+ if (connectionTimer) {
38
+ clearTimeout(connectionTimer);
39
+ connectionTimer = null;
40
+ }
41
+
42
+ const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
43
+ const wsUrl = `${protocol}//${window.location.host}/ws`;
44
+
45
+ console.log('Attempting WebSocket connection to:', wsUrl);
46
+
47
+ try {
48
+ // Close existing connection if any
49
+ if (ws && ws.readyState !== WebSocket.CLOSED) {
50
+ ws.close();
51
+ }
52
+
53
+ ws = new WebSocket(wsUrl);
54
+
55
+ // Set a timeout for connection attempt
56
+ connectionTimer = setTimeout(() => {
57
+ console.log('WebSocket connection timeout');
58
+ if (ws && ws.readyState !== WebSocket.OPEN) {
59
+ ws.close();
60
+ scheduleReconnect();
61
+ }
62
+ }, connectionTimeout);
63
+
64
+ ws.onopen = () => {
65
+ console.log('WebSocket connected successfully');
66
+ if (connectionTimer) {
67
+ clearTimeout(connectionTimer);
68
+ connectionTimer = null;
69
+ }
70
+ reconnectAttempts = 0;
71
+ notifyConnectionChange(true);
72
+ };
73
+
74
+ ws.onmessage = (event) => {
75
+ const message = JSON.parse(event.data);
76
+ if (message.type === 'status' && onStatusUpdate) {
77
+ onStatusUpdate(message.data);
78
+ } else if (message.type === 'log' && onLogMessage) {
79
+ onLogMessage(message.data);
80
+ } else if (message.type === 'tasks' && onTasksUpdate) {
81
+ onTasksUpdate(message.data);
82
+ }
83
+ };
84
+
85
+ ws.onclose = (event) => {
86
+ console.log('WebSocket closed', event.code, event.reason);
87
+ if (connectionTimer) {
88
+ clearTimeout(connectionTimer);
89
+ connectionTimer = null;
90
+ }
91
+ ws = null;
92
+ scheduleReconnect();
93
+ };
94
+
95
+ ws.onerror = (error) => {
96
+ console.error('WebSocket error:', error);
97
+ console.log('WebSocket readyState:', ws?.readyState);
98
+ // Close will be called automatically after error
99
+ };
100
+ } catch (error) {
101
+ console.error('Failed to create WebSocket:', error);
102
+ if (connectionTimer) {
103
+ clearTimeout(connectionTimer);
104
+ connectionTimer = null;
105
+ }
106
+ ws = null;
107
+ scheduleReconnect();
108
+ }
109
+ }
110
+
111
+ function scheduleReconnect() {
112
+ // Fixed 5 second delay between reconnect attempts
113
+ const delay = reconnectDelay;
114
+
115
+ notifyConnectionChange(false, 'reconnecting');
116
+
117
+ console.log(`Scheduling reconnect in ${delay/1000}s... (attempt ${reconnectAttempts + 1})`);
118
+
119
+ reconnectAttempts++;
120
+ reconnectTimer = setTimeout(connect, delay);
121
+ }
122
+
123
+ function notifyConnectionChange(connected, reconnectInfo = '') {
124
+ if (onConnectionChange) {
125
+ onConnectionChange(connected, reconnectInfo);
126
+ }
127
+ }