citrascope 0.8.0__py3-none-any.whl → 0.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,59 +1,55 @@
1
1
  // Configuration management for CitraScope
2
2
 
3
- import { getConfig, saveConfig, getConfigStatus, getHardwareAdapters, getAdapterSchema } from './api.js';
3
+ import { getConfig, saveConfig, getConfigStatus, getHardwareAdapters } from './api.js';
4
4
  import { getFilterColor } from './filters.js';
5
5
 
6
+ function updateStoreEnabledFilters(filters) {
7
+ if (typeof Alpine !== 'undefined' && Alpine.store) {
8
+ const enabled = Object.values(filters || {})
9
+ .filter(f => f.enabled !== false)
10
+ .map(f => ({ name: f.name, color: getFilterColor(f.name) }));
11
+ Alpine.store('citrascope').enabledFilters = enabled;
12
+ }
13
+ }
14
+
6
15
  // API Host constants - must match backend constants in app.py
7
16
  const PROD_API_HOST = 'api.citra.space';
8
17
  const DEV_API_HOST = 'dev.api.citra.space';
9
18
  const DEFAULT_API_PORT = 443;
10
19
 
11
- let currentAdapterSchema = [];
12
- export let currentConfig = {};
13
- let savedAdapter = null; // Track the currently saved adapter
20
+ /**
21
+ * Handle adapter selection change (called from Alpine store)
22
+ */
23
+ async function handleAdapterChange(adapter) {
24
+ const store = Alpine.store('citrascope');
25
+ if (adapter) {
26
+ const allAdapterSettings = store.config.adapter_settings || {};
27
+ const newAdapterSettings = allAdapterSettings[adapter] || {};
28
+
29
+ console.log(`Switching to adapter: ${adapter}`);
30
+ console.log('All adapter settings:', allAdapterSettings);
31
+ console.log('Settings for this adapter:', newAdapterSettings);
32
+
33
+ await loadAdapterSchema(adapter, newAdapterSettings);
34
+ await loadFilterConfig();
35
+ } else {
36
+ store.adapterFields = [];
37
+ store.filterConfigVisible = false;
38
+ }
39
+ }
14
40
 
