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