feat(smarthome): add smart home features and Home Assistant integration (WebSocket protocol, discovery, factories, interfaces)

This commit is contained in:
2026-01-09 16:20:54 +00:00
parent 7bcec69658
commit 38a6e5c250
23 changed files with 4786 additions and 5 deletions

View 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 };