UNPKG

@switchbot/homebridge-switchbot

Version:

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

241 lines (210 loc) • 10.3 kB
import type { HomebridgePluginUiServer } from '@homebridge/plugin-ui-utils' import { RequestError } from '@homebridge/plugin-ui-utils' import { getCredential, getSwitchBotPlatformConfig } from '../utils/config-parser.js' import { uiLog } from '../utils/logger.js' /** * Register discovery endpoint * Discovers SwitchBot devices via BLE and OpenAPI */ export function registerDiscoveryEndpoint(server: HomebridgePluginUiServer) { server.onRequest('/ble-status', async () => { try { const { platform } = await getSwitchBotPlatformConfig(server) const token = getCredential(platform, 'openApiToken') || platform.token const secret = getCredential(platform, 'openApiSecret') || platform.secret const { SwitchBot } = await import('node-switchbot') const switchbot = new SwitchBot({ token: token || undefined, secret: secret || undefined, enableBLE: true, enableFallback: true, enableRetry: true, enableCircuitBreaker: true, enableConnectionIntelligence: true, }) await switchbot.discover({ timeout: 1000 }) return { success: true, data: { available: true, message: 'adapter ready' } } } catch (e) { const message = e instanceof Error ? e.message : String(e) uiLog.warn(`GET /ble-status failed: ${message}`) return { success: true, data: { available: false, message } } } }) server.onRequest('/discover', async (payload?: any) => { uiLog.debug(`[SwitchBot UI/Server] /discover incoming payload: ${JSON.stringify(payload)}`) try { const { platform } = await getSwitchBotPlatformConfig(server) const mode = String(payload?.mode || 'all').toLowerCase() // Only run BLE if mode is all/ble AND bleEnabled is not false (default true if missing) const bleEnabled = payload?.bleEnabled !== false; const runBle = (mode === 'all' || mode === 'ble') && bleEnabled; const runOpenApi = mode === 'all' || mode === 'openapi' const bleScanDurationSeconds = Math.max(3, Math.min(15, Number(payload?.bleScanDurationSeconds || 5))) const bleTimeoutSeconds = Math.max(3, Math.min(30, Number(payload?.bleTimeoutSeconds || 8))) uiLog.debug(`GET /discover - Platform config keys: ${Object.keys(platform).join(', ')}`) const token = getCredential(platform, 'openApiToken') || platform.token const secret = getCredential(platform, 'openApiSecret') || platform.secret const hasOpenAPICredentials = !!(token && secret) if (!hasOpenAPICredentials) { uiLog.warn('GET /discover - No OpenAPI credentials found, will attempt BLE-only discovery') } else { uiLog.info('GET /discover - Using OpenAPI credentials for discovery') } // Import and initialize node-switchbot const { SwitchBot } = await import('node-switchbot') const switchbot = new SwitchBot({ token: token || undefined, secret: secret || undefined, enableBLE: true, enableFallback: true, enableRetry: true, enableCircuitBreaker: true, enableConnectionIntelligence: true, }) const deviceMap = new Map<string, any>() // 1. Try BLE discovery first (with timeout) if (runBle) { uiLog.info('GET /discover - Starting BLE scan...') try { const bleTimeout = bleTimeoutSeconds * 1000 const bleDiscoveryPromise = switchbot.discover({ timeout: bleScanDurationSeconds * 1000 }) const bleDevices = await Promise.race([ bleDiscoveryPromise, new Promise<any[]>(resolve => setTimeout(resolve, bleTimeout, [])), ]) if (Array.isArray(bleDevices) && bleDevices.length > 0) { uiLog.info(`GET /discover - Found ${bleDevices.length} BLE devices`) for (const [index, d] of bleDevices.entries()) { const info = typeof d?.getInfo === 'function' ? d.getInfo() : undefined const id = d?.id || (typeof d?.getId === 'function' ? d.getId() : undefined) || info?.id const mac = d?.mac || (typeof d?.getMAC === 'function' ? d.getMAC() : undefined) || info?.mac if (!id) { uiLog.warn(`GET /discover - BLE device at index ${index} has no id, skipping`) continue } const connectionTypes = Array.isArray(info?.connectionTypes) ? info.connectionTypes : [] const isHybrid = connectionTypes.includes('api') deviceMap.set(id, { id, name: d?.name || (typeof d?.getName === 'function' ? d.getName() : undefined) || info?.name || d?.deviceName || id, type: d?.deviceType || (typeof d?.getDeviceType === 'function' ? d.getDeviceType() : undefined) || info?.deviceType || d?.type || d?.model || 'unknown', model: info?.model || d?.model || d?.deviceModel, address: mac, connectionType: isHybrid ? 'Both' : 'BLE', rssi: info?.rssi || d?.rssi, }) } } else { uiLog.info('GET /discover - No BLE devices found or scan timed out') } } catch (bleErr) { uiLog.warn(`GET /discover - BLE discovery failed: ${bleErr instanceof Error ? bleErr.message : String(bleErr)}`) // Continue with OpenAPI even if BLE fails } } // 2. Get devices from OpenAPI (only if credentials are available) if (runOpenApi && hasOpenAPICredentials) { uiLog.info('GET /discover - Fetching devices from OpenAPI...') try { const apiClient = switchbot.getAPIClient() if (!apiClient) { throw new Error('API client not available - token/secret may be missing') } const apiData = await apiClient.getDevices() uiLog.debug(`GET /discover - OpenAPI response: ${JSON.stringify(apiData)}`) // Parse physical devices - apiData is DeviceListResponse with deviceList and infraredRemoteList const devices = apiData.deviceList || [] const irDevices = apiData.infraredRemoteList || [] uiLog.info(`GET /discover - Found ${devices.length} OpenAPI physical devices and ${irDevices.length} IR devices`) // Process physical devices from OpenAPI for (const d of devices) { const id = d.deviceId if (!id) { continue } const existing = deviceMap.get(id) if (existing) { // Device found via both BLE and OpenAPI existing.connectionType = 'Both' existing.name = d.deviceName || existing.name existing.type = d.deviceType || existing.type // Note: APIDevice doesn't have a model property existing.enabled = d.enableCloudService !== false existing.hubDeviceId = d.hubDeviceId } else { // Device only found via OpenAPI deviceMap.set(id, { id, name: d.deviceName || id, type: d.deviceType || 'unknown', enabled: d.enableCloudService !== false, hubDeviceId: d.hubDeviceId, connectionType: 'OpenAPI', }) } } // Process IR devices (OpenAPI only) for (const d of irDevices) { const id = d.deviceId if (!id) { continue } deviceMap.set(id, { id, name: d.deviceName || id, type: d.remoteType || 'unknown', enabled: true, hubDeviceId: d.hubDeviceId, connectionType: 'OpenAPI', isIR: true, }) } } catch (apiErr) { uiLog.error(`GET /discover - OpenAPI discovery failed: ${apiErr instanceof Error ? apiErr.message : String(apiErr)}`) // If we have BLE devices, we can still return those if (deviceMap.size === 0) { throw apiErr } } } else if (runOpenApi) { uiLog.info('GET /discover - Skipping OpenAPI discovery (no credentials configured)') } const normalizedBleDevices = [...deviceMap.values()].filter(d => d.connectionType === 'BLE' || d.connectionType === 'Both') const firstNormalizedBleId = normalizedBleDevices[0]?.id || 'none' uiLog.info(`GET /discover - Normalized BLE devices: ${normalizedBleDevices.length} (firstId: ${firstNormalizedBleId})`) // Check if we found any devices if (deviceMap.size === 0 && mode === 'openapi' && !hasOpenAPICredentials) { return { success: true, data: [] } } if (deviceMap.size === 0) { const errorMsg = hasOpenAPICredentials ? 'No devices found via BLE or OpenAPI. Make sure devices are powered on and in range.' : 'No devices found via BLE. OpenAPI credentials not configured. Please save your credentials in settings to discover cloud-connected devices, or ensure BLE devices are powered on and in range.' uiLog.error(`GET /discover - ${errorMsg}`) throw new Error(errorMsg) } // Convert map to array const allDiscovered = [...deviceMap.values()] const bleCount = allDiscovered.filter(d => d.connectionType === 'BLE').length const apiCount = allDiscovered.filter(d => d.connectionType === 'OpenAPI').length const bothCount = allDiscovered.filter(d => d.connectionType === 'Both').length uiLog.info(`GET /discover - Total: ${allDiscovered.length} devices (BLE: ${bleCount}, OpenAPI: ${apiCount}, Both: ${bothCount})`) return { success: true, data: allDiscovered } } catch (e) { if (e instanceof Error) { uiLog.error(`Error in /discover: ${e.message}`) if (e.stack) { uiLog.error(`[Stack] ${e.stack}`) } uiLog.error(`[Object]`, e) } else { uiLog.error(`Error in /discover: ${String(e)}`) } throw new RequestError(`Failed to discover devices: ${e instanceof Error ? e.message : String(e)}`, e) } }) }