Files
devicemanager/ts/protocols/protocol.homeassistant.ts

738 lines
22 KiB
TypeScript
Raw Normal View History

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<typeof plugins.WebSocket> | null = null;
private config: IHomeAssistantInstanceConfig;
private messageId: number = 1;
private pendingRequests: Map<number, {
resolve: (value: unknown) => void;
reject: (error: Error) => void;
timeout: ReturnType<typeof setTimeout>;
}> = new Map();
private isAuthenticated: boolean = false;
private haConfig: IHomeAssistantConfig | null = null;
private reconnectAttempt: number = 0;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private stateSubscriptionId: number | null = null;
private entityStates: Map<string, IHomeAssistantEntity> = 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<string, IHomeAssistantEntity> {
return this.entityStates;
}
/**
* Connect to Home Assistant
*/
public async connect(): Promise<void> {
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<void> {
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<void> {
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<T>(type: string, data: Record<string, unknown> = {}): Promise<T> {
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<T>((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<IHomeAssistantConfig> {
return this.sendRequest<IHomeAssistantConfig>('get_config');
}
/**
* Subscribe to state change events
*/
public async subscribeToStateChanges(): Promise<number> {
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<IHomeAssistantEntity[]> {
return this.sendRequest<IHomeAssistantEntity[]>('get_states');
}
/**
* Get a specific entity state
*/
public async getState(entityId: string): Promise<IHomeAssistantEntity | null> {
// 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<IHomeAssistantEntity[]> {
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<string, unknown>
): Promise<void> {
await this.sendRequest('call_service', {
domain,
service,
target,
service_data: serviceData,
});
}
/**
* Turn on an entity
*/
public async turnOn(entityId: string, data?: Record<string, unknown>): Promise<void> {
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<void> {
const domain = entityId.split('.')[0];
await this.callService(domain, 'turn_off', { entity_id: entityId });
}
/**
* Toggle an entity
*/
public async toggle(entityId: string): Promise<void> {
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<void> {
await this.callService('light', 'turn_on', { entity_id: entityId }, options);
}
public async lightTurnOff(entityId: string, options?: { transition?: number }): Promise<void> {
await this.callService('light', 'turn_off', { entity_id: entityId }, options);
}
public async lightToggle(entityId: string, options?: IHomeAssistantLightServiceData): Promise<void> {
await this.callService('light', 'toggle', { entity_id: entityId }, options);
}
// ==========================================================================
// Climate Services
// ==========================================================================
/**
* Set HVAC mode
*/
public async climateSetHvacMode(entityId: string, hvacMode: string): Promise<void> {
await this.callService('climate', 'set_hvac_mode', { entity_id: entityId }, { hvac_mode: hvacMode });
}
/**
* Set target temperature
*/
public async climateSetTemperature(entityId: string, options: IHomeAssistantClimateServiceData): Promise<void> {
await this.callService('climate', 'set_temperature', { entity_id: entityId }, options);
}
/**
* Set fan mode
*/
public async climateSetFanMode(entityId: string, fanMode: string): Promise<void> {
await this.callService('climate', 'set_fan_mode', { entity_id: entityId }, { fan_mode: fanMode });
}
/**
* Set preset mode
*/
public async climateSetPresetMode(entityId: string, presetMode: string): Promise<void> {
await this.callService('climate', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
}
/**
* Set swing mode
*/
public async climateSetSwingMode(entityId: string, swingMode: string): Promise<void> {
await this.callService('climate', 'set_swing_mode', { entity_id: entityId }, { swing_mode: swingMode });
}
/**
* Set aux heat
*/
public async climateSetAuxHeat(entityId: string, auxHeat: boolean): Promise<void> {
await this.callService('climate', 'set_aux_heat', { entity_id: entityId }, { aux_heat: auxHeat });
}
// ==========================================================================
// Cover Services
// ==========================================================================
/**
* Open cover
*/
public async coverOpen(entityId: string): Promise<void> {
await this.callService('cover', 'open_cover', { entity_id: entityId });
}
/**
* Close cover
*/
public async coverClose(entityId: string): Promise<void> {
await this.callService('cover', 'close_cover', { entity_id: entityId });
}
/**
* Stop cover
*/
public async coverStop(entityId: string): Promise<void> {
await this.callService('cover', 'stop_cover', { entity_id: entityId });
}
/**
* Set cover position
*/
public async coverSetPosition(entityId: string, position: number): Promise<void> {
await this.callService('cover', 'set_cover_position', { entity_id: entityId }, { position });
}
/**
* Set cover tilt position
*/
public async coverSetTiltPosition(entityId: string, tiltPosition: number): Promise<void> {
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<void> {
await this.callService('fan', 'turn_on', { entity_id: entityId }, options);
}
/**
* Turn off fan
*/
public async fanTurnOff(entityId: string): Promise<void> {
await this.callService('fan', 'turn_off', { entity_id: entityId });
}
/**
* Set fan percentage
*/
public async fanSetPercentage(entityId: string, percentage: number): Promise<void> {
await this.callService('fan', 'set_percentage', { entity_id: entityId }, { percentage });
}
/**
* Set fan preset mode
*/
public async fanSetPresetMode(entityId: string, presetMode: string): Promise<void> {
await this.callService('fan', 'set_preset_mode', { entity_id: entityId }, { preset_mode: presetMode });
}
/**
* Oscillate fan
*/
public async fanOscillate(entityId: string, oscillating: boolean): Promise<void> {
await this.callService('fan', 'oscillate', { entity_id: entityId }, { oscillating });
}
/**
* Set fan direction
*/
public async fanSetDirection(entityId: string, direction: 'forward' | 'reverse'): Promise<void> {
await this.callService('fan', 'set_direction', { entity_id: entityId }, { direction });
}
// ==========================================================================
// Lock Services
// ==========================================================================
/**
* Lock
*/
public async lockLock(entityId: string): Promise<void> {
await this.callService('lock', 'lock', { entity_id: entityId });
}
/**
* Unlock
*/
public async lockUnlock(entityId: string): Promise<void> {
await this.callService('lock', 'unlock', { entity_id: entityId });
}
/**
* Open (if supported)
*/
public async lockOpen(entityId: string): Promise<void> {
await this.callService('lock', 'open', { entity_id: entityId });
}
// ==========================================================================
// Switch Services
// ==========================================================================
/**
* Turn on switch
*/
public async switchTurnOn(entityId: string): Promise<void> {
await this.callService('switch', 'turn_on', { entity_id: entityId });
}
/**
* Turn off switch
*/
public async switchTurnOff(entityId: string): Promise<void> {
await this.callService('switch', 'turn_off', { entity_id: entityId });
}
/**
* Toggle switch
*/
public async switchToggle(entityId: string): Promise<void> {
await this.callService('switch', 'toggle', { entity_id: entityId });
}
// ==========================================================================
// Media Player Services
// ==========================================================================
/**
* Play media
*/
public async mediaPlayerPlay(entityId: string): Promise<void> {
await this.callService('media_player', 'media_play', { entity_id: entityId });
}
/**
* Pause media
*/
public async mediaPlayerPause(entityId: string): Promise<void> {
await this.callService('media_player', 'media_pause', { entity_id: entityId });
}
/**
* Stop media
*/
public async mediaPlayerStop(entityId: string): Promise<void> {
await this.callService('media_player', 'media_stop', { entity_id: entityId });
}
/**
* Next track
*/
public async mediaPlayerNext(entityId: string): Promise<void> {
await this.callService('media_player', 'media_next_track', { entity_id: entityId });
}
/**
* Previous track
*/
public async mediaPlayerPrevious(entityId: string): Promise<void> {
await this.callService('media_player', 'media_previous_track', { entity_id: entityId });
}
/**
* Set volume
*/
public async mediaPlayerSetVolume(entityId: string, volumeLevel: number): Promise<void> {
await this.callService('media_player', 'volume_set', { entity_id: entityId }, { volume_level: volumeLevel });
}
/**
* Mute/unmute
*/
public async mediaPlayerMute(entityId: string, isMuted: boolean): Promise<void> {
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<void> {
await this.callService('media_player', 'media_seek', { entity_id: entityId }, { seek_position: position });
}
/**
* Select source
*/
public async mediaPlayerSelectSource(entityId: string, source: string): Promise<void> {
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<boolean> {
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);
}
});
}
}