UNPKG

@switchbot/homebridge-switchbot

Version:

The SwitchBot plugin allows you to access your SwitchBot device(s) from HomeKit.

1,147 lines 50.8 kB
// Extend the Window interface to include _discoverySelectedIds for type safety // Batch enable/disable helper (true module scope for UI access) import { addDevicesInBulk, discoverDevices as apiDiscoverDevices, fetchBluetoothStatus, // fetchDevices, // removed unused import syncParentPluginConfigFromDisk, } from './api.js'; import { loadConfiguredDevices } from './devices.js'; import { uiLog } from './logger.js'; import { hideBusyUi, showBusyUi } from './modal.js'; import { getDiscoveryPreferences, renderDiscoveredDevices, setDiscoveryPreferences } from './render.js'; import { toastError, toastInfo, toastSuccess, toastWarning } from './toast.js'; function normalizeId(value) { return String(value ?? '').trim().toLowerCase(); } function dedupeById(devices) { return devices.filter((d, index, arr) => !!d?.id && arr.findIndex(x => x?.id === d.id) === index); } function mergeDiscoveredDevices(existingDevices, incomingDevices) { const deviceMap = new Map(); for (const d of dedupeById(existingDevices)) { deviceMap.set(d.id, { ...d }); } for (const d of dedupeById(incomingDevices)) { const current = deviceMap.get(d.id); if (current) { // Only set 'Both' if both sources are present in this session let nextConnectionType = current.connectionType; if (current.connectionType && d.connectionType && current.connectionType !== d.connectionType) { // Only set 'Both' if both are 'BLE' and 'OpenAPI' (not if one is undefined) const types = [current.connectionType, d.connectionType].sort().join(','); if (types === 'BLE,OpenAPI' || types === 'OpenAPI,BLE') { nextConnectionType = 'Both'; } } deviceMap.set(d.id, { ...current, ...d, connectionType: nextConnectionType, }); } else { deviceMap.set(d.id, { ...d }); } } const merged = [...deviceMap.values()]; // Log merged device structure for debugging if (merged.length > 0) { console.warn('[SwitchBot][Discovery][mergeDiscoveredDevices] Merged device sample:', merged[0]); console.warn('[SwitchBot][Discovery][mergeDiscoveredDevices] Total merged devices:', merged.length); } return merged; } const DISCOVERY_GROUP_BY_KEY = 'discoveryGroupBy'; const DISCOVERY_GROUP_EXPANDED_KEY = 'discoveryGroupExpanded'; const DISCOVERY_BLE_SETTINGS_KEY = 'discoveryBleSettings'; const DISCOVERY_HIDE_ADDED_KEY = 'discoveryHideAdded'; const DISCOVERY_CACHE_KEY = 'discoveryCache'; const DISCOVERY_AUTO_REFRESH_KEY = 'discoveryAutoRefreshSeconds'; const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000; let discoveryAutoRefreshTimer = null; let discoveryLastScannedTimer = null; function setDiscoveryCache(devices) { try { const payload = { timestamp: Date.now(), devices }; localStorage.setItem(DISCOVERY_CACHE_KEY, JSON.stringify(payload)); } catch (_e) { // Ignore storage errors } } function clearDiscoveryCache() { try { localStorage.removeItem(DISCOVERY_CACHE_KEY); } catch (_e) { // Ignore storage errors } } function getDiscoveryCache(validOnly = true) { try { const stored = localStorage.getItem(DISCOVERY_CACHE_KEY); if (!stored) { return null; } const payload = JSON.parse(stored); if (!payload || !Array.isArray(payload.devices) || typeof payload.timestamp !== 'number') { return null; } const age = Date.now() - payload.timestamp; if (validOnly && age > DISCOVERY_CACHE_TTL_MS) { return null; } return payload; } catch (_e) { return null; } } function getDiscoveryAutoRefreshSeconds() { try { const stored = localStorage.getItem(DISCOVERY_AUTO_REFRESH_KEY); const value = Number(stored || 0); return Number.isFinite(value) && value >= 0 ? value : 0; } catch (_e) { return 0; } } function setDiscoveryAutoRefreshSeconds(value) { try { localStorage.setItem(DISCOVERY_AUTO_REFRESH_KEY, String(Math.max(0, value))); } catch (_e) { // Ignore storage errors } } function getDiscoveryHideAddedPreference() { try { return localStorage.getItem(DISCOVERY_HIDE_ADDED_KEY) === 'true'; } catch (_e) { return false; } } function setDiscoveryHideAddedPreference(value) { try { localStorage.setItem(DISCOVERY_HIDE_ADDED_KEY, String(value)); } catch (_e) { // Ignore storage errors } } function formatElapsedShort(ms) { const totalSeconds = Math.max(0, Math.floor(ms / 1000)); if (totalSeconds < 60) { return `${totalSeconds}s ago`; } const minutes = Math.floor(totalSeconds / 60); if (minutes < 60) { return `${minutes}m ago`; } const hours = Math.floor(minutes / 60); if (hours < 24) { return `${hours}h ago`; } const days = Math.floor(hours / 24); return `${days}d ago`; } function updateLastScannedStatus() { const lastScannedStatus = document.getElementById('lastScannedStatus'); if (!lastScannedStatus) { return; } const cache = getDiscoveryCache(false); if (!cache) { lastScannedStatus.textContent = 'Last scanned: never'; return; } const ageMs = Date.now() - cache.timestamp; const timestampText = new Date(cache.timestamp).toLocaleString(); const stale = ageMs > DISCOVERY_CACHE_TTL_MS; lastScannedStatus.textContent = stale ? `Last scanned: ${formatElapsedShort(ageMs)} (${timestampText}, cache expired)` : `Last scanned: ${formatElapsedShort(ageMs)} (${timestampText})`; } async function renderCachedDiscoveryResults() { const cache = getDiscoveryCache(true); const list = document.getElementById('discoveredList'); if (!cache || !list || !cache.devices.length) { return; } list.style.display = 'block'; // Use type-safe property for window._discoverySelectedIds if (!window._discoverySelectedIds) { window._discoverySelectedIds = new Set(); } await updateDiscoveryView(cache.devices, getDiscoveryPreferences(), getDiscoveryGroupByPreference(), getDiscoveryHideAddedPreference(), window._discoverySelectedIds); } function getDiscoveryBleSettings() { try { const stored = localStorage.getItem(DISCOVERY_BLE_SETTINGS_KEY); if (!stored) { return { bleEnabled: true, bleScanDurationSeconds: 5, bleTimeoutSeconds: 8 }; } const parsed = JSON.parse(stored); return { bleEnabled: parsed?.bleEnabled !== false, bleScanDurationSeconds: Math.max(3, Math.min(15, Number(parsed?.bleScanDurationSeconds || 5))), bleTimeoutSeconds: Math.max(3, Math.min(30, Number(parsed?.bleTimeoutSeconds || 8))), }; } catch (_e) { return { bleEnabled: true, bleScanDurationSeconds: 5, bleTimeoutSeconds: 8 }; } } function setDiscoveryBleSettings(settings) { try { localStorage.setItem(DISCOVERY_BLE_SETTINGS_KEY, JSON.stringify(settings)); } catch (_e) { // Ignore storage errors } } export async function initializeDiscoverySettings() { const scanSelect = document.getElementById('bleScanDurationSelect'); const timeoutInput = document.getElementById('bleTimeoutInput'); const disableBleCheckbox = document.getElementById('disableBleScanCheckbox'); const scanSetting = document.getElementById('bleScanSetting'); const timeoutSetting = document.getElementById('bleTimeoutSetting'); const bluetoothStatus = document.getElementById('bluetoothStatus'); const autoRefreshSelect = document.getElementById('autoRefreshIntervalSelect'); const refreshBtn = document.getElementById('refreshDiscoverBtn'); const current = getDiscoveryBleSettings(); if (scanSelect) { scanSelect.value = String(current.bleScanDurationSeconds); } if (timeoutInput) { timeoutInput.value = String(current.bleTimeoutSeconds); } if (disableBleCheckbox) { disableBleCheckbox.checked = !current.bleEnabled; } if (autoRefreshSelect) { autoRefreshSelect.value = String(getDiscoveryAutoRefreshSeconds()); } const updateBleSettingVisibility = () => { const disabled = !!disableBleCheckbox?.checked; if (scanSetting) { scanSetting.style.display = disabled ? 'none' : 'inline-flex'; } if (timeoutSetting) { timeoutSetting.style.display = disabled ? 'none' : 'inline-flex'; } }; const persistFromControls = () => { const next = { bleEnabled: !(disableBleCheckbox?.checked ?? false), bleScanDurationSeconds: Math.max(3, Math.min(15, Number(scanSelect?.value || 5))), bleTimeoutSeconds: Math.max(3, Math.min(30, Number(timeoutInput?.value || 8))), }; setDiscoveryBleSettings(next); }; scanSelect?.addEventListener('change', persistFromControls); timeoutInput?.addEventListener('change', persistFromControls); disableBleCheckbox?.addEventListener('change', () => { persistFromControls(); updateBleSettingVisibility(); }); updateBleSettingVisibility(); if (bluetoothStatus) { const status = await fetchBluetoothStatus(); bluetoothStatus.textContent = status.available ? `Bluetooth: available (${status.message})` : `Bluetooth: unavailable (${status.message})`; } updateLastScannedStatus(); if (discoveryLastScannedTimer) { clearInterval(discoveryLastScannedTimer); } discoveryLastScannedTimer = setInterval(updateLastScannedStatus, 15000); refreshBtn?.addEventListener('click', () => { void discoverDevices(); }); const applyAutoRefresh = () => { const seconds = Math.max(0, Number(autoRefreshSelect?.value || 0)); setDiscoveryAutoRefreshSeconds(seconds); if (discoveryAutoRefreshTimer) { clearInterval(discoveryAutoRefreshTimer); discoveryAutoRefreshTimer = null; } if (seconds > 0) { discoveryAutoRefreshTimer = setInterval(() => { const discoverBtn = document.getElementById('discoverBtn'); if (!discoverBtn || discoverBtn.disabled || document.hidden) { return; } void discoverDevices(); }, seconds * 1000); } }; autoRefreshSelect?.addEventListener('change', applyAutoRefresh); applyAutoRefresh(); await renderCachedDiscoveryResults(); } function getDiscoveryGroupByPreference() { try { const stored = localStorage.getItem(DISCOVERY_GROUP_BY_KEY); if (stored === 'hub' || stored === 'type') { return stored; } return 'type'; // Default to Device Type grouping } catch (_e) { return 'type'; } } function setDiscoveryGroupByPreference(groupBy) { try { localStorage.setItem(DISCOVERY_GROUP_BY_KEY, groupBy); } catch (_e) { // Ignore storage errors } } function getDiscoveryGroupExpandedState() { try { const stored = localStorage.getItem(DISCOVERY_GROUP_EXPANDED_KEY); if (!stored) { return {}; } const parsed = JSON.parse(stored); return typeof parsed === 'object' && parsed ? parsed : {}; } catch (_e) { return {}; } } function setDiscoveryGroupExpandedState(state) { try { localStorage.setItem(DISCOVERY_GROUP_EXPANDED_KEY, JSON.stringify(state)); } catch (_e) { // Ignore storage errors } } function isDiscoveryGroupExpanded(groupKey) { const state = getDiscoveryGroupExpandedState(); return state[groupKey] !== false; } function setDiscoveryGroupExpanded(groupKey, expanded) { const state = getDiscoveryGroupExpandedState(); state[groupKey] = expanded; setDiscoveryGroupExpandedState(state); } export async function discoverDevices() { const btn = document.getElementById('discoverBtn'); const status = document.getElementById('discoverStatus'); const phaseProgress = document.getElementById('discoverPhaseProgress'); const phaseFill = document.getElementById('discoverPhaseFill'); const phaseLabel = document.getElementById('discoverPhaseLabel'); const list = document.getElementById('discoveredList'); const autoAddAll = document.getElementById('autoAddAllCheckbox')?.checked; const scanSelect = document.getElementById('bleScanDurationSelect'); const timeoutInput = document.getElementById('bleTimeoutInput'); const disableBleCheckbox = document.getElementById('disableBleScanCheckbox'); if (!btn) { console.error('[SwitchBot][Discovery] discoverDevices: discoverBtn not found in DOM'); return; } if (!status) { console.error('[SwitchBot][Discovery] discoverDevices: discoverStatus not found in DOM'); return; } if (!list) { console.error('[SwitchBot][Discovery] discoverDevices: discoveredList container not found in DOM'); toastError('Discovery UI error: device list container missing. Please reload the page.'); return; } const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; let spinnerIndex = 0; const startedAt = Date.now(); let phaseStartedAt = startedAt; let phase = 'Preparing discovery...'; // --- Real-time RSSI polling additions --- // (bleScanDurationSeconds is now only used in bleSettings below) const setPhase = (nextPhase) => { phase = nextPhase; phaseStartedAt = Date.now(); }; const getPhasePercent = (phaseName) => { if (phaseName.includes('Scanning BLE')) { return 35; } if (phaseName.includes('Fetching OpenAPI')) { return 75; } if (phaseName.includes('Complete')) { return 100; } return 10; }; const renderProgress = () => { const totalSeconds = Math.max(0, Math.floor((Date.now() - startedAt) / 1000)); const phaseSeconds = Math.max(0, Math.floor((Date.now() - phaseStartedAt) / 1000)); const frame = spinnerFrames[spinnerIndex % spinnerFrames.length]; spinnerIndex += 1; status.textContent = `${frame} ${phase} (${phaseSeconds}s, ${totalSeconds}s total)`; if (phaseProgress) { phaseProgress.style.display = 'block'; } if (phaseFill) { phaseFill.style.width = `${getPhasePercent(phase)}%`; } if (phaseLabel) { phaseLabel.textContent = phase; } }; const progressTimer = setInterval(renderProgress, 250); let discoveredDevices = []; const preferences = getDiscoveryPreferences(); let groupBy = getDiscoveryGroupByPreference(); let hideAdded = getDiscoveryHideAddedPreference(); // Use persistent selection state across renders if (!window._discoverySelectedIds) { window._discoverySelectedIds = new Set(); } const selectedIds = window._discoverySelectedIds; let controlsInitialized = false; // --- Real-time RSSI polling loop --- // (Moved inside main try block after bleSettings is defined) // Batch enable/disable helper (moved to module scope for UI access) async function batchSetDeviceEnabled(selectedIds, enabled) { // Fetch current config // Fetch current config using Homebridge UI API if (typeof homebridge.getPluginConfig !== 'function') { throw new TypeError('homebridge.getPluginConfig is not available'); } const configArr = await homebridge.getPluginConfig(); const platformIdx = Array.isArray(configArr) ? configArr.findIndex(c => (c.platform || c.name || '').toLowerCase().includes('switchbot')) : -1; if (platformIdx === -1) { throw new Error('SwitchBot platform config not found'); } const platformConfig = configArr[platformIdx]; if (!Array.isArray(platformConfig.devices)) { throw new TypeError('No devices array in config'); } let changed = false; for (const dev of platformConfig.devices) { const id = String(dev.deviceId || dev.id || '').trim().toLowerCase(); if (selectedIds.has(id)) { if (dev.enabled !== enabled) { dev.enabled = enabled; changed = true; } } } if (changed) { if (typeof homebridge.updatePluginConfig === 'function') { await homebridge.updatePluginConfig(configArr); } else { throw new TypeError('homebridge.updatePluginConfig is not available'); } if (typeof homebridge.savePluginConfig === 'function') { await homebridge.savePluginConfig(); } } } const ensureDiscoveryControls = async () => { // --- Select All / Deselect All controls --- const selectAllBtn = document.createElement('button'); selectAllBtn.textContent = 'Select All'; selectAllBtn.style.fontSize = '13px'; selectAllBtn.style.padding = '6px 18px'; selectAllBtn.style.borderRadius = '6px'; selectAllBtn.style.background = '#f3f4f6'; selectAllBtn.style.color = '#1d4ed8'; selectAllBtn.style.border = '1px solid #d1d5db'; selectAllBtn.style.cursor = 'pointer'; selectAllBtn.style.marginRight = '8px'; selectAllBtn.onclick = () => { // Add all visible device IDs to selectedIds for (const d of discoveredDevices) { selectedIds.add(normalizeId(d.id)); } window.dispatchEvent(new Event('discovery-selection-changed')); void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); }; const deselectAllBtn = document.createElement('button'); deselectAllBtn.textContent = 'Deselect All'; deselectAllBtn.style.fontSize = '13px'; deselectAllBtn.style.padding = '6px 18px'; deselectAllBtn.style.borderRadius = '6px'; deselectAllBtn.style.background = '#f3f4f6'; deselectAllBtn.style.color = '#ef4444'; deselectAllBtn.style.border = '1px solid #d1d5db'; deselectAllBtn.style.cursor = 'pointer'; deselectAllBtn.onclick = () => { // Remove all visible device IDs from selectedIds for (const d of discoveredDevices) { selectedIds.delete(normalizeId(d.id)); } window.dispatchEvent(new Event('discovery-selection-changed')); void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); }; // Insert select/deselect all controls above the action buttons const selectControlsRow = document.createElement('div'); selectControlsRow.style.display = 'flex'; selectControlsRow.style.gap = '10px'; selectControlsRow.style.margin = '0 0 10px 0'; selectControlsRow.appendChild(selectAllBtn); selectControlsRow.appendChild(deselectAllBtn); if (controlsInitialized) { return; } // Always use persistent selectedIds (already defined in outer scope) const controlsDiv = document.createElement('div'); controlsDiv.style.cssText = 'margin-bottom: 12px; display: flex; gap: 12px; flex-wrap: wrap; align-items: center;'; const filterLabel = document.createElement('label'); filterLabel.style.fontSize = '12px'; filterLabel.style.fontWeight = '500'; filterLabel.textContent = 'Filter:'; const filterGroup = document.createElement('div'); filterGroup.style.display = 'flex'; filterGroup.style.gap = '4px'; const filterOptions = [ { label: 'All', value: 'all' }, { label: 'BLE', value: 'ble' }, { label: 'API', value: 'api' }, { label: 'Both', value: 'both' }, { label: 'IR', value: 'ir' }, ]; for (const option of filterOptions) { const filterBtn = document.createElement('button'); filterBtn.textContent = option.label; filterBtn.style.padding = '4px 8px'; filterBtn.style.fontSize = '11px'; filterBtn.style.borderRadius = '3px'; filterBtn.style.cursor = 'pointer'; filterBtn.style.border = preferences.connectionType === option.value ? '2px solid #007AFF' : '1px solid #ccc'; filterBtn.style.backgroundColor = preferences.connectionType === option.value ? '#f0f7ff' : '#fff'; filterBtn.style.color = preferences.connectionType === option.value ? '#1d4ed8' : '#374151'; filterBtn.onclick = () => { preferences.connectionType = option.value; setDiscoveryPreferences(preferences); void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); Array.prototype.forEach.call(filterGroup.querySelectorAll('button'), (b) => { b.style.border = '1px solid #ccc'; b.style.backgroundColor = '#fff'; b.style.color = '#374151'; }); filterBtn.style.border = '2px solid #007AFF'; filterBtn.style.backgroundColor = '#f0f7ff'; filterBtn.style.color = '#1d4ed8'; }; filterGroup.appendChild(filterBtn); } const sortLabel = document.createElement('label'); sortLabel.style.fontSize = '12px'; sortLabel.style.fontWeight = '500'; sortLabel.style.marginLeft = '8px'; sortLabel.textContent = 'Sort:'; const sortSelect = document.createElement('select'); sortSelect.style.fontSize = '11px'; sortSelect.style.padding = '4px 8px'; sortSelect.style.borderRadius = '3px'; sortSelect.value = preferences.sortBy; const sortOptions = [ { label: 'Name', value: 'name' }, { label: 'Signal Strength', value: 'signal' }, { label: 'Type', value: 'type' }, { label: 'Connection', value: 'connection' }, ]; for (const opt of sortOptions) { const sortOption = document.createElement('option'); sortOption.value = opt.value; sortOption.textContent = opt.label; sortSelect.appendChild(sortOption); } sortSelect.onchange = () => { preferences.sortBy = sortSelect.value; setDiscoveryPreferences(preferences); void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); }; const groupSelect = document.createElement('select'); groupSelect.style.fontSize = '11px'; groupSelect.style.padding = '4px 8px'; groupSelect.style.borderRadius = '3px'; // Set default value to 'type' if no stored preference if (!localStorage.getItem(DISCOVERY_GROUP_BY_KEY)) { groupSelect.value = 'type'; } else { groupSelect.value = groupBy; } const groupLabel = document.createElement('label'); groupLabel.style.fontSize = '12px'; groupLabel.style.fontWeight = '500'; groupLabel.style.marginLeft = '8px'; // Set label text to match selected group const groupLabelTextMap = { connection: 'Connection', hub: 'Hub', type: 'Device Type', }; groupLabel.textContent = `Group: ${groupLabelTextMap[groupSelect.value] || 'Connection'}`; const groupOptions = [ { label: 'Connection', value: 'connection' }, { label: 'Hub', value: 'hub' }, { label: 'Device Type', value: 'type' }, ]; for (const opt of groupOptions) { const groupOption = document.createElement('option'); groupOption.value = opt.value; groupOption.textContent = opt.label; groupSelect.appendChild(groupOption); } groupSelect.onchange = () => { groupBy = groupSelect.value; setDiscoveryGroupByPreference(groupBy); groupLabel.textContent = `Group: ${groupLabelTextMap[groupSelect.value] || 'Connection'}`; void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); }; const hideAddedLabel = document.createElement('label'); hideAddedLabel.style.display = 'inline-flex'; hideAddedLabel.style.alignItems = 'center'; hideAddedLabel.style.gap = '4px'; hideAddedLabel.style.fontSize = '11px'; hideAddedLabel.style.marginLeft = '8px'; const hideAddedCheckbox = document.createElement('input'); hideAddedCheckbox.type = 'checkbox'; hideAddedCheckbox.checked = hideAdded; hideAddedCheckbox.style.margin = '0'; hideAddedCheckbox.style.width = 'auto'; const hideAddedText = document.createElement('span'); hideAddedText.textContent = 'Hide Added'; hideAddedLabel.appendChild(hideAddedCheckbox); hideAddedLabel.appendChild(hideAddedText); hideAddedCheckbox.onchange = () => { hideAdded = hideAddedCheckbox.checked; setDiscoveryHideAddedPreference(hideAdded); void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); }; const searchInput = document.createElement('input'); searchInput.type = 'text'; searchInput.placeholder = 'Search by name, ID, or type...'; searchInput.style.fontSize = '13px'; searchInput.style.padding = '8px 16px'; searchInput.style.borderRadius = '6px'; searchInput.style.border = '1px solid #ccc'; searchInput.style.flex = '1 1 0%'; searchInput.style.minWidth = '120px'; searchInput.style.maxWidth = '100%'; searchInput.style.width = '100%'; searchInput.value = preferences.searchQuery; let searchTimeout; searchInput.oninput = () => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { preferences.searchQuery = searchInput.value; setDiscoveryPreferences(preferences); void updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); }, 300); }; // Shared style for all top action buttons (smaller, more compact) const actionBtnStyle = { fontSize: '16px', padding: '10px 0', borderRadius: '10px', margin: '0 12px 0 0', width: '100%', maxWidth: '220px', fontWeight: 'bold', background: '#ef4444', color: '#fff', border: 'none', cursor: 'pointer', boxShadow: '0 2px 8px #0001', transition: 'background 0.2s', outline: 'none', display: 'block', }; // Add Selected button const addSelectedBtn = document.createElement('button'); addSelectedBtn.textContent = 'Add Selected to Config'; Object.assign(addSelectedBtn.style, actionBtnStyle); addSelectedBtn.disabled = true; addSelectedBtn.onclick = async () => { if (!selectedIds.size) { return; } addSelectedBtn.disabled = true; addSelectedBtn.textContent = 'Adding...'; try { showBusyUi(); const selectedDevices = discoveredDevices.filter(d => selectedIds.has(normalizeId(d.id))); const bulkResult = await addDevicesInBulk(selectedDevices.map(d => ({ deviceId: d.id, name: d.name, type: d.type, rssi: d.rssi, address: d.address, model: d.model, }))); uiLog.info('Batch add response:', bulkResult); if (!bulkResult || bulkResult.success === false) { throw new Error(bulkResult?.data?.message || 'Batch add failed'); } const addedCount = bulkResult?.addedCount ?? bulkResult?.data?.addedCount ?? 0; const skippedCount = bulkResult?.skippedCount ?? bulkResult?.data?.skippedCount ?? 0; toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`); await loadConfiguredDevices(); selectedIds.clear(); addSelectedBtn.disabled = true; addSelectedBtn.textContent = 'Add Selected'; await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); } catch (e) { uiLog.error('Batch add error:', e); toastError(e instanceof Error ? e.message : 'Failed to add devices'); addSelectedBtn.disabled = false; addSelectedBtn.textContent = 'Add Selected'; } finally { hideBusyUi(); } }; // Enable Selected button const enableSelectedBtn = document.createElement('button'); enableSelectedBtn.textContent = 'Enable Selected'; Object.assign(enableSelectedBtn.style, actionBtnStyle); enableSelectedBtn.disabled = true; enableSelectedBtn.onclick = async () => { if (!selectedIds.size) { return; } enableSelectedBtn.disabled = true; enableSelectedBtn.textContent = 'Enabling...'; try { showBusyUi(); await batchSetDeviceEnabled(selectedIds, true); toastSuccess('Selected devices enabled'); await loadConfiguredDevices(); enableSelectedBtn.disabled = true; enableSelectedBtn.textContent = 'Enable Selected'; await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); } catch (e) { uiLog.error('Batch enable error:', e); toastError(e instanceof Error ? e.message : 'Failed to enable devices'); enableSelectedBtn.disabled = false; enableSelectedBtn.textContent = 'Enable Selected'; } finally { hideBusyUi(); } }; // Disable Selected button const disableSelectedBtn = document.createElement('button'); disableSelectedBtn.textContent = 'Disable Selected'; Object.assign(disableSelectedBtn.style, actionBtnStyle); disableSelectedBtn.disabled = true; disableSelectedBtn.onclick = async () => { if (!selectedIds.size) { return; } disableSelectedBtn.disabled = true; disableSelectedBtn.textContent = 'Disabling...'; try { showBusyUi(); await batchSetDeviceEnabled(selectedIds, false); toastSuccess('Selected devices disabled'); await loadConfiguredDevices(); disableSelectedBtn.disabled = true; disableSelectedBtn.textContent = 'Disable Selected'; await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); } catch (e) { uiLog.error('Batch disable error:', e); toastError(e instanceof Error ? e.message : 'Failed to disable devices'); disableSelectedBtn.disabled = false; disableSelectedBtn.textContent = 'Disable Selected'; } finally { hideBusyUi(); } }; // Only add the filter/search controls controlsDiv.appendChild(filterLabel); controlsDiv.appendChild(filterGroup); controlsDiv.appendChild(sortLabel); controlsDiv.appendChild(sortSelect); controlsDiv.appendChild(groupLabel); controlsDiv.appendChild(groupSelect); controlsDiv.appendChild(hideAddedLabel); controlsDiv.appendChild(searchInput); // Top row action buttons container const topActionRow = document.createElement('div'); topActionRow.style.display = 'flex'; topActionRow.style.gap = '20px'; topActionRow.style.margin = '18px 0 10px 0'; topActionRow.style.justifyContent = 'flex-start'; topActionRow.appendChild(addSelectedBtn); topActionRow.appendChild(enableSelectedBtn); topActionRow.appendChild(disableSelectedBtn); // Clear list and append controls in correct order list.innerHTML = ''; list.appendChild(selectControlsRow); list.appendChild(topActionRow); list.appendChild(controlsDiv); let deviceListContainer = document.getElementById('discoveredDevices'); if (!deviceListContainer) { deviceListContainer = document.createElement('ul'); deviceListContainer.id = 'discoveredDevices'; deviceListContainer.style.maxHeight = '400px'; deviceListContainer.style.overflowY = 'auto'; deviceListContainer.style.marginTop = '12px'; deviceListContainer.style.padding = '0'; deviceListContainer.style.listStyle = 'none'; list.appendChild(deviceListContainer); } list.style.display = 'block'; controlsInitialized = true; // Update action button enabled state based on selection const updateActionButtons = () => { const hasSelection = selectedIds.size > 0; addSelectedBtn.disabled = !hasSelection; enableSelectedBtn.disabled = !hasSelection; disableSelectedBtn.disabled = !hasSelection; }; // Listen for selection changes (selection is managed elsewhere, so poll) setInterval(updateActionButtons, 300); }; try { const bleSettings = { bleEnabled: !(disableBleCheckbox?.checked ?? false), bleScanDurationSeconds: Math.max(3, Math.min(15, Number(scanSelect?.value || 5))), bleTimeoutSeconds: Math.max(3, Math.min(30, Number(timeoutInput?.value || 8))), }; setDiscoveryBleSettings(bleSettings); showBusyUi(); btn.disabled = true; btn.textContent = '🔍 Discovering...'; setPhase(bleSettings.bleEnabled ? 'Scanning BLE...' : 'Skipping BLE scan...'); renderProgress(); status.classList.remove('error'); const devicesFoundDisplay = document.getElementById('discoverDevicesFound'); if (devicesFoundDisplay) { devicesFoundDisplay.style.display = 'none'; devicesFoundDisplay.classList.remove('discovery-scanning-pulse'); } if (bleSettings.bleEnabled) { const bleDevicesRaw = await apiDiscoverDevices('ble', bleSettings); discoveredDevices = dedupeById(bleDevicesRaw); uiLog.info('BLE discover response:', bleDevicesRaw); // Update real-time device counter if (devicesFoundDisplay && bleDevicesRaw.length > 0) { devicesFoundDisplay.style.display = 'inline'; devicesFoundDisplay.classList.add('discovery-scanning-pulse'); devicesFoundDisplay.textContent = `📊 ${bleDevicesRaw.length} device(s) found (scanning...)`; } } else { discoveredDevices = []; uiLog.info('BLE discovery skipped by user setting'); } if (!autoAddAll && discoveredDevices.length > 0) { await ensureDiscoveryControls(); await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); status.textContent = `Showing ${discoveredDevices.length} device(s) from BLE, fetching OpenAPI...`; } setPhase('Fetching OpenAPI...'); renderProgress(); try { const openApiDevicesRaw = await apiDiscoverDevices('openapi'); uiLog.info('OpenAPI discover response:', openApiDevicesRaw); discoveredDevices = mergeDiscoveredDevices(discoveredDevices, openApiDevicesRaw); // Update device counter with merged count if (devicesFoundDisplay && discoveredDevices.length > 0) { devicesFoundDisplay.textContent = `📊 ${discoveredDevices.length} device(s) found (complete)`; } } catch (openApiError) { uiLog.warn('OpenAPI phase failed during discovery:', openApiError); // Keep BLE results if available if (!discoveredDevices.length) { throw openApiError; } if (devicesFoundDisplay) { devicesFoundDisplay.classList.remove('discovery-scanning-pulse'); } } setPhase('Complete'); renderProgress(); uiLog.info('Final merged discover response:', discoveredDevices); if (!discoveredDevices.length) { status.textContent = 'No devices found in your SwitchBot account'; toastInfo('No devices found in your SwitchBot account'); list.style.display = 'none'; if (devicesFoundDisplay) { devicesFoundDisplay.style.display = 'none'; devicesFoundDisplay.classList.remove('discovery-scanning-pulse'); } clearDiscoveryCache(); updateLastScannedStatus(); return; } // If auto-add is enabled, add all devices immediately using bulk endpoint if (autoAddAll) { status.textContent = `Auto-adding ${discoveredDevices.length} device(s)...`; try { const bulkResult = await addDevicesInBulk(discoveredDevices.map(d => ({ deviceId: d.id, name: d.name, type: d.type, rssi: d.rssi, address: d.address, model: d.model, }))); uiLog.info('Bulk add response:', bulkResult); if (!bulkResult || bulkResult.success === false) { throw new Error(bulkResult?.data?.message || 'Bulk add failed'); } const addedCount = bulkResult?.addedCount ?? bulkResult?.data?.addedCount ?? 0; const skippedCount = bulkResult?.skippedCount ?? bulkResult?.data?.skippedCount ?? 0; status.textContent = `✓ Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`; if (addedCount > 0) { toastSuccess(`Added ${addedCount} device(s)${skippedCount > 0 ? ` (${skippedCount} skipped)` : ''}`); } else if (skippedCount > 0) { toastWarning(`No new devices were added (${skippedCount} skipped)`); } status.classList.remove('error'); list.style.display = 'none'; // Sync parent Homebridge form cache and auto-save to prevent cache overwrite // Run sync when discovery returned devices (even if backend response shape changes) if (discoveredDevices.length > 0) { const synced = await syncParentPluginConfigFromDisk(true); status.textContent += synced ? ' - Config saved automatically.' : ' - Warning: config may not persist until you close/reopen settings.'; if (synced) { toastSuccess('Configuration synced and saved automatically'); } else { toastWarning('Configuration sync failed; close and reopen settings before Save'); } } } catch (e) { uiLog.error('Bulk add error:', e); status.textContent = `✗ Error: ${e instanceof Error ? e.message : 'Failed to add devices'}`; status.classList.add('error'); toastError(e instanceof Error ? e.message : 'Failed to add devices'); } // Refresh the configured devices list await loadConfiguredDevices(); return; } await ensureDiscoveryControls(); await updateDiscoveryView(discoveredDevices, preferences, groupBy, hideAdded, selectedIds); setDiscoveryCache(discoveredDevices); updateLastScannedStatus(); } catch (e) { uiLog.error('Discovery error:', e); status.textContent = `Error: ${e instanceof Error ? e.message : 'Discovery failed'}`; status.classList.add('error'); toastError(e instanceof Error ? e.message : 'Discovery failed'); list.style.display = 'none'; } finally { clearInterval(progressTimer); hideBusyUi(); if (phaseProgress) { phaseProgress.style.display = 'none'; } if (phaseFill) { phaseFill.style.width = '0%'; } if (phaseLabel) { phaseLabel.textContent = ''; } const devicesFoundDisplay = document.getElementById('discoverDevicesFound'); if (devicesFoundDisplay) { devicesFoundDisplay.style.display = 'none'; devicesFoundDisplay.classList.remove('discovery-scanning-pulse'); } btn.disabled = false; btn.textContent = '🔍 Discover Devices'; } } /** * Update discovery list with filtered and sorted devices */ async function updateDiscoveryView(allDevices, preferences, groupBy, hideAdded, selectedIds) { // Compute visibleDevices based on filters and preferences console.warn('[SwitchBot][Discovery] updateDiscoveryView: allDevices', allDevices); const visibleDevices = allDevices .filter((d) => { // Hide already added devices if hideAdded is true if (hideAdded && d.added) { return false; } // Additional filtering logic can be added here based on preferences return true; }); console.warn('[SwitchBot][Discovery] visibleDevices after filter:', visibleDevices); // Optionally sort devices if needed (sortDevices is imported but not used) // .sort((a, b) => a.name.localeCompare(b.name)) // Set of already added device IDs const configuredIds = new Set(allDevices.filter(d => d.added).map(d => normalizeId(d.id))); // Batch import controls and selection logic are handled later in the file. Removed broken/duplicated code. const getConnectionGroup = (device) => { if (device?.isIR) { return 'IR'; } const connectionType = String(device?.connectionType || '').toLowerCase(); if (connectionType.includes('both')) { return 'Both'; } if (connectionType.includes('ble')) { return 'BLE'; } if (connectionType.includes('api')) { return 'OpenAPI'; } return 'Unknown'; }; const getHubGroup = (device) => { const hub = String(device?.hubDeviceId || '').trim(); return hub ? `Hub ${hub}` : 'No Hub'; }; const getTypeGroup = (device) => { const type = String(device?.type || '').trim(); return type || 'Unknown Type'; }; const groupedDevices = new Map(); for (const d of visibleDevices) { let group = getConnectionGroup(d); if (groupBy === 'hub') { group = getHubGroup(d); } else if (groupBy === 'type') { group = getTypeGroup(d); } const groupDevices = groupedDevices.get(group) || []; groupDevices.push(d); groupedDevices.set(group, groupDevices); } console.warn('[SwitchBot][Discovery] groupedDevices:', groupedDevices); let orderedGroups = []; if (groupBy === 'hub') { const hubGroups = [...groupedDevices.keys()].filter(group => group !== 'No Hub').sort((a, b) => a.localeCompare(b)); orderedGroups = groupedDevices.has('No Hub') ? [...hubGroups, 'No Hub'] : hubGroups; } else if (groupBy === 'type') { const typeGroups = [...groupedDevices.keys()].filter(group => group !== 'Unknown Type').sort((a, b) => a.localeCompare(b)); orderedGroups = groupedDevices.has('Unknown Type') ? [...typeGroups, 'Unknown Type'] : typeGroups; } else { const groupOrder = ['Both', 'BLE', 'OpenAPI', 'IR', 'Unknown']; orderedGroups = groupOrder.filter(group => groupedDevices.has(group)); } const container = document.createElement('div'); container.id = 'discoveredDevices'; container.className = 'discovery-groups'; console.warn('[SwitchBot][Discovery] Rendering device groups:', orderedGroups); if (!visibleDevices.length) { const empty = document.createElement('div'); empty.className = 'discovery-group-empty'; empty.textContent = hideAdded ? 'No devices match current filters (or all are already added).' : 'No devices match current filters.'; container.appendChild(empty); console.warn('[SwitchBot][Discovery] No visible devices after filtering.'); } else { for (const groupName of orderedGroups) { const groupItems = groupedDevices.get(groupName); if (!groupItems?.length) { continue; } console.warn(`[SwitchBot][Discovery] Rendering group: ${groupName}`, groupItems); // ...existing code... const groupSection = document.createElement('section'); groupSection.className = 'discovery-group'; const groupStorageKey = `${groupBy}:${groupName}`; let expanded = isDiscoveryGroupExpanded(groupStorageKey); const groupHeader = document.createElement('button'); groupHeader.className = 'discovery-group-header-btn'; groupHeader.type = 'button'; const setGroupHeaderText = () => { const marker = expanded ? '▾' : '▸'; groupHeader.textContent = `${marker} ${groupName} (${groupItems.length})`; }; setGroupHeaderText(); groupSection.appendChild(groupHeader); const groupList = await renderDiscoveredDevices(groupItems, { configuredIds, selectedIds, onToggleSelect: (device, selected) => { const id = normalizeId(device.id); if (selected) { selectedIds.add(id); } else { selectedIds.delete(id); } // Update Add Selected button state const btn = document.querySelector('button')?.parentElement?.querySelector('button'); if (btn && btn.textContent?.includes('Add Selected')) { btn.disabled = selectedIds.size === 0; } }, }); if (!expanded) { groupList.style.display = 'none'; } groupHeader.onclick = () => { expanded = !expanded; setDiscoveryGroupExpanded(groupStorageKey, expanded); setGroupHeaderText(); groupList.style.display = expanded ? 'grid' : 'none'; }; groupSection.appendChild(groupList); container.appendChild(groupSection); } } // Replace or append the rendered list const existingList = document.getElementById('discoveredDevices'); container.id = 'discoveredDevices'; if (existingList && existingList.parentNode) { existingList.replaceWith(container); } else { // Fallback: append to discovery list container const listContainer = document.getElementById('discoveredList'); if (listContainer) { listContainer.appendChild(container); } else { console.error('[SwitchBot][Discovery] render: discoveredList container not found in DOM (fallback)'); toastError('Discovery UI error: device list container missing. Please reload the page.'); } } // Only update the enabled/disabled state of batch action buttons (created in ensureDiscoveryControls) function updateBatchButtonStates() { // These buttons are created in ensureDiscoveryControls and should have unique IDs const addSelectedBtn = document.getElementById('addSelectedBtn'); const enableSelectedBtn = document.getElementById('enableSelectedBtn'); const disableSelectedBtn = document.getElementById('disableSelectedBtn'); const hasSelection = selectedIds.size > 0; if (addSelectedBtn) { addSelectedBtn.disabled = !hasSelection; } if (enableSelectedBtn) { enableSelectedBtn.disabled = !hasSelection; } if (disableSelectedBtn) { disableSelectedBtn.disabled = !hasSelection; } } window.removeEventListener('discovery-selection-changed', u