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.
- package/dist/adapters/addon/addon.adapter.d.ts +22 -0
- package/dist/adapters/addon/addon.adapter.d.ts.map +1 -0
- package/dist/adapters/addon/addon.adapter.js +92 -0
- package/dist/adapters/addon/addon.adapter.js.map +1 -0
- package/dist/adapters/browser/playwright.adapter.d.ts +27 -0
- package/dist/adapters/browser/playwright.adapter.d.ts.map +1 -0
- package/dist/adapters/browser/playwright.adapter.js +156 -0
- package/dist/adapters/browser/playwright.adapter.js.map +1 -0
- package/dist/adapters/ha/ha-rest.adapter.d.ts +19 -0
- package/dist/adapters/ha/ha-rest.adapter.d.ts.map +1 -0
- package/dist/adapters/ha/ha-rest.adapter.js +70 -0
- package/dist/adapters/ha/ha-rest.adapter.js.map +1 -0
- package/dist/adapters/ha/ha-ws.adapter.d.ts +29 -0
- package/dist/adapters/ha/ha-ws.adapter.d.ts.map +1 -0
- package/dist/adapters/ha/ha-ws.adapter.js +178 -0
- package/dist/adapters/ha/ha-ws.adapter.js.map +1 -0
- package/dist/browser/renderer.d.ts +3 -0
- package/dist/browser/renderer.d.ts.map +1 -0
- package/dist/browser/renderer.js +3 -0
- package/dist/browser/renderer.js.map +1 -0
- package/dist/clients/addon-client.d.ts +3 -0
- package/dist/clients/addon-client.d.ts.map +1 -0
- package/dist/clients/addon-client.js +3 -0
- package/dist/clients/addon-client.js.map +1 -0
- package/dist/clients/ha-rest.d.ts +3 -0
- package/dist/clients/ha-rest.d.ts.map +1 -0
- package/dist/clients/ha-rest.js +3 -0
- package/dist/clients/ha-rest.js.map +1 -0
- package/dist/clients/ha-ws.d.ts +3 -0
- package/dist/clients/ha-ws.d.ts.map +1 -0
- package/dist/clients/ha-ws.js +3 -0
- package/dist/clients/ha-ws.js.map +1 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +2 -0
- package/dist/config.js.map +1 -0
- package/dist/core/config.d.ts +30 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +27 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/errors.d.ts +18 -0
- package/dist/core/errors.d.ts.map +1 -0
- package/dist/core/errors.js +29 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/logger.d.ts +13 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +25 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/domain/models/automation.d.ts +19 -0
- package/dist/domain/models/automation.d.ts.map +1 -0
- package/dist/domain/models/automation.js +2 -0
- package/dist/domain/models/automation.js.map +1 -0
- package/dist/domain/models/dashboard.d.ts +21 -0
- package/dist/domain/models/dashboard.d.ts.map +1 -0
- package/dist/domain/models/dashboard.js +2 -0
- package/dist/domain/models/dashboard.js.map +1 -0
- package/dist/domain/models/entity.d.ts +13 -0
- package/dist/domain/models/entity.d.ts.map +1 -0
- package/dist/domain/models/entity.js +2 -0
- package/dist/domain/models/entity.js.map +1 -0
- package/dist/domain/models/system.d.ts +35 -0
- package/dist/domain/models/system.d.ts.map +1 -0
- package/dist/domain/models/system.js +2 -0
- package/dist/domain/models/system.js.map +1 -0
- package/dist/domain/ports/addon-client.port.d.ts +31 -0
- package/dist/domain/ports/addon-client.port.d.ts.map +1 -0
- package/dist/domain/ports/addon-client.port.js +2 -0
- package/dist/domain/ports/addon-client.port.js.map +1 -0
- package/dist/domain/ports/ha-client.port.d.ts +17 -0
- package/dist/domain/ports/ha-client.port.d.ts.map +1 -0
- package/dist/domain/ports/ha-client.port.js +2 -0
- package/dist/domain/ports/ha-client.port.js.map +1 -0
- package/dist/domain/ports/renderer.port.d.ts +7 -0
- package/dist/domain/ports/renderer.port.d.ts.map +1 -0
- package/dist/domain/ports/renderer.port.js +2 -0
- package/dist/domain/ports/renderer.port.js.map +1 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +87 -0
- package/dist/index.js.map +1 -0
- package/dist/services/automation.service.d.ts +20 -0
- package/dist/services/automation.service.d.ts.map +1 -0
- package/dist/services/automation.service.js +118 -0
- package/dist/services/automation.service.js.map +1 -0
- package/dist/services/dashboard.service.d.ts +22 -0
- package/dist/services/dashboard.service.d.ts.map +1 -0
- package/dist/services/dashboard.service.js +72 -0
- package/dist/services/dashboard.service.js.map +1 -0
- package/dist/services/system.service.d.ts +19 -0
- package/dist/services/system.service.d.ts.map +1 -0
- package/dist/services/system.service.js +83 -0
- package/dist/services/system.service.js.map +1 -0
- package/dist/tools/automation.d.ts +33 -0
- package/dist/tools/automation.d.ts.map +1 -0
- package/dist/tools/automation.js +246 -0
- package/dist/tools/automation.js.map +1 -0
- package/dist/tools/dashboard.d.ts +33 -0
- package/dist/tools/dashboard.d.ts.map +1 -0
- package/dist/tools/dashboard.js +166 -0
- package/dist/tools/dashboard.js.map +1 -0
- package/dist/tools/system.d.ts +44 -0
- package/dist/tools/system.d.ts.map +1 -0
- package/dist/tools/system.js +306 -0
- package/dist/tools/system.js.map +1 -0
- package/dist/tools/types.d.ts +26 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +2 -0
- package/dist/tools/types.js.map +1 -0
- package/package.json +39 -0
- package/src/adapters/addon/addon.adapter.ts +113 -0
- package/src/adapters/browser/playwright.adapter.ts +186 -0
- package/src/adapters/ha/ha-rest.adapter.ts +82 -0
- package/src/adapters/ha/ha-ws.adapter.ts +212 -0
- package/src/browser/renderer.ts +7 -0
- package/src/clients/addon-client.ts +2 -0
- package/src/clients/ha-rest.ts +2 -0
- package/src/clients/ha-ws.ts +2 -0
- package/src/config.ts +1 -0
- package/src/core/config.ts +39 -0
- package/src/core/errors.ts +28 -0
- package/src/core/logger.ts +27 -0
- package/src/domain/models/automation.ts +19 -0
- package/src/domain/models/dashboard.ts +23 -0
- package/src/domain/models/entity.ts +13 -0
- package/src/domain/models/system.ts +37 -0
- package/src/domain/ports/addon-client.port.ts +35 -0
- package/src/domain/ports/ha-client.port.ts +16 -0
- package/src/domain/ports/renderer.port.ts +7 -0
- package/src/index.ts +103 -0
- package/src/services/automation.service.ts +142 -0
- package/src/services/dashboard.service.ts +83 -0
- package/src/services/system.service.ts +94 -0
- package/src/tools/automation.ts +300 -0
- package/src/tools/dashboard.ts +206 -0
- package/src/tools/system.ts +368 -0
- package/src/tools/types.ts +30 -0
- package/tests/clients.test.ts +522 -0
- package/tests/e2e-smoke.test.ts +620 -0
- package/tests/renderer.test.ts +286 -0
- package/tests/skills.test.ts +189 -0
- package/tests/tools.test.ts +530 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { WebSocketServer, WebSocket } from "ws";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { AddressInfo } from "node:net";
|
|
5
|
+
|
|
6
|
+
import { loadConfig } from "../src/config.js";
|
|
7
|
+
import { HARestClient } from "../src/clients/ha-rest.js";
|
|
8
|
+
import { AddonClient } from "../src/clients/addon-client.js";
|
|
9
|
+
import { HAWsClient } from "../src/clients/ha-ws.js";
|
|
10
|
+
|
|
11
|
+
describe("Configuration", () => {
|
|
12
|
+
it("should load default configuration values when env is empty", () => {
|
|
13
|
+
const config = loadConfig({});
|
|
14
|
+
expect(config.haUrl).toBe("http://localhost:8123");
|
|
15
|
+
expect(config.haToken).toBe("");
|
|
16
|
+
expect(config.addonUrl).toBe("http://localhost:8099");
|
|
17
|
+
expect(config.addonKey).toBe("");
|
|
18
|
+
expect(config.browserStateDir).toBeDefined();
|
|
19
|
+
expect(config.browserStateDir).toContain(".ha-ai");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("should load configuration from custom env variables", () => {
|
|
23
|
+
const customEnv = {
|
|
24
|
+
HA_URL: "https://my-ha.duckdns.org:8123/",
|
|
25
|
+
HA_TOKEN: "custom-token-xyz",
|
|
26
|
+
ADDON_URL: "http://homeassistant.local:8099///",
|
|
27
|
+
ADDON_KEY: "secret-addon-key-123",
|
|
28
|
+
BROWSER_STATE_DIR: "/tmp/custom-ha-ai",
|
|
29
|
+
};
|
|
30
|
+
const config = loadConfig(customEnv);
|
|
31
|
+
expect(config.haUrl).toBe("https://my-ha.duckdns.org:8123");
|
|
32
|
+
expect(config.haToken).toBe("custom-token-xyz");
|
|
33
|
+
expect(config.addonUrl).toBe("http://homeassistant.local:8099");
|
|
34
|
+
expect(config.addonKey).toBe("secret-addon-key-123");
|
|
35
|
+
expect(config.browserStateDir).toBe("/tmp/custom-ha-ai");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe("HARestClient", () => {
|
|
40
|
+
let server: http.Server;
|
|
41
|
+
let serverUrl: string;
|
|
42
|
+
let lastHeaders: http.IncomingHttpHeaders;
|
|
43
|
+
let lastRequestBody: any;
|
|
44
|
+
|
|
45
|
+
beforeEach(async () => {
|
|
46
|
+
lastHeaders = {};
|
|
47
|
+
lastRequestBody = null;
|
|
48
|
+
server = http.createServer(async (req, res) => {
|
|
49
|
+
lastHeaders = req.headers;
|
|
50
|
+
const chunks: Buffer[] = [];
|
|
51
|
+
for await (const chunk of req) {
|
|
52
|
+
chunks.push(chunk);
|
|
53
|
+
}
|
|
54
|
+
if (chunks.length > 0) {
|
|
55
|
+
try {
|
|
56
|
+
lastRequestBody = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
57
|
+
} catch {
|
|
58
|
+
lastRequestBody = Buffer.concat(chunks).toString("utf-8");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (req.url === "/api/" && req.method === "GET") {
|
|
63
|
+
if (req.headers.authorization !== "Bearer test-ha-token") {
|
|
64
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
65
|
+
res.end(JSON.stringify({ message: "Unauthorized" }));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
69
|
+
res.end(JSON.stringify({ message: "API running." }));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (req.url === "/api/states" && req.method === "GET") {
|
|
74
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
75
|
+
res.end(
|
|
76
|
+
JSON.stringify([
|
|
77
|
+
{
|
|
78
|
+
entity_id: "light.living_room",
|
|
79
|
+
state: "on",
|
|
80
|
+
attributes: { brightness: 255, friendly_name: "Living Room Light" },
|
|
81
|
+
last_changed: "2026-08-31T08:00:00.000Z",
|
|
82
|
+
last_updated: "2026-08-31T08:00:00.000Z",
|
|
83
|
+
},
|
|
84
|
+
])
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (req.url === "/api/services/light/turn_on" && req.method === "POST") {
|
|
90
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
91
|
+
res.end(
|
|
92
|
+
JSON.stringify([
|
|
93
|
+
{
|
|
94
|
+
entity_id: "light.living_room",
|
|
95
|
+
state: "on",
|
|
96
|
+
},
|
|
97
|
+
])
|
|
98
|
+
);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (req.url === "/api/error" && req.method === "GET") {
|
|
103
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
104
|
+
res.end(JSON.stringify({ message: "Internal server error" }));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
109
|
+
res.end(JSON.stringify({ message: "Not found" }));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await new Promise<void>((resolve) => {
|
|
113
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
114
|
+
});
|
|
115
|
+
const addr = server.address() as AddressInfo;
|
|
116
|
+
serverUrl = `http://127.0.0.1:${addr.port}`;
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
afterEach(async () => {
|
|
120
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("should check API status and pass Bearer token", async () => {
|
|
124
|
+
const client = new HARestClient({ haUrl: serverUrl, haToken: "test-ha-token" });
|
|
125
|
+
const response = await client.checkApi();
|
|
126
|
+
expect(response).toEqual({ message: "API running." });
|
|
127
|
+
expect(lastHeaders.authorization).toBe("Bearer test-ha-token");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("should fail checkApi with invalid token", async () => {
|
|
131
|
+
const client = new HARestClient({ haUrl: serverUrl, haToken: "invalid-token" });
|
|
132
|
+
await expect(client.checkApi()).rejects.toThrow(/401|Unauthorized/i);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should get entity states", async () => {
|
|
136
|
+
const client = new HARestClient({ haUrl: serverUrl, haToken: "test-ha-token" });
|
|
137
|
+
const states = await client.getStates();
|
|
138
|
+
expect(states).toHaveLength(1);
|
|
139
|
+
expect(states[0].entity_id).toBe("light.living_room");
|
|
140
|
+
expect(states[0].state).toBe("on");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("should call a service with service data", async () => {
|
|
144
|
+
const client = new HARestClient({ haUrl: serverUrl, haToken: "test-ha-token" });
|
|
145
|
+
const result = await client.callService("light", "turn_on", {
|
|
146
|
+
entity_id: "light.living_room",
|
|
147
|
+
brightness: 128,
|
|
148
|
+
});
|
|
149
|
+
expect(result).toHaveLength(1);
|
|
150
|
+
expect(lastRequestBody).toEqual({
|
|
151
|
+
entity_id: "light.living_room",
|
|
152
|
+
brightness: 128,
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("AddonClient", () => {
|
|
158
|
+
let server: http.Server;
|
|
159
|
+
let serverUrl: string;
|
|
160
|
+
let lastHeaders: http.IncomingHttpHeaders;
|
|
161
|
+
let lastRequestBody: any;
|
|
162
|
+
|
|
163
|
+
beforeEach(async () => {
|
|
164
|
+
lastHeaders = {};
|
|
165
|
+
lastRequestBody = null;
|
|
166
|
+
server = http.createServer(async (req, res) => {
|
|
167
|
+
lastHeaders = req.headers;
|
|
168
|
+
const chunks: Buffer[] = [];
|
|
169
|
+
for await (const chunk of req) {
|
|
170
|
+
chunks.push(chunk);
|
|
171
|
+
}
|
|
172
|
+
if (chunks.length > 0) {
|
|
173
|
+
try {
|
|
174
|
+
lastRequestBody = JSON.parse(Buffer.concat(chunks).toString("utf-8"));
|
|
175
|
+
} catch {
|
|
176
|
+
lastRequestBody = Buffer.concat(chunks).toString("utf-8");
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (req.headers["x-addon-api-key"] !== "test-addon-key") {
|
|
181
|
+
res.writeHead(401, { "Content-Type": "application/json" });
|
|
182
|
+
res.end(JSON.stringify({ detail: "Invalid or missing X-Addon-API-Key header" }));
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (req.url === "/api/v1/health" && req.method === "GET") {
|
|
187
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
188
|
+
res.end(
|
|
189
|
+
JSON.stringify({
|
|
190
|
+
status: "ok",
|
|
191
|
+
version: "1.0.0",
|
|
192
|
+
config_root: "/config",
|
|
193
|
+
snapshots_count: 3,
|
|
194
|
+
memory_mb: 24.5,
|
|
195
|
+
})
|
|
196
|
+
);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (req.url === "/api/v1/file/read" && req.method === "POST") {
|
|
201
|
+
if (lastRequestBody?.path === "configuration.yaml") {
|
|
202
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
203
|
+
res.end(
|
|
204
|
+
JSON.stringify({
|
|
205
|
+
path: "configuration.yaml",
|
|
206
|
+
content: "default_config:\n",
|
|
207
|
+
size_bytes: 16,
|
|
208
|
+
})
|
|
209
|
+
);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
213
|
+
res.end(JSON.stringify({ detail: "File not found" }));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (req.url === "/api/v1/file/write" && req.method === "POST") {
|
|
218
|
+
if (lastRequestBody?.validate_yaml && lastRequestBody?.content === "invalid: yaml: [") {
|
|
219
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
220
|
+
res.end(JSON.stringify({ detail: "Invalid YAML syntax" }));
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
224
|
+
res.end(
|
|
225
|
+
JSON.stringify({
|
|
226
|
+
success: true,
|
|
227
|
+
path: lastRequestBody?.path,
|
|
228
|
+
snapshot_id: "snap_20260831_123456_configuration_yaml",
|
|
229
|
+
bytes_written: 32,
|
|
230
|
+
})
|
|
231
|
+
);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (req.url === "/api/v1/backup/list" && req.method === "GET") {
|
|
236
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
237
|
+
res.end(
|
|
238
|
+
JSON.stringify([
|
|
239
|
+
{
|
|
240
|
+
snapshot_id: "snap_20260831_123456_configuration_yaml",
|
|
241
|
+
timestamp: "2026-08-31T08:00:00Z",
|
|
242
|
+
original_file: "configuration.yaml",
|
|
243
|
+
backup_file: ".snapshots/configuration.yaml.20260831_123456.bak",
|
|
244
|
+
label: "before update",
|
|
245
|
+
file_size_bytes: 16,
|
|
246
|
+
},
|
|
247
|
+
])
|
|
248
|
+
);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (req.url === "/api/v1/backup/restore" && req.method === "POST") {
|
|
253
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
254
|
+
res.end(
|
|
255
|
+
JSON.stringify({
|
|
256
|
+
success: true,
|
|
257
|
+
restored_file: "configuration.yaml",
|
|
258
|
+
restored_from: ".snapshots/configuration.yaml.20260831_123456.bak",
|
|
259
|
+
safety_backup: ".snapshots/configuration.yaml.safety.bak",
|
|
260
|
+
})
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (req.url?.startsWith("/api/v1/logs/tail") && req.method === "GET") {
|
|
266
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
267
|
+
res.end(
|
|
268
|
+
JSON.stringify({
|
|
269
|
+
lines: ["2026-08-31 INFO Home Assistant started", "2026-08-31 INFO Auth successful for ***REDACTED***"],
|
|
270
|
+
count: 2,
|
|
271
|
+
})
|
|
272
|
+
);
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
277
|
+
res.end(JSON.stringify({ detail: "Not found" }));
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await new Promise<void>((resolve) => {
|
|
281
|
+
server.listen(0, "127.0.0.1", () => resolve());
|
|
282
|
+
});
|
|
283
|
+
const addr = server.address() as AddressInfo;
|
|
284
|
+
serverUrl = `http://127.0.0.1:${addr.port}`;
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
afterEach(async () => {
|
|
288
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should check addon health with API key", async () => {
|
|
292
|
+
const client = new AddonClient({ addonUrl: serverUrl, addonKey: "test-addon-key" });
|
|
293
|
+
const health = await client.checkHealth();
|
|
294
|
+
expect(health.status).toBe("ok");
|
|
295
|
+
expect(health.snapshots_count).toBe(3);
|
|
296
|
+
expect(lastHeaders["x-addon-api-key"]).toBe("test-addon-key");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("should fail when API key is invalid", async () => {
|
|
300
|
+
const client = new AddonClient({ addonUrl: serverUrl, addonKey: "wrong-key" });
|
|
301
|
+
await expect(client.checkHealth()).rejects.toThrow(/401|Invalid or missing/i);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("should read file content", async () => {
|
|
305
|
+
const client = new AddonClient({ addonUrl: serverUrl, addonKey: "test-addon-key" });
|
|
306
|
+
const file = await client.readFile("configuration.yaml");
|
|
307
|
+
expect(file.path).toBe("configuration.yaml");
|
|
308
|
+
expect(file.content).toBe("default_config:\n");
|
|
309
|
+
expect(lastRequestBody).toEqual({ path: "configuration.yaml" });
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("should write file content with optional yaml validation and label", async () => {
|
|
313
|
+
const client = new AddonClient({ addonUrl: serverUrl, addonKey: "test-addon-key" });
|
|
314
|
+
const result = await client.writeFile("configuration.yaml", "default_config:\nsun:\n", {
|
|
315
|
+
validateYaml: true,
|
|
316
|
+
label: "add sun component",
|
|
317
|
+
});
|
|
318
|
+
expect(result.success).toBe(true);
|
|
319
|
+
expect(result.snapshot_id).toBe("snap_20260831_123456_configuration_yaml");
|
|
320
|
+
expect(lastRequestBody).toEqual({
|
|
321
|
+
path: "configuration.yaml",
|
|
322
|
+
content: "default_config:\nsun:\n",
|
|
323
|
+
validate_yaml: true,
|
|
324
|
+
label: "add sun component",
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it("should fail write file when YAML is invalid", async () => {
|
|
329
|
+
const client = new AddonClient({ addonUrl: serverUrl, addonKey: "test-addon-key" });
|
|
330
|
+
await expect(
|
|
331
|
+
client.writeFile("configuration.yaml", "invalid: yaml: [", { validateYaml: true })
|
|
332
|
+
).rejects.toThrow(/400|Invalid YAML/i);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("should list snapshots", async () => {
|
|
336
|
+
const client = new AddonClient({ addonUrl: serverUrl, addonKey: "test-addon-key" });
|
|
337
|
+
const snapshots = await client.listSnapshots();
|
|
338
|
+
expect(snapshots).toHaveLength(1);
|
|
339
|
+
expect(snapshots[0].snapshot_id).toBe("snap_20260831_123456_configuration_yaml");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("should restore snapshot", async () => {
|
|
343
|
+
const client = new AddonClient({ addonUrl: serverUrl, addonKey: "test-addon-key" });
|
|
344
|
+
const restore = await client.restoreSnapshot("snap_20260831_123456_configuration_yaml");
|
|
345
|
+
expect(restore.success).toBe(true);
|
|
346
|
+
expect(restore.restored_file).toBe("configuration.yaml");
|
|
347
|
+
expect(lastRequestBody).toEqual({
|
|
348
|
+
snapshot_id: "snap_20260831_123456_configuration_yaml",
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("should get tail logs", async () => {
|
|
353
|
+
const client = new AddonClient({ addonUrl: serverUrl, addonKey: "test-addon-key" });
|
|
354
|
+
const logs = await client.getLogs(50);
|
|
355
|
+
expect(logs.count).toBe(2);
|
|
356
|
+
expect(logs.lines[0]).toContain("Home Assistant started");
|
|
357
|
+
});
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
describe("HAWsClient", () => {
|
|
361
|
+
let wss: WebSocketServer;
|
|
362
|
+
let wsPort: number;
|
|
363
|
+
let serverToken = "valid-ws-token";
|
|
364
|
+
|
|
365
|
+
beforeEach(async () => {
|
|
366
|
+
wss = new WebSocketServer({ port: 0 });
|
|
367
|
+
await new Promise<void>((resolve) => {
|
|
368
|
+
wss.on("listening", () => {
|
|
369
|
+
const addr = wss.address() as AddressInfo;
|
|
370
|
+
wsPort = addr.port;
|
|
371
|
+
resolve();
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
wss.on("connection", (ws: WebSocket) => {
|
|
376
|
+
// 1. Send auth_required
|
|
377
|
+
ws.send(JSON.stringify({ type: "auth_required", ha_version: "2026.8.0" }));
|
|
378
|
+
|
|
379
|
+
ws.on("message", (raw: string) => {
|
|
380
|
+
try {
|
|
381
|
+
const msg = JSON.parse(raw.toString());
|
|
382
|
+
if (msg.type === "auth") {
|
|
383
|
+
if (msg.access_token === serverToken) {
|
|
384
|
+
ws.send(JSON.stringify({ type: "auth_ok", ha_version: "2026.8.0" }));
|
|
385
|
+
} else {
|
|
386
|
+
ws.send(JSON.stringify({ type: "auth_invalid", message: "Invalid access token" }));
|
|
387
|
+
ws.close();
|
|
388
|
+
}
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
if (msg.type === "lovelace/config") {
|
|
393
|
+
ws.send(
|
|
394
|
+
JSON.stringify({
|
|
395
|
+
id: msg.id,
|
|
396
|
+
type: "result",
|
|
397
|
+
success: true,
|
|
398
|
+
result: {
|
|
399
|
+
title: "My Home",
|
|
400
|
+
views: [{ title: "Overview", path: msg.url_path || "default" }],
|
|
401
|
+
},
|
|
402
|
+
})
|
|
403
|
+
);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (msg.type === "lovelace/config/save") {
|
|
408
|
+
ws.send(
|
|
409
|
+
JSON.stringify({
|
|
410
|
+
id: msg.id,
|
|
411
|
+
type: "result",
|
|
412
|
+
success: true,
|
|
413
|
+
result: null,
|
|
414
|
+
})
|
|
415
|
+
);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (msg.type === "get_states") {
|
|
420
|
+
ws.send(
|
|
421
|
+
JSON.stringify({
|
|
422
|
+
id: msg.id,
|
|
423
|
+
type: "result",
|
|
424
|
+
success: true,
|
|
425
|
+
result: [
|
|
426
|
+
{ entity_id: "sun.sun", state: "above_horizon" },
|
|
427
|
+
{ entity_id: "light.kitchen", state: "off" },
|
|
428
|
+
],
|
|
429
|
+
})
|
|
430
|
+
);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (msg.type === "fail_command") {
|
|
435
|
+
ws.send(
|
|
436
|
+
JSON.stringify({
|
|
437
|
+
id: msg.id,
|
|
438
|
+
type: "result",
|
|
439
|
+
success: false,
|
|
440
|
+
error: { code: "not_found", message: "Entity not found" },
|
|
441
|
+
})
|
|
442
|
+
);
|
|
443
|
+
return;
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
// ignore parse errors
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
afterEach(async () => {
|
|
453
|
+
await new Promise<void>((resolve) => {
|
|
454
|
+
wss.close(() => resolve());
|
|
455
|
+
});
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
it("should connect and complete authentication handshake successfully", async () => {
|
|
459
|
+
const client = new HAWsClient({
|
|
460
|
+
haUrl: `http://127.0.0.1:${wsPort}`,
|
|
461
|
+
haToken: "valid-ws-token",
|
|
462
|
+
});
|
|
463
|
+
await client.connect();
|
|
464
|
+
expect(client.isConnected).toBe(true);
|
|
465
|
+
await client.disconnect();
|
|
466
|
+
expect(client.isConnected).toBe(false);
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
it("should fail authentication when invalid token provided", async () => {
|
|
470
|
+
const client = new HAWsClient({
|
|
471
|
+
haUrl: `http://127.0.0.1:${wsPort}`,
|
|
472
|
+
haToken: "invalid-token",
|
|
473
|
+
});
|
|
474
|
+
await expect(client.connect()).rejects.toThrow(/auth_invalid|invalid access token/i);
|
|
475
|
+
expect(client.isConnected).toBe(false);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("should fetch Lovelace configuration", async () => {
|
|
479
|
+
const client = new HAWsClient({
|
|
480
|
+
haUrl: `http://127.0.0.1:${wsPort}`,
|
|
481
|
+
haToken: "valid-ws-token",
|
|
482
|
+
});
|
|
483
|
+
await client.connect();
|
|
484
|
+
const config = await client.getLovelaceConfig("dashboard-main");
|
|
485
|
+
expect(config.title).toBe("My Home");
|
|
486
|
+
expect(config.views[0].path).toBe("dashboard-main");
|
|
487
|
+
await client.disconnect();
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("should save Lovelace configuration", async () => {
|
|
491
|
+
const client = new HAWsClient({
|
|
492
|
+
haUrl: `http://127.0.0.1:${wsPort}`,
|
|
493
|
+
haToken: "valid-ws-token",
|
|
494
|
+
});
|
|
495
|
+
await client.connect();
|
|
496
|
+
const result = await client.saveLovelaceConfig({ title: "Updated Home", views: [] });
|
|
497
|
+
expect(result).toBeNull();
|
|
498
|
+
await client.disconnect();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("should fetch states via WebSocket", async () => {
|
|
502
|
+
const client = new HAWsClient({
|
|
503
|
+
haUrl: `http://127.0.0.1:${wsPort}`,
|
|
504
|
+
haToken: "valid-ws-token",
|
|
505
|
+
});
|
|
506
|
+
await client.connect();
|
|
507
|
+
const states = await client.fetchStates();
|
|
508
|
+
expect(states).toHaveLength(2);
|
|
509
|
+
expect(states[0].entity_id).toBe("sun.sun");
|
|
510
|
+
await client.disconnect();
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
it("should handle error result from WebSocket command", async () => {
|
|
514
|
+
const client = new HAWsClient({
|
|
515
|
+
haUrl: `http://127.0.0.1:${wsPort}`,
|
|
516
|
+
haToken: "valid-ws-token",
|
|
517
|
+
});
|
|
518
|
+
await client.connect();
|
|
519
|
+
await expect(client.sendMessage({ type: "fail_command" })).rejects.toThrow(/Entity not found/i);
|
|
520
|
+
await client.disconnect();
|
|
521
|
+
});
|
|
522
|
+
});
|