@digital-alchemy/hass 25.10.27-beta.1 → 25.11.16

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 (70) hide show
  1. package/dist/dev/mappings.d.mts +26 -0
  2. package/dist/dev/services.mjs +0 -1
  3. package/dist/dev/services.mjs.map +1 -1
  4. package/dist/hass.module.d.mts +8 -1
  5. package/dist/hass.module.mjs +5 -1
  6. package/dist/hass.module.mjs.map +1 -1
  7. package/dist/helpers/fetch/configuration.d.mts +5 -5
  8. package/dist/helpers/fetch/configuration.mjs.map +1 -1
  9. package/dist/helpers/interfaces.d.mts +11 -0
  10. package/dist/helpers/interfaces.mjs.map +1 -1
  11. package/dist/mock_assistant/mock-assistant.module.d.mts +2 -0
  12. package/dist/services/area.service.mjs +12 -12
  13. package/dist/services/area.service.mjs.map +1 -1
  14. package/dist/services/config.service.d.mts +2 -1
  15. package/dist/services/config.service.mjs +35 -5
  16. package/dist/services/config.service.mjs.map +1 -1
  17. package/dist/services/conversation.service.d.mts +1 -1
  18. package/dist/services/conversation.service.mjs +9 -1
  19. package/dist/services/conversation.service.mjs.map +1 -1
  20. package/dist/services/device.service.mjs +12 -19
  21. package/dist/services/device.service.mjs.map +1 -1
  22. package/dist/services/entity.service.mjs +12 -12
  23. package/dist/services/entity.service.mjs.map +1 -1
  24. package/dist/services/floor.service.mjs +12 -12
  25. package/dist/services/floor.service.mjs.map +1 -1
  26. package/dist/services/index.d.mts +1 -0
  27. package/dist/services/index.mjs +1 -0
  28. package/dist/services/index.mjs.map +1 -1
  29. package/dist/services/label.service.mjs +12 -12
  30. package/dist/services/label.service.mjs.map +1 -1
  31. package/dist/services/registry.service.mjs +4 -3
  32. package/dist/services/registry.service.mjs.map +1 -1
  33. package/dist/services/websocket-api.service.d.mts +1 -1
  34. package/dist/services/websocket-api.service.mjs +70 -6
  35. package/dist/services/websocket-api.service.mjs.map +1 -1
  36. package/dist/services/zone.service.mjs +12 -12
  37. package/dist/services/zone.service.mjs.map +1 -1
  38. package/dist/testing/config.spec.mjs +367 -0
  39. package/dist/testing/config.spec.mjs.map +1 -1
  40. package/dist/testing/conversation.spec.d.mts +1 -0
  41. package/dist/testing/conversation.spec.mjs +42 -0
  42. package/dist/testing/conversation.spec.mjs.map +1 -0
  43. package/dist/testing/fetch-api.spec.mjs +2 -5
  44. package/dist/testing/fetch-api.spec.mjs.map +1 -1
  45. package/dist/testing/websocket.spec.mjs +177 -0
  46. package/dist/testing/websocket.spec.mjs.map +1 -1
  47. package/dist/user.d.mts +7 -0
  48. package/package.json +1 -1
  49. package/src/dev/mappings.mts +15 -0
  50. package/src/dev/registry.mts +0 -1
  51. package/src/dev/services.mts +0 -1
  52. package/src/hass.module.mts +10 -0
  53. package/src/helpers/fetch/configuration.mts +11 -5
  54. package/src/helpers/interfaces.mts +14 -0
  55. package/src/services/area.service.mts +13 -13
  56. package/src/services/config.service.mts +53 -6
  57. package/src/services/conversation.service.mts +16 -2
  58. package/src/services/device.service.mts +13 -21
  59. package/src/services/entity.service.mts +13 -12
  60. package/src/services/floor.service.mts +13 -13
  61. package/src/services/index.mts +1 -0
  62. package/src/services/label.service.mts +13 -13
  63. package/src/services/registry.service.mts +5 -4
  64. package/src/services/websocket-api.service.mts +86 -5
  65. package/src/services/zone.service.mts +13 -13
  66. package/src/testing/config.spec.mts +410 -0
  67. package/src/testing/conversation.spec.mts +48 -0
  68. package/src/testing/fetch-api.spec.mts +2 -5
  69. package/src/testing/websocket.spec.mts +225 -0
  70. package/src/user.mts +14 -0
