import * as plugins from '../plugins.js'; import type { IHomeAssistantInstanceConfig, IHomeAssistantEntity, IHomeAssistantDiscoveredInstance, THomeAssistantDomain, THomeAssistantDiscoveryEvents, } from '../interfaces/homeassistant.interfaces.js'; import { HomeAssistantProtocol } from '../protocols/protocol.homeassistant.js'; /** * mDNS service type for Home Assistant discovery */ const HA_SERVICE_TYPE = '_home-assistant._tcp'; /** * Default domains to discover */ const DEFAULT_DOMAINS: THomeAssistantDomain[] = [ 'light', 'switch', 'sensor', 'binary_sensor', 'climate', 'fan', 'cover', 'lock', 'camera', 'media_player', ]; /** * Home Assistant Discovery * Discovers HA instances via mDNS and/or manual configuration, * connects to them, and enumerates all entities */ export class HomeAssistantDiscovery extends plugins.events.EventEmitter { private bonjour: plugins.bonjourService.Bonjour | null = null; private browser: plugins.bonjourService.Browser | null = null; private discoveredInstances: Map = new Map(); private connectedProtocols: Map = new Map(); private entityCache: Map = new Map(); private enabledDomains: THomeAssistantDomain[]; private isRunning: boolean = false; constructor(options?: { enabledDomains?: THomeAssistantDomain[] }) { super(); this.enabledDomains = options?.enabledDomains || DEFAULT_DOMAINS; } /** * Check if discovery is running */ public get running(): boolean { return this.isRunning; } /** * Get all discovered HA instances */ public getInstances(): IHomeAssistantDiscoveredInstance[] { return Array.from(this.discoveredInstances.values()); } /** * Get connected protocol for an instance */ public getProtocol(instanceId: string): HomeAssistantProtocol | undefined { return this.connectedProtocols.get(instanceId); } /** * Get all connected protocols */ public getProtocols(): Map { return this.connectedProtocols; } /** * Get all cached entities */ public getEntities(): IHomeAssistantEntity[] { return Array.from(this.entityCache.values()); } /** * Get entities by domain */ public getEntitiesByDomain(domain: THomeAssistantDomain): IHomeAssistantEntity[] { return this.getEntities().filter((e) => e.entity_id.startsWith(`${domain}.`)); } /** * Get entities for a specific instance */ public getEntitiesForInstance(instanceId: string): IHomeAssistantEntity[] { const protocol = this.connectedProtocols.get(instanceId); if (!protocol) return []; return Array.from(protocol.entities.values()); } /** * Start mDNS discovery for Home Assistant instances */ public async startMdnsDiscovery(): Promise { if (this.isRunning) { return; } this.bonjour = new plugins.bonjourService.Bonjour(); this.isRunning = true; this.browser = this.bonjour.find({ type: HA_SERVICE_TYPE }, (service) => { this.handleInstanceFound(service); }); this.browser.on('down', (service) => { this.handleInstanceLost(service); }); } /** * Stop mDNS discovery */ public async stopMdnsDiscovery(): Promise { if (!this.isRunning) { return; } if (this.browser) { this.browser.stop(); this.browser = null; } if (this.bonjour) { this.bonjour.destroy(); this.bonjour = null; } this.isRunning = false; } /** * Add a manually configured HA instance */ public async addInstance(config: IHomeAssistantInstanceConfig): Promise { const instanceId = this.generateInstanceId(config.host, config.port || 8123); // Check if already connected if (this.connectedProtocols.has(instanceId)) { return this.connectedProtocols.get(instanceId)!; } // Create protocol and connect const protocol = new HomeAssistantProtocol(config); // Set up event handlers this.setupProtocolHandlers(protocol, instanceId); // Connect await protocol.connect(); // Subscribe to state changes await protocol.subscribeToStateChanges(); // Cache entities const entities = await protocol.getStates(); for (const entity of entities) { if (this.isEnabledDomain(entity.entity_id)) { const cacheKey = `${instanceId}:${entity.entity_id}`; this.entityCache.set(cacheKey, entity); this.emit('entity:found', entity); } } // Store protocol this.connectedProtocols.set(instanceId, protocol); // Also store as discovered instance this.discoveredInstances.set(instanceId, { id: instanceId, host: config.host, port: config.port || 8123, base_url: `http://${config.host}:${config.port || 8123}`, txtRecords: {}, requires_api_password: true, friendlyName: config.friendlyName, }); return protocol; } /** * Remove an HA instance */ public async removeInstance(instanceId: string): Promise { const protocol = this.connectedProtocols.get(instanceId); if (protocol) { await protocol.disconnect(); this.connectedProtocols.delete(instanceId); } this.discoveredInstances.delete(instanceId); // Remove cached entities for this instance for (const key of this.entityCache.keys()) { if (key.startsWith(`${instanceId}:`)) { this.entityCache.delete(key); } } this.emit('instance:lost', instanceId); } /** * Stop all and cleanup */ public async stop(): Promise { await this.stopMdnsDiscovery(); // Disconnect all protocols for (const [instanceId, protocol] of this.connectedProtocols) { await protocol.disconnect(); } this.connectedProtocols.clear(); this.discoveredInstances.clear(); this.entityCache.clear(); } /** * Handle mDNS service found */ private handleInstanceFound(service: plugins.bonjourService.Service): void { const addresses = service.addresses ?? []; const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host; if (!address) { return; } const instanceId = this.generateInstanceId(address, service.port); const txtRecords = this.parseTxtRecords(service.txt); const instance: IHomeAssistantDiscoveredInstance = { id: instanceId, host: address, port: service.port, base_url: txtRecords['base_url'] || `http://${address}:${service.port}`, txtRecords, requires_api_password: txtRecords['requires_api_password'] === 'true', friendlyName: service.name, }; // Check if this is a new instance const existing = this.discoveredInstances.get(instanceId); if (!existing) { this.discoveredInstances.set(instanceId, instance); this.emit('instance:found', instance); } } /** * Handle mDNS service lost */ private handleInstanceLost(service: plugins.bonjourService.Service): void { const addresses = service.addresses ?? []; const address = addresses.find((a) => !a.includes(':')) ?? addresses[0] ?? service.host; if (!address) { return; } const instanceId = this.generateInstanceId(address, service.port); if (this.discoveredInstances.has(instanceId)) { // Don't remove if we have an active connection (manually added) if (!this.connectedProtocols.has(instanceId)) { this.discoveredInstances.delete(instanceId); } this.emit('instance:lost', instanceId); } } /** * Set up event handlers for a protocol */ private setupProtocolHandlers(protocol: HomeAssistantProtocol, instanceId: string): void { protocol.on('state:changed', (event) => { const cacheKey = `${instanceId}:${event.entity_id}`; if (event.new_state) { if (this.isEnabledDomain(event.entity_id)) { const existing = this.entityCache.has(cacheKey); this.entityCache.set(cacheKey, event.new_state); if (existing) { this.emit('entity:updated', event.new_state); } else { this.emit('entity:found', event.new_state); } } } else { // Entity removed if (this.entityCache.has(cacheKey)) { this.entityCache.delete(cacheKey); this.emit('entity:removed', event.entity_id); } } }); protocol.on('disconnected', () => { // Clear cached entities for this instance on disconnect for (const key of this.entityCache.keys()) { if (key.startsWith(`${instanceId}:`)) { this.entityCache.delete(key); } } }); protocol.on('error', (error) => { this.emit('error', error); }); } /** * Check if entity domain is enabled */ private isEnabledDomain(entityId: string): boolean { const domain = entityId.split('.')[0] as THomeAssistantDomain; return this.enabledDomains.includes(domain); } /** * Generate unique instance ID */ private generateInstanceId(host: string, port: number): string { return `ha:${host}:${port}`; } /** * Parse TXT records from mDNS service */ private parseTxtRecords(txt: Record | undefined): Record { const records: Record = {}; if (!txt) { return records; } for (const [key, value] of Object.entries(txt)) { if (typeof value === 'string') { records[key] = value; } else if (Buffer.isBuffer(value)) { records[key] = value.toString('utf-8'); } else if (value !== undefined && value !== null) { records[key] = String(value); } } return records; } /** * Probe if a host has Home Assistant running */ public static async probe( host: string, port: number = 8123, secure: boolean = false, timeout: number = 5000 ): Promise { return HomeAssistantProtocol.probe(host, port, secure, timeout); } } export { HA_SERVICE_TYPE };