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.
Files changed (45) hide show
  1. citrascope/api/abstract_api_client.py +14 -0
  2. citrascope/api/citra_api_client.py +41 -0
  3. citrascope/citra_scope_daemon.py +97 -38
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +144 -8
  5. citrascope/hardware/adapter_registry.py +10 -3
  6. citrascope/hardware/devices/__init__.py +17 -0
  7. citrascope/hardware/devices/abstract_hardware_device.py +79 -0
  8. citrascope/hardware/devices/camera/__init__.py +13 -0
  9. citrascope/hardware/devices/camera/abstract_camera.py +102 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +402 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +744 -0
  13. citrascope/hardware/devices/device_registry.py +273 -0
  14. citrascope/hardware/devices/filter_wheel/__init__.py +7 -0
  15. citrascope/hardware/devices/filter_wheel/abstract_filter_wheel.py +73 -0
  16. citrascope/hardware/devices/focuser/__init__.py +7 -0
  17. citrascope/hardware/devices/focuser/abstract_focuser.py +78 -0
  18. citrascope/hardware/devices/mount/__init__.py +7 -0
  19. citrascope/hardware/devices/mount/abstract_mount.py +115 -0
  20. citrascope/hardware/direct_hardware_adapter.py +787 -0
  21. citrascope/hardware/filter_sync.py +94 -0
  22. citrascope/hardware/indi_adapter.py +6 -2
  23. citrascope/hardware/kstars_dbus_adapter.py +67 -96
  24. citrascope/hardware/nina_adv_http_adapter.py +81 -64
  25. citrascope/hardware/nina_adv_http_survey_template.json +4 -4
  26. citrascope/settings/citrascope_settings.py +25 -0
  27. citrascope/tasks/runner.py +105 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +13 -0
  31. citrascope/time/time_health.py +96 -0
  32. citrascope/time/time_monitor.py +164 -0
  33. citrascope/time/time_sources.py +62 -0
  34. citrascope/web/app.py +274 -51
  35. citrascope/web/static/app.js +379 -36
  36. citrascope/web/static/config.js +448 -108
  37. citrascope/web/static/filters.js +55 -0
  38. citrascope/web/static/style.css +39 -0
  39. citrascope/web/templates/dashboard.html +176 -36
  40. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/METADATA +17 -1
  41. citrascope-0.8.0.dist-info/RECORD +62 -0
  42. citrascope-0.6.1.dist-info/RECORD +0 -41
  43. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/WHEEL +0 -0
  44. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/entry_points.txt +0 -0
  45. {citrascope-0.6.1.dist-info → citrascope-0.8.0.dist-info}/licenses/LICENSE +0 -0
