@digital-alchemy/hass 24.9.4 → 24.9.5

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 (82) hide show
  1. package/README.md +1 -1
  2. package/dist/helpers/notify.helper.d.ts +2 -2
  3. package/package.json +16 -14
  4. package/scripts/mock-assistant.sh +5 -0
  5. package/scripts/run-e2e.sh +7 -0
  6. package/scripts/test.sh +2 -0
  7. package/src/dynamic.ts +4254 -0
  8. package/src/extensions/area.extension.ts +118 -0
  9. package/src/extensions/backup.extension.ts +63 -0
  10. package/src/extensions/call-proxy.extension.ts +113 -0
  11. package/src/extensions/config.extension.ts +119 -0
  12. package/src/extensions/conversation.extension.ts +46 -0
  13. package/src/extensions/device.extension.ts +56 -0
  14. package/src/extensions/entity.extension.ts +344 -0
  15. package/src/extensions/events.extension.ts +25 -0
  16. package/src/extensions/fetch-api.extension.ts +269 -0
  17. package/src/extensions/floor.extension.ts +76 -0
  18. package/src/extensions/id-by.extension.ts +157 -0
  19. package/src/extensions/index.ts +16 -0
  20. package/src/extensions/internal.extension.ts +145 -0
  21. package/src/extensions/label.extension.ts +83 -0
  22. package/src/extensions/reference.extension.ts +330 -0
  23. package/src/extensions/registry.extension.ts +44 -0
  24. package/src/extensions/websocket-api.extension.ts +554 -0
  25. package/src/extensions/zone.extension.ts +69 -0
  26. package/src/hass.module.ts +217 -0
  27. package/src/helpers/backup.helper.ts +11 -0
  28. package/src/helpers/constants.helper.ts +30 -0
  29. package/src/helpers/device.helper.ts +25 -0
  30. package/src/helpers/entity-state.helper.ts +171 -0
  31. package/src/helpers/features.helper.ts +580 -0
  32. package/src/helpers/fetch/calendar.ts +54 -0
  33. package/src/helpers/fetch/configuration.ts +75 -0
  34. package/src/helpers/fetch/index.ts +5 -0
  35. package/src/helpers/fetch/server-log.ts +28 -0
  36. package/src/helpers/fetch/service-list.ts +64 -0
  37. package/src/helpers/fetch/weather-forecasts.ts +86 -0
  38. package/src/helpers/fetch.helper.ts +328 -0
  39. package/src/helpers/id-by.helper.ts +53 -0
  40. package/src/helpers/index.ts +13 -0
  41. package/src/helpers/interfaces.helper.ts +340 -0
  42. package/src/helpers/manifest.helper.ts +0 -0
  43. package/src/helpers/notify.helper.ts +302 -0
  44. package/src/helpers/registry.ts +281 -0
  45. package/src/helpers/utility.helper.ts +147 -0
  46. package/src/helpers/websocket.helper.ts +117 -0
  47. package/src/index.ts +5 -0
  48. package/src/mock_assistant/extensions/area.extension.ts +62 -0
  49. package/src/mock_assistant/extensions/config.extension.ts +33 -0
  50. package/src/mock_assistant/extensions/device.extension.ts +44 -0
  51. package/src/mock_assistant/extensions/entity-registry.extension.ts +41 -0
  52. package/src/mock_assistant/extensions/entity.extension.ts +114 -0
  53. package/src/mock_assistant/extensions/events.extension.ts +37 -0
  54. package/src/mock_assistant/extensions/fetch.extension.ts +3 -0
  55. package/src/mock_assistant/extensions/fixtures.extension.ts +79 -0
  56. package/src/mock_assistant/extensions/floor.extension.ts +64 -0
  57. package/src/mock_assistant/extensions/index.ts +12 -0
  58. package/src/mock_assistant/extensions/label.extension.ts +64 -0
  59. package/src/mock_assistant/extensions/services.extension.ts +25 -0
  60. package/src/mock_assistant/extensions/websocket-api.extension.ts +84 -0
  61. package/src/mock_assistant/extensions/zone.extension.ts +65 -0
  62. package/src/mock_assistant/helpers/fixtures.ts +22 -0
  63. package/src/mock_assistant/helpers/index.ts +1 -0
  64. package/src/mock_assistant/index.ts +3 -0
  65. package/src/mock_assistant/main.ts +46 -0
  66. package/src/mock_assistant/mock-assistant.module.ts +90 -0
  67. package/src/quickboot.module.ts +23 -0
  68. package/src/testing/area.spec.ts +189 -0
  69. package/src/testing/backup.spec.ts +157 -0
  70. package/src/testing/config.spec.ts +188 -0
  71. package/src/testing/device.spec.ts +89 -0
  72. package/src/testing/entity.spec.ts +171 -0
  73. package/src/testing/events.spec.ts +78 -0
  74. package/src/testing/fetch-api.spec.ts +410 -0
  75. package/src/testing/fixtures.spec.ts +158 -0
  76. package/src/testing/floor.spec.ts +186 -0
  77. package/src/testing/id-by.spec.ts +140 -0
  78. package/src/testing/label.spec.ts +186 -0
  79. package/src/testing/ref-by.spec.ts +300 -0
  80. package/src/testing/websocket.spec.ts +63 -0
  81. package/src/testing/workflow.spec.ts +195 -0
  82. package/src/testing/zone.spec.ts +109 -0
