@glasshome/sync-layer 0.1.6 → 0.1.8
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/subscription-manager.d.ts +45 -0
- package/dist/connection/subscriptions.d.ts +6 -0
- package/dist/core/types.d.ts +0 -2
- package/dist/entities/views.d.ts +5 -0
- package/dist/history/query.d.ts +12 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +345 -133
- package/dist/solid/hooks.d.ts +7 -1
- package/dist/solid/index.js +50 -7
- package/dist/subscription-manager-gR0qpDYZ.js +96 -0
- package/package.json +1 -6
- package/dist/demo/demo-provider.js +0 -4
- package/dist/demo-provider-I4BC9Woh.js +0 -112
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Subscription Manager
|
|
3
|
+
*
|
|
4
|
+
* Ref-counted registry that tracks which entity IDs need live updates
|
|
5
|
+
* from Home Assistant. When the active set changes, resubscribes with
|
|
6
|
+
* the new entity_ids filter so HA only sends diffs for displayed entities.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
import type { EntityId } from "../core/types";
|
|
11
|
+
import type { SyncLayerConnection } from "./types";
|
|
12
|
+
type UnsubscribeFn = () => Promise<void> | void;
|
|
13
|
+
/**
|
|
14
|
+
* Register an entity for live updates. Returns an unregister function.
|
|
15
|
+
* Call from useEntity/useEntities hooks via onCleanup.
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerEntity(entityId: EntityId): () => void;
|
|
18
|
+
/**
|
|
19
|
+
* Get the current set of actively subscribed entity IDs.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getActiveEntityIds(): Set<EntityId>;
|
|
22
|
+
/**
|
|
23
|
+
* Set the connection reference used for resubscribing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function setManagerConnection(connection: SyncLayerConnection): void;
|
|
26
|
+
/**
|
|
27
|
+
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
28
|
+
* the manager into the actual WS subscription lifecycle.
|
|
29
|
+
*/
|
|
30
|
+
export declare function setResubscribeHandler(handler: (conn: SyncLayerConnection, entityIds: EntityId[]) => Promise<UnsubscribeFn>): void;
|
|
31
|
+
/**
|
|
32
|
+
* Store the current entity subscription's unsub function.
|
|
33
|
+
* Called after initial subscribe_entities.
|
|
34
|
+
*/
|
|
35
|
+
export declare function setCurrentEntityUnsub(unsub: UnsubscribeFn): void;
|
|
36
|
+
/**
|
|
37
|
+
* Force a resubscribe with the current active set.
|
|
38
|
+
* Used on reconnect.
|
|
39
|
+
*/
|
|
40
|
+
export declare function forceResubscribe(): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Reset manager state. Used on disconnect or for testing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resetManager(): void;
|
|
45
|
+
export {};
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
|
+
import type { EntityId } from "../core/types";
|
|
9
10
|
import type { SyncLayerConnection } from "./types";
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to entity state updates with an entity_ids filter.
|
|
13
|
+
* Called by the subscription manager on flush.
|
|
14
|
+
*/
|
|
15
|
+
export declare function subscribeEntities(connection: SyncLayerConnection, entityIds: EntityId[]): Promise<() => Promise<void>>;
|
|
10
16
|
/**
|
|
11
17
|
* Subscribe to all relevant events from Home Assistant
|
|
12
18
|
*/
|
package/dist/core/types.d.ts
CHANGED
|
@@ -177,9 +177,7 @@ export interface EntityView {
|
|
|
177
177
|
state: string;
|
|
178
178
|
/** Entity attributes */
|
|
179
179
|
attributes: Record<string, any>;
|
|
180
|
-
/** Last state change timestamp */
|
|
181
180
|
lastChanged: Date;
|
|
182
|
-
/** Last update timestamp */
|
|
183
181
|
lastUpdated: Date;
|
|
184
182
|
/** Event context */
|
|
185
183
|
context: {
|
package/dist/entities/views.d.ts
CHANGED
|
@@ -28,3 +28,8 @@ export declare function getAllEntityViews(): EntityView[];
|
|
|
28
28
|
* Rebuild entity view
|
|
29
29
|
*/
|
|
30
30
|
export declare function rebuildEntityView(entityId: EntityId): EntityView | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Shallow equality check for EntityView.
|
|
33
|
+
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
34
|
+
*/
|
|
35
|
+
export declare function entityViewEquals(a: EntityView | undefined, b: EntityView | undefined): boolean;
|
package/dist/history/query.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { EntityId } from "../core/types";
|
|
2
2
|
import type { EntityHistoryData, EntityHistoryQueryOptions } from "./types";
|
|
3
|
+
export interface HistoryPoint {
|
|
4
|
+
entityId: EntityId;
|
|
5
|
+
stateValue: string;
|
|
6
|
+
attributes: Record<string, unknown>;
|
|
7
|
+
lastUpdated: number;
|
|
8
|
+
lastChanged?: number;
|
|
9
|
+
}
|
|
3
10
|
/** Check if an entity has active history tracking. */
|
|
4
11
|
export declare function isHistoryTracked(entityId: EntityId): boolean;
|
|
5
12
|
/** Start tracking history for an entity. Fetches initial backfill. */
|
|
@@ -11,3 +18,8 @@ export declare function untrackEntityHistory(entityId: EntityId): void;
|
|
|
11
18
|
* Called by the entity subscription handler when a tracked entity changes.
|
|
12
19
|
*/
|
|
13
20
|
export declare function appendHistoryPoint(entityId: EntityId, stateValue: string, attributes: Record<string, unknown>, lastUpdated: number, lastChanged?: number): void;
|
|
21
|
+
/**
|
|
22
|
+
* Append multiple history points in a single setState(produce(...)).
|
|
23
|
+
* Called by the rAF-batched subscription flush.
|
|
24
|
+
*/
|
|
25
|
+
export declare function bulkAppendHistoryPoints(points: HistoryPoint[]): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -28,12 +28,14 @@ export { getStream, refreshStream } from "./camera/query";
|
|
|
28
28
|
export type { CameraStream, CameraStreamData, CameraStreams, EntityStreamQueryOptions, StreamFormat, StreamQueryOptions, StreamResult, } from "./camera/types";
|
|
29
29
|
export { getWebRtcClientConfig, sendWebRtcCandidate, startWebRtcSession, } from "./camera/webrtc";
|
|
30
30
|
export { buildAreaView, buildDeviceView, getAreaView, getAreaViews, } from "./entities/area-views";
|
|
31
|
-
export { buildEntityView, getAllEntityViews, getEntityView, getEntityViews, } from "./entities/views";
|
|
31
|
+
export { buildEntityView, entityViewEquals, getAllEntityViews, getEntityView, getEntityViews, } from "./entities/views";
|
|
32
32
|
export { DOMAINS_USE_LAST_UPDATED, entityIdHistoryNeedsAttributes, LINE_ATTRIBUTES_TO_KEEP, NEED_ATTRIBUTE_DOMAINS, } from "./history/constants";
|
|
33
33
|
export { fetchEntityHistory, fetchHistory, historyStateToTimeline } from "./history/fetch";
|
|
34
|
-
export { appendHistoryPoint, isHistoryTracked, trackEntityHistory, untrackEntityHistory, } from "./history/query";
|
|
34
|
+
export { appendHistoryPoint, bulkAppendHistoryPoints, isHistoryTracked, trackEntityHistory, untrackEntityHistory, } from "./history/query";
|
|
35
|
+
export type { HistoryPoint } from "./history/query";
|
|
35
36
|
export type { EntityHistoryData, EntityHistoryQueryOptions, EntityHistoryResult, EntityHistoryState, HistoryData, HistoryQueryOptions, HistoryResult, HistoryStates, TimelineState, } from "./history/types";
|
|
36
37
|
export { fetchForecast, fetchForecastData, fetchForecasts } from "./weather/fetch";
|
|
37
38
|
export { getForecast, getForecasts, refreshForecast, refreshForecasts } from "./weather/query";
|
|
38
39
|
export type { EntityForecastQueryOptions, ForecastQueryOptions, ForecastResult, ForecastsResult, ForecastType, WeatherForecast, WeatherForecastData, WeatherForecasts, WeatherForecastsData, } from "./weather/types";
|
|
40
|
+
export { applyDemoServiceCall, createDemoFixtures, isDemoMode, loadDemoData, unloadDemoData, } from "./demo/demo-provider";
|
|
39
41
|
export declare const VERSION = "0.1.0";
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { resetStore, setState, state } from "./demo-data-gELwUOS_.js";
|
|
2
|
-
import {
|
|
1
|
+
import { createDemoFixtures, resetStore, setState, state } from "./demo-data-gELwUOS_.js";
|
|
2
|
+
import { forceResubscribe, setManagerConnection, setResubscribeHandler } from "./subscription-manager-gR0qpDYZ.js";
|
|
3
3
|
import { callService as callService$1, createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
|
|
4
|
-
import { produce } from "solid-js/store";
|
|
4
|
+
import { produce, reconcile } from "solid-js/store";
|
|
5
5
|
|
|
6
6
|
//#region src/connection/adapter.ts
|
|
7
7
|
/**
|
|
@@ -395,6 +395,114 @@ async function refreshAuth(auth) {
|
|
|
395
395
|
return auth;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/core/types.ts
|
|
400
|
+
/**
|
|
401
|
+
* Extract domain from entity ID
|
|
402
|
+
*/
|
|
403
|
+
function extractDomain(entityId) {
|
|
404
|
+
return entityId.split(".")[0] ?? "";
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
//#endregion
|
|
408
|
+
//#region src/demo/demo-provider.ts
|
|
409
|
+
let _isDemoMode = false;
|
|
410
|
+
/**
|
|
411
|
+
* Whether demo mode is currently active.
|
|
412
|
+
*/
|
|
413
|
+
function isDemoMode() {
|
|
414
|
+
return _isDemoMode;
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Load demo fixtures into the SolidJS store.
|
|
418
|
+
*
|
|
419
|
+
* Populates entities, entity registry, areas, and devices.
|
|
420
|
+
* Sets connection state to "connected" (pretend connected).
|
|
421
|
+
*/
|
|
422
|
+
async function loadDemoData() {
|
|
423
|
+
const fixtures = createDemoFixtures();
|
|
424
|
+
setState("entities", reconcile(fixtures.entities));
|
|
425
|
+
setState("entityRegistry", reconcile(fixtures.entityRegistry));
|
|
426
|
+
setState("areas", reconcile(fixtures.areas));
|
|
427
|
+
setState("devices", reconcile(fixtures.devices));
|
|
428
|
+
setState("hassUrl", "https://demo.home-assistant.local");
|
|
429
|
+
setState("connectionState", "connected");
|
|
430
|
+
_isDemoMode = true;
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Unload demo data and reset to disconnected state.
|
|
434
|
+
*/
|
|
435
|
+
function unloadDemoData() {
|
|
436
|
+
setState("entities", reconcile({}));
|
|
437
|
+
setState("entityRegistry", reconcile({}));
|
|
438
|
+
setState("areas", reconcile({}));
|
|
439
|
+
setState("devices", reconcile({}));
|
|
440
|
+
setState("hassUrl", null);
|
|
441
|
+
setState("connectionState", "disconnected");
|
|
442
|
+
_isDemoMode = false;
|
|
443
|
+
}
|
|
444
|
+
/**
|
|
445
|
+
* Apply an optimistic state update for demo mode.
|
|
446
|
+
*
|
|
447
|
+
* When a service call is made in demo mode, this updates the entity
|
|
448
|
+
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
449
|
+
*/
|
|
450
|
+
function applyDemoServiceCall(domain, service, _serviceData = {}, target = {}) {
|
|
451
|
+
if (!_isDemoMode) return;
|
|
452
|
+
const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
|
|
453
|
+
for (const entityId of entityIds) {
|
|
454
|
+
const entity$1 = state.entities[entityId];
|
|
455
|
+
if (!entity$1) continue;
|
|
456
|
+
const entityDomain = extractDomain(entityId);
|
|
457
|
+
setState(produce((s) => {
|
|
458
|
+
const e = s.entities[entityId];
|
|
459
|
+
if (!e) return;
|
|
460
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
461
|
+
e.last_changed = now;
|
|
462
|
+
e.last_updated = now;
|
|
463
|
+
if (service === "turn_on") applyTurnOn(e, entityDomain, _serviceData);
|
|
464
|
+
else if (service === "turn_off") applyTurnOff(e, entityDomain);
|
|
465
|
+
else if (service === "toggle") if (e.state === "on" || e.state === "playing" || e.state === "open") applyTurnOff(e, entityDomain);
|
|
466
|
+
else applyTurnOn(e, entityDomain, _serviceData);
|
|
467
|
+
else if (service === "lock") e.state = "locked";
|
|
468
|
+
else if (service === "unlock") e.state = "unlocked";
|
|
469
|
+
else if (service === "open_cover") {
|
|
470
|
+
e.state = "open";
|
|
471
|
+
e.attributes.current_position = 100;
|
|
472
|
+
} else if (service === "close_cover") {
|
|
473
|
+
e.state = "closed";
|
|
474
|
+
e.attributes.current_position = 0;
|
|
475
|
+
} else if (service === "set_cover_position" && _serviceData.position != null) {
|
|
476
|
+
e.attributes.current_position = _serviceData.position;
|
|
477
|
+
e.state = _serviceData.position > 0 ? "open" : "closed";
|
|
478
|
+
} else if (service === "set_temperature" && _serviceData.temperature != null) e.attributes.temperature = _serviceData.temperature;
|
|
479
|
+
else if (service === "set_hvac_mode" && _serviceData.hvac_mode != null) e.state = _serviceData.hvac_mode;
|
|
480
|
+
else if (service === "volume_set" && _serviceData.volume_level != null) e.attributes.volume_level = _serviceData.volume_level;
|
|
481
|
+
}));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function applyTurnOn(e, domain, serviceData = {}) {
|
|
485
|
+
if (domain === "light") {
|
|
486
|
+
e.state = "on";
|
|
487
|
+
e.attributes.brightness = serviceData.brightness ?? e.attributes.brightness ?? 255;
|
|
488
|
+
if (serviceData.color_temp_kelvin) e.attributes.color_temp_kelvin = serviceData.color_temp_kelvin;
|
|
489
|
+
} else if (domain === "media_player") e.state = "playing";
|
|
490
|
+
else if (domain === "cover") {
|
|
491
|
+
e.state = "open";
|
|
492
|
+
e.attributes.current_position = 100;
|
|
493
|
+
} else e.state = "on";
|
|
494
|
+
}
|
|
495
|
+
function applyTurnOff(e, domain) {
|
|
496
|
+
if (domain === "light") {
|
|
497
|
+
e.state = "off";
|
|
498
|
+
delete e.attributes.brightness;
|
|
499
|
+
} else if (domain === "media_player") e.state = "paused";
|
|
500
|
+
else if (domain === "cover") {
|
|
501
|
+
e.state = "closed";
|
|
502
|
+
e.attributes.current_position = 0;
|
|
503
|
+
} else e.state = "off";
|
|
504
|
+
}
|
|
505
|
+
|
|
398
506
|
//#endregion
|
|
399
507
|
//#region src/commands/service.ts
|
|
400
508
|
/**
|
|
@@ -649,60 +757,120 @@ function appendHistoryPoint(entityId, stateValue, attributes, lastUpdated, lastC
|
|
|
649
757
|
});
|
|
650
758
|
}));
|
|
651
759
|
}
|
|
760
|
+
/**
|
|
761
|
+
* Append multiple history points in a single setState(produce(...)).
|
|
762
|
+
* Called by the rAF-batched subscription flush.
|
|
763
|
+
*/
|
|
764
|
+
function bulkAppendHistoryPoints(points) {
|
|
765
|
+
if (points.length === 0) return;
|
|
766
|
+
setState(produce((s) => {
|
|
767
|
+
for (const { entityId, stateValue, attributes, lastUpdated, lastChanged } of points) {
|
|
768
|
+
const history = s.history[entityId];
|
|
769
|
+
if (!history) continue;
|
|
770
|
+
history.entityHistory.push({
|
|
771
|
+
s: stateValue,
|
|
772
|
+
a: attributes,
|
|
773
|
+
lu: lastUpdated,
|
|
774
|
+
lc: lastChanged
|
|
775
|
+
});
|
|
776
|
+
history.timeline.push({
|
|
777
|
+
timestamp: lastUpdated,
|
|
778
|
+
state: stateValue,
|
|
779
|
+
attributes,
|
|
780
|
+
lastChanged,
|
|
781
|
+
lastUpdated
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
652
786
|
|
|
653
787
|
//#endregion
|
|
654
788
|
//#region src/connection/subscriptions.ts
|
|
655
|
-
/**
|
|
656
|
-
let
|
|
789
|
+
/** Registry subscription cleanup functions (entity sub managed by subscription-manager) */
|
|
790
|
+
let registrySubscriptions = [];
|
|
791
|
+
/** Buffered messages waiting for next animation frame */
|
|
792
|
+
let messageBuffer = [];
|
|
793
|
+
let rafScheduled = false;
|
|
794
|
+
function bufferMessage(message) {
|
|
795
|
+
messageBuffer.push(message);
|
|
796
|
+
if (!rafScheduled) {
|
|
797
|
+
rafScheduled = true;
|
|
798
|
+
const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : queueMicrotask;
|
|
799
|
+
schedule(flushMessageBuffer);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function flushMessageBuffer() {
|
|
803
|
+
rafScheduled = false;
|
|
804
|
+
const messages = messageBuffer;
|
|
805
|
+
messageBuffer = [];
|
|
806
|
+
if (messages.length === 0) return;
|
|
807
|
+
setState(produce((s) => {
|
|
808
|
+
for (const message of messages) {
|
|
809
|
+
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
|
|
810
|
+
if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
|
|
811
|
+
if (message.r) for (const entityId of message.r) delete s.entities[entityId];
|
|
812
|
+
}
|
|
813
|
+
}));
|
|
814
|
+
const historyPoints = [];
|
|
815
|
+
for (const message of messages) {
|
|
816
|
+
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) collectHistoryFromCompressed(historyPoints, entityId, compressedState);
|
|
817
|
+
if (message.c) {
|
|
818
|
+
for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) collectHistoryFromDiff(historyPoints, entityId, diff["+"]);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (historyPoints.length > 0) bulkAppendHistoryPoints(historyPoints);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Subscribe to entity state updates with an entity_ids filter.
|
|
825
|
+
* Called by the subscription manager on flush.
|
|
826
|
+
*/
|
|
827
|
+
async function subscribeEntities(connection, entityIds) {
|
|
828
|
+
const subscribeMsg = { type: "subscribe_entities" };
|
|
829
|
+
if (entityIds.length > 0) subscribeMsg.entity_ids = entityIds;
|
|
830
|
+
const unsub = await connection.subscribeMessage((message) => {
|
|
831
|
+
bufferMessage(message);
|
|
832
|
+
}, subscribeMsg);
|
|
833
|
+
return unsub;
|
|
834
|
+
}
|
|
657
835
|
/**
|
|
658
836
|
* Subscribe to all relevant events from Home Assistant
|
|
659
837
|
*/
|
|
660
838
|
async function subscribeToUpdates(conn) {
|
|
661
839
|
await cleanupSubscriptions();
|
|
840
|
+
setManagerConnection(conn);
|
|
841
|
+
setResubscribeHandler(subscribeEntities);
|
|
662
842
|
const registryUpdatedSub = await conn.subscribeEvents((event) => {
|
|
663
843
|
handleRegistryUpdate(event);
|
|
664
844
|
}, "entity_registry_updated");
|
|
665
|
-
|
|
845
|
+
registrySubscriptions.push(() => registryUpdatedSub());
|
|
666
846
|
const deviceRegistrySub = await conn.subscribeEvents((event) => {
|
|
667
847
|
handleDeviceRegistryUpdate(event);
|
|
668
848
|
}, "device_registry_updated");
|
|
669
|
-
|
|
849
|
+
registrySubscriptions.push(() => deviceRegistrySub());
|
|
670
850
|
const areaRegistrySub = await conn.subscribeEvents((event) => {
|
|
671
851
|
handleAreaRegistryUpdate(event);
|
|
672
852
|
}, "area_registry_updated");
|
|
673
|
-
|
|
853
|
+
registrySubscriptions.push(() => areaRegistrySub());
|
|
674
854
|
const floorRegistrySub = await conn.subscribeEvents((event) => {
|
|
675
855
|
handleFloorRegistryUpdate(event);
|
|
676
856
|
}, "floor_registry_updated");
|
|
677
|
-
|
|
857
|
+
registrySubscriptions.push(() => floorRegistrySub());
|
|
678
858
|
const labelRegistrySub = await conn.subscribeEvents((event) => {
|
|
679
859
|
handleLabelRegistryUpdate(event);
|
|
680
860
|
}, "label_registry_updated");
|
|
681
|
-
|
|
861
|
+
registrySubscriptions.push(() => labelRegistrySub());
|
|
682
862
|
const statisticsSub = await conn.subscribeEvents((event) => {
|
|
683
863
|
handleStatisticsUpdate(event);
|
|
684
864
|
}, "recorder_5min_statistics_generated");
|
|
685
|
-
|
|
686
|
-
const entityUpdatesSub = await conn.subscribeMessage((message) => {
|
|
687
|
-
setState(produce((s) => {
|
|
688
|
-
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
|
|
689
|
-
if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
|
|
690
|
-
if (message.r) for (const entityId of message.r) delete s.entities[entityId];
|
|
691
|
-
}));
|
|
692
|
-
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) maybeAppendHistory(entityId, compressedState);
|
|
693
|
-
if (message.c) {
|
|
694
|
-
for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) maybeAppendHistoryFromDiff(entityId, diff["+"]);
|
|
695
|
-
}
|
|
696
|
-
}, { type: "subscribe_entities" });
|
|
697
|
-
activeSubscriptions.push(() => entityUpdatesSub());
|
|
865
|
+
registrySubscriptions.push(() => statisticsSub());
|
|
698
866
|
}
|
|
699
867
|
/**
|
|
700
868
|
* Clean up all active subscriptions
|
|
701
869
|
*/
|
|
702
870
|
async function cleanupSubscriptions() {
|
|
703
|
-
const subscriptions = [...
|
|
871
|
+
const subscriptions = [...registrySubscriptions];
|
|
704
872
|
for (const unsub of subscriptions) await unsub();
|
|
705
|
-
|
|
873
|
+
registrySubscriptions = [];
|
|
706
874
|
}
|
|
707
875
|
async function handleRegistryUpdate(event) {
|
|
708
876
|
const { action, entity_id } = event.data;
|
|
@@ -741,24 +909,8 @@ async function handleAreaRegistryUpdate(event) {
|
|
|
741
909
|
if (!conn) return;
|
|
742
910
|
try {
|
|
743
911
|
const areas$1 = await conn.sendMessagePromise({ type: "config/area_registry/list" });
|
|
744
|
-
const
|
|
745
|
-
if (
|
|
746
|
-
const area$1 = {
|
|
747
|
-
id: areaApi.area_id || areaApi.id,
|
|
748
|
-
name: areaApi.name,
|
|
749
|
-
normalized_name: areaApi.normalized_name || areaApi.name.toLowerCase().replace(/\s+/g, "_"),
|
|
750
|
-
aliases: Array.isArray(areaApi.aliases) ? areaApi.aliases : [],
|
|
751
|
-
floor_id: areaApi.floor_id ?? null,
|
|
752
|
-
humidity_entity_id: areaApi.humidity_entity_id ?? null,
|
|
753
|
-
icon: areaApi.icon ?? null,
|
|
754
|
-
labels: Array.isArray(areaApi.labels) ? areaApi.labels : [],
|
|
755
|
-
picture: areaApi.picture ?? null,
|
|
756
|
-
temperature_entity_id: areaApi.temperature_entity_id ?? null,
|
|
757
|
-
created_at: typeof areaApi.created_at === "number" ? new Date(areaApi.created_at * 1e3).toISOString() : areaApi.created_at,
|
|
758
|
-
modified_at: typeof areaApi.modified_at === "number" ? new Date(areaApi.modified_at * 1e3).toISOString() : areaApi.modified_at
|
|
759
|
-
};
|
|
760
|
-
updateArea(area$1);
|
|
761
|
-
}
|
|
912
|
+
const area$1 = areas$1.find((a) => a.id === area_id);
|
|
913
|
+
if (area$1) updateArea(area$1);
|
|
762
914
|
} catch (error) {
|
|
763
915
|
console.error("Error fetching area:", error);
|
|
764
916
|
}
|
|
@@ -812,21 +964,39 @@ function timestampToIso(timestamp) {
|
|
|
812
964
|
if (typeof timestamp === "string") return timestamp;
|
|
813
965
|
return new Date(timestamp * 1e3).toISOString();
|
|
814
966
|
}
|
|
815
|
-
function
|
|
967
|
+
function collectHistoryFromCompressed(points, entityId, compressed) {
|
|
816
968
|
if (!isHistoryTracked(entityId)) return;
|
|
817
969
|
const stateVal = compressed.s ?? compressed.state ?? "unknown";
|
|
818
970
|
const attributes = compressed.a ?? compressed.attributes ?? {};
|
|
819
971
|
const lu = compressed.lu ?? (compressed.last_updated ? new Date(compressed.last_updated).getTime() / 1e3 : Date.now() / 1e3);
|
|
820
972
|
const lc = compressed.lc ?? (compressed.last_changed ? new Date(compressed.last_changed).getTime() / 1e3 : void 0);
|
|
821
|
-
|
|
973
|
+
points.push({
|
|
974
|
+
entityId,
|
|
975
|
+
stateValue: stateVal,
|
|
976
|
+
attributes,
|
|
977
|
+
lastUpdated: lu,
|
|
978
|
+
lastChanged: lc
|
|
979
|
+
});
|
|
822
980
|
}
|
|
823
|
-
function
|
|
981
|
+
function collectHistoryFromDiff(points, entityId, additions) {
|
|
824
982
|
if (!isHistoryTracked(entityId)) return;
|
|
825
|
-
const
|
|
826
|
-
if (
|
|
983
|
+
const stateVal = additions.s ?? additions.state;
|
|
984
|
+
if (stateVal === void 0) return;
|
|
827
985
|
const lu = additions.lu ?? Date.now() / 1e3;
|
|
828
986
|
const lc = additions.lc;
|
|
829
|
-
|
|
987
|
+
const attributes = additions.a ?? additions.attributes ?? {};
|
|
988
|
+
points.push({
|
|
989
|
+
entityId,
|
|
990
|
+
stateValue: stateVal,
|
|
991
|
+
attributes,
|
|
992
|
+
lastUpdated: lu,
|
|
993
|
+
lastChanged: lc
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
function shallowEqualAttributes(existing, incoming) {
|
|
997
|
+
const incomingKeys = Object.keys(incoming);
|
|
998
|
+
for (const key of incomingKeys) if (existing[key] !== incoming[key]) return false;
|
|
999
|
+
return true;
|
|
830
1000
|
}
|
|
831
1001
|
function applyCompressedState(s, entityId, compressed) {
|
|
832
1002
|
const stateVal = compressed.s ?? compressed.state ?? "unknown";
|
|
@@ -838,7 +1008,16 @@ function applyCompressedState(s, entityId, compressed) {
|
|
|
838
1008
|
};
|
|
839
1009
|
const lastChanged = timestampToIso(compressed.lc ?? compressed.last_changed);
|
|
840
1010
|
const lastUpdated = timestampToIso(compressed.lu ?? compressed.last_updated);
|
|
841
|
-
const
|
|
1011
|
+
const existing = s.entities[entityId];
|
|
1012
|
+
if (existing) {
|
|
1013
|
+
const stateChanged = existing.state !== stateVal;
|
|
1014
|
+
const changedAt = existing.last_changed !== lastChanged;
|
|
1015
|
+
if (stateChanged) existing.state = stateVal;
|
|
1016
|
+
if (changedAt) existing.last_changed = lastChanged;
|
|
1017
|
+
if (stateChanged || changedAt) existing.last_updated = lastUpdated;
|
|
1018
|
+
if (!shallowEqualAttributes(existing.attributes, attributes)) Object.assign(existing.attributes, attributes);
|
|
1019
|
+
if (existing.context.id !== context.id) existing.context = context;
|
|
1020
|
+
} else s.entities[entityId] = {
|
|
842
1021
|
entity_id: entityId,
|
|
843
1022
|
state: stateVal,
|
|
844
1023
|
attributes,
|
|
@@ -846,18 +1025,32 @@ function applyCompressedState(s, entityId, compressed) {
|
|
|
846
1025
|
last_updated: lastUpdated,
|
|
847
1026
|
context
|
|
848
1027
|
};
|
|
849
|
-
s.entities[entityId] = entity$1;
|
|
850
1028
|
}
|
|
851
1029
|
function applyStateDiff(s, entityId, diff) {
|
|
852
1030
|
const existing = s.entities[entityId];
|
|
853
1031
|
if (!existing) return;
|
|
854
1032
|
const additions = diff["+"];
|
|
855
1033
|
if (additions) {
|
|
856
|
-
|
|
857
|
-
if (additions.
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
1034
|
+
let stateChanged = false;
|
|
1035
|
+
if (additions.s !== void 0 && existing.state !== additions.s) {
|
|
1036
|
+
existing.state = additions.s;
|
|
1037
|
+
stateChanged = true;
|
|
1038
|
+
}
|
|
1039
|
+
if (additions.a) {
|
|
1040
|
+
for (const [key, value] of Object.entries(additions.a)) if (existing.attributes[key] !== value) {
|
|
1041
|
+
existing.attributes[key] = value;
|
|
1042
|
+
stateChanged = true;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (additions.lc !== void 0) {
|
|
1046
|
+
const lcIso = timestampToIso(additions.lc);
|
|
1047
|
+
if (existing.last_changed !== lcIso) {
|
|
1048
|
+
existing.last_changed = lcIso;
|
|
1049
|
+
stateChanged = true;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (additions.lu !== void 0 && stateChanged) existing.last_updated = timestampToIso(additions.lu);
|
|
1053
|
+
if (additions.c && existing.context.id !== additions.c.id) existing.context = additions.c;
|
|
861
1054
|
}
|
|
862
1055
|
const removals = diff["-"];
|
|
863
1056
|
if (removals?.a) for (const key of removals.a) delete existing.attributes[key];
|
|
@@ -946,6 +1139,7 @@ function setupEventHandlers(conn, options) {
|
|
|
946
1139
|
}));
|
|
947
1140
|
if (wasReconnecting) {
|
|
948
1141
|
await loadInitialData(conn);
|
|
1142
|
+
await forceResubscribe();
|
|
949
1143
|
options.onReconnect?.();
|
|
950
1144
|
}
|
|
951
1145
|
});
|
|
@@ -990,77 +1184,6 @@ async function loadInitialData(conn) {
|
|
|
990
1184
|
}
|
|
991
1185
|
}
|
|
992
1186
|
|
|
993
|
-
//#endregion
|
|
994
|
-
//#region src/entities/index.ts
|
|
995
|
-
/**
|
|
996
|
-
* Get all entity IDs
|
|
997
|
-
*/
|
|
998
|
-
function getAllEntityIds() {
|
|
999
|
-
return Object.keys(state.entities);
|
|
1000
|
-
}
|
|
1001
|
-
/**
|
|
1002
|
-
* Get entities by domain
|
|
1003
|
-
*/
|
|
1004
|
-
function getEntitiesByDomain(domain) {
|
|
1005
|
-
return Object.keys(state.entities).filter((id) => extractDomain(id) === domain);
|
|
1006
|
-
}
|
|
1007
|
-
/**
|
|
1008
|
-
* Get entities via devices in area
|
|
1009
|
-
*
|
|
1010
|
-
* Only includes entities that inherit area from device (entity.area_id is null).
|
|
1011
|
-
*/
|
|
1012
|
-
function getEntitiesViaDevices(areaId) {
|
|
1013
|
-
const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
|
|
1014
|
-
const entityIds = [];
|
|
1015
|
-
for (const device of devicesInArea) {
|
|
1016
|
-
const deviceEntities = getEntitiesByDevice(device.id);
|
|
1017
|
-
for (const entityId of deviceEntities) {
|
|
1018
|
-
const registry = state.entityRegistry[entityId];
|
|
1019
|
-
if (!registry?.area_id) entityIds.push(entityId);
|
|
1020
|
-
}
|
|
1021
|
-
}
|
|
1022
|
-
return entityIds;
|
|
1023
|
-
}
|
|
1024
|
-
/**
|
|
1025
|
-
* Get entities by area
|
|
1026
|
-
*
|
|
1027
|
-
* Returns entities with direct area_id assignments and entities that inherit their area from devices.
|
|
1028
|
-
*/
|
|
1029
|
-
function getEntitiesByArea(areaId) {
|
|
1030
|
-
const directEntityIds = [];
|
|
1031
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
|
|
1032
|
-
const deviceEntityIds = getEntitiesViaDevices(areaId);
|
|
1033
|
-
return Array.from(new Set([...directEntityIds, ...deviceEntityIds]));
|
|
1034
|
-
}
|
|
1035
|
-
/**
|
|
1036
|
-
* Get entities by device
|
|
1037
|
-
*/
|
|
1038
|
-
function getEntitiesByDevice(deviceId) {
|
|
1039
|
-
const entityIds = [];
|
|
1040
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.device_id === deviceId) entityIds.push(entityId);
|
|
1041
|
-
return entityIds;
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Get entities by floor
|
|
1045
|
-
*/
|
|
1046
|
-
function getEntitiesByFloor(floorId) {
|
|
1047
|
-
const entityIds = [];
|
|
1048
|
-
const areasOnFloor = Object.values(state.areas).filter((area$1) => area$1.floor_id === floorId);
|
|
1049
|
-
for (const area$1 of areasOnFloor) {
|
|
1050
|
-
const areaEntities = getEntitiesByArea(area$1.id);
|
|
1051
|
-
entityIds.push(...areaEntities);
|
|
1052
|
-
}
|
|
1053
|
-
return entityIds;
|
|
1054
|
-
}
|
|
1055
|
-
/**
|
|
1056
|
-
* Get entities by label
|
|
1057
|
-
*/
|
|
1058
|
-
function getEntitiesByLabel(labelId) {
|
|
1059
|
-
const entityIds = [];
|
|
1060
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.labels?.includes(labelId)) entityIds.push(entityId);
|
|
1061
|
-
return entityIds;
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
1187
|
//#endregion
|
|
1065
1188
|
//#region src/entities/views.ts
|
|
1066
1189
|
/**
|
|
@@ -1146,9 +1269,29 @@ function getEntityViews(entityIds) {
|
|
|
1146
1269
|
function getAllEntityViews() {
|
|
1147
1270
|
return getEntityViews(Object.keys(state.entities));
|
|
1148
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Shallow equality check for EntityView.
|
|
1274
|
+
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
1275
|
+
*/
|
|
1276
|
+
function entityViewEquals(a, b) {
|
|
1277
|
+
if (a === b) return true;
|
|
1278
|
+
if (!a || !b) return false;
|
|
1279
|
+
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;
|
|
1280
|
+
}
|
|
1149
1281
|
|
|
1150
1282
|
//#endregion
|
|
1151
1283
|
//#region src/entities/area-views.ts
|
|
1284
|
+
function getDevicesForArea(areaId) {
|
|
1285
|
+
const result = [];
|
|
1286
|
+
for (const device of Object.values(state.devices)) if (device.area_id === areaId) result.push(device);
|
|
1287
|
+
return result;
|
|
1288
|
+
}
|
|
1289
|
+
function getEntityIdsForArea(areaId, deviceIdsInArea) {
|
|
1290
|
+
const entityIds = [];
|
|
1291
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) entityIds.push(entityId);
|
|
1292
|
+
else if (!entry.area_id && entry.device_id && deviceIdsInArea.has(entry.device_id)) entityIds.push(entityId);
|
|
1293
|
+
return entityIds;
|
|
1294
|
+
}
|
|
1152
1295
|
/**
|
|
1153
1296
|
* Build a DeviceView from registry data
|
|
1154
1297
|
*/
|
|
@@ -1173,12 +1316,10 @@ function buildDeviceView(device) {
|
|
|
1173
1316
|
function buildAreaView(areaId) {
|
|
1174
1317
|
const area$1 = state.areas[areaId];
|
|
1175
1318
|
if (!area$1) throw new Error(`Area ${areaId} not found`);
|
|
1176
|
-
const
|
|
1177
|
-
|
|
1178
|
-
const
|
|
1179
|
-
const allEntityIds = Array.from(new Set([...directEntityIds, ...deviceEntityIds]));
|
|
1319
|
+
const devicesInArea = getDevicesForArea(areaId);
|
|
1320
|
+
const deviceIdsInArea = new Set(devicesInArea.map((d) => d.id));
|
|
1321
|
+
const allEntityIds = getEntityIdsForArea(areaId, deviceIdsInArea);
|
|
1180
1322
|
const areaEntities = allEntityIds.length > 0 ? getEntityViews(allEntityIds) : [];
|
|
1181
|
-
const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
|
|
1182
1323
|
const devices = devicesInArea.map((device) => buildDeviceView(device));
|
|
1183
1324
|
return {
|
|
1184
1325
|
id: areaId,
|
|
@@ -1309,6 +1450,77 @@ function area(areaId) {
|
|
|
1309
1450
|
return getAreaView(areaId);
|
|
1310
1451
|
}
|
|
1311
1452
|
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/entities/index.ts
|
|
1455
|
+
/**
|
|
1456
|
+
* Get all entity IDs
|
|
1457
|
+
*/
|
|
1458
|
+
function getAllEntityIds() {
|
|
1459
|
+
return Object.keys(state.entities);
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Get entities by domain
|
|
1463
|
+
*/
|
|
1464
|
+
function getEntitiesByDomain(domain) {
|
|
1465
|
+
return Object.keys(state.entities).filter((id) => extractDomain(id) === domain);
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Get entities via devices in area
|
|
1469
|
+
*
|
|
1470
|
+
* Only includes entities that inherit area from device (entity.area_id is null).
|
|
1471
|
+
*/
|
|
1472
|
+
function getEntitiesViaDevices(areaId) {
|
|
1473
|
+
const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
|
|
1474
|
+
const entityIds = [];
|
|
1475
|
+
for (const device of devicesInArea) {
|
|
1476
|
+
const deviceEntities = getEntitiesByDevice(device.id);
|
|
1477
|
+
for (const entityId of deviceEntities) {
|
|
1478
|
+
const registry = state.entityRegistry[entityId];
|
|
1479
|
+
if (!registry?.area_id) entityIds.push(entityId);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return entityIds;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Get entities by area
|
|
1486
|
+
*
|
|
1487
|
+
* Returns entities with direct area_id assignments and entities that inherit their area from devices.
|
|
1488
|
+
*/
|
|
1489
|
+
function getEntitiesByArea(areaId) {
|
|
1490
|
+
const directEntityIds = [];
|
|
1491
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
|
|
1492
|
+
const deviceEntityIds = getEntitiesViaDevices(areaId);
|
|
1493
|
+
return Array.from(new Set([...directEntityIds, ...deviceEntityIds]));
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Get entities by device
|
|
1497
|
+
*/
|
|
1498
|
+
function getEntitiesByDevice(deviceId) {
|
|
1499
|
+
const entityIds = [];
|
|
1500
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.device_id === deviceId) entityIds.push(entityId);
|
|
1501
|
+
return entityIds;
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Get entities by floor
|
|
1505
|
+
*/
|
|
1506
|
+
function getEntitiesByFloor(floorId) {
|
|
1507
|
+
const entityIds = [];
|
|
1508
|
+
const areasOnFloor = Object.values(state.areas).filter((area$1) => area$1.floor_id === floorId);
|
|
1509
|
+
for (const area$1 of areasOnFloor) {
|
|
1510
|
+
const areaEntities = getEntitiesByArea(area$1.id);
|
|
1511
|
+
entityIds.push(...areaEntities);
|
|
1512
|
+
}
|
|
1513
|
+
return entityIds;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Get entities by label
|
|
1517
|
+
*/
|
|
1518
|
+
function getEntitiesByLabel(labelId) {
|
|
1519
|
+
const entityIds = [];
|
|
1520
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.labels?.includes(labelId)) entityIds.push(entityId);
|
|
1521
|
+
return entityIds;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1312
1524
|
//#endregion
|
|
1313
1525
|
//#region src/query/memoization.ts
|
|
1314
1526
|
/**
|
|
@@ -2067,4 +2279,4 @@ async function refreshForecasts(entityId, types) {
|
|
|
2067
2279
|
const VERSION = "0.1.0";
|
|
2068
2280
|
|
|
2069
2281
|
//#endregion
|
|
2070
|
-
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, extractDomain, 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 };
|
|
2282
|
+
export { DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyDemoServiceCall, area, areas, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, refreshAuth, refreshForecast, refreshForecasts, refreshStream, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startWebRtcSession, state, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
|
package/dist/solid/hooks.d.ts
CHANGED
|
@@ -16,16 +16,20 @@ import { callService, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
|
|
|
16
16
|
/**
|
|
17
17
|
* Get a reactive EntityView for an entity ID.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* Registers the entity for live updates via the subscription manager.
|
|
20
20
|
* Only re-runs when the specific entity's data changes.
|
|
21
21
|
*/
|
|
22
22
|
export declare function useEntity(entityId: Accessor<string> | string): Accessor<EntityView | undefined>;
|
|
23
23
|
/**
|
|
24
24
|
* Get reactive EntityViews for multiple entity IDs.
|
|
25
|
+
*
|
|
26
|
+
* Registers all entity IDs for live updates via the subscription manager.
|
|
25
27
|
*/
|
|
26
28
|
export declare function useEntities(entityIds: Accessor<string[]>): Accessor<EntityView[]>;
|
|
27
29
|
/**
|
|
28
30
|
* Get raw HassEntity state (without EntityView transformation).
|
|
31
|
+
*
|
|
32
|
+
* Registers the entity for live updates via the subscription manager.
|
|
29
33
|
*/
|
|
30
34
|
export declare function useEntityState(entityId: Accessor<string> | string): Accessor<HassEntity | undefined>;
|
|
31
35
|
/**
|
|
@@ -56,6 +60,8 @@ export declare function useToggle(): typeof toggle;
|
|
|
56
60
|
export declare function useAreas(): Accessor<AreaView[]>;
|
|
57
61
|
/**
|
|
58
62
|
* Get a reactive AreaView for a specific area.
|
|
63
|
+
*
|
|
64
|
+
* Registers all entities in the area for live updates.
|
|
59
65
|
*/
|
|
60
66
|
export declare function useArea(areaId: Accessor<string> | string): Accessor<AreaView | undefined>;
|
|
61
67
|
/**
|
package/dist/solid/index.js
CHANGED
|
@@ -1,34 +1,62 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { registerEntity } from "../subscription-manager-gR0qpDYZ.js";
|
|
2
|
+
import { createEffect, createMemo, onCleanup } from "solid-js";
|
|
3
|
+
import { buildAreaView, callService, entityViewEquals, extractDomain, getEntityView, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
|
|
3
4
|
|
|
4
5
|
//#region src/solid/hooks.ts
|
|
5
6
|
/**
|
|
6
7
|
* Get a reactive EntityView for an entity ID.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* Registers the entity for live updates via the subscription manager.
|
|
9
10
|
* Only re-runs when the specific entity's data changes.
|
|
10
11
|
*/
|
|
11
12
|
function useEntity(entityId) {
|
|
12
13
|
const getId = typeof entityId === "function" ? entityId : () => entityId;
|
|
14
|
+
createEffect(() => {
|
|
15
|
+
const id = getId();
|
|
16
|
+
if (!id) return;
|
|
17
|
+
const unregister = registerEntity(id);
|
|
18
|
+
onCleanup(unregister);
|
|
19
|
+
});
|
|
13
20
|
return createMemo(() => {
|
|
14
21
|
const id = getId();
|
|
15
22
|
if (!id) return void 0;
|
|
16
23
|
return getEntityView(id);
|
|
17
|
-
});
|
|
24
|
+
}, void 0, { equals: entityViewEquals });
|
|
18
25
|
}
|
|
19
26
|
/**
|
|
20
27
|
* Get reactive EntityViews for multiple entity IDs.
|
|
28
|
+
*
|
|
29
|
+
* Registers all entity IDs for live updates via the subscription manager.
|
|
21
30
|
*/
|
|
22
31
|
function useEntities(entityIds) {
|
|
32
|
+
createEffect(() => {
|
|
33
|
+
const ids = entityIds();
|
|
34
|
+
const unregisters = ids.map((id) => registerEntity(id));
|
|
35
|
+
onCleanup(() => {
|
|
36
|
+
for (const unreg of unregisters) unreg();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
23
39
|
return createMemo(() => {
|
|
24
40
|
return entityIds().map((id) => getEntityView(id)).filter((v) => v !== void 0);
|
|
25
|
-
})
|
|
41
|
+
}, void 0, { equals: (a, b) => {
|
|
42
|
+
if (a.length !== b.length) return false;
|
|
43
|
+
for (let i = 0; i < a.length; i++) if (!entityViewEquals(a[i], b[i])) return false;
|
|
44
|
+
return true;
|
|
45
|
+
} });
|
|
26
46
|
}
|
|
27
47
|
/**
|
|
28
48
|
* Get raw HassEntity state (without EntityView transformation).
|
|
49
|
+
*
|
|
50
|
+
* Registers the entity for live updates via the subscription manager.
|
|
29
51
|
*/
|
|
30
52
|
function useEntityState(entityId) {
|
|
31
53
|
const getId = typeof entityId === "function" ? entityId : () => entityId;
|
|
54
|
+
createEffect(() => {
|
|
55
|
+
const id = getId();
|
|
56
|
+
if (!id) return;
|
|
57
|
+
const unregister = registerEntity(id);
|
|
58
|
+
onCleanup(unregister);
|
|
59
|
+
});
|
|
32
60
|
return createMemo(() => state.entities[getId()]);
|
|
33
61
|
}
|
|
34
62
|
/**
|
|
@@ -77,14 +105,20 @@ function useAreas() {
|
|
|
77
105
|
return void 0;
|
|
78
106
|
}
|
|
79
107
|
}).filter((v) => v !== void 0);
|
|
80
|
-
})
|
|
108
|
+
}, void 0, { equals: (a, b) => {
|
|
109
|
+
if (a.length !== b.length) return false;
|
|
110
|
+
for (let i = 0; i < a.length; i++) if (a[i].id !== b[i].id || a[i].modifiedAt !== b[i].modifiedAt || a[i].entities.length !== b[i].entities.length) return false;
|
|
111
|
+
return true;
|
|
112
|
+
} });
|
|
81
113
|
}
|
|
82
114
|
/**
|
|
83
115
|
* Get a reactive AreaView for a specific area.
|
|
116
|
+
*
|
|
117
|
+
* Registers all entities in the area for live updates.
|
|
84
118
|
*/
|
|
85
119
|
function useArea(areaId) {
|
|
86
120
|
const getId = typeof areaId === "function" ? areaId : () => areaId;
|
|
87
|
-
|
|
121
|
+
const view = createMemo(() => {
|
|
88
122
|
const id = getId();
|
|
89
123
|
if (!id || !state.areas[id]) return void 0;
|
|
90
124
|
try {
|
|
@@ -93,6 +127,15 @@ function useArea(areaId) {
|
|
|
93
127
|
return void 0;
|
|
94
128
|
}
|
|
95
129
|
});
|
|
130
|
+
createEffect(() => {
|
|
131
|
+
const v = view();
|
|
132
|
+
if (!v) return;
|
|
133
|
+
const unregisters = v.entityIds.map((id) => registerEntity(id));
|
|
134
|
+
onCleanup(() => {
|
|
135
|
+
for (const unreg of unregisters) unreg();
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
return view;
|
|
96
139
|
}
|
|
97
140
|
/**
|
|
98
141
|
* Get reactive entity history data.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//#region src/connection/subscription-manager.ts
|
|
2
|
+
/** Ref counts — how many consumers need each entity */
|
|
3
|
+
const refCounts = /* @__PURE__ */ new Map();
|
|
4
|
+
/** Current HA subscription unsub function */
|
|
5
|
+
let currentEntityUnsub = null;
|
|
6
|
+
/** Snapshot of entity IDs in the current HA subscription */
|
|
7
|
+
let subscribedIds = /* @__PURE__ */ new Set();
|
|
8
|
+
/** Connection reference for resubscribing */
|
|
9
|
+
let conn = null;
|
|
10
|
+
/** Pending flush timer (microtask for register, timeout for unregister) */
|
|
11
|
+
let flushScheduled = false;
|
|
12
|
+
/** Delayed unregister timeout */
|
|
13
|
+
let unregisterTimeout = null;
|
|
14
|
+
/** Callback invoked on resubscribe — set by subscriptions.ts */
|
|
15
|
+
let onResubscribe = null;
|
|
16
|
+
/** Delay before unregistered entities are actually removed from subscription */
|
|
17
|
+
const UNREGISTER_DELAY_MS = 5e3;
|
|
18
|
+
/** Guard against concurrent flush() calls */
|
|
19
|
+
let flushInFlight = false;
|
|
20
|
+
/**
|
|
21
|
+
* Register an entity for live updates. Returns an unregister function.
|
|
22
|
+
* Call from useEntity/useEntities hooks via onCleanup.
|
|
23
|
+
*/
|
|
24
|
+
function registerEntity(entityId) {
|
|
25
|
+
refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
|
|
26
|
+
scheduleFlush();
|
|
27
|
+
return () => unregisterEntity(entityId);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Set the connection reference used for resubscribing.
|
|
31
|
+
*/
|
|
32
|
+
function setManagerConnection(connection) {
|
|
33
|
+
conn = connection;
|
|
34
|
+
if (refCounts.size > 0) scheduleFlush();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
38
|
+
* the manager into the actual WS subscription lifecycle.
|
|
39
|
+
*/
|
|
40
|
+
function setResubscribeHandler(handler) {
|
|
41
|
+
onResubscribe = handler;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Force a resubscribe with the current active set.
|
|
45
|
+
* Used on reconnect.
|
|
46
|
+
*/
|
|
47
|
+
async function forceResubscribe() {
|
|
48
|
+
await flush();
|
|
49
|
+
}
|
|
50
|
+
function unregisterEntity(entityId) {
|
|
51
|
+
const count = (refCounts.get(entityId) ?? 1) - 1;
|
|
52
|
+
if (count <= 0) refCounts.delete(entityId);
|
|
53
|
+
else refCounts.set(entityId, count);
|
|
54
|
+
scheduleDelayedFlush();
|
|
55
|
+
}
|
|
56
|
+
function scheduleFlush() {
|
|
57
|
+
if (flushScheduled) return;
|
|
58
|
+
flushScheduled = true;
|
|
59
|
+
queueMicrotask(() => {
|
|
60
|
+
flushScheduled = false;
|
|
61
|
+
flush();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function scheduleDelayedFlush() {
|
|
65
|
+
if (unregisterTimeout) return;
|
|
66
|
+
unregisterTimeout = setTimeout(() => {
|
|
67
|
+
unregisterTimeout = null;
|
|
68
|
+
flush();
|
|
69
|
+
}, UNREGISTER_DELAY_MS);
|
|
70
|
+
}
|
|
71
|
+
async function flush() {
|
|
72
|
+
if (!conn || !onResubscribe) return;
|
|
73
|
+
if (flushInFlight) return;
|
|
74
|
+
flushInFlight = true;
|
|
75
|
+
try {
|
|
76
|
+
const newIds = new Set(refCounts.keys());
|
|
77
|
+
if (setsEqual(newIds, subscribedIds)) return;
|
|
78
|
+
const oldUnsub = currentEntityUnsub;
|
|
79
|
+
const entityIds = [...newIds];
|
|
80
|
+
currentEntityUnsub = await onResubscribe(conn, entityIds);
|
|
81
|
+
subscribedIds = newIds;
|
|
82
|
+
if (oldUnsub) try {
|
|
83
|
+
await oldUnsub();
|
|
84
|
+
} catch {}
|
|
85
|
+
} finally {
|
|
86
|
+
flushInFlight = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function setsEqual(a, b) {
|
|
90
|
+
if (a.size !== b.size) return false;
|
|
91
|
+
for (const id of a) if (!b.has(id)) return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
export { forceResubscribe, registerEntity, setManagerConnection, setResubscribeHandler };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/sync-layer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Type-safe, reactive state synchronization layer for Home Assistant",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -26,11 +26,6 @@
|
|
|
26
26
|
"types": "./dist/testing/index.d.ts",
|
|
27
27
|
"import": "./dist/testing/index.js"
|
|
28
28
|
},
|
|
29
|
-
"./demo": {
|
|
30
|
-
"@glasshome/source": "./src/demo/demo-provider.ts",
|
|
31
|
-
"types": "./dist/demo/demo-provider.d.ts",
|
|
32
|
-
"import": "./dist/demo/demo-provider.js"
|
|
33
|
-
},
|
|
34
29
|
"./package.json": "./package.json"
|
|
35
30
|
},
|
|
36
31
|
"peerDependencies": {
|
|
@@ -1,4 +0,0 @@
|
|
|
1
|
-
import { createDemoFixtures } from "../demo-data-gELwUOS_.js";
|
|
2
|
-
import { applyDemoServiceCall, isDemoMode, loadDemoData, unloadDemoData } from "../demo-provider-I4BC9Woh.js";
|
|
3
|
-
|
|
4
|
-
export { applyDemoServiceCall, createDemoFixtures, isDemoMode, loadDemoData, unloadDemoData };
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
import { createDemoFixtures, setState, state } from "./demo-data-gELwUOS_.js";
|
|
2
|
-
import { produce, reconcile } from "solid-js/store";
|
|
3
|
-
|
|
4
|
-
//#region src/core/types.ts
|
|
5
|
-
/**
|
|
6
|
-
* Extract domain from entity ID
|
|
7
|
-
*/
|
|
8
|
-
function extractDomain(entityId) {
|
|
9
|
-
return entityId.split(".")[0] ?? "";
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
//#endregion
|
|
13
|
-
//#region src/demo/demo-provider.ts
|
|
14
|
-
let _isDemoMode = false;
|
|
15
|
-
/**
|
|
16
|
-
* Whether demo mode is currently active.
|
|
17
|
-
*/
|
|
18
|
-
function isDemoMode() {
|
|
19
|
-
return _isDemoMode;
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Load demo fixtures into the SolidJS store.
|
|
23
|
-
*
|
|
24
|
-
* Populates entities, entity registry, areas, and devices.
|
|
25
|
-
* Sets connection state to "connected" (pretend connected).
|
|
26
|
-
*/
|
|
27
|
-
async function loadDemoData() {
|
|
28
|
-
const fixtures = createDemoFixtures();
|
|
29
|
-
setState("entities", reconcile(fixtures.entities));
|
|
30
|
-
setState("entityRegistry", reconcile(fixtures.entityRegistry));
|
|
31
|
-
setState("areas", reconcile(fixtures.areas));
|
|
32
|
-
setState("devices", reconcile(fixtures.devices));
|
|
33
|
-
setState("hassUrl", "https://demo.home-assistant.local");
|
|
34
|
-
setState("connectionState", "connected");
|
|
35
|
-
_isDemoMode = true;
|
|
36
|
-
}
|
|
37
|
-
/**
|
|
38
|
-
* Unload demo data and reset to disconnected state.
|
|
39
|
-
*/
|
|
40
|
-
function unloadDemoData() {
|
|
41
|
-
setState("entities", reconcile({}));
|
|
42
|
-
setState("entityRegistry", reconcile({}));
|
|
43
|
-
setState("areas", reconcile({}));
|
|
44
|
-
setState("devices", reconcile({}));
|
|
45
|
-
setState("hassUrl", null);
|
|
46
|
-
setState("connectionState", "disconnected");
|
|
47
|
-
_isDemoMode = false;
|
|
48
|
-
}
|
|
49
|
-
/**
|
|
50
|
-
* Apply an optimistic state update for demo mode.
|
|
51
|
-
*
|
|
52
|
-
* When a service call is made in demo mode, this updates the entity
|
|
53
|
-
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
54
|
-
*/
|
|
55
|
-
function applyDemoServiceCall(domain, service, _serviceData = {}, target = {}) {
|
|
56
|
-
if (!_isDemoMode) return;
|
|
57
|
-
const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
|
|
58
|
-
for (const entityId of entityIds) {
|
|
59
|
-
const entity = state.entities[entityId];
|
|
60
|
-
if (!entity) continue;
|
|
61
|
-
const entityDomain = extractDomain(entityId);
|
|
62
|
-
setState(produce((s) => {
|
|
63
|
-
const e = s.entities[entityId];
|
|
64
|
-
if (!e) return;
|
|
65
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
66
|
-
e.last_changed = now;
|
|
67
|
-
e.last_updated = now;
|
|
68
|
-
if (service === "turn_on") applyTurnOn(e, entityDomain, _serviceData);
|
|
69
|
-
else if (service === "turn_off") applyTurnOff(e, entityDomain);
|
|
70
|
-
else if (service === "toggle") if (e.state === "on" || e.state === "playing" || e.state === "open") applyTurnOff(e, entityDomain);
|
|
71
|
-
else applyTurnOn(e, entityDomain, _serviceData);
|
|
72
|
-
else if (service === "lock") e.state = "locked";
|
|
73
|
-
else if (service === "unlock") e.state = "unlocked";
|
|
74
|
-
else if (service === "open_cover") {
|
|
75
|
-
e.state = "open";
|
|
76
|
-
e.attributes.current_position = 100;
|
|
77
|
-
} else if (service === "close_cover") {
|
|
78
|
-
e.state = "closed";
|
|
79
|
-
e.attributes.current_position = 0;
|
|
80
|
-
} else if (service === "set_cover_position" && _serviceData.position != null) {
|
|
81
|
-
e.attributes.current_position = _serviceData.position;
|
|
82
|
-
e.state = _serviceData.position > 0 ? "open" : "closed";
|
|
83
|
-
} else if (service === "set_temperature" && _serviceData.temperature != null) e.attributes.temperature = _serviceData.temperature;
|
|
84
|
-
else if (service === "set_hvac_mode" && _serviceData.hvac_mode != null) e.state = _serviceData.hvac_mode;
|
|
85
|
-
else if (service === "volume_set" && _serviceData.volume_level != null) e.attributes.volume_level = _serviceData.volume_level;
|
|
86
|
-
}));
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
function applyTurnOn(e, domain, serviceData = {}) {
|
|
90
|
-
if (domain === "light") {
|
|
91
|
-
e.state = "on";
|
|
92
|
-
e.attributes.brightness = serviceData.brightness ?? e.attributes.brightness ?? 255;
|
|
93
|
-
if (serviceData.color_temp_kelvin) e.attributes.color_temp_kelvin = serviceData.color_temp_kelvin;
|
|
94
|
-
} else if (domain === "media_player") e.state = "playing";
|
|
95
|
-
else if (domain === "cover") {
|
|
96
|
-
e.state = "open";
|
|
97
|
-
e.attributes.current_position = 100;
|
|
98
|
-
} else e.state = "on";
|
|
99
|
-
}
|
|
100
|
-
function applyTurnOff(e, domain) {
|
|
101
|
-
if (domain === "light") {
|
|
102
|
-
e.state = "off";
|
|
103
|
-
delete e.attributes.brightness;
|
|
104
|
-
} else if (domain === "media_player") e.state = "paused";
|
|
105
|
-
else if (domain === "cover") {
|
|
106
|
-
e.state = "closed";
|
|
107
|
-
e.attributes.current_position = 0;
|
|
108
|
-
} else e.state = "off";
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
//#endregion
|
|
112
|
-
export { applyDemoServiceCall, extractDomain, isDemoMode, loadDemoData, unloadDemoData };
|