279 lines
9.9 KiB
TypeScript
279 lines
9.9 KiB
TypeScript
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<TWizEventHandler>();
|
|
|
|
constructor(private readonly config: IWizConfig) {}
|
|
|
|
public async getSnapshot(): Promise<IWizSnapshot> {
|
|
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<IWizPilotState> {
|
|
const response = await this.sendUdp<IWizPilotState>({ 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<IWizUdpResponse<Record<string, unknown>>> {
|
|
return this.sendUdp<Record<string, unknown>>({ method: 'setPilot', params: payloadArg as Record<string, unknown> });
|
|
}
|
|
|
|
public async getSystemConfig(): Promise<IWizUdpResponse<Record<string, unknown>>> {
|
|
return this.sendUdp<Record<string, unknown>>({ method: 'getSystemConfig', params: {} });
|
|
}
|
|
|
|
public async sendCommand(commandArg: IWizClientCommand): Promise<IWizCommandResult> {
|
|
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<void> {
|
|
this.eventHandlers.clear();
|
|
}
|
|
|
|
private async liveDeviceInfo(pilotArg: IWizPilotState): Promise<IWizDeviceInfo> {
|
|
const systemConfig = await this.getSystemConfig();
|
|
const result = this.record(systemConfig.result) ? systemConfig.result : {};
|
|
return this.staticDeviceInfo(pilotArg, result);
|
|
}
|
|
|
|
private staticDeviceInfo(pilotArg?: IWizPilotState, systemArg: Record<string, unknown> = {}): 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<TResult>(commandArg: IWizUdpCommand): Promise<IWizUdpResponse<TResult>> {
|
|
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<IWizUdpResponse<TResult>>((resolve, reject) => {
|
|
const socket = createSocket('udp4');
|
|
const timers: Array<ReturnType<typeof setTimeout>> = [];
|
|
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<TResult>) => {
|
|
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<TResult>;
|
|
try {
|
|
response = JSON.parse(messageArg.toString('utf8')) as IWizUdpResponse<TResult>;
|
|
} 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<string, unknown> {
|
|
return typeof valueArg === 'object' && valueArg !== null && !Array.isArray(valueArg);
|
|
}
|
|
}
|