@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
text/typescript
// 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
}