Add native hub protocol integrations
This commit is contained in:
@@ -0,0 +1,278 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user