import type { IWizClientCommand, IWizCommandResult, IWizConfig, IWizDeviceInfo, IWizEvent, IWizPilotPatch, IWizPilotState, IWizSnapshot, IWizSnapshotDevice, IWizUdpCommand, IWizUdpResponse, } from './wiz.types.js'; import { wizDefaultPort } from './wiz.types.js'; import { WizMapper } from './wiz.mapper.js'; type TWizEventHandler = (eventArg: IWizEvent) => void; export class WizClient { private readonly events: IWizEvent[] = []; private readonly eventHandlers = new Set(); constructor(private readonly config: IWizConfig) {} public async getSnapshot(): Promise { const host = this.host(); if (!host) { return WizMapper.toSnapshot(this.config, false, this.events); } try { const pilot = await this.getPilot(); const deviceInfo = await this.liveDeviceInfo(pilot).catch(() => this.staticDeviceInfo(pilot)); const device: IWizSnapshotDevice = { host, port: this.port(), mac: pilot.mac || deviceInfo.mac || this.config.mac, name: this.config.name || deviceInfo.name, deviceInfo, pilot, available: true, }; return WizMapper.toSnapshot({ ...this.config, snapshot: undefined, devices: [device], manualEntries: undefined }, true, this.events); } catch (error) { const message = error instanceof Error ? error.message : String(error); this.emit({ type: 'error', data: { message }, timestamp: Date.now() }); return WizMapper.toSnapshot(this.config, false, this.events); } } public onEvent(handlerArg: TWizEventHandler): () => void { this.eventHandlers.add(handlerArg); return () => this.eventHandlers.delete(handlerArg); } public async getPilot(): Promise { const response = await this.sendUdp({ method: 'getPilot', params: {} }); const result = this.record(response.result) ? response.result as IWizPilotState : {}; this.emit({ type: 'pilot', data: result, timestamp: Date.now() }); return result; } public async setPilot(payloadArg: IWizPilotPatch): Promise>> { return this.sendUdp>({ method: 'setPilot', params: payloadArg as Record }); } public async getSystemConfig(): Promise>> { return this.sendUdp>({ method: 'getSystemConfig', params: {} }); } public async sendCommand(commandArg: IWizClientCommand): Promise { let result: IWizCommandResult; if (this.config.commandExecutor) { result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg); } else if (!this.host()) { result = { success: false, error: this.unsupportedLiveControlMessage(), data: { command: commandArg }, }; } else { try { const response = await this.setPilot(commandArg.payload); result = { success: true, data: response.result || response }; } catch (error) { result = { success: false, error: error instanceof Error ? error.message : String(error), data: { command: commandArg }, }; } } this.emit({ type: result.success ? 'command_mapped' : 'command_failed', command: commandArg, data: result, timestamp: Date.now(), deviceId: commandArg.deviceId, entityId: commandArg.entityId, }); return result; } public async destroy(): Promise { this.eventHandlers.clear(); } private async liveDeviceInfo(pilotArg: IWizPilotState): Promise { const systemConfig = await this.getSystemConfig(); const result = this.record(systemConfig.result) ? systemConfig.result : {}; return this.staticDeviceInfo(pilotArg, result); } private staticDeviceInfo(pilotArg?: IWizPilotState, systemArg: Record = {}): IWizDeviceInfo { const moduleName = this.stringValue(systemArg.moduleName) || this.config.deviceInfo?.moduleName; const model = this.config.deviceInfo?.model || moduleName; const isSocket = this.config.deviceInfo?.isSocket ?? this.textContainsSocket(model, moduleName, systemArg.typeId); return { ...this.config.deviceInfo, host: this.host(), port: this.port(), mac: this.stringValue(systemArg.mac) || pilotArg?.mac || this.config.mac || this.config.deviceInfo?.mac, name: this.config.name || this.config.deviceInfo?.name, manufacturer: this.config.deviceInfo?.manufacturer || 'WiZ', model, moduleName, fwVersion: this.stringValue(systemArg.fwVersion) || this.config.deviceInfo?.fwVersion, typeId: this.stringValue(systemArg.typeId) || this.numberValue(systemArg.typeId) || this.config.deviceInfo?.typeId, isSocket, features: { light: !isSocket, switch: isSocket, brightness: !isSocket || typeof pilotArg?.dimming === 'number', color: ['r', 'g', 'b'].every((keyArg) => typeof pilotArg?.[keyArg] === 'number') || this.textContains(moduleName, 'rgb'), colorTemp: typeof pilotArg?.temp === 'number' || this.textContains(moduleName, 'tw'), effect: typeof pilotArg?.sceneId === 'number' || typeof pilotArg?.schdPsetId === 'number', fan: typeof pilotArg?.fanState === 'number', power: typeof pilotArg?.pc === 'number', occupancy: pilotArg?.src === 'pir', ...this.config.deviceInfo?.features, }, }; } private async sendUdp(commandArg: IWizUdpCommand): Promise> { const host = this.host(); if (!host) { throw new Error(this.unsupportedLiveControlMessage()); } const port = this.port(); const timeoutMs = this.timeoutMs(); const { createSocket } = await import('node:dgram'); const payload = Buffer.from(JSON.stringify(commandArg)); return new Promise>((resolve, reject) => { const socket = createSocket('udp4'); const timers: Array> = []; let settled = false; const cleanup = () => { for (const timer of timers) { clearTimeout(timer); } socket.removeAllListeners(); try { socket.close(); } catch { // The socket may already be closed after an early UDP error. } }; const finish = (errorArg: Error | undefined, responseArg?: IWizUdpResponse) => { if (settled) { return; } settled = true; cleanup(); if (errorArg) { reject(errorArg); } else { resolve(responseArg || {}); } }; const send = () => { if (settled) { return; } socket.send(payload, port, host, (errorArg) => { if (errorArg) { finish(errorArg); } }); }; socket.on('error', (errorArg) => finish(errorArg)); socket.on('message', (messageArg) => { let response: IWizUdpResponse; try { response = JSON.parse(messageArg.toString('utf8')) as IWizUdpResponse; } catch (error) { finish(error instanceof Error ? error : new Error(String(error))); return; } if (response.method && response.method !== commandArg.method) { return; } if (response.error) { finish(new Error(response.error.message || `WiZ UDP error ${response.error.code ?? 'unknown'}`)); return; } this.emit({ type: 'udp_response', data: response, timestamp: Date.now() }); finish(undefined, response); }); send(); timers.push(setTimeout(send, 750)); timers.push(setTimeout(send, 2250)); timers.push(setTimeout(() => finish(new Error(`Timed out waiting for WiZ ${commandArg.method} response from ${host}:${port}.`)), timeoutMs)); }); } private emit(eventArg: IWizEvent): void { this.events.push(eventArg); for (const handler of this.eventHandlers) { handler(eventArg); } } private commandResult(resultArg: unknown, commandArg: IWizClientCommand): IWizCommandResult { if (this.isCommandResult(resultArg)) { return resultArg; } return { success: true, data: resultArg ?? { command: commandArg } }; } private isCommandResult(valueArg: unknown): valueArg is IWizCommandResult { return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; } private host(): string | undefined { return this.config.host || this.config.manualEntries?.find((entryArg) => entryArg.host)?.host; } private port(): number { const manualPort = this.config.manualEntries?.find((entryArg) => entryArg.host)?.port; return this.config.port || manualPort || wizDefaultPort; } private timeoutMs(): number { return typeof this.config.timeoutMs === 'number' && this.config.timeoutMs > 0 ? this.config.timeoutMs : 5000; } private unsupportedLiveControlMessage(): string { return 'WiZ live UDP control requires a configured host. Snapshot-only WiZ configs are read-only unless commandExecutor is provided.'; } private textContainsSocket(...valuesArg: unknown[]): boolean { return valuesArg.some((valueArg) => this.textContains(valueArg, 'socket') || this.textContains(valueArg, 'plug')); } private textContains(valueArg: unknown, fragmentArg: string): boolean { return typeof valueArg === 'string' && valueArg.toLowerCase().includes(fragmentArg); } private stringValue(valueArg: unknown): string | undefined { return typeof valueArg === 'string' && valueArg.trim() ? valueArg.trim() : undefined; } private numberValue(valueArg: unknown): number | undefined { return typeof valueArg === 'number' && Number.isFinite(valueArg) ? valueArg : undefined; } private record(valueArg: unknown): valueArg is Record { return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg); } }