@glasshome/sync-layer 0.1.0
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/LICENSE +21 -0
- package/README.md +120 -0
- package/dist/area-views-B-m8vwce.js +321 -0
- package/dist/camera/fetch.d.ts +15 -0
- package/dist/camera/query.d.ts +15 -0
- package/dist/camera/types.d.ts +85 -0
- package/dist/camera/webrtc.d.ts +31 -0
- package/dist/commands/service.d.ts +53 -0
- package/dist/commands/types.d.ts +113 -0
- package/dist/connection/adapter.d.ts +48 -0
- package/dist/connection/auth.d.ts +126 -0
- package/dist/connection/manager.d.ts +42 -0
- package/dist/connection/subscriptions.d.ts +25 -0
- package/dist/connection/types.d.ts +161 -0
- package/dist/core/reducers.d.ts +99 -0
- package/dist/core/store.d.ts +64 -0
- package/dist/core/types.d.ts +336 -0
- package/dist/demo/demo-data.d.ts +24 -0
- package/dist/demo/demo-provider.d.ts +33 -0
- package/dist/demo/demo-provider.js +103 -0
- package/dist/demo-data-D8JXWRpw.js +419 -0
- package/dist/entities/area-views.d.ts +25 -0
- package/dist/entities/index.d.ts +54 -0
- package/dist/entities/views.d.ts +30 -0
- package/dist/history/constants.d.ts +16 -0
- package/dist/history/fetch.d.ts +19 -0
- package/dist/history/query.d.ts +13 -0
- package/dist/history/types.d.ts +146 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.js +1747 -0
- package/dist/query/area-builder.d.ts +25 -0
- package/dist/query/builder.d.ts +18 -0
- package/dist/query/helpers.d.ts +21 -0
- package/dist/query/memoization.d.ts +18 -0
- package/dist/query/projections.d.ts +74 -0
- package/dist/query/types.d.ts +260 -0
- package/dist/solid/hooks.d.ts +88 -0
- package/dist/solid/index.d.ts +10 -0
- package/dist/solid/index.js +176 -0
- package/dist/solid/indices.d.ts +14 -0
- package/dist/store-B1sbyexX.js +54 -0
- package/dist/testing/fixtures.d.ts +161 -0
- package/dist/testing/helpers.d.ts +160 -0
- package/dist/testing/index.d.ts +13 -0
- package/dist/testing/index.js +2047 -0
- package/dist/testing/mock-behaviors/base.d.ts +73 -0
- package/dist/testing/mock-behaviors/climate.d.ts +22 -0
- package/dist/testing/mock-behaviors/cover.d.ts +22 -0
- package/dist/testing/mock-behaviors/index.d.ts +11 -0
- package/dist/testing/mock-behaviors/light.d.ts +20 -0
- package/dist/testing/mock-behaviors/lock.d.ts +20 -0
- package/dist/testing/mock-behaviors/media_player.d.ts +42 -0
- package/dist/testing/mock-behaviors/registry.d.ts +55 -0
- package/dist/testing/mock-behaviors/switch.d.ts +17 -0
- package/dist/testing/mock.d.ts +191 -0
- package/dist/testing/types.d.ts +87 -0
- package/dist/types-Csog0nqk.js +10 -0
- package/dist/weather/fetch.d.ts +19 -0
- package/dist/weather/query.d.ts +23 -0
- package/dist/weather/types.d.ts +126 -0
- package/package.json +85 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1747 @@
|
|
|
1
|
+
import { resetStore, setState, state } from "./store-B1sbyexX.js";
|
|
2
|
+
import { batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, callService, getAllEntityIds, getAllEntityViews, getAreaView, getAreaViews, getEntitiesByArea, getEntitiesByDevice, getEntitiesByDomain, getEntitiesByFloor, getEntitiesByLabel, getEntityView, getEntityViews, sendCommand, toggle, turnOff, turnOn, updateEntity } from "./area-views-B-m8vwce.js";
|
|
3
|
+
import { extractDomain } from "./types-Csog0nqk.js";
|
|
4
|
+
import { createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
|
|
5
|
+
import { produce } from "solid-js/store";
|
|
6
|
+
|
|
7
|
+
//#region src/connection/adapter.ts
|
|
8
|
+
/**
|
|
9
|
+
* Set of debug listeners for outgoing messages.
|
|
10
|
+
* Supports multiple concurrent observers (e.g., WebSocket event recorder + network monitor).
|
|
11
|
+
*/
|
|
12
|
+
const debugOutgoingMessageListeners = /* @__PURE__ */ new Set();
|
|
13
|
+
/**
|
|
14
|
+
* Set of debug listeners for incoming messages.
|
|
15
|
+
* Captures subscription callbacks and sendMessagePromise responses.
|
|
16
|
+
*/
|
|
17
|
+
const debugIncomingMessageListeners = /* @__PURE__ */ new Set();
|
|
18
|
+
/**
|
|
19
|
+
* Register a listener for all outgoing WebSocket messages.
|
|
20
|
+
* Returns an unsubscribe function.
|
|
21
|
+
*
|
|
22
|
+
* @param listener - Function called with each outgoing message object
|
|
23
|
+
* @returns Unsubscribe function to remove the listener
|
|
24
|
+
*/
|
|
25
|
+
function addDebugOutgoingMessageListener(listener) {
|
|
26
|
+
debugOutgoingMessageListeners.add(listener);
|
|
27
|
+
return () => {
|
|
28
|
+
debugOutgoingMessageListeners.delete(listener);
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Register a listener for all incoming WebSocket messages.
|
|
33
|
+
* Returns an unsubscribe function. Receives subscription updates,
|
|
34
|
+
* event callbacks, and sendMessagePromise responses.
|
|
35
|
+
*
|
|
36
|
+
* @param listener - Function called with each incoming message object
|
|
37
|
+
* @returns Unsubscribe function to remove the listener
|
|
38
|
+
*/
|
|
39
|
+
function addDebugIncomingMessageListener(listener) {
|
|
40
|
+
debugIncomingMessageListeners.add(listener);
|
|
41
|
+
return () => {
|
|
42
|
+
debugIncomingMessageListeners.delete(listener);
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function notifyOutgoingListeners(message) {
|
|
46
|
+
if (debugOutgoingMessageListeners.size > 0) for (const listener of debugOutgoingMessageListeners) listener(message);
|
|
47
|
+
}
|
|
48
|
+
function notifyIncomingListeners(message) {
|
|
49
|
+
if (debugIncomingMessageListeners.size > 0) for (const listener of debugIncomingMessageListeners) listener(message);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Wrap HA Connection to SyncLayerConnection interface
|
|
53
|
+
*
|
|
54
|
+
* This adapter makes the real HA Connection compatible with our unified interface,
|
|
55
|
+
* allowing it to be used interchangeably with MockConnection.
|
|
56
|
+
*
|
|
57
|
+
* @param conn - Home Assistant WebSocket connection
|
|
58
|
+
* @returns Wrapped connection implementing SyncLayerConnection
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```typescript
|
|
62
|
+
* const haConn = await createConnection({ auth });
|
|
63
|
+
* const wrappedConn = wrapHAConnection(haConn);
|
|
64
|
+
* await syncLayer.connect({ connection: wrappedConn });
|
|
65
|
+
* ```
|
|
66
|
+
*/
|
|
67
|
+
function wrapHAConnection(conn) {
|
|
68
|
+
return {
|
|
69
|
+
connect: async () => {
|
|
70
|
+
return Promise.resolve();
|
|
71
|
+
},
|
|
72
|
+
disconnect: () => {
|
|
73
|
+
conn.close();
|
|
74
|
+
},
|
|
75
|
+
get connected$() {
|
|
76
|
+
return conn.connected;
|
|
77
|
+
},
|
|
78
|
+
authenticate: async () => {
|
|
79
|
+
return Promise.resolve();
|
|
80
|
+
},
|
|
81
|
+
get authState() {
|
|
82
|
+
return "authenticated";
|
|
83
|
+
},
|
|
84
|
+
subscribeEvents: async (callback, eventType) => {
|
|
85
|
+
const wrappedCallback = (event) => {
|
|
86
|
+
notifyIncomingListeners(event);
|
|
87
|
+
callback(event);
|
|
88
|
+
};
|
|
89
|
+
return conn.subscribeEvents(wrappedCallback, eventType);
|
|
90
|
+
},
|
|
91
|
+
subscribeMessage: async (callback, message) => {
|
|
92
|
+
const wrappedCallback = (msg) => {
|
|
93
|
+
notifyIncomingListeners(msg);
|
|
94
|
+
callback(msg);
|
|
95
|
+
};
|
|
96
|
+
return conn.subscribeMessage(wrappedCallback, message);
|
|
97
|
+
},
|
|
98
|
+
sendMessagePromise: async (message) => {
|
|
99
|
+
notifyOutgoingListeners(message);
|
|
100
|
+
const result = await conn.sendMessagePromise(message);
|
|
101
|
+
notifyIncomingListeners(result);
|
|
102
|
+
return result;
|
|
103
|
+
},
|
|
104
|
+
addEventListener: (handler) => {
|
|
105
|
+
return addDebugIncomingMessageListener(handler);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
//#region src/core/reducers.ts
|
|
112
|
+
/**
|
|
113
|
+
* Bulk update multiple entities
|
|
114
|
+
*
|
|
115
|
+
* Used during initial load or bulk sync operations.
|
|
116
|
+
*
|
|
117
|
+
* @param entities - Array of entity states
|
|
118
|
+
*/
|
|
119
|
+
function bulkUpdateEntities(entities$1) {
|
|
120
|
+
setState(produce((s) => {
|
|
121
|
+
for (const entity$1 of entities$1) s.entities[entity$1.entity_id] = entity$1;
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Update entity registry entry
|
|
126
|
+
*
|
|
127
|
+
* @param entry - Entity registry entry
|
|
128
|
+
*/
|
|
129
|
+
function updateEntityRegistry(entry) {
|
|
130
|
+
setState(produce((s) => {
|
|
131
|
+
s.entityRegistry[entry.entity_id] = entry;
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Remove entity registry entry
|
|
136
|
+
*
|
|
137
|
+
* @param entityId - Entity ID to remove
|
|
138
|
+
*/
|
|
139
|
+
function removeEntityRegistry(entityId) {
|
|
140
|
+
setState(produce((s) => {
|
|
141
|
+
delete s.entityRegistry[entityId];
|
|
142
|
+
}));
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Bulk update entity registry
|
|
146
|
+
*
|
|
147
|
+
* @param entries - Array of registry entries
|
|
148
|
+
*/
|
|
149
|
+
function bulkUpdateEntityRegistry(entries) {
|
|
150
|
+
setState(produce((s) => {
|
|
151
|
+
for (const entry of entries) s.entityRegistry[entry.entity_id] = entry;
|
|
152
|
+
}));
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Update device registry entry
|
|
156
|
+
*
|
|
157
|
+
* @param device - Device registry entry
|
|
158
|
+
*/
|
|
159
|
+
function updateDevice(device) {
|
|
160
|
+
setState(produce((s) => {
|
|
161
|
+
s.devices[device.id] = device;
|
|
162
|
+
}));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Remove device registry entry
|
|
166
|
+
*
|
|
167
|
+
* @param deviceId - Device ID to remove
|
|
168
|
+
*/
|
|
169
|
+
function removeDevice(deviceId) {
|
|
170
|
+
setState(produce((s) => {
|
|
171
|
+
delete s.devices[deviceId];
|
|
172
|
+
}));
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Update area registry entry
|
|
176
|
+
*
|
|
177
|
+
* @param area - Area registry entry
|
|
178
|
+
*/
|
|
179
|
+
function updateArea(area$1) {
|
|
180
|
+
setState(produce((s) => {
|
|
181
|
+
s.areas[area$1.id] = area$1;
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Remove area registry entry
|
|
186
|
+
*
|
|
187
|
+
* @param areaId - Area ID to remove
|
|
188
|
+
*/
|
|
189
|
+
function removeArea(areaId) {
|
|
190
|
+
setState(produce((s) => {
|
|
191
|
+
delete s.areas[areaId];
|
|
192
|
+
}));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Update floor registry entry
|
|
196
|
+
*
|
|
197
|
+
* @param floor - Floor registry entry
|
|
198
|
+
*/
|
|
199
|
+
function updateFloor(floor) {
|
|
200
|
+
setState(produce((s) => {
|
|
201
|
+
s.floors[floor.floor_id] = floor;
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Remove floor registry entry
|
|
206
|
+
*
|
|
207
|
+
* @param floorId - Floor ID to remove
|
|
208
|
+
*/
|
|
209
|
+
function removeFloor(floorId) {
|
|
210
|
+
setState(produce((s) => {
|
|
211
|
+
delete s.floors[floorId];
|
|
212
|
+
}));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Update label registry entry
|
|
216
|
+
*
|
|
217
|
+
* @param label - Label registry entry
|
|
218
|
+
*/
|
|
219
|
+
function updateLabel(label) {
|
|
220
|
+
setState(produce((s) => {
|
|
221
|
+
s.labels[label.label_id] = label;
|
|
222
|
+
}));
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Remove label registry entry
|
|
226
|
+
*
|
|
227
|
+
* @param labelId - Label ID to remove
|
|
228
|
+
*/
|
|
229
|
+
function removeLabel(labelId) {
|
|
230
|
+
setState(produce((s) => {
|
|
231
|
+
delete s.labels[labelId];
|
|
232
|
+
}));
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Update statistics metadata
|
|
236
|
+
*
|
|
237
|
+
* @param metadata - Statistics metadata
|
|
238
|
+
*/
|
|
239
|
+
function updateStatisticsMetadata(metadata) {
|
|
240
|
+
setState(produce((s) => {
|
|
241
|
+
for (const meta of metadata) s.statisticsMetadata[meta.statistic_id] = meta;
|
|
242
|
+
}));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/connection/auth.ts
|
|
247
|
+
/**
|
|
248
|
+
* Authenticate using OAuth flow
|
|
249
|
+
*
|
|
250
|
+
* This function uses `getAuth` from `home-assistant-js-websocket` which:
|
|
251
|
+
* 1. Opens Home Assistant authorization page in a popup
|
|
252
|
+
* 2. Waits for user authorization
|
|
253
|
+
* 3. Exchanges authorization code for tokens
|
|
254
|
+
* 4. Returns authenticated Auth object
|
|
255
|
+
*
|
|
256
|
+
* Note: For a complete OAuth flow, you typically need to:
|
|
257
|
+
* - Set up a redirect URI endpoint in your app
|
|
258
|
+
* - Handle the OAuth callback with the authorization code
|
|
259
|
+
* - Use `getAuthCode` callback to provide the code
|
|
260
|
+
*
|
|
261
|
+
* Alternatively, `getAuth` can automatically handle the flow in browser environments
|
|
262
|
+
* by opening a popup and listening for the callback.
|
|
263
|
+
*
|
|
264
|
+
* @param options - OAuth options
|
|
265
|
+
* @returns Authenticated Auth object
|
|
266
|
+
*
|
|
267
|
+
* @example
|
|
268
|
+
* ```typescript
|
|
269
|
+
* // Basic OAuth flow (browser only)
|
|
270
|
+
* const auth = await authenticateWithOAuth({
|
|
271
|
+
* url: 'https://homeassistant.local:8123',
|
|
272
|
+
* clientId: 'https://homeassistant.local:8123',
|
|
273
|
+
* redirectUri: 'https://myapp.com/auth/callback',
|
|
274
|
+
* });
|
|
275
|
+
* ```
|
|
276
|
+
*
|
|
277
|
+
* @example
|
|
278
|
+
* ```typescript
|
|
279
|
+
* // OAuth with custom code handling
|
|
280
|
+
* const auth = await authenticateWithOAuth({
|
|
281
|
+
* url: 'https://homeassistant.local:8123',
|
|
282
|
+
* clientId: 'https://homeassistant.local:8123',
|
|
283
|
+
* redirectUri: 'https://myapp.com/auth/callback',
|
|
284
|
+
* getAuthCode: async () => {
|
|
285
|
+
* // Wait for user to authorize and get code from callback
|
|
286
|
+
* return await waitForAuthCode();
|
|
287
|
+
* },
|
|
288
|
+
* });
|
|
289
|
+
* ```
|
|
290
|
+
*/
|
|
291
|
+
async function authenticateWithOAuth(options) {
|
|
292
|
+
const { url, clientId, redirectUri, authCode, getAuthCode, saveAuth, loadAuth, limitHassInstance } = options;
|
|
293
|
+
try {
|
|
294
|
+
const auth$1 = await getAuth({
|
|
295
|
+
hassUrl: url,
|
|
296
|
+
clientId: clientId ?? null,
|
|
297
|
+
redirectUrl: redirectUri,
|
|
298
|
+
authCode,
|
|
299
|
+
limitHassInstance,
|
|
300
|
+
async loadTokens() {
|
|
301
|
+
if (loadAuth) {
|
|
302
|
+
const saved = await loadAuth();
|
|
303
|
+
if (saved?.data) return saved.data;
|
|
304
|
+
}
|
|
305
|
+
return null;
|
|
306
|
+
},
|
|
307
|
+
saveTokens(tokens) {
|
|
308
|
+
if (saveAuth && tokens) {
|
|
309
|
+
const authObj = {
|
|
310
|
+
hassUrl: url,
|
|
311
|
+
clientId,
|
|
312
|
+
redirectUrl: redirectUri,
|
|
313
|
+
data: tokens,
|
|
314
|
+
async refreshAccessToken() {
|
|
315
|
+
return Promise.resolve();
|
|
316
|
+
}
|
|
317
|
+
};
|
|
318
|
+
saveAuth(authObj);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
return auth$1;
|
|
323
|
+
} catch {}
|
|
324
|
+
let finalAuthCode = authCode;
|
|
325
|
+
if (!finalAuthCode && getAuthCode) finalAuthCode = await getAuthCode();
|
|
326
|
+
const auth = await getAuth({
|
|
327
|
+
hassUrl: url,
|
|
328
|
+
clientId: clientId ?? null,
|
|
329
|
+
redirectUrl: redirectUri,
|
|
330
|
+
authCode: finalAuthCode,
|
|
331
|
+
limitHassInstance,
|
|
332
|
+
async loadTokens() {
|
|
333
|
+
if (loadAuth) {
|
|
334
|
+
const saved = await loadAuth();
|
|
335
|
+
if (saved?.data) return saved.data;
|
|
336
|
+
}
|
|
337
|
+
return null;
|
|
338
|
+
},
|
|
339
|
+
saveTokens(tokens) {
|
|
340
|
+
if (saveAuth && tokens) {
|
|
341
|
+
const authObj = {
|
|
342
|
+
hassUrl: url,
|
|
343
|
+
clientId,
|
|
344
|
+
redirectUrl: redirectUri,
|
|
345
|
+
data: tokens,
|
|
346
|
+
async refreshAccessToken() {
|
|
347
|
+
return Promise.resolve();
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
saveAuth(authObj);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
return auth;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Authenticate using a long-lived token
|
|
358
|
+
*
|
|
359
|
+
* @param url - Home Assistant instance URL
|
|
360
|
+
* @param token - Long-lived access token
|
|
361
|
+
* @returns Authenticated Auth object
|
|
362
|
+
*
|
|
363
|
+
* @example
|
|
364
|
+
* ```typescript
|
|
365
|
+
* const auth = authenticateWithToken(
|
|
366
|
+
* 'https://homeassistant.local:8123',
|
|
367
|
+
* 'your_long_lived_token'
|
|
368
|
+
* );
|
|
369
|
+
* ```
|
|
370
|
+
*/
|
|
371
|
+
function authenticateWithToken(url, token) {
|
|
372
|
+
return createLongLivedTokenAuth(url, token);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Check if auth object is valid
|
|
376
|
+
*
|
|
377
|
+
* @param auth - Auth object to check
|
|
378
|
+
* @returns True if auth is valid
|
|
379
|
+
*/
|
|
380
|
+
async function isAuthValid(auth) {
|
|
381
|
+
try {
|
|
382
|
+
await auth.refreshAccessToken();
|
|
383
|
+
return true;
|
|
384
|
+
} catch {
|
|
385
|
+
return false;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* Refresh auth tokens
|
|
390
|
+
*
|
|
391
|
+
* @param auth - Auth object to refresh
|
|
392
|
+
* @returns Refreshed Auth object
|
|
393
|
+
*/
|
|
394
|
+
async function refreshAuth(auth) {
|
|
395
|
+
await auth.refreshAccessToken();
|
|
396
|
+
return auth;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
//#endregion
|
|
400
|
+
//#region src/history/constants.ts
|
|
401
|
+
const DOMAINS_USE_LAST_UPDATED = [
|
|
402
|
+
"climate",
|
|
403
|
+
"humidifier",
|
|
404
|
+
"water_heater"
|
|
405
|
+
];
|
|
406
|
+
const NEED_ATTRIBUTE_DOMAINS = [
|
|
407
|
+
"climate",
|
|
408
|
+
"humidifier",
|
|
409
|
+
"input_datetime",
|
|
410
|
+
"water_heater",
|
|
411
|
+
"person",
|
|
412
|
+
"device_tracker"
|
|
413
|
+
];
|
|
414
|
+
const LINE_ATTRIBUTES_TO_KEEP = [
|
|
415
|
+
"temperature",
|
|
416
|
+
"current_temperature",
|
|
417
|
+
"target_temp_low",
|
|
418
|
+
"target_temp_high",
|
|
419
|
+
"hvac_action",
|
|
420
|
+
"humidity",
|
|
421
|
+
"mode",
|
|
422
|
+
"action",
|
|
423
|
+
"current_humidity"
|
|
424
|
+
];
|
|
425
|
+
/**
|
|
426
|
+
* Check if an entity needs attributes in history response
|
|
427
|
+
*/
|
|
428
|
+
function entityIdHistoryNeedsAttributes(entityId) {
|
|
429
|
+
const domain = extractDomain(entityId);
|
|
430
|
+
if (!state.entities[entityId]) return NEED_ATTRIBUTE_DOMAINS.includes(domain);
|
|
431
|
+
return NEED_ATTRIBUTE_DOMAINS.includes(domain);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
//#endregion
|
|
435
|
+
//#region src/history/fetch.ts
|
|
436
|
+
/**
|
|
437
|
+
* Fetch history for entities during a time period
|
|
438
|
+
*/
|
|
439
|
+
async function fetchHistory(options) {
|
|
440
|
+
const { startTime, endTime, entityIds = [], includeStartTimeState = true, significantChangesOnly = true, minimalResponse = true, noAttributes: noAttributesOverride } = options;
|
|
441
|
+
const startTimeStr = startTime.toISOString();
|
|
442
|
+
const endTimeStr = endTime?.toISOString();
|
|
443
|
+
const noAttributes = noAttributesOverride ?? (entityIds.length === 0 ? false : !entityIds.some((entityId) => entityIdHistoryNeedsAttributes(entityId)));
|
|
444
|
+
const params = {
|
|
445
|
+
type: "history/history_during_period",
|
|
446
|
+
start_time: startTimeStr,
|
|
447
|
+
minimal_response: minimalResponse,
|
|
448
|
+
no_attributes: noAttributes,
|
|
449
|
+
include_start_time_state: includeStartTimeState,
|
|
450
|
+
significant_changes_only: significantChangesOnly
|
|
451
|
+
};
|
|
452
|
+
if (endTimeStr) params.end_time = endTimeStr;
|
|
453
|
+
if (entityIds.length !== 0) params.entity_ids = entityIds;
|
|
454
|
+
const response = await sendCommand(params);
|
|
455
|
+
return response ?? {};
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Fetch history for a single entity
|
|
459
|
+
*/
|
|
460
|
+
async function fetchEntityHistory(entityId, options) {
|
|
461
|
+
const { startTime, endTime, includeStartTimeState = true, significantChangesOnly = true, minimalResponse = false, noAttributes = false } = options;
|
|
462
|
+
try {
|
|
463
|
+
const historyData = await fetchHistory({
|
|
464
|
+
startTime,
|
|
465
|
+
endTime,
|
|
466
|
+
entityIds: [entityId],
|
|
467
|
+
includeStartTimeState,
|
|
468
|
+
significantChangesOnly,
|
|
469
|
+
minimalResponse,
|
|
470
|
+
noAttributes
|
|
471
|
+
});
|
|
472
|
+
const entityHistory = historyData[entityId] ?? [];
|
|
473
|
+
const timeline = entityHistory.map((s) => ({
|
|
474
|
+
timestamp: s.lu,
|
|
475
|
+
state: s.s,
|
|
476
|
+
attributes: s.a,
|
|
477
|
+
lastChanged: s.lc,
|
|
478
|
+
lastUpdated: s.lu
|
|
479
|
+
}));
|
|
480
|
+
return {
|
|
481
|
+
entityId,
|
|
482
|
+
timeline,
|
|
483
|
+
entityHistory,
|
|
484
|
+
loading: false,
|
|
485
|
+
error: null,
|
|
486
|
+
lastFetched: Date.now()
|
|
487
|
+
};
|
|
488
|
+
} catch (error) {
|
|
489
|
+
return {
|
|
490
|
+
entityId,
|
|
491
|
+
timeline: [],
|
|
492
|
+
entityHistory: [],
|
|
493
|
+
loading: false,
|
|
494
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
495
|
+
lastFetched: null
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Convert compressed history state to timeline state
|
|
501
|
+
*/
|
|
502
|
+
function historyStateToTimeline(s) {
|
|
503
|
+
return {
|
|
504
|
+
timestamp: s.lu,
|
|
505
|
+
state: s.s,
|
|
506
|
+
attributes: s.a,
|
|
507
|
+
lastChanged: s.lc,
|
|
508
|
+
lastUpdated: s.lu
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
//#endregion
|
|
513
|
+
//#region src/history/query.ts
|
|
514
|
+
/** Set of entity IDs with active history tracking. */
|
|
515
|
+
const trackedEntities = /* @__PURE__ */ new Set();
|
|
516
|
+
/** Check if an entity has active history tracking. */
|
|
517
|
+
function isHistoryTracked(entityId) {
|
|
518
|
+
return trackedEntities.has(entityId);
|
|
519
|
+
}
|
|
520
|
+
/** Start tracking history for an entity. Fetches initial backfill. */
|
|
521
|
+
async function trackEntityHistory(entityId, options) {
|
|
522
|
+
trackedEntities.add(entityId);
|
|
523
|
+
const historyData = await fetchEntityHistory(entityId, options);
|
|
524
|
+
setState(produce((s) => {
|
|
525
|
+
s.history[entityId] = historyData;
|
|
526
|
+
}));
|
|
527
|
+
return historyData;
|
|
528
|
+
}
|
|
529
|
+
/** Stop tracking history for an entity and clear its data. */
|
|
530
|
+
function untrackEntityHistory(entityId) {
|
|
531
|
+
trackedEntities.delete(entityId);
|
|
532
|
+
setState(produce((s) => {
|
|
533
|
+
delete s.history[entityId];
|
|
534
|
+
}));
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Append a state change to an entity's history.
|
|
538
|
+
* Called by the entity subscription handler when a tracked entity changes.
|
|
539
|
+
*/
|
|
540
|
+
function appendHistoryPoint(entityId, stateValue, attributes, lastUpdated, lastChanged) {
|
|
541
|
+
setState(produce((s) => {
|
|
542
|
+
const history = s.history[entityId];
|
|
543
|
+
if (!history) return;
|
|
544
|
+
const point = {
|
|
545
|
+
s: stateValue,
|
|
546
|
+
a: attributes,
|
|
547
|
+
lu: lastUpdated,
|
|
548
|
+
lc: lastChanged
|
|
549
|
+
};
|
|
550
|
+
history.entityHistory.push(point);
|
|
551
|
+
history.timeline.push({
|
|
552
|
+
timestamp: lastUpdated,
|
|
553
|
+
state: stateValue,
|
|
554
|
+
attributes,
|
|
555
|
+
lastChanged,
|
|
556
|
+
lastUpdated
|
|
557
|
+
});
|
|
558
|
+
}));
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/connection/subscriptions.ts
|
|
563
|
+
/** Active subscription cleanup functions (managed externally from store) */
|
|
564
|
+
let activeSubscriptions = [];
|
|
565
|
+
/**
|
|
566
|
+
* Subscribe to all relevant events from Home Assistant
|
|
567
|
+
*/
|
|
568
|
+
async function subscribeToUpdates(conn) {
|
|
569
|
+
await cleanupSubscriptions();
|
|
570
|
+
const registryUpdatedSub = await conn.subscribeEvents((event) => {
|
|
571
|
+
handleRegistryUpdate(event);
|
|
572
|
+
}, "entity_registry_updated");
|
|
573
|
+
activeSubscriptions.push(() => registryUpdatedSub());
|
|
574
|
+
const deviceRegistrySub = await conn.subscribeEvents((event) => {
|
|
575
|
+
handleDeviceRegistryUpdate(event);
|
|
576
|
+
}, "device_registry_updated");
|
|
577
|
+
activeSubscriptions.push(() => deviceRegistrySub());
|
|
578
|
+
const areaRegistrySub = await conn.subscribeEvents((event) => {
|
|
579
|
+
handleAreaRegistryUpdate(event);
|
|
580
|
+
}, "area_registry_updated");
|
|
581
|
+
activeSubscriptions.push(() => areaRegistrySub());
|
|
582
|
+
const floorRegistrySub = await conn.subscribeEvents((event) => {
|
|
583
|
+
handleFloorRegistryUpdate(event);
|
|
584
|
+
}, "floor_registry_updated");
|
|
585
|
+
activeSubscriptions.push(() => floorRegistrySub());
|
|
586
|
+
const labelRegistrySub = await conn.subscribeEvents((event) => {
|
|
587
|
+
handleLabelRegistryUpdate(event);
|
|
588
|
+
}, "label_registry_updated");
|
|
589
|
+
activeSubscriptions.push(() => labelRegistrySub());
|
|
590
|
+
const statisticsSub = await conn.subscribeEvents((event) => {
|
|
591
|
+
handleStatisticsUpdate(event);
|
|
592
|
+
}, "recorder_5min_statistics_generated");
|
|
593
|
+
activeSubscriptions.push(() => statisticsSub());
|
|
594
|
+
const entityUpdatesSub = await conn.subscribeMessage((message) => {
|
|
595
|
+
setState(produce((s) => {
|
|
596
|
+
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
|
|
597
|
+
if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
|
|
598
|
+
if (message.r) for (const entityId of message.r) delete s.entities[entityId];
|
|
599
|
+
}));
|
|
600
|
+
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) maybeAppendHistory(entityId, compressedState);
|
|
601
|
+
if (message.c) {
|
|
602
|
+
for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) maybeAppendHistoryFromDiff(entityId, diff["+"]);
|
|
603
|
+
}
|
|
604
|
+
}, { type: "subscribe_entities" });
|
|
605
|
+
activeSubscriptions.push(() => entityUpdatesSub());
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* Clean up all active subscriptions
|
|
609
|
+
*/
|
|
610
|
+
async function cleanupSubscriptions() {
|
|
611
|
+
const subscriptions = [...activeSubscriptions];
|
|
612
|
+
for (const unsub of subscriptions) await unsub();
|
|
613
|
+
activeSubscriptions = [];
|
|
614
|
+
}
|
|
615
|
+
async function handleRegistryUpdate(event) {
|
|
616
|
+
const { action, entity_id } = event.data;
|
|
617
|
+
if (action === "create" || action === "update") {
|
|
618
|
+
const conn = state.conn;
|
|
619
|
+
if (!conn) return;
|
|
620
|
+
try {
|
|
621
|
+
const entry = await conn.sendMessagePromise({
|
|
622
|
+
type: "config/entity_registry/get",
|
|
623
|
+
entity_id
|
|
624
|
+
});
|
|
625
|
+
if (entry) updateEntityRegistry(entry);
|
|
626
|
+
} catch (error) {
|
|
627
|
+
console.error("Error fetching registry entry:", error);
|
|
628
|
+
}
|
|
629
|
+
} else if (action === "remove") removeEntityRegistry(entity_id);
|
|
630
|
+
}
|
|
631
|
+
async function handleDeviceRegistryUpdate(event) {
|
|
632
|
+
const { action, device_id } = event.data;
|
|
633
|
+
if (action === "create" || action === "update") {
|
|
634
|
+
const conn = state.conn;
|
|
635
|
+
if (!conn) return;
|
|
636
|
+
try {
|
|
637
|
+
const devices = await conn.sendMessagePromise({ type: "config/device_registry/list" });
|
|
638
|
+
const device = devices.find((d) => d.id === device_id);
|
|
639
|
+
if (device) updateDevice(device);
|
|
640
|
+
} catch (error) {
|
|
641
|
+
console.error("Error fetching device:", error);
|
|
642
|
+
}
|
|
643
|
+
} else if (action === "remove") removeDevice(device_id);
|
|
644
|
+
}
|
|
645
|
+
async function handleAreaRegistryUpdate(event) {
|
|
646
|
+
const { action, area_id } = event.data;
|
|
647
|
+
if (action === "create" || action === "update") {
|
|
648
|
+
const conn = state.conn;
|
|
649
|
+
if (!conn) return;
|
|
650
|
+
try {
|
|
651
|
+
const areas$1 = await conn.sendMessagePromise({ type: "config/area_registry/list" });
|
|
652
|
+
const areaApi = areas$1.find((a) => a.area_id === area_id || a.id === area_id);
|
|
653
|
+
if (areaApi) {
|
|
654
|
+
const area$1 = {
|
|
655
|
+
id: areaApi.area_id || areaApi.id,
|
|
656
|
+
name: areaApi.name,
|
|
657
|
+
normalized_name: areaApi.normalized_name || areaApi.name.toLowerCase().replace(/\s+/g, "_"),
|
|
658
|
+
aliases: Array.isArray(areaApi.aliases) ? areaApi.aliases : [],
|
|
659
|
+
floor_id: areaApi.floor_id ?? null,
|
|
660
|
+
humidity_entity_id: areaApi.humidity_entity_id ?? null,
|
|
661
|
+
icon: areaApi.icon ?? null,
|
|
662
|
+
labels: Array.isArray(areaApi.labels) ? areaApi.labels : [],
|
|
663
|
+
picture: areaApi.picture ?? null,
|
|
664
|
+
temperature_entity_id: areaApi.temperature_entity_id ?? null,
|
|
665
|
+
created_at: typeof areaApi.created_at === "number" ? new Date(areaApi.created_at * 1e3).toISOString() : areaApi.created_at,
|
|
666
|
+
modified_at: typeof areaApi.modified_at === "number" ? new Date(areaApi.modified_at * 1e3).toISOString() : areaApi.modified_at
|
|
667
|
+
};
|
|
668
|
+
updateArea(area$1);
|
|
669
|
+
}
|
|
670
|
+
} catch (error) {
|
|
671
|
+
console.error("Error fetching area:", error);
|
|
672
|
+
}
|
|
673
|
+
} else if (action === "remove") removeArea(area_id);
|
|
674
|
+
}
|
|
675
|
+
async function handleFloorRegistryUpdate(event) {
|
|
676
|
+
const { action, floor_id } = event.data;
|
|
677
|
+
if (action === "create" || action === "update") {
|
|
678
|
+
const conn = state.conn;
|
|
679
|
+
if (!conn) return;
|
|
680
|
+
try {
|
|
681
|
+
const floors = await conn.sendMessagePromise({ type: "config/floor_registry/list" });
|
|
682
|
+
const floor = floors.find((f) => f.floor_id === floor_id);
|
|
683
|
+
if (floor) updateFloor(floor);
|
|
684
|
+
} catch (error) {
|
|
685
|
+
console.error("Error fetching floor:", error);
|
|
686
|
+
}
|
|
687
|
+
} else if (action === "remove") removeFloor(floor_id);
|
|
688
|
+
}
|
|
689
|
+
async function handleLabelRegistryUpdate(event) {
|
|
690
|
+
const { action, label_id } = event.data;
|
|
691
|
+
if (action === "create" || action === "update") {
|
|
692
|
+
const conn = state.conn;
|
|
693
|
+
if (!conn) return;
|
|
694
|
+
try {
|
|
695
|
+
const labels = await conn.sendMessagePromise({ type: "config/label_registry/list" });
|
|
696
|
+
const label = labels.find((l) => l.label_id === label_id);
|
|
697
|
+
if (label) updateLabel(label);
|
|
698
|
+
} catch (error) {
|
|
699
|
+
console.error("Error fetching label:", error);
|
|
700
|
+
}
|
|
701
|
+
} else if (action === "remove") removeLabel(label_id);
|
|
702
|
+
}
|
|
703
|
+
async function handleStatisticsUpdate(event) {
|
|
704
|
+
const { statistic_ids } = event.data;
|
|
705
|
+
if (!statistic_ids || statistic_ids.length === 0) return;
|
|
706
|
+
const conn = state.conn;
|
|
707
|
+
if (!conn) return;
|
|
708
|
+
try {
|
|
709
|
+
const metadata = await conn.sendMessagePromise({
|
|
710
|
+
type: "recorder/get_statistics_metadata",
|
|
711
|
+
statistic_ids
|
|
712
|
+
});
|
|
713
|
+
if (metadata && Array.isArray(metadata)) updateStatisticsMetadata(metadata);
|
|
714
|
+
} catch (error) {
|
|
715
|
+
console.error("Error fetching statistics metadata:", error);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
function timestampToIso(timestamp) {
|
|
719
|
+
if (timestamp === void 0) return (/* @__PURE__ */ new Date()).toISOString();
|
|
720
|
+
if (typeof timestamp === "string") return timestamp;
|
|
721
|
+
return new Date(timestamp * 1e3).toISOString();
|
|
722
|
+
}
|
|
723
|
+
function maybeAppendHistory(entityId, compressed) {
|
|
724
|
+
if (!isHistoryTracked(entityId)) return;
|
|
725
|
+
const stateVal = compressed.s ?? compressed.state ?? "unknown";
|
|
726
|
+
const attributes = compressed.a ?? compressed.attributes ?? {};
|
|
727
|
+
const lu = compressed.lu ?? (compressed.last_updated ? new Date(compressed.last_updated).getTime() / 1e3 : Date.now() / 1e3);
|
|
728
|
+
const lc = compressed.lc ?? (compressed.last_changed ? new Date(compressed.last_changed).getTime() / 1e3 : void 0);
|
|
729
|
+
appendHistoryPoint(entityId, stateVal, attributes, lu, lc);
|
|
730
|
+
}
|
|
731
|
+
function maybeAppendHistoryFromDiff(entityId, additions) {
|
|
732
|
+
if (!isHistoryTracked(entityId)) return;
|
|
733
|
+
const entity$1 = state.entities[entityId];
|
|
734
|
+
if (!entity$1) return;
|
|
735
|
+
const lu = additions.lu ?? Date.now() / 1e3;
|
|
736
|
+
const lc = additions.lc;
|
|
737
|
+
appendHistoryPoint(entityId, entity$1.state, entity$1.attributes, lu, lc);
|
|
738
|
+
}
|
|
739
|
+
function applyCompressedState(s, entityId, compressed) {
|
|
740
|
+
const stateVal = compressed.s ?? compressed.state ?? "unknown";
|
|
741
|
+
const attributes = compressed.a ?? compressed.attributes ?? {};
|
|
742
|
+
const context = compressed.c ?? compressed.context ?? {
|
|
743
|
+
id: "",
|
|
744
|
+
parent_id: null,
|
|
745
|
+
user_id: null
|
|
746
|
+
};
|
|
747
|
+
const lastChanged = timestampToIso(compressed.lc ?? compressed.last_changed);
|
|
748
|
+
const lastUpdated = timestampToIso(compressed.lu ?? compressed.last_updated);
|
|
749
|
+
const entity$1 = {
|
|
750
|
+
entity_id: entityId,
|
|
751
|
+
state: stateVal,
|
|
752
|
+
attributes,
|
|
753
|
+
last_changed: lastChanged,
|
|
754
|
+
last_updated: lastUpdated,
|
|
755
|
+
context
|
|
756
|
+
};
|
|
757
|
+
s.entities[entityId] = entity$1;
|
|
758
|
+
}
|
|
759
|
+
function applyStateDiff(s, entityId, diff) {
|
|
760
|
+
const existing = s.entities[entityId];
|
|
761
|
+
if (!existing) return;
|
|
762
|
+
const additions = diff["+"];
|
|
763
|
+
if (additions) {
|
|
764
|
+
if (additions.s !== void 0) existing.state = additions.s;
|
|
765
|
+
if (additions.a) Object.assign(existing.attributes, additions.a);
|
|
766
|
+
if (additions.lc !== void 0) existing.last_changed = timestampToIso(additions.lc);
|
|
767
|
+
if (additions.lu !== void 0) existing.last_updated = timestampToIso(additions.lu);
|
|
768
|
+
if (additions.c) existing.context = additions.c;
|
|
769
|
+
}
|
|
770
|
+
const removals = diff["-"];
|
|
771
|
+
if (removals?.a) for (const key of removals.a) delete existing.attributes[key];
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region src/connection/manager.ts
|
|
776
|
+
/**
|
|
777
|
+
* Initialize connection to Home Assistant
|
|
778
|
+
*/
|
|
779
|
+
async function initConnection(options) {
|
|
780
|
+
try {
|
|
781
|
+
setState(produce((s) => {
|
|
782
|
+
s.connectionState = "connecting";
|
|
783
|
+
s.connectionError = null;
|
|
784
|
+
}));
|
|
785
|
+
let auth;
|
|
786
|
+
if (typeof options.auth === "string") auth = authenticateWithToken(options.url, options.auth);
|
|
787
|
+
else if (typeof options.auth === "object" && "clientId" in options.auth) auth = await authenticateWithOAuth(options.auth);
|
|
788
|
+
else auth = options.auth;
|
|
789
|
+
const conn = await createConnection({
|
|
790
|
+
auth,
|
|
791
|
+
...options.createSocket ? { createSocket: options.createSocket } : {}
|
|
792
|
+
});
|
|
793
|
+
const wrappedConn = wrapHAConnection(conn);
|
|
794
|
+
setState(produce((s) => {
|
|
795
|
+
s.conn = conn;
|
|
796
|
+
s.connectionState = "connected";
|
|
797
|
+
}));
|
|
798
|
+
setupEventHandlers(conn, options);
|
|
799
|
+
await loadInitialData(conn);
|
|
800
|
+
await subscribeToUpdates(wrappedConn);
|
|
801
|
+
options.onConnect?.();
|
|
802
|
+
return conn;
|
|
803
|
+
} catch (error) {
|
|
804
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
805
|
+
setState(produce((s) => {
|
|
806
|
+
s.connectionState = "error";
|
|
807
|
+
s.connectionError = err;
|
|
808
|
+
}));
|
|
809
|
+
options.onError?.(err);
|
|
810
|
+
throw err;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Disconnect from Home Assistant
|
|
815
|
+
*/
|
|
816
|
+
function disconnect() {
|
|
817
|
+
const conn = state.conn;
|
|
818
|
+
if (conn) conn.close();
|
|
819
|
+
setState(produce((s) => {
|
|
820
|
+
s.conn = null;
|
|
821
|
+
s.connectionState = "disconnected";
|
|
822
|
+
}));
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Get current connection
|
|
826
|
+
*/
|
|
827
|
+
function getConnection() {
|
|
828
|
+
return state.conn;
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* Check if currently connected
|
|
832
|
+
*/
|
|
833
|
+
function isConnected() {
|
|
834
|
+
return state.connectionState === "connected";
|
|
835
|
+
}
|
|
836
|
+
function setupEventHandlers(conn, options) {
|
|
837
|
+
conn.addEventListener("disconnected", () => {
|
|
838
|
+
setState(produce((s) => {
|
|
839
|
+
s.connectionState = "disconnected";
|
|
840
|
+
}));
|
|
841
|
+
options.onDisconnect?.();
|
|
842
|
+
});
|
|
843
|
+
conn.addEventListener("reconnect-error", () => {
|
|
844
|
+
setState(produce((s) => {
|
|
845
|
+
s.connectionState = "reconnecting";
|
|
846
|
+
}));
|
|
847
|
+
});
|
|
848
|
+
conn.addEventListener("ready", async () => {
|
|
849
|
+
const wasReconnecting = state.connectionState === "reconnecting";
|
|
850
|
+
setState(produce((s) => {
|
|
851
|
+
s.connectionState = "connected";
|
|
852
|
+
s.connectionError = null;
|
|
853
|
+
}));
|
|
854
|
+
if (wasReconnecting) {
|
|
855
|
+
await loadInitialData(conn);
|
|
856
|
+
options.onReconnect?.();
|
|
857
|
+
}
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
async function loadInitialData(conn) {
|
|
861
|
+
try {
|
|
862
|
+
const [states, entityRegistry, deviceRegistry, areaRegistry, floorRegistry, labelRegistry] = await Promise.all([
|
|
863
|
+
conn.sendMessagePromise({ type: "get_states" }),
|
|
864
|
+
conn.sendMessagePromise({ type: "config/entity_registry/list" }),
|
|
865
|
+
conn.sendMessagePromise({ type: "config/device_registry/list" }),
|
|
866
|
+
conn.sendMessagePromise({ type: "config/area_registry/list" }),
|
|
867
|
+
conn.sendMessagePromise({ type: "config/floor_registry/list" }),
|
|
868
|
+
conn.sendMessagePromise({ type: "config/label_registry/list" })
|
|
869
|
+
]);
|
|
870
|
+
bulkUpdateEntities(states);
|
|
871
|
+
bulkUpdateEntityRegistry(entityRegistry);
|
|
872
|
+
setState(produce((s) => {
|
|
873
|
+
for (const device of deviceRegistry) s.devices[device.id] = device;
|
|
874
|
+
for (const areaApi of areaRegistry) {
|
|
875
|
+
const area$1 = {
|
|
876
|
+
id: areaApi.area_id || areaApi.id,
|
|
877
|
+
name: areaApi.name,
|
|
878
|
+
normalized_name: areaApi.normalized_name || areaApi.name.toLowerCase().replace(/\s+/g, "_"),
|
|
879
|
+
aliases: Array.isArray(areaApi.aliases) ? areaApi.aliases : [],
|
|
880
|
+
floor_id: areaApi.floor_id ?? null,
|
|
881
|
+
humidity_entity_id: areaApi.humidity_entity_id ?? null,
|
|
882
|
+
icon: areaApi.icon ?? null,
|
|
883
|
+
labels: Array.isArray(areaApi.labels) ? areaApi.labels : [],
|
|
884
|
+
picture: areaApi.picture ?? null,
|
|
885
|
+
temperature_entity_id: areaApi.temperature_entity_id ?? null,
|
|
886
|
+
created_at: typeof areaApi.created_at === "number" ? new Date(areaApi.created_at * 1e3).toISOString() : areaApi.created_at,
|
|
887
|
+
modified_at: typeof areaApi.modified_at === "number" ? new Date(areaApi.modified_at * 1e3).toISOString() : areaApi.modified_at
|
|
888
|
+
};
|
|
889
|
+
s.areas[area$1.id] = area$1;
|
|
890
|
+
}
|
|
891
|
+
for (const floor of floorRegistry) s.floors[floor.floor_id] = floor;
|
|
892
|
+
for (const label of labelRegistry) s.labels[label.label_id] = label;
|
|
893
|
+
}));
|
|
894
|
+
} catch (error) {
|
|
895
|
+
console.error("Error loading initial data:", error);
|
|
896
|
+
throw error;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
//#endregion
|
|
901
|
+
//#region src/query/area-builder.ts
|
|
902
|
+
var AreaQueryBuilderImpl = class AreaQueryBuilderImpl {
|
|
903
|
+
queryState;
|
|
904
|
+
constructor(initialState) {
|
|
905
|
+
this.queryState = initialState ?? {
|
|
906
|
+
filters: [],
|
|
907
|
+
sorts: []
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
byFloor(floorId) {
|
|
911
|
+
return this.clone({ filters: [...this.queryState.filters, {
|
|
912
|
+
type: "floor",
|
|
913
|
+
value: floorId
|
|
914
|
+
}] });
|
|
915
|
+
}
|
|
916
|
+
byLabel(labelId) {
|
|
917
|
+
return this.clone({ filters: [...this.queryState.filters, {
|
|
918
|
+
type: "label",
|
|
919
|
+
value: labelId
|
|
920
|
+
}] });
|
|
921
|
+
}
|
|
922
|
+
where(predicate) {
|
|
923
|
+
return this.clone({ filters: [...this.queryState.filters, {
|
|
924
|
+
type: "predicate",
|
|
925
|
+
value: predicate
|
|
926
|
+
}] });
|
|
927
|
+
}
|
|
928
|
+
orderBy(key, direction = "asc") {
|
|
929
|
+
return this.clone({ sorts: [...this.queryState.sorts, {
|
|
930
|
+
key,
|
|
931
|
+
direction
|
|
932
|
+
}] });
|
|
933
|
+
}
|
|
934
|
+
get() {
|
|
935
|
+
return this.execute();
|
|
936
|
+
}
|
|
937
|
+
first() {
|
|
938
|
+
const results = this.execute();
|
|
939
|
+
return results[0];
|
|
940
|
+
}
|
|
941
|
+
subscribe(callback) {
|
|
942
|
+
callback(this.get());
|
|
943
|
+
return () => {};
|
|
944
|
+
}
|
|
945
|
+
clone(updates) {
|
|
946
|
+
return new AreaQueryBuilderImpl({
|
|
947
|
+
...this.queryState,
|
|
948
|
+
...updates
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
execute() {
|
|
952
|
+
let areas$1 = getAreaViews();
|
|
953
|
+
for (const filter of this.queryState.filters) if (filter.type === "floor") areas$1 = areas$1.filter((area$1) => area$1.floorId === filter.value);
|
|
954
|
+
else if (filter.type === "label") areas$1 = areas$1.filter((area$1) => area$1.labels.includes(filter.value));
|
|
955
|
+
else if (filter.type === "predicate") areas$1 = areas$1.filter(filter.value);
|
|
956
|
+
if (this.queryState.sorts.length > 0) areas$1 = this.sortAreas(areas$1);
|
|
957
|
+
return areas$1;
|
|
958
|
+
}
|
|
959
|
+
sortAreas(areas$1) {
|
|
960
|
+
return [...areas$1].sort((a, b) => {
|
|
961
|
+
for (const sort of this.queryState.sorts) {
|
|
962
|
+
const aValue = this.getNestedValue(a, sort.key);
|
|
963
|
+
const bValue = this.getNestedValue(b, sort.key);
|
|
964
|
+
let comparison = 0;
|
|
965
|
+
if (aValue < bValue) comparison = -1;
|
|
966
|
+
else if (aValue > bValue) comparison = 1;
|
|
967
|
+
if (comparison !== 0) return sort.direction === "asc" ? comparison : -comparison;
|
|
968
|
+
}
|
|
969
|
+
return 0;
|
|
970
|
+
});
|
|
971
|
+
}
|
|
972
|
+
getNestedValue(obj, key) {
|
|
973
|
+
if (key in obj) return obj[key];
|
|
974
|
+
const parts = String(key).split(".");
|
|
975
|
+
let value = obj;
|
|
976
|
+
for (const part of parts) value = value?.[part];
|
|
977
|
+
return value;
|
|
978
|
+
}
|
|
979
|
+
};
|
|
980
|
+
/**
|
|
981
|
+
* Create a query builder for areas
|
|
982
|
+
*/
|
|
983
|
+
function areas() {
|
|
984
|
+
return new AreaQueryBuilderImpl();
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* Create a query for a single area
|
|
988
|
+
*/
|
|
989
|
+
function area(areaId) {
|
|
990
|
+
return getAreaView(areaId);
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
//#endregion
|
|
994
|
+
//#region src/query/memoization.ts
|
|
995
|
+
/**
|
|
996
|
+
* Build a unique signature for a query
|
|
997
|
+
*/
|
|
998
|
+
function buildQuerySignature(query) {
|
|
999
|
+
const sig = {
|
|
1000
|
+
index: query.indexFilters.map((f) => `${f.type}:${f.value}`).join(","),
|
|
1001
|
+
predicates: query.predicateFilters.length,
|
|
1002
|
+
sorts: query.sorts.map((s) => `${s.key}:${s.direction}`).join(","),
|
|
1003
|
+
projection: query.projection ? JSON.stringify(query.projection) : null
|
|
1004
|
+
};
|
|
1005
|
+
return JSON.stringify(sig);
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Execute query (no caching -- SolidJS createMemo handles this in Plan 03)
|
|
1009
|
+
*/
|
|
1010
|
+
function memoizedExecute(signature, compute) {
|
|
1011
|
+
return compute();
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region src/query/projections.ts
|
|
1016
|
+
/**
|
|
1017
|
+
* Project entities to IDs only
|
|
1018
|
+
*
|
|
1019
|
+
* @param entities - Array of entity views
|
|
1020
|
+
* @returns Array of entity IDs
|
|
1021
|
+
*/
|
|
1022
|
+
function projectToIds(entities$1) {
|
|
1023
|
+
return entities$1.map((e) => e.id);
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Pick specific fields from entities
|
|
1027
|
+
*
|
|
1028
|
+
* @param entities - Array of entity views
|
|
1029
|
+
* @param keys - Array of field names to include
|
|
1030
|
+
* @returns Array of objects with only specified fields
|
|
1031
|
+
*/
|
|
1032
|
+
function projectPick(entities$1, keys) {
|
|
1033
|
+
return entities$1.map((entity$1) => {
|
|
1034
|
+
const result = {};
|
|
1035
|
+
for (const key of keys) result[key] = entity$1[key];
|
|
1036
|
+
return result;
|
|
1037
|
+
});
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* Pluck a single field from entities
|
|
1041
|
+
*
|
|
1042
|
+
* Supports dot-notation for nested fields (e.g., "attributes.brightness")
|
|
1043
|
+
*
|
|
1044
|
+
* @param entities - Array of entity views
|
|
1045
|
+
* @param key - Field name or dot-path to extract
|
|
1046
|
+
* @returns Array of field values
|
|
1047
|
+
*/
|
|
1048
|
+
function projectPluck(entities$1, key) {
|
|
1049
|
+
return entities$1.map((entity$1) => getNestedValue(entity$1, key));
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* Map entities using a custom function
|
|
1053
|
+
*
|
|
1054
|
+
* @param entities - Array of entity views
|
|
1055
|
+
* @param mapper - Function to transform each entity
|
|
1056
|
+
* @returns Array of mapped values
|
|
1057
|
+
*/
|
|
1058
|
+
function projectMap(entities$1, mapper) {
|
|
1059
|
+
return entities$1.map(mapper);
|
|
1060
|
+
}
|
|
1061
|
+
/**
|
|
1062
|
+
* Get nested value from object using dot-notation
|
|
1063
|
+
*
|
|
1064
|
+
* @param obj - Object to extract value from
|
|
1065
|
+
* @param path - Dot-notation path (e.g., "attributes.brightness")
|
|
1066
|
+
* @returns Value at path or undefined
|
|
1067
|
+
*
|
|
1068
|
+
* @example
|
|
1069
|
+
* ```typescript
|
|
1070
|
+
* const entity = { attributes: { brightness: 255 } };
|
|
1071
|
+
* getNestedValue(entity, "attributes.brightness"); // 255
|
|
1072
|
+
* ```
|
|
1073
|
+
*/
|
|
1074
|
+
function getNestedValue(obj, path) {
|
|
1075
|
+
const parts = path.split(".");
|
|
1076
|
+
let current = obj;
|
|
1077
|
+
for (const part of parts) {
|
|
1078
|
+
if (current === null || current === void 0) return void 0;
|
|
1079
|
+
current = current[part];
|
|
1080
|
+
}
|
|
1081
|
+
return current;
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
//#endregion
|
|
1085
|
+
//#region src/query/builder.ts
|
|
1086
|
+
var EntitiesQueryBuilderImpl = class EntitiesQueryBuilderImpl {
|
|
1087
|
+
queryState;
|
|
1088
|
+
constructor(initialState) {
|
|
1089
|
+
this.queryState = initialState ?? {
|
|
1090
|
+
indexFilters: [],
|
|
1091
|
+
predicateFilters: [],
|
|
1092
|
+
sorts: [],
|
|
1093
|
+
projection: void 0,
|
|
1094
|
+
searchText: void 0,
|
|
1095
|
+
take: void 0,
|
|
1096
|
+
skip: void 0,
|
|
1097
|
+
distinct: void 0
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
byArea(areaId) {
|
|
1101
|
+
return this.clone({ indexFilters: [...this.queryState.indexFilters, {
|
|
1102
|
+
type: "area",
|
|
1103
|
+
value: areaId
|
|
1104
|
+
}] });
|
|
1105
|
+
}
|
|
1106
|
+
byDomain(domain) {
|
|
1107
|
+
return this.clone({ indexFilters: [...this.queryState.indexFilters, {
|
|
1108
|
+
type: "domain",
|
|
1109
|
+
value: domain
|
|
1110
|
+
}] });
|
|
1111
|
+
}
|
|
1112
|
+
byDevice(deviceId) {
|
|
1113
|
+
return this.clone({ indexFilters: [...this.queryState.indexFilters, {
|
|
1114
|
+
type: "device",
|
|
1115
|
+
value: deviceId
|
|
1116
|
+
}] });
|
|
1117
|
+
}
|
|
1118
|
+
byFloor(floorId) {
|
|
1119
|
+
return this.clone({ indexFilters: [...this.queryState.indexFilters, {
|
|
1120
|
+
type: "floor",
|
|
1121
|
+
value: floorId
|
|
1122
|
+
}] });
|
|
1123
|
+
}
|
|
1124
|
+
byLabel(labelId) {
|
|
1125
|
+
return this.clone({ indexFilters: [...this.queryState.indexFilters, {
|
|
1126
|
+
type: "label",
|
|
1127
|
+
value: labelId
|
|
1128
|
+
}] });
|
|
1129
|
+
}
|
|
1130
|
+
where(predicate) {
|
|
1131
|
+
return this.clone({ predicateFilters: [...this.queryState.predicateFilters, predicate] });
|
|
1132
|
+
}
|
|
1133
|
+
search(searchText) {
|
|
1134
|
+
const searchLower = searchText.toLowerCase();
|
|
1135
|
+
return this.where((e) => e.id.toLowerCase().includes(searchLower) || e.name.toLowerCase().includes(searchLower) || e.friendlyName.toLowerCase().includes(searchLower));
|
|
1136
|
+
}
|
|
1137
|
+
orderBy(key, direction = "asc") {
|
|
1138
|
+
return this.clone({ sorts: [...this.queryState.sorts, {
|
|
1139
|
+
key,
|
|
1140
|
+
direction
|
|
1141
|
+
}] });
|
|
1142
|
+
}
|
|
1143
|
+
ids() {
|
|
1144
|
+
return this.clone({ projection: { type: "ids" } });
|
|
1145
|
+
}
|
|
1146
|
+
pick(keys) {
|
|
1147
|
+
return this.clone({ projection: {
|
|
1148
|
+
type: "pick",
|
|
1149
|
+
keys
|
|
1150
|
+
} });
|
|
1151
|
+
}
|
|
1152
|
+
pluck(key) {
|
|
1153
|
+
return this.clone({ projection: {
|
|
1154
|
+
type: "pluck",
|
|
1155
|
+
key
|
|
1156
|
+
} });
|
|
1157
|
+
}
|
|
1158
|
+
map(mapper) {
|
|
1159
|
+
return this.clone({ projection: {
|
|
1160
|
+
type: "map",
|
|
1161
|
+
mapper
|
|
1162
|
+
} });
|
|
1163
|
+
}
|
|
1164
|
+
take(limit) {
|
|
1165
|
+
return this.clone({ take: limit });
|
|
1166
|
+
}
|
|
1167
|
+
skip(count) {
|
|
1168
|
+
return this.clone({ skip: count });
|
|
1169
|
+
}
|
|
1170
|
+
distinct(key) {
|
|
1171
|
+
return this.clone({ distinct: key });
|
|
1172
|
+
}
|
|
1173
|
+
get() {
|
|
1174
|
+
const signature = buildQuerySignature(this.queryState);
|
|
1175
|
+
return memoizedExecute(signature, () => this.execute());
|
|
1176
|
+
}
|
|
1177
|
+
first() {
|
|
1178
|
+
const results = this.get();
|
|
1179
|
+
return results[0];
|
|
1180
|
+
}
|
|
1181
|
+
subscribe(callback) {
|
|
1182
|
+
callback(this.get());
|
|
1183
|
+
return () => {};
|
|
1184
|
+
}
|
|
1185
|
+
clone(updates) {
|
|
1186
|
+
return new EntitiesQueryBuilderImpl({
|
|
1187
|
+
...this.queryState,
|
|
1188
|
+
...updates
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
execute() {
|
|
1192
|
+
const entityIds = this.getEntityIdsFromIndices();
|
|
1193
|
+
let entities$1 = getEntityViews(entityIds);
|
|
1194
|
+
for (const filter of this.queryState.predicateFilters) entities$1 = entities$1.filter(filter);
|
|
1195
|
+
if (this.queryState.sorts.length > 0) entities$1 = this.sortEntities(entities$1);
|
|
1196
|
+
if (this.queryState.distinct) entities$1 = this.applyDistinct(entities$1);
|
|
1197
|
+
if (this.queryState.skip !== void 0) entities$1 = entities$1.slice(this.queryState.skip);
|
|
1198
|
+
if (this.queryState.take !== void 0) entities$1 = entities$1.slice(0, this.queryState.take);
|
|
1199
|
+
if (this.queryState.projection) return this.applyProjection(entities$1);
|
|
1200
|
+
return entities$1;
|
|
1201
|
+
}
|
|
1202
|
+
getEntityIdsFromIndices() {
|
|
1203
|
+
if (this.queryState.indexFilters.length === 0) return getAllEntityIds();
|
|
1204
|
+
const idSets = this.queryState.indexFilters.map((filter) => this.getIdsForFilter(filter));
|
|
1205
|
+
return this.intersectArrays(idSets);
|
|
1206
|
+
}
|
|
1207
|
+
getIdsForFilter(filter) {
|
|
1208
|
+
switch (filter.type) {
|
|
1209
|
+
case "domain": return getEntitiesByDomain(filter.value);
|
|
1210
|
+
case "area": return getEntitiesByArea(filter.value);
|
|
1211
|
+
case "device": return getEntitiesByDevice(filter.value);
|
|
1212
|
+
case "floor": return getEntitiesByFloor(filter.value);
|
|
1213
|
+
case "label": return getEntitiesByLabel(filter.value);
|
|
1214
|
+
default: return [];
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
intersectArrays(arrays) {
|
|
1218
|
+
if (arrays.length === 0) return [];
|
|
1219
|
+
if (arrays.length === 1) return arrays[0] ?? [];
|
|
1220
|
+
let result = arrays[0] ?? [];
|
|
1221
|
+
for (let i = 1; i < arrays.length; i++) {
|
|
1222
|
+
const set = new Set(arrays[i]);
|
|
1223
|
+
result = result.filter((item) => set.has(item));
|
|
1224
|
+
}
|
|
1225
|
+
return result;
|
|
1226
|
+
}
|
|
1227
|
+
sortEntities(entities$1) {
|
|
1228
|
+
const sorted = [...entities$1];
|
|
1229
|
+
sorted.sort((a, b) => {
|
|
1230
|
+
for (const sort of this.queryState.sorts) {
|
|
1231
|
+
const aVal = getNestedValue(a, sort.key);
|
|
1232
|
+
const bVal = getNestedValue(b, sort.key);
|
|
1233
|
+
const comparison = this.compareValues(aVal, bVal);
|
|
1234
|
+
if (comparison !== 0) return sort.direction === "asc" ? comparison : -comparison;
|
|
1235
|
+
}
|
|
1236
|
+
return 0;
|
|
1237
|
+
});
|
|
1238
|
+
return sorted;
|
|
1239
|
+
}
|
|
1240
|
+
compareValues(a, b) {
|
|
1241
|
+
if (a === b) return 0;
|
|
1242
|
+
if (a === null || a === void 0) return 1;
|
|
1243
|
+
if (b === null || b === void 0) return -1;
|
|
1244
|
+
if (typeof a === "string" && typeof b === "string") return a.localeCompare(b);
|
|
1245
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
1246
|
+
return String(a).localeCompare(String(b));
|
|
1247
|
+
}
|
|
1248
|
+
applyProjection(entities$1) {
|
|
1249
|
+
if (!this.queryState.projection) return entities$1;
|
|
1250
|
+
switch (this.queryState.projection.type) {
|
|
1251
|
+
case "ids": return projectToIds(entities$1);
|
|
1252
|
+
case "pick": return projectPick(entities$1, this.queryState.projection.keys);
|
|
1253
|
+
case "pluck": return projectPluck(entities$1, this.queryState.projection.key);
|
|
1254
|
+
case "map": return projectMap(entities$1, this.queryState.projection.mapper);
|
|
1255
|
+
default: return entities$1;
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
applyDistinct(entities$1) {
|
|
1259
|
+
if (!this.queryState.distinct) return entities$1;
|
|
1260
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1261
|
+
const result = [];
|
|
1262
|
+
for (const entity$1 of entities$1) {
|
|
1263
|
+
const value = getNestedValue(entity$1, this.queryState.distinct);
|
|
1264
|
+
const key = JSON.stringify(value);
|
|
1265
|
+
if (!seen.has(key)) {
|
|
1266
|
+
seen.add(key);
|
|
1267
|
+
result.push(entity$1);
|
|
1268
|
+
}
|
|
1269
|
+
}
|
|
1270
|
+
return result;
|
|
1271
|
+
}
|
|
1272
|
+
};
|
|
1273
|
+
var EntityQueryBuilderImpl = class {
|
|
1274
|
+
constructor(entityId) {
|
|
1275
|
+
this.entityId = entityId;
|
|
1276
|
+
}
|
|
1277
|
+
get() {
|
|
1278
|
+
return getEntityView(this.entityId);
|
|
1279
|
+
}
|
|
1280
|
+
pluck(key) {
|
|
1281
|
+
const view = this.get();
|
|
1282
|
+
return view ? view[key] : void 0;
|
|
1283
|
+
}
|
|
1284
|
+
subscribe(callback) {
|
|
1285
|
+
callback(this.get());
|
|
1286
|
+
return () => {};
|
|
1287
|
+
}
|
|
1288
|
+
};
|
|
1289
|
+
/**
|
|
1290
|
+
* Create a query builder for multiple entities
|
|
1291
|
+
*/
|
|
1292
|
+
function entities() {
|
|
1293
|
+
return new EntitiesQueryBuilderImpl();
|
|
1294
|
+
}
|
|
1295
|
+
/**
|
|
1296
|
+
* Create a query builder for a single entity
|
|
1297
|
+
*/
|
|
1298
|
+
function entity(entityId) {
|
|
1299
|
+
return new EntityQueryBuilderImpl(entityId);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
//#endregion
|
|
1303
|
+
//#region src/query/helpers.ts
|
|
1304
|
+
/**
|
|
1305
|
+
* Create a search query builder
|
|
1306
|
+
*/
|
|
1307
|
+
function searchEntities(searchText) {
|
|
1308
|
+
const searchLower = searchText.toLowerCase();
|
|
1309
|
+
return entities().where((e) => e.id.toLowerCase().includes(searchLower) || e.name.toLowerCase().includes(searchLower) || e.friendlyName.toLowerCase().includes(searchLower));
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Create a query for entities in an area by domain
|
|
1313
|
+
*/
|
|
1314
|
+
function entitiesByAreaAndDomain(areaId, domain) {
|
|
1315
|
+
return entities().byArea(areaId).byDomain(domain);
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* Create a query for entities matching a predicate
|
|
1319
|
+
*/
|
|
1320
|
+
function entitiesWhere(predicate) {
|
|
1321
|
+
return entities().where(predicate);
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
//#endregion
|
|
1325
|
+
//#region src/camera/fetch.ts
|
|
1326
|
+
/**
|
|
1327
|
+
* Fetch stream for a camera entity
|
|
1328
|
+
*/
|
|
1329
|
+
async function fetchStream(entityId, format = "hls") {
|
|
1330
|
+
const response = await sendCommand({
|
|
1331
|
+
type: "camera/stream",
|
|
1332
|
+
entity_id: entityId,
|
|
1333
|
+
format
|
|
1334
|
+
});
|
|
1335
|
+
return response?.url ?? null;
|
|
1336
|
+
}
|
|
1337
|
+
/**
|
|
1338
|
+
* Transform relative stream URL to absolute URL
|
|
1339
|
+
*/
|
|
1340
|
+
function transformStreamUrl(streamUrl) {
|
|
1341
|
+
if (!streamUrl) return null;
|
|
1342
|
+
if (streamUrl.startsWith("http://") || streamUrl.startsWith("https://")) return streamUrl;
|
|
1343
|
+
if (streamUrl.startsWith("/")) {
|
|
1344
|
+
const hassUrl = state.hassUrl;
|
|
1345
|
+
if (!hassUrl) {
|
|
1346
|
+
console.warn("[fetchStreamData] No Home Assistant URL available to resolve relative stream URL");
|
|
1347
|
+
return streamUrl;
|
|
1348
|
+
}
|
|
1349
|
+
const baseUrl = hassUrl.replace(/\/$/, "");
|
|
1350
|
+
return `${baseUrl}${streamUrl}`;
|
|
1351
|
+
}
|
|
1352
|
+
return streamUrl;
|
|
1353
|
+
}
|
|
1354
|
+
/**
|
|
1355
|
+
* Fetch stream data for a camera entity
|
|
1356
|
+
*/
|
|
1357
|
+
async function fetchStreamData(entityId, options = {}) {
|
|
1358
|
+
const { format = "hls" } = options;
|
|
1359
|
+
try {
|
|
1360
|
+
const streamUrl = await fetchStream(entityId, format);
|
|
1361
|
+
const absoluteStreamUrl = transformStreamUrl(streamUrl);
|
|
1362
|
+
const stream = {
|
|
1363
|
+
url: absoluteStreamUrl,
|
|
1364
|
+
loading: false,
|
|
1365
|
+
error: null,
|
|
1366
|
+
lastFetched: Date.now(),
|
|
1367
|
+
format,
|
|
1368
|
+
expiresAt: null
|
|
1369
|
+
};
|
|
1370
|
+
return {
|
|
1371
|
+
entityId,
|
|
1372
|
+
stream,
|
|
1373
|
+
loading: false,
|
|
1374
|
+
error: null,
|
|
1375
|
+
lastFetched: Date.now()
|
|
1376
|
+
};
|
|
1377
|
+
} catch (error) {
|
|
1378
|
+
const stream = {
|
|
1379
|
+
url: null,
|
|
1380
|
+
loading: false,
|
|
1381
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1382
|
+
lastFetched: null,
|
|
1383
|
+
format,
|
|
1384
|
+
expiresAt: null
|
|
1385
|
+
};
|
|
1386
|
+
return {
|
|
1387
|
+
entityId,
|
|
1388
|
+
stream,
|
|
1389
|
+
loading: false,
|
|
1390
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1391
|
+
lastFetched: null
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
//#endregion
|
|
1397
|
+
//#region src/camera/query.ts
|
|
1398
|
+
const DEFAULT_REFRESH_INTERVAL = 4 * 60 * 1e3;
|
|
1399
|
+
function needsStreamRefresh(streamData, refreshInterval = DEFAULT_REFRESH_INTERVAL) {
|
|
1400
|
+
if (!streamData || !streamData.lastFetched) return true;
|
|
1401
|
+
const age = Date.now() - streamData.lastFetched;
|
|
1402
|
+
return age >= refreshInterval;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Get stream for a camera entity
|
|
1406
|
+
*/
|
|
1407
|
+
async function getStream(entityId, options = {}) {
|
|
1408
|
+
const { format = "hls", autoRefresh = true, refreshInterval } = options;
|
|
1409
|
+
const cached = state.streams[entityId];
|
|
1410
|
+
if (cached && cached.stream.format === format) {
|
|
1411
|
+
if (autoRefresh && needsStreamRefresh(cached, refreshInterval)) fetchStreamData(entityId, {
|
|
1412
|
+
format,
|
|
1413
|
+
autoRefresh,
|
|
1414
|
+
refreshInterval
|
|
1415
|
+
}).then((streamData$1) => {
|
|
1416
|
+
setState(produce((s) => {
|
|
1417
|
+
s.streams[entityId] = streamData$1;
|
|
1418
|
+
}));
|
|
1419
|
+
});
|
|
1420
|
+
return cached;
|
|
1421
|
+
}
|
|
1422
|
+
const streamData = await fetchStreamData(entityId, {
|
|
1423
|
+
format,
|
|
1424
|
+
autoRefresh,
|
|
1425
|
+
refreshInterval
|
|
1426
|
+
});
|
|
1427
|
+
setState(produce((s) => {
|
|
1428
|
+
s.streams[entityId] = streamData;
|
|
1429
|
+
}));
|
|
1430
|
+
return streamData;
|
|
1431
|
+
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Refresh stream for a camera entity
|
|
1434
|
+
*/
|
|
1435
|
+
async function refreshStream(entityId, format = "hls") {
|
|
1436
|
+
setState(produce((s) => {
|
|
1437
|
+
delete s.streams[entityId];
|
|
1438
|
+
}));
|
|
1439
|
+
return getStream(entityId, { format });
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
//#endregion
|
|
1443
|
+
//#region src/camera/webrtc.ts
|
|
1444
|
+
/**
|
|
1445
|
+
* Get WebRTC client configuration (STUN/TURN servers) from HA
|
|
1446
|
+
*/
|
|
1447
|
+
async function getWebRtcClientConfig(entityId) {
|
|
1448
|
+
const conn = state.conn;
|
|
1449
|
+
if (!conn) throw new Error("Not connected");
|
|
1450
|
+
return conn.sendMessagePromise({
|
|
1451
|
+
type: "camera/webrtc/get_client_config",
|
|
1452
|
+
entity_id: entityId
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
/**
|
|
1456
|
+
* Start WebRTC session with HA.
|
|
1457
|
+
* Returns a promise that resolves when the answer SDP is received.
|
|
1458
|
+
* ICE candidates from HA are forwarded to the provided callback.
|
|
1459
|
+
* Local ICE candidates should be sent via sendWebRtcCandidate().
|
|
1460
|
+
*/
|
|
1461
|
+
function startWebRtcSession(entityId, sdpOffer, onCandidate) {
|
|
1462
|
+
const conn = state.conn;
|
|
1463
|
+
if (!conn) return Promise.reject(new Error("Not connected"));
|
|
1464
|
+
return new Promise((resolve, reject) => {
|
|
1465
|
+
const session = {
|
|
1466
|
+
sessionId: null,
|
|
1467
|
+
unsubscribe: null
|
|
1468
|
+
};
|
|
1469
|
+
let answered = false;
|
|
1470
|
+
const timeout = setTimeout(() => {
|
|
1471
|
+
if (!answered) {
|
|
1472
|
+
session.unsubscribe?.();
|
|
1473
|
+
reject(new Error("WebRTC offer timed out"));
|
|
1474
|
+
}
|
|
1475
|
+
}, 15e3);
|
|
1476
|
+
conn.subscribeMessage((event) => {
|
|
1477
|
+
switch (event.type) {
|
|
1478
|
+
case "session":
|
|
1479
|
+
session.sessionId = event.session_id ?? null;
|
|
1480
|
+
break;
|
|
1481
|
+
case "answer":
|
|
1482
|
+
if (event.answer) {
|
|
1483
|
+
answered = true;
|
|
1484
|
+
clearTimeout(timeout);
|
|
1485
|
+
resolve({
|
|
1486
|
+
answer: event.answer,
|
|
1487
|
+
session
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
break;
|
|
1491
|
+
case "candidate":
|
|
1492
|
+
if (event.candidate) onCandidate(event.candidate);
|
|
1493
|
+
break;
|
|
1494
|
+
case "error":
|
|
1495
|
+
clearTimeout(timeout);
|
|
1496
|
+
session.unsubscribe?.();
|
|
1497
|
+
reject(new Error(event.message || event.code || "WebRTC offer failed"));
|
|
1498
|
+
break;
|
|
1499
|
+
}
|
|
1500
|
+
}, {
|
|
1501
|
+
type: "camera/webrtc/offer",
|
|
1502
|
+
entity_id: entityId,
|
|
1503
|
+
offer: sdpOffer
|
|
1504
|
+
}).then((unsub) => {
|
|
1505
|
+
session.unsubscribe = unsub;
|
|
1506
|
+
}).catch((err) => {
|
|
1507
|
+
clearTimeout(timeout);
|
|
1508
|
+
reject(err);
|
|
1509
|
+
});
|
|
1510
|
+
});
|
|
1511
|
+
}
|
|
1512
|
+
/**
|
|
1513
|
+
* Send a local ICE candidate to HA
|
|
1514
|
+
*/
|
|
1515
|
+
async function sendWebRtcCandidate(entityId, sessionId, candidate) {
|
|
1516
|
+
const conn = state.conn;
|
|
1517
|
+
if (!conn) throw new Error("Not connected");
|
|
1518
|
+
await conn.sendMessagePromise({
|
|
1519
|
+
type: "camera/webrtc/candidate",
|
|
1520
|
+
entity_id: entityId,
|
|
1521
|
+
session_id: sessionId,
|
|
1522
|
+
candidate
|
|
1523
|
+
});
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1526
|
+
//#endregion
|
|
1527
|
+
//#region src/weather/fetch.ts
|
|
1528
|
+
/**
|
|
1529
|
+
* Fetch forecast for a single entity and type
|
|
1530
|
+
*/
|
|
1531
|
+
async function fetchForecast(entityId, type) {
|
|
1532
|
+
const conn = state.conn;
|
|
1533
|
+
if (!conn) throw new Error("Not connected");
|
|
1534
|
+
return new Promise((resolve, reject) => {
|
|
1535
|
+
let resolved = false;
|
|
1536
|
+
let unsubscribe = null;
|
|
1537
|
+
const timeout = setTimeout(() => {
|
|
1538
|
+
if (!resolved && unsubscribe) {
|
|
1539
|
+
resolved = true;
|
|
1540
|
+
unsubscribe().then(() => reject(new Error("Forecast subscription timeout"))).catch(() => reject(new Error("Forecast subscription timeout")));
|
|
1541
|
+
}
|
|
1542
|
+
}, 1e4);
|
|
1543
|
+
conn.subscribeMessage((event) => {
|
|
1544
|
+
if (resolved) return;
|
|
1545
|
+
if (event && typeof event === "object" && "forecast" in event) {
|
|
1546
|
+
resolved = true;
|
|
1547
|
+
clearTimeout(timeout);
|
|
1548
|
+
const forecast = event.forecast;
|
|
1549
|
+
const forecastArray = Array.isArray(forecast) ? forecast : [];
|
|
1550
|
+
if (unsubscribe) unsubscribe().then(() => resolve(forecastArray)).catch(() => resolve(forecastArray));
|
|
1551
|
+
else resolve(forecastArray);
|
|
1552
|
+
}
|
|
1553
|
+
}, {
|
|
1554
|
+
type: "weather/subscribe_forecast",
|
|
1555
|
+
entity_id: entityId,
|
|
1556
|
+
forecast_type: type === "twice_daily" ? "twice_daily" : type
|
|
1557
|
+
}).then((unsubFn) => {
|
|
1558
|
+
unsubscribe = unsubFn;
|
|
1559
|
+
}).catch((error) => {
|
|
1560
|
+
resolved = true;
|
|
1561
|
+
clearTimeout(timeout);
|
|
1562
|
+
reject(error);
|
|
1563
|
+
});
|
|
1564
|
+
});
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Fetch forecasts for a single entity and multiple types
|
|
1568
|
+
*/
|
|
1569
|
+
async function fetchForecasts(entityId, types) {
|
|
1570
|
+
const forecasts = {};
|
|
1571
|
+
const loading = {};
|
|
1572
|
+
const errors = {};
|
|
1573
|
+
const lastFetched = {};
|
|
1574
|
+
const fetchPromises = types.map(async (type) => {
|
|
1575
|
+
loading[type] = true;
|
|
1576
|
+
errors[type] = null;
|
|
1577
|
+
try {
|
|
1578
|
+
const forecast = await fetchForecast(entityId, type);
|
|
1579
|
+
forecasts[type] = forecast;
|
|
1580
|
+
lastFetched[type] = Date.now();
|
|
1581
|
+
loading[type] = false;
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
errors[type] = error instanceof Error ? error : new Error(String(error));
|
|
1584
|
+
forecasts[type] = [];
|
|
1585
|
+
loading[type] = false;
|
|
1586
|
+
lastFetched[type] = null;
|
|
1587
|
+
}
|
|
1588
|
+
});
|
|
1589
|
+
await Promise.all(fetchPromises);
|
|
1590
|
+
return {
|
|
1591
|
+
entityId,
|
|
1592
|
+
forecasts,
|
|
1593
|
+
loading,
|
|
1594
|
+
errors,
|
|
1595
|
+
lastFetched
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Fetch forecast data with error handling
|
|
1600
|
+
*/
|
|
1601
|
+
async function fetchForecastData(entityId, type) {
|
|
1602
|
+
try {
|
|
1603
|
+
const forecast = await fetchForecast(entityId, type);
|
|
1604
|
+
return {
|
|
1605
|
+
entityId,
|
|
1606
|
+
type,
|
|
1607
|
+
forecast,
|
|
1608
|
+
loading: false,
|
|
1609
|
+
error: null,
|
|
1610
|
+
lastFetched: Date.now()
|
|
1611
|
+
};
|
|
1612
|
+
} catch (error) {
|
|
1613
|
+
return {
|
|
1614
|
+
entityId,
|
|
1615
|
+
type,
|
|
1616
|
+
forecast: [],
|
|
1617
|
+
loading: false,
|
|
1618
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
1619
|
+
lastFetched: null
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
//#endregion
|
|
1625
|
+
//#region src/weather/query.ts
|
|
1626
|
+
/**
|
|
1627
|
+
* Get forecast for a single entity and type
|
|
1628
|
+
*/
|
|
1629
|
+
async function getForecast(entityId, type) {
|
|
1630
|
+
const cached = state.forecasts[entityId];
|
|
1631
|
+
if (cached?.forecasts[type] && cached.lastFetched[type]) return {
|
|
1632
|
+
entityId,
|
|
1633
|
+
type,
|
|
1634
|
+
forecast: cached.forecasts[type],
|
|
1635
|
+
loading: cached.loading[type] ?? false,
|
|
1636
|
+
error: cached.errors[type] ?? null,
|
|
1637
|
+
lastFetched: cached.lastFetched[type] ?? null
|
|
1638
|
+
};
|
|
1639
|
+
const forecastData = await fetchForecastData(entityId, type);
|
|
1640
|
+
setState(produce((s) => {
|
|
1641
|
+
if (!s.forecasts[entityId]) s.forecasts[entityId] = {
|
|
1642
|
+
entityId,
|
|
1643
|
+
forecasts: {},
|
|
1644
|
+
loading: {},
|
|
1645
|
+
errors: {},
|
|
1646
|
+
lastFetched: {}
|
|
1647
|
+
};
|
|
1648
|
+
s.forecasts[entityId].forecasts[type] = forecastData.forecast;
|
|
1649
|
+
s.forecasts[entityId].loading[type] = forecastData.loading;
|
|
1650
|
+
s.forecasts[entityId].errors[type] = forecastData.error;
|
|
1651
|
+
s.forecasts[entityId].lastFetched[type] = forecastData.lastFetched;
|
|
1652
|
+
}));
|
|
1653
|
+
return forecastData;
|
|
1654
|
+
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Get forecasts for a single entity and multiple types
|
|
1657
|
+
*/
|
|
1658
|
+
async function getForecasts(entityId, types) {
|
|
1659
|
+
const cachedData = {};
|
|
1660
|
+
const typesToFetch = [];
|
|
1661
|
+
const forecastsRecord = state.forecasts[entityId];
|
|
1662
|
+
for (const type of types) if (forecastsRecord?.forecasts[type] && forecastsRecord.lastFetched[type]) cachedData[type] = {
|
|
1663
|
+
entityId,
|
|
1664
|
+
type,
|
|
1665
|
+
forecast: forecastsRecord.forecasts[type],
|
|
1666
|
+
loading: forecastsRecord.loading[type] ?? false,
|
|
1667
|
+
error: forecastsRecord.errors[type] ?? null,
|
|
1668
|
+
lastFetched: forecastsRecord.lastFetched[type] ?? null
|
|
1669
|
+
};
|
|
1670
|
+
else typesToFetch.push(type);
|
|
1671
|
+
let fetchedData;
|
|
1672
|
+
if (typesToFetch.length > 0) {
|
|
1673
|
+
fetchedData = await fetchForecasts(entityId, typesToFetch);
|
|
1674
|
+
setState(produce((s) => {
|
|
1675
|
+
if (!s.forecasts[entityId]) s.forecasts[entityId] = {
|
|
1676
|
+
entityId,
|
|
1677
|
+
forecasts: {},
|
|
1678
|
+
loading: {},
|
|
1679
|
+
errors: {},
|
|
1680
|
+
lastFetched: {}
|
|
1681
|
+
};
|
|
1682
|
+
for (const type of typesToFetch) {
|
|
1683
|
+
s.forecasts[entityId].forecasts[type] = fetchedData.forecasts[type] ?? [];
|
|
1684
|
+
s.forecasts[entityId].loading[type] = fetchedData.loading[type] ?? false;
|
|
1685
|
+
s.forecasts[entityId].errors[type] = fetchedData.errors[type] ?? null;
|
|
1686
|
+
s.forecasts[entityId].lastFetched[type] = fetchedData.lastFetched[type] ?? null;
|
|
1687
|
+
}
|
|
1688
|
+
}));
|
|
1689
|
+
for (const type of types) if (cachedData[type]) {
|
|
1690
|
+
fetchedData.forecasts[type] = cachedData[type].forecast;
|
|
1691
|
+
fetchedData.loading[type] = cachedData[type].loading;
|
|
1692
|
+
fetchedData.errors[type] = cachedData[type].error;
|
|
1693
|
+
fetchedData.lastFetched[type] = cachedData[type].lastFetched;
|
|
1694
|
+
}
|
|
1695
|
+
} else {
|
|
1696
|
+
fetchedData = {
|
|
1697
|
+
entityId,
|
|
1698
|
+
forecasts: {},
|
|
1699
|
+
loading: {},
|
|
1700
|
+
errors: {},
|
|
1701
|
+
lastFetched: {}
|
|
1702
|
+
};
|
|
1703
|
+
for (const type of types) {
|
|
1704
|
+
const cached = cachedData[type];
|
|
1705
|
+
fetchedData.forecasts[type] = cached.forecast;
|
|
1706
|
+
fetchedData.loading[type] = cached.loading;
|
|
1707
|
+
fetchedData.errors[type] = cached.error;
|
|
1708
|
+
fetchedData.lastFetched[type] = cached.lastFetched;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
return fetchedData;
|
|
1712
|
+
}
|
|
1713
|
+
/**
|
|
1714
|
+
* Refresh forecast
|
|
1715
|
+
*/
|
|
1716
|
+
async function refreshForecast(entityId, type) {
|
|
1717
|
+
setState(produce((s) => {
|
|
1718
|
+
if (s.forecasts[entityId]) {
|
|
1719
|
+
delete s.forecasts[entityId].forecasts[type];
|
|
1720
|
+
delete s.forecasts[entityId].loading[type];
|
|
1721
|
+
delete s.forecasts[entityId].errors[type];
|
|
1722
|
+
delete s.forecasts[entityId].lastFetched[type];
|
|
1723
|
+
}
|
|
1724
|
+
}));
|
|
1725
|
+
return getForecast(entityId, type);
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Refresh forecasts
|
|
1729
|
+
*/
|
|
1730
|
+
async function refreshForecasts(entityId, types) {
|
|
1731
|
+
setState(produce((s) => {
|
|
1732
|
+
if (s.forecasts[entityId]) for (const type of types) {
|
|
1733
|
+
delete s.forecasts[entityId].forecasts[type];
|
|
1734
|
+
delete s.forecasts[entityId].loading[type];
|
|
1735
|
+
delete s.forecasts[entityId].errors[type];
|
|
1736
|
+
delete s.forecasts[entityId].lastFetched[type];
|
|
1737
|
+
}
|
|
1738
|
+
}));
|
|
1739
|
+
return getForecasts(entityId, types);
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
//#endregion
|
|
1743
|
+
//#region src/index.ts
|
|
1744
|
+
const VERSION = "0.1.0";
|
|
1745
|
+
|
|
1746
|
+
//#endregion
|
|
1747
|
+
export { DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, area, areas, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, callService, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isHistoryTracked, refreshAuth, refreshForecast, refreshForecasts, refreshStream, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startWebRtcSession, state, toggle, trackEntityHistory, turnOff, turnOn, untrackEntityHistory, updateEntity };
|