@glasshome/sync-layer 0.1.7 → 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.
@@ -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
  */
@@ -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: {
@@ -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;
@@ -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,10 +28,11 @@ 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";
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createDemoFixtures, resetStore, setState, state } from "./demo-data-gELwUOS_.js";
2
+ import { forceResubscribe, setManagerConnection, setResubscribeHandler } from "./subscription-manager-gR0qpDYZ.js";
2
3
  import { callService as callService$1, createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
3
4
  import { produce, reconcile } from "solid-js/store";
4
5
 
@@ -756,60 +757,120 @@ function appendHistoryPoint(entityId, stateValue, attributes, lastUpdated, lastC
756
757
  });
757
758
  }));
758
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
+ }
759
786
 
760
787
  //#endregion
761
788
  //#region src/connection/subscriptions.ts
762
- /** Active subscription cleanup functions (managed externally from store) */
763
- let activeSubscriptions = [];
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
+ }
764
835
  /**
765
836
  * Subscribe to all relevant events from Home Assistant
766
837
  */
767
838
  async function subscribeToUpdates(conn) {
768
839
  await cleanupSubscriptions();
840
+ setManagerConnection(conn);
841
+ setResubscribeHandler(subscribeEntities);
769
842
  const registryUpdatedSub = await conn.subscribeEvents((event) => {
770
843
  handleRegistryUpdate(event);
771
844
  }, "entity_registry_updated");
772
- activeSubscriptions.push(() => registryUpdatedSub());
845
+ registrySubscriptions.push(() => registryUpdatedSub());
773
846
  const deviceRegistrySub = await conn.subscribeEvents((event) => {
774
847
  handleDeviceRegistryUpdate(event);
775
848
  }, "device_registry_updated");
776
- activeSubscriptions.push(() => deviceRegistrySub());
849
+ registrySubscriptions.push(() => deviceRegistrySub());
777
850
  const areaRegistrySub = await conn.subscribeEvents((event) => {
778
851
  handleAreaRegistryUpdate(event);
779
852
  }, "area_registry_updated");
780
- activeSubscriptions.push(() => areaRegistrySub());
853
+ registrySubscriptions.push(() => areaRegistrySub());
781
854
  const floorRegistrySub = await conn.subscribeEvents((event) => {
782
855
  handleFloorRegistryUpdate(event);
783
856
  }, "floor_registry_updated");
784
- activeSubscriptions.push(() => floorRegistrySub());
857
+ registrySubscriptions.push(() => floorRegistrySub());
785
858
  const labelRegistrySub = await conn.subscribeEvents((event) => {
786
859
  handleLabelRegistryUpdate(event);
787
860
  }, "label_registry_updated");
788
- activeSubscriptions.push(() => labelRegistrySub());
861
+ registrySubscriptions.push(() => labelRegistrySub());
789
862
  const statisticsSub = await conn.subscribeEvents((event) => {
790
863
  handleStatisticsUpdate(event);
791
864
  }, "recorder_5min_statistics_generated");
792
- activeSubscriptions.push(() => statisticsSub());
793
- const entityUpdatesSub = await conn.subscribeMessage((message) => {
794
- setState(produce((s) => {
795
- if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
796
- if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
797
- if (message.r) for (const entityId of message.r) delete s.entities[entityId];
798
- }));
799
- if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) maybeAppendHistory(entityId, compressedState);
800
- if (message.c) {
801
- for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) maybeAppendHistoryFromDiff(entityId, diff["+"]);
802
- }
803
- }, { type: "subscribe_entities" });
804
- activeSubscriptions.push(() => entityUpdatesSub());
865
+ registrySubscriptions.push(() => statisticsSub());
805
866
  }
806
867
  /**
807
868
  * Clean up all active subscriptions
808
869
  */
809
870
  async function cleanupSubscriptions() {
810
- const subscriptions = [...activeSubscriptions];
871
+ const subscriptions = [...registrySubscriptions];
811
872
  for (const unsub of subscriptions) await unsub();
812
- activeSubscriptions = [];
873
+ registrySubscriptions = [];
813
874
  }
