import { ILumiaDeviceBrands, ILumiaDeviceConnectionType, ILumiaDeviceType } from '@lumiastream/lumia-rgb-types'; import { ITapo } from './types/interfaces'; import { UnifiedDevice, UnifiedDeviceFactory, DeviceProtocol } from './unified-device-factory'; import { TapoApi } from './protocols/tapo-api'; import { base64Decode } from './utils/cipher'; import { throwErrorIfFound } from './utils/helpers'; import Axios from 'axios'; import findLocalDevices from 'local-devices'; // Import Kasa discovery functions import NodeDgram from 'dgram'; import { decrypt, encrypt } from './utils/kasa-utils'; import { TplinkDeviceTypes, ETplinkDeviceTypes } from './constants/tplink.constants'; import { getBroadcastNetworkInterfaces } from '@lumiastream/network-cove'; let dgram; if (typeof window !== 'undefined' && typeof (window).dgram !== undefined) { dgram = (window).dgram; } else { dgram = NodeDgram; } export interface UnifiedDiscoveryConfig { token?: string; email?: string; password?: string; types?: ILumiaDeviceType[]; includeKasa?: boolean; includeTapo?: boolean; kasaTimeout?: number; } // Discover Kasa devices via UDP const discoverKasaDevices = (types = TplinkDeviceTypes.ALL, address = '255.255.255.255', timeout = 4000): Promise => new Promise((resolve, reject): any => { const devices = {}; const allFound: Array = []; const deviceTypes: Array = []; if (types === TplinkDeviceTypes.ALL || types === TplinkDeviceTypes.BULBS) { deviceTypes.push(TplinkDeviceTypes.BULBS); } if (types === TplinkDeviceTypes.ALL || types === TplinkDeviceTypes.PLUGS) { deviceTypes.push(TplinkDeviceTypes.PLUGS); } try { const discoveryMsgBuf = encrypt('{"system":{"get_sysinfo":null}}'); const defaultPort = 9999; let socket = dgram.createSocket('udp4'); let broadcastInterval; socket.on('message', (msg, rinfo) => { const decryptedMsg = decrypt(msg).toString('utf8'); if (_isKasaDevice(decryptedMsg)) { const sysInfo = JSON.parse(decryptedMsg).system.get_sysinfo; sysInfo.address = rinfo.address; sysInfo.id = sysInfo.deviceId; sysInfo.name = sysInfo.alias; sysInfo.port = rinfo.port; sysInfo.host = rinfo.address; sysInfo.protocol = DeviceProtocol.KASA; if (!allFound.some((device) => device === sysInfo.address)) { allFound.push(sysInfo.address); if (sysInfo.model.toLowerCase().startsWith('lb') || sysInfo.model.toLowerCase().startsWith('kl') || sysInfo.model.toLowerCase() === 'ep40') { if (sysInfo.model.toLowerCase().startsWith('kl4')) { sysInfo.deviceType = ETplinkDeviceTypes.LIGHTSTRIPS; } else { sysInfo.deviceType = ETplinkDeviceTypes.BULBS; } if (!devices['lights']) { devices['lights'] = [sysInfo]; } else { devices['lights'].push(sysInfo); } } else if ( sysInfo.model.toLowerCase().startsWith('hs') || sysInfo.model.toLowerCase().startsWith('kp') || sysInfo.model.toLowerCase().startsWith('ep') || sysInfo.model.toLowerCase().startsWith('ks') ) { sysInfo.deviceType = ETplinkDeviceTypes.PLUGS; if (!devices['plugs']) { devices['plugs'] = [sysInfo]; } else { devices['plugs'].push(sysInfo); } } } } }); socket.bind(defaultPort, undefined, () => { socket.setBroadcast(true); const broadcastList = [address, ...getBroadcastNetworkInterfaces()]; broadcastInterval = setInterval(() => { broadcastList.forEach((addr) => { socket.send(discoveryMsgBuf, 0, discoveryMsgBuf.length, defaultPort, addr); }); }, 800); setTimeout(() => { clearInterval(broadcastInterval); socket.close(); resolve({ devices, allFound }); }, timeout); }); } catch (e) { reject(e); } }); const _isKasaDevice = (description: string): boolean => { return description.includes('IOT.SMART') && description.includes('get_sysinfo'); }; // Discover Tapo devices via cloud API const discoverTapoDevices = async (config: { token?: string; email?: string; password?: string; types?: ILumiaDeviceType[] }): Promise> => { const api = new TapoApi(config); let token = config.token; if (!token && config.email && config.password) { token = await api.auth({ email: config.email, password: config.password }); } if (!token) { throw new Error('No token available for Tapo discovery'); } const getDeviceRequest = { method: 'getDeviceList', }; const response = await Axios({ method: 'post', url: `${api._baseUrl}/?token=${token}`, data: getDeviceRequest, }); throwErrorIfFound(response.data); const devices: Array = []; const localDevices = await findLocalDevices({ skipNameResolution: true }); response.data.result?.deviceList.forEach(async (deviceInfo: any) => { if (!deviceInfo.ip) { const findableMac = deviceInfo.deviceMac.replace(/:/g, '').toUpperCase(); // Enhanced MAC filtering - prioritize Tapo device MAC prefixes const tapoMacPrefixes = ['84D81B', '1CEACF', '98DAC4', '50C7BF']; const isTapoMac = tapoMacPrefixes.some((prefix) => findableMac.startsWith(prefix)); if (isTapoMac) { const found = localDevices.find((device) => { const deviceMac = device.mac.replace(/:/g, '').toUpperCase(); return deviceMac === findableMac || deviceMac.includes(findableMac.substring(0, 8)); }); if (found) { deviceInfo.ip = found.ip; } else { console.warn(`Tapo device ${deviceInfo.deviceName} (${deviceInfo.deviceMac}) not found on local network`); return; } } else { const found = localDevices.find((device) => findableMac == device.mac.replace(/:/g, '').toUpperCase()); if (found) { deviceInfo.ip = found.ip; } else { return; } } } // Convert to unified format const unifiedDevice = UnifiedDeviceFactory.createUnifiedDevice({ ...deviceInfo, protocol: DeviceProtocol.TAPO, }); // Convert to ITapo format for backward compatibility const tapoDevice = convertToLumiaFormat(deviceInfo, config.types); if (tapoDevice) { devices.push(tapoDevice); } }); return devices; }; // Convert device info to Lumia format const convertToLumiaFormat = (deviceInfo: any, types?: ILumiaDeviceType[]): ITapo | null => { switch (deviceInfo.deviceType) { case 'IOT.SMARTBULB': case 'SMART.TAPOBULB': case 'SMART.TAPOSTRIP': case 'IOT.SMARTSTRIP': { if (types && !types.includes(ILumiaDeviceType.LIGHT)) { return null; } const isTapo = deviceInfo.deviceType.startsWith('SMART.TAPO'); const alias = (isTapo ? base64Decode(deviceInfo.alias) : deviceInfo.alias) ?? deviceInfo.deviceName; const isL900 = deviceInfo?.deviceModel?.includes('L900'); const maxZones = isL900 ? 10 : 1; return { name: alias, id: deviceInfo.deviceMac, address: `http://${deviceInfo.ip}`, host: deviceInfo.ip, lumiaInfo: { alias, identifier: deviceInfo.deviceMac, serial: deviceInfo.hwId, lumiaType: ILumiaDeviceType.LIGHT, zonable: isL900, maxZones: maxZones, zones: isL900 ? Array.from({ length: maxZones }, (_, i) => ({ id: i, name: `Zone ${i + 1}`, addressable: i })) : [], rgb: true, white: true, connectionType: ILumiaDeviceConnectionType.WIFI, brand: ILumiaDeviceBrands.TPLINK, product: deviceInfo?.deviceModel, }, }; } case 'IOT.SMARTPLUGSWITCH': case 'SMART.TAPOPLUG': { if (types && !types.includes(ILumiaDeviceType.PLUG)) { return null; } const isTapo = deviceInfo.deviceType === 'SMART.TAPOPLUG'; const alias = (isTapo ? base64Decode(deviceInfo.alias) : deviceInfo.alias) ?? deviceInfo.deviceName; const isP300 = deviceInfo?.deviceModel?.includes('P300'); const maxZones = isP300 ? 3 : 0; return { name: alias, id: deviceInfo.deviceMac, address: `http://${deviceInfo.ip}`, host: deviceInfo.ip, lumiaInfo: { alias, identifier: deviceInfo.deviceMac, serial: deviceInfo.hwId, lumiaType: ILumiaDeviceType.PLUG, zonable: isP300, maxZones: maxZones, zones: isP300 ? Array.from({ length: maxZones }, (_, i) => ({ id: i, name: `Outlet ${i + 1}`, addressable: i })) : [], rgb: false, white: false, connectionType: ILumiaDeviceConnectionType.WIFI, brand: ILumiaDeviceBrands.TPLINK, product: deviceInfo?.deviceModel, }, }; } case 'SMART.TAPOCAMERA': case 'IOT.SMARTCAMERA': { if (types && !types.includes(ILumiaDeviceType.PLUG)) { return null; } const isTapo = deviceInfo.deviceType === 'SMART.TAPOCAMERA'; const alias = (isTapo ? base64Decode(deviceInfo.alias) : deviceInfo.alias) ?? deviceInfo.deviceName; const isC320WS = deviceInfo?.deviceModel?.includes('C320WS'); return { name: alias, id: deviceInfo.deviceMac, address: `http://${deviceInfo.ip}`, host: deviceInfo.ip, lumiaInfo: { alias, identifier: deviceInfo.deviceMac, serial: deviceInfo.hwId, lumiaType: ILumiaDeviceType.PLUG, zonable: false, maxZones: 0, zones: [], rgb: false, white: isC320WS, connectionType: ILumiaDeviceConnectionType.WIFI, brand: ILumiaDeviceBrands.TPLINK, product: deviceInfo?.deviceModel, }, }; } default: { console.warn(`Unknown device type: ${deviceInfo.deviceType} for device ${deviceInfo.deviceName}`); return null; } } }; // Main unified discovery function const unifiedDiscover = async ( config: UnifiedDiscoveryConfig = {}, ): Promise<{ kasaDevices: any; tapoDevices: Array; allDevices: Array; }> => { const { includeKasa = true, includeTapo = true, kasaTimeout = 4000, ...tapoConfig } = config; let kasaDevices = {}; let tapoDevices: Array = []; const allDevices: Array = []; // Discover Kasa devices if enabled if (includeKasa) { try { console.debug('[UnifiedDiscovery] Starting Kasa device discovery...'); const kasaResult = await discoverKasaDevices(TplinkDeviceTypes.ALL, '255.255.255.255', kasaTimeout); kasaDevices = kasaResult.devices; // Convert Kasa devices to unified format if (kasaDevices['lights']) { kasaDevices['lights'].forEach((device: any) => { allDevices.push(UnifiedDeviceFactory.createUnifiedDevice(device, DeviceProtocol.KASA)); }); } if (kasaDevices['plugs']) { kasaDevices['plugs'].forEach((device: any) => { allDevices.push(UnifiedDeviceFactory.createUnifiedDevice(device, DeviceProtocol.KASA)); }); } console.debug(`[UnifiedDiscovery] Found ${allDevices.length} Kasa devices`); } catch (error) { console.error('[UnifiedDiscovery] Kasa discovery failed:', error); } } // Discover Tapo devices if enabled and credentials provided if (includeTapo && (tapoConfig.token || (tapoConfig.email && tapoConfig.password))) { try { console.debug('[UnifiedDiscovery] Starting Tapo device discovery...'); tapoDevices = await discoverTapoDevices(tapoConfig); // Convert Tapo devices to unified format tapoDevices.forEach((device) => { allDevices.push( UnifiedDeviceFactory.createUnifiedDevice( { id: device.id, name: device.name, host: device.host, deviceType: device.lumiaInfo?.lumiaType, model: device.lumiaInfo?.product, }, DeviceProtocol.TAPO, ), ); }); console.debug(`[UnifiedDiscovery] Found ${tapoDevices.length} Tapo devices`); } catch (error: any) { console.error('[UnifiedDiscovery] Tapo discovery failed:', error?.message); } } console.debug(`[UnifiedDiscovery] Total devices found: ${allDevices.length}`); return { kasaDevices, tapoDevices, allDevices, }; }; export default unifiedDiscover; export { discoverKasaDevices, discoverTapoDevices };