@digital-alchemy/hass 25.11.16 → 25.11.17-beta.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 (31) hide show
  1. package/dist/helpers/entity-state.d.mts +20 -3
  2. package/dist/helpers/utility.d.mts +1 -1
  3. package/dist/mock_assistant/mock-assistant.module.mjs.map +1 -1
  4. package/dist/mock_assistant/services/websocket-api.service.d.mts +3 -1
  5. package/dist/mock_assistant/services/websocket-api.service.mjs +43 -4
  6. package/dist/mock_assistant/services/websocket-api.service.mjs.map +1 -1
  7. package/dist/services/feature.service.d.mts +2 -2
  8. package/dist/services/feature.service.mjs.map +1 -1
  9. package/dist/services/reference.service.d.mts +1 -1
  10. package/dist/services/reference.service.mjs +59 -5
  11. package/dist/services/reference.service.mjs.map +1 -1
  12. package/dist/testing/entity.spec.mjs +14 -0
  13. package/dist/testing/entity.spec.mjs.map +1 -1
  14. package/dist/testing/ref-by.spec.mjs +802 -2
  15. package/dist/testing/ref-by.spec.mjs.map +1 -1
  16. package/dist/testing/scheduler.spec.d.mts +1 -0
  17. package/dist/testing/scheduler.spec.mjs +412 -0
  18. package/dist/testing/scheduler.spec.mjs.map +1 -0
  19. package/dist/testing/workflow.spec.mjs +1 -1
  20. package/dist/testing/workflow.spec.mjs.map +1 -1
  21. package/package.json +16 -16
  22. package/src/helpers/entity-state.mts +20 -3
  23. package/src/helpers/utility.mts +1 -1
  24. package/src/mock_assistant/mock-assistant.module.mts +0 -1
  25. package/src/mock_assistant/services/websocket-api.service.mts +46 -4
  26. package/src/services/feature.service.mts +9 -6
  27. package/src/services/reference.service.mts +75 -7
  28. package/src/testing/entity.spec.mts +15 -0
  29. package/src/testing/ref-by.spec.mts +962 -2
  30. package/src/testing/scheduler.spec.mts +444 -0
  31. package/src/testing/workflow.spec.mts +1 -1
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "name": "@digital-alchemy/hass",
4
4
  "repository": "https://github.com/Digital-Alchemy-TS/hass",
5
5
  "homepage": "https://docs.digital-alchemy.app",
6
- "version": "25.11.16",
6
+ "version": "25.11.17-beta.0",
7
7
  "description": "Typescript APIs for Home Assistant. Includes rest & websocket bindings",
8
8
  "scripts": {
9
9
  "build": "rm -rf dist/; tsc",
@@ -58,25 +58,25 @@
58
58
  },
59
59
  "license": "MIT",
60
60
  "devDependencies": {
61
- "@cspell/eslint-plugin": "^9.2.2",
62
- "@digital-alchemy/core": "^25.10.26",
61
+ "@cspell/eslint-plugin": "^9.3.2",
62
+ "@digital-alchemy/core": "^25.11.19",
63
63
  "@digital-alchemy/synapse": "^25.8.21",
64
- "@digital-alchemy/type-writer": "^25.10.12",
65
- "@eslint/compat": "^1.4.0",
64
+ "@digital-alchemy/type-writer": "^25.11.16",
65
+ "@eslint/compat": "^2.0.0",
66
66
  "@eslint/eslintrc": "^3.3.1",
67
- "@eslint/js": "^9.38.0",
67
+ "@eslint/js": "^9.39.1",
68
68
  "@faker-js/faker": "^10.1.0",
69
69
  "@types/js-yaml": "^4.0.9",
70
- "@types/node": "^24.9.1",
70
+ "@types/node": "^24.10.1",
71
71
  "@types/node-cron": "^3.0.11",
72
72
  "@types/semver": "^7.7.1",
73
73
  "@types/ws": "^8.18.1",
74
- "@typescript-eslint/eslint-plugin": "8.46.2",
75
- "@typescript-eslint/parser": "8.46.2",
76
- "@vitest/coverage-v8": "^4.0.3",
77
- "dayjs": "^1.11.18",
74
+ "@typescript-eslint/eslint-plugin": "8.46.4",
75
+ "@typescript-eslint/parser": "8.46.4",
76
+ "@vitest/coverage-v8": "^4.0.10",
77
+ "dayjs": "^1.11.19",
78
78
  "dotenv": "^17.2.3",
79
- "eslint": "9.38.0",
79
+ "eslint": "9.39.1",
80
80
  "eslint-config-prettier": "10.1.8",
81
81
  "eslint-plugin-import": "^2.32.0",
82
82
  "eslint-plugin-jsonc": "^2.21.0",
@@ -93,18 +93,18 @@
93
93
  "tsx": "^4.20.6",
94
94
  "typescript": "^5.9.3",
95
95
  "uuid": "^13.0.0",
96
- "vitest": "^4.0.3",
96
+ "vitest": "^4.0.10",
97
97
  "ws": "^8.18.3"
98
98
  },
