@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,551 @@
|
|
|
1
|
+
import {
|
|
2
|
+
InternalError,
|
|
3
|
+
is,
|
|
4
|
+
SECOND,
|
|
5
|
+
sleep,
|
|
6
|
+
START,
|
|
7
|
+
TBlackHole,
|
|
8
|
+
TServiceParams,
|
|
9
|
+
} from "@digital-alchemy/core";
|
|
10
|
+
import dayjs, { Dayjs } from "dayjs";
|
|
11
|
+
import EventEmitter from "events";
|
|
12
|
+
import WS from "ws";
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
ConnectionState,
|
|
16
|
+
EntityUpdateEvent,
|
|
17
|
+
HassWebsocketAPI,
|
|
18
|
+
OnHassEventOptions,
|
|
19
|
+
SocketMessageDTO,
|
|
20
|
+
SocketSubscribeOptions,
|
|
21
|
+
} from "..";
|
|
22
|
+
|
|
23
|
+
const CONNECTION_OPEN = 1;
|
|
24
|
+
const CLEANUP_INTERVAL = 5;
|
|
25
|
+
const UNLIMITED = 0;
|
|
26
|
+
const CONNECTION_FAILED = 2;
|
|
27
|
+
let messageCount = START;
|
|
28
|
+
export const SOCKET_CONNECTED = "SOCKET_CONNECTED";
|
|
29
|
+
|
|
30
|
+
export function WebsocketAPI({
|
|
31
|
+
context,
|
|
32
|
+
event,
|
|
33
|
+
hass,
|
|
34
|
+
config,
|
|
35
|
+
internal,
|
|
36
|
+
lifecycle,
|
|
37
|
+
logger,
|
|
38
|
+
scheduler,
|
|
39
|
+
}: TServiceParams): HassWebsocketAPI {
|
|
40
|
+
/**
|
|
41
|
+
* Local attachment points for socket events
|
|
42
|
+
*/
|
|
43
|
+
const socketEvents = new EventEmitter();
|
|
44
|
+
socketEvents.setMaxListeners(UNLIMITED);
|
|
45
|
+
|
|
46
|
+
let MESSAGE_TIMESTAMPS: number[] = [];
|
|
47
|
+
let onSocketReady: () => void;
|
|
48
|
+
const waitingCallback = new Map<number, (result: unknown) => TBlackHole>();
|
|
49
|
+
const isOld = (date: Dayjs) =>
|
|
50
|
+
is.undefined(date) || date.diff(dayjs(), "s") >= config.hass.RETRY_INTERVAL;
|
|
51
|
+
|
|
52
|
+
// Start the socket
|
|
53
|
+
lifecycle.onBootstrap(async () => {
|
|
54
|
+
logger.debug({ name: "onBootstrap" }, `auto starting connection`);
|
|
55
|
+
await manageConnection();
|
|
56
|
+
hass.socket.attachScheduledFunctions();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let lastReceivedMessage: Dayjs;
|
|
60
|
+
let pingSleep: ReturnType<typeof sleep>;
|
|
61
|
+
let lastConnectAttempt: Dayjs;
|
|
62
|
+
let lastPingAttempt: Dayjs;
|
|
63
|
+
|
|
64
|
+
// #MARK: setConnectionState
|
|
65
|
+
function setConnectionState(state: ConnectionState) {
|
|
66
|
+
hass.socket.connectionState = state;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// #MARK: handleUnknownConnectionState
|
|
70
|
+
async function handleUnknownConnectionState() {
|
|
71
|
+
const now = dayjs();
|
|
72
|
+
const name = handleUnknownConnectionState;
|
|
73
|
+
const threshold = config.hass.RETRY_INTERVAL * CONNECTION_FAILED;
|
|
74
|
+
if (!isOld(lastPingAttempt)) {
|
|
75
|
+
// if we very recently attempted a ping, do nothing
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
// send a ping message to force a pong
|
|
79
|
+
logger.trace({ name }, `emitting ping`);
|
|
80
|
+
lastPingAttempt = now;
|
|
81
|
+
|
|
82
|
+
// emit a ping, do not wait for reply (inline)
|
|
83
|
+
hass.socket.sendMessage({ type: "ping" }, false);
|
|
84
|
+
|
|
85
|
+
// reply will be captured by this, waiting at most a second
|
|
86
|
+
pingSleep = sleep(SECOND);
|
|
87
|
+
await pingSleep;
|
|
88
|
+
pingSleep = undefined;
|
|
89
|
+
|
|
90
|
+
if (!isOld(lastReceivedMessage)) {
|
|
91
|
+
// received a least a pong
|
|
92
|
+
hass.socket.setConnectionState("connected");
|
|
93
|
+
logger.trace({ name }, `still there {unknown} => {connected}`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 😨 hass didn't reply
|
|
98
|
+
if (lastReceivedMessage.diff(now, "s") < threshold) {
|
|
99
|
+
// take a deep breath, and try again
|
|
100
|
+
logger.warn({ name }, "failed to receive expected {pong}");
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// 🪦 oof, get rid of the current connection and try again
|
|
104
|
+
await hass.socket.teardown();
|
|
105
|
+
logger.warn({ name }, "hass stopped replying {unknown} => {offline}");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// #MARK: ManageConnection
|
|
109
|
+
async function manageConnection() {
|
|
110
|
+
const now = dayjs();
|
|
111
|
+
const name = manageConnection;
|
|
112
|
+
switch (hass.socket.connectionState) {
|
|
113
|
+
// * connected
|
|
114
|
+
case "connected": {
|
|
115
|
+
if (!isOld(lastReceivedMessage)) {
|
|
116
|
+
// if hass is actively sending messages, don't do anything
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
// 🦗 haven't heard from hass in a while
|
|
120
|
+
hass.socket.setConnectionState("unknown");
|
|
121
|
+
logger.trace({ name }, "no replies in a while {connected} => {unknown}");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// * unknown
|
|
125
|
+
// fall through
|
|
126
|
+
case "unknown": {
|
|
127
|
+
await handleUnknownConnectionState();
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// * connecting
|
|
132
|
+
case "connecting": {
|
|
133
|
+
if (!isOld(lastConnectAttempt)) {
|
|
134
|
+
// schedule happened in the middle of a connect attempt
|
|
135
|
+
// weird but possible
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
// connect probably stalled somewhere
|
|
139
|
+
// maybe we tried to connect before hass was actually ready for an incoming connection?
|
|
140
|
+
//
|
|
141
|
+
// reset and try again
|
|
142
|
+
await hass.socket.teardown();
|
|
143
|
+
logger.warn({ name }, "connection failed {connecting} => {offline}");
|
|
144
|
+
await manageConnection();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// fall through
|
|
149
|
+
case "offline": {
|
|
150
|
+
// ### offline
|
|
151
|
+
// * connection identifies as offline, let's attempt to fix that
|
|
152
|
+
messageCount = START;
|
|
153
|
+
lastConnectAttempt = now;
|
|
154
|
+
hass.socket.setConnectionState("connecting");
|
|
155
|
+
logger.debug({ name }, "initializing new socket {offline} => {connecting}");
|
|
156
|
+
try {
|
|
157
|
+
await hass.socket.init();
|
|
158
|
+
hass.socket.setConnectionState("connected");
|
|
159
|
+
logger.debug({ name }, "auth success {connecting} => {connected}");
|
|
160
|
+
} catch (error) {
|
|
161
|
+
logger.error({ error, name }, "init failed {connecting} => {offline}");
|
|
162
|
+
await hass.socket.teardown();
|
|
163
|
+
}
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
case "invalid": {
|
|
168
|
+
// ### error
|
|
169
|
+
logger.error({ name }, "socket received error, check credentials and restart application");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// #MARK: attachScheduledFunctions
|
|
176
|
+
function attachScheduledFunctions() {
|
|
177
|
+
logger.trace({ name: attachScheduledFunctions }, `attaching interval schedules`);
|
|
178
|
+
scheduler.setInterval(
|
|
179
|
+
async () => await manageConnection(),
|
|
180
|
+
config.hass.RETRY_INTERVAL * SECOND,
|
|
181
|
+
);
|
|
182
|
+
scheduler.setInterval(() => {
|
|
183
|
+
const target = Date.now() - SECOND * config.hass.SOCKET_AVG_DURATION;
|
|
184
|
+
MESSAGE_TIMESTAMPS = MESSAGE_TIMESTAMPS.filter(time => time > target);
|
|
185
|
+
}, CLEANUP_INTERVAL * SECOND);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
lifecycle.onShutdownStart(async () => {
|
|
189
|
+
logger.debug({ name: "onShutdownStart" }, `shutdown - tearing down connection`);
|
|
190
|
+
await hass.socket.teardown();
|
|
191
|
+
socketEvents.removeAllListeners();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
// #MARK: teardown
|
|
195
|
+
async function teardown() {
|
|
196
|
+
if (!hass.socket.connection) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (hass.socket.connection.readyState === CONNECTION_OPEN) {
|
|
200
|
+
logger.debug({ name: teardown }, `closing current connection`);
|
|
201
|
+
hass.socket.connection.close();
|
|
202
|
+
}
|
|
203
|
+
hass.socket.connection = undefined;
|
|
204
|
+
hass.socket.setConnectionState("offline");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// #MARK: fireEvent
|
|
208
|
+
async function fireEvent(event_type: string, event_data: object = {}) {
|
|
209
|
+
return await hass.socket.sendMessage({
|
|
210
|
+
event_data,
|
|
211
|
+
event_type,
|
|
212
|
+
type: "fire_event",
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// #MARK: sendMessage
|
|
217
|
+
async function sendMessage<RESPONSE_VALUE extends unknown = unknown>(
|
|
218
|
+
data: {
|
|
219
|
+
type: string;
|
|
220
|
+
id?: number;
|
|
221
|
+
[key: string]: unknown;
|
|
222
|
+
},
|
|
223
|
+
waitForResponse = true,
|
|
224
|
+
subscription?: () => void,
|
|
225
|
+
): Promise<RESPONSE_VALUE> {
|
|
226
|
+
if (hass.socket.connectionState === "offline") {
|
|
227
|
+
logger.error({ name: sendMessage }, "socket is closed, cannot send message");
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (hass.socket.pauseMessages && data.type !== "ping") {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
countMessage();
|
|
235
|
+
const id = messageCount;
|
|
236
|
+
if (data.type !== "auth") {
|
|
237
|
+
data.id = id;
|
|
238
|
+
}
|
|
239
|
+
const json = JSON.stringify(data);
|
|
240
|
+
const sentAt = new Date();
|
|
241
|
+
|
|
242
|
+
// ? not defined for unit tests
|
|
243
|
+
hass.socket.connection?.send(json);
|
|
244
|
+
if (subscription) {
|
|
245
|
+
return data.id as RESPONSE_VALUE;
|
|
246
|
+
}
|
|
247
|
+
if (!waitForResponse) {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
return await new Promise<RESPONSE_VALUE>(async done => {
|
|
251
|
+
waitingCallback.set(id, done as (result: unknown) => TBlackHole);
|
|
252
|
+
await hass.socket.waitForReply(id, data, sentAt);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function waitForReply(id: number, data: object, sentAt: Date) {
|
|
257
|
+
await sleep(config.hass.EXPECT_RESPONSE_AFTER * SECOND);
|
|
258
|
+
if (!waitingCallback.has(id)) {
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
// this could happen around dropped connections, or a number of other reasons
|
|
262
|
+
//
|
|
263
|
+
// discard the promise so whatever flow is depending on this can get garbage collected
|
|
264
|
+
waitingCallback.delete(id);
|
|
265
|
+
logger.warn(
|
|
266
|
+
{
|
|
267
|
+
message: data,
|
|
268
|
+
name: waitForReply,
|
|
269
|
+
sentAt: internal.utils.relativeDate(sentAt),
|
|
270
|
+
},
|
|
271
|
+
`sent message, did not receive reply`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// #MARK: countMessage
|
|
276
|
+
function countMessage(): void | never {
|
|
277
|
+
messageCount++;
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
MESSAGE_TIMESTAMPS.push(now);
|
|
280
|
+
const avgWindow = config.hass.SOCKET_AVG_DURATION;
|
|
281
|
+
|
|
282
|
+
const perSecondAverage = Math.ceil(
|
|
283
|
+
MESSAGE_TIMESTAMPS.filter(time => time > now - SECOND * avgWindow).length / avgWindow,
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
const { SOCKET_CRASH_REQUESTS_PER_SEC: crashCount, SOCKET_WARN_REQUESTS_PER_SEC: warnCount } =
|
|
287
|
+
config.hass;
|
|
288
|
+
|
|
289
|
+
if (perSecondAverage > crashCount) {
|
|
290
|
+
logger.fatal(
|
|
291
|
+
{ name: countMessage },
|
|
292
|
+
`exceeded {CRASH_REQUESTS_PER_MIN} ([%s]) threshold`,
|
|
293
|
+
crashCount,
|
|
294
|
+
);
|
|
295
|
+
process.exit();
|
|
296
|
+
}
|
|
297
|
+
if (perSecondAverage > warnCount) {
|
|
298
|
+
logger.warn(
|
|
299
|
+
{ name: countMessage },
|
|
300
|
+
`message traffic [%s] > [%s] > [%s]`,
|
|
301
|
+
crashCount,
|
|
302
|
+
perSecondAverage,
|
|
303
|
+
warnCount,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// #MARK: getUrl
|
|
309
|
+
function getUrl() {
|
|
310
|
+
const url = new URL(config.hass.BASE_URL);
|
|
311
|
+
const protocol = url.protocol === `http:` ? `ws:` : `wss:`;
|
|
312
|
+
const path = url.pathname === "/" ? "" : url.pathname;
|
|
313
|
+
const port = url.port ? `:${url.port}` : "";
|
|
314
|
+
return `${protocol}//${url.hostname}${port}${path}/api/websocket`;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// #MARK: init
|
|
318
|
+
async function init(): Promise<void> {
|
|
319
|
+
if (hass.socket.connection) {
|
|
320
|
+
throw new InternalError(
|
|
321
|
+
context,
|
|
322
|
+
"ExistingConnection",
|
|
323
|
+
`Destroy the current connection before creating a new one`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
messageCount = START;
|
|
327
|
+
const url = getUrl();
|
|
328
|
+
try {
|
|
329
|
+
hass.socket.connection = hass.socket.createConnection(url);
|
|
330
|
+
hass.socket.connection.on("message", async (message: string) => {
|
|
331
|
+
try {
|
|
332
|
+
lastReceivedMessage = dayjs();
|
|
333
|
+
const data = JSON.parse(message.toString());
|
|
334
|
+
await onMessage(data);
|
|
335
|
+
pingSleep?.kill("continue");
|
|
336
|
+
} catch (error) {
|
|
337
|
+
// My expectation is `internal.safeExec` should trap any application errors
|
|
338
|
+
// This try/catch should actually be excessive
|
|
339
|
+
// If this error happens, something weird is happening
|
|
340
|
+
logger.error(
|
|
341
|
+
{ error, name: init },
|
|
342
|
+
`💣 error bubbled up from websocket message event handler`,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
hass.socket.connection.on("error", async (error: Error) => {
|
|
348
|
+
logger.error({ error: error, name: init }, "socket error");
|
|
349
|
+
if (hass.socket.connectionState === "connected") {
|
|
350
|
+
hass.socket.setConnectionState("unknown");
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
hass.socket.connection.on("close", async (code, reason) => {
|
|
355
|
+
if (!config.boilerplate.IS_TEST) {
|
|
356
|
+
logger.warn({ code, name: init, reason: reason.toString() }, "connection closed");
|
|
357
|
+
}
|
|
358
|
+
await hass.socket.teardown();
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
await new Promise<void>(done => (onSocketReady = done));
|
|
362
|
+
} catch (error) {
|
|
363
|
+
logger.error({ error, name: init, url }, `initConnection error`);
|
|
364
|
+
hass.socket.setConnectionState("offline");
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// #MARK: onMessage
|
|
369
|
+
/**
|
|
370
|
+
* Called on incoming message.
|
|
371
|
+
* Intended to interpret the basic concept of the message,
|
|
372
|
+
* and route it to the correct callback / global channel / etc
|
|
373
|
+
*
|
|
374
|
+
* ## auth_required
|
|
375
|
+
* Hello message from server, should reply back with an auth msg
|
|
376
|
+
* ## auth_ok
|
|
377
|
+
* Follow up with a request to receive all events, and request a current state listing
|
|
378
|
+
* ## event
|
|
379
|
+
* Something updated it's state
|
|
380
|
+
* ## pong
|
|
381
|
+
* Reply to outgoing ping()
|
|
382
|
+
* ## result
|
|
383
|
+
* Response to an outgoing emit
|
|
384
|
+
*/
|
|
385
|
+
async function onMessage(message: SocketMessageDTO) {
|
|
386
|
+
const id = Number(message.id);
|
|
387
|
+
switch (message.type) {
|
|
388
|
+
case "auth_required": {
|
|
389
|
+
logger.trace({ name: onMessage }, `sending authentication`);
|
|
390
|
+
hass.socket.sendMessage({ access_token: config.hass.TOKEN, type: "auth" }, false);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
case "auth_ok": {
|
|
395
|
+
// * Flag as valid connection
|
|
396
|
+
logger.trace({ name: onMessage }, `event subscriptions starting`);
|
|
397
|
+
await hass.socket.sendMessage({ type: "subscribe_events" }, false);
|
|
398
|
+
onSocketReady?.();
|
|
399
|
+
event.emit(SOCKET_CONNECTED);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
case "event": {
|
|
404
|
+
return await onMessageEvent(id, message);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// 👾
|
|
408
|
+
case "pong": {
|
|
409
|
+
// nothing in particular needs to be done, just don't log an error (default)
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
case "result": {
|
|
414
|
+
return await onMessageResult(id, message);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
case "auth_invalid": {
|
|
418
|
+
hass.socket.setConnectionState("invalid");
|
|
419
|
+
logger.fatal(
|
|
420
|
+
{ message, name: onMessage },
|
|
421
|
+
"received auth invalid {connecting} => {invalid}",
|
|
422
|
+
);
|
|
423
|
+
// ? If you have a use case for making this exit configurable, open a ticket
|
|
424
|
+
process.exit();
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
default: {
|
|
429
|
+
// Code error probably?
|
|
430
|
+
logger.error({ name: onMessage }, `unknown websocket message type: ${message.type}`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// #MARK: onMessageEvent
|
|
436
|
+
function onMessageEvent(id: number, message: SocketMessageDTO) {
|
|
437
|
+
if (message.event.event_type === "state_changed") {
|
|
438
|
+
const { new_state, old_state } = message.event.data;
|
|
439
|
+
const id = new_state?.entity_id || old_state.entity_id;
|
|
440
|
+
if (is.empty(id)) {
|
|
441
|
+
throw new InternalError(
|
|
442
|
+
context,
|
|
443
|
+
"NO_ID",
|
|
444
|
+
"Received state change, but could not identify an entity_id",
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
// ? null = deleted entity
|
|
448
|
+
// TODO: handle renames properly
|
|
449
|
+
if (new_state || new_state === null) {
|
|
450
|
+
hass.entity._entityUpdateReceiver(id, new_state, old_state);
|
|
451
|
+
} else {
|
|
452
|
+
// FIXME: probably removal / rename?
|
|
453
|
+
// It's an edge case for sure, and not positive this code should handle it
|
|
454
|
+
// If you have thoughts, chime in somewhere and we can do more sane things
|
|
455
|
+
logger.debug(
|
|
456
|
+
{ message, name: onMessageEvent },
|
|
457
|
+
`no new state for entity, what caused this?`,
|
|
458
|
+
);
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (hass.socket.pauseMessages) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
if (waitingCallback.has(id)) {
|
|
467
|
+
const f = waitingCallback.get(id);
|
|
468
|
+
waitingCallback.delete(id);
|
|
469
|
+
f(message.event.result);
|
|
470
|
+
}
|
|
471
|
+
socketEvents.emit(message.event.event_type, message.event);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function onMessageResult(id: number, message: SocketMessageDTO) {
|
|
475
|
+
if (waitingCallback.has(id)) {
|
|
476
|
+
if (message.error) {
|
|
477
|
+
logger.error({ message, name: onMessageResult });
|
|
478
|
+
}
|
|
479
|
+
const f = waitingCallback.get(id);
|
|
480
|
+
waitingCallback.delete(id);
|
|
481
|
+
f(message.result);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// #MARK: onEvent
|
|
486
|
+
function onEvent<DATA extends object>({ context, event, once, exec }: OnHassEventOptions<DATA>) {
|
|
487
|
+
logger.trace({ context, event, name: onEvent }, `attaching socket event listener`);
|
|
488
|
+
const callback = async (data: EntityUpdateEvent) => {
|
|
489
|
+
await internal.safeExec(async () => await exec(data as DATA));
|
|
490
|
+
};
|
|
491
|
+
if (once) {
|
|
492
|
+
socketEvents.once(event, callback);
|
|
493
|
+
} else {
|
|
494
|
+
socketEvents.on(event, callback);
|
|
495
|
+
}
|
|
496
|
+
return () => {
|
|
497
|
+
logger.trace({ context, event, name: onEvent }, `removing socket event listener`);
|
|
498
|
+
socketEvents.removeListener(event, callback);
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// #MARK: subscribe
|
|
503
|
+
async function subscribe<EVENT extends string>({
|
|
504
|
+
event_type,
|
|
505
|
+
context,
|
|
506
|
+
exec,
|
|
507
|
+
}: SocketSubscribeOptions<EVENT>) {
|
|
508
|
+
await hass.socket.sendMessage({ event_type, type: "subscribe_events" });
|
|
509
|
+
hass.socket.onEvent({
|
|
510
|
+
context,
|
|
511
|
+
event: event_type,
|
|
512
|
+
exec,
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// #MARK: onConnect
|
|
517
|
+
function onConnect(callback: () => TBlackHole) {
|
|
518
|
+
const wrapped = async () => {
|
|
519
|
+
await internal.safeExec(async () => await callback());
|
|
520
|
+
};
|
|
521
|
+
if (hass.socket.connectionState === "connected") {
|
|
522
|
+
logger.debug(
|
|
523
|
+
{ name: "onConnect" },
|
|
524
|
+
`added callback after socket was already connected, running immediately`,
|
|
525
|
+
);
|
|
526
|
+
setImmediate(wrapped);
|
|
527
|
+
// attach anyways, for restarts or whatever
|
|
528
|
+
}
|
|
529
|
+
event.on(SOCKET_CONNECTED, wrapped);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// #MARK: return object
|
|
533
|
+
return {
|
|
534
|
+
attachScheduledFunctions,
|
|
535
|
+
connection: undefined as WS,
|
|
536
|
+
connectionState: "offline" as ConnectionState,
|
|
537
|
+
createConnection: (url: string) => new WS(url),
|
|
538
|
+
fireEvent,
|
|
539
|
+
init,
|
|
540
|
+
onConnect,
|
|
541
|
+
onEvent,
|
|
542
|
+
onMessage,
|
|
543
|
+
pauseMessages: false,
|
|
544
|
+
sendMessage,
|
|
545
|
+
setConnectionState,
|
|
546
|
+
socketEvents,
|
|
547
|
+
subscribe,
|
|
548
|
+
teardown,
|
|
549
|
+
waitForReply,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { debounce, TServiceParams } from "@digital-alchemy/core";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
EARLY_ON_READY,
|
|
5
|
+
HassZoneService,
|
|
6
|
+
ManifestItem,
|
|
7
|
+
ZONE_REGISTRY_UPDATED,
|
|
8
|
+
ZoneDetails,
|
|
9
|
+
ZoneOptions,
|
|
10
|
+
} from "../helpers";
|
|
11
|
+
|
|
12
|
+
export function Zone({
|
|
13
|
+
config,
|
|
14
|
+
hass,
|
|
15
|
+
event,
|
|
16
|
+
logger,
|
|
17
|
+
context,
|
|
18
|
+
lifecycle,
|
|
19
|
+
}: TServiceParams): HassZoneService {
|
|
20
|
+
hass.socket.onConnect(async () => {
|
|
21
|
+
let loading = new Promise<void>(async done => {
|
|
22
|
+
hass.zone.current = await hass.zone.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: "zone_registry_updated",
|
|
31
|
+
async exec() {
|
|
32
|
+
await debounce(ZONE_REGISTRY_UPDATED, config.hass.EVENT_DEBOUNCE_MS);
|
|
33
|
+
hass.zone.current = await hass.zone.list();
|
|
34
|
+
logger.debug(`zone registry updated`);
|
|
35
|
+
event.emit(ZONE_REGISTRY_UPDATED);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function ZoneCreate(options: ZoneOptions) {
|
|
41
|
+
return new Promise<void>(async done => {
|
|
42
|
+
event.once(ZONE_REGISTRY_UPDATED, done);
|
|
43
|
+
await hass.socket.sendMessage<ManifestItem[]>({
|
|
44
|
+
...options,
|
|
45
|
+
type: "zone/create",
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function ZoneUpdate(zone_id: string, options: ZoneOptions) {
|
|
51
|
+
await hass.socket.sendMessage<ManifestItem[]>({
|
|
52
|
+
...options,
|
|
53
|
+
type: "zone/create",
|
|
54
|
+
zone_id,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function ZoneList() {
|
|
59
|
+
return await hass.socket.sendMessage<ZoneDetails[]>({
|
|
60
|
+
type: "zone/list",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
create: ZoneCreate,
|
|
65
|
+
current: [] as ZoneDetails[],
|
|
66
|
+
list: ZoneList,
|
|
67
|
+
update: ZoneUpdate,
|
|
68
|
+
};
|
|
69
|
+
}
|