@glasshome/sync-layer 0.1.11 → 0.1.13

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.
@@ -36,6 +36,13 @@ export declare function getConnection(): Connection | null;
36
36
  * Check if currently connected
37
37
  */
38
38
  export declare function isConnected(): boolean;
39
+ /**
40
+ * Get the underlying socket's connected flag.
41
+ *
42
+ * Exposed so apps can render a connection indicator in the UI. Distinct from
43
+ * `isConnected()` which reflects the manager's internal lifecycle state.
44
+ */
45
+ export declare function getConnectionState(): boolean;
39
46
  /**
40
47
  * Refresh all data
41
48
  */
@@ -175,8 +175,13 @@ export interface EntityView {
175
175
  domain: EntityDomain;
176
176
  /** Current state value */
177
177
  state: string;
178
- /** Entity attributes */
179
- attributes: Record<string, any>;
178
+ /**
179
+ * Entity attributes from Home Assistant, excluding keys that are
180
+ * surfaced as canonical resolved fields elsewhere on EntityView
181
+ * (`deviceClass`, `unitOfMeasurement`, `friendlyName`, `icon`).
182
+ * Use those fields instead of reaching into `attributes`.
183
+ */
184
+ attributes: Omit<Record<string, any>, "device_class" | "unit_of_measurement" | "friendly_name" | "icon">;
180
185
  lastChanged: Date;
181
186
  lastUpdated: Date;
182
187
  /** Event context */
package/dist/index.d.ts CHANGED
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export { addDebugIncomingMessageListener, addDebugOutgoingMessageListener, } from "./connection/adapter";
10
10
  export type { ConnectionOptions } from "./connection/manager";
11
- export { disconnect, getConnection, initConnection, isConnected } from "./connection/manager";
11
+ export { disconnect, getConnection, getConnectionState, initConnection, isConnected, } from "./connection/manager";
12
12
  export { registerEntity } from "./connection/subscription-manager";
13
13
  export type { SyncLayerConnection } from "./connection/types";
14
14
  export type { OAuthOptions } from "./connection/auth";
package/dist/index.js CHANGED
@@ -1154,6 +1154,85 @@ function applyStateDiff(s, entityId, diff) {
1154
1154
 
1155
1155
  //#endregion
1156
1156
  //#region src/connection/manager.ts
1157
+ const HEARTBEAT_INTERVAL_MS = 3e4;
1158
+ const HEARTBEAT_TIMEOUT_MS = 15e3;
1159
+ const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
1160
+ let heartbeatInterval = null;
1161
+ let suspendTimeout = null;
1162
+ let visiblePromiseResolve = null;
1163
+ let visibilityHandler = null;
1164
+ let pageshowHandler = null;
1165
+ function promiseTimeout(ms, promise) {
1166
+ return new Promise((resolve, reject) => {
1167
+ const timer = setTimeout(() => reject(new Error("Promise timeout")), ms);
1168
+ promise.then((value) => {
1169
+ clearTimeout(timer);
1170
+ resolve(value);
1171
+ }, (err) => {
1172
+ clearTimeout(timer);
1173
+ reject(err);
1174
+ });
1175
+ });
1176
+ }
1177
+ function clearSuspendTimeout() {
1178
+ if (suspendTimeout !== null) {
1179
+ clearTimeout(suspendTimeout);
1180
+ suspendTimeout = null;
1181
+ }
1182
+ }
1183
+ function resolveVisiblePromise() {
1184
+ if (visiblePromiseResolve) {
1185
+ visiblePromiseResolve();
1186
+ visiblePromiseResolve = null;
1187
+ }
1188
+ }
1189
+ function startHeartbeat(conn$1) {
1190
+ stopHeartbeat();
1191
+ heartbeatInterval = setInterval(() => {
1192
+ promiseTimeout(HEARTBEAT_TIMEOUT_MS, conn$1.ping()).catch(() => {
1193
+ conn$1.reconnect();
1194
+ });
1195
+ }, HEARTBEAT_INTERVAL_MS);
1196
+ }
1197
+ function stopHeartbeat() {
1198
+ if (heartbeatInterval !== null) {
1199
+ clearInterval(heartbeatInterval);
1200
+ heartbeatInterval = null;
1201
+ }
1202
+ }
1203
+ function setupLifecycleListeners(conn$1) {
1204
+ if (typeof document === "undefined" || typeof window === "undefined") return;
1205
+ teardownLifecycleListeners();
1206
+ visibilityHandler = () => {
1207
+ if (document.visibilityState === "hidden") {
1208
+ const pending = new Promise((resolve) => {
1209
+ visiblePromiseResolve = resolve;
1210
+ });
1211
+ conn$1.suspendReconnectUntil(pending);
1212
+ clearSuspendTimeout();
1213
+ suspendTimeout = setTimeout(() => {
1214
+ conn$1.suspend();
1215
+ suspendTimeout = null;
1216
+ }, SUSPEND_DELAY_MS);
1217
+ } else {
1218
+ clearSuspendTimeout();
1219
+ resolveVisiblePromise();
1220
+ }
1221
+ };
1222
+ pageshowHandler = (event) => {
1223
+ if (event.persisted) conn$1.reconnect();
1224
+ };
1225
+ document.addEventListener("visibilitychange", visibilityHandler);
1226
+ window.addEventListener("pageshow", pageshowHandler);
1227
+ }
1228
+ function teardownLifecycleListeners() {
1229
+ if (typeof document !== "undefined" && visibilityHandler) document.removeEventListener("visibilitychange", visibilityHandler);
1230
+ if (typeof window !== "undefined" && pageshowHandler) window.removeEventListener("pageshow", pageshowHandler);
1231
+ visibilityHandler = null;
1232
+ pageshowHandler = null;
1233
+ clearSuspendTimeout();
1234
+ resolveVisiblePromise();
1235
+ }
1157
1236
  /**
1158
1237
  * Initialize connection to Home Assistant
1159
1238
  */
@@ -1180,6 +1259,8 @@ async function initConnection(options) {
1180
1259
  setupEventHandlers(conn$1, options);
1181
1260
  await loadInitialData(conn$1);
1182
1261
  await subscribeToUpdates(wrappedConn);
1262
+ setupLifecycleListeners(conn$1);
1263
+ startHeartbeat(conn$1);
1183
1264
  options.onConnect?.();
1184
1265
  return conn$1;
1185
1266
  } catch (error) {
@@ -1197,6 +1278,8 @@ async function initConnection(options) {
1197
1278
  */
1198
1279
  function disconnect() {
1199
1280
  const conn$1 = state.conn;
1281
+ stopHeartbeat();
1282
+ teardownLifecycleListeners();
1200
1283
  if (conn$1) conn$1.close();
1201
1284
  setState(produce((s) => {
1202
1285
  s.conn = null;
@@ -1215,6 +1298,16 @@ function getConnection() {
1215
1298
  function isConnected() {
1216
1299
  return state.connectionState === "connected";
1217
1300
  }
1301
+ /**
1302
+ * Get the underlying socket's connected flag.
1303
+ *
1304
+ * Exposed so apps can render a connection indicator in the UI. Distinct from
1305
+ * `isConnected()` which reflects the manager's internal lifecycle state.
1306
+ */
1307
+ function getConnectionState() {
1308
+ const conn$1 = state.conn;
1309
+ return conn$1 ? conn$1.connected : false;
1310
+ }
1218
1311
  function setupEventHandlers(conn$1, options) {
1219
1312
  conn$1.addEventListener("disconnected", () => {
1220
1313
  setState(produce((s) => {
@@ -1304,6 +1397,7 @@ function buildEntityView(entity$1, registry) {
1304
1397
  const friendlyName = entity$1.attributes.friendly_name ?? registry?.name ?? entity$1.entity_id;
1305
1398
  const name = registry?.name ?? friendlyName;
1306
1399
  const iconResolution = resolveIcon(entity$1, registry);
1400
+ const { device_class: _dc, unit_of_measurement: _uom, friendly_name: _fn, icon: _icon,...restAttributes } = entity$1.attributes;
1307
1401
  let areaId = registry?.area_id ?? null;
1308
1402
  if (areaId === null && registry?.device_id) {
1309
1403
  const device = state.devices[registry.device_id];
@@ -1313,7 +1407,7 @@ function buildEntityView(entity$1, registry) {
1313
1407
  id: entity$1.entity_id,
1314
1408
  domain,
1315
1409
  state: entity$1.state,
1316
- attributes: entity$1.attributes,
1410
+ attributes: restAttributes,
1317
1411
  lastChanged: new Date(entity$1.last_changed),
1318
1412
  lastUpdated: new Date(entity$1.last_updated),
1319
1413
  context: {
@@ -2416,4 +2510,4 @@ async function refreshForecasts(entityId, types) {
2416
2510
  const VERSION = "0.1.0";
2417
2511
 
2418
2512
  //#endregion
2419
- 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, registerEntity, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startWebRtcSession, state, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
2513
+ 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, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, refreshAuth, refreshForecast, refreshForecasts, refreshStream, registerEntity, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startWebRtcSession, state, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
@@ -179,9 +179,6 @@ function useStore(selector) {
179
179
 
180
180
  //#endregion
181
181
  //#region src/solid/indices.ts
182
- /**
183
- * Group items by a key function
184
- */
185
182
  function groupBy(items, keyFn) {
186
183
  const result = {};
187
184
  for (const item of items) {
@@ -190,26 +187,14 @@ function groupBy(items, keyFn) {
190
187
  }
191
188
  return result;
192
189
  }
193
- /**
194
- * Create a lazily-initialized memo. The actual createMemo runs on first
195
- * access, inside whichever Solid root is current at that point.
196
- * This avoids top-level createMemo calls that execute outside any root.
197
- */
198
- function lazyMemo(fn) {
199
- let memo;
200
- return () => {
201
- if (!memo) memo = createMemo(fn);
202
- return memo();
203
- };
204
- }
205
190
  /** Entity IDs grouped by domain (e.g., "light" -> ["light.living_room", ...]) */
206
- const byDomain = lazyMemo(() => groupBy(Object.keys(state.entities), (id) => extractDomain(id)));
191
+ const byDomain = () => groupBy(Object.keys(state.entities), (id) => extractDomain(id));
207
192
  /** Entity IDs grouped by area (via entity registry area_id) */
208
- const byArea = lazyMemo(() => groupBy(Object.keys(state.entityRegistry), (id) => state.entityRegistry[id]?.area_id ?? null));
193
+ const byArea = () => groupBy(Object.keys(state.entityRegistry), (id) => state.entityRegistry[id]?.area_id ?? null);
209
194
  /** Entity IDs grouped by device (via entity registry device_id) */
210
- const byDevice = lazyMemo(() => groupBy(Object.keys(state.entityRegistry), (id) => state.entityRegistry[id]?.device_id ?? null));
195
+ const byDevice = () => groupBy(Object.keys(state.entityRegistry), (id) => state.entityRegistry[id]?.device_id ?? null);
211
196
  /** Entity IDs grouped by floor (device -> area -> floor chain) */
212
- const byFloor = lazyMemo(() => {
197
+ const byFloor = () => {
213
198
  const result = {};
214
199
  for (const [entityId, reg] of Object.entries(state.entityRegistry)) if (reg.device_id) {
215
200
  const device = state.devices[reg.device_id];
@@ -219,15 +204,15 @@ const byFloor = lazyMemo(() => {
219
204
  }
220
205
  }
221
206
  return result;
222
- });
207
+ };
223
208
  /** Entity IDs grouped by label */
224
- const byLabel = lazyMemo(() => {
209
+ const byLabel = () => {
225
210
  const result = {};
226
211
  for (const [entityId, reg] of Object.entries(state.entityRegistry)) for (const label of reg.labels ?? []) (result[label] ??= []).push(entityId);
227
212
  return result;
228
- });
213
+ };
229
214
  /** All entity IDs currently in the store */
230
- const allEntityIds = lazyMemo(() => Object.keys(state.entities));
215
+ const allEntityIds = () => Object.keys(state.entities);
231
216
 
232
217
  //#endregion
233
218
  export { allEntityIds, byArea, byDevice, byDomain, byFloor, byLabel, useArea, useAreas, useCamera, useConnection, useEntities, useEntity, useEntityHistory, useEntityState, useForecast, useService, useStore, useToggle, useTurnOff, useTurnOn };
@@ -1,4 +1,5 @@
1
- /** Reactive derived entity indices — lazily created inside the caller's root. */
1
+ /** Reactive derived entity indices. Each call reads from the SolidJS store directly,
2
+ * so consumers wrap in createMemo within their own reactive root for memoization. */
2
3
  import type { Accessor } from "solid-js";
3
4
  /** Entity IDs grouped by domain (e.g., "light" -> ["light.living_room", ...]) */
4
5
  export declare const byDomain: Accessor<Record<string, string[]>>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/sync-layer",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Type-safe, reactive state synchronization layer for Home Assistant",
5
5
  "repository": {
6
6
  "type": "git",