Add native hub protocol integrations

This commit is contained in:
2026-05-05 14:57:06 +00:00
parent 2823a1c718
commit 1eebd71e7d
102 changed files with 16316 additions and 330 deletions
+278
View File
@@ -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);
}
}