import * as vscode from 'vscode'; import * as https from 'https'; import * as http from 'http'; import { URL } from 'url'; export interface HAEntity { entity_id: string; state: string; attributes: Record; last_changed: string; last_updated: string; } /** Ensure URL has http(s):// prefix and no trailing slash */ function normalizeUrl(raw: string): string { let url = raw.trim().replace(/\/+$/, ''); if (!/^https?:\/\//i.test(url)) { url = 'http://' + url; } return url; } export class HAClient { private entities: HAEntity[] = []; private entityMap: Map = new Map(); private refreshTimer: ReturnType | undefined; private _onEntitiesUpdated = new vscode.EventEmitter(); readonly onEntitiesUpdated = this._onEntitiesUpdated.event; private log: vscode.OutputChannel; constructor(log: vscode.OutputChannel) { this.log = log; this.startAutoRefresh(); } private getConfig() { const config = vscode.workspace.getConfiguration('appdaemon'); return { url: normalizeUrl(config.get('haUrl', 'http://localhost:8123')), token: config.get('haToken', ''), refreshInterval: config.get('entityRefreshInterval', 300) }; } private async request(method: string, path: string, body?: unknown): Promise { const { url, token } = this.getConfig(); if (!token) { throw new Error('HA token not configured — set appdaemon.haToken in settings'); } const fullUrl = new URL(path, url); const isHttps = fullUrl.protocol === 'https:'; const lib = isHttps ? https : http; this.log.appendLine(`[HA] ${method} ${fullUrl.href}`); return new Promise((resolve, reject) => { const options: http.RequestOptions = { hostname: fullUrl.hostname, port: parseInt(fullUrl.port, 10) || (isHttps ? 443 : 8123), path: fullUrl.pathname + fullUrl.search, method, headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' } }; const req = lib.request(options, (res) => { let data = ''; res.on('data', (chunk: Buffer) => { data += chunk; }); res.on('end', () => { this.log.appendLine(`[HA] ${res.statusCode} (${data.length} bytes)`); if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { try { resolve(JSON.parse(data) as T); } catch { resolve(data as unknown as T); } } else { reject(new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`)); } }); }); req.on('error', (err) => { this.log.appendLine(`[HA] ERROR: ${err.message}`); reject(err); }); req.setTimeout(15000, () => { req.destroy(); reject(new Error('Request timed out')); }); if (body) { req.write(JSON.stringify(body)); } req.end(); }); } async fetchEntities(): Promise { try { this.entities = await this.request('GET', '/api/states'); this.entityMap.clear(); for (const entity of this.entities) { this.entityMap.set(entity.entity_id, entity); } this.log.appendLine(`[HA] Loaded ${this.entities.length} entities (${this.getDomains().length} domains)`); this._onEntitiesUpdated.fire(this.entities); return this.entities; } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); this.log.appendLine(`[HA] fetchEntities FAILED: ${msg}`); vscode.window.showWarningMessage(`AppDaemon: Failed to fetch entities — ${msg}`); return this.entities; } } getEntities(): HAEntity[] { return this.entities; } getEntity(entityId: string): HAEntity | undefined { return this.entityMap.get(entityId); } getDomains(): string[] { const domains = new Set(); for (const entity of this.entities) { const dot = entity.entity_id.indexOf('.'); if (dot > 0) { domains.add(entity.entity_id.substring(0, dot)); } } return Array.from(domains).sort(); } async restartHA(): Promise { await this.request('POST', '/api/services/homeassistant/restart'); } async restartHost(): Promise { await this.request('POST', '/api/services/homeassistant/restart_host'); } private startAutoRefresh() { const { refreshInterval } = this.getConfig(); if (refreshInterval > 0) { this.refreshTimer = setInterval(() => { this.fetchEntities(); }, refreshInterval * 1000); } } dispose() { if (this.refreshTimer) { clearInterval(this.refreshTimer); } this._onEntitiesUpdated.dispose(); } }