@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
@@ -290,19 +290,20 @@ export function EntityManager({
290
290
  }
291
291
 
292
292
  // #MARK: onConnect
293
+ void hass.socket.subscribe({
294
+ context,
295
+ event_type: "entity_registry_updated",
296
+ async exec() {
297
+ const ms = perf();
298
+ await debounce(ENTITY_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
299
+ logger.debug("entity registry updated");
300
+ hass.entity.registry.current = await hass.entity.registry.list();
301
+ event.emit(ENTITY_REGISTRY_UPDATED);
302
+ hass.diagnostics.entity?.registry_updated.publish({ ms: ms() });
303
+ },
304
+ });
305
+
293
306
  hass.socket.onConnect(async () => {
294
- hass.socket.subscribe({
295
- context,
296
- event_type: "entity_registry_updated",
297
- async exec() {
298
- const ms = perf();
299
- await debounce(ENTITY_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
300
- logger.debug("entity registry updated");
301
- hass.entity.registry.current = await hass.entity.registry.list();
302
- event.emit(ENTITY_REGISTRY_UPDATED);
303
- hass.diagnostics.entity?.registry_updated.publish({ ms: ms() });
304
- },
305
- });
306
307
  hass.entity.registry.current = await hass.entity.registry.list();
307
308
  });
308
309
 
@@ -13,6 +13,19 @@ export function Floor({
13
13
  logger,
14
14
  lifecycle,
15
15
  }: TServiceParams): HassFloorService {
16
+ void hass.socket.subscribe({
17
+ context,
18
+ event_type: "floor_registry_updated",
19
+ async exec() {
20
+ const ms = perf();
21
+ await debounce(FLOOR_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
22
+ hass.floor.current = await hass.floor.list();
23
+ logger.debug(`floor registry updated`);
24
+ event.emit(FLOOR_REGISTRY_UPDATED);
25
+ hass.diagnostics.floor?.registry_update.publish({ ms: ms() });
26
+ },
27
+ });
28
+
16
29
  hass.socket.onConnect(async () => {
17
30
  let loading = new Promise<void>(async done => {
18
31
  hass.floor.current = await hass.floor.list();
@@ -20,19 +33,6 @@ export function Floor({
20
33
  done();
21
34
  });
22
35
  lifecycle.onReady(async () => loading && (await loading), EARLY_ON_READY);
23
-
24
- hass.socket.subscribe({
25
- context,
26
- event_type: "floor_registry_updated",
27
- async exec() {
28
- const ms = perf();
29
- await debounce(FLOOR_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
30
- hass.floor.current = await hass.floor.list();
31
- logger.debug(`floor registry updated`);
32
- event.emit(FLOOR_REGISTRY_UPDATED);
33
- hass.diagnostics.floor?.registry_update.publish({ ms: ms() });
34
- },
35
- });
36
36
  });
37
37
 
38
38
  return {
@@ -3,6 +3,7 @@ export * from "./area.service.mts";
3
3
  export * from "./backup.service.mts";
4
4
  export * from "./call-proxy.service.mts";
5
5
  export * from "./config.service.mts";
6
+ export * from "./conversation.service.mts";
6
7
  export * from "./device.service.mts";
7
8
  export * from "./diagnostics.service.mts";
8
9
  export * from "./entity.service.mts";
@@ -13,6 +13,19 @@ export function Label({
13
13
  event,
14
14
  context,
15
15
  }: TServiceParams): HassLabelService {
16
+ void hass.socket.subscribe({
17
+ context,
18
+ event_type: "label_registry_updated",
19
+ async exec() {
20
+ const ms = perf();
21
+ await debounce(LABEL_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
22
+ hass.label.current = await hass.label.list();
23
+ logger.debug(`label registry updated`);
24
+ event.emit(LABEL_REGISTRY_UPDATED);
25
+ hass.diagnostics.label?.registry_update.publish({ ms: ms() });
26
+ },
27
+ });
28
+
16
29
  hass.socket.onConnect(async () => {
17
30
  let loading = new Promise<void>(async done => {
18
31
  hass.label.current = await hass.label.list();
@@ -20,19 +33,6 @@ export function Label({
20
33
  done();
21
34
  });
22
35
  lifecycle.onReady(async () => loading && (await loading), EARLY_ON_READY);
23
-
24
- hass.socket.subscribe({
25
- context,
26
- event_type: "label_registry_updated",
27
- async exec() {
28
- const ms = perf();
29
- await debounce(LABEL_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
30
- hass.label.current = await hass.label.list();
31
- logger.debug(`label registry updated`);
32
- event.emit(LABEL_REGISTRY_UPDATED);
33
- hass.diagnostics.label?.registry_update.publish({ ms: ms() });
34
- },
35
- });
36
36
  });
37
37
 
38
38
  async function create(details: LabelOptions) {
@@ -1,7 +1,7 @@
1
+ /* eslint-disable sonarjs/deprecation */
1
2
  import type { TServiceParams } from "@digital-alchemy/core";
2
3
 
3
4
  import type {
4
- ConfigEntry,
5
5
  HassConfig,
6
6
  HassRegistryService,
7
7
  ManifestItem,
@@ -29,10 +29,11 @@ export function Registry({ hass }: TServiceParams): HassRegistryService {
29
29
  });
30
30
  }
31
31
 
32
+ /**
33
+ * @deprecated Use hass.configure.get() instead. This method will be removed in a future version.
34
+ */
32
35
  async function getConfigEntries() {
33
- return await hass.socket.sendMessage<ConfigEntry[]>({
34
- type: "config_entries/get",
35
- });
36
+ return await hass.configure.get();
36
37
  }
37
38
 
38
39
  return {
@@ -1,5 +1,5 @@
1
1
  import type { TBlackHole, TServiceParams } from "@digital-alchemy/core";
2
- import { InternalError, SECOND, sleep, START } from "@digital-alchemy/core";
2
+ import { INCREMENT, InternalError, NONE, SECOND, sleep, START } from "@digital-alchemy/core";
3
3
  import type { Dayjs } from "dayjs";
4
4
  import dayjs from "dayjs";
5
5
  import EventEmitter from "events";
@@ -41,6 +41,7 @@ export function WebsocketAPI({
41
41
  lifecycle,
42
42
  logger,
43
43
  scheduler,
44
+ als,
44
45
  }: TServiceParams): HassWebsocketAPI {
45
46
  const { is } = internal.utils;
46
47
 
@@ -57,6 +58,10 @@ export function WebsocketAPI({
57
58
  const isOld = (date: Dayjs) =>
58
59
  is.undefined(date) || date.diff(dayjs(), "s") >= config.hass.RETRY_INTERVAL;
59
60
 
61
+ // Track all subscriptions for re-establishment on reconnect
62
+ // Map of event_type to count of active subscriptions
63
+ const subscriptionRegistry = new Map<string, number>();
64
+
60
65
  // Start the socket
61
66
  lifecycle.onBootstrap(async () => {
62
67
  logger.debug({ name: "onBootstrap" }, `auto starting connection`);
@@ -298,7 +303,7 @@ export function WebsocketAPI({
298
303
  callback: (message: T) => TBlackHole,
299
304
  ) {
300
305
  const handlers = messageHandlers.get(type) ?? [];
301
- logger.trace({ type }, "register socket message handler");
306
+ logger.trace("register socket message handler [%s]", type);
302
307
  if (!messageHandlers.has(type)) {
303
308
  messageHandlers.set(type, []);
304
309
  }
@@ -504,18 +509,73 @@ export function WebsocketAPI({
504
509
  EVENT extends string,
505
510
  PAYLOAD extends Record<string, unknown> = EmptyObject,
506
511
  >({ event_type, context, exec }: SocketSubscribeOptions<EVENT, PAYLOAD>) {
507
- await hass.socket.sendMessage({ event_type, type: "subscribe_events" });
508
- return hass.socket.onEvent({
512
+ // Memory leak detection: warn if subscribe is called during onConnect using ALS
513
+ const store = als.getStore();
514
+ if (store?.logs?.hassOnConnect) {
515
+ logger.warn(
516
+ { context, event_type, name: subscribe },
517
+ `⚠️ MEMORY LEAK DETECTED: subscribe() called during onConnect callback. ` +
518
+ `This will create duplicate subscriptions on each reconnect. ` +
519
+ `Move subscription to root level using lifecycle hooks instead.`,
520
+ );
521
+ }
522
+
523
+ const current = subscriptionRegistry.get(event_type) ?? NONE;
524
+ if (current == NONE && hass.socket.connectionState === "connected") {
525
+ await hass.socket.sendMessage({ event_type, type: "subscribe_events" });
526
+ }
527
+ subscriptionRegistry.set(event_type, current + INCREMENT);
528
+
529
+ // Register event handler
530
+ const { remove } = hass.socket.onEvent({
509
531
  context,
510
532
  event: event_type,
511
533
  exec,
512
534
  });
535
+
536
+ return internal.removeFn(() => {
537
+ remove();
538
+ const current = subscriptionRegistry.get(event_type) - INCREMENT;
539
+ if (current === NONE) {
540
+ subscriptionRegistry.delete(event_type);
541
+ return;
542
+ }
543
+ subscriptionRegistry.set(event_type, current);
544
+ });
513
545
  }
514
546
 
515
547
  // #MARK: onConnect
516
548
  function onConnect(callback: () => TBlackHole) {
517
549
  const wrapped = async () => {
518
- await internal.safeExec(async () => await callback());
550
+ // Get current store or create new one
551
+ const currentStore = als.getStore();
552
+ const baseData = currentStore || { logs: {} };
553
+
554
+ // Set ALS flag to indicate we're in a connect callback
555
+ als.enterWith({
556
+ ...baseData,
557
+ logs: {
558
+ ...baseData.logs,
559
+ hassOnConnect: true,
560
+ },
561
+ });
562
+
563
+ try {
564
+ await internal.safeExec(async () => await callback());
565
+ } finally {
566
+ // Restore previous ALS context (or clear if none)
567
+ if (currentStore) {
568
+ als.enterWith({
569
+ ...currentStore,
570
+ logs: {
571
+ ...currentStore.logs,
572
+ hassOnConnect: false,
573
+ },
574
+ });
575
+ } else {
576
+ als.enterWith({ logs: {} });
577
+ }
578
+ }
519
579
  };
520
580
  if (hass.socket.connectionState === "connected") {
521
581
  logger.debug(
@@ -544,6 +604,27 @@ export function WebsocketAPI({
544
604
  event.emit(SOCKET_CONNECTED);
545
605
  });
546
606
 
607
+ // Re-establish all registered subscriptions once per connection
608
+ hass.socket.onConnect(async () => {
609
+ if (is.empty(subscriptionRegistry)) {
610
+ return;
611
+ }
612
+ const subscriptions = [...subscriptionRegistry.keys()];
613
+ logger.trace(
614
+ { name: onConnect, subscriptions },
615
+ `re-establishing [%s] subscriptions`,
616
+ subscriptionRegistry.size,
617
+ );
618
+ await Promise.all(
619
+ subscriptions.map(event_type =>
620
+ hass.socket.sendMessage({
621
+ event_type,
622
+ type: "subscribe_events",
623
+ }),
624
+ ),
625
+ );
626
+ });
627
+
547
628
  hass.socket.registerMessageHandler("event", async (message: SocketMessageDTO) => {
548
629
  const id = Number(message.id);
549
630
  return await onMessageEvent(id, message);
@@ -12,6 +12,19 @@ export function Zone({
12
12
  context,
13
13
  lifecycle,
14
14
  }: TServiceParams): HassZoneService {
15
+ void hass.socket.subscribe({
16
+ context,
17
+ event_type: "zone_registry_updated",
18
+ async exec() {
19
+ const ms = perf();
20
+ await debounce(ZONE_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
21
+ hass.zone.current = await hass.zone.list();
22
+ logger.debug(`zone registry updated`);
23
+ event.emit(ZONE_REGISTRY_UPDATED);
24
+ hass.diagnostics.zone?.registry_update.publish({ ms: ms() });
25
+ },
26
+ });
27
+
15
28
  hass.socket.onConnect(async () => {
16
29
  let loading = new Promise<void>(async done => {
17
30
  hass.zone.current = await hass.zone.list();
@@ -19,19 +32,6 @@ export function Zone({
19
32
  done();
20
33
  });
21
34
  lifecycle.onReady(async () => loading && (await loading), EARLY_ON_READY);
22
-
23
- hass.socket.subscribe({
24
- context,
25
- event_type: "zone_registry_updated",
26
- async exec() {
27
- const ms = perf();
28
- await debounce(ZONE_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
29
- hass.zone.current = await hass.zone.list();
30
- logger.debug(`zone registry updated`);
31
- event.emit(ZONE_REGISTRY_UPDATED);
32
- hass.diagnostics.zone?.registry_update.publish({ ms: ms() });
33
- },
34
- });
35
35
  });
36
36
 
37
37
  async function ZoneCreate(options: ZoneOptions) {