377 lines
9.9 KiB
TypeScript
377 lines
9.9 KiB
TypeScript
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<string, IHomeAssistantDiscoveredInstance> = new Map();
|
|
private connectedProtocols: Map<string, HomeAssistantProtocol> = new Map();
|
|
private entityCache: Map<string, IHomeAssistantEntity> = 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<string, HomeAssistantProtocol> {
|
|
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<void> {
|
|
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<void> {
|
|
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<HomeAssistantProtocol> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, unknown> | undefined): Record<string, string> {
|
|
const records: Record<string, string> = {};
|
|
|
|
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<boolean> {
|
|
return HomeAssistantProtocol.probe(host, port, secure, timeout);
|
|
}
|
|
}
|
|
|
|
export { HA_SERVICE_TYPE };
|