citrascope 0.7.0__py3-none-any.whl → 0.9.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 (51) 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 +75 -0
  4. citrascope/hardware/abstract_astro_hardware_adapter.py +97 -2
  5. citrascope/hardware/adapter_registry.py +15 -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 +114 -0
  10. citrascope/hardware/devices/camera/rpi_hq_camera.py +353 -0
  11. citrascope/hardware/devices/camera/usb_camera.py +407 -0
  12. citrascope/hardware/devices/camera/ximea_camera.py +756 -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 +805 -0
  21. citrascope/hardware/dummy_adapter.py +202 -0
  22. citrascope/hardware/filter_sync.py +94 -0
  23. citrascope/hardware/indi_adapter.py +6 -2
  24. citrascope/hardware/kstars_dbus_adapter.py +46 -37
  25. citrascope/hardware/nina_adv_http_adapter.py +13 -11
  26. citrascope/settings/citrascope_settings.py +6 -0
  27. citrascope/tasks/runner.py +2 -0
  28. citrascope/tasks/scope/static_telescope_task.py +17 -12
  29. citrascope/tasks/task.py +3 -0
  30. citrascope/time/__init__.py +14 -0
  31. citrascope/time/time_health.py +103 -0
  32. citrascope/time/time_monitor.py +186 -0
  33. citrascope/time/time_sources.py +261 -0
  34. citrascope/web/app.py +260 -60
  35. citrascope/web/static/app.js +121 -731
  36. citrascope/web/static/components.js +136 -0
  37. citrascope/web/static/config.js +259 -420
  38. citrascope/web/static/filters.js +55 -0
  39. citrascope/web/static/formatters.js +129 -0
  40. citrascope/web/static/store-init.js +204 -0
  41. citrascope/web/static/style.css +44 -0
  42. citrascope/web/templates/_config.html +175 -0
  43. citrascope/web/templates/_config_hardware.html +208 -0
  44. citrascope/web/templates/_monitoring.html +242 -0
  45. citrascope/web/templates/dashboard.html +109 -377
  46. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/METADATA +18 -1
  47. citrascope-0.9.0.dist-info/RECORD +69 -0
  48. citrascope-0.7.0.dist-info/RECORD +0 -41
  49. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/WHEEL +0 -0
  50. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/entry_points.txt +0 -0
  51. {citrascope-0.7.0.dist-info → citrascope-0.9.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,54 +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
+ import { getFilterColor } from './filters.js';
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
+ }
4
14
 
5
15
  // API Host constants - must match backend constants in app.py
6
16
  const PROD_API_HOST = 'api.citra.space';
7
17
  const DEV_API_HOST = 'dev.api.citra.space';
8
18
  const DEFAULT_API_PORT = 443;
9
19
 
10
- let currentAdapterSchema = [];
11
- export let currentConfig = {};
12
- 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
+ }
13
40
 
14
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
+
15
47
  // Populate hardware adapter dropdown
16
48
  await loadAdapterOptions();
17
49
 
18
- // Hardware adapter selection change
19
- const adapterSelect = document.getElementById('hardwareAdapterSelect');
20
- if (adapterSelect) {
21
- adapterSelect.addEventListener('change', async function(e) {
22
- const adapter = e.target.value;
23
- if (adapter) {
24
- await loadAdapterSchema(adapter);
25
- await loadFilterConfig();
26
- } else {
27
- document.getElementById('adapter-settings-container').innerHTML = '';
28
- const filterSection = document.getElementById('filterConfigSection');
29
- if (filterSection) filterSection.style.display = 'none';
30
- }
31
- });
32
- }
33
-
34
- // API endpoint selection change
35
- const apiEndpointSelect = document.getElementById('apiEndpoint');
36
- if (apiEndpointSelect) {
37
- apiEndpointSelect.addEventListener('change', function(e) {
38
- const customContainer = document.getElementById('customHostContainer');
39
- if (e.target.value === 'custom') {
40
- customContainer.style.display = 'block';
41
- } else {
42
- customContainer.style.display = 'none';
43
- }
44
- });
45
- }
46
-
47
- // Config form submission
48
- const configForm = document.getElementById('configForm');
49
- if (configForm) {
50
- configForm.addEventListener('submit', saveConfiguration);
51
- }
50
+ // Config form submission handled by Alpine @submit.prevent in template
51
+ // Expose saveConfiguration for template access
52
+ window.saveConfiguration = saveConfiguration;
52
53
 
