@coffeerunhobby/mcp-ha-connect 2.2.1 → 2.3.1

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 (281) hide show
  1. package/.env.example +1 -1
  2. package/README.md +11 -2
  3. package/dist/omadaClient/client.d.ts +6 -1
  4. package/dist/omadaClient/client.js +12 -1
  5. package/dist/omadaClient/client.js.map +1 -1
  6. package/dist/omadaClient/diagnose.d.ts +68 -0
  7. package/dist/omadaClient/diagnose.js +164 -0
  8. package/dist/omadaClient/diagnose.js.map +1 -0
  9. package/dist/omadaClient/index.d.ts +6 -0
  10. package/dist/omadaClient/index.js +15 -0
  11. package/dist/omadaClient/index.js.map +1 -1
  12. package/dist/omadaClient/macGroups.d.ts +58 -0
  13. package/dist/omadaClient/macGroups.js +124 -0
  14. package/dist/omadaClient/macGroups.js.map +1 -0
  15. package/dist/tools/infra/actions.d.ts +1 -1
  16. package/dist/tools/infra/actions.js +1 -1
  17. package/dist/tools/omada/index.js +6 -0
  18. package/dist/tools/omada/index.js.map +1 -1
  19. package/dist/tools/omada/macGroups.d.ts +19 -0
  20. package/dist/tools/omada/macGroups.js +77 -0
  21. package/dist/tools/omada/macGroups.js.map +1 -0
  22. package/dist/tools/omada/namespace.js +8 -0
  23. package/dist/tools/omada/namespace.js.map +1 -1
  24. package/dist/version.d.ts +1 -1
  25. package/dist/version.js +1 -1
  26. package/package.json +5 -1
  27. package/.codex/review-rules.md +0 -16
  28. package/.dockerignore +0 -8
  29. package/.gitattributes +0 -30
  30. package/.github/workflows/ci.yml +0 -193
  31. package/AGENTS.md +0 -389
  32. package/Dockerfile +0 -50
  33. package/docker-compose.yml +0 -23
  34. package/docs/CHANGELOG.md +0 -341
  35. package/docs/COVERAGE.md +0 -219
  36. package/docs/QUICK_START.md +0 -240
  37. package/eslint.config.js +0 -33
  38. package/n8n/ai-agent-safety-monitor.json +0 -355
  39. package/openapi.json +0 -1259
  40. package/scripts/check-version-sync.mjs +0 -69
  41. package/scripts/generate-jwt.ts +0 -121
  42. package/src/config.ts +0 -440
  43. package/src/haClient/automations.ts +0 -261
  44. package/src/haClient/calendars.ts +0 -94
  45. package/src/haClient/config.ts +0 -52
  46. package/src/haClient/devices.ts +0 -653
  47. package/src/haClient/entities.ts +0 -128
  48. package/src/haClient/events.ts +0 -689
  49. package/src/haClient/history.ts +0 -73
  50. package/src/haClient/index.ts +0 -336
  51. package/src/haClient/request.ts +0 -142
  52. package/src/haClient/services.ts +0 -45
  53. package/src/haClient/states.ts +0 -60
  54. package/src/haClient/updates.ts +0 -58
  55. package/src/haClient/wsCommand.ts +0 -44
  56. package/src/index.ts +0 -164
  57. package/src/localAI/index.ts +0 -88
  58. package/src/localAI/prompts.ts +0 -50
  59. package/src/localAI/providers/base.ts +0 -120
  60. package/src/localAI/providers/ollama.ts +0 -78
  61. package/src/localAI/providers/openai.ts +0 -86
  62. package/src/localAI/types.ts +0 -67
  63. package/src/omadaClient/auth.ts +0 -178
  64. package/src/omadaClient/client.ts +0 -337
  65. package/src/omadaClient/device.ts +0 -118
  66. package/src/omadaClient/index.ts +0 -310
  67. package/src/omadaClient/network.ts +0 -284
  68. package/src/omadaClient/request.ts +0 -365
  69. package/src/omadaClient/security.ts +0 -51
  70. package/src/omadaClient/site.ts +0 -344
  71. package/src/ownerQuestions/service.ts +0 -536
  72. package/src/permissions/index.ts +0 -143
  73. package/src/resources/index.ts +0 -213
  74. package/src/server/auth.ts +0 -175
  75. package/src/server/chatFace.ts +0 -789
  76. package/src/server/chatSlice.ts +0 -57
  77. package/src/server/common.ts +0 -170
  78. package/src/server/eventSubscription.ts +0 -343
  79. package/src/server/http.ts +0 -626
  80. package/src/server/instructions.ts +0 -97
  81. package/src/server/rateLimiter.ts +0 -227
  82. package/src/server/stdio.ts +0 -41
  83. package/src/server/stream.ts +0 -205
  84. package/src/tools/ai/analyzeSensors.ts +0 -33
  85. package/src/tools/ai/index.ts +0 -20
  86. package/src/tools/common.ts +0 -442
  87. package/src/tools/homeassistant/askOwner.ts +0 -133
  88. package/src/tools/homeassistant/automationControls.ts +0 -66
  89. package/src/tools/homeassistant/calendar.ts +0 -97
  90. package/src/tools/homeassistant/callService.ts +0 -25
  91. package/src/tools/homeassistant/controlClimate.ts +0 -68
  92. package/src/tools/homeassistant/controlCover.ts +0 -48
  93. package/src/tools/homeassistant/controlFan.ts +0 -44
  94. package/src/tools/homeassistant/controlLight.ts +0 -38
  95. package/src/tools/homeassistant/controlMediaPlayer.ts +0 -50
  96. package/src/tools/homeassistant/createAutomation.ts +0 -35
  97. package/src/tools/homeassistant/deleteAutomation.ts +0 -24
  98. package/src/tools/homeassistant/entityAction.ts +0 -35
  99. package/src/tools/homeassistant/entityGuard.ts +0 -28
  100. package/src/tools/homeassistant/getAllSensors.ts +0 -46
  101. package/src/tools/homeassistant/getAutomationTrace.ts +0 -30
  102. package/src/tools/homeassistant/getDomainSummary.ts +0 -29
  103. package/src/tools/homeassistant/getEntitiesByDomain.ts +0 -56
  104. package/src/tools/homeassistant/getHistory.ts +0 -24
  105. package/src/tools/homeassistant/getState.ts +0 -24
  106. package/src/tools/homeassistant/getStates.ts +0 -46
  107. package/src/tools/homeassistant/getVersion.ts +0 -21
  108. package/src/tools/homeassistant/index.ts +0 -121
  109. package/src/tools/homeassistant/listAutomations.ts +0 -35
  110. package/src/tools/homeassistant/listEntities.ts +0 -44
  111. package/src/tools/homeassistant/listPersons.ts +0 -46
  112. package/src/tools/homeassistant/sceneAndScript.ts +0 -53
  113. package/src/tools/homeassistant/searchEntities.ts +0 -58
  114. package/src/tools/homeassistant/sendNotification.ts +0 -179
  115. package/src/tools/homeassistant/systemTools.ts +0 -52
  116. package/src/tools/homeassistant/triggerAutomation.ts +0 -24
  117. package/src/tools/index.ts +0 -78
  118. package/src/tools/infra/actions.ts +0 -95
  119. package/src/tools/infra/index.ts +0 -138
  120. package/src/tools/omada/blockClient.ts +0 -28
  121. package/src/tools/omada/cyclePoePort.ts +0 -32
  122. package/src/tools/omada/deleteClient.ts +0 -27
  123. package/src/tools/omada/disableClientRateLimit.ts +0 -23
  124. package/src/tools/omada/getClient.ts +0 -14
  125. package/src/tools/omada/getDevice.ts +0 -14
  126. package/src/tools/omada/getFirewallSetting.ts +0 -14
  127. package/src/tools/omada/getInternetInfo.ts +0 -14
  128. package/src/tools/omada/getLanNetworkList.ts +0 -14
  129. package/src/tools/omada/getLanProfileList.ts +0 -14
  130. package/src/tools/omada/getPortForwardingStatus.ts +0 -26
  131. package/src/tools/omada/getRateLimitProfiles.ts +0 -14
  132. package/src/tools/omada/getSiteNtpStatus.ts +0 -14
  133. package/src/tools/omada/getSsidDetail.ts +0 -22
  134. package/src/tools/omada/getSsidList.ts +0 -21
  135. package/src/tools/omada/getSwitchStackDetail.ts +0 -14
  136. package/src/tools/omada/getThreatList.ts +0 -42
  137. package/src/tools/omada/getWlanGroupList.ts +0 -14
  138. package/src/tools/omada/graph.ts +0 -187
  139. package/src/tools/omada/index.ts +0 -153
  140. package/src/tools/omada/listBlockedClients.ts +0 -17
  141. package/src/tools/omada/listClients.ts +0 -14
  142. package/src/tools/omada/listClientsActivity.ts +0 -25
  143. package/src/tools/omada/listClientsPastConnections.ts +0 -44
  144. package/src/tools/omada/listDevices.ts +0 -14
  145. package/src/tools/omada/listDevicesStats.ts +0 -41
  146. package/src/tools/omada/listMostActiveClients.ts +0 -14
  147. package/src/tools/omada/listSites.ts +0 -18
  148. package/src/tools/omada/namespace.ts +0 -1164
  149. package/src/tools/omada/searchDevices.ts +0 -20
  150. package/src/tools/omada/setClientRateLimit.ts +0 -26
  151. package/src/tools/omada/setClientRateLimitProfile.ts +0 -25
  152. package/src/tools/omada/setSiteNtpServers.ts +0 -36
  153. package/src/tools/omada/setSsidEnabled.ts +0 -38
  154. package/src/tools/omada/unblockClient.ts +0 -26
  155. package/src/tools/types.ts +0 -11
  156. package/src/tools/utils/pagination-schema.ts +0 -27
  157. package/src/types/ai/index.ts +0 -11
  158. package/src/types/ai/types.ts +0 -67
  159. package/src/types/homeassistant/automations.ts +0 -43
  160. package/src/types/homeassistant/calendars.ts +0 -26
  161. package/src/types/homeassistant/config.ts +0 -14
  162. package/src/types/homeassistant/entities.ts +0 -14
  163. package/src/types/homeassistant/errors.ts +0 -33
  164. package/src/types/homeassistant/haConfig.ts +0 -7
  165. package/src/types/homeassistant/history.ts +0 -13
  166. package/src/types/homeassistant/index.ts +0 -46
  167. package/src/types/homeassistant/request.ts +0 -6
  168. package/src/types/homeassistant/serviceCall.ts +0 -18
  169. package/src/types/homeassistant/states.ts +0 -58
  170. package/src/types/homeassistant/updates.ts +0 -28
  171. package/src/types/index.ts +0 -12
  172. package/src/types/omada/activeClientInfo.ts +0 -17
  173. package/src/types/omada/clientActivity.ts +0 -32
  174. package/src/types/omada/clientBlockStatus.ts +0 -11
  175. package/src/types/omada/clientPastConnection.ts +0 -82
  176. package/src/types/omada/index.ts +0 -14
  177. package/src/types/omada/omadaApiResponse.ts +0 -5
  178. package/src/types/omada/omadaClientInfo.ts +0 -11
  179. package/src/types/omada/omadaDeviceInfo.ts +0 -11
  180. package/src/types/omada/omadaDeviceStats.ts +0 -31
  181. package/src/types/omada/omadaSiteSummary.ts +0 -5
  182. package/src/types/omada/oswStackDetail.ts +0 -11
  183. package/src/types/omada/paginatedResult.ts +0 -6
  184. package/src/types/omada/rateLimitProfile.ts +0 -58
  185. package/src/types/omada/threatInfo.ts +0 -38
  186. package/src/types/omada/tokenResult.ts +0 -6
  187. package/src/types.ts +0 -2
  188. package/src/utils/config-validations.ts +0 -68
  189. package/src/utils/jwt.ts +0 -112
  190. package/src/utils/logger.ts +0 -121
  191. package/src/utils/plainText.ts +0 -48
  192. package/src/utils/redact.ts +0 -59
  193. package/src/utils/sanitizeError.ts +0 -18
  194. package/src/utils/tlsDispatcher.ts +0 -69
  195. package/src/version.ts +0 -5
  196. package/tests/bdd/automation-creation.spec.ts +0 -214
  197. package/tests/bdd/automation-management.spec.ts +0 -267
  198. package/tests/bdd/calendar.spec.ts +0 -190
  199. package/tests/bdd/configuration.spec.ts +0 -283
  200. package/tests/bdd/device-control.spec.ts +0 -344
  201. package/tests/bdd/entity-listing.spec.ts +0 -208
  202. package/tests/bdd/entity-search.spec.ts +0 -183
  203. package/tests/bdd/history-retrieval.spec.ts +0 -195
  204. package/tests/bdd/notifications.spec.ts +0 -238
  205. package/tests/bdd/service-calls.spec.ts +0 -144
  206. package/tests/bdd/state-retrieval.spec.ts +0 -204
  207. package/tests/bench/hotpaths.bench.ts +0 -68
  208. package/tests/config.test.ts +0 -355
  209. package/tests/features/automation-creation.feature +0 -36
  210. package/tests/features/automation-management.feature +0 -54
  211. package/tests/features/calendar.feature +0 -37
  212. package/tests/features/configuration.feature +0 -70
  213. package/tests/features/device-control.feature +0 -100
  214. package/tests/features/entity-listing.feature +0 -51
  215. package/tests/features/entity-search.feature +0 -44
  216. package/tests/features/history-retrieval.feature +0 -38
  217. package/tests/features/notifications.feature +0 -37
  218. package/tests/features/service-calls.feature +0 -35
  219. package/tests/features/state-retrieval.feature +0 -37
  220. package/tests/fixtures/openapi-default-slice.json +0 -290
  221. package/tests/haClient/automationTrace.test.ts +0 -59
  222. package/tests/haClient/automations.test.ts +0 -149
  223. package/tests/haClient/config.test.ts +0 -117
  224. package/tests/haClient/entities.test.ts +0 -188
  225. package/tests/haClient/events.test.ts +0 -455
  226. package/tests/haClient/eventsTls.test.ts +0 -52
  227. package/tests/haClient/history.test.ts +0 -228
  228. package/tests/haClient/index.test.ts +0 -243
  229. package/tests/haClient/request.test.ts +0 -217
  230. package/tests/haClient/services.test.ts +0 -120
  231. package/tests/haClient/states.test.ts +0 -119
  232. package/tests/haClient/updates.test.ts +0 -193
  233. package/tests/index.test.ts +0 -160
  234. package/tests/integration/homeassistant.test.ts +0 -144
  235. package/tests/integration/omada.test.ts +0 -167
  236. package/tests/integration/server.test.ts +0 -151
  237. package/tests/localAI/index.test.ts +0 -177
  238. package/tests/localAI/ollama.test.ts +0 -187
  239. package/tests/localAI/openai.test.ts +0 -257
  240. package/tests/omadaClient/actions.test.ts +0 -161
  241. package/tests/omadaClient/clientBlocks.test.ts +0 -170
  242. package/tests/omadaClient/ntp.test.ts +0 -381
  243. package/tests/omadaClient/site.test.ts +0 -37
  244. package/tests/ownerQuestions/service.test.ts +0 -499
  245. package/tests/ownerQuestions/tools.test.ts +0 -186
  246. package/tests/permissions/index.test.ts +0 -217
  247. package/tests/resources/index.test.ts +0 -288
  248. package/tests/scripts/generateJwt.test.ts +0 -44
  249. package/tests/security/accessControl.test.ts +0 -223
  250. package/tests/security/authentication.test.ts +0 -117
  251. package/tests/security/configuration.test.ts +0 -170
  252. package/tests/security/cryptoTransport.test.ts +0 -172
  253. package/tests/security/dependencies.test.ts +0 -152
  254. package/tests/security/injection.test.ts +0 -117
  255. package/tests/security/insecureDesign.test.ts +0 -121
  256. package/tests/security/logging.test.ts +0 -131
  257. package/tests/security/resourceConsumption.test.ts +0 -142
  258. package/tests/server/auth.test.ts +0 -249
  259. package/tests/server/chatFace.test.ts +0 -320
  260. package/tests/server/common.test.ts +0 -84
  261. package/tests/server/http.test.ts +0 -561
  262. package/tests/server/instructions.test.ts +0 -152
  263. package/tests/server/memoizeJsonSchema.test.ts +0 -71
  264. package/tests/server/stdio.test.ts +0 -99
  265. package/tests/server/stream.test.ts +0 -285
  266. package/tests/server/toolVisibility.http.test.ts +0 -111
  267. package/tests/server/toolVisibility.test.ts +0 -129
  268. package/tests/tools/ai/analyzeSensors.test.ts +0 -227
  269. package/tests/tools/common.test.ts +0 -123
  270. package/tests/tools/homeassistant/handlers.test.ts +0 -872
  271. package/tests/tools/infra/invokeAction.test.ts +0 -280
  272. package/tests/tools/omada/graph.test.ts +0 -385
  273. package/tests/tools/omada/handlers.test.ts +0 -897
  274. package/tests/tools/omada/namespace.test.ts +0 -483
  275. package/tests/tools/registry.test.ts +0 -168
  276. package/tests/utils/config-validations.test.ts +0 -352
  277. package/tests/utils/jwt.test.ts +0 -115
  278. package/tests/utils/logger.test.ts +0 -327
  279. package/tests/utils/redact.test.ts +0 -105
  280. package/tsconfig.json +0 -24
  281. 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
- });