814
875
  async function handleRegistryUpdate(event) {
815
876
  const { action, entity_id } = event.data;
@@ -848,24 +909,8 @@ async function handleAreaRegistryUpdate(event) {
848
909
  if (!conn) return;
849
910
  try {
850
911
  const areas$1 = await conn.sendMessagePromise({ type: "config/area_registry/list" });
851
- const areaApi = areas$1.find((a) => a.area_id === area_id || a.id === area_id);
852
- if (areaApi) {
853
- const area$1 = {
854
- id: areaApi.area_id || areaApi.id,
855
- name: areaApi.name,
856
- normalized_name: areaApi.normalized_name || areaApi.name.toLowerCase().replace(/\s+/g, "_"),
857
- aliases: Array.isArray(areaApi.aliases) ? areaApi.aliases : [],
858
- floor_id: areaApi.floor_id ?? null,
859
- humidity_entity_id: areaApi.humidity_entity_id ?? null,
860
- icon: areaApi.icon ?? null,
861
- labels: Array.isArray(areaApi.labels) ? areaApi.labels : [],
862
- picture: areaApi.picture ?? null,
863
- temperature_entity_id: areaApi.temperature_entity_id ?? null,
864
- created_at: typeof areaApi.created_at === "number" ? new Date(areaApi.created_at * 1e3).toISOString() : areaApi.created_at,
865
- modified_at: typeof areaApi.modified_at === "number" ? new Date(areaApi.modified_at * 1e3).toISOString() : areaApi.modified_at
866
- };
867
- updateArea(area$1);
868
- }
912
+ const area$1 = areas$1.find((a) => a.id === area_id);
913
+ if (area$1) updateArea(area$1);
869
914
  } catch (error) {
870
915
  console.error("Error fetching area:", error);
871
916
  }
@@ -919,21 +964,39 @@ function timestampToIso(timestamp) {
919
964
  if (typeof timestamp === "string") return timestamp;
920
965
  return new Date(timestamp * 1e3).toISOString();
921
966
  }
922
- function maybeAppendHistory(entityId, compressed) {
967
+ function collectHistoryFromCompressed(points, entityId, compressed) {
923
968
  if (!isHistoryTracked(entityId)) return;
924
969
  const stateVal = compressed.s ?? compressed.state ?? "unknown";
925
970
  const attributes = compressed.a ?? compressed.attributes ?? {};
926
971
  const lu = compressed.lu ?? (compressed.last_updated ? new Date(compressed.last_updated).getTime() / 1e3 : Date.now() / 1e3);
927
972
  const lc = compressed.lc ?? (compressed.last_changed ? new Date(compressed.last_changed).getTime() / 1e3 : void 0);
928
- appendHistoryPoint(entityId, stateVal, attributes, lu, lc);
973
+ points.push({
974
+ entityId,
975
+ stateValue: stateVal,
976
+ attributes,
977
+ lastUpdated: lu,
978
+ lastChanged: lc
979
+ });
929
980
  }
930
- function maybeAppendHistoryFromDiff(entityId, additions) {
981
+ function collectHistoryFromDiff(points, entityId, additions) {
931
982
  if (!isHistoryTracked(entityId)) return;
932
- const entity$1 = state.entities[entityId];
933
- if (!entity$1) return;
983
+ const stateVal = additions.s ?? additions.state;
984
+ if (stateVal === void 0) return;
934
985
  const lu = additions.lu ?? Date.now() / 1e3;
935
986
  const lc = additions.lc;
936
- appendHistoryPoint(entityId, entity$1.state, entity$1.attributes, lu, lc);
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;
937
1000
  }
938
1001
  function applyCompressedState(s, entityId, compressed) {
939
1002
  const stateVal = compressed.s ?? compressed.state ?? "unknown";
@@ -945,7 +1008,16 @@ function applyCompressedState(s, entityId, compressed) {
945
1008
  };
946
1009
  const lastChanged = timestampToIso(compressed.lc ?? compressed.last_changed);
947
1010
  const lastUpdated = timestampToIso(compressed.lu ?? compressed.last_updated);
948
- const entity$1 = {
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] = {
949
1021
  entity_id: entityId,
950
1022
  state: stateVal,
951
1023
  attributes,
@@ -953,18 +1025,32 @@ function applyCompressedState(s, entityId, compressed) {
953
1025
  last_updated: lastUpdated,
954
1026
  context
955
1027
  };
956
- s.entities[entityId] = entity$1;
957
1028
  }
958
1029
  function applyStateDiff(s, entityId, diff) {
959
1030
  const existing = s.entities[entityId];
960
1031
  if (!existing) return;
961
1032
  const additions = diff["+"];
962
1033
  if (additions) {
963
- if (additions.s !== void 0) existing.state = additions.s;
964
- if (additions.a) Object.assign(existing.attributes, additions.a);
965
- if (additions.lc !== void 0) existing.last_changed = timestampToIso(additions.lc);
966
- if (additions.lu !== void 0) existing.last_updated = timestampToIso(additions.lu);
967
- if (additions.c) existing.context = additions.c;
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;
968
1054
  }
969
1055
  const removals = diff["-"];
970
1056
  if (removals?.a) for (const key of removals.a) delete existing.attributes[key];
@@ -1053,6 +1139,7 @@ function setupEventHandlers(conn, options) {
1053
1139
  }));
1054
1140
  if (wasReconnecting) {
1055
1141
  await loadInitialData(conn);
1142
+ await forceResubscribe();
1056
1143
  options.onReconnect?.();
1057
1144
  }
1058
1145
  });
@@ -1097,77 +1184,6 @@ async function loadInitialData(conn) {
1097
1184
  }
1098
1185
  }
1099
1186
 
1100
- //#endregion
1101
- //#region src/entities/index.ts
1102
- /**
1103
- * Get all entity IDs
1104
- */
1105
- function getAllEntityIds() {
1106
- return Object.keys(state.entities);
1107
- }
1108
- /**
1109
- * Get entities by domain
1110
- */
1111
- function getEntitiesByDomain(domain) {
1112
- return Object.keys(state.entities).filter((id) => extractDomain(id) === domain);
1113
- }
1114
- /**
1115
- * Get entities via devices in area
1116
- *
1117
- * Only includes entities that inherit area from device (entity.area_id is null).
1118
- */
1119
- function getEntitiesViaDevices(areaId) {
1120
- const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
1121
- const entityIds = [];
1122
- for (const device of devicesInArea) {
1123
- const deviceEntities = getEntitiesByDevice(device.id);
1124
- for (const entityId of deviceEntities) {
1125
- const registry = state.entityRegistry[entityId];
1126
- if (!registry?.area_id) entityIds.push(entityId);
1127
- }
1128
- }
1129
- return entityIds;
1130
- }
1131
- /**
1132
- * Get entities by area
1133
- *
1134
- * Returns entities with direct area_id assignments and entities that inherit their area from devices.
1135
- */
1136
- function getEntitiesByArea(areaId) {
1137
- const directEntityIds = [];
1138
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
1139
- const deviceEntityIds = getEntitiesViaDevices(areaId);
1140
- return Array.from(new Set([...directEntityIds, ...deviceEntityIds]));
1141
- }
1142
- /**
1143
- * Get entities by device
1144
- */
1145
- function getEntitiesByDevice(deviceId) {
1146
- const entityIds = [];
1147
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.device_id === deviceId) entityIds.push(entityId);
1148
- return entityIds;
1149
- }
1150
- /**
1151
- * Get entities by floor
1152
- */
1153
- function getEntitiesByFloor(floorId) {
1154
- const entityIds = [];
1155
- const areasOnFloor = Object.values(state.areas).filter((area$1) => area$1.floor_id === floorId);
1156
- for (const area$1 of areasOnFloor) {
1157
- const areaEntities = getEntitiesByArea(area$1.id);
1158
- entityIds.push(...areaEntities);
1159
- }
1160
- return entityIds;
1161
- }
1162
- /**
1163
- * Get entities by label
1164
- */
1165
- function getEntitiesByLabel(labelId) {
1166
- const entityIds = [];
1167
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.labels?.includes(labelId)) entityIds.push(entityId);
1168
- return entityIds;
1169
- }
1170
-
1171
1187
  //#endregion
