UNPKG

@switchbot/homebridge-switchbot

Version:

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

1,090 lines (978 loc) 36.5 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: string): Promise<void> { 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: number | undefined): { level: 'excellent' | 'good' | 'fair' | 'poor' | 'unknown' color: string bgColor: string description: string bars: number } { 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: number | undefined): HTMLElement { 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: number | undefined): HTMLElement { 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: string, style: string): HTMLElement { const badge = document.createElement('span') badge.textContent = text badge.style.cssText = style return badge } export function renderConnectionBadge(connectionType: string): HTMLElement | null { 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(): HTMLElement { 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: any): string { return String(value ?? '').trim().toLowerCase() } function scrollToConfiguredDevice(deviceId: string): void { const normalizedId = normalizeId(deviceId) const target = document.querySelector(`[data-device-id="${normalizedId}"]`) as HTMLElement | null 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: any): HTMLElement { 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: any): string { 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: any): HTMLElement { 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: Array<{ value: number, ts: number }> = [] 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: Array<{ label: string, value: string, copyable?: boolean }> = [ { 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: any[], options: { configuredIds?: Set<string> selectedIds?: Set<string> onToggleSelect?: (device: any, selected: boolean) => void } = {}, ): Promise<HTMLElement> { 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<string>() const selectedIds = options.selectedIds ?? new Set<string>() 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: any[], connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir' = 'all', searchQuery = '', ): any[] { 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: any[], sortBy: 'name' | 'signal' | 'type' | 'connection' = 'name', ): any[] { 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: Record<string, number> = { 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(): { connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir' sortBy: 'name' | 'signal' | 'type' | 'connection' searchQuery: string } { 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: { connectionType: 'all' | 'ble' | 'api' | 'both' | 'ir' sortBy: 'name' | 'signal' | 'type' | 'connection' searchQuery: string }): void { try { localStorage.setItem('discoveryPreferences', JSON.stringify(preferences)) } catch (_e) { // Ignore storage errors } } export function renderDeviceList(list: any[]): void { 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 }