@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.4.0

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 (293) hide show
  1. package/.env.example +1 -1
  2. package/README.md +24 -2
  3. package/dist/omadaClient/accessControl.d.ts +290 -0
  4. package/dist/omadaClient/accessControl.js +509 -0
  5. package/dist/omadaClient/accessControl.js.map +1 -0
  6. package/dist/omadaClient/client.d.ts +6 -1
  7. package/dist/omadaClient/client.js +12 -1
  8. package/dist/omadaClient/client.js.map +1 -1
  9. package/dist/omadaClient/diagnose.d.ts +68 -0
  10. package/dist/omadaClient/diagnose.js +164 -0
  11. package/dist/omadaClient/diagnose.js.map +1 -0
  12. package/dist/omadaClient/index.d.ts +9 -0
  13. package/dist/omadaClient/index.js +19 -0
  14. package/dist/omadaClient/index.js.map +1 -1
  15. package/dist/omadaClient/macGroups.d.ts +58 -0
  16. package/dist/omadaClient/macGroups.js +110 -0
  17. package/dist/omadaClient/macGroups.js.map +1 -0
  18. package/dist/omadaClient/serialize.d.ts +13 -0
  19. package/dist/omadaClient/serialize.js +29 -0
  20. package/dist/omadaClient/serialize.js.map +1 -0
  21. package/dist/omadaClient/timeRange.d.ts +41 -0
  22. package/dist/omadaClient/timeRange.js +109 -0
  23. package/dist/omadaClient/timeRange.js.map +1 -0
  24. package/dist/tools/infra/actions.d.ts +1 -1
  25. package/dist/tools/infra/actions.js +1 -1
  26. package/dist/tools/omada/accessControl.d.ts +151 -0
  27. package/dist/tools/omada/accessControl.js +158 -0
  28. package/dist/tools/omada/accessControl.js.map +1 -0
  29. package/dist/tools/omada/index.js +9 -0
  30. package/dist/tools/omada/index.js.map +1 -1
  31. package/dist/tools/omada/macGroups.d.ts +19 -0
  32. package/dist/tools/omada/macGroups.js +77 -0
  33. package/dist/tools/omada/macGroups.js.map +1 -0
  34. package/dist/tools/omada/namespace.js +16 -0
  35. package/dist/tools/omada/namespace.js.map +1 -1
  36. package/dist/version.d.ts +1 -1
  37. package/dist/version.js +1 -1
  38. package/package.json +5 -1
  39. package/.codex/review-rules.md +0 -16
  40. package/.dockerignore +0 -8
  41. package/.gitattributes +0 -30
  42. package/.github/workflows/ci.yml +0 -193
  43. package/AGENTS.md +0 -389
  44. package/Dockerfile +0 -50
  45. package/docker-compose.yml +0 -23
  46. package/docs/CHANGELOG.md +0 -341
  47. package/docs/COVERAGE.md +0 -219
  48. package/docs/QUICK_START.md +0 -240
  49. package/eslint.config.js +0 -33
  50. package/n8n/ai-agent-safety-monitor.json +0 -355
  51. package/openapi.json +0 -1259
  52. package/scripts/check-version-sync.mjs +0 -69
  53. package/scripts/generate-jwt.ts +0 -121
  54. package/src/config.ts +0 -440
  55. package/src/haClient/automations.ts +0 -261
  56. package/src/haClient/calendars.ts +0 -94
  57. package/src/haClient/config.ts +0 -52
  58. package/src/haClient/devices.ts +0 -653
  59. package/src/haClient/entities.ts +0 -128
  60. package/src/haClient/events.ts +0 -689
  61. package/src/haClient/history.ts +0 -73
  62. package/src/haClient/index.ts +0 -336
  63. package/src/haClient/request.ts +0 -142
  64. package/src/haClient/services.ts +0 -45
  65. package/src/haClient/states.ts +0 -60
  66. package/src/haClient/updates.ts +0 -58
  67. package/src/haClient/wsCommand.ts +0 -44
  68. package/src/index.ts +0 -164
  69. package/src/localAI/index.ts +0 -88
  70. package/src/localAI/prompts.ts +0 -50
  71. package/src/localAI/providers/base.ts +0 -120
  72. package/src/localAI/providers/ollama.ts +0 -78
  73. package/src/localAI/providers/openai.ts +0 -86
  74. package/src/localAI/types.ts +0 -67
  75. package/src/omadaClient/auth.ts +0 -178
  76. package/src/omadaClient/client.ts +0 -337
  77. package/src/omadaClient/device.ts +0 -118
  78. package/src/omadaClient/index.ts +0 -310
  79. package/src/omadaClient/network.ts +0 -284
  80. package/src/omadaClient/request.ts +0 -365
  81. package/src/omadaClient/security.ts +0 -51
  82. package/src/omadaClient/site.ts +0 -344
  83. package/src/ownerQuestions/service.ts +0 -536
  84. package/src/permissions/index.ts +0 -143
  85. package/src/resources/index.ts +0 -213
  86. package/src/server/auth.ts +0 -175
  87. package/src/server/chatFace.ts +0 -789
  88. package/src/server/chatSlice.ts +0 -57
  89. package/src/server/common.ts +0 -170
  90. package/src/server/eventSubscription.ts +0 -343
  91. package/src/server/http.ts +0 -626
  92. package/src/server/instructions.ts +0 -97
  93. package/src/server/rateLimiter.ts +0 -227
  94. package/src/server/stdio.ts +0 -41
  95. package/src/server/stream.ts +0 -205
  96. package/src/tools/ai/analyzeSensors.ts +0 -33
  97. package/src/tools/ai/index.ts +0 -20
  98. package/src/tools/common.ts +0 -442
  99. package/src/tools/homeassistant/askOwner.ts +0 -133
  100. package/src/tools/homeassistant/automationControls.ts +0 -66
  101. package/src/tools/homeassistant/calendar.ts +0 -97
  102. package/src/tools/homeassistant/callService.ts +0 -25
  103. package/src/tools/homeassistant/controlClimate.ts +0 -68
  104. package/src/tools/homeassistant/controlCover.ts +0 -48
  105. package/src/tools/homeassistant/controlFan.ts +0 -44
  106. package/src/tools/homeassistant/controlLight.ts +0 -38
  107. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  108. package/src/tools/homeassistant/createAutomation.ts +0 -35
  109. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  110. package/src/tools/homeassistant/entityAction.ts +0 -35
  111. package/src/tools/homeassistant/entityGuard.ts +0 -28
  112. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  113. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  114. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  115. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  116. package/src/tools/homeassistant/getHistory.ts +0 -24
  117. package/src/tools/homeassistant/getState.ts +0 -24
  118. package/src/tools/homeassistant/getStates.ts +0 -46
  119. package/src/tools/homeassistant/getVersion.ts +0 -21
  120. package/src/tools/homeassistant/index.ts +0 -121
  121. package/src/tools/homeassistant/listAutomations.ts +0 -35
  122. package/src/tools/homeassistant/listEntities.ts +0 -44
  123. package/src/tools/homeassistant/listPersons.ts +0 -46
  124. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  125. package/src/tools/homeassistant/searchEntities.ts +0 -58
  126. package/src/tools/homeassistant/sendNotification.ts +0 -179
  127. package/src/tools/homeassistant/systemTools.ts +0 -52
  128. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  129. package/src/tools/index.ts +0 -78
  130. package/src/tools/infra/actions.ts +0 -95
  131. package/src/tools/infra/index.ts +0 -138
  132. package/src/tools/omada/blockClient.ts +0 -28
  133. package/src/tools/omada/cyclePoePort.ts +0 -32
  134. package/src/tools/omada/deleteClient.ts +0 -27
  135. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  136. package/src/tools/omada/getClient.ts +0 -14
  137. package/src/tools/omada/getDevice.ts +0 -14
  138. package/src/tools/omada/getFirewallSetting.ts +0 -14
  139. package/src/tools/omada/getInternetInfo.ts +0 -14
  140. package/src/tools/omada/getLanNetworkList.ts +0 -14
  141. package/src/tools/omada/getLanProfileList.ts +0 -14
  142. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  143. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  144. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  145. package/src/tools/omada/getSsidDetail.ts +0 -22
  146. package/src/tools/omada/getSsidList.ts +0 -21
  147. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  148. package/src/tools/omada/getThreatList.ts +0 -42
  149. package/src/tools/omada/getWlanGroupList.ts +0 -14
  150. package/src/tools/omada/graph.ts +0 -187
  151. package/src/tools/omada/index.ts +0 -153
  152. package/src/tools/omada/listBlockedClients.ts +0 -17
  153. package/src/tools/omada/listClients.ts +0 -14
  154. package/src/tools/omada/listClientsActivity.ts +0 -25
  155. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  156. package/src/tools/omada/listDevices.ts +0 -14
  157. package/src/tools/omada/listDevicesStats.ts +0 -41
  158. package/src/tools/omada/listMostActiveClients.ts +0 -14
  159. package/src/tools/omada/listSites.ts +0 -18
  160. package/src/tools/omada/namespace.ts +0 -1164
  161. package/src/tools/omada/searchDevices.ts +0 -20
  162. package/src/tools/omada/setClientRateLimit.ts +0 -26
  163. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  164. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  165. package/src/tools/omada/setSsidEnabled.ts +0 -38
  166. package/src/tools/omada/unblockClient.ts +0 -26
  167. package/src/tools/types.ts +0 -11
  168. package/src/tools/utils/pagination-schema.ts +0 -27
  169. package/src/types/ai/index.ts +0 -11
  170. package/src/types/ai/types.ts +0 -67
  171. package/src/types/homeassistant/automations.ts +0 -43
  172. package/src/types/homeassistant/calendars.ts +0 -26
  173. package/src/types/homeassistant/config.ts +0 -14
  174. package/src/types/homeassistant/entities.ts +0 -14
  175. package/src/types/homeassistant/errors.ts +0 -33
  176. package/src/types/homeassistant/haConfig.ts +0 -7
  177. package/src/types/homeassistant/history.ts +0 -13
  178. package/src/types/homeassistant/index.ts +0 -46
  179. package/src/types/homeassistant/request.ts +0 -6
  180. package/src/types/homeassistant/serviceCall.ts +0 -18
  181. package/src/types/homeassistant/states.ts +0 -58
  182. package/src/types/homeassistant/updates.ts +0 -28
  183. package/src/types/index.ts +0 -12
  184. package/src/types/omada/activeClientInfo.ts +0 -17
  185. package/src/types/omada/clientActivity.ts +0 -32
  186. package/src/types/omada/clientBlockStatus.ts +0 -11
  187. package/src/types/omada/clientPastConnection.ts +0 -82
  188. package/src/types/omada/index.ts +0 -14
  189. package/src/types/omada/omadaApiResponse.ts +0 -5
  190. package/src/types/omada/omadaClientInfo.ts +0 -11
  191. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  192. package/src/types/omada/omadaDeviceStats.ts +0 -31
  193. package/src/types/omada/omadaSiteSummary.ts +0 -5
  194. package/src/types/omada/oswStackDetail.ts +0 -11
  195. package/src/types/omada/paginatedResult.ts +0 -6
  196. package/src/types/omada/rateLimitProfile.ts +0 -58
  197. package/src/types/omada/threatInfo.ts +0 -38
  198. package/src/types/omada/tokenResult.ts +0 -6
  199. package/src/types.ts +0 -2
  200. package/src/utils/config-validations.ts +0 -68
  201. package/src/utils/jwt.ts +0 -112
  202. package/src/utils/logger.ts +0 -121
  203. package/src/utils/plainText.ts +0 -48
  204. package/src/utils/redact.ts +0 -59
  205. package/src/utils/sanitizeError.ts +0 -18
  206. package/src/utils/tlsDispatcher.ts +0 -69
  207. package/src/version.ts +0 -5
  208. package/tests/bdd/automation-creation.spec.ts +0 -214
  209. package/tests/bdd/automation-management.spec.ts +0 -267
  210. package/tests/bdd/calendar.spec.ts +0 -190
  211. package/tests/bdd/configuration.spec.ts +0 -283
  212. package/tests/bdd/device-control.spec.ts +0 -344
  213. package/tests/bdd/entity-listing.spec.ts +0 -208
  214. package/tests/bdd/entity-search.spec.ts +0 -183
  215. package/tests/bdd/history-retrieval.spec.ts +0 -195
  216. package/tests/bdd/notifications.spec.ts +0 -238
  217. package/tests/bdd/service-calls.spec.ts +0 -144
  218. package/tests/bdd/state-retrieval.spec.ts +0 -204
  219. package/tests/bench/hotpaths.bench.ts +0 -68
  220. package/tests/config.test.ts +0 -355
  221. package/tests/features/automation-creation.feature +0 -36
  222. package/tests/features/automation-management.feature +0 -54
  223. package/tests/features/calendar.feature +0 -37
  224. package/tests/features/configuration.feature +0 -70
  225. package/tests/features/device-control.feature +0 -100
  226. package/tests/features/entity-listing.feature +0 -51
  227. package/tests/features/entity-search.feature +0 -44
  228. package/tests/features/history-retrieval.feature +0 -38
  229. package/tests/features/notifications.feature +0 -37
  230. package/tests/features/service-calls.feature +0 -35
  231. package/tests/features/state-retrieval.feature +0 -37
  232. package/tests/fixtures/openapi-default-slice.json +0 -290
  233. package/tests/haClient/automationTrace.test.ts +0 -59
  234. package/tests/haClient/automations.test.ts +0 -149
  235. package/tests/haClient/config.test.ts +0 -117
  236. package/tests/haClient/entities.test.ts +0 -188
  237. package/tests/haClient/events.test.ts +0 -455
  238. package/tests/haClient/eventsTls.test.ts +0 -52
  239. package/tests/haClient/history.test.ts +0 -228
  240. package/tests/haClient/index.test.ts +0 -243
  241. package/tests/haClient/request.test.ts +0 -217
  242. package/tests/haClient/services.test.ts +0 -120
  243. package/tests/haClient/states.test.ts +0 -119
  244. package/tests/haClient/updates.test.ts +0 -193
  245. package/tests/index.test.ts +0 -160
  246. package/tests/integration/homeassistant.test.ts +0 -144
  247. package/tests/integration/omada.test.ts +0 -167
  248. package/tests/integration/server.test.ts +0 -151
  249. package/tests/localAI/index.test.ts +0 -177
  250. package/tests/localAI/ollama.test.ts +0 -187
  251. package/tests/localAI/openai.test.ts +0 -257
  252. package/tests/omadaClient/actions.test.ts +0 -161
  253. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  254. package/tests/omadaClient/ntp.test.ts +0 -381
  255. package/tests/omadaClient/site.test.ts +0 -37
  256. package/tests/ownerQuestions/service.test.ts +0 -499
  257. package/tests/ownerQuestions/tools.test.ts +0 -186
  258. package/tests/permissions/index.test.ts +0 -217
  259. package/tests/resources/index.test.ts +0 -288
  260. package/tests/scripts/generateJwt.test.ts +0 -44
  261. package/tests/security/accessControl.test.ts +0 -223
  262. package/tests/security/authentication.test.ts +0 -117
  263. package/tests/security/configuration.test.ts +0 -170
  264. package/tests/security/cryptoTransport.test.ts +0 -172
  265. package/tests/security/dependencies.test.ts +0 -152
  266. package/tests/security/injection.test.ts +0 -117
  267. package/tests/security/insecureDesign.test.ts +0 -121
  268. package/tests/security/logging.test.ts +0 -131
  269. package/tests/security/resourceConsumption.test.ts +0 -142
  270. package/tests/server/auth.test.ts +0 -249
  271. package/tests/server/chatFace.test.ts +0 -320
  272. package/tests/server/common.test.ts +0 -84
  273. package/tests/server/http.test.ts +0 -561
  274. package/tests/server/instructions.test.ts +0 -152
  275. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  276. package/tests/server/stdio.test.ts +0 -99
  277. package/tests/server/stream.test.ts +0 -285
  278. package/tests/server/toolVisibility.http.test.ts +0 -111
  279. package/tests/server/toolVisibility.test.ts +0 -129
  280. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  281. package/tests/tools/common.test.ts +0 -123
  282. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  283. package/tests/tools/infra/invokeAction.test.ts +0 -280
  284. package/tests/tools/omada/graph.test.ts +0 -385
  285. package/tests/tools/omada/handlers.test.ts +0 -897
  286. package/tests/tools/omada/namespace.test.ts +0 -483
  287. package/tests/tools/registry.test.ts +0 -168
  288. package/tests/utils/config-validations.test.ts +0 -352
  289. package/tests/utils/jwt.test.ts +0 -115
  290. package/tests/utils/logger.test.ts +0 -327
  291. package/tests/utils/redact.test.ts +0 -105
  292. package/tsconfig.json +0 -24
  293. package/vitest.config.ts +0 -38
