@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,157 @@
1
+ import { is, TServiceParams } from "@digital-alchemy/core";
2
+
3
+ import {
4
+ TAreaId,
5
+ TDeviceId,
6
+ TFloorId,
7
+ TLabelId,
8
+ TPlatformId,
9
+ TUniqueId,
10
+ TUniqueIDMapping,
11
+ } from "../dynamic";
12
+ import {
13
+ ALL_DOMAINS,
14
+ ANY_ENTITY,
15
+ EntityRegistryItem,
16
+ IDByInterface,
17
+ PICK_ENTITY,
18
+ PICK_FROM_AREA,
19
+ PICK_FROM_DEVICE,
20
+ PICK_FROM_FLOOR,
21
+ PICK_FROM_LABEL,
22
+ PICK_FROM_PLATFORM,
23
+ } from "../helpers";
24
+
25
+ const check = <RAW extends ANY_ENTITY>(raw: RAW[], domains: ALL_DOMAINS[]) => {
26
+ if (!is.empty(domains)) {
27
+ raw = raw.filter(entity => is.domain(entity, domains));
28
+ }
29
+ return raw;
30
+ };
31
+
32
+ export function IDByExtension({ hass, logger }: TServiceParams): IDByInterface {
33
+ // * byDomain
34
+ function byDomain<DOMAIN extends ALL_DOMAINS>(domain: DOMAIN) {
35
+ const MASTER_STATE = hass.entity._masterState();
36
+ return Object.keys(MASTER_STATE[domain] ?? {}).map(
37
+ id => `${domain}.${id}` as PICK_ENTITY<DOMAIN>,
38
+ );
39
+ }
40
+
41
+ // * unique_id
42
+ function unique_id<
43
+ UNIQUE_ID extends TUniqueId,
44
+ ENTITY_ID extends Extract<TUniqueIDMapping[UNIQUE_ID], ANY_ENTITY> = Extract<
45
+ TUniqueIDMapping[UNIQUE_ID],
46
+ ANY_ENTITY
47
+ >,
48
+ >(unique_id: UNIQUE_ID): ENTITY_ID {
49
+ hass.entity.warnEarly("byUniqueId");
50
+ const entity = hass.entity.registry.current.find(
51
+ i => i.unique_id === unique_id,
52
+ ) as EntityRegistryItem<ENTITY_ID>;
53
+ if (!entity) {
54
+ logger.error({ name: unique_id, unique_id }, `could not find an entity`);
55
+ return undefined;
56
+ }
57
+ return entity?.entity_id;
58
+ }
59
+
60
+ // * label
61
+ function label<LABEL extends TLabelId, DOMAIN extends ALL_DOMAINS>(
62
+ label: LABEL,
63
+ ...domains: DOMAIN[]
64
+ ) {
65
+ hass.entity.warnEarly("label");
66
+ return check(
67
+ hass.entity.registry.current
68
+ .filter(i => i.labels.includes(label))
69
+ .map(i => i.entity_id as PICK_FROM_LABEL<LABEL, DOMAIN>),
70
+ domains,
71
+ );
72
+ }
73
+
74
+ // * area
75
+ function area<AREA extends TAreaId, DOMAIN extends ALL_DOMAINS>(
76
+ area: AREA,
77
+ ...domains: DOMAIN[]
78
+ ) {
79
+ hass.entity.warnEarly("area");
80
+
81
+ // find entities are associated with the area directly
82
+ const fromEntity = hass.entity.registry.current
83
+ .filter(i => i.area_id === area)
84
+ .map(i => i.entity_id);
85
+
86
+ // identify devices
87
+ const devices = new Set(
88
+ hass.device.current.filter(device => device.area_id === area).map(i => i.id),
89
+ );
90
+
91
+ // extract entities associated with device, that have not been assigned to a room
92
+ const fromDevice = hass.entity.registry.current
93
+ .filter(entity => devices.has(entity.device_id) && is.empty(entity.area_id))
94
+ .map(i => i.entity_id);
95
+
96
+ return check(
97
+ // merge lists
98
+ is.unique([...fromEntity, ...fromDevice]),
99
+ domains,
100
+ ) as PICK_FROM_AREA<AREA, DOMAIN>[];
101
+ }
102
+
103
+ // * device
104
+ function device<DEVICE extends TDeviceId, DOMAIN extends ALL_DOMAINS>(
105
+ device: DEVICE,
106
+ ...domains: DOMAIN[]
107
+ ): PICK_FROM_DEVICE<DEVICE, DOMAIN>[] {
108
+ hass.entity.warnEarly("device");
109
+ return check(
110
+ hass.entity.registry.current
111
+ .filter(i => i.device_id === device)
112
+ .map(i => i.entity_id as PICK_FROM_DEVICE<DEVICE, DOMAIN>),
113
+ domains,
114
+ );
115
+ }
116
+
117
+ // * floor
118
+ function floor<FLOOR extends TFloorId, DOMAIN extends ALL_DOMAINS>(
119
+ floor: FLOOR,
120
+ ...domains: DOMAIN[]
121
+ ): PICK_FROM_FLOOR<FLOOR, DOMAIN>[] {
122
+ hass.entity.warnEarly("floor");
123
+ const areas = new Set<TAreaId>(
124
+ hass.area.current.filter(i => i.floor_id === floor).map(i => i.area_id),
125
+ );
126
+ return check(
127
+ hass.entity.registry.current
128
+ .filter(i => areas.has(i.area_id))
129
+ .map(i => i.entity_id as PICK_FROM_FLOOR<FLOOR, DOMAIN>),
130
+ domains,
131
+ );
132
+ }
133
+
134
+ // * platform
135
+ function platform<PLATFORM extends TPlatformId, DOMAIN extends ALL_DOMAINS>(
136
+ platform: PLATFORM,
137
+ ...domains: DOMAIN[]
138
+ ): PICK_FROM_PLATFORM<PLATFORM, DOMAIN>[] {
139
+ hass.entity.warnEarly("platform");
140
+ return check(
141
+ hass.entity.registry.current
142
+ .filter(i => i.platform === platform)
143
+ .map(i => i.entity_id as PICK_FROM_PLATFORM<PLATFORM, DOMAIN>),
144
+ domains,
145
+ );
146
+ }
147
+
148
+ return {
149
+ area,
150
+ device,
151
+ domain: byDomain,
152
+ floor,
153
+ label,
154
+ platform,
155
+ unique_id,
156
+ };
157
+ }
@@ -0,0 +1,16 @@
1
+ export * from "./area.extension";
2
+ export * from "./backup.extension";
3
+ export * from "./call-proxy.extension";
4
+ export * from "./config.extension";
5
+ export * from "./device.extension";
6
+ export * from "./entity.extension";
7
+ export * from "./events.extension";
8
+ export * from "./fetch-api.extension";
9
+ export * from "./floor.extension";
10
+ export * from "./id-by.extension";
11
+ export * from "./internal.extension";
12
+ export * from "./label.extension";
13
+ export * from "./reference.extension";
14
+ export * from "./registry.extension";
15
+ export * from "./websocket-api.extension";
16
+ export * from "./zone.extension";
@@ -0,0 +1,145 @@
1
+ import { FIRST, InternalError, is, TServiceParams } from "@digital-alchemy/core";
2
+ import { createWriteStream } from "fs";
3
+ import { pipeline } from "stream";
4
+ import { promisify } from "util";
5
+
6
+ import {
7
+ buildFilterString,
8
+ DownloadOptions,
9
+ FetchArguments,
10
+ FetcherOptions,
11
+ FetchProcessTypes,
12
+ FetchWith,
13
+ MaybeHttpError,
14
+ TFetchBody,
15
+ } from "../helpers";
16
+
17
+ const streamPipeline = promisify(pipeline);
18
+
19
+ export function FetchInternals({ logger, context: parentContext }: TServiceParams) {
20
+ return ({ headers: base_headers, baseUrl: base_url, context: logContext }: FetcherOptions) => {
21
+ const capabilities: string[] = [];
22
+
23
+ if (!is.empty(capabilities)) {
24
+ logger.trace({ capabilities, name: logContext }, `initialized fetcher`);
25
+ }
26
+
27
+ function checkForHttpErrors<T extends unknown = unknown>(maybeError: MaybeHttpError): T {
28
+ if (
29
+ is.object(maybeError) &&
30
+ maybeError !== null &&
31
+ is.number(maybeError.statusCode) &&
32
+ is.string(maybeError.error)
33
+ ) {
34
+ // Log the error if needed
35
+ logger.error({ error: maybeError, name: logContext }, maybeError.message);
36
+
37
+ // Throw a FetchRequestError
38
+ // throw new FetchRequestError(maybeError);
39
+ throw new InternalError(logContext || parentContext, maybeError.error, maybeError.message);
40
+ }
41
+
42
+ return maybeError as T;
43
+ }
44
+
45
+ // #MARK: fetchHandleResponse
46
+ async function fetchHandleResponse<T extends unknown = unknown>(
47
+ process: FetchProcessTypes,
48
+ response: Response,
49
+ ): Promise<T> {
50
+ if (process === false || process === "raw") {
51
+ return response as T;
52
+ }
53
+ const text = await response.text();
54
+ if (process === "text") {
55
+ return text as unknown as T;
56
+ }
57
+ if (!["{", "["].includes(text.charAt(FIRST))) {
58
+ if (["OK"].includes(text)) {
59
+ logger.debug({ name: logContext, text }, "full response text");
60
+ } else {
61
+ // It's probably a coding error error, and not something a user did.
62
+ // Will try to keep the array up to date if any other edge cases pop up
63
+ logger.warn({ name: logContext, text }, `unexpected api Response`);
64
+ }
65
+ return text as T;
66
+ }
67
+ const parsed = JSON.parse(text);
68
+ return checkForHttpErrors<T>(parsed);
69
+ }
70
+
71
+ function fetchCreateUrl({ rawUrl, url, ...fetchWith }: FetchWith): string {
72
+ let out = url || "";
73
+ if (!rawUrl) {
74
+ const base = fetchWith.baseUrl || fetchWrapper.base_url;
75
+ out = base + url;
76
+ }
77
+ if (!is.empty(fetchWith.params)) {
78
+ out = `${out}?${buildFilterString(fetchWith)}`;
79
+ }
80
+ return out;
81
+ }
82
+
83
+ // #MARK: execFetch
84
+ async function exec<T, BODY extends TFetchBody = undefined>({
85
+ body,
86
+ headers = {},
87
+ method = "get",
88
+ process,
89
+ ...fetchWith
90
+ }: Partial<FetchArguments<BODY>>) {
91
+ const contentType = is.object(body) ? { "Content-Type": "application/json" } : {};
92
+ const result = await global.fetch(fetchCreateUrl(fetchWith), {
93
+ body: is.object(body) ? JSON.stringify(body) : body,
94
+ headers: {
95
+ ...contentType,
96
+ ...fetchWrapper.base_headers,
97
+ ...headers,
98
+ },
99
+ method,
100
+ });
101
+ return await fetchHandleResponse<T>(process, result);
102
+ }
103
+
104
+ async function download({
105
+ destination,
106
+ body,
107
+ headers = {},
108
+ method = "get",
109
+ ...fetchWith
110
+ }: DownloadOptions) {
111
+ const url: string = await fetchCreateUrl(fetchWith);
112
+ const response = await fetch(url, {
113
+ body: is.object(body) ? JSON.stringify(body) : body,
114
+ headers: { ...fetchWrapper.base_headers, ...headers },
115
+ method,
116
+ });
117
+
118
+ const stream = createWriteStream(destination);
119
+ await streamPipeline(response.body, stream);
120
+ }
121
+
122
+ // #MARK: return object
123
+ const fetchWrapper = {
124
+ base_headers,
125
+ base_url,
126
+ download,
127
+ exec,
128
+ /**
129
+ * @deprecated set base_url directly
130
+ */
131
+ setBaseUrl: (url: string) => (fetchWrapper.base_url = url),
132
+ /**
133
+ * @deprecated set base_headers directly
134
+ */
135
+ setHeaders: (headers: Record<string, string>) => (fetchWrapper.base_headers = headers),
136
+ };
137
+ return fetchWrapper;
138
+ };
139
+ }
140
+
141
+ export type TFetch = <T, BODY extends object = object>(
142
+ fetchWith: Partial<FetchArguments<BODY>>,
143
+ ) => Promise<T>;
144
+
145
+ export type TDownload = (fetchWith: DownloadOptions) => Promise<void>;
@@ -0,0 +1,83 @@
1
+ import { debounce, TServiceParams } from "@digital-alchemy/core";
2
+
3
+ import { TLabelId } from "../dynamic";
4
+ import {
5
+ EARLY_ON_READY,
6
+ HassLabelService,
7
+ LABEL_REGISTRY_UPDATED,
8
+ LabelDefinition,
9
+ LabelOptions,
10
+ } from "../helpers";
11
+
12
+ export function Label({
13
+ hass,
14
+ config,
15
+ logger,
16
+ lifecycle,
17
+ event,
18
+ context,
19
+ }: TServiceParams): HassLabelService {
20
+ hass.socket.onConnect(async () => {
21
+ let loading = new Promise<void>(async done => {
22
+ hass.label.current = await hass.label.list();
23
+ loading = undefined;
24
+ done();
25
+ });
26
+ lifecycle.onReady(async () => loading && (await loading), EARLY_ON_READY);
27
+
28
+ hass.socket.subscribe({
29
+ context,
30
+ event_type: "label_registry_updated",
31
+ async exec() {
32
+ await debounce(LABEL_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
33
+ hass.label.current = await hass.label.list();
34
+ logger.debug(`label registry updated`);
35
+ event.emit(LABEL_REGISTRY_UPDATED);
36
+ },
37
+ });
38
+ });
39
+
40
+ async function create(details: LabelOptions) {
41
+ return await new Promise<void>(async done => {
42
+ event.once(LABEL_REGISTRY_UPDATED, done);
43
+ await hass.socket.sendMessage({
44
+ type: "config/label_registry/create",
45
+ ...details,
46
+ });
47
+ });
48
+ }
49
+
50
+ async function deleteLabel(label_id: TLabelId) {
51
+ return await new Promise<void>(async done => {
52
+ event.once(LABEL_REGISTRY_UPDATED, done);
53
+ await hass.socket.sendMessage({
54
+ label_id,
55
+ type: "config/label_registry/delete",
56
+ });
57
+ });
58
+ }
59
+
60
+ async function list() {
61
+ return await hass.socket.sendMessage<LabelDefinition[]>({
62
+ type: "config/label_registry/list",
63
+ });
64
+ }
65
+
66
+ async function update(details: LabelDefinition) {
67
+ return await new Promise<void>(async done => {
68
+ event.once(LABEL_REGISTRY_UPDATED, done);
69
+ await hass.socket.sendMessage({
70
+ type: "config/label_registry/update",
71
+ ...details,
72
+ });
73
+ });
74
+ }
75
+
76
+ return {
77
+ create,
78
+ current: [] as LabelDefinition[],
79
+ delete: deleteLabel,
80
+ list,
81
+ update,
82
+ };
83
+ }