99
99
  "peerDependencies": {
100
100
  "@digital-alchemy/core": "*"
101
101
  },
102
102
  "dependencies": {
103
- "dayjs": "^1.11.18",
103
+ "dayjs": "^1.11.19",
104
104
  "semver": "^7.7.3",
105
- "type-fest": "^5.1.0",
105
+ "type-fest": "^5.2.0",
106
106
  "uuid": "^13.0.0",
107
107
  "ws": "^8.18.3"
108
108
  },
109
- "packageManager": "yarn@4.10.3"
109
+ "packageManager": "yarn@4.11.0"
110
110
  }
@@ -1,4 +1,4 @@
1
- import type { FIRST, RemoveCallback, TBlackHole } from "@digital-alchemy/core";
1
+ import type { FIRST, RemoveCallback, TBlackHole, TContext, TOffset } from "@digital-alchemy/core";
2
2
  import type { Dayjs } from "dayjs";
3
3
  import type { Except } from "type-fest";
4
4
 
@@ -46,6 +46,17 @@ export type RemovableCallback<ENTITY_ID extends ANY_ENTITY> = (
46
46
  callback: TEntityUpdateCallback<ENTITY_ID>,
47
47
  ) => RemoveCallback;
48
48
 
49
+ export type OnStateForOptions<ENTITY_ID extends ANY_ENTITY> = (
50
+ | (Pick<ENTITY_STATE<ENTITY_ID>, "state"> & { matches?: never })
51
+ | ({
52
+ matches: (new_state: ENTITY_STATE<ENTITY_ID>, old_state: ENTITY_STATE<ENTITY_ID>) => boolean;
53
+ } & { state?: never })
54
+ ) & {
55
+ for: TOffset;
56
+ context?: TContext;
57
+ exec: (state: ByIdProxy<ENTITY_ID>) => TBlackHole;
58
+ };
59
+
49
60
  export type ByIdProxy<ENTITY_ID extends ANY_ENTITY> = ENTITY_STATE<ENTITY_ID> & {
50
61
  entity_id: ENTITY_ID;
51
62
  /**
@@ -63,11 +74,17 @@ export type ByIdProxy<ENTITY_ID extends ANY_ENTITY> = ENTITY_STATE<ENTITY_ID> &
63
74
  /**
64
75
  * Will resolve with the next state of the next value. No time limit
65
76
  */
66
- nextState: (timeoutMs?: number) => Promise<ENTITY_STATE<ENTITY_ID>>;
77
+ nextState: (timeout?: TOffset) => Promise<ENTITY_STATE<ENTITY_ID>>;
78
+ /**
79
+ * Runs on state change -
80
+ * If state matches the indicated target, then a timer will be initiated
81
+ * As long as the condition holds true, the callback will be executed at end of the timer
82
+ */
83
+ onStateFor: (options: OnStateForOptions<ENTITY_ID>) => RemoveCallback;
67
84
  /**
68
85
  * Will resolve when state
69
86
  */
70
- waitForState: (state: string | number, timeoutMs?: number) => Promise<ENTITY_STATE<ENTITY_ID>>;
87
+ waitForState: (state: string | number, timeout?: TOffset) => Promise<ENTITY_STATE<ENTITY_ID>>;
71
88
  /**
72
89
  * Access the immediate previous entity state
73
90
  */
@@ -1,6 +1,6 @@
1
1
  import type { TBlackHole } from "@digital-alchemy/core";
2
2
  import { is } from "@digital-alchemy/core";
3
- import type { Dayjs } from "dayjs";
3
+ import { type Dayjs } from "dayjs";
4
4
  import type { Get } from "type-fest";
5
5
 
6
6
  import type {
@@ -17,7 +17,6 @@ import {
17
17
  MockWebsocketAPI,
18
18
  MockZoneExtension,
19
19
  } from "./services/index.mts";
20
-
21
20
  export const LIB_MOCK_ASSISTANT = CreateLibrary({
22
21
  configuration: {
23
22
  EMIT_SLEEP: {
@@ -5,6 +5,7 @@ import type { PartialDeep, WritableDeep } from "type-fest";
5
5
  import type WS from "ws";
6
6
 
7
7
  import type { SocketMessageDTO } from "../../helpers/index.mts";
8
+ import { SOCKET_CONNECTED } from "../../services/websocket-api.service.mts";
8
9
 
9
10
  const CONNECTION_CLOSED = 0;
10
11
  // const CONNECTION_OPEN = 1;
@@ -13,7 +14,7 @@ const UNLIMITED = 0;
13
14
 
14
15
  export const INTERNAL_MESSAGE = "INTERNAL_MESSAGE";
15
16
 
16
- export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
17
+ export function MockWebsocketAPI({ hass, config, lifecycle, event }: TServiceParams) {
17
18
  const connection = new EventEmitter() as WritableDeep<WS>;
18
19
  connection.setMaxListeners(UNLIMITED);
19
20
  lifecycle.onShutdownStart(() => {
@@ -28,7 +29,8 @@ export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
28
29
  // connection.send = (...data) =>
29
30
 
30
31
  hass.socket.createConnection = () => {
31
- setImmediate(() => {
32
+ // Use Promise.resolve().then() instead of setImmediate for better compatibility with fake timers
33
+ Promise.resolve().then(() => {
32
34
  if (!config.mock_assistant.PASS_AUTH) {
33
35
  return;
34
36
  }
@@ -37,6 +39,40 @@ export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
37
39
  return connection;
38
40
  };
39
41
 
42
+ // Mock init - resolves immediately for tests
43
+ // The real service's init() waits for onSocketReady, but in tests we don't need that
44
+ async function init(): Promise<void> {
45
+ if (hass.socket.connection) {
46
+ return;
47
+ }
48
+ hass.socket.connection = hass.socket.createConnection("");
49
+ hass.socket.setConnectionState("connecting");
50
+
51
+ // Set up message handler - the real service's handlers registered in onPreInit
52
+ // will process messages via registerMessageHandler
53
+ connection.on("message", async (message: string) => {
54
+ try {
55
+ JSON.parse(message.toString());
56
+ // Real service's handlers will process this via onMessage/registerMessageHandler
57
+ } catch {
58
+ // Ignore parse errors
59
+ }
60
+ });
61
+
62
+ // In tests, we can resolve immediately without waiting for real socket handshake
63
+ // The real service's onBootstrap calls this, and it needs to complete for lifecycle to progress
64
+ hass.socket.setConnectionState("connected");
65
+
66
+ // Emit auth_ok to trigger the real service's handlers (registered in onPreInit)
67
+ // This allows the real service's auth_ok handler to run and do its setup
68
+ if (config.mock_assistant.PASS_AUTH) {
69
+ // Use microtask to ensure handlers are registered first
70
+ await Promise.resolve();
71
+ sendMessage({ type: "auth_ok" });
72
+ event.emit(SOCKET_CONNECTED);
73
+ }
74
+ }
75
+
40
76
  connection.send = (data: string) => {
41
77
  const payload = JSON.parse(data) as { type: string; id: number };
42
78
  connection.emit(INTERNAL_MESSAGE, payload);
@@ -49,7 +85,7 @@ export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
49
85
  return;
50
86
  }
51
87
  default: {
52
- setImmediate(() => {
88
+ Promise.resolve().then(() => {
53
89
  sendMessage({
54
90
  id: payload.id,
55
91
  result: null,
@@ -61,13 +97,19 @@ export function MockWebsocketAPI({ hass, config, lifecycle }: TServiceParams) {
61
97
  };
62
98
 
63
99
  function sendMessage(data: PartialDeep<SocketMessageDTO>) {
64
- setImmediate(() => {
100
+ // Use Promise.resolve() instead of setImmediate for better fake timer compatibility
101
+ Promise.resolve().then(() => {
65
102
  connection.emit("message", JSON.stringify(data));
66
103
  });
67
104
  }
68
105
 
69
106
  return {
107
+ attachScheduledFunctions: () => {
108
+ // Mock implementation - no-op for tests
109
+ // The real websocket service sets up intervals here, but we don't need them in tests
110
+ },
70
111
  connection: connection as WS,
112
+ init,
71
113
  onMessage<DATA extends object>(
72
114
  type: string,
73
115
  callback: (data: DATA & MessageData) => TBlackHole,
@@ -38,7 +38,9 @@ export function HassFeatureService({
38
38
  }, 0);
39
39
  }
40
40
 
41
- function lookup(input: PICK_ENTITY | ByIdProxy<PICK_ENTITY>) {
41
+ function lookup<T extends UsedSupportedFeatureDomains>(
42
+ input: PICK_ENTITY | ByIdProxy<PICK_ENTITY<T>>,
43
+ ) {
42
44
  const ref = is.string(input) ? hass.refBy.id(input) : input;
43
45
  const attributes = ref.attributes as { supported_features: number };
44
46
  return attributes?.supported_features ?? 0;
@@ -69,7 +71,9 @@ export function HassFeatureService({
69
71
  * Helper function to get all supported features as an array
70
72
  * Can accept a number (bitmask), entity ID, or entity proxy
71
73
  */
72
- function getSupportedFeatures(input: number | PICK_ENTITY | ByIdProxy<PICK_ENTITY>): number[] {
74
+ function getSupportedFeatures<T extends UsedSupportedFeatureDomains>(
75
+ input: number | PICK_ENTITY | ByIdProxy<PICK_ENTITY<T>>,
76
+ ): number[] {
73
77
  const features = is.number(input) ? input : lookup(input);
74
78
 
75
79
  const supported: number[] = [];
@@ -84,10 +88,9 @@ export function HassFeatureService({
84
88
  }
85
89
 
86
90
  function listEntityFeatures<
87
- DOMAIN extends UsedSupportedFeatureDomains = UsedSupportedFeatureDomains,
88
- >(
89
- input: PICK_ENTITY<DOMAIN> | ByIdProxy<PICK_ENTITY<DOMAIN>>,
90
- ): SupportedEntityFeatures<DOMAIN>[] {
91
+ DOMAIN extends UsedSupportedFeatureDomains,
92
+ ENTITY extends PICK_ENTITY<DOMAIN> = PICK_ENTITY<DOMAIN>,
93
+ >(input: ENTITY | ByIdProxy<ENTITY>): SupportedEntityFeatures<DOMAIN>[] {
91
94
  const inputDomain = domain(is.string(input) ? input : input.entity_id);
92
95
  const domainFeatures = SUPPORTED_FEATURES[inputDomain.toUpperCase() as SupportedFeatureDomains];
93
96
  if (!domainFeatures) {
@@ -1,4 +1,4 @@
1
- import type { TAnyFunction, TServiceParams } from "@digital-alchemy/core";
1
+ import type { TAnyFunction, TOffset, TServiceParams } from "@digital-alchemy/core";
2
2
  import { DOWN, NONE, sleep, UP } from "@digital-alchemy/core";
3
3
  import type { Dayjs } from "dayjs";
4
4
  import dayjs from "dayjs";
@@ -9,6 +9,7 @@ import type {
9
9
  ByIdProxy,
10
10
  ENTITY_STATE,
11
11
  HassReferenceService,
12
+ OnStateForOptions,
12
13
  RemoveCallback,
13
14
  } from "../helpers/index.mts";
14
15
  import { domain, perf } from "../helpers/index.mts";
@@ -86,6 +87,7 @@ export function ReferenceService({
86
87
  hass,
87
88
  logger,
88
89
  internal,
90
+ scheduler,
89
91
  event,
90
92
  }: TServiceParams): HassReferenceService {
91
93
  const { is } = internal.utils;
@@ -94,6 +96,9 @@ export function ReferenceService({
94
96
  entity: ENTITY,
95
97
  property: PROPERTY,
96
98
  ): Get<ENTITY_STATE<ENTITY>, PROPERTY> {
99
+ if (!is.string(property)) {
100
+ return undefined;
101
+ }
97
102
  const valid = ["state", "attributes", "last"].some(i => property.startsWith(i));
98
103
  if (!valid) {
99
104
  logger.error({ entity, name: proxyGetLogic, property }, `invalid property lookup`);
@@ -134,6 +139,7 @@ export function ReferenceService({
134
139
  "last",
135
140
  "nextState",
136
141
  "once",
142
+ "onStateFor",
137
143
  "onUpdate",
138
144
  "previous",
139
145
  "removeAllListeners",
@@ -170,8 +176,62 @@ export function ReferenceService({
170
176
  // things that shouldn't be needed: this extract
171
177
  // eslint-disable-next-line sonarjs/function-return-type
172
178
  get: (_, property: Extract<keyof ByIdProxy<ENTITY_ID>, string>) => {
179
+ // Handle Symbol properties (e.g., when vitest formats test output)
180
+ if (!is.string(property)) {
181
+ return undefined;
182
+ }
173
183
  hass.diagnostics.reference?.get_property.publish({ entity_id, property });
174
184
  switch (property) {
185
+ // #MARK: runAfter
186
+ case "onStateFor": {
187
+ return function ({
188
+ context,
189
+ ...options
190
+ }: OnStateForOptions<ENTITY_ID>): RemoveCallback {
191
+ let timerRemove: RemoveCallback;
192
+ const remove = proxy.onUpdate((new_state, old_state) => {
193
+ const matches = options.matches
194
+ ? options.matches(new_state, old_state)
195
+ : options.state === new_state.state;
196
+ if (!matches) {
197
+ if (timerRemove) {
198
+ timerRemove();
199
+ timerRemove = undefined;
200
+ logger.trace({ context, entity_id }, "cleared timer - state no longer matches");
201
+ }
202
+ return;
203
+ }
204
+
205
+ if (timerRemove) {
206
+ logger.trace({ context, entity_id }, "timer already running, skipping");
207
+ return;
208
+ }
209
+ timerRemove = scheduler.setTimeout(async () => {
210
+ logger.trace(
211
+ { context, entity_id, for: options.for },
212
+ "timer fired - executing callback",
213
+ );
214
+ internal.safeExec({
215
+ context,
216
+ exec: async () => await options.exec(proxy),
217
+ });
218
+ }, options.for);
219
+ logger.trace(
220
+ { context, entity_id, for: options.for },
221
+ "started timer for state condition",
222
+ );
223
+ });
224
+
225
+ return internal.removeFn(() => {
226
+ if (timerRemove) {
227
+ timerRemove();
228
+ }
229
+ remove();
230
+ logger.trace({ context, entity_id }, "removed [onStateFor] listener");
231
+ });
232
+ };
233
+ }
234
+
175
235
  // #MARK: onUpdate
176
236
  case "onUpdate": {
177
237
  return (callback: TAnyFunction) => {
@@ -246,7 +306,7 @@ export function ReferenceService({
246
306
 
247
307
  // #MARK: nextState
248
308
  case "nextState": {
249
- return async (timeout?: number) =>
309
+ return async (timeout?: TOffset) =>
250
310
  await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
251
311
  // - set up cleanup function
252
312
  const remove = () => {
@@ -274,9 +334,13 @@ export function ReferenceService({
274
334
 
275
335
  // - race!
276
336
  let wait: ReturnType<typeof sleep>;
277
- if (is.number(timeout) && timeout > NONE) {
337
+ if (is.undefined(timeout)) {
338
+ return;
339
+ }
340
+ const duration = internal.utils.getIntervalMs(timeout);
341
+ if (duration > NONE) {
278
342
  // keep track of sleep so it can be cleaned up also
279
- wait = sleep(timeout);
343
+ wait = sleep(duration);
280
344
  await wait;
281
345
  wait = undefined;
282
346
  if (done) {
@@ -290,7 +354,7 @@ export function ReferenceService({
290
354
 
291
355
  // #MARK: waitForState
292
356
  case "waitForState": {
293
- return async (state: string | number, timeout?: number) =>
357
+ return async (state: string | number, timeout?: TOffset) =>
294
358
  await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
295
359
  const remove = () => {
296
360
  done = undefined;
@@ -326,8 +390,12 @@ export function ReferenceService({
326
390
 
327
391
  event.on(entity_id, complete);
328
392
  let wait: ReturnType<typeof sleep>;
329
- if (is.number(timeout) && timeout > NONE) {
330
- wait = sleep(timeout);
393
+ if (is.undefined(timeout)) {
394
+ return;
395
+ }
396
+ const duration = internal.utils.getIntervalMs(timeout);
397
+ if (duration > NONE) {
398
+ wait = sleep(duration);
331
399
  await wait;
332
400
  wait = undefined;
333
401
  if (done) {
@@ -242,8 +242,23 @@ describe("Entity", () => {
242
242
  const spy = vi.fn();
243
243
  subscribe(hass.diagnostics.entity.refresh_entities.name, spy);
244
244
 
245
+ // Mock getAllEntities to return entities so refresh doesn't call process.exit()
246
+ vi.spyOn(hass.fetch, "getAllEntities").mockResolvedValue([
247
+ {
248
+ attributes: {},
249
+ context: { id: "test", parent_id: null, user_id: null },
250
+ entity_id: "sensor.magic",
251
+ last_changed: dayjs(),
252
+ last_reported: dayjs(),
253
+ last_updated: dayjs(),
254
+ state: "unavailable",
255
+ } as ENTITY_STATE<ANY_ENTITY>,
256
+ ]);
257
+
245
258
  lifecycle.onReady(async () => {
246
259
  await hass.entity.refresh();
260
+ // Wait for setImmediate to complete
261
+ await sleep(10);
247
262
  expect(spy).toHaveBeenCalledWith(
248
263
  expect.objectContaining({
249
264
  emitUpdates: [],