@digital-alchemy/hass 24.9.4 → 24.10.1
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.
- package/README.md +1 -1
- package/dist/extensions/call-proxy.extension.d.ts +1 -1
- package/dist/extensions/call-proxy.extension.js +4 -1
- package/dist/extensions/call-proxy.extension.js.map +1 -1
- package/dist/extensions/entity.extension.js +3 -0
- package/dist/extensions/entity.extension.js.map +1 -1
- package/dist/extensions/websocket-api.extension.js +5 -11
- package/dist/extensions/websocket-api.extension.js.map +1 -1
- package/dist/helpers/notify.helper.d.ts +2 -2
- package/package.json +16 -14
- package/scripts/mock-assistant.sh +5 -0
- package/scripts/run-e2e.sh +7 -0
- package/scripts/test.sh +2 -0
- package/src/dynamic.ts +4254 -0
- package/src/extensions/area.extension.ts +118 -0
- package/src/extensions/backup.extension.ts +63 -0
- package/src/extensions/call-proxy.extension.ts +122 -0
- package/src/extensions/config.extension.ts +119 -0
- package/src/extensions/conversation.extension.ts +46 -0
- package/src/extensions/device.extension.ts +56 -0
- package/src/extensions/entity.extension.ts +347 -0
- package/src/extensions/events.extension.ts +25 -0
- package/src/extensions/fetch-api.extension.ts +269 -0
- package/src/extensions/floor.extension.ts +76 -0
- package/src/extensions/id-by.extension.ts +157 -0
- package/src/extensions/index.ts +16 -0
- package/src/extensions/internal.extension.ts +145 -0
- package/src/extensions/label.extension.ts +83 -0
- package/src/extensions/reference.extension.ts +330 -0
- package/src/extensions/registry.extension.ts +44 -0
- package/src/extensions/websocket-api.extension.ts +551 -0
- package/src/extensions/zone.extension.ts +69 -0
- package/src/hass.module.ts +217 -0
- package/src/helpers/backup.helper.ts +11 -0
- package/src/helpers/constants.helper.ts +30 -0
- package/src/helpers/device.helper.ts +25 -0
- package/src/helpers/entity-state.helper.ts +171 -0
- package/src/helpers/features.helper.ts +580 -0
- package/src/helpers/fetch/calendar.ts +54 -0
- package/src/helpers/fetch/configuration.ts +75 -0
- package/src/helpers/fetch/index.ts +5 -0
- package/src/helpers/fetch/server-log.ts +28 -0
- package/src/helpers/fetch/service-list.ts +64 -0
- package/src/helpers/fetch/weather-forecasts.ts +86 -0
- package/src/helpers/fetch.helper.ts +328 -0
- package/src/helpers/id-by.helper.ts +53 -0
- package/src/helpers/index.ts +13 -0
- package/src/helpers/interfaces.helper.ts +340 -0
- package/src/helpers/manifest.helper.ts +0 -0
- package/src/helpers/notify.helper.ts +302 -0
- package/src/helpers/registry.ts +281 -0
- package/src/helpers/utility.helper.ts +147 -0
- package/src/helpers/websocket.helper.ts +117 -0
- package/src/index.ts +5 -0
- package/src/mock_assistant/extensions/area.extension.ts +62 -0
- package/src/mock_assistant/extensions/config.extension.ts +33 -0
- package/src/mock_assistant/extensions/device.extension.ts +44 -0
- package/src/mock_assistant/extensions/entity-registry.extension.ts +41 -0
- package/src/mock_assistant/extensions/entity.extension.ts +114 -0
- package/src/mock_assistant/extensions/events.extension.ts +37 -0
- package/src/mock_assistant/extensions/fetch.extension.ts +3 -0
- package/src/mock_assistant/extensions/fixtures.extension.ts +79 -0
- package/src/mock_assistant/extensions/floor.extension.ts +64 -0
- package/src/mock_assistant/extensions/index.ts +12 -0
- package/src/mock_assistant/extensions/label.extension.ts +64 -0
- package/src/mock_assistant/extensions/services.extension.ts +25 -0
- package/src/mock_assistant/extensions/websocket-api.extension.ts +84 -0
- package/src/mock_assistant/extensions/zone.extension.ts +65 -0
- package/src/mock_assistant/helpers/fixtures.ts +22 -0
- package/src/mock_assistant/helpers/index.ts +1 -0
- package/src/mock_assistant/index.ts +3 -0
- package/src/mock_assistant/main.ts +46 -0
- package/src/mock_assistant/mock-assistant.module.ts +90 -0
- package/src/quickboot.module.ts +23 -0
- package/src/testing/area.spec.ts +189 -0
- package/src/testing/backup.spec.ts +157 -0
- package/src/testing/config.spec.ts +188 -0
- package/src/testing/device.spec.ts +89 -0
- package/src/testing/entity.spec.ts +171 -0
- package/src/testing/events.spec.ts +78 -0
- package/src/testing/fetch-api.spec.ts +410 -0
- package/src/testing/fixtures.spec.ts +158 -0
- package/src/testing/floor.spec.ts +186 -0
- package/src/testing/id-by.spec.ts +140 -0
- package/src/testing/label.spec.ts +186 -0
- package/src/testing/ref-by.spec.ts +300 -0
- package/src/testing/websocket.spec.ts +63 -0
- package/src/testing/workflow.spec.ts +195 -0
- package/src/testing/zone.spec.ts +109 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import {
|
|
2
|
+
debounce,
|
|
3
|
+
each,
|
|
4
|
+
eachSeries,
|
|
5
|
+
INCREMENT,
|
|
6
|
+
is,
|
|
7
|
+
SECOND,
|
|
8
|
+
sleep,
|
|
9
|
+
START,
|
|
10
|
+
TServiceParams,
|
|
11
|
+
} from "@digital-alchemy/core";
|
|
12
|
+
import dayjs, { Dayjs } from "dayjs";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ALL_DOMAINS,
|
|
16
|
+
ANY_ENTITY,
|
|
17
|
+
EditLabelOptions,
|
|
18
|
+
ENTITY_REGISTRY_UPDATED,
|
|
19
|
+
ENTITY_STATE,
|
|
20
|
+
EntityHistoryDTO,
|
|
21
|
+
EntityHistoryItem,
|
|
22
|
+
EntityHistoryResult,
|
|
23
|
+
EntityRegistryItem,
|
|
24
|
+
HassEntityManager,
|
|
25
|
+
PICK_ENTITY,
|
|
26
|
+
TMasterState,
|
|
27
|
+
} from "..";
|
|
28
|
+
|
|
29
|
+
const MAX_ATTEMPTS = 10;
|
|
30
|
+
const RECENT = 5;
|
|
31
|
+
|
|
32
|
+
export function EntityManager({
|
|
33
|
+
logger,
|
|
34
|
+
hass,
|
|
35
|
+
config,
|
|
36
|
+
lifecycle,
|
|
37
|
+
event,
|
|
38
|
+
context,
|
|
39
|
+
internal,
|
|
40
|
+
}: TServiceParams): HassEntityManager {
|
|
41
|
+
// #MARK: Local vars
|
|
42
|
+
/**
|
|
43
|
+
* MASTER_STATE.switch.desk_light = {entity_id,state,attributes,...}
|
|
44
|
+
*/
|
|
45
|
+
let MASTER_STATE = {} as Partial<TMasterState>;
|
|
46
|
+
const PREVIOUS_STATE = new Map<ANY_ENTITY, ENTITY_STATE<ANY_ENTITY>>();
|
|
47
|
+
let lastRefresh: Dayjs;
|
|
48
|
+
function warnEarly(method: string) {
|
|
49
|
+
if (!init) {
|
|
50
|
+
lifecycle.onReady(() => {
|
|
51
|
+
if (config.boilerplate.LOG_LEVEL !== "trace") {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
logger.error(
|
|
55
|
+
"attempted to use [%s] before application booted. use {lifecycle.onReady}",
|
|
56
|
+
method,
|
|
57
|
+
);
|
|
58
|
+
// eslint-disable-next-line no-console
|
|
59
|
+
console.trace(method);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// * Local event emitter for coordination of socket events
|
|
65
|
+
// Other libraries will internally take advantage of this eventemitter
|
|
66
|
+
let init = false;
|
|
67
|
+
|
|
68
|
+
// #MARK: getCurrentState
|
|
69
|
+
function getCurrentState<ENTITY_ID extends ANY_ENTITY>(
|
|
70
|
+
entity_id: ENTITY_ID,
|
|
71
|
+
): ENTITY_STATE<ENTITY_ID> {
|
|
72
|
+
const out = internal.utils.object.get(MASTER_STATE, entity_id) ?? {};
|
|
73
|
+
return out as ENTITY_STATE<ENTITY_ID>;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// #MARK: history
|
|
77
|
+
async function history<ENTITES extends ANY_ENTITY[]>(
|
|
78
|
+
payload: Omit<EntityHistoryDTO<ENTITES>, "type">,
|
|
79
|
+
) {
|
|
80
|
+
logger.trace({ payload }, `looking up entity history`);
|
|
81
|
+
const result = (await hass.socket.sendMessage({
|
|
82
|
+
...payload,
|
|
83
|
+
end_time: dayjs(payload.end_time).toISOString(),
|
|
84
|
+
start_time: dayjs(payload.start_time).toISOString(),
|
|
85
|
+
type: "history/history_during_period",
|
|
86
|
+
})) as Record<ANY_ENTITY, EntityHistoryItem[]>;
|
|
87
|
+
|
|
88
|
+
const entities = Object.keys(result) as ANY_ENTITY[];
|
|
89
|
+
return Object.fromEntries(
|
|
90
|
+
entities.map((entity_id: ANY_ENTITY) => {
|
|
91
|
+
const key = entity_id;
|
|
92
|
+
const states = result[entity_id];
|
|
93
|
+
const value = states.map(data => {
|
|
94
|
+
return {
|
|
95
|
+
attributes: data.a,
|
|
96
|
+
date: new Date(data.lu * SECOND),
|
|
97
|
+
state: data.s,
|
|
98
|
+
} as EntityHistoryResult;
|
|
99
|
+
});
|
|
100
|
+
return [key, value];
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// #MARK: listEntities
|
|
106
|
+
function listEntities<DOMAIN extends ALL_DOMAINS = ALL_DOMAINS>(
|
|
107
|
+
domain?: DOMAIN,
|
|
108
|
+
): PICK_ENTITY<DOMAIN>[] {
|
|
109
|
+
if (domain) {
|
|
110
|
+
return Object.keys(MASTER_STATE[domain as ALL_DOMAINS]).map(
|
|
111
|
+
id => `${domain}.${id}` as PICK_ENTITY<DOMAIN>,
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return Object.keys(MASTER_STATE).flatMap(domain =>
|
|
115
|
+
Object.keys(MASTER_STATE[domain as ALL_DOMAINS]).map(
|
|
116
|
+
id => `${domain}.${id}` as PICK_ENTITY<DOMAIN>,
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// #MARK: refresh
|
|
122
|
+
async function refresh(recursion = START): Promise<void> {
|
|
123
|
+
const now = dayjs();
|
|
124
|
+
if (lastRefresh) {
|
|
125
|
+
const diff = lastRefresh.diff(now, "ms");
|
|
126
|
+
if (diff >= RECENT * SECOND) {
|
|
127
|
+
logger.warn({ diff }, `multiple refreshes in close time`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
lastRefresh = now;
|
|
131
|
+
// - Fetch list of entities
|
|
132
|
+
const states = await hass.fetch.getAllEntities();
|
|
133
|
+
|
|
134
|
+
// - Keep retrying until max failures reached
|
|
135
|
+
if (!is.array(states) || is.empty(states)) {
|
|
136
|
+
if (recursion > MAX_ATTEMPTS) {
|
|
137
|
+
logger.fatal(
|
|
138
|
+
{ name: refresh },
|
|
139
|
+
`failed to load service list from Home Assistant. validate configuration`,
|
|
140
|
+
);
|
|
141
|
+
process.exit();
|
|
142
|
+
}
|
|
143
|
+
logger.warn(
|
|
144
|
+
{ name: refresh, response: states },
|
|
145
|
+
"failed to retrieve entity list. retrying {%s}/[%s]",
|
|
146
|
+
recursion,
|
|
147
|
+
MAX_ATTEMPTS,
|
|
148
|
+
);
|
|
149
|
+
await sleep(config.hass.RETRY_INTERVAL * SECOND);
|
|
150
|
+
await refresh(recursion + INCREMENT);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// - Preserve old state for comparison
|
|
155
|
+
const oldState = MASTER_STATE;
|
|
156
|
+
MASTER_STATE = {};
|
|
157
|
+
const emitUpdates: ENTITY_STATE<ANY_ENTITY>[] = [];
|
|
158
|
+
|
|
159
|
+
// - Go through all entities, setting the state
|
|
160
|
+
// ~ If this is a refresh (not an initial boot), track what changed so events can be emitted
|
|
161
|
+
states.forEach(entity => {
|
|
162
|
+
// ? Set first, ensure data is populated
|
|
163
|
+
// `nextTick` will fire AFTER loop finishes
|
|
164
|
+
internal.utils.object.set(
|
|
165
|
+
MASTER_STATE,
|
|
166
|
+
entity.entity_id,
|
|
167
|
+
entity,
|
|
168
|
+
is.undefined(internal.utils.object.get(oldState, entity.entity_id)),
|
|
169
|
+
);
|
|
170
|
+
if (!init) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const old = internal.utils.object.get(oldState, entity.entity_id);
|
|
174
|
+
if (is.equal(old, entity)) {
|
|
175
|
+
// logger.trace(
|
|
176
|
+
// { entity_id: entity.entity_id, name: refresh },
|
|
177
|
+
// `no change on refresh`,
|
|
178
|
+
// );
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
emitUpdates.push(entity);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Attempt to not blow up the system?
|
|
185
|
+
// TODO: does this gain anything? is a debounce needed somewhere else instead?
|
|
186
|
+
setImmediate(async () => {
|
|
187
|
+
await each(
|
|
188
|
+
emitUpdates,
|
|
189
|
+
async entity =>
|
|
190
|
+
await entityUpdateReceiver(
|
|
191
|
+
entity.entity_id,
|
|
192
|
+
entity satisfies ENTITY_STATE<ANY_ENTITY>,
|
|
193
|
+
internal.utils.object.get(oldState, entity.entity_id),
|
|
194
|
+
),
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
init = true;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// #MARK: EntityUpdateReceiver
|
|
201
|
+
function entityUpdateReceiver<ENTITY extends ANY_ENTITY = ANY_ENTITY>(
|
|
202
|
+
entity_id: ENTITY,
|
|
203
|
+
new_state: ENTITY_STATE<ENTITY>,
|
|
204
|
+
old_state: ENTITY_STATE<ENTITY>,
|
|
205
|
+
) {
|
|
206
|
+
PREVIOUS_STATE.set(entity_id, old_state);
|
|
207
|
+
if (new_state === null) {
|
|
208
|
+
logger.warn(
|
|
209
|
+
{ name: entityUpdateReceiver },
|
|
210
|
+
`removing deleted entity [%s] from {MASTER_STATE}`,
|
|
211
|
+
entity_id,
|
|
212
|
+
);
|
|
213
|
+
internal.utils.object.del(MASTER_STATE, entity_id);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
internal.utils.object.set(MASTER_STATE, entity_id, new_state);
|
|
217
|
+
if (!hass.socket.pauseMessages) {
|
|
218
|
+
event.emit(entity_id, new_state, old_state);
|
|
219
|
+
const unique_id = hass.entity.registry.current.find(
|
|
220
|
+
i => i.entity_id === entity_id,
|
|
221
|
+
)?.unique_id;
|
|
222
|
+
if (is.number(unique_id) || !is.empty(unique_id)) {
|
|
223
|
+
event.emit(unique_id, new_state, old_state);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// #MARK: onPostConfig
|
|
229
|
+
lifecycle.onPostConfig(async function HassEntityPostConfig() {
|
|
230
|
+
logger.debug({ name: HassEntityPostConfig }, `pre populate {MASTER_STATE}`);
|
|
231
|
+
await hass.entity.refresh();
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
async function AddLabel({ entity, label }: EditLabelOptions) {
|
|
235
|
+
await each([entity].flat(), async entity => {
|
|
236
|
+
const current = await entityGet(entity);
|
|
237
|
+
if (current?.labels?.includes(label)) {
|
|
238
|
+
logger.debug({ name: entity }, `already has label {%s}`, label);
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
return await new Promise<void>(async done => {
|
|
242
|
+
event.once(ENTITY_REGISTRY_UPDATED, done);
|
|
243
|
+
await hass.socket.sendMessage({
|
|
244
|
+
entity_id: entity,
|
|
245
|
+
labels: [...current.labels, label],
|
|
246
|
+
type: "config/entity_registry/update",
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// #MARK: EntitySource
|
|
253
|
+
async function EntitySource() {
|
|
254
|
+
return await hass.socket.sendMessage<Record<ANY_ENTITY, { domain: string }>>({
|
|
255
|
+
type: "entity/source",
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// #MARK: EntityList
|
|
260
|
+
async function EntityList() {
|
|
261
|
+
return await hass.socket.sendMessage<EntityRegistryItem<ANY_ENTITY>[]>({
|
|
262
|
+
type: "config/entity_registry/list",
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// #MARK: RemoveLabel
|
|
267
|
+
async function RemoveLabel({ entity, label }: EditLabelOptions) {
|
|
268
|
+
const current = await entityGet(entity);
|
|
269
|
+
if (!current?.labels?.includes(label)) {
|
|
270
|
+
logger.debug({ name: entity }, `does not have label {%s}`, label);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
logger.debug({ name: entity }, `removing label [%s]`, label);
|
|
274
|
+
return await new Promise<void>(async done => {
|
|
275
|
+
event.once(ENTITY_REGISTRY_UPDATED, done);
|
|
276
|
+
await hass.socket.sendMessage({
|
|
277
|
+
entity_id: entity,
|
|
278
|
+
labels: current.labels.filter(i => i !== label),
|
|
279
|
+
type: "config/entity_registry/update",
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// #MARK: EntityGet
|
|
285
|
+
async function entityGet<ENTITY extends ANY_ENTITY>(entity_id: ENTITY) {
|
|
286
|
+
return await hass.socket.sendMessage<EntityRegistryItem<ENTITY>>({
|
|
287
|
+
entity_id: entity_id,
|
|
288
|
+
type: "config/entity_registry/get",
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// #MARK: onConnect
|
|
293
|
+
hass.socket.onConnect(async () => {
|
|
294
|
+
hass.socket.subscribe({
|
|
295
|
+
context,
|
|
296
|
+
event_type: "entity_registry_updated",
|
|
297
|
+
async exec() {
|
|
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
|
+
},
|
|
303
|
+
});
|
|
304
|
+
hass.entity.registry.current = await hass.entity.registry.list();
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// #MARK: RemoveEntity
|
|
308
|
+
async function RemoveEntity(entity_id: ANY_ENTITY | ANY_ENTITY[]) {
|
|
309
|
+
warnEarly("RemoveEntity");
|
|
310
|
+
await eachSeries([entity_id].flat(), async entity_id => {
|
|
311
|
+
logger.debug({ name: entity_id }, `removing entity`);
|
|
312
|
+
await hass.socket.sendMessage({
|
|
313
|
+
entity_id,
|
|
314
|
+
type: "config/entity_registry/remove",
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// #MARK: return object
|
|
320
|
+
return {
|
|
321
|
+
_entityUpdateReceiver: entityUpdateReceiver,
|
|
322
|
+
|
|
323
|
+
_masterState: () => MASTER_STATE,
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Retrieves the current state of a given entity. This method returns
|
|
327
|
+
* raw data, offering a direct view of the entity's state at a given moment.
|
|
328
|
+
*/
|
|
329
|
+
getCurrentState,
|
|
330
|
+
|
|
331
|
+
history,
|
|
332
|
+
listEntities,
|
|
333
|
+
previousState: (entity_id: ANY_ENTITY) => PREVIOUS_STATE.get(entity_id),
|
|
334
|
+
refresh,
|
|
335
|
+
registry: {
|
|
336
|
+
addLabel: AddLabel,
|
|
337
|
+
current: [] as EntityRegistryItem<ANY_ENTITY>[],
|
|
338
|
+
get: entityGet,
|
|
339
|
+
list: EntityList,
|
|
340
|
+
registryList: EntityList,
|
|
341
|
+
removeEntity: RemoveEntity,
|
|
342
|
+
removeLabel: RemoveLabel,
|
|
343
|
+
source: EntitySource,
|
|
344
|
+
},
|
|
345
|
+
warnEarly,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { TServiceParams } from "@digital-alchemy/core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AREA_REGISTRY_UPDATED,
|
|
5
|
+
DEVICE_REGISTRY_UPDATED,
|
|
6
|
+
ENTITY_REGISTRY_UPDATED,
|
|
7
|
+
FLOOR_REGISTRY_UPDATED,
|
|
8
|
+
HassEventsService,
|
|
9
|
+
LABEL_REGISTRY_UPDATED,
|
|
10
|
+
SimpleCallback,
|
|
11
|
+
ZONE_REGISTRY_UPDATED,
|
|
12
|
+
} from "../helpers";
|
|
13
|
+
|
|
14
|
+
export function Events({ event }: TServiceParams): HassEventsService {
|
|
15
|
+
return {
|
|
16
|
+
onAreaRegistryUpdate: (callback: SimpleCallback) => event.on(AREA_REGISTRY_UPDATED, callback),
|
|
17
|
+
onDeviceRegistryUpdate: (callback: SimpleCallback) =>
|
|
18
|
+
event.on(DEVICE_REGISTRY_UPDATED, callback),
|
|
19
|
+
onEntityRegistryUpdate: (callback: SimpleCallback) =>
|
|
20
|
+
event.on(ENTITY_REGISTRY_UPDATED, callback),
|
|
21
|
+
onFloorRegistryUpdate: (callback: SimpleCallback) => event.on(FLOOR_REGISTRY_UPDATED, callback),
|
|
22
|
+
onLabelRegistryUpdate: (callback: SimpleCallback) => event.on(LABEL_REGISTRY_UPDATED, callback),
|
|
23
|
+
onZoneRegistryUpdate: (callback: SimpleCallback) => event.on(ZONE_REGISTRY_UPDATED, callback),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { DOWN, is, NO_CHANGE, SECOND, TServiceParams, UP } from "@digital-alchemy/core";
|
|
2
|
+
import dayjs, { Dayjs } from "dayjs";
|
|
3
|
+
import { lt } from "semver";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ALL_SERVICE_DOMAINS,
|
|
7
|
+
ANY_ENTITY,
|
|
8
|
+
CalendarEvent,
|
|
9
|
+
CalendarFetchOptions,
|
|
10
|
+
CheckConfigResult,
|
|
11
|
+
ENTITY_STATE,
|
|
12
|
+
FetchArguments,
|
|
13
|
+
FilteredFetchArguments,
|
|
14
|
+
HassConfig,
|
|
15
|
+
HassServiceDTO,
|
|
16
|
+
HomeAssistantServerLogItem,
|
|
17
|
+
MIN_SUPPORTED_HASS_VERSION,
|
|
18
|
+
PICK_SERVICE,
|
|
19
|
+
PICK_SERVICE_PARAMETERS,
|
|
20
|
+
PostConfigPriorities,
|
|
21
|
+
RawCalendarEvent,
|
|
22
|
+
TFetchBody,
|
|
23
|
+
} from "..";
|
|
24
|
+
|
|
25
|
+
type SendBody<STATE extends string | number = string, ATTRIBUTES extends object = object> = {
|
|
26
|
+
attributes?: ATTRIBUTES;
|
|
27
|
+
state?: STATE;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function FetchAPI({ logger, lifecycle, context, hass, config }: TServiceParams) {
|
|
31
|
+
const fetcher = hass.internals({ context });
|
|
32
|
+
const { download: downloader } = fetcher;
|
|
33
|
+
|
|
34
|
+
// Load configurations
|
|
35
|
+
lifecycle.onPostConfig(() => {
|
|
36
|
+
fetcher.base_url = config.hass.BASE_URL;
|
|
37
|
+
fetcher.base_headers = { Authorization: `Bearer ${config.hass.TOKEN}` };
|
|
38
|
+
}, PostConfigPriorities.FETCH);
|
|
39
|
+
|
|
40
|
+
lifecycle.onBootstrap(async () => {
|
|
41
|
+
const target = await hass.fetch.getConfig();
|
|
42
|
+
if (lt(target.version, MIN_SUPPORTED_HASS_VERSION)) {
|
|
43
|
+
logger.fatal(
|
|
44
|
+
{ target: target.version },
|
|
45
|
+
"minimum supported version of home assistant: %s",
|
|
46
|
+
MIN_SUPPORTED_HASS_VERSION,
|
|
47
|
+
);
|
|
48
|
+
process.exit();
|
|
49
|
+
}
|
|
50
|
+
logger.debug(`hass version %s`, target.version);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function calendarSearch({
|
|
54
|
+
calendar,
|
|
55
|
+
start = dayjs(),
|
|
56
|
+
end,
|
|
57
|
+
}: CalendarFetchOptions): Promise<CalendarEvent[]> {
|
|
58
|
+
if (Array.isArray(calendar)) {
|
|
59
|
+
const list = await Promise.all(
|
|
60
|
+
calendar.map(async cal => await calendarSearch({ calendar: cal, end, start })),
|
|
61
|
+
);
|
|
62
|
+
return list.flat().sort((a, b) =>
|
|
63
|
+
// eslint-disable-next-line sonarjs/no-nested-conditional
|
|
64
|
+
a.start.isSame(b.start) ? NO_CHANGE : a.start.isAfter(b.start) ? UP : DOWN,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const params = { end: end.toISOString(), start: start.toISOString() };
|
|
69
|
+
const events = await hass.fetch.fetch<RawCalendarEvent[]>({
|
|
70
|
+
params,
|
|
71
|
+
url: `/api/calendars/${encodeURIComponent(calendar)}`,
|
|
72
|
+
});
|
|
73
|
+
logger.trace(
|
|
74
|
+
{ name: calendarSearch, params },
|
|
75
|
+
`%s search found %s events`,
|
|
76
|
+
calendar,
|
|
77
|
+
events.length,
|
|
78
|
+
);
|
|
79
|
+
return events.map(({ start, end, ...extra }) => ({
|
|
80
|
+
...extra,
|
|
81
|
+
end: dayjs(end.dateTime),
|
|
82
|
+
start: dayjs(start.dateTime),
|
|
83
|
+
}));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function callService<
|
|
87
|
+
DOMAIN extends ALL_SERVICE_DOMAINS,
|
|
88
|
+
SERVICE extends PICK_SERVICE<DOMAIN>,
|
|
89
|
+
>(serviceName: SERVICE, data: PICK_SERVICE_PARAMETERS<DOMAIN, SERVICE>): Promise<void> {
|
|
90
|
+
const [domain, service] = serviceName.split(".");
|
|
91
|
+
await hass.fetch.fetch({
|
|
92
|
+
body: data as TFetchBody,
|
|
93
|
+
method: "post",
|
|
94
|
+
url: `/api/services/${domain}/${service}`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function checkConfig(): Promise<CheckConfigResult> {
|
|
99
|
+
logger.trace({ name: checkConfig }, `send`);
|
|
100
|
+
return await hass.fetch.fetch({
|
|
101
|
+
method: `post`,
|
|
102
|
+
url: `/api/config/core/check_config`,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function download(destination: string, fetchWith: FilteredFetchArguments): Promise<void> {
|
|
107
|
+
logger.trace({ name: download }, `send`);
|
|
108
|
+
await downloader({
|
|
109
|
+
...fetchWith,
|
|
110
|
+
baseUrl: config.hass.BASE_URL,
|
|
111
|
+
destination,
|
|
112
|
+
headers: { Authorization: `Bearer ${config.hass.TOKEN}` },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function fetchEntityCustomizations<
|
|
117
|
+
T extends Record<never, unknown> = Record<"global" | "local", Record<string, string>>,
|
|
118
|
+
>(entityId: ANY_ENTITY): Promise<T> {
|
|
119
|
+
logger.trace({ name: fetchEntityCustomizations }, `send`);
|
|
120
|
+
return await hass.fetch.fetch<T>({
|
|
121
|
+
url: `/api/config/customize/config/${encodeURIComponent(entityId)}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function fetchEntityHistory<
|
|
126
|
+
ENTITY extends ANY_ENTITY = ANY_ENTITY,
|
|
127
|
+
T extends ENTITY_STATE<ENTITY> = ENTITY_STATE<ENTITY>,
|
|
128
|
+
>(
|
|
129
|
+
entity_id: ENTITY,
|
|
130
|
+
from: Date | Dayjs,
|
|
131
|
+
to: Date | Dayjs,
|
|
132
|
+
extra: { minimal_response?: "" } = {},
|
|
133
|
+
): Promise<T[]> {
|
|
134
|
+
logger.info(
|
|
135
|
+
{
|
|
136
|
+
entity_id,
|
|
137
|
+
from: dayjs(from).toISOString(),
|
|
138
|
+
name: fetchEntityHistory,
|
|
139
|
+
to: dayjs(to).toISOString(),
|
|
140
|
+
},
|
|
141
|
+
`fetch entity history`,
|
|
142
|
+
);
|
|
143
|
+
const result = await hass.fetch.fetch<[T[]]>({
|
|
144
|
+
params: {
|
|
145
|
+
end_time: to.toISOString(),
|
|
146
|
+
filter_entity_id: entity_id,
|
|
147
|
+
...extra,
|
|
148
|
+
},
|
|
149
|
+
url: `/api/history/period/${encodeURIComponent(from.toISOString())}`,
|
|
150
|
+
});
|
|
151
|
+
if (!Array.isArray(result)) {
|
|
152
|
+
logger.error({ name: fetchEntityHistory, result }, `unexpected return result`);
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
const [out] = result;
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function fireEvent<DATA extends TFetchBody = object>(
|
|
160
|
+
event: string,
|
|
161
|
+
data?: DATA,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
logger.trace({ data, event, name: fireEvent }, `firing event`);
|
|
164
|
+
const response = await hass.fetch.fetch<{ message: string }, DATA>({
|
|
165
|
+
body: data,
|
|
166
|
+
method: "post",
|
|
167
|
+
url: `/api/events/${encodeURIComponent(event)}`,
|
|
168
|
+
});
|
|
169
|
+
if (response?.message !== `Event ${event} fired.`) {
|
|
170
|
+
logger.debug({ name: fetchEntityHistory, response }, `unexpected response from firing event`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function getAllEntities(): Promise<ENTITY_STATE<ANY_ENTITY>[]> {
|
|
175
|
+
logger.trace({ name: getAllEntities }, `send`);
|
|
176
|
+
return await hass.fetch.fetch<ENTITY_STATE<ANY_ENTITY>[]>({
|
|
177
|
+
url: `/api/states`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function getConfig(): Promise<HassConfig> {
|
|
182
|
+
logger.trace({ name: getConfig }, `send`);
|
|
183
|
+
return await hass.fetch.fetch({ url: `/api/config` });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function getLogs(): Promise<HomeAssistantServerLogItem[]> {
|
|
187
|
+
logger.trace({ name: getLogs }, `send`);
|
|
188
|
+
const results = await hass.fetch.fetch<HomeAssistantServerLogItem[]>({
|
|
189
|
+
url: `/api/error/all`,
|
|
190
|
+
});
|
|
191
|
+
return results.map(i => {
|
|
192
|
+
i.timestamp = Math.floor(i.timestamp * SECOND);
|
|
193
|
+
i.first_occurred = Math.floor(i.first_occurred * SECOND);
|
|
194
|
+
return i;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function getRawLogs(): Promise<string> {
|
|
199
|
+
logger.trace({ name: getRawLogs }, `send`);
|
|
200
|
+
return await hass.fetch.fetch<string>({
|
|
201
|
+
process: "text",
|
|
202
|
+
url: `/api/error_log`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function listServices(): Promise<HassServiceDTO[]> {
|
|
207
|
+
logger.trace({ name: listServices }, `send`);
|
|
208
|
+
return await hass.fetch.fetch<HassServiceDTO[]>({ url: `/api/services` });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function updateEntity<
|
|
212
|
+
STATE extends string | number = string,
|
|
213
|
+
ATTRIBUTES extends object = object,
|
|
214
|
+
>(entity_id: ANY_ENTITY, { attributes, state }: SendBody<STATE, ATTRIBUTES>): Promise<void> {
|
|
215
|
+
const body: SendBody<STATE> = {};
|
|
216
|
+
// ! ORDER MATTERS FOR APPLYING
|
|
217
|
+
// Must be applied in alphabetical order for unit test reasons
|
|
218
|
+
if (!is.empty(attributes)) {
|
|
219
|
+
body.attributes = attributes;
|
|
220
|
+
}
|
|
221
|
+
if (state !== undefined) {
|
|
222
|
+
body.state = state;
|
|
223
|
+
}
|
|
224
|
+
logger.trace({ ...body, entity_id, name: updateEntity }, `set entity state`);
|
|
225
|
+
await hass.fetch.fetch({
|
|
226
|
+
body,
|
|
227
|
+
method: "post",
|
|
228
|
+
url: `/api/states/${encodeURIComponent(entity_id)}`,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function webhook(webhook_name: string, data: object = {}): Promise<void> {
|
|
233
|
+
logger.trace({ data, name: webhook, webhook_name }, `send`);
|
|
234
|
+
await hass.fetch.fetch({
|
|
235
|
+
body: data,
|
|
236
|
+
method: "post",
|
|
237
|
+
process: "text",
|
|
238
|
+
url: `/api/webhook/${encodeURIComponent(webhook_name)}`,
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
async function checkCredentials(): Promise<{ message: string } | string> {
|
|
243
|
+
logger.trace({ name: checkCredentials }, `send`);
|
|
244
|
+
return await hass.fetch.fetch({
|
|
245
|
+
url: `/api/`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return {
|
|
250
|
+
_fetcher: fetcher,
|
|
251
|
+
calendarSearch,
|
|
252
|
+
callService,
|
|
253
|
+
checkConfig,
|
|
254
|
+
checkCredentials,
|
|
255
|
+
download,
|
|
256
|
+
fetch: async <T, BODY extends TFetchBody = undefined>(options: Partial<FetchArguments<BODY>>) =>
|
|
257
|
+
await fetcher.exec<T, BODY>(options),
|
|
258
|
+
fetchEntityCustomizations,
|
|
259
|
+
fetchEntityHistory,
|
|
260
|
+
fireEvent,
|
|
261
|
+
getAllEntities,
|
|
262
|
+
getConfig,
|
|
263
|
+
getLogs,
|
|
264
|
+
getRawLogs,
|
|
265
|
+
listServices,
|
|
266
|
+
updateEntity,
|
|
267
|
+
webhook,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { debounce, TServiceParams } from "@digital-alchemy/core";
|
|
2
|
+
|
|
3
|
+
import { TFloorId } from "../dynamic";
|
|
4
|
+
import {
|
|
5
|
+
EARLY_ON_READY,
|
|
6
|
+
FLOOR_REGISTRY_UPDATED,
|
|
7
|
+
FloorCreate,
|
|
8
|
+
FloorDetails,
|
|
9
|
+
HassFloorService,
|
|
10
|
+
} from "../helpers";
|
|
11
|
+
|
|
12
|
+
export function Floor({
|
|
13
|
+
hass,
|
|
14
|
+
config,
|
|
15
|
+
context,
|
|
16
|
+
event,
|
|
17
|
+
logger,
|
|
18
|
+
lifecycle,
|
|
19
|
+
}: TServiceParams): HassFloorService {
|
|
20
|
+
hass.socket.onConnect(async () => {
|
|
21
|
+
let loading = new Promise<void>(async done => {
|
|
22
|
+
hass.floor.current = await hass.floor.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: "floor_registry_updated",
|
|
31
|
+
async exec() {
|
|
32
|
+
await debounce(FLOOR_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
|
|
33
|
+
hass.floor.current = await hass.floor.list();
|
|
34
|
+
logger.debug(`floor registry updated`);
|
|
35
|
+
event.emit(FLOOR_REGISTRY_UPDATED);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
async create(details: FloorCreate) {
|
|
42
|
+
return await new Promise<void>(async done => {
|
|
43
|
+
event.once(FLOOR_REGISTRY_UPDATED, done);
|
|
44
|
+
await hass.socket.sendMessage({
|
|
45
|
+
aliases: [],
|
|
46
|
+
type: "config/floor_registry/create",
|
|
47
|
+
...details,
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
},
|
|
51
|
+
current: [] as FloorDetails[],
|
|
52
|
+
async delete(floor_id: TFloorId) {
|
|
53
|
+
return await new Promise<void>(async done => {
|
|
54
|
+
event.once(FLOOR_REGISTRY_UPDATED, done);
|
|
55
|
+
await hass.socket.sendMessage({
|
|
56
|
+
floor_id,
|
|
57
|
+
type: "config/floor_registry/delete",
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
},
|
|
61
|
+
async list() {
|
|
62
|
+
return await hass.socket.sendMessage<FloorDetails[]>({
|
|
63
|
+
type: "config/floor_registry/list",
|
|
64
|
+
});
|
|
65
|
+
},
|
|
66
|
+
async update(details: FloorDetails) {
|
|
67
|
+
return await new Promise<void>(async done => {
|
|
68
|
+
event.once(FLOOR_REGISTRY_UPDATED, done);
|
|
69
|
+
await hass.socket.sendMessage({
|
|
70
|
+
type: "config/floor_registry/update",
|
|
71
|
+
...details,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|