@@ -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 function(e) {
22
+ adapterSelect.addEventListener('change', async (e) => {
21
23
  const adapter = e.target.value;
22
24
  if (adapter) {
23
- await loadAdapterSchema(adapter);
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', function(e) {
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
- await loadAdapterSchema(config.hardware_adapter);
164
- populateAdapterSettings(config.adapter_settings || {});
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
- const data = await getAdapterSchema(adapterName);
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
- let html = '<h5 class="mb-3">Adapter Settings</h5><div class="row g-3 mb-4">';
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
- schema.forEach(field => {
199
- const isRequired = field.required ? '<span class="text-danger">*</span>' : '';
200
- const placeholder = field.placeholder || '';
201
- const description = field.description || '';
202
- const displayName = field.friendly_name || field.name;
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
- html += '<div class="col-12 col-md-6">';
205
- html += `<label for="adapter_${field.name}" class="form-label">${displayName} ${isRequired}</label>`;
293
+ if (description && field.type !== 'bool') {
294
+ html += `<small class="text-muted">${description}</small>`;
295
+ }
296
+ html += '</div>';
297
+ });
206
298
 
207
- if (field.type === 'bool') {
208
- html += `<div class="form-check mt-2">`;
209
- html += `<input class="form-check-input adapter-setting" type="checkbox" id="adapter_${field.name}" data-field="${field.name}" data-type="${field.type}">`;
210
- html += `<label class="form-check-label" for="adapter_${field.name}">${description}</label>`;
211
- html += `</div>`;
212
- } else if (field.options && field.options.length > 0) {
213
- const displayName = field.friendly_name || field.name;
214
- html += `<select id="adapter_${field.name}" class="form-select adapter-setting" data-field="${field.name}" data-type="${field.type}" ${field.required ? 'required' : ''}>`;
215
- html += `<option value="">-- Select ${displayName} --</option>`;
216
- field.options.forEach(opt => {
217
- html += `<option value="${opt}">${opt}</option>`;
218
- });
219
- html += `</select>`;
220
- } else if (field.type === 'int' || field.type === 'float') {
221
- const min = field.min !== undefined ? `min="${field.min}"` : '';
222
- const max = field.max !== undefined ? `max="${field.max}"` : '';
223
- html += `<input type="number" id="adapter_${field.name}" class="form-control adapter-setting" `;
224
- html += `data-field="${field.name}" data-type="${field.type}" `;
225
- html += `placeholder="${placeholder}" ${min} ${max} ${field.required ? 'required' : ''}>`;
226
- } else {
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
- if (description && field.type !== 'bool') {
235
- html += `<small class="text-muted">${description}</small>`;
320
+ // Repopulate settings (preserves user's selections)
321
+ populateAdapterSettings(currentSettings);
322
+ });
236
323
  }
237
- html += '</div>';
238
324
  });
325
+ }
239
326
 
240
- html += '</div>';
241
- container.innerHTML = html;
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 (value !== '' && value !== null) {
280
- if (fieldType === 'int') {
281
- value = parseInt(value, 10);
282
- } else if (fieldType === 'float') {
283
- value = parseFloat(value);
284
- } else if (fieldType === 'bool') {
285
- // Already handled above
286
- }
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
- const errorDiv = document.getElementById('configError');
387
- errorDiv.textContent = message;
388
- errorDiv.style.display = 'block';
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
- const successDiv = document.getElementById('configSuccess');
396
- successDiv.textContent = message;
397
- successDiv.style.display = 'block';
398
-
399
- // Auto-hide after 5 seconds
400
- setTimeout(() => {
401
- successDiv.style.display = 'none';
402
- }, 5000);
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
- document.getElementById('configError').style.display = 'none';
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>${filterId}</td>
470
- <td>${filter.name}</td>
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
- let successCount = 0;
502
- let failedCount = 0;
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
- // Save all filter values
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
- failedCount++;
511
- continue;
718
+ if (Number.isNaN(focusPosition) || focusPosition < 0) {
719
+ continue; // Skip invalid entries
512
720
  }
513
721
 
514
- try {
515
- const response = await fetch(`/api/adapter/filters/${filterId}`, {
516
- method: 'PATCH',
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
- if (response.ok) {
524
- successCount++;
525
- } else {
526
- failedCount++;
527
- console.error(`Failed to save filter ${filterId}: HTTP ${response.status}`);
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
- } catch (error) {
530
- failedCount++;
531
- console.error(`Error saving filter ${filterId}:`, error);
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
- // Clear any previous messages
549
- hideConfigMessages();
792
+ // Check if this is a cancel action
793
+ const isCancel = button.dataset.action === 'cancel';
550
794
 
551
- // Disable button and show spinner
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
- showConfigSuccess('Autofocus completed successfully');
565
- // Reload filter config to show updated focus positions
566
- await loadFilterConfig();
828
+ showToast('Autofocus queued', 'success');
829
+ updateAutofocusButton(true);
567
830
  } else {
568
- // Show clear error message (e.g., if autofocus already running or other conflict)
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
- showConfigError('Failed to trigger autofocus');
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
  */