feat(smarthome): add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)
This commit is contained in:
376
ts/discovery/discovery.classes.homeassistant.ts
Normal file
376
ts/discovery/discovery.classes.homeassistant.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user