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:
jango-blockchained
2025-02-03 00:52:35 +01:00
parent 2da617c2d9
commit f049f439b9
3 changed files with 225 additions and 42 deletions

View File

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

View File

@@ -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';

View File

@@ -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[] = [