import * as plugins from '../plugins.js'; import type { IHomeAssistantInstanceConfig, IHomeAssistantEntity, IHomeAssistantConfig, IHomeAssistantStateChangedEvent, IHomeAssistantMessage, IHomeAssistantAuthRequired, IHomeAssistantAuthOk, IHomeAssistantAuthInvalid, IHomeAssistantResult, IHomeAssistantEvent, THomeAssistantProtocolEvents, IHomeAssistantLightServiceData, IHomeAssistantClimateServiceData, IHomeAssistantCoverServiceData, IHomeAssistantFanServiceData, IHomeAssistantMediaPlayerServiceData, } from '../interfaces/homeassistant.interfaces.js'; /** * Home Assistant WebSocket Protocol Handler * Connects to HA via WebSocket, handles authentication, state subscriptions, and service calls */ export class HomeAssistantProtocol extends plugins.events.EventEmitter { private ws: InstanceType | null = null; private config: IHomeAssistantInstanceConfig; private messageId: number = 1; private pendingRequests: Map void; reject: (error: Error) => void; timeout: ReturnType; }> = new Map(); private isAuthenticated: boolean = false; private haConfig: IHomeAssistantConfig | null = null; private reconnectAttempt: number = 0; private reconnectTimer: ReturnType | null = null; private stateSubscriptionId: number | null = null; private entityStates: Map = new Map(); private intentionalDisconnect: boolean = false; constructor(config: IHomeAssistantInstanceConfig) { super(); this.config = { port: 8123, secure: false, autoReconnect: true, reconnectDelay: 5000, ...config, }; } /** * Get the WebSocket URL for this HA instance */ private get wsUrl(): string { const protocol = this.config.secure ? 'wss' : 'ws'; return `${protocol}://${this.config.host}:${this.config.port}/api/websocket`; } /** * Get connection state */ public get isConnected(): boolean { return this.ws !== null && this.ws.readyState === plugins.WebSocket.OPEN && this.isAuthenticated; } /** * Get HA config if authenticated */ public get homeAssistantConfig(): IHomeAssistantConfig | null { return this.haConfig; } /** * Get all cached entity states */ public get entities(): Map { return this.entityStates; } /** * Connect to Home Assistant */ public async connect(): Promise { if (this.ws && this.ws.readyState === plugins.WebSocket.OPEN) { return; } this.intentionalDisconnect = false; return new Promise((resolve, reject) => { try { this.ws = new plugins.WebSocket(this.wsUrl); const connectionTimeout = setTimeout(() => { if (this.ws) { this.ws.close(); this.ws = null; } reject(new Error(`Connection timeout to ${this.wsUrl}`)); }, 10000); this.ws.on('open', () => { // Connection established, waiting for auth_required }); this.ws.on('message', async (data: Buffer | string) => { const message = JSON.parse(data.toString()) as IHomeAssistantMessage; if (message.type === 'auth_required') { // Send authentication await this.sendAuth(); } else if (message.type === 'auth_ok') { clearTimeout(connectionTimeout); this.isAuthenticated = true; this.reconnectAttempt = 0; // Get HA config try { this.haConfig = await this.getConfig(); this.emit('authenticated', this.haConfig); } catch (err) { // Non-fatal, continue anyway } this.emit('connected'); resolve(); } else if (message.type === 'auth_invalid') { clearTimeout(connectionTimeout); const authInvalid = message as IHomeAssistantAuthInvalid; this.emit('auth:failed', authInvalid.message); reject(new Error(`Authentication failed: ${authInvalid.message}`)); } else { // Handle other messages this.handleMessage(message); } }); this.ws.on('error', (error: Error) => { this.emit('error', error); }); this.ws.on('close', () => { this.isAuthenticated = false; this.stateSubscriptionId = null; // Reject all pending requests for (const [id, request] of this.pendingRequests) { clearTimeout(request.timeout); request.reject(new Error('Connection closed')); this.pendingRequests.delete(id); } this.emit('disconnected'); // Auto-reconnect if not intentional if (this.config.autoReconnect && !this.intentionalDisconnect) { this.scheduleReconnect(); } }); } catch (err) { reject(err); } }); } /** * Disconnect from Home Assistant */ public async disconnect(): Promise { this.intentionalDisconnect = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } if (this.ws) { // Clear all pending requests for (const [id, request] of this.pendingRequests) { clearTimeout(request.timeout); request.reject(new Error('Disconnecting')); this.pendingRequests.delete(id); } this.ws.close(); this.ws = null; } this.isAuthenticated = false; this.stateSubscriptionId = null; this.entityStates.clear(); } /** * Send authentication message */ private async sendAuth(): Promise { if (!this.ws) return; const authMessage = { type: 'auth', access_token: this.config.token, }; this.ws.send(JSON.stringify(authMessage)); } /** * Handle incoming messages */ private handleMessage(message: IHomeAssistantMessage): void { if (message.type === 'result') { const result = message as IHomeAssistantResult; const pending = this.pendingRequests.get(result.id); if (pending) { clearTimeout(pending.timeout); this.pendingRequests.delete(result.id); if (result.success) { pending.resolve(result.result); } else { pending.reject(new Error(result.error?.message || 'Unknown error')); } } } else if (message.type === 'event') { const event = message as IHomeAssistantEvent; if (event.event.event_type === 'state_changed') { const stateChanged = event.event.data as IHomeAssistantStateChangedEvent; // Update cached state if (stateChanged.new_state) { this.entityStates.set(stateChanged.entity_id, stateChanged.new_state); } else { this.entityStates.delete(stateChanged.entity_id); } this.emit('state:changed', stateChanged); } } } /** * Send a request and wait for response */ private async sendRequest(type: string, data: Record = {}): Promise { if (!this.ws || !this.isAuthenticated) { throw new Error('Not connected to Home Assistant'); } const id = this.messageId++; const message = { id, type, ...data }; return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.pendingRequests.delete(id); reject(new Error(`Request timeout: ${type}`)); }, 30000); this.pendingRequests.set(id, { resolve: resolve as (value: unknown) => void, reject, timeout, }); this.ws!.send(JSON.stringify(message)); }); } /** * Schedule reconnection attempt */ private scheduleReconnect(): void { if (this.reconnectTimer) return; this.reconnectAttempt++; const delay = Math.min( this.config.reconnectDelay! * Math.pow(1.5, this.reconnectAttempt - 1), 60000 // Max 60 seconds ); this.emit('reconnecting', this.reconnectAttempt); this.reconnectTimer = setTimeout(async () => { this.reconnectTimer = null; try { await this.connect(); // Re-subscribe to state changes if (this.isConnected) { await this.subscribeToStateChanges(); } } catch { // connect() will schedule another reconnect on failure } }, delay); } // ========================================================================== // Public API - State // ========================================================================== /** * Get HA config */ public async getConfig(): Promise { return this.sendRequest('get_config'); } /** * Subscribe to state change events */ public async subscribeToStateChanges(): Promise { const result = await this.sendRequest<{ context: { id: string } }>('subscribe_events', { event_type: 'state_changed', }); // Get all current states after subscribing const states = await this.getStates(); for (const entity of states) { this.entityStates.set(entity.entity_id, entity); } this.emit('states:loaded', states); this.stateSubscriptionId = this.messageId - 1; return this.stateSubscriptionId; } /** * Get all entity states */ public async getStates(): Promise { return this.sendRequest('get_states'); } /** * Get a specific entity state */ public async getState(entityId: string): Promise { // First check cache const cached = this.entityStates.get(entityId); if (cached) return cached; // Otherwise fetch all states and find it const states = await this.getStates(); return states.find((s) => s.entity_id === entityId) || null; } /** * Get entities by domain */ public async getEntitiesByDomain(domain: string): Promise { const states = await this.getStates(); return states.filter((s) => s.entity_id.startsWith(`${domain}.`)); } // ========================================================================== // Public API - Service Calls // ========================================================================== /** * Call a Home Assistant service */ public async callService( domain: string, service: string, target?: { entity_id?: string | string[]; device_id?: string | string[]; area_id?: string | string[] }, serviceData?: Record ): Promise { await this.sendRequest('call_service', { domain, service, target, service_data: serviceData, }); } /** * Turn on an entity */ public async turnOn(entityId: string, data?: Record): Promise { const domain = entityId.split('.')[0]; await this.callService(domain, 'turn_on', { entity_id: entityId }, data); } /** * Turn off an entity */ public async turnOff(entityId: string): Promise { const domain = entityId.split('.')[0]; await this.callService(domain, 'turn_off', { entity_id: entityId }); } /** * Toggle an entity */ public async toggle(entityId: string): Promise { const domain = entityId.split('.')[0]; await this.callService(domain, 'toggle', { entity_id: entityId }); } // ========================================================================== // Light Services // ========================================================================== /** * Control a light */ public async lightTurnOn(entityId: string, options?: IHomeAssistantLightServiceData): Promise { await this.callService('light', 'turn_on', { entity_id: entityId }, options); } public async lightTurnOff(entityId: string, options?: { transition?: number }): Promise { await this.callService('light', 'turn_off', { entity_id: entityId }, options); } public async lightToggle(entityId: string, options?: IHomeAssistantLightServiceData): Promise { await this.callService('light', 'toggle', { entity_id: entityId }, options); } // ========================================================================== // Climate Services // ========================================================================== /** * Set HVAC mode */ public async climateSetHvacMode(entityId: string, hvacMode: string): Promise { await this.callService('climate', 'set_hvac_mode', { entity_id: entityId }, { hvac_mode: hvacMode }); } /** * Set target temperature */ public async climateSetTemperature(entityId: string, options: IHomeAssistantClimateServiceData): Promise { await this.callService('climate', 'set_temperature', { entity_id: entityId }, options); } /** * Set fan mode */ public async climateSetFanMode(entityId: string, fanMode: string): Promise { await this.callService('climate', 'set_fan_mode', { entity_id: entityId }, { fan_mode: fanMode }); } /** * Set preset mode */ public async climateSetPresetMode(entityId: string, presetMode: string): Promise { await this.callService('climate', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode }); } /** * Set swing mode */ public async climateSetSwingMode(entityId: string, swingMode: string): Promise { await this.callService('climate', 'set_swing_mode', { entity_id: entityId }, { swing_mode: swingMode }); } /** * Set aux heat */ public async climateSetAuxHeat(entityId: string, auxHeat: boolean): Promise { await this.callService('climate', 'set_aux_heat', { entity_id: entityId }, { aux_heat: auxHeat }); } // ========================================================================== // Cover Services // ========================================================================== /** * Open cover */ public async coverOpen(entityId: string): Promise { await this.callService('cover', 'open_cover', { entity_id: entityId }); } /** * Close cover */ public async coverClose(entityId: string): Promise { await this.callService('cover', 'close_cover', { entity_id: entityId }); } /** * Stop cover */ public async coverStop(entityId: string): Promise { await this.callService('cover', 'stop_cover', { entity_id: entityId }); } /** * Set cover position */ public async coverSetPosition(entityId: string, position: number): Promise { await this.callService('cover', 'set_cover_position', { entity_id: entityId }, { position }); } /** * Set cover tilt position */ public async coverSetTiltPosition(entityId: string, tiltPosition: number): Promise { await this.callService('cover', 'set_cover_tilt_position', { entity_id: entityId }, { tilt_position: tiltPosition }); } // ========================================================================== // Fan Services // ========================================================================== /** * Turn on fan */ public async fanTurnOn(entityId: string, options?: IHomeAssistantFanServiceData): Promise { await this.callService('fan', 'turn_on', { entity_id: entityId }, options); } /** * Turn off fan */ public async fanTurnOff(entityId: string): Promise { await this.callService('fan', 'turn_off', { entity_id: entityId }); } /** * Set fan percentage */ public async fanSetPercentage(entityId: string, percentage: number): Promise { await this.callService('fan', 'set_percentage', { entity_id: entityId }, { percentage }); } /** * Set fan preset mode */ public async fanSetPresetMode(entityId: string, presetMode: string): Promise { await this.callService('fan', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode }); } /** * Oscillate fan */ public async fanOscillate(entityId: string, oscillating: boolean): Promise { await this.callService('fan', 'oscillate', { entity_id: entityId }, { oscillating }); } /** * Set fan direction */ public async fanSetDirection(entityId: string, direction: 'forward' | 'reverse'): Promise { await this.callService('fan', 'set_direction', { entity_id: entityId }, { direction }); } // ========================================================================== // Lock Services // ========================================================================== /** * Lock */ public async lockLock(entityId: string): Promise { await this.callService('lock', 'lock', { entity_id: entityId }); } /** * Unlock */ public async lockUnlock(entityId: string): Promise { await this.callService('lock', 'unlock', { entity_id: entityId }); } /** * Open (if supported) */ public async lockOpen(entityId: string): Promise { await this.callService('lock', 'open', { entity_id: entityId }); } // ========================================================================== // Switch Services // ========================================================================== /** * Turn on switch */ public async switchTurnOn(entityId: string): Promise { await this.callService('switch', 'turn_on', { entity_id: entityId }); } /** * Turn off switch */ public async switchTurnOff(entityId: string): Promise { await this.callService('switch', 'turn_off', { entity_id: entityId }); } /** * Toggle switch */ public async switchToggle(entityId: string): Promise { await this.callService('switch', 'toggle', { entity_id: entityId }); } // ========================================================================== // Media Player Services // ========================================================================== /** * Play media */ public async mediaPlayerPlay(entityId: string): Promise { await this.callService('media_player', 'media_play', { entity_id: entityId }); } /** * Pause media */ public async mediaPlayerPause(entityId: string): Promise { await this.callService('media_player', 'media_pause', { entity_id: entityId }); } /** * Stop media */ public async mediaPlayerStop(entityId: string): Promise { await this.callService('media_player', 'media_stop', { entity_id: entityId }); } /** * Next track */ public async mediaPlayerNext(entityId: string): Promise { await this.callService('media_player', 'media_next_track', { entity_id: entityId }); } /** * Previous track */ public async mediaPlayerPrevious(entityId: string): Promise { await this.callService('media_player', 'media_previous_track', { entity_id: entityId }); } /** * Set volume */ public async mediaPlayerSetVolume(entityId: string, volumeLevel: number): Promise { await this.callService('media_player', 'volume_set', { entity_id: entityId }, { volume_level: volumeLevel }); } /** * Mute/unmute */ public async mediaPlayerMute(entityId: string, isMuted: boolean): Promise { await this.callService('media_player', 'volume_mute', { entity_id: entityId }, { is_volume_muted: isMuted }); } /** * Seek to position */ public async mediaPlayerSeek(entityId: string, position: number): Promise { await this.callService('media_player', 'media_seek', { entity_id: entityId }, { seek_position: position }); } /** * Select source */ public async mediaPlayerSelectSource(entityId: string, source: string): Promise { await this.callService('media_player', 'select_source', { entity_id: entityId }, { source }); } // ========================================================================== // Camera Services // ========================================================================== /** * Get camera snapshot URL */ public getCameraSnapshotUrl(entityId: string): string { const protocol = this.config.secure ? 'https' : 'http'; const entity = this.entityStates.get(entityId); const accessToken = (entity?.attributes as { access_token?: string })?.access_token || ''; return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy/${entityId}?token=${accessToken}`; } /** * Get camera stream URL */ public getCameraStreamUrl(entityId: string): string { const protocol = this.config.secure ? 'https' : 'http'; return `${protocol}://${this.config.host}:${this.config.port}/api/camera_proxy_stream/${entityId}`; } // ========================================================================== // Static Helpers // ========================================================================== /** * Probe if a Home Assistant instance is reachable */ public static async probe(host: string, port: number = 8123, secure: boolean = false, timeout: number = 5000): Promise { return new Promise((resolve) => { const protocol = secure ? 'wss' : 'ws'; const url = `${protocol}://${host}:${port}/api/websocket`; try { const ws = new plugins.WebSocket(url); const timer = setTimeout(() => { ws.close(); resolve(false); }, timeout); ws.on('open', () => { // Wait for auth_required message }); ws.on('message', (data: Buffer | string) => { const message = JSON.parse(data.toString()) as IHomeAssistantMessage; if (message.type === 'auth_required') { clearTimeout(timer); ws.close(); resolve(true); } }); ws.on('error', () => { clearTimeout(timer); resolve(false); }); } catch { resolve(false); } }); } }