ha-ai-mcp-server 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/dist/adapters/addon/addon.adapter.d.ts +22 -0
  2. package/dist/adapters/addon/addon.adapter.d.ts.map +1 -0
  3. package/dist/adapters/addon/addon.adapter.js +92 -0
  4. package/dist/adapters/addon/addon.adapter.js.map +1 -0
  5. package/dist/adapters/browser/playwright.adapter.d.ts +27 -0
  6. package/dist/adapters/browser/playwright.adapter.d.ts.map +1 -0
  7. package/dist/adapters/browser/playwright.adapter.js +156 -0
  8. package/dist/adapters/browser/playwright.adapter.js.map +1 -0
  9. package/dist/adapters/ha/ha-rest.adapter.d.ts +19 -0
  10. package/dist/adapters/ha/ha-rest.adapter.d.ts.map +1 -0
  11. package/dist/adapters/ha/ha-rest.adapter.js +70 -0
  12. package/dist/adapters/ha/ha-rest.adapter.js.map +1 -0
  13. package/dist/adapters/ha/ha-ws.adapter.d.ts +29 -0
  14. package/dist/adapters/ha/ha-ws.adapter.d.ts.map +1 -0
  15. package/dist/adapters/ha/ha-ws.adapter.js +178 -0
  16. package/dist/adapters/ha/ha-ws.adapter.js.map +1 -0
  17. package/dist/browser/renderer.d.ts +3 -0
  18. package/dist/browser/renderer.d.ts.map +1 -0
  19. package/dist/browser/renderer.js +3 -0
  20. package/dist/browser/renderer.js.map +1 -0
  21. package/dist/clients/addon-client.d.ts +3 -0
  22. package/dist/clients/addon-client.d.ts.map +1 -0
  23. package/dist/clients/addon-client.js +3 -0
  24. package/dist/clients/addon-client.js.map +1 -0
  25. package/dist/clients/ha-rest.d.ts +3 -0
  26. package/dist/clients/ha-rest.d.ts.map +1 -0
  27. package/dist/clients/ha-rest.js +3 -0
  28. package/dist/clients/ha-rest.js.map +1 -0
  29. package/dist/clients/ha-ws.d.ts +3 -0
  30. package/dist/clients/ha-ws.d.ts.map +1 -0
  31. package/dist/clients/ha-ws.js +3 -0
  32. package/dist/clients/ha-ws.js.map +1 -0
  33. package/dist/config.d.ts +2 -0
  34. package/dist/config.d.ts.map +1 -0
  35. package/dist/config.js +2 -0
  36. package/dist/config.js.map +1 -0
  37. package/dist/core/config.d.ts +30 -0
  38. package/dist/core/config.d.ts.map +1 -0
  39. package/dist/core/config.js +27 -0
  40. package/dist/core/config.js.map +1 -0
  41. package/dist/core/errors.d.ts +18 -0
  42. package/dist/core/errors.d.ts.map +1 -0
  43. package/dist/core/errors.js +29 -0
  44. package/dist/core/errors.js.map +1 -0
  45. package/dist/core/logger.d.ts +13 -0
  46. package/dist/core/logger.d.ts.map +1 -0
  47. package/dist/core/logger.js +25 -0
  48. package/dist/core/logger.js.map +1 -0
  49. package/dist/domain/models/automation.d.ts +19 -0
  50. package/dist/domain/models/automation.d.ts.map +1 -0
  51. package/dist/domain/models/automation.js +2 -0
  52. package/dist/domain/models/automation.js.map +1 -0
  53. package/dist/domain/models/dashboard.d.ts +21 -0
  54. package/dist/domain/models/dashboard.d.ts.map +1 -0
  55. package/dist/domain/models/dashboard.js +2 -0
  56. package/dist/domain/models/dashboard.js.map +1 -0
  57. package/dist/domain/models/entity.d.ts +13 -0
  58. package/dist/domain/models/entity.d.ts.map +1 -0
  59. package/dist/domain/models/entity.js +2 -0
  60. package/dist/domain/models/entity.js.map +1 -0
  61. package/dist/domain/models/system.d.ts +35 -0
  62. package/dist/domain/models/system.d.ts.map +1 -0
  63. package/dist/domain/models/system.js +2 -0
  64. package/dist/domain/models/system.js.map +1 -0
  65. package/dist/domain/ports/addon-client.port.d.ts +31 -0
  66. package/dist/domain/ports/addon-client.port.d.ts.map +1 -0
  67. package/dist/domain/ports/addon-client.port.js +2 -0
  68. package/dist/domain/ports/addon-client.port.js.map +1 -0
  69. package/dist/domain/ports/ha-client.port.d.ts +17 -0
  70. package/dist/domain/ports/ha-client.port.d.ts.map +1 -0
  71. package/dist/domain/ports/ha-client.port.js +2 -0
  72. package/dist/domain/ports/ha-client.port.js.map +1 -0
  73. package/dist/domain/ports/renderer.port.d.ts +7 -0
  74. package/dist/domain/ports/renderer.port.d.ts.map +1 -0
  75. package/dist/domain/ports/renderer.port.js +2 -0
  76. package/dist/domain/ports/renderer.port.js.map +1 -0
  77. package/dist/index.d.ts +10 -0
  78. package/dist/index.d.ts.map +1 -0
  79. package/dist/index.js +87 -0
  80. package/dist/index.js.map +1 -0
  81. package/dist/services/automation.service.d.ts +20 -0
  82. package/dist/services/automation.service.d.ts.map +1 -0
  83. package/dist/services/automation.service.js +118 -0
  84. package/dist/services/automation.service.js.map +1 -0
  85. package/dist/services/dashboard.service.d.ts +22 -0
  86. package/dist/services/dashboard.service.d.ts.map +1 -0
  87. package/dist/services/dashboard.service.js +72 -0
  88. package/dist/services/dashboard.service.js.map +1 -0
  89. package/dist/services/system.service.d.ts +19 -0
  90. package/dist/services/system.service.d.ts.map +1 -0
  91. package/dist/services/system.service.js +83 -0
  92. package/dist/services/system.service.js.map +1 -0
  93. package/dist/tools/automation.d.ts +33 -0
  94. package/dist/tools/automation.d.ts.map +1 -0
  95. package/dist/tools/automation.js +246 -0
  96. package/dist/tools/automation.js.map +1 -0
  97. package/dist/tools/dashboard.d.ts +33 -0
  98. package/dist/tools/dashboard.d.ts.map +1 -0
  99. package/dist/tools/dashboard.js +166 -0
  100. package/dist/tools/dashboard.js.map +1 -0
  101. package/dist/tools/system.d.ts +44 -0
  102. package/dist/tools/system.d.ts.map +1 -0
  103. package/dist/tools/system.js +306 -0
  104. package/dist/tools/system.js.map +1 -0
  105. package/dist/tools/types.d.ts +26 -0
  106. package/dist/tools/types.d.ts.map +1 -0
  107. package/dist/tools/types.js +2 -0
  108. package/dist/tools/types.js.map +1 -0
  109. package/package.json +39 -0
  110. package/src/adapters/addon/addon.adapter.ts +113 -0
  111. package/src/adapters/browser/playwright.adapter.ts +186 -0
  112. package/src/adapters/ha/ha-rest.adapter.ts +82 -0
  113. package/src/adapters/ha/ha-ws.adapter.ts +212 -0
  114. package/src/browser/renderer.ts +7 -0
  115. package/src/clients/addon-client.ts +2 -0
  116. package/src/clients/ha-rest.ts +2 -0
  117. package/src/clients/ha-ws.ts +2 -0
  118. package/src/config.ts +1 -0
  119. package/src/core/config.ts +39 -0
  120. package/src/core/errors.ts +28 -0
  121. package/src/core/logger.ts +27 -0
  122. package/src/domain/models/automation.ts +19 -0
  123. package/src/domain/models/dashboard.ts +23 -0
  124. package/src/domain/models/entity.ts +13 -0
  125. package/src/domain/models/system.ts +37 -0
  126. package/src/domain/ports/addon-client.port.ts +35 -0
  127. package/src/domain/ports/ha-client.port.ts +16 -0
  128. package/src/domain/ports/renderer.port.ts +7 -0
  129. package/src/index.ts +103 -0
  130. package/src/services/automation.service.ts +142 -0
  131. package/src/services/dashboard.service.ts +83 -0
  132. package/src/services/system.service.ts +94 -0
  133. package/src/tools/automation.ts +300 -0
  134. package/src/tools/dashboard.ts +206 -0
  135. package/src/tools/system.ts +368 -0
  136. package/src/tools/types.ts +30 -0
  137. package/tests/clients.test.ts +522 -0
  138. package/tests/e2e-smoke.test.ts +620 -0
  139. package/tests/renderer.test.ts +286 -0
  140. package/tests/skills.test.ts +189 -0
  141. package/tests/tools.test.ts +530 -0
  142. package/tsconfig.json +10 -0
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "ha-ai-mcp-server",
3
+ "version": "0.3.8",
4
+ "description": "Model Context Protocol server for Home Assistant with Playwright visual feedback",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "ha-ai-mcp": "./dist/index.js"
9
+ },
10
+ "scripts": {
11
+ "build": "tsc",
12
+ "test": "vitest run",
13
+ "test:watch": "vitest",
14
+ "start": "node dist/index.js",
15
+ "lint": "tsc --noEmit"
16
+ },
17
+ "dependencies": {
18
+ "@modelcontextprotocol/sdk": "^1.6.0",
19
+ "axios": "^1.7.9",
20
+ "playwright": "^1.50.0",
21
+ "ws": "^8.18.0",
22
+ "zod": "^3.24.2"
23
+ },
24
+ "devDependencies": {
25
+ "@types/node": "^20.17.19",
26
+ "@types/ws": "^8.5.14",
27
+ "typescript": "^5.7.3",
28
+ "vitest": "^4.1.11"
29
+ },
30
+ "keywords": [
31
+ "home-assistant",
32
+ "mcp",
33
+ "model-context-protocol",
34
+ "playwright",
35
+ "lovelace",
36
+ "ai-agents"
37
+ ],
38
+ "license": "Apache-2.0"
39
+ }
@@ -0,0 +1,113 @@
1
+ import axios, { AxiosInstance } from "axios";
2
+ import {
3
+ AddonHealthResult,
4
+ FileReadResult,
5
+ FileWriteResult,
6
+ IAddonClient,
7
+ } from "../../domain/ports/addon-client.port.js";
8
+ import { BackupRestoreResult, LogsTailResult, SnapshotInfo } from "../../domain/models/system.js";
9
+ import { ClientError } from "../../core/errors.js";
10
+
11
+ export interface AddonClientOptions {
12
+ addonUrl: string;
13
+ addonKey: string;
14
+ timeoutMs?: number;
15
+ }
16
+
17
+ export class AddonAdapter implements IAddonClient {
18
+ private readonly client: AxiosInstance;
19
+
20
+ constructor(options: AddonClientOptions | string, addonKey?: string) {
21
+ let url: string;
22
+ let key: string;
23
+ let timeout = 10000;
24
+
25
+ if (typeof options === "string") {
26
+ url = options;
27
+ key = addonKey ?? "";
28
+ } else {
29
+ url = options.addonUrl;
30
+ key = options.addonKey;
31
+ timeout = options.timeoutMs ?? 10000;
32
+ }
33
+
34
+ const cleanUrl = url.replace(/\/+$/, "");
35
+
36
+ this.client = axios.create({
37
+ baseURL: cleanUrl,
38
+ timeout,
39
+ headers: {
40
+ "X-Addon-API-Key": key,
41
+ "Content-Type": "application/json",
42
+ },
43
+ });
44
+ }
45
+
46
+ async checkHealth(): Promise<AddonHealthResult> {
47
+ try {
48
+ const resp = await this.client.get<AddonHealthResult>("/api/v1/health");
49
+ return resp.data;
50
+ } catch (err: any) {
51
+ throw new ClientError(`Addon health check failed: ${err.message}`, err.response?.status);
52
+ }
53
+ }
54
+
55
+ async readFile(path: string): Promise<FileReadResult> {
56
+ try {
57
+ const resp = await this.client.post<FileReadResult>("/api/v1/file/read", { path });
58
+ return resp.data;
59
+ } catch (err: any) {
60
+ throw new ClientError(`Addon readFile failed: ${err.message}`, err.response?.status);
61
+ }
62
+ }
63
+
64
+ async writeFile(
65
+ path: string,
66
+ content: string,
67
+ options: { validateYaml?: boolean; label?: string } = {}
68
+ ): Promise<FileWriteResult> {
69
+ try {
70
+ const resp = await this.client.post<FileWriteResult>("/api/v1/file/write", {
71
+ path,
72
+ content,
73
+ validate_yaml: options.validateYaml ?? true,
74
+ label: options.label ?? "",
75
+ });
76
+ return resp.data;
77
+ } catch (err: any) {
78
+ throw new ClientError(`Addon writeFile failed: ${err.message}`, err.response?.status);
79
+ }
80
+ }
81
+
82
+ async listSnapshots(): Promise<SnapshotInfo[]> {
83
+ try {
84
+ const resp = await this.client.get<SnapshotInfo[]>("/api/v1/backup/list");
85
+ return resp.data;
86
+ } catch (err: any) {
87
+ throw new ClientError(`Addon listSnapshots failed: ${err.message}`, err.response?.status);
88
+ }
89
+ }
90
+
91
+ async restoreSnapshot(snapshotId: string): Promise<BackupRestoreResult> {
92
+ try {
93
+ const resp = await this.client.post<BackupRestoreResult>("/api/v1/backup/restore", {
94
+ snapshot_id: snapshotId,
95
+ });
96
+ return resp.data;
97
+ } catch (err: any) {
98
+ throw new ClientError(`Addon restoreSnapshot failed: ${err.message}`, err.response?.status);
99
+ }
100
+ }
101
+
102
+ async getLogs(lines: number = 100): Promise<LogsTailResult> {
103
+ try {
104
+ const resp = await this.client.get<LogsTailResult>(`/api/v1/logs/tail?lines=${lines}`);
105
+ return resp.data;
106
+ } catch (err: any) {
107
+ throw new ClientError(`Addon getLogs failed: ${err.message}`, err.response?.status);
108
+ }
109
+ }
110
+ }
111
+
112
+ // Backward-compatible alias
113
+ export { AddonAdapter as AddonClient };
@@ -0,0 +1,186 @@
1
+ import path from "path";
2
+ import os from "os";
3
+ import { Browser, BrowserContext, chromium } from "playwright";
4
+ import { IDashboardRenderer } from "../../domain/ports/renderer.port.js";
5
+ import { DevicePreset, RenderOptions, RenderResult, ViewportDimensions } from "../../domain/models/dashboard.js";
6
+ import { ClientError } from "../../core/errors.js";
7
+
8
+ export interface RendererConfig {
9
+ haUrl: string;
10
+ haToken: string;
11
+ browserStateDir?: string;
12
+ }
13
+
14
+ export const DEVICE_PRESETS: Record<DevicePreset, ViewportDimensions> = {
15
+ desktop: { width: 1920, height: 1080 },
16
+ tablet: { width: 768, height: 1024 },
17
+ mobile: { width: 375, height: 812 },
18
+ };
19
+
20
+ export function resolveViewport(options: RenderOptions): ViewportDimensions {
21
+ if (options.viewport) {
22
+ return options.viewport;
23
+ }
24
+ if (options.devicePreset && DEVICE_PRESETS[options.devicePreset]) {
25
+ return DEVICE_PRESETS[options.devicePreset];
26
+ }
27
+ return DEVICE_PRESETS.desktop;
28
+ }
29
+
30
+ export function resolveUrl(baseUrl: string, urlPath: string): string {
31
+ if (urlPath.startsWith("http://") || urlPath.startsWith("https://")) {
32
+ return urlPath;
33
+ }
34
+ const cleanBase = baseUrl.replace(/\/+$/, "");
35
+ const cleanPath = urlPath.startsWith("/") ? urlPath.slice(1) : urlPath;
36
+ return cleanPath ? `${cleanBase}/${cleanPath}` : `${cleanBase}/`;
37
+ }
38
+
39
+ export class PlaywrightDashboardAdapter implements IDashboardRenderer {
40
+ private readonly haUrl: string;
41
+ private readonly haToken: string;
42
+ private readonly browserStateDir: string;
43
+ private browser: Browser | null = null;
44
+
45
+ constructor(config: RendererConfig) {
46
+ this.haUrl = config.haUrl.replace(/\/+$/, "");
47
+ this.haToken = config.haToken;
48
+ this.browserStateDir = config.browserStateDir || path.join(os.homedir(), ".ha-ai");
49
+ }
50
+
51
+ get isInitialized(): boolean {
52
+ return this.browser !== null;
53
+ }
54
+
55
+ async initBrowser(headless: boolean = true): Promise<void> {
56
+ if (!this.browser) {
57
+ try {
58
+ this.browser = await chromium.launch({
59
+ headless,
60
+ args: ["--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage"],
61
+ });
62
+ } catch (err: any) {
63
+ throw new ClientError(`Failed to launch Chromium browser via Playwright: ${err.message}`);
64
+ }
65
+ }
66
+ }
67
+
68
+ async close(): Promise<void> {
69
+ if (this.browser) {
70
+ await this.browser.close();
71
+ this.browser = null;
72
+ }
73
+ }
74
+
75
+ resolveViewport(options: RenderOptions): ViewportDimensions {
76
+ return resolveViewport(options);
77
+ }
78
+
79
+ resolveUrl(urlPath: string): string {
80
+ return resolveUrl(this.haUrl, urlPath);
81
+ }
82
+
83
+ async setupAuth(context: BrowserContext): Promise<void> {
84
+ const authData = {
85
+ access_token: this.haToken,
86
+ token_type: "Bearer",
87
+ expires_in: 315360000,
88
+ hassUrl: this.haUrl,
89
+ };
90
+
91
+ await context.addInitScript(
92
+ (data) => {
93
+ try {
94
+ const storage = (globalThis as any).localStorage;
95
+ if (storage) {
96
+ storage.setItem("hassTokens", JSON.stringify(data));
97
+ storage.setItem("dockedSidebar", '"auto"');
98
+ }
99
+ } catch {
100
+ // ignore in environments without localStorage
101
+ }
102
+ },
103
+ authData
104
+ );
105
+ }
106
+
107
+ async captureDashboard(options: RenderOptions): Promise<RenderResult> {
108
+ await this.initBrowser(true);
109
+
110
+ if (!this.browser) {
111
+ throw new ClientError("Browser instance is not initialized");
112
+ }
113
+
114
+ const viewport = this.resolveViewport(options);
115
+ const targetUrl = this.resolveUrl(options.urlPath);
116
+ const timeoutMs = options.timeoutMs ?? 15000;
117
+
118
+ let colorScheme: "dark" | "light" | "no-preference" = "no-preference";
119
+ if (options.darkMode === true) {
120
+ colorScheme = "dark";
121
+ } else if (options.darkMode === false) {
122
+ colorScheme = "light";
123
+ }
124
+
125
+ const context = await this.browser.newContext({
126
+ viewport,
127
+ colorScheme,
128
+ deviceScaleFactor: 1,
129
+ });
130
+
131
+ try {
132
+ await this.setupAuth(context);
133
+ const page = await context.newPage();
134
+
135
+ try {
136
+ await page.goto(targetUrl, {
137
+ waitUntil: "domcontentloaded",
138
+ timeout: timeoutMs,
139
+ });
140
+
141
+ await Promise.race([
142
+ page.waitForLoadState("networkidle", { timeout: 3000 }),
143
+ new Promise((resolve) => setTimeout(resolve, 1000)),
144
+ ]).catch(() => {});
145
+
146
+ try {
147
+ await page.waitForSelector("home-assistant, home-assistant-main, hui-view, ha-card", {
148
+ timeout: 2000,
149
+ });
150
+ } catch {
151
+ // Continue if selector is delayed
152
+ }
153
+
154
+ await new Promise((resolve) => setTimeout(resolve, 300));
155
+ } catch (err: any) {
156
+ throw new ClientError(`Failed loading dashboard at ${targetUrl}: ${err.message}`);
157
+ }
158
+
159
+ let buffer: Buffer;
160
+
161
+ if (options.elementSelector) {
162
+ const element = await page.$(options.elementSelector);
163
+ if (!element) {
164
+ throw new ClientError(`Element selector not found on page: ${options.elementSelector}`);
165
+ }
166
+ buffer = await element.screenshot({ type: "png" });
167
+ } else {
168
+ buffer = await page.screenshot({ type: "png", fullPage: false });
169
+ }
170
+
171
+ const base64Png = buffer.toString("base64");
172
+
173
+ return {
174
+ imageBuffer: buffer,
175
+ base64Png,
176
+ width: viewport.width,
177
+ height: viewport.height,
178
+ url: targetUrl,
179
+ };
180
+ } finally {
181
+ await context.close();
182
+ }
183
+ }
184
+ }
185
+
186
+ export { PlaywrightDashboardAdapter as DashboardRenderer };
@@ -0,0 +1,82 @@
1
+ import axios, { AxiosInstance } from "axios";
2
+ import { IHARestClient } from "../../domain/ports/ha-client.port.js";
3
+ import { HAEntityState } from "../../domain/models/entity.js";
4
+ import { ClientError } from "../../core/errors.js";
5
+
6
+ export interface HARestOptions {
7
+ haUrl: string;
8
+ haToken: string;
9
+ timeoutMs?: number;
10
+ }
11
+
12
+ export class HARestAdapter implements IHARestClient {
13
+ private readonly client: AxiosInstance;
14
+
15
+ constructor(options: HARestOptions | string, haToken?: string) {
16
+ let url: string;
17
+ let token: string;
18
+ let timeout = 10000;
19
+
20
+ if (typeof options === "string") {
21
+ url = options;
22
+ token = haToken ?? "";
23
+ } else {
24
+ url = options.haUrl;
25
+ token = options.haToken;
26
+ timeout = options.timeoutMs ?? 10000;
27
+ }
28
+
29
+ const cleanUrl = url.replace(/\/+$/, "");
30
+
31
+ this.client = axios.create({
32
+ baseURL: cleanUrl,
33
+ timeout,
34
+ headers: {
35
+ Authorization: `Bearer ${token}`,
36
+ "Content-Type": "application/json",
37
+ },
38
+ });
39
+ }
40
+
41
+ async checkApi(): Promise<{ message: string }> {
42
+ try {
43
+ const resp = await this.client.get<{ message: string }>("/api/");
44
+ return resp.data;
45
+ } catch (err: any) {
46
+ throw new ClientError(`HA REST /api/ check failed: ${err.message}`, err.response?.status);
47
+ }
48
+ }
49
+
50
+ async getStates(): Promise<HAEntityState[]> {
51
+ try {
52
+ const resp = await this.client.get<HAEntityState[]>("/api/states");
53
+ return resp.data;
54
+ } catch (err: any) {
55
+ throw new ClientError(`Failed to fetch HA states: ${err.message}`, err.response?.status);
56
+ }
57
+ }
58
+
59
+ async callService(domain: string, service: string, serviceData: Record<string, any> = {}): Promise<any> {
60
+ try {
61
+ const resp = await this.client.post(`/api/services/${domain}/${service}`, serviceData);
62
+ return resp.data;
63
+ } catch (err: any) {
64
+ throw new ClientError(`Failed calling service ${domain}.${service}: ${err.message}`, err.response?.status);
65
+ }
66
+ }
67
+
68
+ async getSupervisorLogs(linesCount = 100): Promise<string[]> {
69
+ try {
70
+ const resp = await this.client.get<string>("/api/hassio/supervisor/logs", {
71
+ responseType: "text",
72
+ });
73
+ const lines = String(resp.data).split("\n").filter(Boolean);
74
+ return lines.slice(-linesCount);
75
+ } catch {
76
+ return [];
77
+ }
78
+ }
79
+ }
80
+
81
+ // Backward-compatible alias
82
+ export { HARestAdapter as HARestClient };
@@ -0,0 +1,212 @@
1
+ import WebSocket from "ws";
2
+ import { IHAWsClient } from "../../domain/ports/ha-client.port.js";
3
+ import { HAEntityState } from "../../domain/models/entity.js";
4
+ import { ClientError } from "../../core/errors.js";
5
+
6
+ export interface HAWsOptions {
7
+ haUrl: string;
8
+ haToken: string;
9
+ connectTimeoutMs?: number;
10
+ requestTimeoutMs?: number;
11
+ }
12
+
13
+ interface PendingRequest {
14
+ resolve: (value: any) => void;
15
+ reject: (reason: any) => void;
16
+ timer: NodeJS.Timeout;
17
+ }
18
+
19
+ export class HAWsAdapter implements IHAWsClient {
20
+ private readonly wsUrl: string;
21
+ private readonly haToken: string;
22
+ private readonly connectTimeoutMs: number;
23
+ private readonly requestTimeoutMs: number;
24
+ private ws: WebSocket | null = null;
25
+ private messageId = 1;
26
+ private pendingRequests = new Map<number, PendingRequest>();
27
+ private isAuthenticated = false;
28
+
29
+ constructor(options: HAWsOptions | string, haToken?: string) {
30
+ let url: string;
31
+ let token: string;
32
+
33
+ if (typeof options === "string") {
34
+ url = options;
35
+ token = haToken ?? "";
36
+ this.connectTimeoutMs = 10000;
37
+ this.requestTimeoutMs = 15000;
38
+ } else {
39
+ url = options.haUrl;
40
+ token = options.haToken;
41
+ this.connectTimeoutMs = options.connectTimeoutMs ?? 10000;
42
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 15000;
43
+ }
44
+
45
+ this.haToken = token;
46
+ const cleanUrl = url.replace(/\/+$/, "");
47
+ if (cleanUrl.startsWith("http://")) {
48
+ this.wsUrl = `ws://${cleanUrl.slice("http://".length)}/api/websocket`;
49
+ } else if (cleanUrl.startsWith("https://")) {
50
+ this.wsUrl = `wss://${cleanUrl.slice("https://".length)}/api/websocket`;
51
+ } else if (cleanUrl.startsWith("ws://") || cleanUrl.startsWith("wss://")) {
52
+ this.wsUrl = cleanUrl.endsWith("/api/websocket") ? cleanUrl : `${cleanUrl}/api/websocket`;
53
+ } else {
54
+ this.wsUrl = `ws://${cleanUrl}/api/websocket`;
55
+ }
56
+ }
57
+
58
+ get isConnected(): boolean {
59
+ return this.isAuthenticated && this.ws !== null && this.ws.readyState === WebSocket.OPEN;
60
+ }
61
+
62
+ async connect(): Promise<void> {
63
+ if (this.ws && this.isAuthenticated && this.ws.readyState === WebSocket.OPEN) {
64
+ return;
65
+ }
66
+
67
+ return new Promise((resolve, reject) => {
68
+ let isSettled = false;
69
+ const timeout = setTimeout(() => {
70
+ if (!isSettled) {
71
+ isSettled = true;
72
+ this.disconnect();
73
+ reject(new ClientError("HA WebSocket connection timed out"));
74
+ }
75
+ }, this.connectTimeoutMs);
76
+
77
+ try {
78
+ this.ws = new WebSocket(this.wsUrl);
79
+ } catch (err: any) {
80
+ clearTimeout(timeout);
81
+ return reject(new ClientError(`WebSocket initialization failed: ${err.message}`));
82
+ }
83
+
84
+ this.ws.on("open", () => {});
85
+
86
+ this.ws.on("message", (raw: WebSocket.RawData) => {
87
+ try {
88
+ const msg = JSON.parse(raw.toString("utf-8"));
89
+
90
+ if (msg.type === "auth_required") {
91
+ this.ws?.send(JSON.stringify({ type: "auth", access_token: this.haToken }));
92
+ return;
93
+ }
94
+
95
+ if (msg.type === "auth_ok") {
96
+ this.isAuthenticated = true;
97
+ if (!isSettled) {
98
+ isSettled = true;
99
+ clearTimeout(timeout);
100
+ resolve();
101
+ }
102
+ return;
103
+ }
104
+
105
+ if (msg.type === "auth_invalid") {
106
+ this.isAuthenticated = false;
107
+ if (!isSettled) {
108
+ isSettled = true;
109
+ clearTimeout(timeout);
110
+ reject(new ClientError(`HA WebSocket authentication failed: ${msg.message || "Invalid token"}`));
111
+ }
112
+ this.disconnect();
113
+ return;
114
+ }
115
+
116
+ if (typeof msg.id === "number" && this.pendingRequests.has(msg.id)) {
117
+ const pending = this.pendingRequests.get(msg.id)!;
118
+ this.pendingRequests.delete(msg.id);
119
+ clearTimeout(pending.timer);
120
+
121
+ if (msg.success) {
122
+ pending.resolve(msg.result ?? null);
123
+ } else {
124
+ pending.reject(new ClientError(msg.error?.message || `WebSocket command ${msg.id} failed`));
125
+ }
126
+ }
127
+ } catch (err) {
128
+ // Ignore non-json frames
129
+ }
130
+ });
131
+
132
+ this.ws.on("error", (err: Error) => {
133
+ if (!isSettled) {
134
+ isSettled = true;
135
+ clearTimeout(timeout);
136
+ reject(new ClientError(`HA WebSocket error: ${err.message}`));
137
+ }
138
+ });
139
+
140
+ this.ws.on("close", () => {
141
+ this.isAuthenticated = false;
142
+ for (const [id, pending] of this.pendingRequests) {
143
+ clearTimeout(pending.timer);
144
+ pending.reject(new ClientError(`WebSocket closed before request ${id} resolved`));
145
+ }
146
+ this.pendingRequests.clear();
147
+ });
148
+ });
149
+ }
150
+
151
+ async disconnect(): Promise<void> {
152
+ if (this.ws) {
153
+ try {
154
+ this.ws.terminate();
155
+ } catch {
156
+ // ignore
157
+ }
158
+ this.ws = null;
159
+ }
160
+ this.isAuthenticated = false;
161
+ }
162
+
163
+ async sendMessage<T = any>(command: Record<string, any>): Promise<T> {
164
+ return this.sendCommand<T>(command);
165
+ }
166
+
167
+ private async sendCommand<T = any>(command: Record<string, any>): Promise<T> {
168
+ await this.connect();
169
+
170
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
171
+ throw new ClientError("WebSocket is not connected");
172
+ }
173
+
174
+ const id = this.messageId++;
175
+ const payload = { ...command, id };
176
+
177
+ return new Promise<T>((resolve, reject) => {
178
+ const timer = setTimeout(() => {
179
+ if (this.pendingRequests.has(id)) {
180
+ this.pendingRequests.delete(id);
181
+ reject(new ClientError(`WebSocket command ${id} timed out after ${this.requestTimeoutMs}ms`));
182
+ }
183
+ }, this.requestTimeoutMs);
184
+
185
+ this.pendingRequests.set(id, { resolve, reject, timer });
186
+ this.ws!.send(JSON.stringify(payload));
187
+ });
188
+ }
189
+
190
+ async getLovelaceConfig(urlPath?: string | null): Promise<Record<string, any>> {
191
+ const cmd: Record<string, any> = { type: "lovelace/config" };
192
+ if (urlPath) {
193
+ cmd.url_path = urlPath;
194
+ }
195
+ return this.sendCommand<Record<string, any>>(cmd);
196
+ }
197
+
198
+ async saveLovelaceConfig(config: Record<string, any>, urlPath?: string | null): Promise<null> {
199
+ const cmd: Record<string, any> = { type: "lovelace/config/save", config };
200
+ if (urlPath) {
201
+ cmd.url_path = urlPath;
202
+ }
203
+ return this.sendCommand<null>(cmd);
204
+ }
205
+
206
+ async fetchStates(): Promise<HAEntityState[]> {
207
+ return this.sendCommand<HAEntityState[]>({ type: "get_states" });
208
+ }
209
+ }
210
+
211
+ // Backward-compatible alias
212
+ export { HAWsAdapter as HAWsClient };
@@ -0,0 +1,7 @@
1
+ export * from "../adapters/browser/playwright.adapter.js";
2
+ export {
3
+ PlaywrightDashboardAdapter as DashboardRenderer,
4
+ resolveViewport,
5
+ resolveUrl,
6
+ DEVICE_PRESETS,
7
+ } from "../adapters/browser/playwright.adapter.js";
@@ -0,0 +1,2 @@
1
+ export * from "../adapters/addon/addon.adapter.js";
2
+ export { AddonAdapter as AddonClient } from "../adapters/addon/addon.adapter.js";
@@ -0,0 +1,2 @@
1
+ export * from "../adapters/ha/ha-rest.adapter.js";
2
+ export { HARestAdapter as HARestClient } from "../adapters/ha/ha-rest.adapter.js";
@@ -0,0 +1,2 @@
1
+ export * from "../adapters/ha/ha-ws.adapter.js";
2
+ export { HAWsAdapter as HAWsClient } from "../adapters/ha/ha-ws.adapter.js";
package/src/config.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./core/config.js";
@@ -0,0 +1,39 @@
1
+ import os from "os";
2
+ import path from "path";
3
+ import { z } from "zod";
4
+
5
+ export const ConfigSchema = z.object({
6
+ HA_URL: z.string().url().default("http://localhost:8123"),
7
+ HA_TOKEN: z.string().default(""),
8
+ ADDON_URL: z.string().url().default("http://localhost:8099"),
9
+ ADDON_KEY: z.string().default(""),
10
+ BROWSER_STATE_DIR: z.string().default(() => path.join(os.homedir(), ".ha-ai")),
11
+ });
12
+
13
+ export type RawConfig = z.infer<typeof ConfigSchema>;
14
+
15
+ export interface AppConfig {
16
+ haUrl: string;
17
+ haToken: string;
18
+ addonUrl: string;
19
+ addonKey: string;
20
+ browserStateDir: string;
21
+ }
22
+
23
+ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig {
24
+ const parsed = ConfigSchema.parse({
25
+ HA_URL: env.HA_URL,
26
+ HA_TOKEN: env.HA_TOKEN,
27
+ ADDON_URL: env.ADDON_URL,
28
+ ADDON_KEY: env.ADDON_KEY,
29
+ BROWSER_STATE_DIR: env.BROWSER_STATE_DIR,
30
+ });
31
+
32
+ return {
33
+ haUrl: parsed.HA_URL.replace(/\/+$/, ""),
34
+ haToken: parsed.HA_TOKEN,
35
+ addonUrl: parsed.ADDON_URL.replace(/\/+$/, ""),
36
+ addonKey: parsed.ADDON_KEY,
37
+ browserStateDir: parsed.BROWSER_STATE_DIR,
38
+ };
39
+ }