UNPKG

@switchbot/homebridge-switchbot

Version:

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

967 lines 39.9 kB
// Move regexes to module scope to avoid re-compilation on every call // import type { DEVICE_TYPES } from './constants.js' // Removed unused import const SPACES_REGEX = /\s/g; const CAMELCASE_REGEX = /([A-Z])/g; const FIRST_CHAR_REGEX = /^./; async function copyTextWithFallback(text) { try { await navigator.clipboard.writeText(text); } catch { const textarea = document.createElement('textarea'); textarea.value = text; textarea.setAttribute('readonly', ''); textarea.style.cssText = 'position:fixed;left:-9999px;opacity:0;pointer-events:none'; document.body.appendChild(textarea); textarea.select(); textarea.setSelectionRange(0, textarea.value.length); document.execCommand('copy'); textarea.remove(); } } /** * Get RSSI signal quality level and color based on dBm value * @param rssi Signal strength in dBm (typically -30 to -90) * @returns Object with quality level, color, and description */ export function getRssiSignalQuality(rssi) { if (!rssi || rssi === 0) { return { level: 'unknown', color: '#999', bgColor: '#f5f5f5', description: 'Signal strength unknown', bars: 0, }; } const dbm = Math.floor(rssi); if (dbm > -60) { return { level: 'excellent', color: '#34a853', bgColor: '#e8f5e9', description: `Excellent (${dbm} dBm)`, bars: 4, }; } else if (dbm > -75) { return { level: 'good', color: '#fbbc04', bgColor: '#fffde7', description: `Good (${dbm} dBm)`, bars: 3, }; } else if (dbm > -85) { return { level: 'fair', color: '#ff9800', bgColor: '#fff3e0', description: `Fair (${dbm} dBm)`, bars: 2, }; } else { return { level: 'poor', color: '#ea4335', bgColor: '#ffebee', description: `Poor (${dbm} dBm) - unreliable`, bars: 1, }; } } /** * Create visual signal strength indicator bars * @param rssi Signal strength in dBm * @returns HTML element showing filled bars */ export function renderSignalBars(rssi) { const quality = getRssiSignalQuality(rssi); const container = document.createElement('span'); container.style.display = 'inline-flex'; container.style.gap = '2px'; container.style.alignItems = 'center'; container.style.marginLeft = '8px'; container.style.fontSize = '12px'; // Create 4 bars for (let i = 1; i <= 4; i++) { const bar = document.createElement('span'); bar.style.height = `${i * 3}px`; bar.style.width = '3px'; bar.style.borderRadius = '1px'; bar.style.border = `1px solid ${quality.color}`; if (i <= quality.bars) { bar.style.backgroundColor = quality.color; } else { bar.style.backgroundColor = 'transparent'; } container.appendChild(bar); } // Add tooltip container.title = quality.description; return container; } /** * Create signal quality badge with color * @param rssi Signal strength in dBm * @returns HTML element showing quality level */ export function renderSignalQualityBadge(rssi) { const quality = getRssiSignalQuality(rssi); const badge = document.createElement('span'); badge.textContent = quality.level.charAt(0).toUpperCase() + quality.level.slice(1); badge.style.cssText = ` background: ${quality.color}; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; `; badge.title = quality.description; return badge; } export function renderBadge(text, style) { const badge = document.createElement('span'); badge.textContent = text; badge.style.cssText = style; return badge; } export function renderConnectionBadge(connectionType) { if (!connectionType) { return null; } const badge = renderBadge(connectionType, ''); if (connectionType === 'BLE') { badge.style.cssText = 'background: #4285f4; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;'; } else if (connectionType === 'Both') { badge.style.cssText = 'background: #34a853; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;'; } else { badge.style.cssText = 'background: #9e9e9e; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;'; } return badge; } export function renderIRBadge() { return renderBadge('IR', 'background: #ff6b35; color: white; padding: 2px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 8px;'); } function normalizeId(value) { return String(value ?? '').trim().toLowerCase(); } function scrollToConfiguredDevice(deviceId) { const normalizedId = normalizeId(deviceId); const target = document.querySelector(`[data-device-id="${normalizedId}"]`); if (!target) { return; } target.scrollIntoView({ behavior: 'smooth', block: 'center' }); const originalOutline = target.style.outline; const originalBackground = target.style.background; target.style.outline = '2px solid var(--switchbot-red, #ef4444)'; target.style.background = 'rgba(239, 68, 68, 0.08)'; setTimeout(() => { target.style.outline = originalOutline; target.style.background = originalBackground; }, 1800); } function createConnectionTestControls(device) { const controls = document.createElement('div'); controls.style.display = 'inline-flex'; controls.style.alignItems = 'center'; controls.style.gap = '6px'; const button = document.createElement('button'); button.textContent = 'Test Connection'; button.className = 'secondary'; button.style.padding = '4px 9px'; button.style.fontSize = '11px'; const status = document.createElement('span'); status.style.fontSize = '10px'; status.style.opacity = '0.85'; status.style.whiteSpace = 'normal'; status.style.overflowWrap = 'anywhere'; button.onclick = async () => { const startedAt = Date.now(); button.disabled = true; button.textContent = 'Testing...'; status.textContent = 'Checking...'; status.style.color = '#6b7280'; try { const { testDeviceConnection } = await import('./api.js'); const result = await testDeviceConnection({ deviceId: String(device?.id || device?.deviceId || ''), connectionType: device?.connectionType, address: device?.address, }); const measuredLatency = Number(result?.latencyMs) > 0 ? Number(result.latencyMs) : Date.now() - startedAt; if (result?.success) { const method = result?.method || 'Auto'; status.textContent = `✓ ${method} · ${measuredLatency}ms`; status.style.color = '#16a34a'; } else { const detail = result?.message ? ` · ${result.message}` : ''; status.textContent = `✗ Failed · ${measuredLatency}ms${detail}`; status.style.color = '#dc2626'; } } catch (e) { status.textContent = `✗ Failed · ${Date.now() - startedAt}ms`; status.style.color = '#dc2626'; } finally { button.disabled = false; button.textContent = 'Test Connection'; } }; controls.appendChild(button); controls.appendChild(status); return controls; } function formatLastSeen(value) { if (!value) { return 'N/A'; } try { const date = new Date(value); return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString(); } catch (_e) { return String(value); } } export function renderDeviceDetailsPanel(device) { const details = document.createElement('div'); details.className = 'device-details-panel'; details.style.borderTop = '1px solid #ddd'; details.style.padding = '8px'; details.style.borderRadius = '4px'; details.style.fontSize = '12px'; details.style.marginTop = '4px'; // --- Battery history trending --- // Persist battery readings in localStorage per device const batteryHistoryKey = `batteryHistory:${device?.id || device?.deviceId}`; let batteryHistory = []; try { const raw = localStorage.getItem(batteryHistoryKey); if (raw) { batteryHistory = JSON.parse(raw); } } catch (e) { // Optionally log or handle error } const now = Date.now(); if (typeof device?.battery === 'number') { // Only add if different from last or >1h since last const last = batteryHistory.at(-1); if (!last || last.value !== device.battery || now - last.ts > 60 * 60 * 1000) { batteryHistory.push({ value: device.battery, ts: now }); // Keep only last 30 entries (about a month if daily) if (batteryHistory.length > 30) { batteryHistory = batteryHistory.slice(-30); } try { localStorage.setItem(batteryHistoryKey, JSON.stringify(batteryHistory)); } catch (e) { // Optionally log or handle error } } } const rows = [ { label: 'Name', value: String(device?.name || device?.configDeviceName || 'N/A') }, { label: 'Device ID', value: String(device?.id || device?.deviceId || 'N/A'), copyable: !!(device?.id || device?.deviceId) }, { label: 'MAC Address', value: String(device?.address || 'N/A'), copyable: !!device?.address }, { label: 'Device Type', value: String(device?.type || device?.configDeviceType || 'N/A') }, { label: 'Model', value: String(device?.model || 'N/A') }, { label: 'Hub ID', value: String(device?.hubDeviceId || 'N/A') }, { label: 'Battery', value: device?.battery !== undefined && device?.battery !== null ? `${device.battery}%` : 'N/A' }, { label: 'Firmware', value: String(device?.version || device?.firmware || 'N/A') }, { label: 'Cloud Service', value: device?.enabled === false ? 'Disabled' : 'Enabled' }, { label: 'Last Seen', value: formatLastSeen(device?.lastSeen || device?.lastseen || device?.updatedAt) }, ]; for (const row of rows) { const line = document.createElement('div'); line.style.display = 'flex'; line.style.alignItems = 'center'; line.style.justifyContent = 'space-between'; line.style.gap = '8px'; line.style.padding = '2px 0'; const label = document.createElement('span'); label.style.fontWeight = '600'; label.style.minWidth = '110px'; label.textContent = `${row.label}:`; const valueWrap = document.createElement('span'); valueWrap.style.display = 'inline-flex'; valueWrap.style.alignItems = 'center'; valueWrap.style.gap = '6px'; valueWrap.style.flex = '1'; valueWrap.style.justifyContent = 'flex-end'; valueWrap.style.minWidth = '0'; const value = document.createElement('span'); value.style.fontFamily = 'monospace'; value.style.fontSize = '11px'; value.style.opacity = '0.9'; value.style.whiteSpace = 'normal'; value.style.overflowWrap = 'anywhere'; value.style.wordBreak = 'break-word'; value.style.textAlign = 'right'; value.textContent = row.value; valueWrap.appendChild(value); if (row.copyable && row.value && row.value !== 'N/A') { const copyBtn = document.createElement('button'); copyBtn.textContent = '📋'; copyBtn.title = `Copy ${row.label}`; copyBtn.style.padding = '2px 6px'; copyBtn.style.fontSize = '10px'; copyBtn.style.lineHeight = '1'; copyBtn.style.background = '#e5e7eb'; copyBtn.style.color = '#111827'; copyBtn.onclick = async () => { try { await navigator.clipboard.writeText(row.value); copyBtn.textContent = '✓'; setTimeout(() => { copyBtn.textContent = '📋'; }, 1200); } catch (_e) { copyBtn.textContent = '!'; setTimeout(() => { copyBtn.textContent = '📋'; }, 1200); } }; valueWrap.appendChild(copyBtn); } line.appendChild(label); line.appendChild(valueWrap); details.appendChild(line); // If this is the Battery row, add a sparkline chart below if (row.label === 'Battery' && Array.isArray(batteryHistory) && batteryHistory.length > 1) { const chart = document.createElement('div'); chart.style.margin = '2px 0 8px 0'; chart.style.width = '100%'; chart.style.height = '28px'; chart.style.display = 'flex'; // SVG sparkline const w = 120; const h = 24; const pad = 2; const min = Math.min(...batteryHistory.map(b => b.value), 100); const max = Math.max(...batteryHistory.map(b => b.value), 0); const range = max - min || 1; const points = batteryHistory.map((b, i) => { const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1); const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range); return `${x},${y}`; }).join(' '); const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', String(w)); svg.setAttribute('height', String(h)); svg.setAttribute('viewBox', `0 0 ${w} ${h}`); svg.style.display = 'block'; svg.style.background = '#f3f4f6'; svg.style.borderRadius = '3px'; svg.style.marginTop = '2px'; svg.style.boxShadow = '0 1px 2px #0001'; // Polyline for trend const polyline = document.createElementNS('http://www.w3.org/2000/svg', 'polyline'); polyline.setAttribute('points', points); polyline.setAttribute('fill', 'none'); polyline.setAttribute('stroke', '#2563eb'); polyline.setAttribute('stroke-width', '2'); svg.appendChild(polyline); // Dots for each point batteryHistory.forEach((b, i) => { const x = pad + (i * (w - 2 * pad)) / (batteryHistory.length - 1); const y = pad + (h - 2 * pad) * (1 - (b.value - min) / range); const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', String(x)); circle.setAttribute('cy', String(y)); circle.setAttribute('r', '2.5'); circle.setAttribute('fill', '#2563eb'); svg.appendChild(circle); }); // Min/max labels const minLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); minLabel.setAttribute('x', '2'); minLabel.setAttribute('y', String(h - 2)); minLabel.setAttribute('font-size', '9'); minLabel.setAttribute('fill', '#888'); minLabel.textContent = `${min}%`; svg.appendChild(minLabel); const maxLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); maxLabel.setAttribute('x', String(w - 18)); maxLabel.setAttribute('y', '10'); maxLabel.setAttribute('font-size', '9'); maxLabel.setAttribute('fill', '#888'); maxLabel.textContent = `${max}%`; svg.appendChild(maxLabel); chart.appendChild(svg); details.appendChild(chart); } } // --- Expose advanced/extra features dynamically --- const featureKeys = [ 'airQuality', 'pm25', 'pm10', 'voc', 'co2', 'humidity', 'temperature', 'preset', 'mode', 'presetMode', 'direction', 'calibration', 'multiCommand', 'extendedInfo', 'segmentedControl', 'features', 'capabilities', 'state', ]; const shown = new Set(rows.map(r => r.label.toLowerCase().replace(SPACES_REGEX, ''))); for (const key of featureKeys) { if (device && device[key] !== undefined && !shown.has(key.toLowerCase())) { const line = document.createElement('div'); line.style.display = 'flex'; line.style.alignItems = 'center'; line.style.justifyContent = 'space-between'; line.style.gap = '8px'; line.style.padding = '2px 0'; const label = document.createElement('span'); label.style.fontWeight = '600'; label.style.minWidth = '110px'; label.textContent = `${key.replace(CAMELCASE_REGEX, ' $1').replace(FIRST_CHAR_REGEX, s => s.toUpperCase())}:`; const value = document.createElement('span'); value.style.fontFamily = 'monospace'; value.style.fontSize = '11px'; value.style.opacity = '0.9'; value.style.whiteSpace = 'normal'; value.style.overflowWrap = 'anywhere'; value.style.wordBreak = 'break-word'; value.style.textAlign = 'right'; value.textContent = typeof device[key] === 'object' ? JSON.stringify(device[key]) : String(device[key]); line.appendChild(label); line.appendChild(value); details.appendChild(line); } } return details; } export async function renderDiscoveredDevices(devices, options = {}) { const ul = document.createElement('ul'); ul.className = 'device-grid'; ul.style.maxHeight = '400px'; ul.style.overflowY = 'auto'; ul.style.marginTop = '12px'; ul.style.padding = '0'; ul.style.listStyle = 'none'; const { addDeviceToConfig } = await import('./discovery.js'); const { loadConfiguredDevices } = await import('./devices.js'); const configuredIds = options.configuredIds ?? new Set(); const selectedIds = options.selectedIds ?? new Set(); const onToggleSelect = options.onToggleSelect; for (const d of devices) { // Defensive check: warn if device is missing id, name, or type if (!d || (!d.id && !d.deviceId) || (!d.name && !d.type)) { console.warn('[SwitchBot][Discovery][renderDiscoveredDevices] Device missing required fields:', d); } const deviceId = normalizeId(d.id); const alreadyAdded = configuredIds.has(deviceId); const li = document.createElement('li'); li.className = 'device-item'; li.style.display = 'flex'; li.style.flexDirection = 'column'; li.style.alignItems = 'stretch'; li.style.justifyContent = 'flex-start'; li.style.padding = '5px 8px'; li.style.marginBottom = '0'; li.style.borderRadius = '5px'; li.style.transition = 'all 0.2s ease'; const info = document.createElement('div'); info.style.flex = '1 1 auto'; info.style.width = '100%'; info.style.minWidth = '0'; const nameContainer = document.createElement('div'); nameContainer.style.display = 'flex'; nameContainer.style.alignItems = 'center'; nameContainer.style.marginBottom = '0'; nameContainer.style.flexWrap = 'wrap'; nameContainer.style.gap = '4px'; const name = document.createElement('div'); name.style.fontWeight = '500'; name.style.fontSize = '13px'; name.textContent = d.name || d.id; const selectCheckbox = document.createElement('input'); selectCheckbox.type = 'checkbox'; selectCheckbox.style.width = 'auto'; selectCheckbox.style.margin = '0 2px 0 0'; selectCheckbox.checked = selectedIds.has(deviceId); if (alreadyAdded) { selectCheckbox.disabled = true; selectCheckbox.title = 'Already configured'; } selectCheckbox.onchange = () => { onToggleSelect?.(d, selectCheckbox.checked); // Notify listeners (e.g., batch buttons) of selection change window.dispatchEvent(new CustomEvent('discovery-selection-changed')); }; nameContainer.appendChild(selectCheckbox); nameContainer.appendChild(name); // Show firmware update available indicator if present if (d.firmwareUpdateAvailable) { const fwBadge = document.createElement('span'); fwBadge.textContent = 'Update Available'; fwBadge.style.cssText = 'background: #fb923c; color: #111; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'; fwBadge.title = 'A firmware update is available for this device.'; nameContainer.appendChild(fwBadge); } // Show offline/unreachable indicator if device is offline let offline = false; const lastSeen = d.lastSeen || d.lastseen || d.updatedAt; if (typeof d.offline === 'boolean') { offline = d.offline; } else if (lastSeen) { try { const last = new Date(lastSeen).getTime(); if (!Number.isNaN(last)) { if (Date.now() - last > 1000 * 60 * 60) { // 1 hour offline = true; } } } catch { } } if (offline) { const offlineBadge = document.createElement('span'); offlineBadge.textContent = 'Offline'; offlineBadge.style.cssText = 'background: #dc2626; color: white; padding: 1px 7px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'; offlineBadge.title = 'Device is offline or unreachable.'; nameContainer.appendChild(offlineBadge); } const expandedDetails = document.createElement('div'); expandedDetails.style.display = 'none'; expandedDetails.appendChild(renderDeviceDetailsPanel(d)); const expandBtn = document.createElement('button'); expandBtn.textContent = '▾'; expandBtn.title = 'Show details'; expandBtn.style.padding = '2px 6px'; expandBtn.style.fontSize = '11px'; expandBtn.style.marginLeft = '4px'; expandBtn.style.background = '#e5e7eb'; expandBtn.style.color = '#111827'; expandBtn.style.transition = 'transform 0.2s ease'; expandBtn.onclick = () => { const isHidden = expandedDetails.style.display === 'none'; expandedDetails.style.display = isHidden ? 'block' : 'none'; expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'; }; nameContainer.appendChild(expandBtn); const duplicateBadge = document.createElement('span'); duplicateBadge.textContent = alreadyAdded ? '✓ Already Added' : '➕ New Device'; duplicateBadge.style.cssText = alreadyAdded ? 'background: #16a34a; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;' : 'background: #2563eb; color: white; padding: 1px 5px; border-radius: 3px; font-size: 9px; font-weight: 600;'; nameContainer.appendChild(duplicateBadge); // Add connection type badge if (d.connectionType) { const badge = renderConnectionBadge(d.connectionType); if (badge) { nameContainer.appendChild(badge); } } // Add IR badge if it's an IR device if (d.isIR) { nameContainer.appendChild(renderIRBadge()); } // Add signal strength visualization (only for BLE/wireless devices) if (d.rssi !== undefined && d.rssi !== null && d.rssi !== 0) { nameContainer.appendChild(renderSignalBars(d.rssi)); nameContainer.appendChild(renderSignalQualityBadge(d.rssi)); } // Add battery warning indicator if battery < 20% if (typeof d.battery === 'number' && d.battery < 20) { const batteryWarn = document.createElement('span'); batteryWarn.textContent = `⚠️ ${d.battery}%`; batteryWarn.style.cssText = d.battery < 10 ? 'background: #dc2626; color: white; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;' : 'background: #fbbf24; color: #111; padding: 1px 6px; border-radius: 3px; font-size: 10px; font-weight: 600; margin-left: 6px;'; batteryWarn.title = d.battery < 10 ? 'Battery critically low' : 'Battery low'; nameContainer.appendChild(batteryWarn); } // Defensive check: warn if device is missing id, name, or type (for details panel) if (!d || (!d.id && !d.deviceId) || (!d.name && !d.type)) { console.warn('[SwitchBot][Discovery][renderDeviceDetailsPanel] Device missing required fields:', d); } const details = document.createElement('div'); details.style.fontSize = '10px'; details.style.opacity = '0.7'; details.style.marginTop = '0'; details.style.fontFamily = 'monospace'; details.style.whiteSpace = 'normal'; details.style.overflowWrap = 'anywhere'; details.style.wordBreak = 'break-word'; let detailsText = `ID: ${d.id} | Type: ${d.type} | Model: ${d.model || 'N/A'}`; if (d.hubDeviceId) { detailsText += ` | Hub: ${d.hubDeviceId}`; } if (d.address) { detailsText += ` | MAC: ${d.address}`; } details.textContent = detailsText; info.appendChild(nameContainer); info.appendChild(details); info.appendChild(expandedDetails); const addBtn = document.createElement('button'); addBtn.textContent = alreadyAdded ? 'Already Added' : 'Add to Config'; addBtn.style.marginLeft = '0'; addBtn.style.marginTop = '2px'; addBtn.style.padding = '4px 9px'; addBtn.style.fontSize = '11px'; addBtn.style.whiteSpace = 'nowrap'; addBtn.style.flexShrink = '0'; addBtn.disabled = alreadyAdded; if (alreadyAdded) { addBtn.style.opacity = '0.65'; addBtn.style.cursor = 'not-allowed'; addBtn.style.background = '#6b7280'; } addBtn.onclick = async () => { if (alreadyAdded) { return; } await addDeviceToConfig(d); }; if (alreadyAdded) { const viewBtn = document.createElement('button'); viewBtn.textContent = 'View in Config'; viewBtn.className = 'secondary'; viewBtn.style.marginLeft = '0'; viewBtn.style.padding = '4px 9px'; viewBtn.style.fontSize = '11px'; viewBtn.onclick = async () => { await loadConfiguredDevices(); scrollToConfiguredDevice(d.id); }; li.appendChild(info); const actions = document.createElement('div'); actions.className = 'device-actions'; actions.style.display = 'flex'; actions.style.alignItems = 'center'; actions.style.flexWrap = 'wrap'; actions.style.justifyContent = 'flex-start'; actions.style.marginLeft = '0'; actions.style.width = '100%'; actions.style.marginTop = '2px'; actions.style.gap = '5px'; actions.appendChild(viewBtn); actions.appendChild(addBtn); actions.appendChild(createConnectionTestControls(d)); li.appendChild(actions); ul.appendChild(li); continue; } const actions = document.createElement('div'); actions.className = 'device-actions'; actions.style.display = 'flex'; actions.style.flexWrap = 'wrap'; actions.style.justifyContent = 'flex-start'; actions.style.marginLeft = '0'; actions.style.width = '100%'; actions.style.marginTop = '2px'; actions.style.gap = '5px'; actions.appendChild(addBtn); actions.appendChild(createConnectionTestControls(d)); li.appendChild(info); li.appendChild(actions); ul.appendChild(li); } return ul; } /** * Filter devices by connection type and search query * @param devices Discovered devices array * @param connectionType Filter: 'all' | 'ble' | 'api' | 'both' | 'ir' * @param searchQuery Search term to match against name/id/type * @returns Filtered devices array */ export function filterDevices(devices, connectionType = 'all', searchQuery = '') { let filtered = [...devices]; // Filter by connection type if (connectionType !== 'all') { filtered = filtered.filter((d) => { if (connectionType === 'ir') { return d.isIR === true; } if (connectionType === 'ble') { return d.connectionType === 'BLE' || d.connectionType?.includes('BLE'); } if (connectionType === 'api') { return d.connectionType === 'OpenAPI' || d.connectionType === 'API' || d.connectionType?.includes('API'); } if (connectionType === 'both') { return d.connectionType === 'Both' || d.connectionType?.includes('Both'); } return true; }); } // Filter by search query if (searchQuery.trim()) { const query = searchQuery.toLowerCase(); filtered = filtered.filter((d) => { const name = (d.name || '').toLowerCase(); const id = (d.id || '').toLowerCase(); const type = (d.type || '').toLowerCase(); const model = (d.model || '').toLowerCase(); return name.includes(query) || id.includes(query) || type.includes(query) || model.includes(query); }); } return filtered; } /** * Sort devices by specified criteria * @param devices Devices array to sort * @param sortBy Sort criterion: 'name' | 'signal' | 'type' | 'connection' * @returns Sorted devices array */ export function sortDevices(devices, sortBy = 'name') { const sorted = [...devices]; switch (sortBy) { case 'signal': { // Sort by RSSI descending (strongest signal first) sorted.sort((a, b) => { const aRssi = a.rssi || 0; const bRssi = b.rssi || 0; return bRssi - aRssi; // Descending order (higher is stronger) }); break; } case 'type': { // Sort by device type alphabetically sorted.sort((a, b) => { const aType = (a.type || '').localeCompare(b.type || ''); return aType; }); break; } case 'connection': { // Sort by connection type: Both > BLE > OpenAPI > Others const connectionOrder = { Both: 0, BLE: 1, OpenAPI: 2, API: 2, Unknown: 3, }; sorted.sort((a, b) => { const aOrder = connectionOrder[a.connectionType || 'Unknown'] ?? 3; const bOrder = connectionOrder[b.connectionType || 'Unknown'] ?? 3; return aOrder - bOrder; }); break; } case 'name': default: { // Sort by name alphabetically sorted.sort((a, b) => (a.name || a.id || '').localeCompare(b.name || b.id || '')); break; } } return sorted; } /** * Get filter/sort preferences from localStorage * @returns Object with current filter and sort preferences */ export function getDiscoveryPreferences() { try { const stored = localStorage.getItem('discoveryPreferences'); if (stored) { return JSON.parse(stored); } } catch (_e) { // Ignore parse errors } return { connectionType: 'all', sortBy: 'name', searchQuery: '', }; } /** * Save filter/sort preferences to localStorage * @param preferences Preferences object to save * @param preferences.connectionType Connection type filter * @param preferences.sortBy Sort criterion * @param preferences.searchQuery Search query string */ export function setDiscoveryPreferences(preferences) { try { localStorage.setItem('discoveryPreferences', JSON.stringify(preferences)); } catch (_e) { // Ignore storage errors } } export function renderDeviceList(list) { const ul = document.getElementById('devices'); const status = document.getElementById('status'); const removeAllContainer = document.getElementById('removeAllContainer'); if (!ul || !status) { return; } if (!list.length) { status.textContent = 'No devices found in config.'; ul.innerHTML = ''; // Hide remove all button when no devices if (removeAllContainer) { removeAllContainer.style.display = 'none'; } return; } status.textContent = `Found ${list.length} device(s)`; ul.classList.add('device-grid'); ul.style.padding = '0'; ul.innerHTML = ''; // Show remove all button when devices exist if (removeAllContainer) { removeAllContainer.style.display = 'block'; } for (const d of list) { const li = document.createElement('li'); li.className = 'device-item'; li.setAttribute('data-device-id', normalizeId(d.id)); li.style.display = 'flex'; li.style.flexDirection = 'column'; li.style.alignItems = 'stretch'; li.style.padding = '5px 8px'; li.style.marginBottom = '0'; const info = document.createElement('div'); info.style.flex = '1 1 auto'; info.style.width = '100%'; info.style.minWidth = '0'; const nameContainer = document.createElement('div'); nameContainer.style.display = 'flex'; nameContainer.style.alignItems = 'center'; nameContainer.style.marginBottom = '0'; nameContainer.style.flexWrap = 'wrap'; nameContainer.style.gap = '4px'; const name = document.createElement('div'); name.style.fontWeight = '500'; name.style.fontSize = '13px'; name.textContent = d.configDeviceName || d.name || d.id; const expandedDetails = document.createElement('div'); expandedDetails.style.display = 'none'; expandedDetails.appendChild(renderDeviceDetailsPanel(d)); const expandBtn = document.createElement('button'); expandBtn.textContent = '▾'; expandBtn.title = 'Show details'; expandBtn.style.padding = '2px 6px'; expandBtn.style.fontSize = '11px'; expandBtn.style.marginLeft = '4px'; expandBtn.style.background = '#e5e7eb'; expandBtn.style.color = '#111827'; expandBtn.style.transition = 'transform 0.2s ease'; expandBtn.onclick = () => { const isHidden = expandedDetails.style.display === 'none'; expandedDetails.style.display = isHidden ? 'block' : 'none'; expandBtn.style.transform = isHidden ? 'rotate(180deg)' : 'rotate(0deg)'; }; nameContainer.appendChild(name); nameContainer.appendChild(expandBtn); // Add signal strength visualization if RSSI is available if (d.rssi !== undefined && d.rssi !== null && d.rssi !== 0) { nameContainer.appendChild(renderSignalBars(d.rssi)); nameContainer.appendChild(renderSignalQualityBadge(d.rssi)); } const meta = document.createElement('div'); meta.style.opacity = '0.7'; meta.style.fontSize = '10px'; meta.style.fontFamily = 'monospace'; const deviceIdentifier = d.deviceId || d.id; const id = `ID: ${deviceIdentifier}`; const typeText = d.configDeviceType || d.type ? `Type: ${d.configDeviceType || d.type}` : ''; const connText = d.connectionPreference ? `Conn: ${d.connectionPreference}` : ''; const roomText = d.room ? `Room: ${d.room}` : ''; meta.textContent = [id, typeText, connText, roomText].filter(Boolean).join(' | '); info.appendChild(nameContainer); info.appendChild(meta); info.appendChild(expandedDetails); const buttons = document.createElement('div'); buttons.className = 'device-actions'; buttons.style.display = 'flex'; buttons.style.flexWrap = 'wrap'; buttons.style.justifyContent = 'flex-start'; buttons.style.marginLeft = '0'; buttons.style.width = '100%'; buttons.style.marginTop = '2px'; buttons.style.gap = '5px'; const editBtn = document.createElement('button'); editBtn.textContent = '✏️ Edit'; editBtn.style.padding = '4px 9px'; editBtn.style.fontSize = '11px'; editBtn.onclick = async () => { const { editDevice } = await import('./modals.js'); await editDevice(d); }; const copyBtn = document.createElement('button'); copyBtn.textContent = 'Copy ID'; copyBtn.style.padding = '4px 9px'; copyBtn.style.fontSize = '11px'; copyBtn.addEventListener('click', async () => { try { if (deviceIdentifier) { await copyTextWithFallback(deviceIdentifier); } copyBtn.textContent = 'Copied'; copyBtn.classList.add('success'); setTimeout(() => { copyBtn.textContent = 'Copy ID'; copyBtn.classList.remove('success'); }, 1200); } catch (e) { copyBtn.textContent = 'Failed'; copyBtn.classList.add('error'); setTimeout(() => { copyBtn.textContent = 'Copy ID'; copyBtn.classList.remove('error'); }, 1200); } }); const deleteBtn = document.createElement('button'); deleteBtn.textContent = '🗑️ Delete'; deleteBtn.style.padding = '4px 9px'; deleteBtn.style.fontSize = '11px'; deleteBtn.style.background = '#ef4444'; deleteBtn.onclick = async () => { const { deleteDeviceFromConfig } = await import('./devices-delete.js'); await deleteDeviceFromConfig(d.id || d.deviceId, d.name || d.id || d.deviceId); }; buttons.appendChild(editBtn); buttons.appendChild(copyBtn); buttons.appendChild(deleteBtn); buttons.appendChild(createConnectionTestControls(d)); li.appendChild(info); li.appendChild(buttons); ul.appendChild(li); } // No return value needed for void function } //# sourceMappingURL=render.js.map