// Libs import net from 'node:net' import { Service } from 'homebridge' import debounce from 'lodash.debounce' import { EventEmitter } from 'node:events' // Yeelight import { YeelightDeviceProps, YeelightIdentify, YeelightResponse } from './yeelight.typing' import { YEELIGHT_COMMAND, YEELIGHT_DEVICE_OPTIONS, YEELIGHT_PROPERTIES, YEELIGHT_ROW_SEPARATOR } from './yeelight.constant' // Utils import { Shared } from '../shared' import { debounceAsync, handleAddEventListener } from '../utils' import { DeviceResponseValueMapping, EventAdaptorConfig, PropertyValue } from '../main' import { WithUUID } from 'hap-nodejs/dist/types' import { Characteristic } from 'hap-nodejs/dist/lib/Characteristic' export class Device extends EventEmitter { // Flags public isConnected = false // Request & Response private previousCommandId = 1 private responseResolvePool: { [id: string]: (data: YeelightResponse) => void } = {} // Refs private socket?: net.Socket private service?: Service private identify: YeelightIdentify constructor (props: YeelightDeviceProps) { super() Shared.log.info('[YeelightDeviceService] initializing device info') this.identify = props.identify this.service = props.service } /** * 添加 Device - HomeBridge 数据适配器 * @param type * @param config */ public addEventAdaptor = (type: WithUUID<{ new (): Characteristic }>, config: EventAdaptorConfig) => { handleAddEventListener({ name: `YeelightDeviceService-${this.identify.address}`, type, config, service: this.service, getProperty: this.debouncedGetProperty, setProperty: this.setProperty, }) } /** * Getter */ private getProperty = async () => { try { const { result = [] } = await this.sendCommand(YEELIGHT_COMMAND.GetProp, YEELIGHT_PROPERTIES) return YEELIGHT_PROPERTIES.reduce((acc, cur, idx) => { return Object.assign(acc, { [cur]: result[idx] }) }, {} as DeviceResponseValueMapping) } catch (e) { Shared.log.error('[YeelightDeviceService] get prop error', e) } } private debouncedGetProperty = debounceAsync(this.getProperty.bind(this), Shared.config.requestPropertyDebounceThreshold) /** * Setter * @param method * @param params */ private setProperty = async (method: YEELIGHT_COMMAND, params: PropertyValue[]) => { try { await this.sendCommand(method, params) } catch (e) { Shared.log.error('[YeelightDeviceService] set prop error', e) } } /** * 链接设备 * @param force - 强制链接 */ private connect = (force: boolean = false) => { return new Promise((resolve, reject) => { try { // Guard: connected if (this.isConnected && !force) return // Init socket Shared.log.info('[YeelightDeviceService] initializing socket') this.socket = new net.Socket({ allowHalfOpen: false }) // Bind event handlers this.socket.on('data', data => { this.handleSocketReceiveResponse(data) }) this.socket.on('error', error => { this.emit('socketError', error) this.handleSocketError(error) }) this.socket.on('end', () => { this.emit('socketEnd') this.handleSocketEnded() }) // Connect this.socket.connect({ host: this.identify.address, port: YEELIGHT_DEVICE_OPTIONS.port, }, () => { this.isConnected = true resolve() }) } catch (error: any) { this.handleSocketError(error) reject() } }) } private debouncedConnect = debounce(this.connect.bind(this), Shared.config.reConnectFailureThreshold) /** * 发送命令 * @param method * @param params */ private sendCommand = (method: YEELIGHT_COMMAND, params: PropertyValue[] = []) => { return new Promise(async (resolve, reject) => { // Guard: not connected if (!this.isConnected) await this.debouncedConnect() // Guard: still not connected if (!this.isConnected || !this.socket) { Shared.log.info('[YeelightDeviceService] send command aboard, device not connected') return reject(new Error('Device is not connected')) } // Build command const id = this.previousCommandId++ const command = JSON.stringify({ id, method, params }) // Add to resolve pool this.responseResolvePool[id] = response => resolve(response) // Send command Shared.log.info(`[YeelightDeviceService] sending command`, command) this.socket.write(`${command}${YEELIGHT_ROW_SEPARATOR}`) }) } /** * 断开链接 */ private disconnect = () => { Shared.log.info('[YeelightDeviceService] disconnecting device') this.debouncedConnect.cancel() this.resetSocket() } /** * 处理设备回复 * @param data */ private handleSocketReceiveResponse = (data: any) => { try { Shared.log.info(`[YeelightDeviceService] response received, parsing data`) const dataArray = data.toString().split(YEELIGHT_ROW_SEPARATOR) for (const data of dataArray) { // Guard: empty content if (data.length === 0) continue // Guard: empty response id const response = JSON.parse(data) if (response.id === undefined) continue // Guard: empty resolve const resolve = this.responseResolvePool[response.id] if (!resolve) continue // Resolve response Shared.log.info(`[YeelightDeviceService] - response parsed`) resolve(response) delete this.responseResolvePool[response.id] } } catch (e) { Shared.log.error('[YeelightDiscoverService] - parsing response error', e, data) } } /** * 重设端口 */ private resetSocket = () => { this.isConnected = false this.socket?.destroy() delete this.socket } /** * 处理端口错误 * @param error */ private handleSocketError = (error: Error) => { // Error: unreachable, no need to retry if (error?.message.includes('EHOSTUNREACH')) { Shared.log.error('[YeelightDeviceService] Socket unreachable', error.message) return this.resetSocket() } // Error: normal error with retry Shared.log.error('[YeelightDeviceService] Socket error', error.message) this.resetSocket() // Reconnect this.debouncedConnect() } /** * 处理端口结束 */ private handleSocketEnded = () => { Shared.log.info('[YeelightDeviceService] Socket ended') return this.resetSocket() } }