@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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +120 -0
  3. package/dist/area-views-B-m8vwce.js +321 -0
  4. package/dist/camera/fetch.d.ts +15 -0
  5. package/dist/camera/query.d.ts +15 -0
  6. package/dist/camera/types.d.ts +85 -0
  7. package/dist/camera/webrtc.d.ts +31 -0
  8. package/dist/commands/service.d.ts +53 -0
  9. package/dist/commands/types.d.ts +113 -0
  10. package/dist/connection/adapter.d.ts +48 -0
  11. package/dist/connection/auth.d.ts +126 -0
  12. package/dist/connection/manager.d.ts +42 -0
  13. package/dist/connection/subscriptions.d.ts +25 -0
  14. package/dist/connection/types.d.ts +161 -0
  15. package/dist/core/reducers.d.ts +99 -0
  16. package/dist/core/store.d.ts +64 -0
  17. package/dist/core/types.d.ts +336 -0
  18. package/dist/demo/demo-data.d.ts +24 -0
  19. package/dist/demo/demo-provider.d.ts +33 -0
  20. package/dist/demo/demo-provider.js +103 -0
  21. package/dist/demo-data-D8JXWRpw.js +419 -0
  22. package/dist/entities/area-views.d.ts +25 -0
  23. package/dist/entities/index.d.ts +54 -0
  24. package/dist/entities/views.d.ts +30 -0
  25. package/dist/history/constants.d.ts +16 -0
  26. package/dist/history/fetch.d.ts +19 -0
  27. package/dist/history/query.d.ts +13 -0
  28. package/dist/history/types.d.ts +146 -0
  29. package/dist/index.d.ts +38 -0
  30. package/dist/index.js +1747 -0
  31. package/dist/query/area-builder.d.ts +25 -0
  32. package/dist/query/builder.d.ts +18 -0
  33. package/dist/query/helpers.d.ts +21 -0
  34. package/dist/query/memoization.d.ts +18 -0
  35. package/dist/query/projections.d.ts +74 -0
  36. package/dist/query/types.d.ts +260 -0
  37. package/dist/solid/hooks.d.ts +88 -0
  38. package/dist/solid/index.d.ts +10 -0
  39. package/dist/solid/index.js +176 -0
  40. package/dist/solid/indices.d.ts +14 -0
  41. package/dist/store-B1sbyexX.js +54 -0
  42. package/dist/testing/fixtures.d.ts +161 -0
  43. package/dist/testing/helpers.d.ts +160 -0
  44. package/dist/testing/index.d.ts +13 -0
  45. package/dist/testing/index.js +2047 -0
  46. package/dist/testing/mock-behaviors/base.d.ts +73 -0
  47. package/dist/testing/mock-behaviors/climate.d.ts +22 -0
  48. package/dist/testing/mock-behaviors/cover.d.ts +22 -0
  49. package/dist/testing/mock-behaviors/index.d.ts +11 -0
  50. package/dist/testing/mock-behaviors/light.d.ts +20 -0
  51. package/dist/testing/mock-behaviors/lock.d.ts +20 -0
  52. package/dist/testing/mock-behaviors/media_player.d.ts +42 -0
  53. package/dist/testing/mock-behaviors/registry.d.ts +55 -0
  54. package/dist/testing/mock-behaviors/switch.d.ts +17 -0
  55. package/dist/testing/mock.d.ts +191 -0
  56. package/dist/testing/types.d.ts +87 -0
  57. package/dist/types-Csog0nqk.js +10 -0
  58. package/dist/weather/fetch.d.ts +19 -0
  59. package/dist/weather/query.d.ts +23 -0
  60. package/dist/weather/types.d.ts +126 -0
  61. 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 };