@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,48 +0,0 @@
1
- /**
2
- * Plain-text validation for user-visible strings we put on the owner's phone.
3
- *
4
- * Text is NFC-normalized first (so composed letters such as Romanian ș/ț/ă count
5
- * as one character), then every character must be a letter, digit, punctuation,
6
- * a math/currency/modifier symbol, the degree sign, or a plain space; line
7
- * breaks only where allowed. Everything else is refused, never stripped:
8
- * emoji and pictographs, control and invisible format characters (zero-width
9
- * joiners, bidi overrides that make text display differently than it reads),
10
- * variation selectors and leftover combining marks ("zalgo"), private-use and
11
- * lone surrogates.
12
- */
13
-
14
- const ALLOWED_CHAR = /^[\p{L}\p{N}\p{P}\p{Sm}\p{Sc}\p{Sk}° ]$/u;
15
-
16
- export interface PlainTextOptions {
17
- field: string;
18
- maxLength: number;
19
- allowNewlines?: boolean;
20
- }
21
-
22
- /**
23
- * @returns the NFC-normalized text
24
- * @throws Error naming the field and the first offending character
25
- */
26
- export function validatePlainText(text: string, options: PlainTextOptions): string {
27
- const normalized = text.normalize('NFC');
28
- const chars = [...normalized];
29
- if (chars.length === 0 || normalized.trim() === '') {
30
- throw new Error(`${options.field} must not be empty`);
31
- }
32
- if (chars.length > options.maxLength) {
33
- throw new Error(`${options.field} is ${chars.length} characters; the maximum is ${options.maxLength}`);
34
- }
35
- for (const [index, char] of chars.entries()) {
36
- if (char === '\n' && options.allowNewlines) {
37
- continue;
38
- }
39
- if (!ALLOWED_CHAR.test(char)) {
40
- const codePoint = char.codePointAt(0)!.toString(16).toUpperCase().padStart(4, '0');
41
- throw new Error(
42
- `${options.field} contains a disallowed character U+${codePoint} at position ${index + 1}; ` +
43
- 'only plain text is accepted (no emoji, control or invisible characters)'
44
- );
45
- }
46
- }
47
- return normalized;
48
- }
@@ -1,59 +0,0 @@
1
- /**
2
- * Redaction of secrets in tool responses.
3
- *
4
- * Home Assistant and Omada return secrets inside ordinary read results: the
5
- * Wi-Fi password (Omada `pskSetting.securityKey`), RADIUS / CoA / SNMP secrets,
6
- * PPSK and VPN keys, portal passwords, camera access tokens in image URLs. A tool
7
- * response goes to an AI client and from there into transcripts, so every tool
8
- * response is passed through `redactSecrets` (see toToolResult).
9
- *
10
- * Only non-empty string values under secret-looking names are replaced, with
11
- * "<redacted>": an empty value stays empty (so "is a password set?" is still
12
- * answerable), and booleans or numbers under similar names (`hidePwd`,
13
- * `encryptionPsk`) are left alone. `token=` / `access_token=` query values inside
14
- * URLs are masked as well. The input is never modified.
15
- *
16
- * Applied at the response boundary only, never inside the API clients: internal
17
- * read-modify-write paths (SSID schedule, site NTP) must see real values.
18
- */
19
-
20
- export const REDACTED = '<redacted>';
21
-
22
- /**
23
- * Secret-looking field names, matched on the whole name or its ending
24
- * (case-insensitive), from the Omada Open API and Home Assistant responses.
25
- */
26
- const SECRET_NAME =
27
- /(^key$|password|passwd|pwd$|secret|token$|passphrase|presharedkey|privatekey|securitykey|communitystring|^psk$|md5key|simplekey|authcode|devicekey)/i;
28
-
29
- /** Credentials inside URLs, e.g. /api/camera_proxy/camera.door?token=abc123 */
30
- const URL_SECRET = /([?&](?:access_token|token|key|password|secret)=)[^&#\s"]+/gi;
31
-
32
- export function isSecretName(name: string): boolean {
33
- return SECRET_NAME.test(name);
34
- }
35
-
36
- export function redactSecrets<T>(value: T): T {
37
- return redact(value, new WeakSet()) as T;
38
- }
39
-
40
- function redact(value: unknown, seen: WeakSet<object>): unknown {
41
- if (typeof value === 'string') {
42
- return value.includes('=') ? value.replace(URL_SECRET, `$1${REDACTED}`) : value;
43
- }
44
- if (typeof value !== 'object' || value === null) {
45
- return value;
46
- }
47
- if (seen.has(value)) {
48
- return '[circular]';
49
- }
50
- seen.add(value);
51
- if (Array.isArray(value)) {
52
- return value.map((item) => redact(item, seen));
53
- }
54
- const out: Record<string, unknown> = {};
55
- for (const [key, field] of Object.entries(value as Record<string, unknown>)) {
56
- out[key] = typeof field === 'string' && field !== '' && isSecretName(key) ? REDACTED : redact(field, seen);
57
- }
58
- return out;
59
- }
@@ -1,18 +0,0 @@
1
- /**
2
- * Error sanitization for client-facing responses (M6 / OWASP A09:2021).
3
- *
4
- * Raw `error.message` values routinely embed infrastructure detail — internal
5
- * hostnames, ports, filesystem paths, upstream stack fragments, occasionally
6
- * tokens. None of that belongs in an HTTP response body. Callers should log the
7
- * full error server-side (via `logger.error`) and send the return of this helper
8
- * to the client instead.
9
- *
10
- * The function intentionally ignores the error's own text: returning a constant
11
- * generic string is the whole point — there is no "safe subset" of an arbitrary
12
- * upstream message we can trust to forward.
13
- */
14
- const GENERIC_MESSAGE = 'Internal server error';
15
-
16
- export function sanitizeError(_error: unknown, fallback: string = GENERIC_MESSAGE): string {
17
- return fallback;
18
- }
@@ -1,69 +0,0 @@
1
- /**
2
- * Per-client TLS dispatcher helper (SEC-CRYPTO / H2).
3
- *
4
- * When a client must talk to a host with a self-signed certificate, the ONLY
5
- * acceptable way to relax certificate validation is to scope it to that single
6
- * client. The previous implementation toggled the process-global
7
- * `NODE_TLS_REJECT_UNAUTHORIZED` env var around each `fetch` call, which
8
- * disables TLS verification for EVERY concurrent outbound request in the
9
- * process for the duration of the call — a TOCTOU race that silently strips
10
- * certificate validation from unrelated Home Assistant / AI traffic.
11
- *
12
- * Node's global `fetch` (undici) ignores the legacy `agent` option; the
13
- * supported mechanism is a per-call `dispatcher`. We build one undici Agent per
14
- * client instance and pass it on every request, leaving the process-global TLS
15
- * policy untouched.
16
- *
17
- * THE PAIRING RULE (v1.7.0, root cause of the 2026-07-10 v1.5.5 incident):
18
- * a dispatcher built from the npm `undici` package must ONLY be passed to
19
- * `undici`'s own `fetch` — never to Node's BUILT-IN fetch. The built-in fetch
20
- * is powered by whatever undici Node bundles, and its dispatch-handler
21
- * interface drifts across majors: on Node 26 an npm-undici-6 Agent fails with
22
- * "invalid onError method", surfacing as `fetch failed` on every request (the
23
- * NAS crash-loop). `tlsAwareFetch` enforces the rule at one choke point.
24
- */
25
-
26
- import { Agent, fetch as undiciFetch, type Dispatcher } from 'undici';
27
-
28
- /**
29
- * `fetch` init augmented with undici's per-request dispatcher option, which is
30
- * not present in the lib.dom / @types/node `RequestInit` typings.
31
- */
32
- export type FetchInitWithDispatcher = RequestInit & { dispatcher?: Dispatcher };
33
-
34
- /**
35
- * Build an undici dispatcher that disables TLS certificate validation, scoped
36
- * to a single client instance. Returns `undefined` when `strictSsl` is not
37
- * explicitly disabled, so strict clients carry no dispatcher and use Node's
38
- * default (validating) global agent.
39
- *
40
- * @param strictSsl - When `false`, certificate validation is disabled for this
41
- * client only. Any other value (including `undefined`) keeps validation on.
42
- * @param baseUrl - The client's base URL. For plain-`http:` targets no
43
- * dispatcher is created even when `strictSsl` is false: TLS relaxation is
44
- * meaningless without TLS, and carrying a dispatcher there only risks the
45
- * version-mixing failure described above for zero benefit (this was exactly
46
- * the NAS production config that crashed v1.5.5 — http HA + strictSsl=false).
47
- */
48
- export function createTlsDispatcher(strictSsl: boolean | undefined, baseUrl?: string): Dispatcher | undefined {
49
- if (strictSsl === false) {
50
- if (baseUrl !== undefined && baseUrl.trim().toLowerCase().startsWith('http:')) {
51
- return undefined;
52
- }
53
- return new Agent({ connect: { rejectUnauthorized: false } });
54
- }
55
- return undefined;
56
- }
57
-
58
- /**
59
- * Fetch enforcing the pairing rule: requests carrying a dispatcher go through
60
- * undici's own `fetch` (Agent and fetch from the SAME library — compatible on
61
- * every Node version); requests without one use Node's built-in fetch
62
- * (unchanged behavior, and unit tests stubbing `global.fetch` keep working).
63
- */
64
- export function tlsAwareFetch(url: string, init: FetchInitWithDispatcher): Promise<Response> {
65
- if (init.dispatcher) {
66
- return undiciFetch(url, init as Parameters<typeof undiciFetch>[1]) as unknown as Promise<Response>;
67
- }
68
- return fetch(url, init);
69
- }
package/src/version.ts DELETED
@@ -1,5 +0,0 @@
1
- /**
2
- * Package version - hardcoded at build time for reliability
3
- * Keep in sync with package.json version
4
- */
5
- export const VERSION = '2.2.1';
@@ -1,214 +0,0 @@
1
- /**
2
- * BDD tests for Automation Creation
3
- * Uses vitest-cucumber for Gherkin syntax
4
- */
5
-
6
- import { loadFeature, describeFeature } from '@amiceli/vitest-cucumber';
7
- import { expect, vi } from 'vitest';
8
- import { AutomationOperations } from '../../src/haClient/automations.js';
9
- import type { StateOperations } from '../../src/haClient/states.js';
10
- import type { ServiceOperations } from '../../src/haClient/services.js';
11
- import type { RequestHandler } from '../../src/haClient/request.js';
12
- import type { AutomationConfig, AutomationTrigger, AutomationAction } from '../../src/types/index.js';
13
-
14
- const feature = await loadFeature('tests/features/automation-creation.feature');
15
-
16
- describeFeature(feature, ({ Background, Scenario }) => {
17
- // Shared test state
18
- let mockStateOps: StateOperations;
19
- let mockServiceOps: ServiceOperations;
20
- let mockRequest: RequestHandler;
21
- let automationOps: AutomationOperations;
22
- let automationConfig: Partial<AutomationConfig>;
23
- let triggers: AutomationTrigger[];
24
- let actions: AutomationAction[];
25
- let result: { id: string } | null;
26
- let error: Error | null;
27
-
28
- Background(({ Given }) => {
29
- Given('a connected Home Assistant client', () => {
30
- // Reset state for each scenario
31
- automationConfig = {};
32
- triggers = [];
33
- actions = [];
34
- result = null;
35
- error = null;
36
-
37
- // Setup mocks
38
- mockStateOps = {
39
- getStates: vi.fn().mockResolvedValue([]),
40
- getState: vi.fn().mockResolvedValue(null),
41
- } as unknown as StateOperations;
42
-
43
- mockServiceOps = {
44
- callService: vi.fn().mockResolvedValue({ context: { id: 'test' } }),
45
- } as unknown as ServiceOperations;
46
-
47
- mockRequest = {
48
- post: vi.fn().mockResolvedValue({ result: 'ok' }),
49
- get: vi.fn().mockResolvedValue({}),
50
- request: vi.fn().mockResolvedValue({}),
51
- } as unknown as RequestHandler;
52
-
53
- automationOps = new AutomationOperations(mockStateOps, mockServiceOps, mockRequest);
54
- });
55
- });
56
-
57
- Scenario('Create a simple time-based automation', ({ Given, When, Then, And }) => {
58
- Given('an automation config with alias "Morning Lights"', () => {
59
- automationConfig.alias = 'Morning Lights';
60
- });
61
-
62
- And('a time trigger at "06:00:00"', () => {
63
- triggers.push({
64
- platform: 'time',
65
- at: '06:00:00',
66
- });
67
- });
68
-
69
- And('an action to turn on "light.bedroom"', () => {
70
- actions.push({
71
- service: 'light.turn_on',
72
- target: { entity_id: 'light.bedroom' },
73
- });
74
- });
75
-
76
- When('I create the automation', async () => {
77
- try {
78
- result = await automationOps.createAutomation({
79
- alias: automationConfig.alias!,
80
- trigger: triggers,
81
- action: actions,
82
- });
83
- } catch (e) {
84
- error = e as Error;
85
- }
86
- });
87
-
88
- Then('the automation should be created successfully', () => {
89
- expect(error).toBeNull();
90
- expect(result).not.toBeNull();
91
- expect(mockRequest.post).toHaveBeenCalled();
92
- });
93
-
94
- And('the automation ID should be returned', () => {
95
- expect(result?.id).toBeDefined();
96
- expect(typeof result?.id).toBe('string');
97
- });
98
- });
99
-
100
- Scenario('Create an automation with state trigger', ({ Given, When, Then, And }) => {
101
- Given('an automation config with alias "Motion Alert"', () => {
102
- automationConfig.alias = 'Motion Alert';
103
- });
104
-
105
- And('a state trigger for "binary_sensor.motion" changing to "on"', () => {
106
- triggers.push({
107
- platform: 'state',
108
- entity_id: 'binary_sensor.motion',
109
- to: 'on',
110
- });
111
- });
112
-
113
- And('an action to send notification "Motion detected!"', () => {
114
- actions.push({
115
- service: 'notify.persistent_notification',
116
- data: { message: 'Motion detected!' },
117
- });
118
- });
119
-
120
- When('I create the automation', async () => {
121
- try {
122
- result = await automationOps.createAutomation({
123
- alias: automationConfig.alias!,
124
- trigger: triggers,
125
- action: actions,
126
- });
127
- } catch (e) {
128
- error = e as Error;
129
- }
130
- });
131
-
132
- Then('the automation should be created successfully', () => {
133
- expect(error).toBeNull();
134
- expect(result).not.toBeNull();
135
- });
136
- });
137
-
138
- Scenario('Reject automation without required fields', ({ Given, When, Then }) => {
139
- Given('an automation config without alias', () => {
140
- // Leave alias undefined
141
- automationConfig = {};
142
- });
143
-
144
- When('I attempt to create the automation', async () => {
145
- // Simulate the tool validation that happens in createAutomation.ts
146
- // The actual validation is in the tool layer, not AutomationOperations
147
- if (!automationConfig.alias) {
148
- error = new Error('alias, trigger, and action are required');
149
- }
150
- });
151
-
152
- Then('it should fail with error "alias, trigger, and action are required"', () => {
153
- expect(error).not.toBeNull();
154
- expect(error?.message).toBe('alias, trigger, and action are required');
155
- });
156
- });
157
-
158
- Scenario('Create automation with multiple actions', ({ Given, When, Then, And }) => {
159
- Given('an automation config with alias "Goodnight Routine"', () => {
160
- automationConfig.alias = 'Goodnight Routine';
161
- });
162
-
163
- And('a time trigger at "23:00:00"', () => {
164
- triggers.push({
165
- platform: 'time',
166
- at: '23:00:00',
167
- });
168
- });
169
-
170
- And('an action to turn off "light.living_room"', () => {
171
- actions.push({
172
- service: 'light.turn_off',
173
- target: { entity_id: 'light.living_room' },
174
- });
175
- });
176
-
177
- And('an action to turn off "light.kitchen"', () => {
178
- actions.push({
179
- service: 'light.turn_off',
180
- target: { entity_id: 'light.kitchen' },
181
- });
182
- });
183
-
184
- And('an action to set "climate.thermostat" to 18 degrees', () => {
185
- actions.push({
186
- service: 'climate.set_temperature',
187
- target: { entity_id: 'climate.thermostat' },
188
- data: { temperature: 18 },
189
- });
190
- });
191
-
192
- When('I create the automation', async () => {
193
- try {
194
- result = await automationOps.createAutomation({
195
- alias: automationConfig.alias!,
196
- trigger: triggers,
197
- action: actions,
198
- });
199
- } catch (e) {
200
- error = e as Error;
201
- }
202
- });
203
-
204
- Then('the automation should be created successfully', () => {
205
- expect(error).toBeNull();
206
- expect(result).not.toBeNull();
207
-
208
- // Verify the POST was called with all actions
209
- const postCall = (mockRequest.post as ReturnType<typeof vi.fn>).mock.calls[0];
210
- const payload = postCall[1];
211
- expect(payload.action).toHaveLength(3);
212
- });
213
- });
214
- });
@@ -1,267 +0,0 @@
1
- /**
2
- * BDD tests for Automation Management
3
- * Uses vitest-cucumber for Gherkin syntax
4
- */
5
-
6
- import { loadFeature, describeFeature } from '@amiceli/vitest-cucumber';
7
- import { expect, vi } from 'vitest';
8
- import { AutomationOperations } from '../../src/haClient/automations.js';
9
- import type { StateOperations } from '../../src/haClient/states.js';
10
- import type { ServiceOperations } from '../../src/haClient/services.js';
11
- import type { RequestHandler } from '../../src/haClient/request.js';
12
- import type { Entity, Automation } from '../../src/types/index.js';
13
-
14
- const feature = await loadFeature('tests/features/automation-management.feature');
15
-
16
- describeFeature(feature, ({ Scenario }) => {
17
- // Shared test state
18
- let mockStateOps: StateOperations;
19
- let mockServiceOps: ServiceOperations;
20
- let mockRequest: RequestHandler;
21
- let automationOps: AutomationOperations;
22
- let automations: Automation[];
23
- let automation: Automation | null;
24
- let entityId: string;
25
- let lastServiceCall: { domain: string; service: string; target?: { entity_id: string }; service_data?: Record<string, unknown> } | null;
26
- let lastRequest: { method: string; path: string } | null;
27
-
28
- const mockAutomationEntities: Entity[] = [
29
- {
30
- entity_id: 'automation.morning_lights',
31
- state: 'on',
32
- attributes: {
33
- friendly_name: 'Morning Lights',
34
- last_triggered: '2026-01-08T06:00:00.000Z',
35
- description: 'Turn on lights at sunrise',
36
- mode: 'single',
37
- },
38
- last_changed: '2026-01-08T06:00:00.000Z',
39
- last_updated: '2026-01-08T06:00:00.000Z',
40
- context: { id: 'ctx-1', parent_id: null, user_id: null },
41
- },
42
- {
43
- entity_id: 'automation.night_mode',
44
- state: 'off',
45
- attributes: {
46
- friendly_name: 'Night Mode',
47
- last_triggered: null,
48
- mode: 'restart',
49
- },
50
- last_changed: '2026-01-07T22:00:00.000Z',
51
- last_updated: '2026-01-08T10:00:00.000Z',
52
- context: { id: 'ctx-2', parent_id: null, user_id: null },
53
- },
54
- {
55
- entity_id: 'automation.dynamic_scene',
56
- state: 'on',
57
- attributes: {
58
- friendly_name: 'Dynamic Scene',
59
- mode: 'queued',
60
- },
61
- last_changed: '2026-01-08T09:30:00.000Z',
62
- last_updated: '2026-01-08T09:30:00.000Z',
63
- context: { id: 'ctx-3', parent_id: null, user_id: null },
64
- },
65
- ];
66
-
67
- function setupMocks() {
68
- lastServiceCall = null;
69
- lastRequest = null;
70
-
71
- mockStateOps = {
72
- getStates: vi.fn().mockResolvedValue(mockAutomationEntities),
73
- getState: vi.fn().mockImplementation(async (id: string) => {
74
- return mockAutomationEntities.find(e => e.entity_id === id) || null;
75
- }),
76
- } as unknown as StateOperations;
77
-
78
- mockServiceOps = {
79
- callService: vi.fn().mockImplementation(async (data) => {
80
- lastServiceCall = {
81
- domain: data.domain,
82
- service: data.service,
83
- target: data.target,
84
- service_data: data.service_data,
85
- };
86
- return { context: { id: 'test-ctx' } };
87
- }),
88
- } as unknown as ServiceOperations;
89
-
90
- mockRequest = {
91
- post: vi.fn().mockResolvedValue({ result: 'ok' }),
92
- get: vi.fn().mockResolvedValue([]),
93
- request: vi.fn().mockImplementation(async (path: string, options: { method: string }) => {
94
- lastRequest = { method: options.method, path };
95
- return {};
96
- }),
97
- } as unknown as RequestHandler;
98
-
99
- automationOps = new AutomationOperations(mockStateOps, mockServiceOps, mockRequest);
100
- }
101
-
102
- Scenario('List all automations', ({ Given, When, Then, And }) => {
103
- Given('a Home Assistant with automations', () => {
104
- setupMocks();
105
- });
106
-
107
- When('I list all automations', async () => {
108
- automations = await automationOps.getAutomations();
109
- });
110
-
111
- Then('I should get all automation entities', () => {
112
- expect(automations).toHaveLength(3);
113
- });
114
-
115
- And('each automation should have id, alias, and state', () => {
116
- automations.forEach(a => {
117
- expect(a.id).toBeDefined();
118
- expect(a.alias).toBeDefined();
119
- expect(a.state).toMatch(/^(on|off)$/);
120
- });
121
- });
122
- });
123
-
124
- Scenario('Get automation details', ({ Given, When, Then, And }) => {
125
- Given('an automation "automation.morning_lights" exists', () => {
126
- setupMocks();
127
- entityId = 'automation.morning_lights';
128
- });
129
-
130
- When('I get the automation details', async () => {
131
- automation = await automationOps.getAutomation(entityId);
132
- });
133
-
134
- Then('I should see the automation alias "Morning Lights"', () => {
135
- expect(automation?.alias).toBe('Morning Lights');
136
- });
137
-
138
- And('the state should be "on"', () => {
139
- expect(automation?.state).toBe('on');
140
- });
141
-
142
- And('the mode should be "single"', () => {
143
- expect(automation?.mode).toBe('single');
144
- });
145
- });
146
-
147
- Scenario('Trigger an automation manually', ({ Given, When, Then, And }) => {
148
- Given('an automation "automation.morning_lights" exists', () => {
149
- setupMocks();
150
- entityId = 'automation.morning_lights';
151
- });
152
-
153
- When('I trigger the automation', async () => {
154
- await automationOps.triggerAutomation(entityId);
155
- });
156
-
157
- Then('the automation.trigger service should be called', () => {
158
- expect(lastServiceCall?.domain).toBe('automation');
159
- expect(lastServiceCall?.service).toBe('trigger');
160
- });
161
-
162
- And('the target should be "automation.morning_lights"', () => {
163
- expect(lastServiceCall?.target?.entity_id).toBe('automation.morning_lights');
164
- });
165
- });
166
-
167
- Scenario('Trigger automation with variables', ({ Given, When, Then, And }) => {
168
- Given('an automation "automation.dynamic_scene" exists', () => {
169
- setupMocks();
170
- entityId = 'automation.dynamic_scene';
171
- });
172
-
173
- When('I trigger the automation with variables {"brightness": 100}', async () => {
174
- await automationOps.triggerAutomation(entityId, { brightness: 100 });
175
- });
176
-
177
- Then('the automation.trigger service should be called', () => {
178
- expect(lastServiceCall?.domain).toBe('automation');
179
- expect(lastServiceCall?.service).toBe('trigger');
180
- });
181
-
182
- And('the variables should be passed', () => {
183
- expect(lastServiceCall?.service_data?.variables).toEqual({ brightness: 100 });
184
- });
185
- });
186
-
187
- Scenario('Enable a disabled automation', ({ Given, When, Then }) => {
188
- Given('a disabled automation "automation.night_mode" exists', () => {
189
- setupMocks();
190
- entityId = 'automation.night_mode';
191
- });
192
-
193
- When('I enable the automation', async () => {
194
- await automationOps.enableAutomation(entityId);
195
- });
196
-
197
- Then('the automation.turn_on service should be called', () => {
198
- expect(lastServiceCall?.domain).toBe('automation');
199
- expect(lastServiceCall?.service).toBe('turn_on');
200
- expect(lastServiceCall?.target?.entity_id).toBe(entityId);
201
- });
202
- });
203
-
204
- Scenario('Disable an enabled automation', ({ Given, When, Then }) => {
205
- Given('an enabled automation "automation.morning_lights" exists', () => {
206
- setupMocks();
207
- entityId = 'automation.morning_lights';
208
- });
209
-
210
- When('I disable the automation', async () => {
211
- await automationOps.disableAutomation(entityId);
212
- });
213
-
214
- Then('the automation.turn_off service should be called', () => {
215
- expect(lastServiceCall?.domain).toBe('automation');
216
- expect(lastServiceCall?.service).toBe('turn_off');
217
- expect(lastServiceCall?.target?.entity_id).toBe(entityId);
218
- });
219
- });
220
-
221
- Scenario('Toggle automation state', ({ Given, When, Then }) => {
222
- Given('an automation "automation.morning_lights" exists', () => {
223
- setupMocks();
224
- entityId = 'automation.morning_lights';
225
- });
226
-
227
- When('I toggle the automation', async () => {
228
- await automationOps.toggleAutomation(entityId);
229
- });
230
-
231
- Then('the automation.toggle service should be called', () => {
232
- expect(lastServiceCall?.domain).toBe('automation');
233
- expect(lastServiceCall?.service).toBe('toggle');
234
- expect(lastServiceCall?.target?.entity_id).toBe(entityId);
235
- });
236
- });
237
-
238
- Scenario('Reload automations from configuration', ({ Given, When, Then }) => {
239
- Given('a Home Assistant with automations', () => {
240
- setupMocks();
241
- });
242
-
243
- When('I reload automations', async () => {
244
- await automationOps.reloadAutomations();
245
- });
246
-
247
- Then('the automation.reload service should be called', () => {
248
- expect(lastServiceCall?.domain).toBe('automation');
249
- expect(lastServiceCall?.service).toBe('reload');
250
- });
251
- });
252
-
253
- Scenario('Delete an automation', ({ Given, When, Then }) => {
254
- Given('an automation with ID "1234567890" exists', () => {
255
- setupMocks();
256
- });
257
-
258
- When('I delete the automation', async () => {
259
- await automationOps.deleteAutomation('1234567890');
260
- });
261
-
262
- Then('a DELETE request should be made to the config API', () => {
263
- expect(lastRequest?.method).toBe('DELETE');
264
- expect(lastRequest?.path).toBe('/config/automation/config/1234567890');
265
- });
266
- });
267
- });