Refactor Home Assistant WebSocket Integration with Enhanced Type Safety
- Implemented a new HomeAssistantInstance class with comprehensive WebSocket handling - Added robust authentication and message management for Home Assistant connections - Created type-safe interfaces for states, services, and event subscriptions - Updated import paths and simplified configuration loading - Improved error handling and connection management for Home Assistant interactions
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
import { CreateApplication, TServiceParams, ServiceFunction, AlsExtension, GetApisResult, ILogger, InternalDefinition, TContext, TInjectedConfig, TLifecycleBase, TScheduler } from "@digital-alchemy/core";
|
import { CreateApplication, TServiceParams, ServiceFunction, AlsExtension, GetApisResult, ILogger, InternalDefinition, TContext, TInjectedConfig, TLifecycleBase, TScheduler } from "@digital-alchemy/core";
|
||||||
import { Area, Backup, CallProxy, Configure, Device, EntityManager, EventsService, FetchAPI, FetchInternals, Floor, IDByExtension, Label, LIB_HASS, ReferenceService, Registry, WebsocketAPI, Zone } from "@digital-alchemy/hass";
|
import { Area, Backup, CallProxy, Configure, Device, EntityManager, EventsService, FetchAPI, FetchInternals, Floor, IDByExtension, Label, LIB_HASS, ReferenceService, Registry, WebsocketAPI, Zone } from "@digital-alchemy/hass";
|
||||||
import { DomainSchema } from "../schemas.js";
|
import { DomainSchema } from "../schemas.js";
|
||||||
import { HASS_CONFIG } from "../config/hass.config.js";
|
import { HASS_CONFIG } from "../config/index.js";
|
||||||
import { WebSocket } from 'ws';
|
import WebSocket from 'ws';
|
||||||
import { EventEmitter } from 'events';
|
import { EventEmitter } from 'events';
|
||||||
import * as HomeAssistant from '../types/hass.js';
|
import * as HomeAssistant from '../types/hass.js';
|
||||||
|
import { HassEntity, HassEvent, HassService } from '../interfaces/hass.js';
|
||||||
|
|
||||||
type Environments = "development" | "production" | "test";
|
type Environments = "development" | "production" | "test";
|
||||||
|
|
||||||
@@ -18,38 +19,23 @@ type HassServices = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Define the type for Home Assistant instance
|
// Define the type for Home Assistant instance
|
||||||
interface HassInstance extends TServiceParams {
|
interface HassInstance {
|
||||||
baseUrl: string;
|
states: {
|
||||||
token: string;
|
get: () => Promise<HassEntity[]>;
|
||||||
wsClient: HassWebSocketClient | undefined;
|
subscribe: (callback: (states: HassEntity[]) => void) => Promise<number>;
|
||||||
services: HassServices;
|
unsubscribe: (subscription: number) => void;
|
||||||
als: AlsExtension;
|
};
|
||||||
context: TContext;
|
services: {
|
||||||
event: EventEmitter<[never]>;
|
get: () => Promise<Record<string, Record<string, HassService>>>;
|
||||||
internal: InternalDefinition;
|
call: (domain: string, service: string, serviceData?: Record<string, any>) => Promise<void>;
|
||||||
lifecycle: TLifecycleBase;
|
};
|
||||||
logger: ILogger;
|
connection: {
|
||||||
scheduler: TScheduler;
|
socket: WebSocket;
|
||||||
config: TInjectedConfig;
|
subscribeEvents: (callback: (event: HassEvent) => void, eventType?: string) => Promise<number>;
|
||||||
params: TServiceParams;
|
unsubscribeEvents: (subscription: number) => void;
|
||||||
hass: GetApisResult<{
|
};
|
||||||
area: typeof Area;
|
subscribeEvents: (callback: (event: HassEvent) => void, eventType?: string) => Promise<number>;
|
||||||
backup: typeof Backup;
|
unsubscribeEvents: (subscription: number) => void;
|
||||||
call: typeof CallProxy;
|
|
||||||
configure: typeof Configure;
|
|
||||||
device: typeof Device;
|
|
||||||
entity: typeof EntityManager;
|
|
||||||
events: typeof EventsService;
|
|
||||||
fetch: typeof FetchAPI;
|
|
||||||
floor: typeof Floor;
|
|
||||||
idBy: typeof IDByExtension;
|
|
||||||
internals: typeof FetchInternals;
|
|
||||||
label: typeof Label;
|
|
||||||
refBy: typeof ReferenceService;
|
|
||||||
registry: typeof Registry;
|
|
||||||
socket: typeof WebsocketAPI;
|
|
||||||
zone: typeof Zone;
|
|
||||||
}>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration type for application with more specific constraints
|
// Configuration type for application with more specific constraints
|
||||||
@@ -416,14 +402,208 @@ export class HassInstanceImpl implements HassInstance {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hassInstance: HassInstance | null = null;
|
class HomeAssistantInstance implements HassInstance {
|
||||||
|
private messageId = 1;
|
||||||
|
private messageCallbacks = new Map<number, (result: any) => void>();
|
||||||
|
private eventCallbacks = new Map<number, (event: HassEvent) => void>();
|
||||||
|
private stateCallbacks = new Map<number, (states: HassEntity[]) => void>();
|
||||||
|
private _authenticated = false;
|
||||||
|
private socket: WebSocket;
|
||||||
|
private readonly _states: HassInstance['states'];
|
||||||
|
private readonly _services: HassInstance['services'];
|
||||||
|
private readonly _connection: HassInstance['connection'];
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
if (!HASS_CONFIG.TOKEN) {
|
||||||
|
throw new Error('Home Assistant token is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.socket = new WebSocket(HASS_CONFIG.SOCKET_URL);
|
||||||
|
|
||||||
|
this._states = {
|
||||||
|
get: async (): Promise<HassEntity[]> => {
|
||||||
|
const message = {
|
||||||
|
type: 'get_states'
|
||||||
|
};
|
||||||
|
return this.sendMessage(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
subscribe: async (callback: (states: HassEntity[]) => void): Promise<number> => {
|
||||||
|
const id = this.messageId++;
|
||||||
|
this.stateCallbacks.set(id, callback);
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'subscribe_events',
|
||||||
|
event_type: 'state_changed'
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.sendMessage(message);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
|
||||||
|
unsubscribe: (subscription: number): void => {
|
||||||
|
this.stateCallbacks.delete(subscription);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._services = {
|
||||||
|
get: async (): Promise<Record<string, Record<string, HassService>>> => {
|
||||||
|
const message = {
|
||||||
|
type: 'get_services'
|
||||||
|
};
|
||||||
|
return this.sendMessage(message);
|
||||||
|
},
|
||||||
|
|
||||||
|
call: async (domain: string, service: string, serviceData?: Record<string, any>): Promise<void> => {
|
||||||
|
const message = {
|
||||||
|
type: 'call_service',
|
||||||
|
domain,
|
||||||
|
service,
|
||||||
|
service_data: serviceData
|
||||||
|
};
|
||||||
|
await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this._connection = {
|
||||||
|
socket: this.socket,
|
||||||
|
subscribeEvents: this.subscribeEvents.bind(this),
|
||||||
|
unsubscribeEvents: this.unsubscribeEvents.bind(this)
|
||||||
|
};
|
||||||
|
|
||||||
|
this.setupWebSocket();
|
||||||
|
}
|
||||||
|
|
||||||
|
get authenticated(): boolean {
|
||||||
|
return this._authenticated;
|
||||||
|
}
|
||||||
|
|
||||||
|
get states(): HassInstance['states'] {
|
||||||
|
return this._states;
|
||||||
|
}
|
||||||
|
|
||||||
|
get services(): HassInstance['services'] {
|
||||||
|
return this._services;
|
||||||
|
}
|
||||||
|
|
||||||
|
get connection(): HassInstance['connection'] {
|
||||||
|
return this._connection;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupWebSocket() {
|
||||||
|
this.socket.on('open', () => {
|
||||||
|
this.authenticate();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('message', (data: WebSocket.Data) => {
|
||||||
|
if (typeof data === 'string') {
|
||||||
|
const message = JSON.parse(data);
|
||||||
|
this.handleMessage(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('close', () => {
|
||||||
|
console.log('WebSocket connection closed');
|
||||||
|
// Implement reconnection logic here
|
||||||
|
});
|
||||||
|
|
||||||
|
this.socket.on('error', (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private authenticate() {
|
||||||
|
const auth = {
|
||||||
|
type: 'auth',
|
||||||
|
access_token: HASS_CONFIG.TOKEN
|
||||||
|
};
|
||||||
|
this.socket.send(JSON.stringify(auth));
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleMessage(message: any) {
|
||||||
|
if (message.type === 'auth_ok') {
|
||||||
|
this._authenticated = true;
|
||||||
|
console.log('Authenticated with Home Assistant');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'auth_invalid') {
|
||||||
|
console.error('Authentication failed:', message.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'event') {
|
||||||
|
const callback = this.eventCallbacks.get(message.id);
|
||||||
|
if (callback) {
|
||||||
|
callback(message.event);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === 'result') {
|
||||||
|
const callback = this.messageCallbacks.get(message.id);
|
||||||
|
if (callback) {
|
||||||
|
callback(message.result);
|
||||||
|
this.messageCallbacks.delete(message.id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendMessage(message: any): Promise<any> {
|
||||||
|
if (!this._authenticated) {
|
||||||
|
throw new Error('Not authenticated with Home Assistant');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const id = this.messageId++;
|
||||||
|
message.id = id;
|
||||||
|
|
||||||
|
this.messageCallbacks.set(id, resolve);
|
||||||
|
this.socket.send(JSON.stringify(message));
|
||||||
|
|
||||||
|
// Add timeout
|
||||||
|
setTimeout(() => {
|
||||||
|
this.messageCallbacks.delete(id);
|
||||||
|
reject(new Error('Message timeout'));
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribeEvents(callback: (event: HassEvent) => void, eventType?: string): Promise<number> {
|
||||||
|
const id = this.messageId++;
|
||||||
|
this.eventCallbacks.set(id, callback);
|
||||||
|
|
||||||
|
const message = {
|
||||||
|
type: 'subscribe_events',
|
||||||
|
event_type: eventType
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.sendMessage(message);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public unsubscribeEvents(subscription: number): void {
|
||||||
|
this.eventCallbacks.delete(subscription);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let hassInstance: HomeAssistantInstance | null = null;
|
||||||
|
|
||||||
export async function get_hass(): Promise<HassInstance> {
|
export async function get_hass(): Promise<HassInstance> {
|
||||||
if (!hassInstance) {
|
if (!hassInstance) {
|
||||||
// Safely get configuration keys, providing an empty object as fallback
|
hassInstance = new HomeAssistantInstance();
|
||||||
const _sortedConfigKeys = Object.keys(MY_APP.configuration ?? {}).sort();
|
// Wait for authentication
|
||||||
const instance = await MY_APP.bootstrap();
|
await new Promise<void>((resolve) => {
|
||||||
hassInstance = instance as HassInstance;
|
const checkAuth = () => {
|
||||||
|
if (hassInstance?.authenticated) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
setTimeout(checkAuth, 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
checkAuth();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return hassInstance;
|
return hassInstance;
|
||||||
}
|
}
|
||||||
@@ -54,6 +54,7 @@ export interface HassState {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add-on interfaces
|
||||||
export interface HassAddon {
|
export interface HassAddon {
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -178,4 +179,7 @@ export interface AutomationConfigParams {
|
|||||||
condition?: any[];
|
condition?: any[];
|
||||||
action: any[];
|
action: any[];
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export Home Assistant types
|
||||||
|
export * from './hass.js';
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { Tool } from '../interfaces/index.js';
|
import { Tool, HassEntity } from '../interfaces/index.js';
|
||||||
import { get_hass } from '../hass/index.js';
|
import { get_hass } from '../hass/index.js';
|
||||||
import { DomainSchema } from '../schemas.js';
|
import { DomainSchema } from '../schemas.js';
|
||||||
import { HassEntity, HassState } from '../interfaces/index.js';
|
|
||||||
|
|
||||||
// Define tools array
|
// Define tools array
|
||||||
export const tools: Tool[] = [
|
export const tools: Tool[] = [
|
||||||
|
|||||||
Reference in New Issue
Block a user