53
54
  // Load initial config
54
55
  await loadConfiguration();
@@ -82,21 +83,13 @@ async function checkConfigStatus() {
82
83
  async function loadAdapterOptions() {
83
84
  try {
84
85
  const data = await getHardwareAdapters();
85
- const adapterSelect = document.getElementById('hardwareAdapterSelect');
86
-
87
- if (adapterSelect && data.adapters) {
88
- // Clear existing options except the first placeholder
89
- while (adapterSelect.options.length > 1) {
90
- adapterSelect.remove(1);
91
- }
92
86
 
93
- // Add options from API
94
- data.adapters.forEach(adapterName => {
95
- const option = document.createElement('option');
96
- option.value = adapterName;
97
- option.textContent = data.descriptions[adapterName] || adapterName;
98
- adapterSelect.appendChild(option);
99
- });
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;
100
93
  }
101
94
  } catch (error) {
102
95
  console.error('Failed to load hardware adapters:', error);
@@ -109,74 +102,34 @@ async function loadAdapterOptions() {
109
102
  async function loadConfiguration() {
110
103
  try {
111
104
  const config = await getConfig();
112
- currentConfig = config; // Save for reuse when saving
113
- savedAdapter = config.hardware_adapter; // Track saved adapter
114
105
 
115
- // Display config file path
116
- const configPathElement = document.getElementById('configFilePath');
117
- if (configPathElement && config.config_file_path) {
118
- configPathElement.textContent = config.config_file_path;
119
- }
106
+ // Sync to Alpine store - x-model handles form population
107
+ if (typeof Alpine !== 'undefined' && Alpine.store) {
108
+ const store = Alpine.store('citrascope');
120
109
 
121
- // Display log file path
122
- const logPathElement = document.getElementById('logFilePath');
123
- if (logPathElement) {
124
- if (config.log_file_path) {
125
- logPathElement.textContent = config.log_file_path;
126
- } else {
127
- 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
128
119
  }
129
- }
130
-
131
- // Display images directory path
132
- const imagesDirElement = document.getElementById('imagesDirPath');
133
- if (imagesDirElement && config.images_dir_path) {
134
- imagesDirElement.textContent = config.images_dir_path;
135
- }
136
-
137
- // API endpoint selector
138
- const apiEndpointSelect = document.getElementById('apiEndpoint');
139
- const customHostContainer = document.getElementById('customHostContainer');
140
- const customHost = document.getElementById('customHost');
141
- const customPort = document.getElementById('customPort');
142
- const customUseSsl = document.getElementById('customUseSsl');
143
-
144
- if (config.host === PROD_API_HOST) {
145
- apiEndpointSelect.value = 'production';
146
- customHostContainer.style.display = 'none';
147
- } else if (config.host === DEV_API_HOST) {
148
- apiEndpointSelect.value = 'development';
149
- customHostContainer.style.display = 'none';
150
- } else {
151
- apiEndpointSelect.value = 'custom';
152
- customHostContainer.style.display = 'block';
153
- customHost.value = config.host || '';
154
- customPort.value = config.port || DEFAULT_API_PORT;
155
- customUseSsl.checked = config.use_ssl !== undefined ? config.use_ssl : true;
156
- }
157
-
158
- // Core fields
159
- document.getElementById('personal_access_token').value = config.personal_access_token || '';
160
- document.getElementById('telescopeId').value = config.telescope_id || '';
161
- document.getElementById('hardwareAdapterSelect').value = config.hardware_adapter || '';
162
- document.getElementById('logLevel').value = config.log_level || 'INFO';
163
- document.getElementById('keep_images').checked = config.keep_images || false;
164
- document.getElementById('file_logging_enabled').checked = config.file_logging_enabled !== undefined ? config.file_logging_enabled : true;
165
-
166
- // Load autofocus settings (top-level)
167
- const scheduledAutofocusEnabled = document.getElementById('scheduled_autofocus_enabled');
168
- const autofocusInterval = document.getElementById('autofocus_interval_minutes');
169
- if (scheduledAutofocusEnabled) {
170
- scheduledAutofocusEnabled.checked = config.scheduled_autofocus_enabled || false;
171
- }
172
- if (autofocusInterval) {
173
- autofocusInterval.value = config.autofocus_interval_minutes || 60;
174
- }
175
120
 
176
- // Load adapter-specific settings if adapter is selected
177
- if (config.hardware_adapter) {
178
- await loadAdapterSchema(config.hardware_adapter);
179
- populateAdapterSettings(config.adapter_settings || {});
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
+ }
180
133
  }
181
134
  } catch (error) {
182
135
  console.error('Failed to load config:', error);
@@ -184,134 +137,53 @@ async function loadConfiguration() {
184
137
  }
185
138
 
186
139
  /**
187
- * Load adapter schema and render settings form
140
+ * Get default value for a field type
188
141
  */
189
- async function loadAdapterSchema(adapterName) {
190
- try {
191
- const data = await getAdapterSchema(adapterName);
192
- currentAdapterSchema = data.schema || [];
193
- renderAdapterSettings(currentAdapterSchema);
194
- } catch (error) {
195
- console.error('Failed to load adapter schema:', error);
196
- showConfigError(`Failed to load settings for ${adapterName}`);
197
- }
142
+ function getDefaultForType(type) {
143
+ if (type === 'bool') return false;
144
+ if (type === 'int' || type === 'float') return 0;
145
+ return '';
198
146
  }
199
147
 
200
148
  /**
201
- * Render adapter-specific settings form
149
+ * Load adapter schema and merge with current values
202
150
  */
203
- function renderAdapterSettings(schema) {
204
- const container = document.getElementById('adapter-settings-container');
205
-
206
- if (!schema || schema.length === 0) {
207
- container.innerHTML = '';
208
- return;
209
- }
210
-
211
- let html = '<h5 class="mb-3">Adapter Settings</h5><div class="row g-3 mb-4">';
212
-
213
- schema.forEach(field => {
214
- // Skip readonly fields (handled elsewhere in UI)
215
- if (field.readonly) {
216
- return;
217
- }
218
-
219
- const isRequired = field.required ? '<span class="text-danger">*</span>' : '';
220
- const placeholder = field.placeholder || '';
221
- const description = field.description || '';
222
- const displayName = field.friendly_name || field.name;
223
-
224
- html += '<div class="col-12 col-md-6">';
225
- html += `<label for="adapter_${field.name}" class="form-label">${displayName} ${isRequired}</label>`;
226
-
227
- if (field.type === 'bool') {
228
- html += `<div class="form-check mt-2">`;
229
- html += `<input class="form-check-input adapter-setting" type="checkbox" id="adapter_${field.name}" data-field="${field.name}" data-type="${field.type}">`;
230
- html += `<label class="form-check-label" for="adapter_${field.name}">${description}</label>`;
231
- html += `</div>`;
232
- } else if (field.options && field.options.length > 0) {
233
- const displayName = field.friendly_name || field.name;
234
- html += `<select id="adapter_${field.name}" class="form-select adapter-setting" data-field="${field.name}" data-type="${field.type}" ${field.required ? 'required' : ''}>`;
235
- html += `<option value="">-- Select ${displayName} --</option>`;
236
- field.options.forEach(opt => {
237
- html += `<option value="${opt}">${opt}</option>`;
238
- });
239
- html += `</select>`;
240
- } else if (field.type === 'int' || field.type === 'float') {
241
- const min = field.min !== undefined ? `min="${field.min}"` : '';
242
- const max = field.max !== undefined ? `max="${field.max}"` : '';
243
- html += `<input type="number" id="adapter_${field.name}" class="form-control adapter-setting" `;
244
- html += `data-field="${field.name}" data-type="${field.type}" `;
245
- html += `placeholder="${placeholder}" ${min} ${max} ${field.required ? 'required' : ''}>`;
246
- } else {
247
- // Default to text input
248
- const pattern = field.pattern ? `pattern="${field.pattern}"` : '';
249
- html += `<input type="text" id="adapter_${field.name}" class="form-control adapter-setting" `;
250
- html += `data-field="${field.name}" data-type="${field.type}" `;
251
- html += `placeholder="${placeholder}" ${pattern} ${field.required ? 'required' : ''}>`;
252
- }
253
-
254
- if (description && field.type !== 'bool') {
255
- html += `<small class="text-muted">${description}</small>`;
256
- }
257
- html += '</div>';
258
- });
151
+ async function loadAdapterSchema(adapterName, currentSettings = {}) {
152
+ try {
153
+ // Pass current adapter settings for dynamic schema generation
154
+ const settingsParam = Object.keys(currentSettings).length > 0
155
+ ? `?current_settings=${encodeURIComponent(JSON.stringify(currentSettings))}`
156
+ : '';
259
157
 
260
- html += '</div>';
261
- container.innerHTML = html;
262
- }
158
+ console.log(`Loading schema for ${adapterName} with settings:`, currentSettings);
263
159
 
264
- /**
265
- * Populate adapter settings with values
266
- */
267
- function populateAdapterSettings(adapterSettings) {
268
- Object.entries(adapterSettings).forEach(([key, value]) => {
269
- const input = document.getElementById(`adapter_${key}`);
270
- if (input) {
271
- if (input.type === 'checkbox') {
272
- input.checked = value;
273
- } else {
274
- input.value = value;
275
- }
276
- }
277
- });
278
- }
160
+ const response = await fetch(`/api/hardware-adapters/${adapterName}/schema${settingsParam}`);
161
+ const data = await response.json();
279
162
 
280
- /**
281
- * Collect adapter settings from form
282
- */
283
- function collectAdapterSettings() {
284
- const settings = {};
285
- const inputs = document.querySelectorAll('.adapter-setting');
163
+ console.log(`Schema API response:`, data);
286
164
 
287
- inputs.forEach(input => {
288
- const fieldName = input.dataset.field;
289
- const fieldType = input.dataset.type;
290
- let value;
165
+ const schema = data.schema || [];
291
166
 
292
- if (input.type === 'checkbox') {
293
- value = input.checked;
294
- } else {
295
- value = input.value;
296
- }
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
+ }));
297
175
 
298
- // Skip empty values for non-checkbox fields (will use backend defaults)
299
- if (input.type !== 'checkbox' && (value === '' || value === null || value === undefined)) {
300
- return;
301
- }
176
+ console.log('Loaded adapter fields:', enrichedFields.map(f => `${f.name}=${f.value} (${f.type})`));
302
177
 
303
- // Type conversion
304
- if (fieldType === 'int') {
305
- value = parseInt(value, 10);
306
- } else if (fieldType === 'float') {
307
- value = parseFloat(value);
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;
308
182
  }
309
- // bool type already handled above
310
-
311
- settings[fieldName] = value;
312
- });
313
-
314
- return settings;
183
+ } catch (error) {
184
+ console.error('Failed to load adapter schema:', error);
185
+ showConfigError(`Failed to load settings for ${adapterName}`);
186
+ }
315
187
  }
316
188
 
317
189
  /**
@@ -320,68 +192,71 @@ function collectAdapterSettings() {
320
192
  async function saveConfiguration(event) {
321
193
  event.preventDefault();
322
194
 
323
- const saveButton = document.getElementById('saveConfigButton');
324
- const buttonText = document.getElementById('saveButtonText');
325
- const spinner = document.getElementById('saveButtonSpinner');
326
-
327
- // Show loading state
328
- saveButton.disabled = true;
329
- spinner.style.display = 'inline-block';
330
- buttonText.textContent = 'Saving...';
331
-
332
195
  // Hide previous messages
333
196
  hideConfigMessages();
334
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
+
335
203
  // Determine API host settings based on endpoint selection
336
- const apiEndpoint = document.getElementById('apiEndpoint').value;
337
204
  let host, port, use_ssl;
338
-
339
- if (apiEndpoint === 'production') {
205
+ if (store.apiEndpoint === 'production') {
340
206
  host = PROD_API_HOST;
341
207
  port = DEFAULT_API_PORT;
342
208
  use_ssl = true;
343
- } else if (apiEndpoint === 'development') {
209
+ } else if (store.apiEndpoint === 'development') {
344
210
  host = DEV_API_HOST;
345
211
  port = DEFAULT_API_PORT;
346
212
  use_ssl = true;
347
- } else { // custom
348
- host = document.getElementById('customHost').value;
349
- port = parseInt(document.getElementById('customPort').value, 10);
350
- 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;
351
217
  }
352
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
+
353
228
  const config = {
354
- personal_access_token: document.getElementById('personal_access_token').value,
355
- telescope_id: document.getElementById('telescopeId').value,
356
- hardware_adapter: document.getElementById('hardwareAdapterSelect').value,
357
- adapter_settings: collectAdapterSettings(),
358
- log_level: document.getElementById('logLevel').value,
359
- keep_images: document.getElementById('keep_images').checked,
360
- file_logging_enabled: document.getElementById('file_logging_enabled').checked,
361
- // Autofocus settings (top-level)
362
- scheduled_autofocus_enabled: document.getElementById('scheduled_autofocus_enabled')?.checked || false,
363
- autofocus_interval_minutes: parseInt(document.getElementById('autofocus_interval_minutes')?.value || 60, 10),
364
- // API settings from endpoint selector
365
- host: host,
366
- port: port,
367
- use_ssl: use_ssl,
368
- // Preserve other settings from loaded config
369
- max_task_retries: currentConfig.max_task_retries || 3,
370
- initial_retry_delay_seconds: currentConfig.initial_retry_delay_seconds || 30,
371
- max_retry_delay_seconds: currentConfig.max_retry_delay_seconds || 300,
372
- log_retention_days: currentConfig.log_retention_days || 30,
373
- 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,
374
249
  };
375
250
 
376
251
  try {
377
- // Validate filters BEFORE saving main config (belt and suspenders)
378
- const inputs = document.querySelectorAll('.filter-focus-input');
379
- if (inputs.length > 0) {
380
- const checkboxes = document.querySelectorAll('.filter-enabled-checkbox');
381
- 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;
382
257
  if (enabledCount === 0) {
383
258
  showConfigMessage('At least one filter must be enabled', 'danger');
384
- return; // Exit early without saving anything
259
+ return;
385
260
  }
386
261
  }
387
262
 
@@ -389,7 +264,9 @@ async function saveConfiguration(event) {
389
264
 
390
265
  if (result.ok) {
391
266
  // Update saved adapter to match newly saved config
392
- savedAdapter = config.hardware_adapter;
267
+ if (typeof Alpine !== 'undefined' && Alpine.store) {
268
+ Alpine.store('citrascope').savedAdapter = config.hardware_adapter;
269
+ }
393
270
 
394
271
  // After config saved successfully, save any modified filter focus positions
395
272
  const filterResults = await saveModifiedFilters();
@@ -416,9 +293,7 @@ async function saveConfiguration(event) {
416
293
  showConfigError('Failed to save configuration: ' + error.message);
417
294
  } finally {
418
295
  // Reset button state
419
- saveButton.disabled = false;
420
- spinner.style.display = 'none';
421
- buttonText.textContent = 'Save Configuration';
296
+ store.isSavingConfig = false;
422
297
  }
423
298
  }
424
299
 
@@ -428,7 +303,7 @@ async function saveConfiguration(event) {
428
303
  * @param {string} type - 'danger' for errors, 'success' for success messages
429
304
  * @param {boolean} autohide - Whether to auto-hide the toast
430
305
  */
431
- function createToast(message, type = 'danger', autohide = false) {
306
+ export function createToast(message, type = 'danger', autohide = false) {
432
307
  const toastContainer = document.getElementById('toastContainer');
433
308
  if (!toastContainer) {
434
309
  console.error('Toast container not found');
@@ -521,86 +396,53 @@ export function showConfigSection() {
521
396
  * Load and display filter configuration
522
397
  */
523
398
  async function loadFilterConfig() {
524
- const filterSection = document.getElementById('filterConfigSection');
525
- const changeMessage = document.getElementById('filterAdapterChangeMessage');
526
- const tableContainer = document.getElementById('filterTableContainer');
399
+ const store = Alpine.store('citrascope');
527
400
 
528
401
  // Check if selected adapter matches saved adapter
529
- const adapterSelect = document.getElementById('hardwareAdapterSelect');
530
- const selectedAdapter = adapterSelect ? adapterSelect.value : null;
402
+ const selectedAdapter = store.config.hardware_adapter;
531
403
 
532
- if (selectedAdapter && savedAdapter && selectedAdapter !== savedAdapter) {
404
+ if (selectedAdapter && store.savedAdapter && selectedAdapter !== store.savedAdapter) {
533
405
  // Adapter has changed but not saved yet - show message and hide table
534
- if (filterSection) filterSection.style.display = 'block';
535
- if (changeMessage) changeMessage.style.display = 'block';
536
- if (tableContainer) tableContainer.style.display = 'none';
406
+ store.filterConfigVisible = true;
407
+ store.filterAdapterChangeMessageVisible = true;
537
408
  return;
538
409
  }
539
410
 
540
411
  // Hide message and show table when adapters match
541
- if (changeMessage) changeMessage.style.display = 'none';
542
- if (tableContainer) tableContainer.style.display = 'block';
412
+ store.filterAdapterChangeMessageVisible = false;
543
413
 
544
414
  try {
545
415
  const response = await fetch('/api/adapter/filters');
546
416
 
547
417
  if (response.status === 404 || response.status === 503) {
548
- // Adapter doesn't support filters or isn't available
549
- if (filterSection) filterSection.style.display = 'none';
418
+ store.filterConfigVisible = false;
550
419
  return;
551
420
  }
552
421
 
553
422
  const data = await response.json();
554
423
 
555
424
  if (response.ok && data.filters) {
556
- // Show the filter section
557
- if (filterSection) filterSection.style.display = 'block';
558
-
559
- // Populate filter table
560
- const tbody = document.getElementById('filterTableBody');
561
- const noFiltersMsg = document.getElementById('noFiltersMessage');
562
-
563
- if (tbody) {
564
- tbody.innerHTML = '';
565
- const filters = data.filters;
566
- const filterIds = Object.keys(filters).sort();
567
-
568
- if (filterIds.length === 0) {
569
- if (noFiltersMsg) noFiltersMsg.style.display = 'block';
570
- } else {
571
- if (noFiltersMsg) noFiltersMsg.style.display = 'none';
572
-
573
- filterIds.forEach(filterId => {
574
- const filter = filters[filterId];
575
- const isEnabled = filter.enabled !== undefined ? filter.enabled : true;
576
- const row = document.createElement('tr');
577
- row.innerHTML = `
578
- <td>
579
- <input type="checkbox"
580
- class="form-check-input filter-enabled-checkbox"
581
- data-filter-id="${filterId}"
582
- ${isEnabled ? 'checked' : ''}>
583
- </td>
584
- <td>${filter.name}</td>
585
- <td>
586
- <input type="number"
587
- class="form-control form-control-sm filter-focus-input"
588
- data-filter-id="${filterId}"
589
- value="${filter.focus_position}"
590
- min="0"
591
- step="1">
592
- </td>
593
- `;
594
- tbody.appendChild(row);
595
- });
596
- }
597
- }
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;
598
440
  } else {
599
- if (filterSection) filterSection.style.display = 'none';
441
+ store.filterConfigVisible = false;
600
442
  }
601
443
  } catch (error) {
602
444
  console.error('Error loading filter config:', error);
603
- if (filterSection) filterSection.style.display = 'none';
445
+ store.filterConfigVisible = false;
604
446
  }
605
447
  }
606
448
 
@@ -609,75 +451,85 @@ async function loadFilterConfig() {
609
451
  * Returns: Object with { success: number, failed: number }
610
452
  */
611
453
  async function saveModifiedFilters() {
612
- const inputs = document.querySelectorAll('.filter-focus-input');
613
- 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);
614
457
 
615
- // Belt and suspenders: Validate at least one filter is enabled before saving
616
- const checkboxes = document.querySelectorAll('.filter-enabled-checkbox');
617
- const enabledCount = Array.from(checkboxes).filter(cb => cb.checked).length;
458
+ if (filterIds.length === 0) return { success: 0, failed: 0 };
459
+
460
+ // Validate at least one filter is enabled
461
+ const enabledCount = Object.values(filters).filter(f => f.enabled).length;
618
462
  if (enabledCount === 0) {
619
463
  showConfigMessage('At least one filter must be enabled', 'danger');
620
- return { success: 0, failed: inputs.length };
464
+ return { success: 0, failed: filterIds.length };
621
465
  }
622
466
 
623
- let successCount = 0;
624
- let failedCount = 0;
625
-
626
- // Save all filter values
627
- for (const input of inputs) {
628
- const filterId = input.dataset.filterId;
629
- const focusPosition = parseInt(input.value);
467
+ // Collect all filter updates from store
468
+ const filterUpdates = [];
469
+ for (const [filterId, filter] of Object.entries(filters)) {
470
+ const focusPosition = parseInt(filter.focus_position);
471
+ if (Number.isNaN(focusPosition) || focusPosition < 0) continue;
630
472
 
631
- if (isNaN(focusPosition) || focusPosition < 0) {
632
- failedCount++;
633
- continue;
634
- }
473
+ filterUpdates.push({
474
+ filter_id: filterId,
475
+ focus_position: focusPosition,
476
+ enabled: filter.enabled !== undefined ? filter.enabled : true
477
+ });
478
+ }
635
479
 
636
- // Get enabled state from corresponding checkbox
637
- const checkbox = document.querySelector(`.filter-enabled-checkbox[data-filter-id="${filterId}"]`);
638
- const enabled = checkbox ? checkbox.checked : true;
480
+ if (filterUpdates.length === 0) return { success: 0, failed: 0 };
639
481
 
640
- try {
641
- const response = await fetch(`/api/adapter/filters/${filterId}`, {
642
- method: 'PATCH',
643
- headers: {
644
- 'Content-Type': 'application/json'
645
- },
646
- body: JSON.stringify({ focus_position: focusPosition, enabled: enabled })
647
- });
482
+ // Send single batch update
483
+ try {
484
+ const response = await fetch('/api/adapter/filters/batch', {
485
+ method: 'POST',
486
+ headers: {
487
+ 'Content-Type': 'application/json'
488
+ },
489
+ body: JSON.stringify(filterUpdates)
490
+ });
648
491
 
649
- if (response.ok) {
650
- successCount++;
651
- } else {
652
- failedCount++;
653
- const data = await response.json();
654
- console.error(`Failed to save filter ${filterId}: ${data.error || 'Unknown error'}`);
655
- // If it's the "last enabled filter" error, show it to the user
656
- if (response.status === 400 && data.error && data.error.includes('last enabled filter')) {
657
- showConfigMessage(data.error, 'danger');
492
+ if (response.ok) {
493
+ const data = await response.json();
494
+ const successCount = data.updated_count || 0;
495
+
496
+ // After batch update, sync to backend
497
+ try {
498
+ const syncResponse = await fetch('/api/adapter/filters/sync', {
499
+ method: 'POST'
500
+ });
501
+ if (!syncResponse.ok) {
502
+ console.error('Failed to sync filters to backend');
658
503
  }
504
+ } catch (error) {
505
+ console.error('Error syncing filters to backend:', error);
659
506
  }
660
- } catch (error) {
661
- failedCount++;
662
- console.error(`Error saving filter ${filterId}:`, error);
507
+
508
+ return { success: successCount, failed: 0 };
509
+ } else {
510
+ const data = await response.json();
511
+ const errorMsg = data.error || 'Unknown error';
512
+ console.error(`Failed to save filters: ${errorMsg}`);
513
+
514
+ // Show error to user
515
+ if (response.status === 400 && errorMsg.includes('last enabled filter')) {
516
+ showConfigMessage(errorMsg, 'danger');
517
+ }
518
+
519
+ return { success: 0, failed: filterUpdates.length };
663
520
  }
521
+ } catch (error) {
522
+ console.error('Error saving filters:', error);
523
+ return { success: 0, failed: filterUpdates.length };
664
524
  }
665
-
666
- return { success: successCount, failed: failedCount };
667
525
  }
668
526
 
669
527
  /**
670
528
  * Trigger or cancel autofocus routine
671
529
  */
672
530
  async function triggerAutofocus() {
673
- const button = document.getElementById('runAutofocusButton');
674
- const buttonText = document.getElementById('autofocusButtonText');
675
- const buttonSpinner = document.getElementById('autofocusButtonSpinner');
676
-
677
- if (!button || !buttonText || !buttonSpinner) return;
678
-
679
- // Check if this is a cancel action
680
- const isCancel = button.dataset.action === 'cancel';
531
+ const store = Alpine.store('citrascope');
532
+ const isCancel = store.status?.autofocus_requested;
681
533
 
682
534
  if (isCancel) {
683
535
  // Cancel autofocus
@@ -689,7 +541,6 @@ async function triggerAutofocus() {
689
541
 
690
542
  if (response.ok && data.success) {
691
543
  showToast('Autofocus cancelled', 'info');
692
- updateAutofocusButton(false);
693
544
  } else {
694
545
  showToast('Nothing to cancel', 'warning');
695
546
  }
@@ -701,8 +552,7 @@ async function triggerAutofocus() {
701
552
  }
702
553
 
703
554
  // Request autofocus
704
- button.disabled = true;
705
- buttonSpinner.style.display = 'inline-block';
555
+ store.isAutofocusing = true;
706
556
 
707
557
  try {
708
558
  const response = await fetch('/api/adapter/autofocus', {
@@ -713,7 +563,6 @@ async function triggerAutofocus() {
713
563
 
714
564
  if (response.ok) {
715
565
  showToast('Autofocus queued', 'success');
716
- updateAutofocusButton(true);
717
566
  } else {
718
567
  showToast(data.error || 'Autofocus request failed', 'error');
719
568
  }
@@ -721,30 +570,7 @@ async function triggerAutofocus() {
721
570
  console.error('Error triggering autofocus:', error);
722
571
  showToast('Failed to trigger autofocus', 'error');
723
572
  } finally {
724
- button.disabled = false;
725
- buttonSpinner.style.display = 'none';
726
- }
727
- }
728
-
729
- /**
730
- * Update autofocus button state based on whether autofocus is queued
731
- */
732
- function updateAutofocusButton(isQueued) {
733
- const button = document.getElementById('runAutofocusButton');
734
- const buttonText = document.getElementById('autofocusButtonText');
735
-
736
- if (!button || !buttonText) return;
737
-
738
- if (isQueued) {
739
- buttonText.textContent = 'Cancel Autofocus';
740
- button.dataset.action = 'cancel';
741
- button.classList.remove('btn-outline-primary');
742
- button.classList.add('btn-outline-warning');
743
- } else {
744
- buttonText.textContent = 'Run Autofocus';
745
- button.dataset.action = 'request';
746
- button.classList.remove('btn-outline-warning');
747
- button.classList.add('btn-outline-primary');
573
+ store.isAutofocusing = false;
748
574
  }
749
575
  }
750
576
 
@@ -795,12 +621,25 @@ export async function initFilterConfig() {
795
621
  /**
796
622
  * Setup autofocus button event listener (call once during init)
797
623
  */
624
+ // Autofocus button now uses Alpine @click directive in template
625
+ // Expose triggerAutofocus for template access
798
626
  export function setupAutofocusButton() {
799
- const autofocusBtn = document.getElementById('runAutofocusButton');
800
- if (autofocusBtn) {
801
- autofocusBtn.addEventListener('click', triggerAutofocus);
802
- }
627
+ window.triggerAutofocus = triggerAutofocus;
803
628
  }
804
629
 
805
- // Make showConfigSection available globally for onclick handlers in HTML
806
- window.showConfigSection = showConfigSection;
630
+ /**
631
+ * Reload adapter schema (called from Alpine components when device type changes)
632
+ */
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
+ });
643
+
644
+ await loadAdapterSchema(adapter, currentSettings);
645
+ }