@@ -1,455 +0,0 @@
1
- /**
2
- * EventSubscriber against a fake Home Assistant WebSocket server: commands are
3
- * matched to results by id, each event copy reaches only its own subscription,
4
- * and subscriptions are restored after a reconnect.
5
- */
6
-
7
- import { describe, it, expect, afterEach, vi } from 'vitest';
8
- import { WebSocketServer, type WebSocket } from 'ws';
9
- import type { AddressInfo } from 'node:net';
10
-
11
- import { EventSubscriber, HaCommandError, type HaEvent } from '../../src/haClient/events.js';
12
- import { logger } from '../../src/utils/logger.js';
13
- import { createHaWebSocketCommand } from '../../src/haClient/wsCommand.js';
14
-
15
- interface FakeHa {
16
- url: string;
17
- received: Array<Record<string, unknown>>;
18
- sockets: WebSocket[];
19
- /** Reply to a command; default: success with an empty result. */
20
- onCommand: (message: Record<string, unknown>, socket: WebSocket) => void;
21
- close: () => Promise<void>;
22
- }
23
-
24
- async function fakeHa(): Promise<FakeHa> {
25
- const wss = new WebSocketServer({ port: 0 });
26
- await new Promise<void>((resolve) => wss.once('listening', () => resolve()));
27
- const fake: FakeHa = {
28
- url: `http://127.0.0.1:${(wss.address() as AddressInfo).port}`,
29
- received: [],
30
- sockets: [],
31
- onCommand: (message, socket) => socket.send(JSON.stringify({ id: message.id, type: 'result', success: true, result: null })),
32
- close: () =>
33
- new Promise((resolve) => {
34
- for (const socket of fake.sockets) socket.terminate();
35
- wss.close(() => resolve());
36
- }),
37
- };
38
- wss.on('connection', (socket) => {
39
- fake.sockets.push(socket);
40
- socket.send(JSON.stringify({ type: 'auth_required' }));
41
- socket.on('message', (raw) => {
42
- const message = JSON.parse(raw.toString()) as Record<string, unknown>;
43
- fake.received.push(message);
44
- if (message.type === 'auth') {
45
- socket.send(JSON.stringify({ type: 'auth_ok' }));
46
- } else {
47
- fake.onCommand(message, socket);
48
- }
49
- });
50
- });
51
- return fake;
52
- }
53
-
54
- const sendEvent = (socket: WebSocket, subscriptionId: number, eventType: string, data: Record<string, unknown> = {}) =>
55
- socket.send(
56
- JSON.stringify({
57
- id: subscriptionId,
58
- type: 'event',
59
- event: { event_type: eventType, data, origin: 'REMOTE', time_fired: '', context: { id: 'c', parent_id: null, user_id: 'u' } },
60
- })
61
- );
62
-
63
- const until = async (condition: () => boolean, ms = 3000) => {
64
- const deadline = Date.now() + ms;
65
- while (!condition()) {
66
- if (Date.now() > deadline) throw new Error('condition not met in time');
67
- await new Promise((r) => setTimeout(r, 10));
68
- }
69
- };
70
-
71
- describe('EventSubscriber', () => {
72
- let fake: FakeHa;
73
- let subscriber: EventSubscriber;
74
-
75
- afterEach(async () => {
76
- subscriber?.disconnect();
77
- await fake?.close();
78
- });
79
-
80
- it('matches results to commands by id, even when HA answers out of order', async () => {
81
- fake = await fakeHa();
82
- const pending: Array<Record<string, unknown>> = [];
83
- fake.onCommand = (message, socket) => {
84
- pending.push(message);
85
- if (pending.length === 2) {
86
- for (const m of [...pending].reverse()) {
87
- socket.send(JSON.stringify({ id: m.id, type: 'result', success: true, result: { echo: m.type } }));
88
- }
89
- }
90
- };
91
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't' });
92
- await subscriber.connect();
93
-
94
- const [a, b] = await Promise.all([subscriber.sendCommand({ type: 'first' }), subscriber.sendCommand({ type: 'second' })]);
95
-
96
- expect(a).toEqual({ echo: 'first' });
97
- expect(b).toEqual({ echo: 'second' });
98
- });
99
-
100
- it('rejects a failed command with HA\'s error code only, never its free-text message', async () => {
101
- fake = await fakeHa();
102
- fake.onCommand = (message, socket) =>
103
- socket.send(
104
- JSON.stringify({
105
- id: message.id,
106
- type: 'result',
107
- success: false,
108
- error: { code: 'unauthorized', message: 'Denied for https://ha.internal:8123/api?token=SECRET123' },
109
- })
110
- );
111
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't' });
112
-
113
- const error = await subscriber.sendCommand({ type: 'trace/list' }).catch((e: Error) => e);
114
- expect((error as Error).message).toBe("Home Assistant rejected 'trace/list' (unauthorized)");
115
- expect((error as Error).message).not.toContain('SECRET123');
116
- });
117
-
118
- it('never logs Home Assistant\'s free-text error message, at any level', async () => {
119
- fake = await fakeHa();
120
- fake.onCommand = (message, socket) =>
121
- socket.send(JSON.stringify({ id: message.id, type: 'result', success: false, error: { code: 'unauthorized', message: 'see https://ha/api?token=SECRET123' } }));
122
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't' });
123
- const spies = (['debug', 'info', 'warn', 'error'] as const).map((level) => vi.spyOn(logger, level));
124
- try {
125
- await subscriber.sendCommand({ type: 'trace/list' }).catch(() => undefined);
126
- for (const spy of spies) {
127
- expect(JSON.stringify(spy.mock.calls)).not.toContain('SECRET123');
128
- }
129
- } finally {
130
- for (const spy of spies) spy.mockRestore();
131
- }
132
- });
133
-
134
- it('a subscription Home Assistant refuses is reported to the caller and dropped', async () => {
135
- fake = await fakeHa();
136
- fake.onCommand = (message, socket) =>
137
- socket.send(JSON.stringify({ id: message.id, type: 'result', success: false, error: { code: 'unauthorized', message: 'Unauthorized' } }));
138
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't' });
139
- await subscriber.connect();
140
-
141
- const error = await subscriber.subscribeEventType('mobile_app_notification_action', () => undefined).catch((e: Error) => e);
142
-
143
- expect(error).toBeInstanceOf(HaCommandError);
144
- expect((error as HaCommandError).code).toBe('unauthorized');
145
- expect(subscriber.getSubscriptionCount()).toBe(0);
146
- });
147
-
148
- it('a refusal seen after a reconnect goes to onRejected and drops the subscription', async () => {
149
- fake = await fakeHa();
150
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't', reconnectInterval: 20 });
151
- const rejected: HaCommandError[] = [];
152
- await subscriber.subscribeEventType('mobile_app_notification_action', () => undefined, (e) => rejected.push(e));
153
-
154
- // After the reconnect the token is no longer an admin.
155
- fake.onCommand = (message, socket) =>
156
- socket.send(JSON.stringify({ id: message.id, type: 'result', success: false, error: { code: 'unauthorized', message: 'no' } }));
157
- fake.sockets[0].terminate();
158
-
159
- await until(() => rejected.length === 1);
160
- expect(rejected[0].code).toBe('unauthorized');
161
- expect(subscriber.getSubscriptionCount()).toBe(0);
162
- });
163
-
164
- it('a refusal after an offline start reaches onRejected', async () => {
165
- const probe = await fakeHa();
166
- const url = probe.url;
167
- const port = Number(new URL(url).port);
168
- await probe.close();
169
-
170
- subscriber = new EventSubscriber({ baseUrl: url, token: 't', reconnectInterval: 20, maxReconnectDelay: 50 });
171
- const rejected: HaCommandError[] = [];
172
- // HA is down: queued, no error.
173
- await subscriber.subscribeEventType('mobile_app_notification_action', () => undefined, (e) => rejected.push(e));
174
-
175
- // HA comes up with a non-admin token: the subscription is refused.
176
- const wss = new WebSocketServer({ port });
177
- fake = { url, received: [], sockets: [], onCommand: () => undefined, close: () => new Promise((r) => { for (const c of wss.clients) c.terminate(); wss.close(() => r()); }) };
178
- wss.on('connection', (socket) => {
179
- fake.sockets.push(socket);
180
- socket.send(JSON.stringify({ type: 'auth_required' }));
181
- socket.on('message', (raw) => {
182
- const m = JSON.parse(raw.toString()) as Record<string, unknown>;
183
- fake.received.push(m);
184
- socket.send(JSON.stringify(m.type === 'auth' ? { type: 'auth_ok' } : { id: m.id, type: 'result', success: false, error: { code: 'unauthorized', message: 'no' } }));
185
- });
186
- });
187
-
188
- await until(() => rejected.length === 1);
189
- expect(rejected[0].code).toBe('unauthorized');
190
- expect(subscriber.getSubscriptionCount()).toBe(0);
191
- });
192
-
193
- it('delivers an event HA sends right after the confirmation, in the same read', async () => {
194
- fake = await fakeHa();
195
- fake.onCommand = (message, socket) => {
196
- socket.send(JSON.stringify({ id: message.id, type: 'result', success: true, result: null }));
197
- if (message.type === 'subscribe_events') {
198
- // Same tick, back to back: the client reads both before any promise continuation.
199
- sendEvent(socket, message.id as number, 'mobile_app_notification_action', { action: 'immediate' });
200
- }
201
- };
202
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't' });
203
- const seen: HaEvent[] = [];
204
-
205
- await subscriber.subscribeEventType('mobile_app_notification_action', (e) => seen.push(e));
206
-
207
- await until(() => seen.length === 1);
208
- expect(seen[0].data.action).toBe('immediate');
209
- });
210
-
211
- it('a confirmation that arrives after the timeout does not leave a duplicate subscription on HA', async () => {
212
- fake = await fakeHa();
213
- const active = new Set<number>();
214
- let subscribeCalls = 0;
215
- fake.onCommand = (message, socket) => {
216
- const reply = () => socket.send(JSON.stringify({ id: message.id, type: 'result', success: true, result: null }));
217
- if (message.type === 'subscribe_events') {
218
- active.add(message.id as number); // HA registers it at once...
219
- if (++subscribeCalls === 1) {
220
- setTimeout(reply, 120); // ...but the first confirmation is late
221
- return;
222
- }
223
- } else if (message.type === 'unsubscribe_events') {
224
- active.delete(message.subscription as number);
225
- }
226
- reply();
227
- };
228
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't', commandTimeout: 40, resubscribeRetryDelay: 20 });
229
-
230
- await subscriber.subscribeEventType('mobile_app_notification_action', () => undefined);
231
- await until(() => subscribeCalls === 2);
232
- await new Promise((r) => setTimeout(r, 200)); // the late confirmation has arrived by now
233
-
234
- expect(active.size).toBe(1);
235
- const ids = fake.received.filter((m) => m.type === 'subscribe_events').map((m) => m.id);
236
- expect(fake.received.filter((m) => m.type === 'unsubscribe_events').map((m) => m.subscription)).toEqual([ids[0]]);
237
- });
238
-
239
- it('retries a subscription whose confirmation never arrived', async () => {
240
- fake = await fakeHa();
241
- let subscribeCalls = 0;
242
- fake.onCommand = (message, socket) => {
243
- if (message.type === 'subscribe_events' && ++subscribeCalls === 1) return; // lost
244
- socket.send(JSON.stringify({ id: message.id, type: 'result', success: true, result: null }));
245
- };
246
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't', commandTimeout: 50, resubscribeRetryDelay: 50 });
247
- const seen: HaEvent[] = [];
248
- await subscriber.subscribeEventType('mobile_app_notification_action', (e) => seen.push(e));
249
-
250
- await until(() => subscribeCalls === 2);
251
- const confirmed = fake.received.filter((m) => m.type === 'subscribe_events')[1];
252
- await new Promise((r) => setTimeout(r, 20));
253
- sendEvent(fake.sockets[0], confirmed.id as number, 'mobile_app_notification_action', { action: 'ok' });
254
- await until(() => seen.length === 1);
255
- });
256
-
257
- it('replaces an unexpected error code with unknown_error', async () => {
258
- fake = await fakeHa();
259
- fake.onCommand = (message, socket) =>
260
- socket.send(JSON.stringify({ id: message.id, type: 'result', success: false, error: { code: 'x?token=SECRET', message: 'boom' } }));
261
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't' });
262
-
263
- await expect(subscriber.sendCommand({ type: 'trace/list' })).rejects.toThrow("Home Assistant rejected 'trace/list' (unknown_error)");
264
- });
265
-
266
- it('reports a connection failure without the internal address', async () => {
267
- fake = await fakeHa();
268
- const url = fake.url;
269
- await fake.close();
270
- subscriber = new EventSubscriber({ baseUrl: url, token: 't', maxReconnectAttempts: 0 });
271
-
272
- const error = await subscriber.connect().catch((e: Error) => e);
273
- expect((error as Error).message).toBe('Could not connect to the Home Assistant WebSocket');
274
- expect((error as Error).message).not.toContain('127.0.0.1');
275
- });
276
-
277
- it('unsubscribing while HA is offline forgets the subscription for good', async () => {
278
- fake = await fakeHa();
279
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't', reconnectInterval: 20 });
280
- await subscriber.subscribeEventType('kept_event', () => undefined);
281
- const gone = await subscriber.subscribeEventType('gone_event', () => undefined);
282
-
283
- fake.sockets[0].terminate(); // HA goes away
284
- await until(() => !subscriber.isConnected());
285
- await expect(subscriber.unsubscribe(gone)).resolves.toBeUndefined();
286
- expect(subscriber.getSubscriptionCount()).toBe(1);
287
-
288
- // On reconnect only the remaining subscription is restored.
289
- await until(() => fake.sockets.length === 2 && fake.received.filter((m) => m.type === 'subscribe_events').length === 3);
290
- await new Promise((r) => setTimeout(r, 50));
291
- const restored = fake.received.filter((m) => m.type === 'subscribe_events').slice(2).map((m) => m.event_type);
292
- expect(restored).toEqual(['kept_event']);
293
- });
294
-
295
- it('a subscription cancelled before HA confirms it is cancelled on HA\'s side too', async () => {
296
- fake = await fakeHa();
297
- let confirm!: () => void;
298
- fake.onCommand = (message, socket) => {
299
- const reply = () => socket.send(JSON.stringify({ id: message.id, type: 'result', success: true, result: null }));
300
- if (message.type === 'subscribe_events') confirm = reply;
301
- else reply();
302
- };
303
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't' });
304
- await subscriber.connect();
305
-
306
- const subscribing = subscriber.subscribeEventType('slow_event', () => undefined);
307
- await until(() => fake.received.some((m) => m.type === 'subscribe_events'));
308
- const id = [...(subscriber as unknown as { subscriptions: Map<string, unknown> }).subscriptions.keys()][0];
309
- await subscriber.unsubscribe(id); // before HA confirmed
310
- confirm();
311
- await subscribing;
312
-
313
- const sent = fake.received.find((m) => m.type === 'subscribe_events')!;
314
- await until(() => fake.received.some((m) => m.type === 'unsubscribe_events'));
315
- expect(fake.received.find((m) => m.type === 'unsubscribe_events')).toMatchObject({ subscription: sent.id });
316
- expect(subscriber.getSubscriptionCount()).toBe(0);
317
- });
318
-
319
- it('delivers each event once per subscription when two subscribe to the same type', async () => {
320
- fake = await fakeHa();
321
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't' });
322
- const first: HaEvent[] = [];
323
- const second: HaEvent[] = [];
324
- await subscriber.subscribeEventType('mobile_app_notification_action', (e) => first.push(e));
325
- await subscriber.subscribeEventType('mobile_app_notification_action', (e) => second.push(e));
326
-
327
- // HA sends one copy per subscription, each tagged with that subscription's id.
328
- const ids = fake.received.filter((m) => m.type === 'subscribe_events').map((m) => m.id as number);
329
- for (const id of ids) sendEvent(fake.sockets[0], id, 'mobile_app_notification_action', { action: 'X' });
330
-
331
- await until(() => first.length + second.length >= 2);
332
- await new Promise((r) => setTimeout(r, 50));
333
- expect(first).toHaveLength(1);
334
- expect(second).toHaveLength(1);
335
- });
336
-
337
- it('restores its subscriptions after the connection drops', async () => {
338
- fake = await fakeHa();
339
- subscriber = new EventSubscriber({ baseUrl: fake.url, token: 't', reconnectInterval: 20 });
340
- const seen: HaEvent[] = [];
341
- await subscriber.subscribeEventType('mobile_app_notification_action', (e) => seen.push(e));
342
-
343
- fake.sockets[0].terminate(); // HA restarts
344
- await until(() => fake.received.filter((m) => m.type === 'subscribe_events').length === 2);
345
-
346
- const resubscribe = fake.received.filter((m) => m.type === 'subscribe_events')[1];
347
- expect(resubscribe.event_type).toBe('mobile_app_notification_action');
348
- sendEvent(fake.sockets[1], resubscribe.id as number, 'mobile_app_notification_action', { action: 'after-restart' });
349
- await until(() => seen.length === 1);
350
- expect(seen[0].data.action).toBe('after-restart');
351
- });
352
-
353
- it('a subscription made while HA is down goes live when HA comes up', async () => {
354
- // Reserve a port, then free it: HA is "down" on that port at first.
355
- const probe = await fakeHa();
356
- const url = probe.url;
357
- const port = Number(new URL(url).port);
358
- await probe.close();
359
-
360
- subscriber = new EventSubscriber({ baseUrl: url, token: 't', reconnectInterval: 20, maxReconnectDelay: 50 });
361
- await expect(subscriber.connect()).rejects.toThrow();
362
- const seen: HaEvent[] = [];
363
- await subscriber.subscribeEventType('mobile_app_notification_action', (e) => seen.push(e));
364
-
365
- // Home Assistant starts on the same port.
366
- const wss = new WebSocketServer({ port });
367
- fake = { url, received: [], sockets: [], onCommand: () => undefined, close: () => new Promise((r) => { for (const c of wss.clients) c.terminate(); wss.close(() => r()); }) };
368
- wss.on('connection', (socket) => {
369
- fake.sockets.push(socket);
370
- socket.send(JSON.stringify({ type: 'auth_required' }));
371
- socket.on('message', (raw) => {
372
- const m = JSON.parse(raw.toString()) as Record<string, unknown>;
373
- fake.received.push(m);
374
- socket.send(JSON.stringify(m.type === 'auth' ? { type: 'auth_ok' } : { id: m.id, type: 'result', success: true, result: null }));
375
- });
376
- });
377
-
378
- await until(() => fake.received.some((m) => m.type === 'subscribe_events'));
379
- const sub = fake.received.find((m) => m.type === 'subscribe_events')!;
380
- expect(sub.event_type).toBe('mobile_app_notification_action');
381
- sendEvent(fake.sockets[0], sub.id as number, 'mobile_app_notification_action', { action: 'late' });
382
- await until(() => seen.length === 1);
383
- // Sent once, not again on top of an existing subscription.
384
- expect(fake.received.filter((m) => m.type === 'subscribe_events')).toHaveLength(1);
385
- });
386
-
387
- it('keeps retrying with a capped delay instead of giving up', async () => {
388
- fake = await fakeHa();
389
- const url = fake.url;
390
- await fake.close();
391
- subscriber = new EventSubscriber({ baseUrl: url, token: 't', reconnectInterval: 5, maxReconnectDelay: 20 });
392
-
393
- await expect(subscriber.connect()).rejects.toThrow();
394
- // Many more attempts than the old hard limit of 10, still trying.
395
- await new Promise((r) => setTimeout(r, 400));
396
- expect((subscriber as unknown as { reconnectAttempts: number }).reconnectAttempts).toBeGreaterThan(10);
397
- });
398
- });
399
-
400
- describe('createHaWebSocketCommand', () => {
401
- let fake: FakeHa;
402
- afterEach(async () => fake?.close());
403
-
404
- it('fails within its deadline when Home Assistant never completes authentication', async () => {
405
- fake = await fakeHa();
406
- fake.sockets = [];
407
- const silent = new WebSocketServer({ port: 0 });
408
- await new Promise<void>((resolve) => silent.once('listening', () => resolve()));
409
- try {
410
- const command = createHaWebSocketCommand({
411
- baseUrl: `http://127.0.0.1:${(silent.address() as AddressInfo).port}`,
412
- token: 't',
413
- timeoutMs: 200,
414
- });
415
- const started = Date.now();
416
- await expect(command({ type: 'trace/list' })).rejects.toThrow(/timed out/i);
417
- expect(Date.now() - started).toBeLessThan(2000);
418
- } finally {
419
- for (const client of silent.clients) client.terminate();
420
- await new Promise<void>((resolve) => silent.close(() => resolve()));
421
- }
422
- });
423
-
424
- it('fails at once when Home Assistant closes the connection before authentication', async () => {
425
- fake = await fakeHa();
426
- const closing = new WebSocketServer({ port: 0 });
427
- await new Promise<void>((resolve) => closing.once('listening', () => resolve()));
428
- closing.on('connection', (socket) => socket.close());
429
- try {
430
- const command = createHaWebSocketCommand({
431
- baseUrl: `http://127.0.0.1:${(closing.address() as AddressInfo).port}`,
432
- token: 't',
433
- timeoutMs: 5000,
434
- });
435
- const started = Date.now();
436
- await expect(command({ type: 'trace/list' })).rejects.toThrow(/before authentication/);
437
- expect(Date.now() - started).toBeLessThan(2000);
438
- } finally {
439
- await new Promise<void>((resolve) => closing.close(() => resolve()));
440
- }
441
- });
442
-
443
- it('connects, sends one command, returns its result and disconnects', async () => {
444
- fake = await fakeHa();
445
- fake.onCommand = (message, socket) =>
446
- socket.send(JSON.stringify({ id: message.id, type: 'result', success: true, result: [{ run_id: 'r1' }] }));
447
-
448
- const command = createHaWebSocketCommand({ baseUrl: fake.url, token: 't' });
449
- const result = await command({ type: 'trace/list', domain: 'automation', item_id: '1700' });
450
-
451
- expect(result).toEqual([{ run_id: 'r1' }]);
452
- expect(fake.received.find((m) => m.type === 'trace/list')).toMatchObject({ domain: 'automation', item_id: '1700' });
453
- await until(() => fake.sockets[0].readyState === fake.sockets[0].CLOSED);
454
- });
455
- });
@@ -1,52 +0,0 @@
1
- /**
2
- * The Home Assistant WebSocket connections follow HA_STRICT_SSL like the REST
3
- * client: certificate verification is skipped only when strictSsl is false.
4
- */
5
-
6
- import { describe, it, expect, vi, beforeEach } from 'vitest';
7
-
8
- const constructed: Array<{ url: string; options: Record<string, unknown> | undefined }> = [];
9
-
10
- vi.mock('ws', async (importOriginal) => {
11
- const actual = await importOriginal<typeof import('ws')>();
12
- class RecordingWebSocket extends actual.WebSocket {
13
- constructor(url: string, options?: Record<string, unknown>) {
14
- constructed.push({ url, options });
15
- super(url, options as never);
16
- this.on('error', () => undefined);
17
- }
18
- }
19
- return { ...actual, default: RecordingWebSocket, WebSocket: RecordingWebSocket };
20
- });
21
-
22
- const { EventSubscriber } = await import('../../src/haClient/events.js');
23
- const { createHaWebSocketCommand } = await import('../../src/haClient/wsCommand.js');
24
-
25
- // Port 9 (discard) on localhost: the connection fails fast; only the options matter.
26
- const HTTPS_HA = 'https://127.0.0.1:9';
27
-
28
- describe('WebSocket TLS policy', () => {
29
- beforeEach(() => {
30
- constructed.length = 0;
31
- });
32
-
33
- it.each([
34
- [undefined, true],
35
- [true, true],
36
- [false, false],
37
- ])('strictSsl=%s -> rejectUnauthorized=%s (event subscriber)', async (strictSsl, expected) => {
38
- const subscriber = new EventSubscriber({ baseUrl: HTTPS_HA, token: 't', strictSsl, maxReconnectAttempts: 0, connectTimeout: 500 });
39
- await subscriber.connect().catch(() => undefined);
40
- subscriber.disconnect();
41
-
42
- expect(constructed[0].url).toBe('wss://127.0.0.1:9/api/websocket');
43
- expect(constructed[0].options).toMatchObject({ rejectUnauthorized: expected });
44
- });
45
-
46
- it('the one-shot command used for traces passes the policy through', async () => {
47
- const command = createHaWebSocketCommand({ baseUrl: HTTPS_HA, token: 't', strictSsl: false, timeoutMs: 500 });
48
- await command({ type: 'trace/list' }).catch(() => undefined);
49
-
50
- expect(constructed[0].options).toMatchObject({ rejectUnauthorized: false });
51
- });
52
- });