citrascope 0.6.1__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.
- citrascope/api/abstract_api_client.py +14 -0
- citrascope/api/citra_api_client.py +41 -0
- citrascope/citra_scope_daemon.py +97 -38
- citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
- citrascope/hardware/adapter_registry.py +10 -3
- citrascope/hardware/devices/__init__.py +17 -0
- citrascope/hardware/devices/abstract_hardware_device.py +79 -0
- citrascope/hardware/devices/camera/__init__.py +13 -0
- citrascope/hardware/devices/camera/abstract_camera.py +102 -0
- citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
- citrascope/hardware/devices/camera/usb_camera.py +402 -0
- citrascope/hardware/devices/camera/ximea_camera.py +744 -0
- citrascope/hardware/devices/device_registry.py +273 -0
- citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
- citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
- citrascope/hardware/devices/focuser/__init__.py +7 -0
- citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
- citrascope/hardware/devices/mount/__init__.py +7 -0
- citrascope/hardware/devices/mount/abstract_mount.py +115 -0
- citrascope/hardware/direct_hardware_adapter.py +787 -0
- citrascope/hardware/filter_sync.py +94 -0
- citrascope/hardware/indi_adapter.py +6 -2
- citrascope/hardware/kstars_dbus_adapter.py +67 -96
- citrascope/hardware/nina_adv_http_adapter.py +81 -64
- citrascope/hardware/nina_adv_http_survey_template.json +4 -4
- citrascope/settings/citrascope_settings.py +25 -0
- citrascope/tasks/runner.py +105 -0
- citrascope/tasks/scope/static_telescope_task.py +17 -12
- citrascope/tasks/task.py +3 -0
- citrascope/time/__init__.py +13 -0
- citrascope/time/time_health.py +96 -0
- citrascope/time/time_monitor.py +164 -0
- citrascope/time/time_sources.py +62 -0
- citrascope/web/app.py +274 -51
- citrascope/web/static/app.js +379 -36
- citrascope/web/static/config.js +448 -108
- citrascope/web/static/filters.js +55 -0
- citrascope/web/static/style.css +39 -0
- citrascope/web/templates/dashboard.html +176 -36
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
- citrascope-0.8.0.dist-info/RECORD +62 -0
- citrascope-0.6.1.dist-info/RECORD +0 -41
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
- {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
citrascope/web/static/config.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Configuration management for CitraScope
|
|
2
2
|
|
|
3
3
|
import { getConfig, saveConfig, getConfigStatus, getHardwareAdapters, getAdapterSchema } from './api.js';
|
|
4
|
+
import { getFilterColor } from './filters.js';
|
|
4
5
|
|
|
5
6
|
// API Host constants - must match backend constants in app.py
|
|
6
7
|
const PROD_API_HOST = 'api.citra.space';
|
|
@@ -9,6 +10,7 @@ const DEFAULT_API_PORT = 443;
|
|
|
9
10
|
|
|
10
11
|
let currentAdapterSchema = [];
|
|
11
12
|
export let currentConfig = {};
|
|
13
|
+
let savedAdapter = null; // Track the currently saved adapter
|
|
12
14
|
|
|
13
15
|
export async function initConfig() {
|
|
14
16
|
// Populate hardware adapter dropdown
|
|
@@ -17,12 +19,19 @@ export async function initConfig() {
|
|
|
17
19
|
// Hardware adapter selection change
|
|
18
20
|
const adapterSelect = document.getElementById('hardwareAdapterSelect');
|
|
19
21
|
if (adapterSelect) {
|
|
20
|
-
adapterSelect.addEventListener('change', async
|
|
22
|
+
adapterSelect.addEventListener('change', async (e) => {
|
|
21
23
|
const adapter = e.target.value;
|
|
22
24
|
if (adapter) {
|
|
23
|
-
|
|
25
|
+
// Extract the NEW adapter's saved settings from the nested structure
|
|
26
|
+
const allAdapterSettings = currentConfig.adapter_settings || {};
|
|
27
|
+
const newAdapterSettings = allAdapterSettings[adapter] || {};
|
|
28
|
+
await loadAdapterSchema(adapter, newAdapterSettings);
|
|
29
|
+
populateAdapterSettings(newAdapterSettings);
|
|
30
|
+
await loadFilterConfig();
|
|
24
31
|
} else {
|
|
25
32
|
document.getElementById('adapter-settings-container').innerHTML = '';
|
|
33
|
+
const filterSection = document.getElementById('filterConfigSection');
|
|
34
|
+
if (filterSection) filterSection.style.display = 'none';
|
|
26
35
|
}
|
|
27
36
|
});
|
|
28
37
|
}
|
|
@@ -30,7 +39,7 @@ export async function initConfig() {
|
|
|
30
39
|
// API endpoint selection change
|
|
31
40
|
const apiEndpointSelect = document.getElementById('apiEndpoint');
|
|
32
41
|
if (apiEndpointSelect) {
|
|
33
|
-
apiEndpointSelect.addEventListener('change',
|
|
42
|
+
apiEndpointSelect.addEventListener('change', (e) => {
|
|
34
43
|
const customContainer = document.getElementById('customHostContainer');
|
|
35
44
|
if (e.target.value === 'custom') {
|
|
36
45
|
customContainer.style.display = 'block';
|
|
@@ -106,6 +115,7 @@ async function loadConfiguration() {
|
|
|
106
115
|
try {
|
|
107
116
|
const config = await getConfig();
|
|
108
117
|
currentConfig = config; // Save for reuse when saving
|
|
118
|
+
savedAdapter = config.hardware_adapter; // Track saved adapter
|
|
109
119
|
|
|
110
120
|
// Display config file path
|
|
111
121
|
const configPathElement = document.getElementById('configFilePath');
|
|
@@ -158,10 +168,31 @@ async function loadConfiguration() {
|
|
|
158
168
|
document.getElementById('keep_images').checked = config.keep_images || false;
|
|
159
169
|
document.getElementById('file_logging_enabled').checked = config.file_logging_enabled !== undefined ? config.file_logging_enabled : true;
|
|
160
170
|
|
|
171
|
+
// Load autofocus settings (top-level)
|
|
172
|
+
const scheduledAutofocusEnabled = document.getElementById('scheduled_autofocus_enabled');
|
|
173
|
+
const autofocusInterval = document.getElementById('autofocus_interval_minutes');
|
|
174
|
+
if (scheduledAutofocusEnabled) {
|
|
175
|
+
scheduledAutofocusEnabled.checked = config.scheduled_autofocus_enabled || false;
|
|
176
|
+
}
|
|
177
|
+
if (autofocusInterval) {
|
|
178
|
+
autofocusInterval.value = config.autofocus_interval_minutes || 60;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Load time sync settings (monitoring always enabled)
|
|
182
|
+
const timeOffsetPause = document.getElementById('time_offset_pause_ms');
|
|
183
|
+
|
|
184
|
+
if (timeOffsetPause) {
|
|
185
|
+
timeOffsetPause.value = config.time_offset_pause_ms || 500;
|
|
186
|
+
}
|
|
187
|
+
|
|
161
188
|
// Load adapter-specific settings if adapter is selected
|
|
162
189
|
if (config.hardware_adapter) {
|
|
163
|
-
|
|
164
|
-
|
|
190
|
+
// adapter_settings is nested: {"nina": {...}, "kstars": {...}, "direct": {...}}
|
|
191
|
+
// Extract the current adapter's settings
|
|
192
|
+
const allAdapterSettings = config.adapter_settings || {};
|
|
193
|
+
const currentAdapterSettings = allAdapterSettings[config.hardware_adapter] || {};
|
|
194
|
+
await loadAdapterSchema(config.hardware_adapter, currentAdapterSettings);
|
|
195
|
+
populateAdapterSettings(currentAdapterSettings);
|
|
165
196
|
}
|
|
166
197
|
} catch (error) {
|
|
167
198
|
console.error('Failed to load config:', error);
|
|
@@ -171,9 +202,16 @@ async function loadConfiguration() {
|
|
|
171
202
|
/**
|
|
172
203
|
* Load adapter schema and render settings form
|
|
173
204
|
*/
|
|
174
|
-
async function loadAdapterSchema(adapterName) {
|
|
205
|
+
async function loadAdapterSchema(adapterName, currentSettings = {}) {
|
|
175
206
|
try {
|
|
176
|
-
|
|
207
|
+
// Pass current adapter settings for dynamic schema generation
|
|
208
|
+
const settingsParam = Object.keys(currentSettings).length > 0
|
|
209
|
+
? `?current_settings=${encodeURIComponent(JSON.stringify(currentSettings))}`
|
|
210
|
+
: '';
|
|
211
|
+
|
|
212
|
+
const response = await fetch(`/api/hardware-adapters/${adapterName}/schema${settingsParam}`);
|
|
213
|
+
const data = await response.json();
|
|
214
|
+
|
|
177
215
|
currentAdapterSchema = data.schema || [];
|
|
178
216
|
renderAdapterSettings(currentAdapterSchema);
|
|
179
217
|
} catch (error) {
|
|
@@ -193,52 +231,115 @@ function renderAdapterSettings(schema) {
|
|
|
193
231
|
return;
|
|
194
232
|
}
|
|
195
233
|
|
|
196
|
-
|
|
234
|
+
// Group fields by their 'group' property
|
|
235
|
+
const grouped = schema.reduce((acc, field) => {
|
|
236
|
+
if (field.readonly) return acc; // Skip readonly fields
|
|
237
|
+
const group = field.group || 'General';
|
|
238
|
+
if (!acc[group]) acc[group] = [];
|
|
239
|
+
acc[group].push(field);
|
|
240
|
+
return acc;
|
|
241
|
+
}, {});
|
|
242
|
+
|
|
243
|
+
let html = '<h5 class="mb-3">Adapter Settings</h5>';
|
|
244
|
+
|
|
245
|
+
// Render each group as a card
|
|
246
|
+
Object.entries(grouped).forEach(([groupName, fields]) => {
|
|
247
|
+
html += `<div class="card bg-dark border-secondary mb-3">`;
|
|
248
|
+
html += `<div class="card-header">`;
|
|
249
|
+
html += `<h6 class="mb-0"><i class="bi bi-${getGroupIcon(groupName)} me-2"></i>${groupName}</h6>`;
|
|
250
|
+
html += `</div>`;
|
|
251
|
+
html += `<div class="card-body">`;
|
|
252
|
+
html += `<div class="row g-3">`;
|
|
253
|
+
|
|
254
|
+
fields.forEach(field => {
|
|
255
|
+
const isRequired = field.required ? '<span class="text-danger">*</span>' : '';
|
|
256
|
+
const placeholder = field.placeholder || '';
|
|
257
|
+
const description = field.description || '';
|
|
258
|
+
const displayName = field.friendly_name || field.name;
|
|
197
259
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
260
|
+
html += '<div class="col-12 col-md-4">';
|
|
261
|
+
html += `<label for="adapter_${field.name}" class="form-label">${displayName} ${isRequired}</label>`;
|
|
262
|
+
|
|
263
|
+
if (field.type === 'bool') {
|
|
264
|
+
html += `<div class="form-check mt-2">`;
|
|
265
|
+
html += `<input class="form-check-input adapter-setting" type="checkbox" id="adapter_${field.name}" data-field="${field.name}" data-type="${field.type}">`;
|
|
266
|
+
html += `<label class="form-check-label" for="adapter_${field.name}">${description}</label>`;
|
|
267
|
+
html += `</div>`;
|
|
268
|
+
} else if (field.options && field.options.length > 0) {
|
|
269
|
+
html += `<select id="adapter_${field.name}" class="form-select adapter-setting" data-field="${field.name}" data-type="${field.type}" ${field.required ? 'required' : ''}>`;
|
|
270
|
+
html += `<option value="">-- Select ${displayName} --</option>`;
|
|
271
|
+
field.options.forEach(opt => {
|
|
272
|
+
// Handle both object format {value, label} and plain string options
|
|
273
|
+
const optValue = typeof opt === 'object' ? opt.value : opt;
|
|
274
|
+
const optLabel = typeof opt === 'object' ? opt.label : opt;
|
|
275
|
+
html += `<option value="${optValue}">${optLabel}</option>`;
|
|
276
|
+
});
|
|
277
|
+
html += `</select>`;
|
|
278
|
+
} else if (field.type === 'int' || field.type === 'float') {
|
|
279
|
+
const min = field.min !== undefined ? `min="${field.min}"` : '';
|
|
280
|
+
const max = field.max !== undefined ? `max="${field.max}"` : '';
|
|
281
|
+
const step = field.type === 'float' ? 'step="any"' : '';
|
|
282
|
+
html += `<input type="number" id="adapter_${field.name}" class="form-control adapter-setting" `;
|
|
283
|
+
html += `data-field="${field.name}" data-type="${field.type}" `;
|
|
284
|
+
html += `placeholder="${placeholder}" ${min} ${max} ${step} ${field.required ? 'required' : ''}>`;
|
|
285
|
+
} else {
|
|
286
|
+
// Default to text input
|
|
287
|
+
const pattern = field.pattern ? `pattern="${field.pattern}"` : '';
|
|
288
|
+
html += `<input type="text" id="adapter_${field.name}" class="form-control adapter-setting" `;
|
|
289
|
+
html += `data-field="${field.name}" data-type="${field.type}" `;
|
|
290
|
+
html += `placeholder="${placeholder}" ${pattern} ${field.required ? 'required' : ''}>`;
|
|
291
|
+
}
|
|
203
292
|
|
|
204
|
-
|
|
205
|
-
|
|
293
|
+
if (description && field.type !== 'bool') {
|
|
294
|
+
html += `<small class="text-muted">${description}</small>`;
|
|
295
|
+
}
|
|
296
|
+
html += '</div>';
|
|
297
|
+
});
|
|
206
298
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
// Default to text input
|
|
228
|
-
const pattern = field.pattern ? `pattern="${field.pattern}"` : '';
|
|
229
|
-
html += `<input type="text" id="adapter_${field.name}" class="form-control adapter-setting" `;
|
|
230
|
-
html += `data-field="${field.name}" data-type="${field.type}" `;
|
|
231
|
-
html += `placeholder="${placeholder}" ${pattern} ${field.required ? 'required' : ''}>`;
|
|
232
|
-
}
|
|
299
|
+
html += `</div></div></div>`; // Close row, card-body, card
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
container.innerHTML = html;
|
|
303
|
+
|
|
304
|
+
// Add change listeners to device type fields to reload schema dynamically
|
|
305
|
+
const deviceTypeFields = ['camera_type', 'mount_type', 'filter_wheel_type', 'focuser_type'];
|
|
306
|
+
deviceTypeFields.forEach(fieldName => {
|
|
307
|
+
const select = document.getElementById(`adapter_${fieldName}`);
|
|
308
|
+
if (select) {
|
|
309
|
+
select.addEventListener('change', async () => {
|
|
310
|
+
// Get current adapter name
|
|
311
|
+
const adapterSelect = document.getElementById('hardwareAdapterSelect');
|
|
312
|
+
if (!adapterSelect || !adapterSelect.value) return;
|
|
313
|
+
|
|
314
|
+
// Collect current settings
|
|
315
|
+
const currentSettings = collectAdapterSettings();
|
|
316
|
+
|
|
317
|
+
// Reload schema with new device type selection
|
|
318
|
+
await loadAdapterSchema(adapterSelect.value, currentSettings);
|
|
233
319
|
|
|
234
|
-
|
|
235
|
-
|
|
320
|
+
// Repopulate settings (preserves user's selections)
|
|
321
|
+
populateAdapterSettings(currentSettings);
|
|
322
|
+
});
|
|
236
323
|
}
|
|
237
|
-
html += '</div>';
|
|
238
324
|
});
|
|
325
|
+
}
|
|
239
326
|
|
|
240
|
-
|
|
241
|
-
|
|
327
|
+
/**
|
|
328
|
+
* Get Bootstrap icon name for a group
|
|
329
|
+
*/
|
|
330
|
+
function getGroupIcon(groupName) {
|
|
331
|
+
const icons = {
|
|
332
|
+
'Camera': 'camera',
|
|
333
|
+
'Mount': 'compass',
|
|
334
|
+
'Filter Wheel': 'circle',
|
|
335
|
+
'Focuser': 'eyeglasses',
|
|
336
|
+
'Connection': 'ethernet',
|
|
337
|
+
'Devices': 'hdd-network',
|
|
338
|
+
'Imaging': 'image',
|
|
339
|
+
'General': 'gear',
|
|
340
|
+
'Advanced': 'sliders'
|
|
341
|
+
};
|
|
342
|
+
return icons[groupName] || 'gear';
|
|
242
343
|
}
|
|
243
344
|
|
|
244
345
|
/**
|
|
@@ -275,16 +376,18 @@ function collectAdapterSettings() {
|
|
|
275
376
|
value = input.value;
|
|
276
377
|
}
|
|
277
378
|
|
|
379
|
+
// Skip empty values for non-checkbox fields (will use backend defaults)
|
|
380
|
+
if (input.type !== 'checkbox' && (value === '' || value === null || value === undefined)) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
|
|
278
384
|
// Type conversion
|
|
279
|
-
if (
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
value = parseFloat(value);
|
|
284
|
-
} else if (fieldType === 'bool') {
|
|
285
|
-
// Already handled above
|
|
286
|
-
}
|
|
385
|
+
if (fieldType === 'int') {
|
|
386
|
+
value = parseInt(value, 10);
|
|
387
|
+
} else if (fieldType === 'float') {
|
|
388
|
+
value = parseFloat(value);
|
|
287
389
|
}
|
|
390
|
+
// bool type already handled above
|
|
288
391
|
|
|
289
392
|
settings[fieldName] = value;
|
|
290
393
|
});
|
|
@@ -336,6 +439,11 @@ async function saveConfiguration(event) {
|
|
|
336
439
|
log_level: document.getElementById('logLevel').value,
|
|
337
440
|
keep_images: document.getElementById('keep_images').checked,
|
|
338
441
|
file_logging_enabled: document.getElementById('file_logging_enabled').checked,
|
|
442
|
+
// Autofocus settings (top-level)
|
|
443
|
+
scheduled_autofocus_enabled: document.getElementById('scheduled_autofocus_enabled')?.checked || false,
|
|
444
|
+
autofocus_interval_minutes: parseInt(document.getElementById('autofocus_interval_minutes')?.value || 60, 10),
|
|
445
|
+
// Time sync settings (monitoring always enabled)
|
|
446
|
+
time_offset_pause_ms: parseFloat(document.getElementById('time_offset_pause_ms')?.value || 500),
|
|
339
447
|
// API settings from endpoint selector
|
|
340
448
|
host: host,
|
|
341
449
|
port: port,
|
|
@@ -345,12 +453,27 @@ async function saveConfiguration(event) {
|
|
|
345
453
|
initial_retry_delay_seconds: currentConfig.initial_retry_delay_seconds || 30,
|
|
346
454
|
max_retry_delay_seconds: currentConfig.max_retry_delay_seconds || 300,
|
|
347
455
|
log_retention_days: currentConfig.log_retention_days || 30,
|
|
456
|
+
last_autofocus_timestamp: currentConfig.last_autofocus_timestamp, // Preserve timestamp
|
|
348
457
|
};
|
|
349
458
|
|
|
350
459
|
try {
|
|
460
|
+
// Validate filters BEFORE saving main config (belt and suspenders)
|
|
461
|
+
const inputs = document.querySelectorAll('.filter-focus-input');
|
|
462
|
+
if (inputs.length > 0) {
|
|
463
|
+
const checkboxes = document.querySelectorAll('.filter-enabled-checkbox');
|
|
464
|
+
const enabledCount = Array.from(checkboxes).filter(cb => cb.checked).length;
|
|
465
|
+
if (enabledCount === 0) {
|
|
466
|
+
showConfigMessage('At least one filter must be enabled', 'danger');
|
|
467
|
+
return; // Exit early without saving anything
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
351
471
|
const result = await saveConfig(config);
|
|
352
472
|
|
|
353
473
|
if (result.ok) {
|
|
474
|
+
// Update saved adapter to match newly saved config
|
|
475
|
+
savedAdapter = config.hardware_adapter;
|
|
476
|
+
|
|
354
477
|
// After config saved successfully, save any modified filter focus positions
|
|
355
478
|
const filterResults = await saveModifiedFilters();
|
|
356
479
|
|
|
@@ -364,6 +487,9 @@ async function saveConfiguration(event) {
|
|
|
364
487
|
}
|
|
365
488
|
|
|
366
489
|
showConfigSuccess(message);
|
|
490
|
+
|
|
491
|
+
// Reload filters to re-enable editing for the new adapter
|
|
492
|
+
await loadFilterConfig();
|
|
367
493
|
} else {
|
|
368
494
|
// Check for specific error codes
|
|
369
495
|
const errorMsg = result.data.error || result.data.message || 'Failed to save configuration';
|
|
@@ -379,35 +505,82 @@ async function saveConfiguration(event) {
|
|
|
379
505
|
}
|
|
380
506
|
}
|
|
381
507
|
|
|
508
|
+
/**
|
|
509
|
+
* Create and show a Bootstrap toast notification
|
|
510
|
+
* @param {string} message - The message to display
|
|
511
|
+
* @param {string} type - 'danger' for errors, 'success' for success messages
|
|
512
|
+
* @param {boolean} autohide - Whether to auto-hide the toast
|
|
513
|
+
*/
|
|
514
|
+
export function createToast(message, type = 'danger', autohide = false) {
|
|
515
|
+
const toastContainer = document.getElementById('toastContainer');
|
|
516
|
+
if (!toastContainer) {
|
|
517
|
+
console.error('Toast container not found');
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Create toast element
|
|
522
|
+
const toastId = `toast-${Date.now()}`;
|
|
523
|
+
const toastHTML = `
|
|
524
|
+
<div id="${toastId}" class="toast text-bg-${type}" role="alert" aria-live="assertive" aria-atomic="true">
|
|
525
|
+
<div class="toast-header text-bg-${type}">
|
|
526
|
+
<strong class="me-auto">CitraScope</strong>
|
|
527
|
+
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
528
|
+
</div>
|
|
529
|
+
<div class="toast-body">
|
|
530
|
+
${message}
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
`;
|
|
534
|
+
|
|
535
|
+
// Insert toast into container
|
|
536
|
+
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
|
|
537
|
+
|
|
538
|
+
// Get the toast element and initialize Bootstrap toast
|
|
539
|
+
const toastElement = document.getElementById(toastId);
|
|
540
|
+
const toast = new bootstrap.Toast(toastElement, {
|
|
541
|
+
autohide: autohide,
|
|
542
|
+
delay: 5000
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
// Remove toast element from DOM after it's hidden
|
|
546
|
+
toastElement.addEventListener('hidden.bs.toast', () => {
|
|
547
|
+
toastElement.remove();
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
// Show the toast
|
|
551
|
+
toast.show();
|
|
552
|
+
}
|
|
553
|
+
|
|
382
554
|
/**
|
|
383
555
|
* Show configuration error message
|
|
384
556
|
*/
|
|
385
557
|
function showConfigError(message) {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
558
|
+
createToast(message, 'danger', false);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Show configuration message (can be error or success)
|
|
563
|
+
*/
|
|
564
|
+
function showConfigMessage(message, type = 'danger') {
|
|
565
|
+
if (type === 'danger') {
|
|
566
|
+
showConfigError(message);
|
|
567
|
+
} else {
|
|
568
|
+
showConfigSuccess(message);
|
|
569
|
+
}
|
|
389
570
|
}
|
|
390
571
|
|
|
391
572
|
/**
|
|
392
573
|
* Show configuration success message
|
|
393
574
|
*/
|
|
394
575
|
function showConfigSuccess(message) {
|
|
395
|
-
|
|
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);
|
|
576
|
+
createToast(message, 'success', true);
|
|
403
577
|
}
|
|
404
578
|
|
|
405
579
|
/**
|
|
406
|
-
* Hide all configuration messages
|
|
580
|
+
* Hide all configuration messages (no-op for toast compatibility)
|
|
407
581
|
*/
|
|
408
582
|
function hideConfigMessages() {
|
|
409
|
-
|
|
410
|
-
document.getElementById('configSuccess').style.display = 'none';
|
|
583
|
+
// No-op - toasts handle their own hiding
|
|
411
584
|
}
|
|
412
585
|
|
|
413
586
|
/**
|
|
@@ -432,6 +605,24 @@ export function showConfigSection() {
|
|
|
432
605
|
*/
|
|
433
606
|
async function loadFilterConfig() {
|
|
434
607
|
const filterSection = document.getElementById('filterConfigSection');
|
|
608
|
+
const changeMessage = document.getElementById('filterAdapterChangeMessage');
|
|
609
|
+
const tableContainer = document.getElementById('filterTableContainer');
|
|
610
|
+
|
|
611
|
+
// Check if selected adapter matches saved adapter
|
|
612
|
+
const adapterSelect = document.getElementById('hardwareAdapterSelect');
|
|
613
|
+
const selectedAdapter = adapterSelect ? adapterSelect.value : null;
|
|
614
|
+
|
|
615
|
+
if (selectedAdapter && savedAdapter && selectedAdapter !== savedAdapter) {
|
|
616
|
+
// Adapter has changed but not saved yet - show message and hide table
|
|
617
|
+
if (filterSection) filterSection.style.display = 'block';
|
|
618
|
+
if (changeMessage) changeMessage.style.display = 'block';
|
|
619
|
+
if (tableContainer) tableContainer.style.display = 'none';
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
// Hide message and show table when adapters match
|
|
624
|
+
if (changeMessage) changeMessage.style.display = 'none';
|
|
625
|
+
if (tableContainer) tableContainer.style.display = 'block';
|
|
435
626
|
|
|
436
627
|
try {
|
|
437
628
|
const response = await fetch('/api/adapter/filters');
|
|
@@ -448,6 +639,9 @@ async function loadFilterConfig() {
|
|
|
448
639
|
// Show the filter section
|
|
449
640
|
if (filterSection) filterSection.style.display = 'block';
|
|
450
641
|
|
|
642
|
+
// Update enabled filters display on dashboard
|
|
643
|
+
updateEnabledFiltersDisplay(data.filters);
|
|
644
|
+
|
|
451
645
|
// Populate filter table
|
|
452
646
|
const tbody = document.getElementById('filterTableBody');
|
|
453
647
|
const noFiltersMsg = document.getElementById('noFiltersMessage');
|
|
@@ -464,10 +658,19 @@ async function loadFilterConfig() {
|
|
|
464
658
|
|
|
465
659
|
filterIds.forEach(filterId => {
|
|
466
660
|
const filter = filters[filterId];
|
|
661
|
+
const isEnabled = filter.enabled !== undefined ? filter.enabled : true;
|
|
662
|
+
const filterColor = getFilterColor(filter.name);
|
|
467
663
|
const row = document.createElement('tr');
|
|
468
664
|
row.innerHTML = `
|
|
469
|
-
<td
|
|
470
|
-
|
|
665
|
+
<td>
|
|
666
|
+
<input type="checkbox"
|
|
667
|
+
class="form-check-input filter-enabled-checkbox"
|
|
668
|
+
data-filter-id="${filterId}"
|
|
669
|
+
${isEnabled ? 'checked' : ''}>
|
|
670
|
+
</td>
|
|
671
|
+
<td>
|
|
672
|
+
<span class="badge" style="background-color: ${filterColor}; color: white;">${filter.name}</span>
|
|
673
|
+
</td>
|
|
471
674
|
<td>
|
|
472
675
|
<input type="number"
|
|
473
676
|
class="form-control form-control-sm filter-focus-input"
|
|
@@ -491,52 +694,93 @@ async function loadFilterConfig() {
|
|
|
491
694
|
}
|
|
492
695
|
|
|
493
696
|
/**
|
|
494
|
-
* Save all filter focus positions (called during main config save)
|
|
697
|
+
* Save all filter focus positions and enabled states (called during main config save)
|
|
495
698
|
* Returns: Object with { success: number, failed: number }
|
|
496
699
|
*/
|
|
497
700
|
async function saveModifiedFilters() {
|
|
498
701
|
const inputs = document.querySelectorAll('.filter-focus-input');
|
|
499
702
|
if (inputs.length === 0) return { success: 0, failed: 0 }; // No filters to save
|
|
500
703
|
|
|
501
|
-
|
|
502
|
-
|
|
704
|
+
// Belt and suspenders: Validate at least one filter is enabled before saving
|
|
705
|
+
const checkboxes = document.querySelectorAll('.filter-enabled-checkbox');
|
|
706
|
+
const enabledCount = Array.from(checkboxes).filter(cb => cb.checked).length;
|
|
707
|
+
if (enabledCount === 0) {
|
|
708
|
+
showConfigMessage('At least one filter must be enabled', 'danger');
|
|
709
|
+
return { success: 0, failed: inputs.length };
|
|
710
|
+
}
|
|
503
711
|
|
|
504
|
-
//
|
|
712
|
+
// Collect all filter updates into array
|
|
713
|
+
const filterUpdates = [];
|
|
505
714
|
for (const input of inputs) {
|
|
506
715
|
const filterId = input.dataset.filterId;
|
|
507
716
|
const focusPosition = parseInt(input.value);
|
|
508
717
|
|
|
509
|
-
if (isNaN(focusPosition) || focusPosition < 0) {
|
|
510
|
-
|
|
511
|
-
continue;
|
|
718
|
+
if (Number.isNaN(focusPosition) || focusPosition < 0) {
|
|
719
|
+
continue; // Skip invalid entries
|
|
512
720
|
}
|
|
513
721
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
headers: {
|
|
518
|
-
'Content-Type': 'application/json'
|
|
519
|
-
},
|
|
520
|
-
body: JSON.stringify({ focus_position: focusPosition })
|
|
521
|
-
});
|
|
722
|
+
// Get enabled state from corresponding checkbox
|
|
723
|
+
const checkbox = document.querySelector(`.filter-enabled-checkbox[data-filter-id="${filterId}"]`);
|
|
724
|
+
const enabled = checkbox ? checkbox.checked : true;
|
|
522
725
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
726
|
+
filterUpdates.push({
|
|
727
|
+
filter_id: filterId,
|
|
728
|
+
focus_position: focusPosition,
|
|
729
|
+
enabled: enabled
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (filterUpdates.length === 0) {
|
|
734
|
+
return { success: 0, failed: 0 };
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Send single batch update
|
|
738
|
+
try {
|
|
739
|
+
const response = await fetch('/api/adapter/filters/batch', {
|
|
740
|
+
method: 'POST',
|
|
741
|
+
headers: {
|
|
742
|
+
'Content-Type': 'application/json'
|
|
743
|
+
},
|
|
744
|
+
body: JSON.stringify(filterUpdates)
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
if (response.ok) {
|
|
748
|
+
const data = await response.json();
|
|
749
|
+
const successCount = data.updated_count || 0;
|
|
750
|
+
|
|
751
|
+
// After batch update, sync to backend
|
|
752
|
+
try {
|
|
753
|
+
const syncResponse = await fetch('/api/adapter/filters/sync', {
|
|
754
|
+
method: 'POST'
|
|
755
|
+
});
|
|
756
|
+
if (!syncResponse.ok) {
|
|
757
|
+
console.error('Failed to sync filters to backend');
|
|
758
|
+
}
|
|
759
|
+
} catch (error) {
|
|
760
|
+
console.error('Error syncing filters to backend:', error);
|
|
528
761
|
}
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
762
|
+
|
|
763
|
+
return { success: successCount, failed: 0 };
|
|
764
|
+
} else {
|
|
765
|
+
const data = await response.json();
|
|
766
|
+
const errorMsg = data.error || 'Unknown error';
|
|
767
|
+
console.error(`Failed to save filters: ${errorMsg}`);
|
|
768
|
+
|
|
769
|
+
// Show error to user
|
|
770
|
+
if (response.status === 400 && errorMsg.includes('last enabled filter')) {
|
|
771
|
+
showConfigMessage(errorMsg, 'danger');
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
return { success: 0, failed: filterUpdates.length };
|
|
532
775
|
}
|
|
776
|
+
} catch (error) {
|
|
777
|
+
console.error('Error saving filters:', error);
|
|
778
|
+
return { success: 0, failed: filterUpdates.length };
|
|
533
779
|
}
|
|
534
|
-
|
|
535
|
-
return { success: successCount, failed: failedCount };
|
|
536
780
|
}
|
|
537
781
|
|
|
538
782
|
/**
|
|
539
|
-
* Trigger autofocus routine
|
|
783
|
+
* Trigger or cancel autofocus routine
|
|
540
784
|
*/
|
|
541
785
|
async function triggerAutofocus() {
|
|
542
786
|
const button = document.getElementById('runAutofocusButton');
|
|
@@ -545,12 +789,32 @@ async function triggerAutofocus() {
|
|
|
545
789
|
|
|
546
790
|
if (!button || !buttonText || !buttonSpinner) return;
|
|
547
791
|
|
|
548
|
-
//
|
|
549
|
-
|
|
792
|
+
// Check if this is a cancel action
|
|
793
|
+
const isCancel = button.dataset.action === 'cancel';
|
|
550
794
|
|
|
551
|
-
|
|
795
|
+
if (isCancel) {
|
|
796
|
+
// Cancel autofocus
|
|
797
|
+
try {
|
|
798
|
+
const response = await fetch('/api/adapter/autofocus/cancel', {
|
|
799
|
+
method: 'POST'
|
|
800
|
+
});
|
|
801
|
+
const data = await response.json();
|
|
802
|
+
|
|
803
|
+
if (response.ok && data.success) {
|
|
804
|
+
showToast('Autofocus cancelled', 'info');
|
|
805
|
+
updateAutofocusButton(false);
|
|
806
|
+
} else {
|
|
807
|
+
showToast('Nothing to cancel', 'warning');
|
|
808
|
+
}
|
|
809
|
+
} catch (error) {
|
|
810
|
+
console.error('Error cancelling autofocus:', error);
|
|
811
|
+
showToast('Failed to cancel autofocus', 'error');
|
|
812
|
+
}
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// Request autofocus
|
|
552
817
|
button.disabled = true;
|
|
553
|
-
buttonText.textContent = 'Running Autofocus...';
|
|
554
818
|
buttonSpinner.style.display = 'inline-block';
|
|
555
819
|
|
|
556
820
|
try {
|
|
@@ -561,24 +825,78 @@ async function triggerAutofocus() {
|
|
|
561
825
|
const data = await response.json();
|
|
562
826
|
|
|
563
827
|
if (response.ok) {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
await loadFilterConfig();
|
|
828
|
+
showToast('Autofocus queued', 'success');
|
|
829
|
+
updateAutofocusButton(true);
|
|
567
830
|
} else {
|
|
568
|
-
|
|
569
|
-
showConfigError(data.error || 'Autofocus failed');
|
|
831
|
+
showToast(data.error || 'Autofocus request failed', 'error');
|
|
570
832
|
}
|
|
571
833
|
} catch (error) {
|
|
572
834
|
console.error('Error triggering autofocus:', error);
|
|
573
|
-
|
|
835
|
+
showToast('Failed to trigger autofocus', 'error');
|
|
574
836
|
} finally {
|
|
575
|
-
// Re-enable button
|
|
576
837
|
button.disabled = false;
|
|
577
|
-
buttonText.textContent = 'Run Autofocus';
|
|
578
838
|
buttonSpinner.style.display = 'none';
|
|
579
839
|
}
|
|
580
840
|
}
|
|
581
841
|
|
|
842
|
+
/**
|
|
843
|
+
* Update autofocus button state based on whether autofocus is queued
|
|
844
|
+
*/
|
|
845
|
+
function updateAutofocusButton(isQueued) {
|
|
846
|
+
const button = document.getElementById('runAutofocusButton');
|
|
847
|
+
const buttonText = document.getElementById('autofocusButtonText');
|
|
848
|
+
|
|
849
|
+
if (!button || !buttonText) return;
|
|
850
|
+
|
|
851
|
+
if (isQueued) {
|
|
852
|
+
buttonText.textContent = 'Cancel Autofocus';
|
|
853
|
+
button.dataset.action = 'cancel';
|
|
854
|
+
button.classList.remove('btn-outline-primary');
|
|
855
|
+
button.classList.add('btn-outline-warning');
|
|
856
|
+
} else {
|
|
857
|
+
buttonText.textContent = 'Run Autofocus';
|
|
858
|
+
button.dataset.action = 'request';
|
|
859
|
+
button.classList.remove('btn-outline-warning');
|
|
860
|
+
button.classList.add('btn-outline-primary');
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/**
|
|
865
|
+
* Show toast notification
|
|
866
|
+
*/
|
|
867
|
+
function showToast(message, type = 'info') {
|
|
868
|
+
// Use Bootstrap toast if available, otherwise fallback to alert
|
|
869
|
+
const toastContainer = document.getElementById('toastContainer');
|
|
870
|
+
if (!toastContainer) {
|
|
871
|
+
console.log(`Toast (${type}): ${message}`);
|
|
872
|
+
return;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
const toastId = `toast-${Date.now()}`;
|
|
876
|
+
const bgClass = type === 'success' ? 'bg-success' :
|
|
877
|
+
type === 'error' ? 'bg-danger' :
|
|
878
|
+
type === 'warning' ? 'bg-warning' : 'bg-info';
|
|
879
|
+
|
|
880
|
+
const toastHtml = `
|
|
881
|
+
<div id="${toastId}" class="toast align-items-center text-white ${bgClass} border-0" role="alert" aria-live="assertive" aria-atomic="true">
|
|
882
|
+
<div class="d-flex">
|
|
883
|
+
<div class="toast-body">${message}</div>
|
|
884
|
+
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
|
|
885
|
+
</div>
|
|
886
|
+
</div>
|
|
887
|
+
`;
|
|
888
|
+
|
|
889
|
+
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
|
890
|
+
const toastElement = document.getElementById(toastId);
|
|
891
|
+
const toast = new bootstrap.Toast(toastElement, { delay: 3000 });
|
|
892
|
+
toast.show();
|
|
893
|
+
|
|
894
|
+
// Remove from DOM after hidden
|
|
895
|
+
toastElement.addEventListener('hidden.bs.toast', () => {
|
|
896
|
+
toastElement.remove();
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
|
|
582
900
|
/**
|
|
583
901
|
* Initialize filter configuration on page load
|
|
584
902
|
*/
|
|
@@ -587,6 +905,28 @@ export async function initFilterConfig() {
|
|
|
587
905
|
await loadFilterConfig();
|
|
588
906
|
}
|
|
589
907
|
|
|
908
|
+
/**
|
|
909
|
+
* Update the enabled filters display on the dashboard.
|
|
910
|
+
* @param {Object} filters - Filter configuration object
|
|
911
|
+
*/
|
|
912
|
+
function updateEnabledFiltersDisplay(filters) {
|
|
913
|
+
const filtersEl = document.getElementById('enabledFilters');
|
|
914
|
+
if (!filtersEl) return;
|
|
915
|
+
|
|
916
|
+
const enabledFilters = Object.values(filters)
|
|
917
|
+
.filter(filter => filter.enabled !== false)
|
|
918
|
+
.map(filter => filter.name);
|
|
919
|
+
|
|
920
|
+
if (enabledFilters.length > 0) {
|
|
921
|
+
filtersEl.innerHTML = enabledFilters.map(filterName => {
|
|
922
|
+
const color = getFilterColor(filterName);
|
|
923
|
+
return `<span class="badge me-1" style="background-color: ${color}; color: white;">${filterName}</span>`;
|
|
924
|
+
}).join('');
|
|
925
|
+
} else {
|
|
926
|
+
filtersEl.textContent = '-';
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
590
930
|
/**
|
|
591
931
|
* Setup autofocus button event listener (call once during init)
|
|
592
932
|
*/
|