15
41
  export async function initConfig() {
42
+ // Attach config methods to Alpine store
43
+ const store = Alpine.store('citrascope');
44
+ store.handleAdapterChange = handleAdapterChange;
45
+ store.reloadAdapterSchema = reloadAdapterSchema;
46
+
16
47
  // Populate hardware adapter dropdown
17
48
  await loadAdapterOptions();
18
49
 
19
- // Hardware adapter selection change
20
- const adapterSelect = document.getElementById('hardwareAdapterSelect');
21
- if (adapterSelect) {
22
- adapterSelect.addEventListener('change', async (e) => {
23
- const adapter = e.target.value;
24
- if (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();
31
- } else {
32
- document.getElementById('adapter-settings-container').innerHTML = '';
33
- const filterSection = document.getElementById('filterConfigSection');
34
- if (filterSection) filterSection.style.display = 'none';
35
- }
36
- });
37
- }
38
-
39
- // API endpoint selection change
40
- const apiEndpointSelect = document.getElementById('apiEndpoint');
41
- if (apiEndpointSelect) {
42
- apiEndpointSelect.addEventListener('change', (e) => {
43
- const customContainer = document.getElementById('customHostContainer');
44
- if (e.target.value === 'custom') {
45
- customContainer.style.display = 'block';
46
- } else {
47
- customContainer.style.display = 'none';
48
- }
49
- });
50
- }
51
-
52
- // Config form submission
53
- const configForm = document.getElementById('configForm');
54
- if (configForm) {
55
- configForm.addEventListener('submit', saveConfiguration);
56
- }
50
+ // Config form submission handled by Alpine @submit.prevent in template
51
+ // Expose saveConfiguration for template access
52
+ window.saveConfiguration = saveConfiguration;
57
53
 
58
54
  // Load initial config
59
55
  await loadConfiguration();
@@ -71,9 +67,9 @@ async function checkConfigStatus() {
71
67
  // Show setup wizard if not configured
72
68
  const wizardModal = new bootstrap.Modal(document.getElementById('setupWizard'));
73
69
  wizardModal.show();
74
- }
75
-
76
- if (status.error) {
70
+ } else if (status.error) {
71
+ // Only show error toast if configured but there's an error (e.g., connection issue)
72
+ // Don't show toast for "not configured" since modal already handles that
77
73
  showConfigError(status.error);
78
74
  }
79
75
  } catch (error) {
@@ -87,21 +83,13 @@ async function checkConfigStatus() {
87
83
  async function loadAdapterOptions() {
88
84
  try {
89
85
  const data = await getHardwareAdapters();
90
- const adapterSelect = document.getElementById('hardwareAdapterSelect');
91
86
 
92
- if (adapterSelect && data.adapters) {
93
- // Clear existing options except the first placeholder
94
- while (adapterSelect.options.length > 1) {
95
- adapterSelect.remove(1);
96
- }
97
-
98
- // Add options from API
99
- data.adapters.forEach(adapterName => {
100
- const option = document.createElement('option');
101
- option.value = adapterName;
102
- option.textContent = data.descriptions[adapterName] || adapterName;
103
- adapterSelect.appendChild(option);
104
- });
87
+ if (data.adapters && typeof Alpine !== 'undefined' && Alpine.store) {
88
+ const options = data.adapters.map(adapterName => ({
89
+ value: adapterName,
90
+ label: data.descriptions[adapterName] || adapterName
91
+ }));
92
+ Alpine.store('citrascope').hardwareAdapters = options;
105
93
  }
106
94
  } catch (error) {
107
95
  console.error('Failed to load hardware adapters:', error);
@@ -114,85 +102,34 @@ async function loadAdapterOptions() {
114
102
  async function loadConfiguration() {
115
103
  try {
116
104
  const config = await getConfig();
117
- currentConfig = config; // Save for reuse when saving
118
- savedAdapter = config.hardware_adapter; // Track saved adapter
119
105
 
120
- // Display config file path
121
- const configPathElement = document.getElementById('configFilePath');
122
- if (configPathElement && config.config_file_path) {
123
- configPathElement.textContent = config.config_file_path;
124
- }
106
+ // Sync to Alpine store - x-model handles form population
107
+ if (typeof Alpine !== 'undefined' && Alpine.store) {
108
+ const store = Alpine.store('citrascope');
125
109
 
126
- // Display log file path
127
- const logPathElement = document.getElementById('logFilePath');
128
- if (logPathElement) {
129
- if (config.log_file_path) {
130
- logPathElement.textContent = config.log_file_path;
131
- } else {
132
- logPathElement.textContent = 'Disabled';
110
+ // Normalize boolean fields that may come as null from backend
111
+ if (config.file_logging_enabled === null || config.file_logging_enabled === undefined) {
112
+ config.file_logging_enabled = true; // Default to true
113
+ }
114
+ if (config.keep_images === null || config.keep_images === undefined) {
115
+ config.keep_images = false; // Default to false
116
+ }
117
+ if (config.scheduled_autofocus_enabled === null || config.scheduled_autofocus_enabled === undefined) {
118
+ config.scheduled_autofocus_enabled = false; // Default to false
133
119
  }
134
- }
135
-
136
- // Display images directory path
137
- const imagesDirElement = document.getElementById('imagesDirPath');
138
- if (imagesDirElement && config.images_dir_path) {
139
- imagesDirElement.textContent = config.images_dir_path;
140
- }
141
-
142
- // API endpoint selector
143
- const apiEndpointSelect = document.getElementById('apiEndpoint');
144
- const customHostContainer = document.getElementById('customHostContainer');
145
- const customHost = document.getElementById('customHost');
146
- const customPort = document.getElementById('customPort');
147
- const customUseSsl = document.getElementById('customUseSsl');
148
-
149
- if (config.host === PROD_API_HOST) {
150
- apiEndpointSelect.value = 'production';
151
- customHostContainer.style.display = 'none';
152
- } else if (config.host === DEV_API_HOST) {
153
- apiEndpointSelect.value = 'development';
154
- customHostContainer.style.display = 'none';
155
- } else {
156
- apiEndpointSelect.value = 'custom';
157
- customHostContainer.style.display = 'block';
158
- customHost.value = config.host || '';
159
- customPort.value = config.port || DEFAULT_API_PORT;
160
- customUseSsl.checked = config.use_ssl !== undefined ? config.use_ssl : true;
161
- }
162
-
163
- // Core fields
164
- document.getElementById('personal_access_token').value = config.personal_access_token || '';
165
- document.getElementById('telescopeId').value = config.telescope_id || '';
166
- document.getElementById('hardwareAdapterSelect').value = config.hardware_adapter || '';
167
- document.getElementById('logLevel').value = config.log_level || 'INFO';
168
- document.getElementById('keep_images').checked = config.keep_images || false;
169
- document.getElementById('file_logging_enabled').checked = config.file_logging_enabled !== undefined ? config.file_logging_enabled : true;
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
120
 
188
- // Load adapter-specific settings if adapter is selected
189
- if (config.hardware_adapter) {
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);
121
+ store.config = config;
122
+ store.savedAdapter = config.hardware_adapter; // Sync savedAdapter to store
123
+ store.apiEndpoint =
124
+ config.host === PROD_API_HOST ? 'production' :
125
+ config.host === DEV_API_HOST ? 'development' : 'custom';
126
+
127
+ // Load adapter-specific settings if adapter is selected
128
+ if (config.hardware_adapter) {
129
+ const allAdapterSettings = config.adapter_settings || {};
130
+ const currentAdapterSettings = allAdapterSettings[config.hardware_adapter] || {};
131
+ await loadAdapterSchema(config.hardware_adapter, currentAdapterSettings);
132
+ }
196
133
  }
197
134
  } catch (error) {
198
135
  console.error('Failed to load config:', error);
@@ -200,7 +137,16 @@ async function loadConfiguration() {
200
137
  }
201
138
 
202
139
  /**
203
- * Load adapter schema and render settings form
140
+ * Get default value for a field type
141
+ */
142
+ function getDefaultForType(type) {
143
+ if (type === 'bool') return false;
144
+ if (type === 'int' || type === 'float') return 0;
145
+ return '';
146
+ }
147
+
148
+ /**
149
+ * Load adapter schema and merge with current values
204
150
  */
205
151
  async function loadAdapterSchema(adapterName, currentSettings = {}) {
206
152
  try {
@@ -209,190 +155,35 @@ async function loadAdapterSchema(adapterName, currentSettings = {}) {
209
155
  ? `?current_settings=${encodeURIComponent(JSON.stringify(currentSettings))}`
210
156
  : '';
211
157
 
158
+ console.log(`Loading schema for ${adapterName} with settings:`, currentSettings);
159
+
212
160
  const response = await fetch(`/api/hardware-adapters/${adapterName}/schema${settingsParam}`);
213
161
  const data = await response.json();
214
162
 
215
- currentAdapterSchema = data.schema || [];
216
- renderAdapterSettings(currentAdapterSchema);
217
- } catch (error) {
218
- console.error('Failed to load adapter schema:', error);
219
- showConfigError(`Failed to load settings for ${adapterName}`);
220
- }
221
- }
222
-
223
- /**
224
- * Render adapter-specific settings form
225
- */
226
- function renderAdapterSettings(schema) {
227
- const container = document.getElementById('adapter-settings-container');
228
-
229
- if (!schema || schema.length === 0) {
230
- container.innerHTML = '';
231
- return;
232
- }
233
-
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;
259
-
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
- }
292
-
293
- if (description && field.type !== 'bool') {
294
- html += `<small class="text-muted">${description}</small>`;
295
- }
296
- html += '</div>';
297
- });
298
-
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();
163
+ console.log(`Schema API response:`, data);
316
164
 
317
- // Reload schema with new device type selection
318
- await loadAdapterSchema(adapterSelect.value, currentSettings);
165
+ const schema = data.schema || [];
319
166
 
320
- // Repopulate settings (preserves user's selections)
321
- populateAdapterSettings(currentSettings);
322
- });
323
- }
324
- });
325
- }
167
+ // Merge schema with values into enriched field objects
168
+ // Use Alpine.reactive to ensure nested properties are reactive
169
+ const enrichedFields = schema
170
+ .filter(field => !field.readonly) // Skip readonly fields
171
+ .map(field => Alpine.reactive({
172
+ ...field, // All schema properties (name, type, options, etc.)
173
+ value: currentSettings[field.name] ?? field.default ?? getDefaultForType(field.type)
174
+ }));
326
175
 
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';
343
- }
176
+ console.log('Loaded adapter fields:', enrichedFields.map(f => `${f.name}=${f.value} (${f.type})`));
344
177
 
345
- /**
346
- * Populate adapter settings with values
347
- */
348
- function populateAdapterSettings(adapterSettings) {
349
- Object.entries(adapterSettings).forEach(([key, value]) => {
350
- const input = document.getElementById(`adapter_${key}`);
351
- if (input) {
352
- if (input.type === 'checkbox') {
353
- input.checked = value;
354
- } else {
355
- input.value = value;
356
- }
178
+ // Update Alpine store with unified field objects
179
+ if (typeof Alpine !== 'undefined' && Alpine.store) {
180
+ const store = Alpine.store('citrascope');
181
+ store.adapterFields = enrichedFields;
357
182
  }
358
- });
359
- }
360
-
361
- /**
362
- * Collect adapter settings from form
363
- */
364
- function collectAdapterSettings() {
365
- const settings = {};
366
- const inputs = document.querySelectorAll('.adapter-setting');
367
-
368
- inputs.forEach(input => {
369
- const fieldName = input.dataset.field;
370
- const fieldType = input.dataset.type;
371
- let value;
372
-
373
- if (input.type === 'checkbox') {
374
- value = input.checked;
375
- } else {
376
- value = input.value;
377
- }
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
-
384
- // Type conversion
385
- if (fieldType === 'int') {
386
- value = parseInt(value, 10);
387
- } else if (fieldType === 'float') {
388
- value = parseFloat(value);
389
- }
390
- // bool type already handled above
391
-
392
- settings[fieldName] = value;
393
- });
394
-
395
- return settings;
183
+ } catch (error) {
184
+ console.error('Failed to load adapter schema:', error);
185
+ showConfigError(`Failed to load settings for ${adapterName}`);
186
+ }
396
187
  }
397
188
 
398
189
  /**
@@ -401,70 +192,71 @@ function collectAdapterSettings() {
401
192
  async function saveConfiguration(event) {
402
193
  event.preventDefault();
403
194
 
404
- const saveButton = document.getElementById('saveConfigButton');
405
- const buttonText = document.getElementById('saveButtonText');
406
- const spinner = document.getElementById('saveButtonSpinner');
407
-
408
- // Show loading state
409
- saveButton.disabled = true;
410
- spinner.style.display = 'inline-block';
411
- buttonText.textContent = 'Saving...';
412
-
413
195
  // Hide previous messages
414
196
  hideConfigMessages();
415
197
 
198
+ // Get config from store (x-model keeps it up to date)
199
+ const store = Alpine.store('citrascope');
200
+ store.isSavingConfig = true;
201
+ const formConfig = store.config;
202
+
416
203
  // Determine API host settings based on endpoint selection
417
- const apiEndpoint = document.getElementById('apiEndpoint').value;
418
204
  let host, port, use_ssl;
419
-
420
- if (apiEndpoint === 'production') {
205
+ if (store.apiEndpoint === 'production') {
421
206
  host = PROD_API_HOST;
422
207
  port = DEFAULT_API_PORT;
423
208
  use_ssl = true;
424
- } else if (apiEndpoint === 'development') {
209
+ } else if (store.apiEndpoint === 'development') {
425
210
  host = DEV_API_HOST;
426
211
  port = DEFAULT_API_PORT;
427
212
  use_ssl = true;
428
- } else { // custom
429
- host = document.getElementById('customHost').value;
430
- port = parseInt(document.getElementById('customPort').value, 10);
431
- use_ssl = document.getElementById('customUseSsl').checked;
213
+ } else {
214
+ host = formConfig.host || '';
215
+ port = formConfig.port || DEFAULT_API_PORT;
216
+ use_ssl = formConfig.use_ssl !== undefined ? formConfig.use_ssl : true;
432
217
  }
433
218
 
219
+ // Convert adapterFields back to flat settings object (for current adapter only)
220
+ const adapterSettings = {};
221
+ (store.adapterFields || []).forEach(field => {
222
+ // Include all fields with defined values (including 0, false, empty string)
223
+ if (field.value !== undefined && field.value !== null) {
224
+ adapterSettings[field.name] = field.value;
225
+ }
226
+ });
227
+
434
228
  const config = {
435
- personal_access_token: document.getElementById('personal_access_token').value,
436
- telescope_id: document.getElementById('telescopeId').value,
437
- hardware_adapter: document.getElementById('hardwareAdapterSelect').value,
438
- adapter_settings: collectAdapterSettings(),
439
- log_level: document.getElementById('logLevel').value,
440
- keep_images: document.getElementById('keep_images').checked,
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),
447
- // API settings from endpoint selector
448
- host: host,
449
- port: port,
450
- use_ssl: use_ssl,
451
- // Preserve other settings from loaded config
452
- max_task_retries: currentConfig.max_task_retries || 3,
453
- initial_retry_delay_seconds: currentConfig.initial_retry_delay_seconds || 30,
454
- max_retry_delay_seconds: currentConfig.max_retry_delay_seconds || 300,
455
- log_retention_days: currentConfig.log_retention_days || 30,
456
- last_autofocus_timestamp: currentConfig.last_autofocus_timestamp, // Preserve timestamp
229
+ personal_access_token: formConfig.personal_access_token || '',
230
+ telescope_id: formConfig.telescope_id || '',
231
+ hardware_adapter: formConfig.hardware_adapter || '',
232
+ adapter_settings: adapterSettings, // Send flat settings for current adapter
233
+ log_level: formConfig.log_level || 'INFO',
234
+ keep_images: formConfig.keep_images || false,
235
+ file_logging_enabled: formConfig.file_logging_enabled !== undefined ? formConfig.file_logging_enabled : true,
236
+ scheduled_autofocus_enabled: formConfig.scheduled_autofocus_enabled || false,
237
+ autofocus_interval_minutes: parseInt(formConfig.autofocus_interval_minutes || 60, 10),
238
+ time_check_interval_minutes: parseInt(formConfig.time_check_interval_minutes || 5, 10),
239
+ time_offset_pause_ms: parseFloat(formConfig.time_offset_pause_ms || 500),
240
+ host,
241
+ port,
242
+ use_ssl,
243
+ // Preserve other settings from Alpine store (the single source of truth)
244
+ max_task_retries: store.config.max_task_retries || 3,
245
+ initial_retry_delay_seconds: store.config.initial_retry_delay_seconds || 30,
246
+ max_retry_delay_seconds: store.config.max_retry_delay_seconds || 300,
247
+ log_retention_days: store.config.log_retention_days || 30,
248
+ last_autofocus_timestamp: store.config.last_autofocus_timestamp,
457
249
  };
458
250
 
459
251
  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;
252
+ // Validate filters BEFORE saving main config
253
+ const filters = store.filters || {};
254
+ const filterCount = Object.keys(filters).length;
255
+ if (filterCount > 0) {
256
+ const enabledCount = Object.values(filters).filter(f => f.enabled).length;
465
257
  if (enabledCount === 0) {
466
258
  showConfigMessage('At least one filter must be enabled', 'danger');
467
- return; // Exit early without saving anything
259
+ return;
468
260
  }
469
261
  }
470
262
 
@@ -472,7 +264,9 @@ async function saveConfiguration(event) {
472
264
 
473
265
  if (result.ok) {
474
266
  // Update saved adapter to match newly saved config
475
- savedAdapter = config.hardware_adapter;
267
+ if (typeof Alpine !== 'undefined' && Alpine.store) {
268
+ Alpine.store('citrascope').savedAdapter = config.hardware_adapter;
269
+ }
476
270
 
477
271
  // After config saved successfully, save any modified filter focus positions
478
272
  const filterResults = await saveModifiedFilters();
@@ -499,9 +293,7 @@ async function saveConfiguration(event) {
499
293
  showConfigError('Failed to save configuration: ' + error.message);
500
294
  } finally {
501
295
  // Reset button state
502
- saveButton.disabled = false;
503
- spinner.style.display = 'none';
504
- buttonText.textContent = 'Save Configuration';
296
+ store.isSavingConfig = false;
505
297
  }
506
298
  }
507
299
 
@@ -604,92 +396,53 @@ export function showConfigSection() {
604
396
  * Load and display filter configuration
605
397
  */
606
398
  async function loadFilterConfig() {
607
- const filterSection = document.getElementById('filterConfigSection');
608
- const changeMessage = document.getElementById('filterAdapterChangeMessage');
609
- const tableContainer = document.getElementById('filterTableContainer');
399
+ const store = Alpine.store('citrascope');
610
400
 
611
401
  // Check if selected adapter matches saved adapter
612
- const adapterSelect = document.getElementById('hardwareAdapterSelect');
613
- const selectedAdapter = adapterSelect ? adapterSelect.value : null;
402
+ const selectedAdapter = store.config.hardware_adapter;
614
403
 
615
- if (selectedAdapter && savedAdapter && selectedAdapter !== savedAdapter) {
404
+ if (selectedAdapter && store.savedAdapter && selectedAdapter !== store.savedAdapter) {
616
405
  // 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';
406
+ store.filterConfigVisible = true;
407
+ store.filterAdapterChangeMessageVisible = true;
620
408
  return;
621
409
  }
622
410
 
623
411
  // Hide message and show table when adapters match
624
- if (changeMessage) changeMessage.style.display = 'none';
625
- if (tableContainer) tableContainer.style.display = 'block';
412
+ store.filterAdapterChangeMessageVisible = false;
626
413
 
627
414
  try {
628
415
  const response = await fetch('/api/adapter/filters');
629
416
 
630
417
  if (response.status === 404 || response.status === 503) {
631
- // Adapter doesn't support filters or isn't available
632
- if (filterSection) filterSection.style.display = 'none';
418
+ store.filterConfigVisible = false;
633
419
  return;
634
420
  }
635
421
 
636
422
  const data = await response.json();
637
423
 
638
424
  if (response.ok && data.filters) {
639
- // Show the filter section
640
- if (filterSection) filterSection.style.display = 'block';
641
-
642
- // Update enabled filters display on dashboard
643
- updateEnabledFiltersDisplay(data.filters);
644
-
645
- // Populate filter table
646
- const tbody = document.getElementById('filterTableBody');
647
- const noFiltersMsg = document.getElementById('noFiltersMessage');
648
-
649
- if (tbody) {
650
- tbody.innerHTML = '';
651
- const filters = data.filters;
652
- const filterIds = Object.keys(filters).sort();
653
-
654
- if (filterIds.length === 0) {
655
- if (noFiltersMsg) noFiltersMsg.style.display = 'block';
656
- } else {
657
- if (noFiltersMsg) noFiltersMsg.style.display = 'none';
658
-
659
- filterIds.forEach(filterId => {
660
- const filter = filters[filterId];
661
- const isEnabled = filter.enabled !== undefined ? filter.enabled : true;
662
- const filterColor = getFilterColor(filter.name);
663
- const row = document.createElement('tr');
664
- row.innerHTML = `
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>
674
- <td>
675
- <input type="number"
676
- class="form-control form-control-sm filter-focus-input"
677
- data-filter-id="${filterId}"
678
- value="${filter.focus_position}"
679
- min="0"
680
- step="1">
681
- </td>
682
- `;
683
- tbody.appendChild(row);
684
- });
685
- }
686
- }
425
+ store.filterConfigVisible = true;
426
+
427
+ // Update enabled filters display on dashboard (Alpine store)
428
+ updateStoreEnabledFilters(data.filters);
429
+
430
+ // Add color to each filter and update store
431
+ const filtersWithColor = {};
432
+ Object.entries(data.filters).forEach(([id, filter]) => {
433
+ filtersWithColor[id] = {
434
+ ...filter,
435
+ color: getFilterColor(filter.name),
436
+ enabled: filter.enabled !== undefined ? filter.enabled : true
437
+ };
438
+ });
439
+ store.filters = filtersWithColor;
687
440
  } else {
688
- if (filterSection) filterSection.style.display = 'none';
441
+ store.filterConfigVisible = false;
689
442
  }
690
443
  } catch (error) {
691
444
  console.error('Error loading filter config:', error);
692
- if (filterSection) filterSection.style.display = 'none';
445
+ store.filterConfigVisible = false;
693
446
  }
694
447
  }
695
448
 
@@ -698,41 +451,33 @@ async function loadFilterConfig() {
698
451
  * Returns: Object with { success: number, failed: number }
699
452
  */
700
453
  async function saveModifiedFilters() {
701
- const inputs = document.querySelectorAll('.filter-focus-input');
702
- if (inputs.length === 0) return { success: 0, failed: 0 }; // No filters to save
454
+ const store = Alpine.store('citrascope');
455
+ const filters = store.filters || {};
456
+ const filterIds = Object.keys(filters);
457
+
458
+ if (filterIds.length === 0) return { success: 0, failed: 0 };
703
459
 
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;
460
+ // Validate at least one filter is enabled
461
+ const enabledCount = Object.values(filters).filter(f => f.enabled).length;
707
462
  if (enabledCount === 0) {
708
463
  showConfigMessage('At least one filter must be enabled', 'danger');
709
- return { success: 0, failed: inputs.length };
464
+ return { success: 0, failed: filterIds.length };
710
465
  }
711
466
 
712
- // Collect all filter updates into array
467
+ // Collect all filter updates from store
713
468
  const filterUpdates = [];
714
- for (const input of inputs) {
715
- const filterId = input.dataset.filterId;
716
- const focusPosition = parseInt(input.value);
717
-
718
- if (Number.isNaN(focusPosition) || focusPosition < 0) {
719
- continue; // Skip invalid entries
720
- }
721
-
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;
469
+ for (const [filterId, filter] of Object.entries(filters)) {
470
+ const focusPosition = parseInt(filter.focus_position);
471
+ if (Number.isNaN(focusPosition) || focusPosition < 0) continue;
725
472
 
726
473
  filterUpdates.push({
727
474
  filter_id: filterId,
728
475
  focus_position: focusPosition,
729
- enabled: enabled
476
+ enabled: filter.enabled !== undefined ? filter.enabled : true
730
477
  });
731
478
  }
732
479
 
733
- if (filterUpdates.length === 0) {
734
- return { success: 0, failed: 0 };
735
- }
480
+ if (filterUpdates.length === 0) return { success: 0, failed: 0 };
736
481
 
737
482
  // Send single batch update
738
483
  try {
@@ -783,14 +528,8 @@ async function saveModifiedFilters() {
783
528
  * Trigger or cancel autofocus routine
784
529
  */
785
530
  async function triggerAutofocus() {
786
- const button = document.getElementById('runAutofocusButton');
787
- const buttonText = document.getElementById('autofocusButtonText');
788
- const buttonSpinner = document.getElementById('autofocusButtonSpinner');
789
-
790
- if (!button || !buttonText || !buttonSpinner) return;
791
-
792
- // Check if this is a cancel action
793
- const isCancel = button.dataset.action === 'cancel';
531
+ const store = Alpine.store('citrascope');
532
+ const isCancel = store.status?.autofocus_requested;
794
533
 
795
534
  if (isCancel) {
796
535
  // Cancel autofocus
@@ -802,7 +541,6 @@ async function triggerAutofocus() {
802
541
 
803
542
  if (response.ok && data.success) {
804
543
  showToast('Autofocus cancelled', 'info');
805
- updateAutofocusButton(false);
806
544
  } else {
807
545
  showToast('Nothing to cancel', 'warning');
808
546
  }
@@ -814,8 +552,7 @@ async function triggerAutofocus() {
814
552
  }
815
553
 
816
554
  // Request autofocus
817
- button.disabled = true;
818
- buttonSpinner.style.display = 'inline-block';
555
+ store.isAutofocusing = true;
819
556
 
820
557
  try {
821
558
  const response = await fetch('/api/adapter/autofocus', {
@@ -826,7 +563,6 @@ async function triggerAutofocus() {
826
563
 
827
564
  if (response.ok) {
828
565
  showToast('Autofocus queued', 'success');
829
- updateAutofocusButton(true);
830
566
  } else {
831
567
  showToast(data.error || 'Autofocus request failed', 'error');
832
568
  }
@@ -834,30 +570,7 @@ async function triggerAutofocus() {
834
570
  console.error('Error triggering autofocus:', error);
835
571
  showToast('Failed to trigger autofocus', 'error');
836
572
  } finally {
837
- button.disabled = false;
838
- buttonSpinner.style.display = 'none';
839
- }
840
- }
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');
573
+ store.isAutofocusing = false;
861
574
  }
862
575
  }
863
576
 
@@ -906,36 +619,27 @@ export async function initFilterConfig() {
906
619
  }
907
620
 
908
621
  /**
909
- * Update the enabled filters display on the dashboard.
910
- * @param {Object} filters - Filter configuration object
622
+ * Setup autofocus button event listener (call once during init)
911
623
  */
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
- }
624
+ // Autofocus button now uses Alpine @click directive in template
625
+ // Expose triggerAutofocus for template access
626
+ export function setupAutofocusButton() {
627
+ window.triggerAutofocus = triggerAutofocus;
928
628
  }
929
629
 
930
630
  /**
931
- * Setup autofocus button event listener (call once during init)
631
+ * Reload adapter schema (called from Alpine components when device type changes)
932
632
  */
933
- export function setupAutofocusButton() {
934
- const autofocusBtn = document.getElementById('runAutofocusButton');
935
- if (autofocusBtn) {
936
- autofocusBtn.addEventListener('click', triggerAutofocus);
937
- }
938
- }
633
+ async function reloadAdapterSchema() {
634
+ const store = Alpine.store('citrascope');
635
+ const adapter = store.config.hardware_adapter;
636
+ if (!adapter) return;
637
+
638
+ // Convert current adapterFields back to flat settings object
639
+ const currentSettings = {};
640
+ (store.adapterFields || []).forEach(field => {
641
+ currentSettings[field.name] = field.value;
642
+ });
939
643
 
940
- // Make showConfigSection available globally for onclick handlers in HTML
941
- window.showConfigSection = showConfigSection;
644
+ await loadAdapterSchema(adapter, currentSettings);
645
+ }