refactor: optimize configuration and tool implementations
- Standardized error handling across tool implementations - Improved return type consistency for tool execution results - Simplified configuration parsing and type definitions - Enhanced type safety for various configuration schemas - Cleaned up and normalized tool response structures - Updated SSE and event subscription tool implementations
This commit is contained in:
@@ -1,429 +1,64 @@
|
||||
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 { DomainSchema } from "../schemas.js";
|
||||
import { HASS_CONFIG } from "../config/hass.config.js";
|
||||
import { WebSocket } from 'ws';
|
||||
import { EventEmitter } from 'events';
|
||||
import * as HomeAssistant from '../types/hass.js';
|
||||
import { CreateApplication } from "@digital-alchemy/core";
|
||||
import { LIB_HASS } from "@digital-alchemy/hass";
|
||||
|
||||
type Environments = "development" | "production" | "test";
|
||||
|
||||
// Define the type for Home Assistant services
|
||||
type HassServiceMethod = (data: Record<string, unknown>) => Promise<void>;
|
||||
|
||||
type HassServices = {
|
||||
[K in keyof typeof DomainSchema.Values]: {
|
||||
[service: string]: HassServiceMethod;
|
||||
};
|
||||
};
|
||||
|
||||
// Define the type for Home Assistant instance
|
||||
interface HassInstance extends TServiceParams {
|
||||
baseUrl: string;
|
||||
token: string;
|
||||
wsClient: HassWebSocketClient | undefined;
|
||||
services: HassServices;
|
||||
als: AlsExtension;
|
||||
context: TContext;
|
||||
event: EventEmitter<[never]>;
|
||||
internal: InternalDefinition;
|
||||
lifecycle: TLifecycleBase;
|
||||
logger: ILogger;
|
||||
scheduler: TScheduler;
|
||||
config: TInjectedConfig;
|
||||
params: TServiceParams;
|
||||
hass: GetApisResult<{
|
||||
area: typeof Area;
|
||||
backup: typeof Backup;
|
||||
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
|
||||
type ApplicationConfiguration = {
|
||||
NODE_ENV: ServiceFunction<Environments>;
|
||||
};
|
||||
|
||||
// Strict configuration type for Home Assistant
|
||||
type HassConfiguration = {
|
||||
BASE_URL: {
|
||||
type: "string";
|
||||
description: string;
|
||||
required: true;
|
||||
default: string;
|
||||
};
|
||||
TOKEN: {
|
||||
type: "string";
|
||||
description: string;
|
||||
required: true;
|
||||
default: string;
|
||||
};
|
||||
SOCKET_URL: {
|
||||
type: "string";
|
||||
description: string;
|
||||
required: true;
|
||||
default: string;
|
||||
};
|
||||
SOCKET_TOKEN: {
|
||||
type: "string";
|
||||
description: string;
|
||||
required: true;
|
||||
default: string;
|
||||
};
|
||||
};
|
||||
|
||||
// application
|
||||
const MY_APP = CreateApplication<ApplicationConfiguration, {}>({
|
||||
// Create the application following the documentation example
|
||||
const app = CreateApplication({
|
||||
libraries: [LIB_HASS],
|
||||
name: "home_automation",
|
||||
configuration: {
|
||||
NODE_ENV: {
|
||||
type: "string",
|
||||
default: "development",
|
||||
enum: ["development", "production", "test"],
|
||||
description: "Code runner addon can set with it's own NODE_ENV",
|
||||
hass: {
|
||||
BASE_URL: {
|
||||
type: "string" as const,
|
||||
default: process.env.HASS_HOST || "http://localhost:8123",
|
||||
description: "Home Assistant URL",
|
||||
},
|
||||
TOKEN: {
|
||||
type: "string" as const,
|
||||
default: process.env.HASS_TOKEN || "",
|
||||
description: "Home Assistant long-lived access token",
|
||||
},
|
||||
},
|
||||
},
|
||||
services: {
|
||||
NODE_ENV: () => {
|
||||
// Directly return the default value or use process.env
|
||||
return (process.env.NODE_ENV as Environments) || "development";
|
||||
}
|
||||
},
|
||||
libraries: [
|
||||
{
|
||||
...LIB_HASS,
|
||||
configuration: {
|
||||
BASE_URL: {
|
||||
type: "string",
|
||||
description: "Home Assistant base URL",
|
||||
required: true,
|
||||
default: HASS_CONFIG.BASE_URL
|
||||
},
|
||||
TOKEN: {
|
||||
type: "string",
|
||||
description: "Home Assistant long-lived access token",
|
||||
required: true,
|
||||
default: HASS_CONFIG.TOKEN
|
||||
},
|
||||
SOCKET_URL: {
|
||||
type: "string",
|
||||
description: "Home Assistant WebSocket URL",
|
||||
required: true,
|
||||
default: HASS_CONFIG.SOCKET_URL
|
||||
},
|
||||
SOCKET_TOKEN: {
|
||||
type: "string",
|
||||
description: "Home Assistant WebSocket token",
|
||||
required: true,
|
||||
default: HASS_CONFIG.SOCKET_TOKEN
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
name: 'hass' as const
|
||||
});
|
||||
|
||||
export interface HassConfig {
|
||||
host: string;
|
||||
token: string;
|
||||
let instance: Awaited<ReturnType<typeof app.bootstrap>>;
|
||||
|
||||
export async function get_hass() {
|
||||
if (!instance) {
|
||||
try {
|
||||
instance = await app.bootstrap();
|
||||
} catch (error) {
|
||||
console.error("Failed to initialize Home Assistant:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
const CONFIG: Record<string, HassConfig> = {
|
||||
development: {
|
||||
host: process.env.HASS_HOST || 'http://localhost:8123',
|
||||
token: process.env.HASS_TOKEN || ''
|
||||
},
|
||||
production: {
|
||||
host: process.env.HASS_HOST || '',
|
||||
token: process.env.HASS_TOKEN || ''
|
||||
},
|
||||
test: {
|
||||
host: 'http://localhost:8123',
|
||||
token: 'test_token'
|
||||
}
|
||||
};
|
||||
|
||||
export class HassWebSocketClient extends EventEmitter {
|
||||
private ws: WebSocket | null = null;
|
||||
private messageId = 1;
|
||||
private subscriptions = new Map<number, (data: any) => void>();
|
||||
private reconnectAttempts = 0;
|
||||
private options: {
|
||||
autoReconnect: boolean;
|
||||
maxReconnectAttempts: number;
|
||||
reconnectDelay: number;
|
||||
};
|
||||
|
||||
constructor(
|
||||
private url: string,
|
||||
private token: string,
|
||||
options: Partial<typeof HassWebSocketClient.prototype.options> = {}
|
||||
) {
|
||||
super();
|
||||
this.options = {
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 3,
|
||||
reconnectDelay: 1000,
|
||||
...options
|
||||
};
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
this.emit('open');
|
||||
const authMessage: HomeAssistant.AuthMessage = {
|
||||
type: 'auth',
|
||||
access_token: this.token
|
||||
};
|
||||
this.ws?.send(JSON.stringify(authMessage));
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: string) => {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
this.handleMessage(message);
|
||||
} catch (error) {
|
||||
this.emit('error', new Error('Failed to parse message'));
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('close', () => {
|
||||
this.emit('disconnected');
|
||||
if (this.options.autoReconnect && this.reconnectAttempts < this.options.maxReconnectAttempts) {
|
||||
setTimeout(() => {
|
||||
this.reconnectAttempts++;
|
||||
this.connect();
|
||||
}, this.options.reconnectDelay);
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (error) => {
|
||||
this.emit('error', error);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private handleMessage(message: any): void {
|
||||
switch (message.type) {
|
||||
case 'auth_ok':
|
||||
this.emit('auth_ok');
|
||||
break;
|
||||
case 'auth_invalid':
|
||||
this.emit('auth_invalid');
|
||||
break;
|
||||
case 'result':
|
||||
// Handle command results
|
||||
break;
|
||||
case 'event':
|
||||
if (message.event) {
|
||||
this.emit('event', message.event);
|
||||
const subscription = this.subscriptions.get(message.id);
|
||||
if (subscription) {
|
||||
subscription(message.event.data);
|
||||
}
|
||||
}
|
||||
break;
|
||||
default:
|
||||
this.emit('error', new Error(`Unknown message type: ${message.type}`));
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeEvents(callback: (data: any) => void, eventType?: string): Promise<number> {
|
||||
const id = this.messageId++;
|
||||
const message = {
|
||||
id,
|
||||
type: 'subscribe_events',
|
||||
event_type: eventType
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('WebSocket not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.subscriptions.set(id, callback);
|
||||
this.ws.send(JSON.stringify(message));
|
||||
resolve(id);
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribeEvents(subscriptionId: number): Promise<void> {
|
||||
const message = {
|
||||
id: this.messageId++,
|
||||
type: 'unsubscribe_events',
|
||||
subscription: subscriptionId
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error('WebSocket not connected'));
|
||||
return;
|
||||
}
|
||||
|
||||
this.ws.send(JSON.stringify(message));
|
||||
this.subscriptions.delete(subscriptionId);
|
||||
resolve();
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
// Helper function to call Home Assistant services
|
||||
export async function call_service(
|
||||
domain: string,
|
||||
service: string,
|
||||
data: Record<string, any>,
|
||||
) {
|
||||
const hass = await get_hass();
|
||||
return hass.hass.internals.callService(domain, service, data);
|
||||
}
|
||||
|
||||
export class HassInstanceImpl implements HassInstance {
|
||||
public readonly baseUrl: string;
|
||||
public readonly token: string;
|
||||
public wsClient: HassWebSocketClient | undefined;
|
||||
|
||||
public services!: HassServices;
|
||||
public als!: AlsExtension;
|
||||
public context!: TContext;
|
||||
public event!: EventEmitter<[never]>;
|
||||
public internal!: InternalDefinition;
|
||||
public lifecycle!: TLifecycleBase;
|
||||
public logger!: ILogger;
|
||||
public scheduler!: TScheduler;
|
||||
public config!: TInjectedConfig;
|
||||
public params!: TServiceParams;
|
||||
public hass!: GetApisResult<{
|
||||
area: typeof Area;
|
||||
backup: typeof Backup;
|
||||
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;
|
||||
}>;
|
||||
|
||||
constructor(baseUrl: string, token: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
this.token = token;
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
// Initialize all required properties with proper type instantiation
|
||||
this.services = {} as HassServices;
|
||||
this.als = {} as AlsExtension;
|
||||
this.context = {} as TContext;
|
||||
this.event = new EventEmitter();
|
||||
this.internal = {} as InternalDefinition;
|
||||
this.lifecycle = {} as TLifecycleBase;
|
||||
this.logger = {} as ILogger;
|
||||
this.scheduler = {} as TScheduler;
|
||||
this.config = {} as TInjectedConfig;
|
||||
this.params = {} as TServiceParams;
|
||||
this.hass = {} as GetApisResult<any>;
|
||||
}
|
||||
|
||||
async fetchStates(): Promise<HomeAssistant.Entity[]> {
|
||||
const response = await fetch(`${this.baseUrl}/api/states`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch states: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data as HomeAssistant.Entity[];
|
||||
}
|
||||
|
||||
async fetchState(entityId: string): Promise<HomeAssistant.Entity> {
|
||||
const response = await fetch(`${this.baseUrl}/api/states/${entityId}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch state: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data as HomeAssistant.Entity;
|
||||
}
|
||||
|
||||
async callService(domain: string, service: string, data: Record<string, any>): Promise<void> {
|
||||
const response = await fetch(`${this.baseUrl}/api/services/${domain}/${service}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Service call failed: ${response.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeEvents(callback: (event: HomeAssistant.Event) => void, eventType?: string): Promise<number> {
|
||||
if (!this.wsClient) {
|
||||
this.wsClient = new HassWebSocketClient(
|
||||
this.baseUrl.replace(/^http/, 'ws') + '/api/websocket',
|
||||
this.token
|
||||
);
|
||||
await this.wsClient.connect();
|
||||
}
|
||||
|
||||
return this.wsClient.subscribeEvents(callback, eventType);
|
||||
}
|
||||
|
||||
async unsubscribeEvents(subscriptionId: number): Promise<void> {
|
||||
if (this.wsClient) {
|
||||
await this.wsClient.unsubscribeEvents(subscriptionId);
|
||||
}
|
||||
}
|
||||
// Helper function to list devices
|
||||
export async function list_devices() {
|
||||
const hass = await get_hass();
|
||||
return hass.hass.device.list();
|
||||
}
|
||||
|
||||
let hassInstance: HassInstance | null = null;
|
||||
// Helper function to get entity states
|
||||
export async function get_states() {
|
||||
const hass = await get_hass();
|
||||
return hass.hass.internals.getStates();
|
||||
}
|
||||
|
||||
export async function get_hass(): Promise<HassInstance> {
|
||||
if (!hassInstance) {
|
||||
// Safely get configuration keys, providing an empty object as fallback
|
||||
const _sortedConfigKeys = Object.keys(MY_APP.configuration ?? {}).sort();
|
||||
const instance = await MY_APP.bootstrap();
|
||||
hassInstance = instance as HassInstance;
|
||||
}
|
||||
return hassInstance;
|
||||
}
|
||||
// Helper function to get a specific entity state
|
||||
export async function get_state(entity_id: string) {
|
||||
const hass = await get_hass();
|
||||
return hass.hass.internals.getState(entity_id);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user