@glasshome/sync-layer 0.4.0 → 0.6.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/dist/connection/types.d.ts +0 -10
- package/dist/core/privileged-conn.d.ts +29 -0
- package/dist/core/store.d.ts +0 -2
- package/dist/host-api.d.ts +50 -0
- package/dist/index.d.ts +5 -7
- package/dist/index.js +126 -1006
- package/dist/solid/hooks.d.ts +1 -1
- package/dist/solid/index.js +2 -2
- package/dist/{demo-data-CcG2efdQ.js → statistics-C6JQBDVf.js} +755 -6
- package/package.json +2 -7
- package/dist/testing/fixtures.d.ts +0 -161
- package/dist/testing/helpers.d.ts +0 -160
- package/dist/testing/index.d.ts +0 -13
- package/dist/testing/index.js +0 -2067
- package/dist/testing/mock-behaviors/base.d.ts +0 -73
- package/dist/testing/mock-behaviors/climate.d.ts +0 -22
- package/dist/testing/mock-behaviors/cover.d.ts +0 -22
- package/dist/testing/mock-behaviors/index.d.ts +0 -11
- package/dist/testing/mock-behaviors/light.d.ts +0 -20
- package/dist/testing/mock-behaviors/lock.d.ts +0 -20
- package/dist/testing/mock-behaviors/media_player.d.ts +0 -42
- package/dist/testing/mock-behaviors/registry.d.ts +0 -55
- package/dist/testing/mock-behaviors/switch.d.ts +0 -17
- package/dist/testing/mock.d.ts +0 -196
- package/dist/testing/types.d.ts +0 -87
package/dist/index.js
CHANGED
|
@@ -1,112 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { applyDemoServiceCall, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, callService, createDemoFixtures, demoEnergyPreferences, entityViewEquals, extractDomain, fetchStatisticsDuringPeriod, forceResubscribe, getAllEntityViews, getAreaView, getAreaViews, getEntityView, getEntityViews, isDemoMode, isEnergyEntity, loadDemoData, normalizeStatisticTime, privilegedConn, registerEntity, sendCommand, setManagerConnection, setPrivilegedConn, setResubscribeHandler, setState, startDemoEnergyTicker, state, stopDemoEnergyTicker, synthesizeEnergyHistory, toggle, turnOff, turnOn, unloadDemoData, updateEntity } from "./statistics-C6JQBDVf.js";
|
|
2
2
|
import { runHaBridgeWorker } from "./worker-main-DTbzEKor.js";
|
|
3
|
-
import { createConnection,
|
|
4
|
-
import { produce
|
|
3
|
+
import { createConnection, getAuth } from "home-assistant-js-websocket";
|
|
4
|
+
import { produce } from "solid-js/store";
|
|
5
5
|
|
|
6
|
-
//#region src/connection/adapter.ts
|
|
7
|
-
/**
|
|
8
|
-
* Set of debug listeners for outgoing messages.
|
|
9
|
-
* Supports multiple concurrent observers (e.g., WebSocket event recorder + network monitor).
|
|
10
|
-
*/
|
|
11
|
-
const debugOutgoingMessageListeners = /* @__PURE__ */ new Set();
|
|
12
|
-
/**
|
|
13
|
-
* Set of debug listeners for incoming messages.
|
|
14
|
-
* Captures subscription callbacks and sendMessagePromise responses.
|
|
15
|
-
*/
|
|
16
|
-
const debugIncomingMessageListeners = /* @__PURE__ */ new Set();
|
|
17
|
-
/**
|
|
18
|
-
* Register a listener for all outgoing WebSocket messages.
|
|
19
|
-
* Returns an unsubscribe function.
|
|
20
|
-
*
|
|
21
|
-
* @param listener - Function called with each outgoing message object
|
|
22
|
-
* @returns Unsubscribe function to remove the listener
|
|
23
|
-
*/
|
|
24
|
-
function addDebugOutgoingMessageListener(listener) {
|
|
25
|
-
debugOutgoingMessageListeners.add(listener);
|
|
26
|
-
return () => {
|
|
27
|
-
debugOutgoingMessageListeners.delete(listener);
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Register a listener for all incoming WebSocket messages.
|
|
32
|
-
* Returns an unsubscribe function. Receives subscription updates,
|
|
33
|
-
* event callbacks, and sendMessagePromise responses.
|
|
34
|
-
*
|
|
35
|
-
* @param listener - Function called with each incoming message object
|
|
36
|
-
* @returns Unsubscribe function to remove the listener
|
|
37
|
-
*/
|
|
38
|
-
function addDebugIncomingMessageListener(listener) {
|
|
39
|
-
debugIncomingMessageListeners.add(listener);
|
|
40
|
-
return () => {
|
|
41
|
-
debugIncomingMessageListeners.delete(listener);
|
|
42
|
-
};
|
|
43
|
-
}
|
|
44
|
-
function notifyOutgoingListeners(message) {
|
|
45
|
-
if (debugOutgoingMessageListeners.size > 0) for (const listener of debugOutgoingMessageListeners) listener(message);
|
|
46
|
-
}
|
|
47
|
-
function notifyIncomingListeners(message) {
|
|
48
|
-
if (debugIncomingMessageListeners.size > 0) for (const listener of debugIncomingMessageListeners) listener(message);
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Wrap HA Connection to SyncLayerConnection interface
|
|
52
|
-
*
|
|
53
|
-
* This adapter makes the real HA Connection compatible with our unified interface,
|
|
54
|
-
* allowing it to be used interchangeably with MockConnection.
|
|
55
|
-
*
|
|
56
|
-
* @param conn - Home Assistant WebSocket connection
|
|
57
|
-
* @returns Wrapped connection implementing SyncLayerConnection
|
|
58
|
-
*
|
|
59
|
-
* @example
|
|
60
|
-
* ```typescript
|
|
61
|
-
* const haConn = await createConnection({ auth });
|
|
62
|
-
* const wrappedConn = wrapHAConnection(haConn);
|
|
63
|
-
* await syncLayer.connect({ connection: wrappedConn });
|
|
64
|
-
* ```
|
|
65
|
-
*/
|
|
66
|
-
function wrapHAConnection(conn$1) {
|
|
67
|
-
return {
|
|
68
|
-
connect: async () => {
|
|
69
|
-
return Promise.resolve();
|
|
70
|
-
},
|
|
71
|
-
disconnect: () => {
|
|
72
|
-
conn$1.close();
|
|
73
|
-
},
|
|
74
|
-
get connected$() {
|
|
75
|
-
return conn$1.connected;
|
|
76
|
-
},
|
|
77
|
-
authenticate: async () => {
|
|
78
|
-
return Promise.resolve();
|
|
79
|
-
},
|
|
80
|
-
get authState() {
|
|
81
|
-
return "authenticated";
|
|
82
|
-
},
|
|
83
|
-
subscribeEvents: async (callback, eventType) => {
|
|
84
|
-
const wrappedCallback = (event) => {
|
|
85
|
-
notifyIncomingListeners(event);
|
|
86
|
-
callback(event);
|
|
87
|
-
};
|
|
88
|
-
return conn$1.subscribeEvents(wrappedCallback, eventType);
|
|
89
|
-
},
|
|
90
|
-
subscribeMessage: async (callback, message) => {
|
|
91
|
-
const wrappedCallback = (msg) => {
|
|
92
|
-
notifyIncomingListeners(msg);
|
|
93
|
-
callback(msg);
|
|
94
|
-
};
|
|
95
|
-
return conn$1.subscribeMessage(wrappedCallback, message);
|
|
96
|
-
},
|
|
97
|
-
sendMessagePromise: async (message) => {
|
|
98
|
-
notifyOutgoingListeners(message);
|
|
99
|
-
const result = await conn$1.sendMessagePromise(message);
|
|
100
|
-
notifyIncomingListeners(result);
|
|
101
|
-
return result;
|
|
102
|
-
},
|
|
103
|
-
addEventListener: (handler) => {
|
|
104
|
-
return addDebugIncomingMessageListener(handler);
|
|
105
|
-
}
|
|
106
|
-
};
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
//#endregion
|
|
110
6
|
//#region src/core/reducers.ts
|
|
111
7
|
/**
|
|
112
8
|
* Bulk update multiple entities
|
|
@@ -352,402 +248,6 @@ async function authenticateWithOAuth(options) {
|
|
|
352
248
|
});
|
|
353
249
|
return auth;
|
|
354
250
|
}
|
|
355
|
-
/**
|
|
356
|
-
* Authenticate using a long-lived token
|
|
357
|
-
*
|
|
358
|
-
* @param url - Home Assistant instance URL
|
|
359
|
-
* @param token - Long-lived access token
|
|
360
|
-
* @returns Authenticated Auth object
|
|
361
|
-
*
|
|
362
|
-
* @example
|
|
363
|
-
* ```typescript
|
|
364
|
-
* const auth = authenticateWithToken(
|
|
365
|
-
* 'https://homeassistant.local:8123',
|
|
366
|
-
* 'your_long_lived_token'
|
|
367
|
-
* );
|
|
368
|
-
* ```
|
|
369
|
-
*/
|
|
370
|
-
function authenticateWithToken(url, token) {
|
|
371
|
-
return createLongLivedTokenAuth(url, token);
|
|
372
|
-
}
|
|
373
|
-
/**
|
|
374
|
-
* Check if auth object is valid
|
|
375
|
-
*
|
|
376
|
-
* @param auth - Auth object to check
|
|
377
|
-
* @returns True if auth is valid
|
|
378
|
-
*/
|
|
379
|
-
async function isAuthValid(auth) {
|
|
380
|
-
try {
|
|
381
|
-
await auth.refreshAccessToken();
|
|
382
|
-
return true;
|
|
383
|
-
} catch {
|
|
384
|
-
return false;
|
|
385
|
-
}
|
|
386
|
-
}
|
|
387
|
-
/**
|
|
388
|
-
* Refresh auth tokens
|
|
389
|
-
*
|
|
390
|
-
* @param auth - Auth object to refresh
|
|
391
|
-
* @returns Refreshed Auth object
|
|
392
|
-
*/
|
|
393
|
-
async function refreshAuth(auth) {
|
|
394
|
-
await auth.refreshAccessToken();
|
|
395
|
-
return auth;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
//#endregion
|
|
399
|
-
//#region src/connection/subscription-manager.ts
|
|
400
|
-
if (globalThis.__GH_SYNC_LAYER_SUB_MGR__) console.error("[@glasshome/sync-layer] DUPLICATE subscription-manager module instantiated. registerEntity() and the connection setter will hit different singletons → subscribe_entities will never be sent. Cause: relative import of subscription-manager from a separate entry point.");
|
|
401
|
-
globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
|
|
402
|
-
/** Ref counts — how many consumers need each entity */
|
|
403
|
-
const refCounts = /* @__PURE__ */ new Map();
|
|
404
|
-
/** Current HA subscription unsub function */
|
|
405
|
-
let currentEntityUnsub = null;
|
|
406
|
-
/** Snapshot of entity IDs in the current HA subscription */
|
|
407
|
-
let subscribedIds = /* @__PURE__ */ new Set();
|
|
408
|
-
/** Connection reference for resubscribing */
|
|
409
|
-
let conn = null;
|
|
410
|
-
/** Pending flush timer (microtask for register, timeout for unregister) */
|
|
411
|
-
let flushScheduled = false;
|
|
412
|
-
/** Delayed unregister timeout */
|
|
413
|
-
let unregisterTimeout = null;
|
|
414
|
-
/** Callback invoked on resubscribe — set by subscriptions.ts */
|
|
415
|
-
let onResubscribe = null;
|
|
416
|
-
/** Delay before unregistered entities are actually removed from subscription */
|
|
417
|
-
const UNREGISTER_DELAY_MS = 5e3;
|
|
418
|
-
/** Guard against concurrent flush() calls */
|
|
419
|
-
let flushInFlight = false;
|
|
420
|
-
/**
|
|
421
|
-
* Register an entity for live updates. Returns an unregister function.
|
|
422
|
-
* Call from useEntity/useEntities hooks via onCleanup.
|
|
423
|
-
*/
|
|
424
|
-
function registerEntity(entityId) {
|
|
425
|
-
refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
|
|
426
|
-
scheduleFlush();
|
|
427
|
-
return () => unregisterEntity(entityId);
|
|
428
|
-
}
|
|
429
|
-
/**
|
|
430
|
-
* Set the connection reference used for resubscribing.
|
|
431
|
-
*/
|
|
432
|
-
function setManagerConnection(connection) {
|
|
433
|
-
conn = connection;
|
|
434
|
-
if (refCounts.size > 0) scheduleFlush();
|
|
435
|
-
}
|
|
436
|
-
/**
|
|
437
|
-
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
438
|
-
* the manager into the actual WS subscription lifecycle.
|
|
439
|
-
*/
|
|
440
|
-
function setResubscribeHandler(handler) {
|
|
441
|
-
onResubscribe = handler;
|
|
442
|
-
}
|
|
443
|
-
/**
|
|
444
|
-
* Force a resubscribe with the current active set.
|
|
445
|
-
* Used on reconnect.
|
|
446
|
-
*/
|
|
447
|
-
async function forceResubscribe() {
|
|
448
|
-
await flush();
|
|
449
|
-
}
|
|
450
|
-
function unregisterEntity(entityId) {
|
|
451
|
-
const count = (refCounts.get(entityId) ?? 1) - 1;
|
|
452
|
-
if (count <= 0) refCounts.delete(entityId);
|
|
453
|
-
else refCounts.set(entityId, count);
|
|
454
|
-
scheduleDelayedFlush();
|
|
455
|
-
}
|
|
456
|
-
function scheduleFlush() {
|
|
457
|
-
if (flushScheduled) return;
|
|
458
|
-
flushScheduled = true;
|
|
459
|
-
queueMicrotask(() => {
|
|
460
|
-
flushScheduled = false;
|
|
461
|
-
flush();
|
|
462
|
-
});
|
|
463
|
-
}
|
|
464
|
-
function scheduleDelayedFlush() {
|
|
465
|
-
if (unregisterTimeout) return;
|
|
466
|
-
unregisterTimeout = setTimeout(() => {
|
|
467
|
-
unregisterTimeout = null;
|
|
468
|
-
flush();
|
|
469
|
-
}, UNREGISTER_DELAY_MS);
|
|
470
|
-
}
|
|
471
|
-
async function flush() {
|
|
472
|
-
if (!conn || !onResubscribe) return;
|
|
473
|
-
if (flushInFlight) return;
|
|
474
|
-
flushInFlight = true;
|
|
475
|
-
try {
|
|
476
|
-
const newIds = new Set(refCounts.keys());
|
|
477
|
-
if (setsEqual(newIds, subscribedIds)) return;
|
|
478
|
-
const oldUnsub = currentEntityUnsub;
|
|
479
|
-
const entityIds = [...newIds];
|
|
480
|
-
currentEntityUnsub = await onResubscribe(conn, entityIds);
|
|
481
|
-
subscribedIds = newIds;
|
|
482
|
-
if (oldUnsub) try {
|
|
483
|
-
await oldUnsub();
|
|
484
|
-
} catch {}
|
|
485
|
-
} finally {
|
|
486
|
-
flushInFlight = false;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
function setsEqual(a, b) {
|
|
490
|
-
if (a.size !== b.size) return false;
|
|
491
|
-
for (const id of a) if (!b.has(id)) return false;
|
|
492
|
-
return true;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
//#endregion
|
|
496
|
-
//#region src/core/types.ts
|
|
497
|
-
/**
|
|
498
|
-
* Extract domain from entity ID
|
|
499
|
-
*/
|
|
500
|
-
function extractDomain(entityId) {
|
|
501
|
-
return entityId.split(".")[0] ?? "";
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
//#endregion
|
|
505
|
-
//#region src/demo/demo-provider.ts
|
|
506
|
-
let _isDemoMode = false;
|
|
507
|
-
/**
|
|
508
|
-
* Whether demo mode is currently active.
|
|
509
|
-
*/
|
|
510
|
-
function isDemoMode() {
|
|
511
|
-
return _isDemoMode;
|
|
512
|
-
}
|
|
513
|
-
/**
|
|
514
|
-
* Load demo fixtures into the SolidJS store.
|
|
515
|
-
*
|
|
516
|
-
* Populates entities, entity registry, areas, and devices.
|
|
517
|
-
* Sets connection state to "connected" (pretend connected).
|
|
518
|
-
*/
|
|
519
|
-
async function loadDemoData() {
|
|
520
|
-
const fixtures = createDemoFixtures();
|
|
521
|
-
setState("entities", reconcile(fixtures.entities));
|
|
522
|
-
setState("entityRegistry", reconcile(fixtures.entityRegistry));
|
|
523
|
-
setState("areas", reconcile(fixtures.areas));
|
|
524
|
-
setState("devices", reconcile(fixtures.devices));
|
|
525
|
-
setState("hassUrl", "https://demo.home-assistant.local");
|
|
526
|
-
setState("connectionState", "connected");
|
|
527
|
-
_isDemoMode = true;
|
|
528
|
-
startDemoEnergyTicker();
|
|
529
|
-
}
|
|
530
|
-
/**
|
|
531
|
-
* Unload demo data and reset to disconnected state.
|
|
532
|
-
*/
|
|
533
|
-
function unloadDemoData() {
|
|
534
|
-
stopDemoEnergyTicker();
|
|
535
|
-
setState("entities", reconcile({}));
|
|
536
|
-
setState("entityRegistry", reconcile({}));
|
|
537
|
-
setState("areas", reconcile({}));
|
|
538
|
-
setState("devices", reconcile({}));
|
|
539
|
-
setState("hassUrl", null);
|
|
540
|
-
setState("connectionState", "disconnected");
|
|
541
|
-
_isDemoMode = false;
|
|
542
|
-
}
|
|
543
|
-
const ENERGY_TICK_MS = 5e3;
|
|
544
|
-
let _energyTicker = null;
|
|
545
|
-
/** Write the current energy sample into the store as string states (like real HA). */
|
|
546
|
-
function tickEnergy() {
|
|
547
|
-
const nowMs = Date.now();
|
|
548
|
-
const sample = simulateEnergy(nowMs);
|
|
549
|
-
const sun = sunEvents(nowMs);
|
|
550
|
-
const nowIso = new Date(nowMs).toISOString();
|
|
551
|
-
setState(produce((s) => {
|
|
552
|
-
for (const entityId of ENERGY_ENTITY_IDS) {
|
|
553
|
-
const e = s.entities[entityId];
|
|
554
|
-
if (!e) continue;
|
|
555
|
-
const value = energyEntityValue(entityId, sample);
|
|
556
|
-
if (value === void 0) continue;
|
|
557
|
-
const next = formatEnergyState(entityId, value);
|
|
558
|
-
if (e.state !== next) e.last_changed = nowIso;
|
|
559
|
-
e.state = next;
|
|
560
|
-
e.last_updated = nowIso;
|
|
561
|
-
}
|
|
562
|
-
const sunEntity = s.entities["sun.sun"];
|
|
563
|
-
if (sunEntity) {
|
|
564
|
-
const nextState = isSunUp(nowMs) ? "above_horizon" : "below_horizon";
|
|
565
|
-
if (sunEntity.state !== nextState) sunEntity.last_changed = nowIso;
|
|
566
|
-
sunEntity.state = nextState;
|
|
567
|
-
sunEntity.last_updated = nowIso;
|
|
568
|
-
sunEntity.attributes.elevation = sun.elevation;
|
|
569
|
-
sunEntity.attributes.next_rising = sun.nextRising;
|
|
570
|
-
sunEntity.attributes.next_setting = sun.nextSetting;
|
|
571
|
-
}
|
|
572
|
-
}));
|
|
573
|
-
}
|
|
574
|
-
/**
|
|
575
|
-
* Start the demo energy ticker. Updates the energy sensors and `sun.sun`
|
|
576
|
-
* every 5 seconds. Idempotent: clears any prior interval first. SSR-safe.
|
|
577
|
-
*/
|
|
578
|
-
function startDemoEnergyTicker() {
|
|
579
|
-
stopDemoEnergyTicker();
|
|
580
|
-
if (typeof setInterval !== "function") return;
|
|
581
|
-
tickEnergy();
|
|
582
|
-
_energyTicker = setInterval(tickEnergy, ENERGY_TICK_MS);
|
|
583
|
-
}
|
|
584
|
-
/** Stop the demo energy ticker if running. */
|
|
585
|
-
function stopDemoEnergyTicker() {
|
|
586
|
-
if (_energyTicker !== null) {
|
|
587
|
-
clearInterval(_energyTicker);
|
|
588
|
-
_energyTicker = null;
|
|
589
|
-
}
|
|
590
|
-
}
|
|
591
|
-
/**
|
|
592
|
-
* Apply an optimistic state update for demo mode.
|
|
593
|
-
*
|
|
594
|
-
* When a service call is made in demo mode, this updates the entity
|
|
595
|
-
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
596
|
-
*/
|
|
597
|
-
function applyDemoServiceCall(_domain, service, _serviceData = {}, target = {}) {
|
|
598
|
-
if (!_isDemoMode) return;
|
|
599
|
-
const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
|
|
600
|
-
for (const entityId of entityIds) {
|
|
601
|
-
const entity$1 = state.entities[entityId];
|
|
602
|
-
if (!entity$1) continue;
|
|
603
|
-
const entityDomain = extractDomain(entityId);
|
|
604
|
-
setState(produce((s) => {
|
|
605
|
-
const e = s.entities[entityId];
|
|
606
|
-
if (!e) return;
|
|
607
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
608
|
-
e.last_changed = now;
|
|
609
|
-
e.last_updated = now;
|
|
610
|
-
if (service === "turn_on") applyTurnOn(e, entityDomain, _serviceData);
|
|
611
|
-
else if (service === "turn_off") applyTurnOff(e, entityDomain);
|
|
612
|
-
else if (service === "toggle") if (e.state === "on" || e.state === "playing" || e.state === "open") applyTurnOff(e, entityDomain);
|
|
613
|
-
else applyTurnOn(e, entityDomain, _serviceData);
|
|
614
|
-
else if (service === "lock") e.state = "locked";
|
|
615
|
-
else if (service === "unlock") e.state = "unlocked";
|
|
616
|
-
else if (service === "open_cover") {
|
|
617
|
-
e.state = "open";
|
|
618
|
-
if ("current_position" in e.attributes) e.attributes.current_position = 100;
|
|
619
|
-
} else if (service === "close_cover") {
|
|
620
|
-
e.state = "closed";
|
|
621
|
-
if ("current_position" in e.attributes) e.attributes.current_position = 0;
|
|
622
|
-
} else if (service === "set_cover_position" && _serviceData.position != null) {
|
|
623
|
-
e.attributes.current_position = _serviceData.position;
|
|
624
|
-
e.state = _serviceData.position > 0 ? "open" : "closed";
|
|
625
|
-
} else if (service === "set_temperature" && _serviceData.temperature != null) e.attributes.temperature = _serviceData.temperature;
|
|
626
|
-
else if (service === "set_hvac_mode" && _serviceData.hvac_mode != null) e.state = _serviceData.hvac_mode;
|
|
627
|
-
else if (service === "volume_set" && _serviceData.volume_level != null) e.attributes.volume_level = _serviceData.volume_level;
|
|
628
|
-
}));
|
|
629
|
-
}
|
|
630
|
-
}
|
|
631
|
-
function applyTurnOn(e, domain, serviceData = {}) {
|
|
632
|
-
if (domain === "light") {
|
|
633
|
-
e.state = "on";
|
|
634
|
-
e.attributes.brightness = serviceData.brightness ?? e.attributes.brightness ?? 255;
|
|
635
|
-
if (serviceData.color_temp_kelvin) e.attributes.color_temp_kelvin = serviceData.color_temp_kelvin;
|
|
636
|
-
} else if (domain === "media_player") e.state = "playing";
|
|
637
|
-
else if (domain === "cover") {
|
|
638
|
-
e.state = "open";
|
|
639
|
-
if ("current_position" in e.attributes) e.attributes.current_position = 100;
|
|
640
|
-
} else e.state = "on";
|
|
641
|
-
}
|
|
642
|
-
function applyTurnOff(e, domain) {
|
|
643
|
-
if (domain === "light") {
|
|
644
|
-
e.state = "off";
|
|
645
|
-
delete e.attributes.brightness;
|
|
646
|
-
} else if (domain === "media_player") e.state = "paused";
|
|
647
|
-
else if (domain === "cover") {
|
|
648
|
-
e.state = "closed";
|
|
649
|
-
if ("current_position" in e.attributes) e.attributes.current_position = 0;
|
|
650
|
-
} else e.state = "off";
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
//#endregion
|
|
654
|
-
//#region src/commands/service.ts
|
|
655
|
-
/**
|
|
656
|
-
* Call a Home Assistant service
|
|
657
|
-
*/
|
|
658
|
-
async function callService(domain, service, serviceData = {}, target = {}) {
|
|
659
|
-
const connection = state.conn;
|
|
660
|
-
if (!connection) {
|
|
661
|
-
applyDemoServiceCall(domain, service, serviceData, target);
|
|
662
|
-
return;
|
|
663
|
-
}
|
|
664
|
-
try {
|
|
665
|
-
const mockConnection = connection;
|
|
666
|
-
if (mockConnection && typeof mockConnection.callService === "function") await mockConnection.callService(domain, service, serviceData, target);
|
|
667
|
-
else await connection.sendMessagePromise({
|
|
668
|
-
type: "call_service",
|
|
669
|
-
domain,
|
|
670
|
-
service,
|
|
671
|
-
service_data: serviceData,
|
|
672
|
-
target
|
|
673
|
-
});
|
|
674
|
-
} catch (error) {
|
|
675
|
-
throw new Error(`Service call failed: ${error.message || String(error)}`);
|
|
676
|
-
}
|
|
677
|
-
}
|
|
678
|
-
/**
|
|
679
|
-
* Turn on entity or entities
|
|
680
|
-
*/
|
|
681
|
-
async function turnOn(entityId, serviceData = {}) {
|
|
682
|
-
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
683
|
-
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
684
|
-
const domain = firstEntityId.split(".")[0];
|
|
685
|
-
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
686
|
-
await callService(domain, "turn_on", serviceData, { entity_id: entityId });
|
|
687
|
-
}
|
|
688
|
-
/**
|
|
689
|
-
* Turn off entity or entities
|
|
690
|
-
*/
|
|
691
|
-
async function turnOff(entityId, serviceData = {}) {
|
|
692
|
-
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
693
|
-
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
694
|
-
const domain = firstEntityId.split(".")[0];
|
|
695
|
-
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
696
|
-
await callService(domain, "turn_off", serviceData, { entity_id: entityId });
|
|
697
|
-
}
|
|
698
|
-
/**
|
|
699
|
-
* Toggle entity or entities
|
|
700
|
-
*/
|
|
701
|
-
async function toggle(entityId, serviceData = {}) {
|
|
702
|
-
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
703
|
-
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
704
|
-
const domain = firstEntityId.split(".")[0];
|
|
705
|
-
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
706
|
-
await callService(domain, "toggle", serviceData, { entity_id: entityId });
|
|
707
|
-
}
|
|
708
|
-
/**
|
|
709
|
-
* Update entity registry entry
|
|
710
|
-
*/
|
|
711
|
-
async function updateEntity(entityId, updates) {
|
|
712
|
-
const connection = state.conn;
|
|
713
|
-
if (!connection) throw new Error("Not connected to Home Assistant. Call initConnection() first.");
|
|
714
|
-
try {
|
|
715
|
-
const result = await connection.sendMessagePromise({
|
|
716
|
-
type: "config/entity_registry/update",
|
|
717
|
-
entity_id: entityId,
|
|
718
|
-
...updates
|
|
719
|
-
});
|
|
720
|
-
if (result && typeof result === "object" && "_mockError" in result) throw new Error(`Entity registry entry not found: ${entityId}`);
|
|
721
|
-
return result;
|
|
722
|
-
} catch (error) {
|
|
723
|
-
if (error?.message && error.message.includes("Entity registry entry not found")) throw error;
|
|
724
|
-
throw new Error(`Entity update failed: ${error.message || String(error)}`);
|
|
725
|
-
}
|
|
726
|
-
}
|
|
727
|
-
/**
|
|
728
|
-
* Send a generic WebSocket command
|
|
729
|
-
*/
|
|
730
|
-
async function sendCommand(command) {
|
|
731
|
-
const connection = state.conn;
|
|
732
|
-
if (!connection) throw new Error("Not connected");
|
|
733
|
-
try {
|
|
734
|
-
return await connection.sendMessagePromise(command);
|
|
735
|
-
} catch (error) {
|
|
736
|
-
throw new Error(`Command failed: ${error.message || String(error)}`);
|
|
737
|
-
}
|
|
738
|
-
}
|
|
739
|
-
/**
|
|
740
|
-
* Execute multiple service calls in parallel
|
|
741
|
-
*/
|
|
742
|
-
async function batchServiceCalls(calls) {
|
|
743
|
-
await Promise.all(calls.map((call) => callService(call.domain, call.service, call.serviceData, call.target)));
|
|
744
|
-
}
|
|
745
|
-
/**
|
|
746
|
-
* Execute multiple entity updates in parallel
|
|
747
|
-
*/
|
|
748
|
-
async function batchEntityUpdates(updates) {
|
|
749
|
-
return Promise.all(updates.map((u) => updateEntity(u.entityId, u.updates)));
|
|
750
|
-
}
|
|
751
251
|
|
|
752
252
|
//#endregion
|
|
753
253
|
//#region src/history/constants.ts
|
|
@@ -1011,35 +511,35 @@ async function subscribeEntities(connection, entityIds) {
|
|
|
1011
511
|
/**
|
|
1012
512
|
* Subscribe to all relevant events from Home Assistant
|
|
1013
513
|
*/
|
|
1014
|
-
async function subscribeToUpdates(conn
|
|
514
|
+
async function subscribeToUpdates(conn) {
|
|
1015
515
|
await cleanupSubscriptions();
|
|
1016
|
-
setManagerConnection(conn
|
|
516
|
+
setManagerConnection(conn);
|
|
1017
517
|
setResubscribeHandler(subscribeEntities);
|
|
1018
|
-
const registryUpdatedSub = await conn
|
|
518
|
+
const registryUpdatedSub = await conn.subscribeEvents((event) => {
|
|
1019
519
|
handleRegistryUpdate(event);
|
|
1020
520
|
}, "entity_registry_updated");
|
|
1021
521
|
registrySubscriptions.push(() => registryUpdatedSub());
|
|
1022
|
-
const deviceRegistrySub = await conn
|
|
522
|
+
const deviceRegistrySub = await conn.subscribeEvents((event) => {
|
|
1023
523
|
handleDeviceRegistryUpdate(event);
|
|
1024
524
|
}, "device_registry_updated");
|
|
1025
525
|
registrySubscriptions.push(() => deviceRegistrySub());
|
|
1026
|
-
const areaRegistrySub = await conn
|
|
526
|
+
const areaRegistrySub = await conn.subscribeEvents((event) => {
|
|
1027
527
|
handleAreaRegistryUpdate(event);
|
|
1028
528
|
}, "area_registry_updated");
|
|
1029
529
|
registrySubscriptions.push(() => areaRegistrySub());
|
|
1030
|
-
const floorRegistrySub = await conn
|
|
530
|
+
const floorRegistrySub = await conn.subscribeEvents((event) => {
|
|
1031
531
|
handleFloorRegistryUpdate(event);
|
|
1032
532
|
}, "floor_registry_updated");
|
|
1033
533
|
registrySubscriptions.push(() => floorRegistrySub());
|
|
1034
|
-
const labelRegistrySub = await conn
|
|
534
|
+
const labelRegistrySub = await conn.subscribeEvents((event) => {
|
|
1035
535
|
handleLabelRegistryUpdate(event);
|
|
1036
536
|
}, "label_registry_updated");
|
|
1037
537
|
registrySubscriptions.push(() => labelRegistrySub());
|
|
1038
|
-
const statisticsSub = await conn
|
|
538
|
+
const statisticsSub = await conn.subscribeEvents((event) => {
|
|
1039
539
|
handleStatisticsUpdate(event);
|
|
1040
540
|
}, "recorder_5min_statistics_generated");
|
|
1041
541
|
registrySubscriptions.push(() => statisticsSub());
|
|
1042
|
-
const coreConfigSub = await conn
|
|
542
|
+
const coreConfigSub = await conn.subscribeEvents(() => {
|
|
1043
543
|
handleCoreConfigUpdate();
|
|
1044
544
|
}, "core_config_updated");
|
|
1045
545
|
registrySubscriptions.push(() => coreConfigSub());
|
|
@@ -1053,10 +553,10 @@ async function cleanupSubscriptions() {
|
|
|
1053
553
|
registrySubscriptions = [];
|
|
1054
554
|
}
|
|
1055
555
|
async function handleCoreConfigUpdate() {
|
|
1056
|
-
const conn
|
|
1057
|
-
if (!conn
|
|
556
|
+
const conn = privilegedConn();
|
|
557
|
+
if (!conn) return;
|
|
1058
558
|
try {
|
|
1059
|
-
const config = await conn
|
|
559
|
+
const config = await conn.sendMessagePromise({ type: "get_config" });
|
|
1060
560
|
if (config) setState("config", config);
|
|
1061
561
|
} catch (error) {
|
|
1062
562
|
console.error("Error refreshing core config:", error);
|
|
@@ -1065,10 +565,10 @@ async function handleCoreConfigUpdate() {
|
|
|
1065
565
|
async function handleRegistryUpdate(event) {
|
|
1066
566
|
const { action, entity_id } = event.data;
|
|
1067
567
|
if (action === "create" || action === "update") {
|
|
1068
|
-
const conn
|
|
1069
|
-
if (!conn
|
|
568
|
+
const conn = privilegedConn();
|
|
569
|
+
if (!conn) return;
|
|
1070
570
|
try {
|
|
1071
|
-
const entry = await conn
|
|
571
|
+
const entry = await conn.sendMessagePromise({
|
|
1072
572
|
type: "config/entity_registry/get",
|
|
1073
573
|
entity_id
|
|
1074
574
|
});
|
|
@@ -1081,10 +581,10 @@ async function handleRegistryUpdate(event) {
|
|
|
1081
581
|
async function handleDeviceRegistryUpdate(event) {
|
|
1082
582
|
const { action, device_id } = event.data;
|
|
1083
583
|
if (action === "create" || action === "update") {
|
|
1084
|
-
const conn
|
|
1085
|
-
if (!conn
|
|
584
|
+
const conn = privilegedConn();
|
|
585
|
+
if (!conn) return;
|
|
1086
586
|
try {
|
|
1087
|
-
const devices = await conn
|
|
587
|
+
const devices = await conn.sendMessagePromise({ type: "config/device_registry/list" });
|
|
1088
588
|
const device = devices.find((d) => d.id === device_id);
|
|
1089
589
|
if (device) updateDevice(device);
|
|
1090
590
|
} catch (error) {
|
|
@@ -1095,10 +595,10 @@ async function handleDeviceRegistryUpdate(event) {
|
|
|
1095
595
|
async function handleAreaRegistryUpdate(event) {
|
|
1096
596
|
const { action, area_id } = event.data;
|
|
1097
597
|
if (action === "create" || action === "update") {
|
|
1098
|
-
const conn
|
|
1099
|
-
if (!conn
|
|
598
|
+
const conn = privilegedConn();
|
|
599
|
+
if (!conn) return;
|
|
1100
600
|
try {
|
|
1101
|
-
const areas$1 = await conn
|
|
601
|
+
const areas$1 = await conn.sendMessagePromise({ type: "config/area_registry/list" });
|
|
1102
602
|
const area$1 = areas$1.find((a) => a.id === area_id);
|
|
1103
603
|
if (area$1) updateArea(area$1);
|
|
1104
604
|
} catch (error) {
|
|
@@ -1109,10 +609,10 @@ async function handleAreaRegistryUpdate(event) {
|
|
|
1109
609
|
async function handleFloorRegistryUpdate(event) {
|
|
1110
610
|
const { action, floor_id } = event.data;
|
|
1111
611
|
if (action === "create" || action === "update") {
|
|
1112
|
-
const conn
|
|
1113
|
-
if (!conn
|
|
612
|
+
const conn = privilegedConn();
|
|
613
|
+
if (!conn) return;
|
|
1114
614
|
try {
|
|
1115
|
-
const floors = await conn
|
|
615
|
+
const floors = await conn.sendMessagePromise({ type: "config/floor_registry/list" });
|
|
1116
616
|
const floor = floors.find((f) => f.floor_id === floor_id);
|
|
1117
617
|
if (floor) updateFloor(floor);
|
|
1118
618
|
} catch (error) {
|
|
@@ -1123,10 +623,10 @@ async function handleFloorRegistryUpdate(event) {
|
|
|
1123
623
|
async function handleLabelRegistryUpdate(event) {
|
|
1124
624
|
const { action, label_id } = event.data;
|
|
1125
625
|
if (action === "create" || action === "update") {
|
|
1126
|
-
const conn
|
|
1127
|
-
if (!conn
|
|
626
|
+
const conn = privilegedConn();
|
|
627
|
+
if (!conn) return;
|
|
1128
628
|
try {
|
|
1129
|
-
const labels = await conn
|
|
629
|
+
const labels = await conn.sendMessagePromise({ type: "config/label_registry/list" });
|
|
1130
630
|
const label = labels.find((l) => l.label_id === label_id);
|
|
1131
631
|
if (label) updateLabel(label);
|
|
1132
632
|
} catch (error) {
|
|
@@ -1137,10 +637,10 @@ async function handleLabelRegistryUpdate(event) {
|
|
|
1137
637
|
async function handleStatisticsUpdate(event) {
|
|
1138
638
|
const { statistic_ids } = event.data;
|
|
1139
639
|
if (!statistic_ids || statistic_ids.length === 0) return;
|
|
1140
|
-
const conn
|
|
1141
|
-
if (!conn
|
|
640
|
+
const conn = privilegedConn();
|
|
641
|
+
if (!conn) return;
|
|
1142
642
|
try {
|
|
1143
|
-
const metadata = await conn
|
|
643
|
+
const metadata = await conn.sendMessagePromise({
|
|
1144
644
|
type: "recorder/get_statistics_metadata",
|
|
1145
645
|
statistic_ids
|
|
1146
646
|
});
|
|
@@ -1248,8 +748,6 @@ function applyStateDiff(s, entityId, diff) {
|
|
|
1248
748
|
|
|
1249
749
|
//#endregion
|
|
1250
750
|
//#region src/connection/manager.ts
|
|
1251
|
-
const HEARTBEAT_INTERVAL_MS = 3e4;
|
|
1252
|
-
const HEARTBEAT_TIMEOUT_MS = 15e3;
|
|
1253
751
|
const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
|
|
1254
752
|
let activeConn = null;
|
|
1255
753
|
let heartbeatInterval = null;
|
|
@@ -1257,18 +755,6 @@ let suspendTimeout = null;
|
|
|
1257
755
|
let visiblePromiseResolve = null;
|
|
1258
756
|
let visibilityHandler = null;
|
|
1259
757
|
let pageshowHandler = null;
|
|
1260
|
-
function promiseTimeout(ms, promise) {
|
|
1261
|
-
return new Promise((resolve, reject) => {
|
|
1262
|
-
const timer = setTimeout(() => reject(new Error("Promise timeout")), ms);
|
|
1263
|
-
promise.then((value) => {
|
|
1264
|
-
clearTimeout(timer);
|
|
1265
|
-
resolve(value);
|
|
1266
|
-
}, (err) => {
|
|
1267
|
-
clearTimeout(timer);
|
|
1268
|
-
reject(err);
|
|
1269
|
-
});
|
|
1270
|
-
});
|
|
1271
|
-
}
|
|
1272
758
|
function clearSuspendTimeout() {
|
|
1273
759
|
if (suspendTimeout !== null) {
|
|
1274
760
|
clearTimeout(suspendTimeout);
|
|
@@ -1281,45 +767,12 @@ function resolveVisiblePromise() {
|
|
|
1281
767
|
visiblePromiseResolve = null;
|
|
1282
768
|
}
|
|
1283
769
|
}
|
|
1284
|
-
function startHeartbeat(conn$1) {
|
|
1285
|
-
stopHeartbeat();
|
|
1286
|
-
heartbeatInterval = setInterval(() => {
|
|
1287
|
-
promiseTimeout(HEARTBEAT_TIMEOUT_MS, conn$1.ping()).catch(() => {
|
|
1288
|
-
conn$1.reconnect();
|
|
1289
|
-
});
|
|
1290
|
-
}, HEARTBEAT_INTERVAL_MS);
|
|
1291
|
-
}
|
|
1292
770
|
function stopHeartbeat() {
|
|
1293
771
|
if (heartbeatInterval !== null) {
|
|
1294
772
|
clearInterval(heartbeatInterval);
|
|
1295
773
|
heartbeatInterval = null;
|
|
1296
774
|
}
|
|
1297
775
|
}
|
|
1298
|
-
function setupLifecycleListeners(conn$1) {
|
|
1299
|
-
if (typeof document === "undefined" || typeof window === "undefined") return;
|
|
1300
|
-
teardownLifecycleListeners();
|
|
1301
|
-
visibilityHandler = () => {
|
|
1302
|
-
if (document.visibilityState === "hidden") {
|
|
1303
|
-
const pending = new Promise((resolve) => {
|
|
1304
|
-
visiblePromiseResolve = resolve;
|
|
1305
|
-
});
|
|
1306
|
-
conn$1.suspendReconnectUntil(pending);
|
|
1307
|
-
clearSuspendTimeout();
|
|
1308
|
-
suspendTimeout = setTimeout(() => {
|
|
1309
|
-
conn$1.suspend();
|
|
1310
|
-
suspendTimeout = null;
|
|
1311
|
-
}, SUSPEND_DELAY_MS);
|
|
1312
|
-
} else {
|
|
1313
|
-
clearSuspendTimeout();
|
|
1314
|
-
resolveVisiblePromise();
|
|
1315
|
-
}
|
|
1316
|
-
};
|
|
1317
|
-
pageshowHandler = (event) => {
|
|
1318
|
-
if (event.persisted) conn$1.reconnect();
|
|
1319
|
-
};
|
|
1320
|
-
document.addEventListener("visibilitychange", visibilityHandler);
|
|
1321
|
-
window.addEventListener("pageshow", pageshowHandler);
|
|
1322
|
-
}
|
|
1323
776
|
function teardownLifecycleListeners() {
|
|
1324
777
|
if (typeof document !== "undefined" && visibilityHandler) document.removeEventListener("visibilitychange", visibilityHandler);
|
|
1325
778
|
if (typeof window !== "undefined" && pageshowHandler) window.removeEventListener("pageshow", pageshowHandler);
|
|
@@ -1329,47 +782,6 @@ function teardownLifecycleListeners() {
|
|
|
1329
782
|
resolveVisiblePromise();
|
|
1330
783
|
}
|
|
1331
784
|
/**
|
|
1332
|
-
* Initialize connection to Home Assistant
|
|
1333
|
-
*/
|
|
1334
|
-
async function initConnection(options) {
|
|
1335
|
-
try {
|
|
1336
|
-
setState(produce((s) => {
|
|
1337
|
-
s.connectionState = "connecting";
|
|
1338
|
-
s.connectionError = null;
|
|
1339
|
-
}));
|
|
1340
|
-
let auth;
|
|
1341
|
-
if (typeof options.auth === "string") auth = authenticateWithToken(options.url, options.auth);
|
|
1342
|
-
else if (typeof options.auth === "object" && "clientId" in options.auth) auth = await authenticateWithOAuth(options.auth);
|
|
1343
|
-
else auth = options.auth;
|
|
1344
|
-
const conn$1 = await createConnection({
|
|
1345
|
-
auth,
|
|
1346
|
-
...options.createSocket ? { createSocket: options.createSocket } : {}
|
|
1347
|
-
});
|
|
1348
|
-
const wrappedConn = wrapHAConnection(conn$1);
|
|
1349
|
-
activeConn = conn$1;
|
|
1350
|
-
setState(produce((s) => {
|
|
1351
|
-
s.conn = conn$1;
|
|
1352
|
-
s.hassUrl = options.url.replace(/\/$/, "");
|
|
1353
|
-
s.connectionState = "connected";
|
|
1354
|
-
}));
|
|
1355
|
-
setupEventHandlers(conn$1, options);
|
|
1356
|
-
await loadInitialData(conn$1);
|
|
1357
|
-
await subscribeToUpdates(wrappedConn);
|
|
1358
|
-
setupLifecycleListeners(conn$1);
|
|
1359
|
-
startHeartbeat(conn$1);
|
|
1360
|
-
options.onConnect?.();
|
|
1361
|
-
return conn$1;
|
|
1362
|
-
} catch (error) {
|
|
1363
|
-
const err = error instanceof Error ? error : new Error(String(error));
|
|
1364
|
-
setState(produce((s) => {
|
|
1365
|
-
s.connectionState = "error";
|
|
1366
|
-
s.connectionError = err;
|
|
1367
|
-
}));
|
|
1368
|
-
options.onError?.(err);
|
|
1369
|
-
throw err;
|
|
1370
|
-
}
|
|
1371
|
-
}
|
|
1372
|
-
/**
|
|
1373
785
|
* Disconnect from Home Assistant
|
|
1374
786
|
*/
|
|
1375
787
|
function disconnect() {
|
|
@@ -1378,17 +790,11 @@ function disconnect() {
|
|
|
1378
790
|
activeConn?.close();
|
|
1379
791
|
activeConn = null;
|
|
1380
792
|
setState(produce((s) => {
|
|
1381
|
-
|
|
793
|
+
setPrivilegedConn(null);
|
|
1382
794
|
s.connectionState = "disconnected";
|
|
1383
795
|
}));
|
|
1384
796
|
}
|
|
1385
797
|
/**
|
|
1386
|
-
* Get current connection link
|
|
1387
|
-
*/
|
|
1388
|
-
function getConnection() {
|
|
1389
|
-
return state.conn;
|
|
1390
|
-
}
|
|
1391
|
-
/**
|
|
1392
798
|
* Check if currently connected
|
|
1393
799
|
*/
|
|
1394
800
|
function isConnected() {
|
|
@@ -1403,41 +809,16 @@ function isConnected() {
|
|
|
1403
809
|
function getConnectionState() {
|
|
1404
810
|
return activeConn ? activeConn.connected : state.connectionState === "connected";
|
|
1405
811
|
}
|
|
1406
|
-
function
|
|
1407
|
-
conn$1.addEventListener("disconnected", () => {
|
|
1408
|
-
setState(produce((s) => {
|
|
1409
|
-
s.connectionState = "disconnected";
|
|
1410
|
-
}));
|
|
1411
|
-
options.onDisconnect?.();
|
|
1412
|
-
});
|
|
1413
|
-
conn$1.addEventListener("reconnect-error", () => {
|
|
1414
|
-
setState(produce((s) => {
|
|
1415
|
-
s.connectionState = "reconnecting";
|
|
1416
|
-
}));
|
|
1417
|
-
});
|
|
1418
|
-
conn$1.addEventListener("ready", async () => {
|
|
1419
|
-
const wasReconnecting = state.connectionState === "reconnecting";
|
|
1420
|
-
setState(produce((s) => {
|
|
1421
|
-
s.connectionState = "connected";
|
|
1422
|
-
s.connectionError = null;
|
|
1423
|
-
}));
|
|
1424
|
-
if (wasReconnecting) {
|
|
1425
|
-
await loadInitialData(conn$1);
|
|
1426
|
-
await forceResubscribe();
|
|
1427
|
-
options.onReconnect?.();
|
|
1428
|
-
}
|
|
1429
|
-
});
|
|
1430
|
-
}
|
|
1431
|
-
async function loadInitialData(conn$1) {
|
|
812
|
+
async function loadInitialData(conn) {
|
|
1432
813
|
try {
|
|
1433
814
|
const [states, entityRegistry, deviceRegistry, areaRegistry, floorRegistry, labelRegistry, config] = await Promise.all([
|
|
1434
|
-
conn
|
|
1435
|
-
conn
|
|
1436
|
-
conn
|
|
1437
|
-
conn
|
|
1438
|
-
conn
|
|
1439
|
-
conn
|
|
1440
|
-
conn
|
|
815
|
+
conn.sendMessagePromise({ type: "get_states" }),
|
|
816
|
+
conn.sendMessagePromise({ type: "config/entity_registry/list" }),
|
|
817
|
+
conn.sendMessagePromise({ type: "config/device_registry/list" }),
|
|
818
|
+
conn.sendMessagePromise({ type: "config/area_registry/list" }),
|
|
819
|
+
conn.sendMessagePromise({ type: "config/floor_registry/list" }),
|
|
820
|
+
conn.sendMessagePromise({ type: "config/label_registry/list" }),
|
|
821
|
+
conn.sendMessagePromise({ type: "get_config" })
|
|
1441
822
|
]);
|
|
1442
823
|
bulkUpdateEntities(states);
|
|
1443
824
|
bulkUpdateEntityRegistry(entityRegistry);
|
|
@@ -1470,223 +851,6 @@ async function loadInitialData(conn$1) {
|
|
|
1470
851
|
}
|
|
1471
852
|
}
|
|
1472
853
|
|
|
1473
|
-
//#endregion
|
|
1474
|
-
//#region src/entities/views.ts
|
|
1475
|
-
/**
|
|
1476
|
-
* Get icon for an entity from registry or attributes
|
|
1477
|
-
*/
|
|
1478
|
-
function resolveIcon(entity$1, registry) {
|
|
1479
|
-
if (registry?.icon) return {
|
|
1480
|
-
icon: registry.icon,
|
|
1481
|
-
source: "registry"
|
|
1482
|
-
};
|
|
1483
|
-
if (entity$1.attributes?.icon) return {
|
|
1484
|
-
icon: entity$1.attributes.icon,
|
|
1485
|
-
source: "attribute"
|
|
1486
|
-
};
|
|
1487
|
-
return null;
|
|
1488
|
-
}
|
|
1489
|
-
/**
|
|
1490
|
-
* Build an EntityView from runtime state and registry metadata
|
|
1491
|
-
*/
|
|
1492
|
-
function buildEntityView(entity$1, registry) {
|
|
1493
|
-
const domain = extractDomain(entity$1.entity_id);
|
|
1494
|
-
const attributes = entity$1.attributes ?? {};
|
|
1495
|
-
const friendlyName = attributes.friendly_name ?? registry?.name ?? entity$1.entity_id;
|
|
1496
|
-
const name = registry?.name ?? friendlyName;
|
|
1497
|
-
const iconResolution = resolveIcon(entity$1, registry);
|
|
1498
|
-
const { device_class: _dc, unit_of_measurement: _uom, friendly_name: _fn, icon: _icon,...restAttributes } = attributes;
|
|
1499
|
-
let areaId = registry?.area_id ?? null;
|
|
1500
|
-
if (areaId === null && registry?.device_id) {
|
|
1501
|
-
const device = state.devices[registry.device_id];
|
|
1502
|
-
if (device) areaId = device.area_id ?? null;
|
|
1503
|
-
}
|
|
1504
|
-
return {
|
|
1505
|
-
id: entity$1.entity_id,
|
|
1506
|
-
domain,
|
|
1507
|
-
state: entity$1.state,
|
|
1508
|
-
attributes: restAttributes,
|
|
1509
|
-
lastChanged: new Date(entity$1.last_changed),
|
|
1510
|
-
lastUpdated: new Date(entity$1.last_updated),
|
|
1511
|
-
context: {
|
|
1512
|
-
id: entity$1.context?.id ?? "",
|
|
1513
|
-
parentId: entity$1.context?.parent_id ?? null,
|
|
1514
|
-
userId: entity$1.context?.user_id ?? null
|
|
1515
|
-
},
|
|
1516
|
-
name,
|
|
1517
|
-
friendlyName,
|
|
1518
|
-
areaId,
|
|
1519
|
-
deviceId: registry?.device_id ?? null,
|
|
1520
|
-
platform: registry?.platform ?? "unknown",
|
|
1521
|
-
uniqueId: registry?.unique_id ?? null,
|
|
1522
|
-
isDisabled: registry?.disabled_by !== null && registry?.disabled_by !== void 0,
|
|
1523
|
-
isHidden: registry?.hidden_by !== null && registry?.hidden_by !== void 0,
|
|
1524
|
-
icon: iconResolution?.icon ?? null,
|
|
1525
|
-
iconSource: iconResolution?.source ?? "default",
|
|
1526
|
-
entityCategory: registry?.entity_category ?? null,
|
|
1527
|
-
labels: registry?.labels ?? [],
|
|
1528
|
-
aliases: registry?.aliases ?? [],
|
|
1529
|
-
deviceClass: registry?.device_class ?? attributes.device_class ?? null,
|
|
1530
|
-
unitOfMeasurement: registry?.unit_of_measurement ?? attributes.unit_of_measurement ?? null,
|
|
1531
|
-
supportedFeatures: registry?.supported_features ?? attributes.supported_features
|
|
1532
|
-
};
|
|
1533
|
-
}
|
|
1534
|
-
/**
|
|
1535
|
-
* Get or build an EntityView
|
|
1536
|
-
*/
|
|
1537
|
-
function getEntityView(entityId) {
|
|
1538
|
-
const entity$1 = state.entities[entityId];
|
|
1539
|
-
if (!entity$1) return void 0;
|
|
1540
|
-
const registry = state.entityRegistry[entityId];
|
|
1541
|
-
return buildEntityView(entity$1, registry);
|
|
1542
|
-
}
|
|
1543
|
-
/**
|
|
1544
|
-
* Get multiple entity views
|
|
1545
|
-
*/
|
|
1546
|
-
function getEntityViews(entityIds) {
|
|
1547
|
-
const views = [];
|
|
1548
|
-
for (const id of entityIds) {
|
|
1549
|
-
const view = getEntityView(id);
|
|
1550
|
-
if (view) views.push(view);
|
|
1551
|
-
}
|
|
1552
|
-
return views;
|
|
1553
|
-
}
|
|
1554
|
-
/**
|
|
1555
|
-
* Get all entity views
|
|
1556
|
-
*/
|
|
1557
|
-
function getAllEntityViews() {
|
|
1558
|
-
return getEntityViews(Object.keys(state.entities));
|
|
1559
|
-
}
|
|
1560
|
-
/**
|
|
1561
|
-
* Shallow equality check for EntityView.
|
|
1562
|
-
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
1563
|
-
*/
|
|
1564
|
-
function entityViewEquals(a, b) {
|
|
1565
|
-
if (a === b) return true;
|
|
1566
|
-
if (!a || !b) return false;
|
|
1567
|
-
return a.id === b.id && a.state === b.state && a.lastChanged.getTime() === b.lastChanged.getTime() && a.name === b.name && a.friendlyName === b.friendlyName && a.areaId === b.areaId && a.deviceId === b.deviceId && a.icon === b.icon && a.isDisabled === b.isDisabled && a.isHidden === b.isHidden && a.attributes === b.attributes;
|
|
1568
|
-
}
|
|
1569
|
-
|
|
1570
|
-
//#endregion
|
|
1571
|
-
//#region src/entities/area-views.ts
|
|
1572
|
-
/** The area an entity belongs to: its own `area_id` wins; otherwise it inherits
|
|
1573
|
-
* the area of its device. Null when neither resolves. */
|
|
1574
|
-
function entityAreaId(entry) {
|
|
1575
|
-
if (entry.area_id) return entry.area_id;
|
|
1576
|
-
if (entry.device_id) return state.devices[entry.device_id]?.area_id ?? null;
|
|
1577
|
-
return null;
|
|
1578
|
-
}
|
|
1579
|
-
/** One pass over the device + entity registries → membership per area.
|
|
1580
|
-
* O(devices + entities) for the whole home, vs the O(areas × (devices +
|
|
1581
|
-
* entities)) of resolving each area independently. */
|
|
1582
|
-
function computeAreaMembership() {
|
|
1583
|
-
const byArea = /* @__PURE__ */ new Map();
|
|
1584
|
-
const ensure = (areaId) => {
|
|
1585
|
-
let m = byArea.get(areaId);
|
|
1586
|
-
if (!m) {
|
|
1587
|
-
m = {
|
|
1588
|
-
devices: [],
|
|
1589
|
-
entityIds: []
|
|
1590
|
-
};
|
|
1591
|
-
byArea.set(areaId, m);
|
|
1592
|
-
}
|
|
1593
|
-
return m;
|
|
1594
|
-
};
|
|
1595
|
-
for (const device of Object.values(state.devices)) if (device.area_id) ensure(device.area_id).devices.push(device);
|
|
1596
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) {
|
|
1597
|
-
const areaId = entityAreaId(entry);
|
|
1598
|
-
if (areaId) ensure(areaId).entityIds.push(entityId);
|
|
1599
|
-
}
|
|
1600
|
-
return byArea;
|
|
1601
|
-
}
|
|
1602
|
-
/** Membership for a single area, without indexing every other area. */
|
|
1603
|
-
function membershipFor(areaId) {
|
|
1604
|
-
const devices = [];
|
|
1605
|
-
for (const device of Object.values(state.devices)) if (device.area_id === areaId) devices.push(device);
|
|
1606
|
-
const entityIds = [];
|
|
1607
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entityAreaId(entry) === areaId) entityIds.push(entityId);
|
|
1608
|
-
return {
|
|
1609
|
-
devices,
|
|
1610
|
-
entityIds
|
|
1611
|
-
};
|
|
1612
|
-
}
|
|
1613
|
-
/**
|
|
1614
|
-
* Build a DeviceView from registry data
|
|
1615
|
-
*/
|
|
1616
|
-
function buildDeviceView(device) {
|
|
1617
|
-
return {
|
|
1618
|
-
id: device.id,
|
|
1619
|
-
name: device.name ?? "",
|
|
1620
|
-
nameByUser: device.name_by_user ?? null,
|
|
1621
|
-
manufacturer: device.manufacturer ?? null,
|
|
1622
|
-
model: device.model ?? null,
|
|
1623
|
-
swVersion: device.sw_version ?? null,
|
|
1624
|
-
areaId: device.area_id ?? null,
|
|
1625
|
-
configEntries: device.config_entries ?? [],
|
|
1626
|
-
connections: device.connections ?? [],
|
|
1627
|
-
identifiers: device.identifiers ?? [],
|
|
1628
|
-
disabledBy: device.disabled_by ?? null
|
|
1629
|
-
};
|
|
1630
|
-
}
|
|
1631
|
-
/** Assemble an AreaView from pre-resolved membership. `entities` is a lazy
|
|
1632
|
-
* getter: materializing live EntityViews reads entity state, so deferring it
|
|
1633
|
-
* keeps callers that only need names/ids/counts off the per-state-tick
|
|
1634
|
-
* recompute path. The getter stays reactive — read inside a tracking scope it
|
|
1635
|
-
* tracks those entities' state and updates like any other store read. */
|
|
1636
|
-
function assembleAreaView(areaId, membership) {
|
|
1637
|
-
const area$1 = state.areas[areaId];
|
|
1638
|
-
if (!area$1) throw new Error(`Area ${areaId} not found`);
|
|
1639
|
-
const devices = membership.devices.map((device) => buildDeviceView(device));
|
|
1640
|
-
const entityIds = membership.entityIds;
|
|
1641
|
-
return {
|
|
1642
|
-
id: areaId,
|
|
1643
|
-
name: area$1.name,
|
|
1644
|
-
normalizedName: area$1.normalized_name ?? area$1.name.toLowerCase(),
|
|
1645
|
-
aliases: Array.isArray(area$1.aliases) ? area$1.aliases : [],
|
|
1646
|
-
floorId: area$1.floor_id ?? null,
|
|
1647
|
-
icon: area$1.icon ?? null,
|
|
1648
|
-
picture: area$1.picture ?? null,
|
|
1649
|
-
labels: Array.isArray(area$1.labels) ? area$1.labels : [],
|
|
1650
|
-
temperatureEntityId: area$1.temperature_entity_id ?? null,
|
|
1651
|
-
humidityEntityId: area$1.humidity_entity_id ?? null,
|
|
1652
|
-
createdAt: area$1.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1653
|
-
modifiedAt: area$1.modified_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1654
|
-
devices,
|
|
1655
|
-
entityIds,
|
|
1656
|
-
deviceIds: devices.map((d) => d.id),
|
|
1657
|
-
get entities() {
|
|
1658
|
-
return entityIds.length > 0 ? getEntityViews(entityIds) : [];
|
|
1659
|
-
}
|
|
1660
|
-
};
|
|
1661
|
-
}
|
|
1662
|
-
/**
|
|
1663
|
-
* Build an AreaView for a single area from registry data.
|
|
1664
|
-
*/
|
|
1665
|
-
function buildAreaView(areaId) {
|
|
1666
|
-
return assembleAreaView(areaId, membershipFor(areaId));
|
|
1667
|
-
}
|
|
1668
|
-
/**
|
|
1669
|
-
* Get all area views. Membership is resolved in a single registry pass; entity
|
|
1670
|
-
* views stay lazy per area (see `assembleAreaView`).
|
|
1671
|
-
*/
|
|
1672
|
-
function getAreaViews() {
|
|
1673
|
-
const membership = computeAreaMembership();
|
|
1674
|
-
return Object.keys(state.areas).map((areaId) => assembleAreaView(areaId, membership.get(areaId) ?? {
|
|
1675
|
-
devices: [],
|
|
1676
|
-
entityIds: []
|
|
1677
|
-
}));
|
|
1678
|
-
}
|
|
1679
|
-
/**
|
|
1680
|
-
* Get area view
|
|
1681
|
-
*/
|
|
1682
|
-
function getAreaView(areaId) {
|
|
1683
|
-
try {
|
|
1684
|
-
return buildAreaView(areaId);
|
|
1685
|
-
} catch {
|
|
1686
|
-
return void 0;
|
|
1687
|
-
}
|
|
1688
|
-
}
|
|
1689
|
-
|
|
1690
854
|
//#endregion
|
|
1691
855
|
//#region src/query/area-builder.ts
|
|
1692
856
|
var AreaQueryBuilderImpl = class AreaQueryBuilderImpl {
|
|
@@ -2192,7 +1356,7 @@ const DEMO_HLS_STREAM = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
|
|
|
2192
1356
|
*/
|
|
2193
1357
|
async function fetchStream(entityId, format = "hls") {
|
|
2194
1358
|
if (isDemoMode()) return format === "hls" ? DEMO_HLS_STREAM : null;
|
|
2195
|
-
if (!
|
|
1359
|
+
if (!privilegedConn()) return null;
|
|
2196
1360
|
const response = await sendCommand({
|
|
2197
1361
|
type: "camera/stream",
|
|
2198
1362
|
entity_id: entityId,
|
|
@@ -2372,9 +1536,9 @@ function hassMediaUrl(path) {
|
|
|
2372
1536
|
* Get WebRTC client configuration (STUN/TURN servers) from HA
|
|
2373
1537
|
*/
|
|
2374
1538
|
async function getWebRtcClientConfig(entityId) {
|
|
2375
|
-
const conn
|
|
2376
|
-
if (!conn
|
|
2377
|
-
return conn
|
|
1539
|
+
const conn = privilegedConn();
|
|
1540
|
+
if (!conn) throw new Error("No connection available");
|
|
1541
|
+
return conn.sendMessagePromise({
|
|
2378
1542
|
type: "camera/webrtc/get_client_config",
|
|
2379
1543
|
entity_id: entityId
|
|
2380
1544
|
});
|
|
@@ -2386,8 +1550,8 @@ async function getWebRtcClientConfig(entityId) {
|
|
|
2386
1550
|
* Local ICE candidates should be sent via sendWebRtcCandidate().
|
|
2387
1551
|
*/
|
|
2388
1552
|
function startWebRtcSession(entityId, sdpOffer, onCandidate) {
|
|
2389
|
-
const conn
|
|
2390
|
-
if (!conn
|
|
1553
|
+
const conn = privilegedConn();
|
|
1554
|
+
if (!conn) return Promise.reject(new Error("Not connected"));
|
|
2391
1555
|
return new Promise((resolve, reject) => {
|
|
2392
1556
|
const session = {
|
|
2393
1557
|
sessionId: null,
|
|
@@ -2400,7 +1564,7 @@ function startWebRtcSession(entityId, sdpOffer, onCandidate) {
|
|
|
2400
1564
|
reject(new Error("WebRTC offer timed out"));
|
|
2401
1565
|
}
|
|
2402
1566
|
}, 15e3);
|
|
2403
|
-
conn
|
|
1567
|
+
conn.subscribeMessage((event) => {
|
|
2404
1568
|
switch (event.type) {
|
|
2405
1569
|
case "session":
|
|
2406
1570
|
session.sessionId = event.session_id ?? null;
|
|
@@ -2440,9 +1604,9 @@ function startWebRtcSession(entityId, sdpOffer, onCandidate) {
|
|
|
2440
1604
|
* Send a local ICE candidate to HA
|
|
2441
1605
|
*/
|
|
2442
1606
|
async function sendWebRtcCandidate(entityId, sessionId, candidate) {
|
|
2443
|
-
const conn
|
|
2444
|
-
if (!conn
|
|
2445
|
-
await conn
|
|
1607
|
+
const conn = privilegedConn();
|
|
1608
|
+
if (!conn) throw new Error("Not connected");
|
|
1609
|
+
await conn.sendMessagePromise({
|
|
2446
1610
|
type: "camera/webrtc/candidate",
|
|
2447
1611
|
entity_id: entityId,
|
|
2448
1612
|
session_id: sessionId,
|
|
@@ -2450,77 +1614,6 @@ async function sendWebRtcCandidate(entityId, sessionId, candidate) {
|
|
|
2450
1614
|
});
|
|
2451
1615
|
}
|
|
2452
1616
|
|
|
2453
|
-
//#endregion
|
|
2454
|
-
//#region src/history/statistics.ts
|
|
2455
|
-
const STATISTIC_TYPES = [
|
|
2456
|
-
"mean",
|
|
2457
|
-
"sum",
|
|
2458
|
-
"min",
|
|
2459
|
-
"max",
|
|
2460
|
-
"state",
|
|
2461
|
-
"change"
|
|
2462
|
-
];
|
|
2463
|
-
/**
|
|
2464
|
-
* HA returns start/end as ISO strings or ms numbers depending on version.
|
|
2465
|
-
* Normalize both shapes to ms epoch numbers; unparseable values become NaN.
|
|
2466
|
-
*/
|
|
2467
|
-
function normalizeStatisticTime(value) {
|
|
2468
|
-
if (typeof value === "number") return value;
|
|
2469
|
-
if (typeof value === "string") return new Date(value).getTime();
|
|
2470
|
-
return NaN;
|
|
2471
|
-
}
|
|
2472
|
-
function normalizeNumber(value) {
|
|
2473
|
-
return typeof value === "number" ? value : void 0;
|
|
2474
|
-
}
|
|
2475
|
-
function normalizeStatisticValue(raw) {
|
|
2476
|
-
const value = {
|
|
2477
|
-
start: normalizeStatisticTime(raw.start),
|
|
2478
|
-
end: normalizeStatisticTime(raw.end)
|
|
2479
|
-
};
|
|
2480
|
-
const mean = normalizeNumber(raw.mean);
|
|
2481
|
-
if (mean !== void 0) value.mean = mean;
|
|
2482
|
-
const sum = normalizeNumber(raw.sum);
|
|
2483
|
-
if (sum !== void 0) value.sum = sum;
|
|
2484
|
-
const min = normalizeNumber(raw.min);
|
|
2485
|
-
if (min !== void 0) value.min = min;
|
|
2486
|
-
const max = normalizeNumber(raw.max);
|
|
2487
|
-
if (max !== void 0) value.max = max;
|
|
2488
|
-
const stateVal = normalizeNumber(raw.state);
|
|
2489
|
-
if (stateVal !== void 0) value.state = stateVal;
|
|
2490
|
-
const change = normalizeNumber(raw.change);
|
|
2491
|
-
if (change !== void 0) value.change = change;
|
|
2492
|
-
return value;
|
|
2493
|
-
}
|
|
2494
|
-
/**
|
|
2495
|
-
* Fetch long-term statistics for one or more statistic ids during a period.
|
|
2496
|
-
*
|
|
2497
|
-
* Statistic ids with no data are returned as empty arrays so callers never
|
|
2498
|
-
* hit `undefined`.
|
|
2499
|
-
*/
|
|
2500
|
-
async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
|
|
2501
|
-
const { startTime, endTime, period } = options;
|
|
2502
|
-
if (isDemoMode()) {
|
|
2503
|
-
const result$1 = {};
|
|
2504
|
-
const statPeriod = period === "hour" || period === "day" ? period : "day";
|
|
2505
|
-
const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
|
|
2506
|
-
for (const id of statisticIds) result$1[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : [];
|
|
2507
|
-
return result$1;
|
|
2508
|
-
}
|
|
2509
|
-
const params = {
|
|
2510
|
-
type: "recorder/statistics_during_period",
|
|
2511
|
-
start_time: startTime.toISOString(),
|
|
2512
|
-
statistic_ids: statisticIds,
|
|
2513
|
-
period,
|
|
2514
|
-
types: STATISTIC_TYPES
|
|
2515
|
-
};
|
|
2516
|
-
if (endTime) params.end_time = endTime.toISOString();
|
|
2517
|
-
const response = await connection.sendMessagePromise(params);
|
|
2518
|
-
const result = {};
|
|
2519
|
-
for (const id of statisticIds) result[id] = [];
|
|
2520
|
-
if (response && typeof response === "object") for (const [id, buckets] of Object.entries(response)) result[id] = Array.isArray(buckets) ? buckets.map(normalizeStatisticValue) : [];
|
|
2521
|
-
return result;
|
|
2522
|
-
}
|
|
2523
|
-
|
|
2524
1617
|
//#endregion
|
|
2525
1618
|
//#region src/weather/fetch.ts
|
|
2526
1619
|
/**
|
|
@@ -2568,8 +1661,8 @@ function buildDemoForecast(entityId, type) {
|
|
|
2568
1661
|
*/
|
|
2569
1662
|
async function fetchForecast(entityId, type) {
|
|
2570
1663
|
if (isDemoMode()) return buildDemoForecast(entityId, type);
|
|
2571
|
-
const conn
|
|
2572
|
-
if (!conn
|
|
1664
|
+
const conn = privilegedConn();
|
|
1665
|
+
if (!conn) throw new Error("Not connected");
|
|
2573
1666
|
return new Promise((resolve, reject) => {
|
|
2574
1667
|
let resolved = false;
|
|
2575
1668
|
let unsubscribe = null;
|
|
@@ -2579,7 +1672,7 @@ async function fetchForecast(entityId, type) {
|
|
|
2579
1672
|
unsubscribe().then(() => reject(new Error("Forecast subscription timeout"))).catch(() => reject(new Error("Forecast subscription timeout")));
|
|
2580
1673
|
}
|
|
2581
1674
|
}, 1e4);
|
|
2582
|
-
conn
|
|
1675
|
+
conn.subscribeMessage((event) => {
|
|
2583
1676
|
if (resolved) return;
|
|
2584
1677
|
if (event && typeof event === "object" && "forecast" in event) {
|
|
2585
1678
|
resolved = true;
|
|
@@ -2778,47 +1871,6 @@ async function refreshForecasts(entityId, types) {
|
|
|
2778
1871
|
return getForecasts(entityId, types);
|
|
2779
1872
|
}
|
|
2780
1873
|
|
|
2781
|
-
//#endregion
|
|
2782
|
-
//#region src/connection/bridged.ts
|
|
2783
|
-
/**
|
|
2784
|
-
* Wire an HA bridge (worker-backed connection) into the store. The bridge's
|
|
2785
|
-
* facade rides the same load/subscribe pipeline as a direct socket; only the
|
|
2786
|
-
* lifecycle signals differ — they arrive as worker messages instead of
|
|
2787
|
-
* Connection events.
|
|
2788
|
-
*
|
|
2789
|
-
* Call after bridge.connect() resolved. Returns when initial data is loaded.
|
|
2790
|
-
*/
|
|
2791
|
-
async function attachBridgeToStore(bridge, hassUrl, mediaProxyBase) {
|
|
2792
|
-
setState(produce((s) => {
|
|
2793
|
-
s.conn = bridge.conn;
|
|
2794
|
-
s.hassUrl = hassUrl.replace(/\/$/, "");
|
|
2795
|
-
s.mediaProxyBase = mediaProxyBase ?? null;
|
|
2796
|
-
s.connectionState = "connected";
|
|
2797
|
-
s.connectionError = null;
|
|
2798
|
-
}));
|
|
2799
|
-
await loadInitialData(bridge.conn);
|
|
2800
|
-
await subscribeToUpdates(bridge.conn);
|
|
2801
|
-
}
|
|
2802
|
-
/** Map worker connection-state messages onto the store. */
|
|
2803
|
-
function applyBridgeConnState(state$1) {
|
|
2804
|
-
setState(produce((s) => {
|
|
2805
|
-
s.connectionState = state$1;
|
|
2806
|
-
if (state$1 === "connected") s.connectionError = null;
|
|
2807
|
-
}));
|
|
2808
|
-
}
|
|
2809
|
-
/** Re-sync after the worker reports a completed reconnect. */
|
|
2810
|
-
async function reloadAfterBridgeReconnect(bridge) {
|
|
2811
|
-
await loadInitialData(bridge.conn);
|
|
2812
|
-
await forceResubscribe();
|
|
2813
|
-
}
|
|
2814
|
-
/** Detach on shutdown; the caller terminates the worker itself. */
|
|
2815
|
-
function detachBridgeFromStore() {
|
|
2816
|
-
setState(produce((s) => {
|
|
2817
|
-
s.conn = null;
|
|
2818
|
-
s.connectionState = "disconnected";
|
|
2819
|
-
}));
|
|
2820
|
-
}
|
|
2821
|
-
|
|
2822
1874
|
//#endregion
|
|
2823
1875
|
//#region src/worker/bridge-client.ts
|
|
2824
1876
|
var BridgeNeedsAuthError = class extends Error {
|
|
@@ -2910,7 +1962,7 @@ function createHaBridge(worker, events = {}) {
|
|
|
2910
1962
|
});
|
|
2911
1963
|
};
|
|
2912
1964
|
}
|
|
2913
|
-
const conn
|
|
1965
|
+
const conn = {
|
|
2914
1966
|
connect: () => Promise.resolve(),
|
|
2915
1967
|
disconnect: () => post({ k: "disconnect" }),
|
|
2916
1968
|
get connected$() {
|
|
@@ -2947,7 +1999,7 @@ function createHaBridge(worker, events = {}) {
|
|
|
2947
1999
|
disconnect: () => post({ k: "disconnect" }),
|
|
2948
2000
|
reconnect: () => post({ k: "reconnect" }),
|
|
2949
2001
|
clearTokens: () => post({ k: "clear_tokens" }),
|
|
2950
|
-
conn
|
|
2002
|
+
conn,
|
|
2951
2003
|
registerWidget(widgetId, caps) {
|
|
2952
2004
|
const channel = new MessageChannel();
|
|
2953
2005
|
post({
|
|
@@ -2983,9 +2035,77 @@ function createHaBridge(worker, events = {}) {
|
|
|
2983
2035
|
};
|
|
2984
2036
|
}
|
|
2985
2037
|
|
|
2038
|
+
//#endregion
|
|
2039
|
+
//#region src/connection/bridged.ts
|
|
2040
|
+
/**
|
|
2041
|
+
* Wire an HA bridge (worker-backed connection) into the store. The bridge's
|
|
2042
|
+
* facade rides the same load/subscribe pipeline as a direct socket; only the
|
|
2043
|
+
* lifecycle signals differ — they arrive as worker messages instead of
|
|
2044
|
+
* Connection events.
|
|
2045
|
+
*
|
|
2046
|
+
* Call after bridge.connect() resolved. Returns when initial data is loaded.
|
|
2047
|
+
*/
|
|
2048
|
+
async function attachBridgeToStore(bridge, hassUrl, mediaProxyBase) {
|
|
2049
|
+
setState(produce((s) => {
|
|
2050
|
+
setPrivilegedConn(bridge.conn);
|
|
2051
|
+
s.hassUrl = hassUrl.replace(/\/$/, "");
|
|
2052
|
+
s.mediaProxyBase = mediaProxyBase ?? null;
|
|
2053
|
+
s.connectionState = "connected";
|
|
2054
|
+
s.connectionError = null;
|
|
2055
|
+
}));
|
|
2056
|
+
await loadInitialData(bridge.conn);
|
|
2057
|
+
await subscribeToUpdates(bridge.conn);
|
|
2058
|
+
}
|
|
2059
|
+
/** Map worker connection-state messages onto the store. */
|
|
2060
|
+
function applyBridgeConnState(state$1) {
|
|
2061
|
+
setState(produce((s) => {
|
|
2062
|
+
s.connectionState = state$1;
|
|
2063
|
+
if (state$1 === "connected") s.connectionError = null;
|
|
2064
|
+
}));
|
|
2065
|
+
}
|
|
2066
|
+
/** Re-sync after the worker reports a completed reconnect. */
|
|
2067
|
+
async function reloadAfterBridgeReconnect(bridge) {
|
|
2068
|
+
await loadInitialData(bridge.conn);
|
|
2069
|
+
await forceResubscribe();
|
|
2070
|
+
}
|
|
2071
|
+
/** Detach on shutdown; the caller terminates the worker itself. */
|
|
2072
|
+
function detachBridgeFromStore() {
|
|
2073
|
+
setState(produce((s) => {
|
|
2074
|
+
setPrivilegedConn(null);
|
|
2075
|
+
s.connectionState = "disconnected";
|
|
2076
|
+
}));
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
//#endregion
|
|
2080
|
+
//#region src/host-api.ts
|
|
2081
|
+
let unclaimed = {
|
|
2082
|
+
attachBridgeToStore,
|
|
2083
|
+
detachBridgeFromStore,
|
|
2084
|
+
applyBridgeConnState,
|
|
2085
|
+
reloadAfterBridgeReconnect,
|
|
2086
|
+
createHaBridge,
|
|
2087
|
+
authenticateWithOAuth,
|
|
2088
|
+
disconnect,
|
|
2089
|
+
loadDemoData,
|
|
2090
|
+
unloadDemoData
|
|
2091
|
+
};
|
|
2092
|
+
/**
|
|
2093
|
+
* Take the host-only surface. Returns it once; null on every later call.
|
|
2094
|
+
*
|
|
2095
|
+
* Call it exactly once, during host boot, and keep the result in a module the
|
|
2096
|
+
* import map does not serve. Calling it from anywhere else is what this guards
|
|
2097
|
+
* against, so a null return means something already claimed it — treat that as
|
|
2098
|
+
* a fatal misconfiguration, not a retry.
|
|
2099
|
+
*/
|
|
2100
|
+
function claimHostApi() {
|
|
2101
|
+
const api = unclaimed;
|
|
2102
|
+
unclaimed = null;
|
|
2103
|
+
return api;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2986
2106
|
//#endregion
|
|
2987
2107
|
//#region src/index.ts
|
|
2988
2108
|
const VERSION = "0.2.1";
|
|
2989
2109
|
|
|
2990
2110
|
//#endregion
|
|
2991
|
-
export { BridgeNeedsAuthError, DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION,
|
|
2111
|
+
export { BridgeNeedsAuthError, DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, appendHistoryPoint, applyDemoServiceCall, area, areas, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, claimHostApi, createDemoFixtures, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, hassMediaUrl, historyStateToTimeline, isConnected, isDemoMode, isHistoryTracked, normalizeStatisticTime, refreshForecast, refreshForecasts, refreshStream, registerEntity, runHaBridgeWorker, searchEntities, sendCommand, sendWebRtcCandidate, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, untrackEntityHistory, updateEntity };
|