@@ -1,3 +1,5 @@
1
+ import { sleep } from "@digital-alchemy/core";
2
+
1
3
  import { hassTestRunner } from "../mock_assistant/index.mts";
2
4
 
3
5
  describe("Websocket", () => {
@@ -5,6 +7,9 @@ describe("Websocket", () => {
5
7
  await hassTestRunner.teardown();
6
8
  vi.restoreAllMocks();
7
9
  });
10
+ beforeEach(() => {
11
+ hassTestRunner.emitLogs("silent");
12
+ });
8
13
 
9
14
  describe("API Interactions", () => {
10
15
  it("should emit events onConnect", async () => {
@@ -212,4 +217,224 @@ describe("Websocket", () => {
212
217
  });
213
218
  });
214
219
  });
220
+
221
+ describe("Memory Leak Detection", () => {
222
+ it("should NOT warn when subscribe is called outside onConnect", async () => {
223
+ expect.assertions(1);
224
+ await hassTestRunner.run(({ lifecycle, hass, logger, context }) => {
225
+ const warnSpy = vi.spyOn(logger, "warn").mockImplementation(() => {});
226
+
227
+ lifecycle.onReady(async () => {
228
+ await hass.socket.subscribe({
229
+ context,
230
+ event_type: "test_event",
231
+ exec: () => {},
232
+ });
233
+
234
+ // Should not have been called with memory leak warning
235
+ const memoryLeakWarnings = warnSpy.mock.calls.filter(call =>
236
+ call[1]?.toString().includes("MEMORY LEAK DETECTED"),
237
+ );
238
+ expect(memoryLeakWarnings.length).toBe(0);
239
+ });
240
+ });
241
+ });
242
+
243
+ it("should warn when subscribe is called inside onConnect callback", async () => {
244
+ expect.assertions(2);
245
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
246
+ await hassTestRunner.emitLogs("warn").run(({ lifecycle, hass, internal, context, event }) => {
247
+ internal.boilerplate.configuration.set("boilerplate", "LOG_LEVEL", "warn");
248
+ lifecycle.onReady(async () => {
249
+ // Wait for initial connection to complete
250
+ await new Promise(resolve => setTimeout(resolve, 100));
251
+
252
+ // Ensure socket is connected
253
+ expect(hass.socket.connectionState).toBe("connected");
254
+
255
+ // Register onConnect callback that subscribes (this should trigger warning)
256
+ hass.socket.onConnect(async () => {
257
+ await hass.socket.subscribe({
258
+ context,
259
+ event_type: "test_event_warn",
260
+ exec: () => {},
261
+ });
262
+ });
263
+
264
+ // Trigger onConnect by emitting SOCKET_CONNECTED event again
265
+ event.emit("SOCKET_CONNECTED");
266
+ await sleep(0);
267
+
268
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("MEMORY LEAK DETECTED"));
269
+ });
270
+ });
271
+ });
272
+ });
273
+
274
+ describe("Subscription Registry", () => {
275
+ it("should prevent duplicate subscriptions", async () => {
276
+ expect.assertions(1);
277
+ await hassTestRunner.run(({ lifecycle, hass, context }) => {
278
+ const sendMessageSpy = vi
279
+ .spyOn(hass.socket, "sendMessage")
280
+ .mockImplementation(async () => undefined);
281
+
282
+ lifecycle.onReady(async () => {
283
+ // Clear spy after initial bootstrap subscriptions
284
+ sendMessageSpy.mockClear();
285
+
286
+ // Subscribe to the same event twice (even with same context)
287
+ // Since registry tracks by event_type only, second subscription won't send message
288
+ await hass.socket.subscribe({
289
+ context,
290
+ event_type: "duplicate_test",
291
+ exec: () => {},
292
+ });
293
+
294
+ await hass.socket.subscribe({
295
+ context,
296
+ event_type: "duplicate_test",
297
+ exec: () => {},
298
+ });
299
+
300
+ // Should only send subscribe message once (duplicate event_type is prevented)
301
+ const subscribeCalls = sendMessageSpy.mock.calls.filter(
302
+ call =>
303
+ call[0]?.type === "subscribe_events" && call[0]?.event_type === "duplicate_test",
304
+ );
305
+ expect(subscribeCalls.length).toBe(1);
306
+ });
307
+ });
308
+ });
309
+
310
+ it("should allow different event types with same context", async () => {
311
+ expect.assertions(1);
312
+ await hassTestRunner.run(({ lifecycle, hass, context }) => {
313
+ const sendMessageSpy = vi
314
+ .spyOn(hass.socket, "sendMessage")
315
+ .mockImplementation(async () => undefined);
316
+
317
+ lifecycle.onReady(async () => {
318
+ await hass.socket.subscribe({
319
+ context,
320
+ event_type: "event_type_1",
321
+ exec: () => {},
322
+ });
323
+
324
+ await hass.socket.subscribe({
325
+ context,
326
+ event_type: "event_type_2",
327
+ exec: () => {},
328
+ });
329
+
330
+ // Should send subscribe message for both
331
+ const subscribeCalls = sendMessageSpy.mock.calls.filter(
332
+ call => call[0]?.type === "subscribe_events",
333
+ );
334
+ expect(subscribeCalls.length).toBeGreaterThanOrEqual(2);
335
+ });
336
+ });
337
+ });
338
+
339
+ it("should re-establish subscriptions on reconnect", async () => {
340
+ expect.assertions(2);
341
+ await hassTestRunner.run(({ lifecycle, hass, context, event }) => {
342
+ const sendMessageSpy = vi
343
+ .spyOn(hass.socket, "sendMessage")
344
+ .mockImplementation(async () => undefined);
345
+
346
+ lifecycle.onReady(async () => {
347
+ // Register a subscription
348
+ await hass.socket.subscribe({
349
+ context,
350
+ event_type: "reconnect_test",
351
+ exec: () => {},
352
+ });
353
+
354
+ // Clear the spy to only track re-establishment calls
355
+ sendMessageSpy.mockClear();
356
+
357
+ // Register an onConnect callback that will trigger re-establishment
358
+ hass.socket.onConnect(async () => {
359
+ // This callback will trigger re-establishment
360
+ });
361
+
362
+ // Trigger reconnect by emitting SOCKET_CONNECTED event
363
+ event.emit("SOCKET_CONNECTED");
364
+
365
+ // Wait for async operations
366
+ await new Promise(resolve => setTimeout(resolve, 50));
367
+
368
+ // Should have re-established the subscription
369
+ const reestablishCalls = sendMessageSpy.mock.calls.filter(
370
+ call =>
371
+ call[0]?.type === "subscribe_events" && call[0]?.event_type === "reconnect_test",
372
+ );
373
+ expect(reestablishCalls.length).toBeGreaterThan(0);
374
+ expect(sendMessageSpy).toHaveBeenCalled();
375
+ });
376
+ });
377
+ });
378
+
379
+ it("should re-establish multiple subscriptions on reconnect", async () => {
380
+ expect.assertions(4);
381
+ await hassTestRunner.run(({ lifecycle, hass, context, event }) => {
382
+ const sendMessageSpy = vi
383
+ .spyOn(hass.socket, "sendMessage")
384
+ .mockImplementation(async () => undefined);
385
+
386
+ lifecycle.onReady(async () => {
387
+ // Wait for initial connection to complete
388
+ await new Promise(resolve => setTimeout(resolve, 50));
389
+
390
+ // Register multiple subscriptions
391
+ await hass.socket.subscribe({
392
+ context,
393
+ event_type: "multi_test_1",
394
+ exec: () => {},
395
+ });
396
+
397
+ await hass.socket.subscribe({
398
+ context,
399
+ event_type: "multi_test_2",
400
+ exec: () => {},
401
+ });
402
+
403
+ await hass.socket.subscribe({
404
+ context,
405
+ event_type: "multi_test_3",
406
+ exec: () => {},
407
+ });
408
+
409
+ // Clear the spy to only track re-establishment calls
410
+ sendMessageSpy.mockClear();
411
+
412
+ // Register an onConnect callback that will trigger re-establishment
413
+ hass.socket.onConnect(async () => {
414
+ // This callback will trigger re-establishment
415
+ });
416
+
417
+ // Trigger reconnect by emitting SOCKET_CONNECTED event
418
+ event.emit("SOCKET_CONNECTED");
419
+
420
+ // Wait for async operations
421
+ await new Promise(resolve => setTimeout(resolve, 150));
422
+
423
+ // Should have re-established our specific subscriptions (along with others)
424
+ const reestablishCalls = sendMessageSpy.mock.calls.filter(
425
+ call =>
426
+ call[0]?.type === "subscribe_events" &&
427
+ (call[0]?.event_type === "multi_test_1" ||
428
+ call[0]?.event_type === "multi_test_2" ||
429
+ call[0]?.event_type === "multi_test_3"),
430
+ );
431
+ // Verify each specific subscription was re-established
432
+ expect(reestablishCalls.length).toBe(3);
433
+ expect(reestablishCalls.some(call => call[0]?.event_type === "multi_test_1")).toBe(true);
434
+ expect(reestablishCalls.some(call => call[0]?.event_type === "multi_test_2")).toBe(true);
435
+ expect(reestablishCalls.some(call => call[0]?.event_type === "multi_test_3")).toBe(true);
436
+ });
437
+ });
438
+ });
439
+ });
215
440
  });
