import type { IEsphomeClientCommand, IEsphomeCommandResult, IEsphomeConfig, IEsphomeEvent, IEsphomeSnapshot } from './esphome.types.js'; import { EsphomeMapper } from './esphome.mapper.js'; type TEsphomeEventHandler = (eventArg: IEsphomeEvent) => void; export class EsphomeClient { private readonly events: IEsphomeEvent[] = []; private readonly eventHandlers = new Set(); constructor(private readonly config: IEsphomeConfig) {} public async getSnapshot(): Promise { return EsphomeMapper.toSnapshot(this.config, undefined, this.events); } public onEvent(handlerArg: TEsphomeEventHandler): () => void { this.eventHandlers.add(handlerArg); return () => this.eventHandlers.delete(handlerArg); } public async sendCommand(commandArg: IEsphomeClientCommand): Promise { let result: IEsphomeCommandResult; if (this.config.commandExecutor) { result = this.commandResult(await this.config.commandExecutor(commandArg), commandArg); } else { result = { success: false, error: this.unsupportedLiveControlMessage(), data: { command: commandArg }, }; } this.emit({ type: result.success ? 'command_mapped' : 'command_failed', command: commandArg, data: result, timestamp: Date.now(), deviceId: this.stringValue(commandArg.deviceId), entityId: commandArg.entityId, }); return result; } public async connectLive(): Promise { await new EsphomeNativeApiConnection(this.config).connect(); } public async destroy(): Promise { this.eventHandlers.clear(); } private emit(eventArg: IEsphomeEvent): void { this.events.push(eventArg); for (const handler of this.eventHandlers) { handler(eventArg); } } private commandResult(resultArg: unknown, commandArg: IEsphomeClientCommand): IEsphomeCommandResult { if (this.isCommandResult(resultArg)) { return resultArg; } return { success: true, data: resultArg ?? { command: commandArg } }; } private isCommandResult(valueArg: unknown): valueArg is IEsphomeCommandResult { return typeof valueArg === 'object' && valueArg !== null && 'success' in valueArg; } private unsupportedLiveControlMessage(): string { if (this.hasEncryptionKey()) { return 'ESPHome live native API writes require protobuf framing plus Noise encryption support, which is not implemented. The mapped command was not sent.'; } if (this.hasPassword()) { return 'ESPHome live native API writes require protobuf framing plus legacy password login support, which is not implemented. The mapped command was not sent.'; } return 'ESPHome live native API writes require protobuf framing, which is not implemented. The mapped command was not sent.'; } private hasEncryptionKey(): boolean { return Boolean(this.config.encryptionKey || this.config.noisePsk || this.config.manualEntries?.some((entryArg) => entryArg.encryptionKey || entryArg.noisePsk)); } private hasPassword(): boolean { return Boolean(this.config.password || this.config.manualEntries?.some((entryArg) => entryArg.password)); } private stringValue(valueArg: unknown): string | undefined { if (typeof valueArg === 'string') { return valueArg; } if (typeof valueArg === 'number' && Number.isFinite(valueArg)) { return String(valueArg); } return undefined; } } export class EsphomeNativeApiConnection { constructor(private readonly config: IEsphomeConfig) {} public async connect(): Promise { throw new Error(this.unsupportedMessage()); } public async sendCommand(commandArg: IEsphomeClientCommand): Promise { void commandArg; throw new Error(this.unsupportedMessage()); } private unsupportedMessage(): string { if (this.config.encryptionKey || this.config.noisePsk || this.config.manualEntries?.some((entryArg) => entryArg.encryptionKey || entryArg.noisePsk)) { return 'Encrypted ESPHome native API uses Noise plus protobuf over TCP; live TCP support is intentionally not implemented in this dependency-free port.'; } return 'ESPHome native API uses protobuf over TCP; live TCP support is intentionally not implemented in this dependency-free port.'; } }