import { Socket , createSocket , RemoteInfo } from 'dgram' ; import { EventEmitter } from 'events' ; import util from 'util' ; import type log from 'loglevel' ; import { encrypt , decrypt } from 'tplink-smarthome-crypto' ; import type { MarkOptional } from 'ts-essentials' ; import Device , { isBulbSysinfo , isPlugSysinfo } from './device' ; import type { Sysinfo } from './device' ; import Bulb from './bulb' ; import Plug , { hasSysinfoChildren } from './plug' ; import createLogger from './logger' ; import type { Logger } from './logger' ; import TcpConnection from './network/tcp-connection' ; import UdpConnection from './network/udp-connection' ; import { compareMac , isObjectLike } from './utils' ; import { Realtime } from './shared/emeter' ; const discoveryMsgBuf = encrypt ( '{"system":{"get_sysinfo":{}}}' ) ; export type AnyDevice = Bulb | Plug ; type DeviceDiscovery = { status : string ; seenOnDiscovery : number ; } ; type AnyDeviceDiscovery = ( Bulb | Plug ) & Partial < DeviceDiscovery > ; type SysinfoResponse = { system : { get_sysinfo : Sysinfo ; } ; } ; type EmeterResponse = PlugEmeterResponse | BulbEmeterResponse ; type PlugEmeterResponse = { emeter ? : { get_realtime ? : { err_code : number ; } & Realtime ; } ; } ; type BulbEmeterResponse = { 'smartlife.iot.common.emeter' ? : { get_realtime ? : { err_code : number ; } & Realtime ; } ; } ; type DiscoveryResponse = SysinfoResponse & EmeterResponse ; type AnyDeviceOptions = ConstructorParameters < typeof Bulb > [ 0 ] | ConstructorParameters < typeof Plug > [ 0 ] ; type AnyDeviceOptionsCon = MarkOptional < ConstructorParameters < typeof Plug > [ 0 ] , 'client' | 'sysInfo' > | MarkOptional < ConstructorParameters < typeof Bulb > [ 0 ] , 'client' | 'sysInfo' > ; type DeviceOptionsDiscovery = MarkOptional < ConstructorParameters < typeof Plug > [ 0 ] , 'client' | 'sysInfo' | 'host' > | MarkOptional < ConstructorParameters < typeof Bulb > [ 0 ] , 'client' | 'sysInfo' | 'host' > ; export type DiscoveryDevice = { host : string ; port ? : number ; } ; function isSysinfoResponse ( candidate ) { return ( isObjectLike ( candidate ) && 'system' in candidate && isObjectLike ( candidate . system ) && 'get_sysinfo' in candidate . system ) ; } export interface ClientConstructorOptions { defaultValue defaultSendOptions ? : SendOptions ; defaultValue logLevel ? : log . LogLevelDesc ; logger ? : Logger ; } export interface DiscoveryOptions { /**
     * address to bind udp socket
     */ address ? : string ; /**
     * port to bind udp socket
     */ port ? : number ; defaultValue broadcast ? : string ; defaultValue discoveryInterval ? : number ; defaultValue discoveryTimeout ? : number ; defaultValue offlineTolerance ? : number ; deviceTypes ? : Array < 'plug' | 'bulb' > ; defaultValue macAddresses ? : string [ ] ; defaultValue excludeMacAddresses ? : string [ ] ; /**
     * called with fn(sysInfo), return truthy value to include device
     */ filterCallback ? : ( sysInfo : Sysinfo ) => boolean ; defaultValue breakoutChildren ? : boolean ; /**
     * passed to device constructors
     */ deviceOptions ? : DeviceOptionsDiscovery ; /**
     * known devices to query instead of relying only on broadcast
     */ devices ? : DiscoveryDevice [ ] ; } typeParam typeParam typeParam typeParam export type SendOptions = { timeout ? : number ; transport ? : 'tcp' | 'udp' ; useSharedSocket ? : boolean ; sharedSocketTimeout ? : number ; } ; export interface ClientEventEmitter { /**
     * First response from device.
     */ on ( event , listener ) : this ; /**
     * Follow up response from device.
     */ on ( event , listener ) : this ; /**
     * No response from device.
     */ on ( event , listener ) : this ; /**
     * First response from Bulb.
     */ on ( event , listener ) : this ; /**
     * Follow up response from Bulb.
     */ on ( event , listener ) : this ; /**
     * No response from Bulb.
     */ on ( event , listener ) : this ; /**
     * First response from Plug.
     */ on ( event , listener ) : this ; /**
     * Follow up response from Plug.
     */ on ( event , listener ) : this ; /**
     * No response from Plug.
     */ on ( event , listener ) : this ; /**
     * Invalid/Unknown response from device.
     */ on ( event , listener ) : this ; /**
     * Error during discovery.
     */ on ( event , listener ) : this ; emit ( event , device ) : boolean ; emit ( event , device ) : boolean ; emit ( event , device ) : boolean ; emit ( event , device ) : boolean ; emit ( event , device ) : boolean ; emit ( event , device ) : boolean ; emit ( event , device ) : boolean ; emit ( event , device ) : boolean ; emit ( event , device ) : boolean ; emit ( event , { rinfo , response , decryptedResponse , } ) : boolean ; emit ( event , error ) : boolean ; } /**
 * Client that sends commands to specified devices or discover devices on the local subnet.
 * - Contains factory methods to create devices.
 * - Events are emitted after  {@link #startDiscovery} is called.
 *  noInheritDoc export default class Client extends EventEmitter implements ClientEventEmitter { defaultSendOptions : Required < SendOptions > = { timeout : 10000 , transport : 'tcp' , useSharedSocket : false , sharedSocketTimeout : 20000 , } ; log : Logger ; devices : Map < string , AnyDeviceDiscovery > = new Map ( ) ; discoveryTimer : NodeJS . Timeout | null = null ; discoveryPacketSequence = 0 ; maxSocketId = 0 ; socket ? : Socket ; isSocketBound = false ; constructor ( options = { } ) { super ( ) ; const { defaultSendOptions , logLevel = 'warn' , logger } = options ; this . defaultSendOptions = { ... this . defaultSendOptions , ... defaultSendOptions , } ; this . log = createLogger ( { logger , level : logLevel } ) ; } internal getNextSocketId ( ) { this . maxSocketId += 1 ; return this . maxSocketId ; } /**
     *  https `payload` and sends to device.
     * - If `payload` is not a string, it is `JSON.stringify`'d.
     * - Promise fulfills with encrypted string response.
     *
     * Devices use JSON to communicate.\
     * For Example:
     * - If a device receives:
     *   - `{"system":{"get_sysinfo":{}}}`
     * - It responds with:
     * ```
     *     {"system":{"get_sysinfo":{
     *       err_code: 0,
     *       sw_ver: "1.0.8 Build 151113 Rel.24658",
     *       hw_ver: "1.0",
     *       ...
     *     }}}
     * ```
     *
     * All responses from device contain an `err_code` (`0` is success).
     *
     *  returns async send ( payload , host , port = 9999 , sendOptions ? ) { const thisSendOptions = { ... this . defaultSendOptions , ... sendOptions , useSharedSocket : false , } ; const payloadString = ! ( typeof payload === 'string' ) ? JSON . stringify ( payload ) : payload ; let connection ; if ( thisSendOptions . transport === 'udp' ) { connection = new UdpConnection ( host , port , this . log , this ) ; } else { connection = new TcpConnection ( host , port , this . log , this ) ; } const response = await connection . send ( payloadString , port , host , thisSendOptions ) ; connection . close ( ) ; return response ; } returns throws ResponseError throws Error async getSysInfo ( host , port = 9999 , sendOptions ? ) { this . log . debug ( 'client.getSysInfo(%j)' , { host , port , sendOptions } ) ; const response = await this . send ( '{"system":{"get_sysinfo":{}}}' , host , port , sendOptions ) ; const responseObj = JSON . parse ( response ) ; if ( isSysinfoResponse ( responseObj ) ) { return responseObj . system . get_sysinfo ; } throw new Error ( `Unexpected Response: ${ response }` ) ; } internal emit ( eventName , ... args ) { let ret = false ; if ( args [ 0 ] instanceof Device ) { if ( super . emit ( `device-${ eventName }` , ... args ) ) { ret = true ; } if ( args [ 0 ] . deviceType !== 'device' ) { if ( super . emit ( `${ args [ 0 ] . deviceType }-${ eventName }` , ... args ) ) { ret = true ; } } } else if ( super . emit ( eventName , ... args ) ) { ret = true ; } return ret ; } /**
     * Creates Bulb object.
     *
     * See [Device constructor] Device and [Bulb constructor] Bulb for valid options.
     *  param deviceOptions - passed to [Bulb constructor] Bulb getBulb ( deviceOptions ) { return new Bulb ( { defaultSendOptions : this . defaultSendOptions , ... deviceOptions , client : this , } ) ; } /**
     * Creates  Plug object.
     *
     * See [Device constructor] Device and [Plug constructor] Plug for valid options.
     *  param deviceOptions - passed to [Plug constructor] Plug getPlug ( deviceOptions ) { return new Plug ( { defaultSendOptions : this . defaultSendOptions , ... deviceOptions , client : this , } ) ; } /**
     * Creates a  Plug or  Bulb from passed in sysInfo or after querying device to determine type.
     *
     * See [Device constructor] Device , [Bulb constructor] Bulb , [Plug constructor] Plug for valid options.
     *  param deviceOptions - passed to [Device constructor] Device throws ResponseError async getDevice ( deviceOptions , sendOptions ? ) { this . log . debug ( 'client.getDevice(%j)' , { deviceOptions , sendOptions } ) ; let sysInfo ; if ( 'sysInfo' in deviceOptions && deviceOptions . sysInfo !== undefined ) { sysInfo = deviceOptions . sysInfo ; } else { sysInfo = await this . getSysInfo ( deviceOptions . host , deviceOptions . port , sendOptions ) ; } const combinedDeviceOptions = { ... deviceOptions , client : this , } as AnyDeviceOptions ; return this . getDeviceFromSysInfo ( sysInfo , combinedDeviceOptions ) ; } /**
     * Creates device corresponding to the provided `sysInfo`.
     *
     * See [Device constructor] Device , [Bulb constructor] Bulb , [Plug constructor] Plug for valid options
     *  param deviceOptions throws Error getDeviceFromSysInfo ( sysInfo , deviceOptions ) { if ( isPlugSysinfo ( sysInfo ) ) { return this . getPlug ( { ... deviceOptions , sysInfo } ) ; } if ( isBulbSysinfo ( sysInfo ) ) { return this . getBulb ( { ... deviceOptions , sysInfo } ) ; } throw new Error ( 'Could not determine device from sysinfo' ) ; } /**
     * Guess the device type from provided `sysInfo`.
     *
     * Based on sysinfo.[type|mic_type]
     */ getTypeFromSysInfo ( sysInfo ) { const type = 'type' in sysInfo ? sysInfo . type : sysInfo . mic_type ; switch ( true ) { case /plug/i . test ( type ) : return 'plug' ; case /bulb/i . test ( type ) : return 'bulb' ; default : return 'device' ; } } /**
     * Discover TP-Link Smarthome devices on the network.
     *
     * - Sends a discovery packet (via UDP) to the `broadcast` address every `discoveryInterval`(ms).
     * - Stops discovery after `discoveryTimeout`(ms) (if `0`, runs until  Client . stopDiscovery is called).
     *   - If a device does not respond after `offlineTolerance` number of attempts,  Client . device is emitted.
     * - If `deviceTypes` are specified only matching devices are found.
     * - If `macAddresses` are specified only devices with matching MAC addresses are found.
     * - If `excludeMacAddresses` are specified devices with matching MAC addresses are excluded.
     * - if `filterCallback` is specified only devices where the callback returns a truthy value are found.
     * - If `devices` are specified it will attempt to contact them directly in addition to sending to the broadcast address.
     *   - `devices` are specified as an array of `[{host, [port: 9999]}]`.
     *  fires fires fires fires fires fires fires fires fires fires fires startDiscovery ( options = { } ) { this . log . debug ( 'client.startDiscovery(%j)' , options ) ; const { address , port , broadcast = '255.255.255.255' , discoveryInterval = 10000 , discoveryTimeout = 0 , offlineTolerance = 3 , deviceTypes , macAddresses = [ ] , excludeMacAddresses = [ ] , filterCallback , breakoutChildren = true , deviceOptions , devices , } = options ; try { const socket = createSocket ( 'udp4' ) ; this . socket = socket ; socket . on ( 'message' , ( msg , rinfo ) => { const decryptedMsg = decrypt ( msg ) . toString ( 'utf8' ) ; this . log . debug ( `client.startDiscovery(): socket:message From: ${ rinfo . address } ${ rinfo . port } Message: ${ decryptedMsg }` ) ; let response ; let sysInfo ; try { response = JSON . parse ( decryptedMsg ) ; sysInfo = response . system . get_sysinfo ; } catch ( err ) { this . log . debug ( `client.startDiscovery(): Error parsing JSON: %s\nFrom: ${ rinfo . address } ${ rinfo . port } Original: [%s] Decrypted: [${ decryptedMsg }]` , err , msg ) ; this . emit ( 'discovery-invalid' , { rinfo , response : msg , decryptedResponse : decrypt ( msg ) , } ) ; return ; } if ( deviceTypes && deviceTypes . length > 0 ) { const deviceType = this . getTypeFromSysInfo ( sysInfo ) ; if ( ! ( deviceTypes as string [ ] ) . includes ( deviceType ) ) { this . log . debug ( `client.startDiscovery(): Filtered out: ${ sysInfo . alias } [${ sysInfo . deviceId }] (${ deviceType }), allowed device types: (%j)` , deviceTypes ) ; return ; } } let mac ; if ( 'mac' in sysInfo ) mac = sysInfo . mac ; else if ( 'mic_mac' in sysInfo ) mac = sysInfo . mic_mac ; else if ( 'ethernet_mac' in sysInfo ) mac = sysInfo . ethernet_mac ; else mac = '' ; if ( macAddresses && macAddresses . length > 0 ) { if ( ! compareMac ( mac , macAddresses ) ) { this . log . debug ( `client.startDiscovery(): Filtered out: ${ sysInfo . alias } [${ sysInfo . deviceId }] (${ mac }), allowed macs: (%j)` , macAddresses ) ; return ; } } if ( excludeMacAddresses && excludeMacAddresses . length > 0 ) { if ( compareMac ( mac , excludeMacAddresses ) ) { this . log . debug ( `client.startDiscovery(): Filtered out: ${ sysInfo . alias } [${ sysInfo . deviceId }] (${ mac }), excluded mac` ) ; return ; } } if ( typeof filterCallback === 'function' ) { if ( ! filterCallback ( sysInfo ) ) { this . log . debug ( `client.startDiscovery(): Filtered out: ${ sysInfo . alias } [${ sysInfo . deviceId }], callback` ) ; return ; } } this . createOrUpdateDeviceFromSysInfo ( { sysInfo , host : rinfo . address , port : rinfo . port , breakoutChildren , options : deviceOptions , } ) ; } ) ; socket . on ( 'error' , ( err ) => { this . log . error ( 'client.startDiscovery: UDP Error: %s' , err ) ; this . stopDiscovery ( ) ; this . emit ( 'error' , err ) ; } ) ; socket . bind ( port , address , ( ) => { this . isSocketBound = true ; const sockAddress = socket . address ( ) ; this . log . debug ( `client.socket: UDP ${ sockAddress . family } listening on ${ sockAddress . address }:${ sockAddress . port }` ) ; socket . setBroadcast ( true ) ; this . discoveryTimer = setInterval ( ( ) => { this . sendDiscovery ( socket , broadcast , devices , offlineTolerance ) ; } , discoveryInterval ) ; this . sendDiscovery ( socket , broadcast , devices , offlineTolerance ) ; if ( discoveryTimeout > 0 ) { setTimeout ( ( ) => { this . log . debug ( 'client.startDiscovery: discoveryTimeout reached, stopping discovery' ) ; this . stopDiscovery ( ) ; } , discoveryTimeout ) ; } } ) ; } catch ( err ) { this . log . error ( 'client.startDiscovery: %s' , err ) ; this . emit ( 'error' , err ) ; } return this ; } private static setSysInfoForDevice ( device , sysInfo ) { if ( device instanceof Plug ) { if ( ! isPlugSysinfo ( sysInfo ) ) { throw new TypeError ( util . format ( 'Expected PlugSysinfo but received: %O' , sysInfo ) ) ; } device . setSysInfo ( sysInfo ) ; } else if ( device instanceof Bulb ) { if ( ! isBulbSysinfo ( sysInfo ) ) { throw new TypeError ( util . format ( 'Expected BulbSysinfo but received: %O' , sysInfo ) ) ; } device . setSysInfo ( sysInfo ) ; } } private createOrUpdateDeviceFromSysInfo ( { sysInfo , host , port , options , breakoutChildren , } ) { const process = ( id , childId ? ) : void => { let device ; if ( this . devices . has ( id ) && this . devices . get ( id ) !== undefined ) { device = this . devices . get ( id ) ! ; device . host = host ; device . port = port ; Client . setSysInfoForDevice ( device , sysInfo ) ; device . status = 'online' ; device . seenOnDiscovery = this . discoveryPacketSequence ; this . emit ( 'online' , device ) ; } else { device = this . getDeviceFromSysInfo ( sysInfo , { ... options , client : this , host , port , childId , } ) ; device . status = 'online' ; device . seenOnDiscovery = this . discoveryPacketSequence ; this . devices . set ( id , device ) ; this . emit ( 'new' , device ) ; } } ; if ( breakoutChildren && hasSysinfoChildren ( sysInfo ) ) { sysInfo . children . forEach ( ( child ) => { const childId = child . id . length === 2 ? sysInfo . deviceId + child . id : child . id ; process ( childId , childId ) ; } ) ; } else { process ( sysInfo . deviceId ) ; } } /**
     * Stops discovery and closes UDP socket.
     */ stopDiscovery ( ) { this . log . debug ( 'client.stopDiscovery()' ) ; if ( this . discoveryTimer !== null ) clearInterval ( this . discoveryTimer ) ; this . discoveryTimer = null ; if ( this . isSocketBound ) { this . isSocketBound = false ; if ( this . socket != null ) this . socket . close ( ) ; } } private sendDiscovery ( socket , address , devices = [ ] , offlineTolerance ) { this . log . debug ( 'client.sendDiscovery(%s, %j, %s)' , address , devices , offlineTolerance ) ; try { this . devices . forEach ( ( device ) => { if ( device . status !== 'offline' ) { const diff = this . discoveryPacketSequence - ( device . seenOnDiscovery || 0 ) ; if ( diff >= offlineTolerance ) { device . status = 'offline' ; this . emit ( 'offline' , device ) ; } } } ) ; if ( ! this . isSocketBound ) { return ; } socket . send ( discoveryMsgBuf , 0 , discoveryMsgBuf . length , 9999 , address ) ; devices . forEach ( ( d ) => { this . log . debug ( 'client.sendDiscovery() direct device:' , d ) ; socket . send ( discoveryMsgBuf , 0 , discoveryMsgBuf . length , d . port || 9999 , d . host ) ; } ) ; if ( this . discoveryPacketSequence >= Number . MAX_VALUE ) { this . discoveryPacketSequence = 0 ; } else { this . discoveryPacketSequence += 1 ; } } catch ( err ) { this . log . error ( 'client.sendDiscovery: %s' , err ) ; this . emit ( 'error' , err ) ; } } }