package/src/user.mts CHANGED
@@ -50,12 +50,26 @@ export interface HassThemeMapping {
50
50
  // "{theme_name}": true | "light" | "dark" | "light" | "dark"
51
51
  }
52
52
 
53
+ export interface HassConfigEntryMapping {
54
+ // "{entry_id}": { title: string; domain: string }
55
+ }
56
+
57
+ // Reverse lookup: find entry_id by title
58
+ export type FindEntryIdByTitle<
59
+ TITLE extends HassConfigEntryMapping[keyof HassConfigEntryMapping]["title"],
60
+ > = {
61
+ [K in keyof HassConfigEntryMapping]: HassConfigEntryMapping[K]["title"] extends TITLE ? K : never;
62
+ }[keyof HassConfigEntryMapping];
63
+
64
+ export type TConfigEntryTitle = HassConfigEntryMapping[keyof HassConfigEntryMapping]["title"];
65
+
53
66
  // #MARK: extract
54
67
  export type TAreaId = UnPrefix<keyof HassAreaMapping>;
55
68
  export type TDeviceId = UnPrefix<keyof HassDeviceMapping>;
56
69
  export type TFloorId = UnPrefix<keyof HassFloorMapping>;
57
70
  export type TLabelId = UnPrefix<keyof HassLabelMapping>;
58
71
  export type TPlatformId = UnPrefix<keyof HassPlatformMapping>;
72
+ export type TConfigEntryId = keyof HassConfigEntryMapping;
59
73
 
60
74
  export type TRawDomains = keyof HassDomainMapping;
61
75
  export type TZoneId = keyof HassZoneMapping;