@@ -0,0 +1,330 @@
1
+ import { DOWN, is, NONE, sleep, TAnyFunction, TServiceParams, UP } from "@digital-alchemy/core";
2
+ import dayjs, { Dayjs } from "dayjs";
3
+ import { Get } from "type-fest";
4
+
5
+ import { SERVICE_LIST_UPDATED } from "..";
6
+ import {
7
+ TAreaId,
8
+ TDeviceId,
9
+ TFloorId,
10
+ TLabelId,
11
+ TPlatformId,
12
+ TRawDomains,
13
+ TUniqueId,
14
+ TUniqueIDMapping,
15
+ } from "../dynamic";
16
+ import {
17
+ ALL_SERVICE_DOMAINS,
18
+ ANY_ENTITY,
19
+ ByIdProxy,
20
+ domain,
21
+ ENTITY_STATE,
22
+ HassReferenceService,
23
+ PICK_ENTITY,
24
+ PICK_FROM_AREA,
25
+ PICK_FROM_DEVICE,
26
+ PICK_FROM_FLOOR,
27
+ PICK_FROM_LABEL,
28
+ PICK_FROM_PLATFORM,
29
+ } from "../helpers";
30
+
31
+ export function ReferenceExtension({
32
+ hass,
33
+ logger,
34
+ internal,
35
+ event,
36
+ }: TServiceParams): HassReferenceService {
37
+ const ENTITY_PROXIES = new Map<ANY_ENTITY, ByIdProxy<ANY_ENTITY>>();
38
+ // #MARK:proxyGetLogic
39
+ function proxyGetLogic<ENTITY extends ANY_ENTITY = ANY_ENTITY, PROPERTY extends string = string>(
40
+ entity: ENTITY,
41
+ property: PROPERTY,
42
+ ): Get<ENTITY_STATE<ENTITY>, PROPERTY> {
43
+ const valid = ["state", "attributes", "last"].some(i => property.startsWith(i));
44
+ if (!valid) {
45
+ logger.error({ entity, name: proxyGetLogic, property }, `invalid property lookup`);
46
+ return undefined;
47
+ }
48
+ const current = hass.entity.getCurrentState(entity);
49
+ if (!current) {
50
+ logger.error({ name: entity, property }, `proxyGetLogic cannot find entity`);
51
+ }
52
+ if (property.startsWith("last")) {
53
+ const value = internal.utils.object.get(current, property) as string;
54
+ return dayjs(value) as Get<ENTITY_STATE<ENTITY>, PROPERTY>;
55
+ }
56
+ if (property === "state") {
57
+ if (domain(entity) === "sensor" && is.number(Number(current.state))) {
58
+ return Number(current.state) as Get<ENTITY_STATE<ENTITY>, PROPERTY>;
59
+ }
60
+ return current.state as Get<ENTITY_STATE<ENTITY>, PROPERTY>;
61
+ }
62
+
63
+ return (current.attributes || {}) as Get<ENTITY_STATE<ENTITY>, PROPERTY>;
64
+ }
65
+
66
+ // #MARK: byId
67
+ function byId<ENTITY_ID extends ANY_ENTITY>(entity_id: ENTITY_ID): ByIdProxy<ENTITY_ID> {
68
+ const entity_domain = domain(entity_id) as ALL_SERVICE_DOMAINS;
69
+ if (!ENTITY_PROXIES.has(entity_id)) {
70
+ const { ...thing } = hass.entity.getCurrentState(entity_id) as ByIdProxy<ENTITY_ID>;
71
+ let loaded = false;
72
+
73
+ function keys() {
74
+ const entityDomain = domain(entity_id);
75
+ return [
76
+ "attributes",
77
+ "entity_id",
78
+ "history",
79
+ "last",
80
+ "nextState",
81
+ "once",
82
+ "onUpdate",
83
+ "previous",
84
+ "removeAllListeners",
85
+ "state",
86
+ "waitForState",
87
+ ...hass.configure
88
+ .getServices()
89
+ .filter(({ domain }) => domain === entityDomain)
90
+ .flatMap(i => Object.keys(i.services))
91
+ .sort((a, b) => (a > b ? UP : DOWN)),
92
+ ];
93
+ }
94
+ function appendKeys(force = false) {
95
+ if (loaded && !force) {
96
+ return;
97
+ }
98
+ // Not gonna build types for this, and ts-expect-error fails in jest
99
+ // This is a weird hack for an obscure feature, so sue me
100
+ //
101
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
102
+ // @ts-ignore
103
+ keys().forEach(i => (thing[i] ??= () => {}));
104
+ if (!is.empty(hass.configure.getServices())) {
105
+ loaded = true;
106
+ }
107
+ }
108
+ event.on(SERVICE_LIST_UPDATED, () => appendKeys(true));
109
+ ENTITY_PROXIES.set(
110
+ entity_id,
111
+ // just because you can't do generics properly....
112
+ new Proxy(thing, {
113
+ // things that shouldn't be needed: this extract
114
+ // eslint-disable-next-line sonarjs/function-return-type
115
+ get: (_, property: Extract<keyof ByIdProxy<ENTITY_ID>, string>) => {
116
+ switch (property) {
117
+ // * onUpdate
118
+ case "onUpdate": {
119
+ return (callback: TAnyFunction) => {
120
+ const removableCallback = async (
121
+ a: ENTITY_STATE<ENTITY_ID>,
122
+ b: ENTITY_STATE<ENTITY_ID>,
123
+ ) => await internal.safeExec(async () => callback(a, b, remove));
124
+ function remove() {
125
+ event.removeListener(entity_id, removableCallback);
126
+ }
127
+
128
+ event.on(entity_id, removableCallback);
129
+ return { remove };
130
+ };
131
+ }
132
+
133
+ // * removeAllListeners
134
+ case "removeAllListeners": {
135
+ return function () {
136
+ event.removeAllListeners(entity_id);
137
+ };
138
+ }
139
+
140
+ // * history
141
+ case "history": {
142
+ return async function (from: Dayjs | Date, to: Dayjs | Date) {
143
+ return await hass.fetch.fetchEntityHistory(entity_id, from, to);
144
+ };
145
+ }
146
+
147
+ // * once
148
+ case "once": {
149
+ return (callback: TAnyFunction) =>
150
+ event.once(entity_id, async (a, b) => callback(a, b));
151
+ }
152
+
153
+ // * entity_id
154
+ case "entity_id": {
155
+ return entity_id;
156
+ }
157
+
158
+ // * previous
159
+ case "previous": {
160
+ return hass.entity.previousState(entity_id);
161
+ }
162
+
163
+ // * nextState
164
+ case "nextState": {
165
+ return async (timeout?: number) =>
166
+ await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
167
+ const complete = (entity: ENTITY_STATE<ENTITY_ID>) => {
168
+ if (done) {
169
+ done(entity satisfies ENTITY_STATE<ENTITY_ID>);
170
+ done = undefined;
171
+ }
172
+ };
173
+ event.once(entity_id, complete);
174
+ if (is.number(timeout) && timeout > NONE) {
175
+ await sleep(timeout);
176
+ if (done) {
177
+ logger.debug({ entity_id, name: "nextState", timeout }, "timed out");
178
+ done(undefined);
179
+ done = undefined;
180
+ event.removeListener(entity_id, complete);
181
+ }
182
+ }
183
+ });
184
+ }
185
+
186
+ // * waitForState
187
+ case "waitForState": {
188
+ return async (state: string | number, timeout?: number) =>
189
+ await new Promise<ENTITY_STATE<ENTITY_ID>>(async done => {
190
+ const complete = (entity: ENTITY_STATE<ENTITY_ID>) => {
191
+ if (entity.state !== state) {
192
+ logger.trace(
193
+ {
194
+ expected: state,
195
+ incoming: entity.state,
196
+ name: "waitForState",
197
+ },
198
+ `state did not match`,
199
+ );
200
+ return;
201
+ }
202
+ if (done) {
203
+ done(entity satisfies ENTITY_STATE<ENTITY_ID>);
204
+ done = undefined;
205
+ event.removeListener(entity_id, complete);
206
+ }
207
+ };
208
+ event.on(entity_id, complete);
209
+ if (is.number(timeout) && timeout > NONE) {
210
+ await sleep(timeout);
211
+ if (done) {
212
+ logger.debug({ entity_id, name: "waitForState", timeout }, "timed out");
213
+ done(undefined);
214
+ done = undefined;
215
+ event.removeListener(entity_id, complete);
216
+ }
217
+ }
218
+ });
219
+ }
220
+ }
221
+ if (hass.configure.isService(entity_domain, property)) {
222
+ return async function (data = {}) {
223
+ // @ts-expect-error it's fine
224
+ return await hass.call[entity_domain][property]({
225
+ entity_id,
226
+ ...data,
227
+ });
228
+ };
229
+ }
230
+ return proxyGetLogic(entity_id, property);
231
+ },
232
+ has(_, property: string) {
233
+ appendKeys();
234
+ return property in thing;
235
+ },
236
+ ownKeys() {
237
+ appendKeys();
238
+ return Object.keys(thing);
239
+ },
240
+ set(_, property: Extract<keyof ByIdProxy<ENTITY_ID>, string>, value: unknown) {
241
+ // * state
242
+ if (property === "state") {
243
+ setImmediate(async () => {
244
+ logger.debug({ entity_id, state: value }, `emitting set state via rest`);
245
+ await hass.fetch.updateEntity(entity_id, {
246
+ state: value as string | number,
247
+ });
248
+ });
249
+ return true;
250
+ }
251
+ // * attributes
252
+ if (property === "attributes") {
253
+ if (!is.object(value)) {
254
+ logger.error(`can only provide objects as attributes`);
255
+ return false;
256
+ }
257
+ setImmediate(async () => {
258
+ logger.debug(
259
+ { attributes: Object.keys(value), entity_id },
260
+ `updating attributes via rest`,
261
+ );
262
+ await hass.fetch.updateEntity(entity_id, {
263
+ attributes: value,
264
+ });
265
+ });
266
+ return true;
267
+ }
268
+ logger.error({ entity_id, property }, `cannot set property on entity`);
269
+ return false;
270
+ },
271
+ }),
272
+ );
273
+ }
274
+ return ENTITY_PROXIES.get(entity_id) as ByIdProxy<ENTITY_ID>;
275
+ }
276
+
277
+ return {
278
+ area: <AREA extends TAreaId, DOMAINS extends TRawDomains = TRawDomains>(
279
+ area: AREA,
280
+ ...domains: DOMAINS[]
281
+ ): ByIdProxy<PICK_FROM_AREA<AREA, DOMAINS>>[] =>
282
+ hass.idBy.area<AREA, DOMAINS>(area, ...domains).map(id => byId(id)),
283
+
284
+ device: <DEVICE extends TDeviceId, DOMAINS extends TRawDomains = TRawDomains>(
285
+ device: DEVICE,
286
+ ...domains: DOMAINS[]
287
+ ): ByIdProxy<PICK_FROM_DEVICE<DEVICE, DOMAINS>>[] =>
288
+ hass.idBy.device<DEVICE, DOMAINS>(device, ...domains).map(id => byId(id)),
289
+
290
+ domain: <DOMAIN extends TRawDomains = TRawDomains>(
291
+ domain: DOMAIN,
292
+ ): ByIdProxy<PICK_ENTITY<DOMAIN>>[] => hass.idBy.domain<DOMAIN>(domain).map(id => byId(id)),
293
+
294
+ floor: <FLOOR extends TFloorId, DOMAINS extends TRawDomains = TRawDomains>(
295
+ floor: FLOOR,
296
+ ...domains: DOMAINS[]
297
+ ): ByIdProxy<PICK_FROM_FLOOR<FLOOR, DOMAINS>>[] =>
298
+ hass.idBy.floor<FLOOR, DOMAINS>(floor, ...domains).map(id => byId(id)),
299
+
300
+ id: byId,
301
+
302
+ label: <LABEL extends TLabelId, DOMAINS extends TRawDomains = TRawDomains>(
303
+ label: LABEL,
304
+ ...domains: DOMAINS[]
305
+ ): ByIdProxy<PICK_FROM_LABEL<LABEL, DOMAINS>>[] =>
306
+ hass.idBy.label<LABEL, DOMAINS>(label, ...domains).map(id => byId(id)),
307
+
308
+ platform: <PLATFORM extends TPlatformId, DOMAINS extends TRawDomains = TRawDomains>(
309
+ platform: PLATFORM,
310
+ ...domains: DOMAINS[]
311
+ ): ByIdProxy<PICK_FROM_PLATFORM<PLATFORM, DOMAINS>>[] =>
312
+ hass.idBy.platform<PLATFORM, DOMAINS>(platform, ...domains).map(id => byId(id)),
313
+
314
+ unique_id: <
315
+ UNIQUE_ID extends TUniqueId,
316
+ ENTITY_ID extends Extract<TUniqueIDMapping[UNIQUE_ID], ANY_ENTITY> = Extract<
317
+ TUniqueIDMapping[UNIQUE_ID],
318
+ ANY_ENTITY
319
+ >,
320
+ >(
321
+ unique_id: UNIQUE_ID,
322
+ ): ByIdProxy<ENTITY_ID> => {
323
+ const id = hass.idBy.unique_id<UNIQUE_ID, ENTITY_ID>(unique_id);
324
+ if (!id) {
325
+ return undefined;
326
+ }
327
+ return byId(id);
328
+ },
329
+ };
330
+ }
@@ -0,0 +1,44 @@
1
+ import { TServiceParams } from "@digital-alchemy/core";
2
+
3
+ import {
4
+ ConfigEntry,
5
+ HassConfig,
6
+ HassRegistryService,
7
+ ManifestItem,
8
+ UpdateCoreOptions,
9
+ ZoneDetails,
10
+ } from "../helpers";
11
+
12
+ export function Registry({ hass }: TServiceParams): HassRegistryService {
13
+ async function ManifestList() {
14
+ return await hass.socket.sendMessage<ManifestItem[]>({
15
+ type: "manifest/list",
16
+ });
17
+ }
18
+
19
+ async function UpdateCore(options: UpdateCoreOptions) {
20
+ await hass.socket.sendMessage<ZoneDetails[]>({
21
+ ...options,
22
+ type: "config/core/update",
23
+ });
24
+ }
25
+
26
+ async function GetConfig() {
27
+ return await hass.socket.sendMessage<HassConfig>({
28
+ type: "get_config",
29
+ });
30
+ }
31
+
32
+ async function GetConfigEntries() {
33
+ return await hass.socket.sendMessage<ConfigEntry[]>({
34
+ type: "config_entries/get",
35
+ });
36
+ }
37
+
38
+ return {
39
+ getConfig: GetConfig,
40
+ getConfigEntries: GetConfigEntries,
41
+ manifestList: ManifestList,
42
+ updateCore: UpdateCore,
43
+ };
44
+ }