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