1172
1188
  //#region src/entities/views.ts
1173
1189
  /**
@@ -1253,9 +1269,29 @@ function getEntityViews(entityIds) {
1253
1269
  function getAllEntityViews() {
1254
1270
  return getEntityViews(Object.keys(state.entities));
1255
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
+ }
1256
1281
 
1257
1282
  //#endregion
1258
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
+ }
1259
1295
  /**
1260
1296
  * Build a DeviceView from registry data
1261
1297
  */
@@ -1280,12 +1316,10 @@ function buildDeviceView(device) {
1280
1316
  function buildAreaView(areaId) {
1281
1317
  const area$1 = state.areas[areaId];
1282
1318
  if (!area$1) throw new Error(`Area ${areaId} not found`);
1283
- const directEntityIds = [];
1284
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
1285
- const deviceEntityIds = getEntitiesViaDevices(areaId);
1286
- 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);
1287
1322
  const areaEntities = allEntityIds.length > 0 ? getEntityViews(allEntityIds) : [];
1288
- const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
1289
1323
  const devices = devicesInArea.map((device) => buildDeviceView(device));
1290
1324
  return {
1291
1325
  id: areaId,
@@ -1416,6 +1450,77 @@ function area(areaId) {
1416
1450
  return getAreaView(areaId);
1417
1451
  }
1418
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
+
1419
1524
  //#endregion
1420
1525
  //#region src/query/memoization.ts
1421
1526
  /**
@@ -2174,4 +2279,4 @@ async function refreshForecasts(entityId, types) {
2174
2279
  const VERSION = "0.1.0";
2175
2280
 
2176
2281
  //#endregion
2177
- 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, callService, createDemoFixtures, 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, isDemoMode, isHistoryTracked, loadDemoData, refreshAuth, refreshForecast, refreshForecasts, refreshStream, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startWebRtcSession, state, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, 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 };
@@ -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
- * Direct createMemo over store -- zero overhead, fine-grained reactivity.
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
  /**
@@ -1,34 +1,62 @@
1
- import { createMemo } from "solid-js";
2
- import { buildAreaView, callService, extractDomain, getEntityView, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
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
- * Direct createMemo over store -- zero overhead, fine-grained reactivity.
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
- return createMemo(() => {
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.7",
3
+ "version": "0.1.8",
4
4
  "description": "Type-safe, reactive state synchronization layer for Home Assistant",
5
5
  "repository": {
6
6
  "type": "git",