163 lines
5.4 KiB
TypeScript
163 lines
5.4 KiB
TypeScript
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<string, unknown>;
|
|
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<string, HAEntity> = new Map();
|
|
private refreshTimer: ReturnType<typeof setInterval> | undefined;
|
|
private _onEntitiesUpdated = new vscode.EventEmitter<HAEntity[]>();
|
|
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<string>('haUrl', 'http://localhost:8123')),
|
|
token: config.get<string>('haToken', ''),
|
|
refreshInterval: config.get<number>('entityRefreshInterval', 300)
|
|
};
|
|
}
|
|
|
|
private async request<T = unknown>(method: string, path: string, body?: unknown): Promise<T> {
|
|
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<T>((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<HAEntity[]> {
|
|
try {
|
|
this.entities = await this.request<HAEntity[]>('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<string>();
|
|
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<void> {
|
|
await this.request('POST', '/api/services/homeassistant/restart');
|
|
}
|
|
|
|
async restartHost(): Promise<void> {
|
|
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();
|
|
}
|
|
}
|