@glasshome/sync-layer 0.2.2 → 0.3.1

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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Type-safe, reactive state synchronization layer for Home Assistant.
4
4
 
5
- Provides connection management, entity querying, service calls, and reactive state via a SolidJS store — all with full TypeScript types powered by `@glasshome/ha-types`.
5
+ Provides connection management, entity querying, service calls, and reactive state via a SolidJS store — all with full TypeScript types powered by `@glasshome/ha-types`. Part of [GlassHome](https://glasshome.app), the local-first dashboard for Home Assistant.
6
6
 
7
7
  ## Install
8
8
 
@@ -7,7 +7,7 @@ import type { HaBridge } from "../worker/bridge-client";
7
7
  *
8
8
  * Call after bridge.connect() resolved. Returns when initial data is loaded.
9
9
  */
10
- export declare function attachBridgeToStore(bridge: HaBridge, hassUrl: string): Promise<void>;
10
+ export declare function attachBridgeToStore(bridge: HaBridge, hassUrl: string, mediaProxyBase?: string): Promise<void>;
11
11
  /** Map worker connection-state messages onto the store. */
12
12
  export declare function applyBridgeConnState(state: "connected" | "disconnected" | "reconnecting"): void;
13
13
  /** Re-sync after the worker reports a completed reconnect. */
@@ -32,6 +32,9 @@ export interface GlassHomeState {
32
32
  connectionError: Error | null;
33
33
  /** Home Assistant base URL */
34
34
  hassUrl: string | null;
35
+ /** Same-origin prefix for the HA media proxy (e.g. "/ha-proxy/media"), or null
36
+ * to resolve media directly against hassUrl. Set by the host at connect. */
37
+ mediaProxyBase: string | null;
35
38
  /** Home Assistant core config (unit system, locale, currency); null until loaded */
36
39
  config: import("@glasshome/ha-types").HassConfig | null;
37
40
  /** Live entity state (keyed by entity_id) */
@@ -6,6 +6,7 @@ const initialState = {
6
6
  connectionState: "disconnected",
7
7
  connectionError: null,
8
8
  hassUrl: null,
9
+ mediaProxyBase: null,
9
10
  config: null,
10
11
  entities: {},
11
12
  entityRegistry: {},
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * Area View Builder
3
3
  *
4
- * Constructs unified AreaView objects by combining registry data
5
- * with computed entities and devices using the query engine.
4
+ * Constructs unified AreaView objects by combining registry data with computed
5
+ * entities and devices.
6
+ *
7
+ * Membership (which entities/devices belong to an area) is registry-driven and
8
+ * cheap; live entity *views* read entity state and are therefore exposed as a
9
+ * lazy `entities` getter. Callers that only need names, ids, or counts never
10
+ * touch entity state, so building the whole-area list does not recompute on
11
+ * every entity state tick.
6
12
  *
7
13
  * @packageDocumentation
8
14
  */
@@ -12,11 +18,12 @@ import type { AreaId, AreaView, DeviceRegistryEntry, DeviceView } from "../core/
12
18
  */
13
19
  export declare function buildDeviceView(device: DeviceRegistryEntry): DeviceView;
14
20
  /**
15
- * Build an AreaView from registry data using query engine
21
+ * Build an AreaView for a single area from registry data.
16
22
  */
17
23
  export declare function buildAreaView(areaId: AreaId): AreaView;
18
24
  /**
19
- * Get all area views
25
+ * Get all area views. Membership is resolved in a single registry pass; entity
26
+ * views stay lazy per area (see `assembleAreaView`).
20
27
  */
21
28
  export declare function getAreaViews(): AreaView[];
22
29
  /**
package/dist/index.d.ts CHANGED
@@ -27,6 +27,7 @@ export type { CallServiceRequest, CoreEventType, Domain, EntityCategory, EntityR
27
27
  export { fetchStream, fetchStreamData } from "./camera/fetch";
28
28
  export { getStream, refreshStream } from "./camera/query";
29
29
  export { fetchEnergyPreferences } from "./energy/prefs";
30
+ export { hassMediaUrl } from "./media/url";
30
31
  export type { EnergyDeviceConsumption, EnergyPreferences, EnergySource, } from "./energy/prefs";
31
32
  export type { CameraStream, CameraStreamData, CameraStreams, EntityStreamQueryOptions, StreamFormat, StreamQueryOptions, StreamResult, } from "./camera/types";
32
33
  export { getWebRtcClientConfig, sendWebRtcCandidate, startWebRtcSession, } from "./camera/webrtc";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { ENERGY_ENTITY_IDS, createDemoFixtures, demoEnergyPreferences, energyEntityValue, formatEnergyState, isEnergyEntity, isSunUp, resetStore, setState, simulateEnergy, state, sunEvents, synthesizeEnergyHistory, synthesizeEnergyStatistics } from "./demo-data-CavpxBw3.js";
2
- import { runHaBridgeWorker } from "./worker-main-BYGgbA7k.js";
1
+ import { ENERGY_ENTITY_IDS, createDemoFixtures, demoEnergyPreferences, energyEntityValue, formatEnergyState, isEnergyEntity, isSunUp, resetStore, setState, simulateEnergy, state, sunEvents, synthesizeEnergyHistory, synthesizeEnergyStatistics } from "./demo-data-DiGSP4EH.js";
2
+ import { runHaBridgeWorker } from "./worker-main-DTbzEKor.js";
3
3
  import { createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
4
4
  import { produce, reconcile } from "solid-js/store";
5
5
 
@@ -1568,16 +1568,46 @@ function entityViewEquals(a, b) {
1568
1568
 
1569
1569
  //#endregion
1570
1570
  //#region src/entities/area-views.ts
1571
- function getDevicesForArea(areaId) {
1572
- const result = [];
1573
- for (const device of Object.values(state.devices)) if (device.area_id === areaId) result.push(device);
1574
- return result;
1571
+ /** The area an entity belongs to: its own `area_id` wins; otherwise it inherits
1572
+ * the area of its device. Null when neither resolves. */
1573
+ function entityAreaId(entry) {
1574
+ if (entry.area_id) return entry.area_id;
1575
+ if (entry.device_id) return state.devices[entry.device_id]?.area_id ?? null;
1576
+ return null;
1577
+ }
1578
+ /** One pass over the device + entity registries → membership per area.
1579
+ * O(devices + entities) for the whole home, vs the O(areas × (devices +
1580
+ * entities)) of resolving each area independently. */
1581
+ function computeAreaMembership() {
1582
+ const byArea = /* @__PURE__ */ new Map();
1583
+ const ensure = (areaId) => {
1584
+ let m = byArea.get(areaId);
1585
+ if (!m) {
1586
+ m = {
1587
+ devices: [],
1588
+ entityIds: []
1589
+ };
1590
+ byArea.set(areaId, m);
1591
+ }
1592
+ return m;
1593
+ };
1594
+ for (const device of Object.values(state.devices)) if (device.area_id) ensure(device.area_id).devices.push(device);
1595
+ for (const [entityId, entry] of Object.entries(state.entityRegistry)) {
1596
+ const areaId = entityAreaId(entry);
1597
+ if (areaId) ensure(areaId).entityIds.push(entityId);
1598
+ }
1599
+ return byArea;
1575
1600
  }
1576
- function getEntityIdsForArea(areaId, deviceIdsInArea) {
1601
+ /** Membership for a single area, without indexing every other area. */
1602
+ function membershipFor(areaId) {
1603
+ const devices = [];
1604
+ for (const device of Object.values(state.devices)) if (device.area_id === areaId) devices.push(device);
1577
1605
  const entityIds = [];
1578
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) entityIds.push(entityId);
1579
- else if (!entry.area_id && entry.device_id && deviceIdsInArea.has(entry.device_id)) entityIds.push(entityId);
1580
- return entityIds;
1606
+ for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entityAreaId(entry) === areaId) entityIds.push(entityId);
1607
+ return {
1608
+ devices,
1609
+ entityIds
1610
+ };
1581
1611
  }
1582
1612
  /**
1583
1613
  * Build a DeviceView from registry data
@@ -1597,17 +1627,16 @@ function buildDeviceView(device) {
1597
1627
  disabledBy: device.disabled_by ?? null
1598
1628
  };
1599
1629
  }
1600
- /**
1601
- * Build an AreaView from registry data using query engine
1602
- */
1603
- function buildAreaView(areaId) {
1630
+ /** Assemble an AreaView from pre-resolved membership. `entities` is a lazy
1631
+ * getter: materializing live EntityViews reads entity state, so deferring it
1632
+ * keeps callers that only need names/ids/counts off the per-state-tick
1633
+ * recompute path. The getter stays reactive — read inside a tracking scope it
1634
+ * tracks those entities' state and updates like any other store read. */
1635
+ function assembleAreaView(areaId, membership) {
1604
1636
  const area$1 = state.areas[areaId];
1605
1637
  if (!area$1) throw new Error(`Area ${areaId} not found`);
1606
- const devicesInArea = getDevicesForArea(areaId);
1607
- const deviceIdsInArea = new Set(devicesInArea.map((d) => d.id));
1608
- const allEntityIds = getEntityIdsForArea(areaId, deviceIdsInArea);
1609
- const areaEntities = allEntityIds.length > 0 ? getEntityViews(allEntityIds) : [];
1610
- const devices = devicesInArea.map((device) => buildDeviceView(device));
1638
+ const devices = membership.devices.map((device) => buildDeviceView(device));
1639
+ const entityIds = membership.entityIds;
1611
1640
  return {
1612
1641
  id: areaId,
1613
1642
  name: area$1.name,
@@ -1621,17 +1650,30 @@ function buildAreaView(areaId) {
1621
1650
  humidityEntityId: area$1.humidity_entity_id ?? null,
1622
1651
  createdAt: area$1.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1623
1652
  modifiedAt: area$1.modified_at ?? (/* @__PURE__ */ new Date()).toISOString(),
1624
- entities: areaEntities,
1625
1653
  devices,
1626
- entityIds: allEntityIds,
1627
- deviceIds: devices.map((d) => d.id)
1654
+ entityIds,
1655
+ deviceIds: devices.map((d) => d.id),
1656
+ get entities() {
1657
+ return entityIds.length > 0 ? getEntityViews(entityIds) : [];
1658
+ }
1628
1659
  };
1629
1660
  }
1630
1661
  /**
1631
- * Get all area views
1662
+ * Build an AreaView for a single area from registry data.
1663
+ */
1664
+ function buildAreaView(areaId) {
1665
+ return assembleAreaView(areaId, membershipFor(areaId));
1666
+ }
1667
+ /**
1668
+ * Get all area views. Membership is resolved in a single registry pass; entity
1669
+ * views stay lazy per area (see `assembleAreaView`).
1632
1670
  */
1633
1671
  function getAreaViews() {
1634
- return Object.keys(state.areas).map((areaId) => buildAreaView(areaId));
1672
+ const membership = computeAreaMembership();
1673
+ return Object.keys(state.areas).map((areaId) => assembleAreaView(areaId, membership.get(areaId) ?? {
1674
+ devices: [],
1675
+ entityIds: []
1676
+ }));
1635
1677
  }
1636
1678
  /**
1637
1679
  * Get area view
@@ -2298,6 +2340,30 @@ async function fetchEnergyPreferences(connection) {
2298
2340
  };
2299
2341
  }
2300
2342
 
2343
+ //#endregion
2344
+ //#region src/media/url.ts
2345
+ /**
2346
+ * Resolve an HA media reference (e.g. an entity's `entity_picture`, a camera
2347
+ * proxy path) to a URL the page can actually load.
2348
+ *
2349
+ * HA gives these as origin-relative paths (`/api/media_player_proxy/...?token=`)
2350
+ * carrying their own signed token. A bare `<img src>` would resolve them against
2351
+ * the dash origin and 404. When the host set `mediaProxyBase`, route through the
2352
+ * same-origin media proxy so the page CSP (`img-src 'self'`) covers it in every
2353
+ * mode, LAN and remote. Otherwise fall back to prefixing `hassUrl` directly
2354
+ * (the LAN-only path, used until a transport sets the proxy base).
2355
+ *
2356
+ * Absolute URLs and non-path values pass through untouched.
2357
+ */
2358
+ function hassMediaUrl(path) {
2359
+ if (!path) return void 0;
2360
+ if (/^https?:\/\//i.test(path)) return path;
2361
+ if (!path.startsWith("/")) return path;
2362
+ const base = state.mediaProxyBase;
2363
+ if (base) return `${base}?path=${encodeURIComponent(path)}`;
2364
+ return state.hassUrl ? `${state.hassUrl}${path}` : path;
2365
+ }
2366
+
2301
2367
  //#endregion
2302
2368
  //#region src/camera/webrtc.ts
2303
2369
  /**
@@ -2720,10 +2786,11 @@ async function refreshForecasts(entityId, types) {
2720
2786
  *
2721
2787
  * Call after bridge.connect() resolved. Returns when initial data is loaded.
2722
2788
  */
2723
- async function attachBridgeToStore(bridge, hassUrl) {
2789
+ async function attachBridgeToStore(bridge, hassUrl, mediaProxyBase) {
2724
2790
  setState(produce((s) => {
2725
2791
  s.conn = bridge.conn;
2726
2792
  s.hassUrl = hassUrl.replace(/\/$/, "");
2793
+ s.mediaProxyBase = mediaProxyBase ?? null;
2727
2794
  s.connectionState = "connected";
2728
2795
  s.connectionError = null;
2729
2796
  }));
@@ -2919,4 +2986,4 @@ function createHaBridge(worker, events = {}) {
2919
2986
  const VERSION = "0.2.1";
2920
2987
 
2921
2988
  //#endregion
2922
- export { BridgeNeedsAuthError, DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyBridgeConnState, applyDemoServiceCall, area, areas, attachBridgeToStore, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, createHaBridge, detachBridgeFromStore, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, normalizeStatisticTime, refreshAuth, refreshForecast, refreshForecasts, refreshStream, registerEntity, reloadAfterBridgeReconnect, resetStore, runHaBridgeWorker, searchEntities, sendCommand, sendWebRtcCandidate, setState, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
2989
+ export { BridgeNeedsAuthError, DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyBridgeConnState, applyDemoServiceCall, area, areas, attachBridgeToStore, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, createHaBridge, detachBridgeFromStore, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, hassMediaUrl, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, normalizeStatisticTime, refreshAuth, refreshForecast, refreshForecasts, refreshStream, registerEntity, reloadAfterBridgeReconnect, resetStore, runHaBridgeWorker, searchEntities, sendCommand, sendWebRtcCandidate, setState, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Resolve an HA media reference (e.g. an entity's `entity_picture`, a camera
3
+ * proxy path) to a URL the page can actually load.
4
+ *
5
+ * HA gives these as origin-relative paths (`/api/media_player_proxy/...?token=`)
6
+ * carrying their own signed token. A bare `<img src>` would resolve them against
7
+ * the dash origin and 404. When the host set `mediaProxyBase`, route through the
8
+ * same-origin media proxy so the page CSP (`img-src 'self'`) covers it in every
9
+ * mode, LAN and remote. Otherwise fall back to prefixing `hassUrl` directly
10
+ * (the LAN-only path, used until a transport sets the proxy base).
11
+ *
12
+ * Absolute URLs and non-path values pass through untouched.
13
+ */
14
+ export declare function hassMediaUrl(path: string | null | undefined): string | undefined;
@@ -1,5 +1,5 @@
1
1
  import { createEffect, createMemo, createResource, onCleanup } from "solid-js";
2
- import { buildAreaView, callService, entityViewEquals, extractDomain, fetchStatisticsDuringPeriod, getEntityView, isDemoMode, registerEntity, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
2
+ import { buildAreaView, callService, entityViewEquals, extractDomain, fetchStatisticsDuringPeriod, getAreaViews, getEntityView, isDemoMode, registerEntity, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
3
3
 
4
4
  //#region src/solid/hooks.ts
5
5
  /**
@@ -116,21 +116,13 @@ function useToggle() {
116
116
  * Get reactive list of all area views.
117
117
  */
118
118
  function useAreas() {
119
- return createMemo(() => {
120
- return Object.keys(state.areas).map((areaId) => {
121
- try {
122
- return buildAreaView(areaId);
123
- } catch {
124
- return void 0;
125
- }
126
- }).filter((v) => v !== void 0);
127
- }, void 0, { equals: (a, b) => {
119
+ return createMemo(() => getAreaViews(), void 0, { equals: (a, b) => {
128
120
  if (a.length !== b.length) return false;
129
121
  for (let i = 0; i < a.length; i++) {
130
122
  const aItem = a[i];
131
123
  const bItem = b[i];
132
124
  if (!aItem || !bItem) return false;
133
- if (aItem.id !== bItem.id || aItem.modifiedAt !== bItem.modifiedAt || aItem.entities.length !== bItem.entities.length) return false;
125
+ if (aItem.id !== bItem.id || aItem.modifiedAt !== bItem.modifiedAt || aItem.entityIds.length !== bItem.entityIds.length) return false;
134
126
  }
135
127
  return true;
136
128
  } });
@@ -1,4 +1,4 @@
1
- import { createDemoFixtures, state } from "../demo-data-CavpxBw3.js";
1
+ import { createDemoFixtures, state } from "../demo-data-DiGSP4EH.js";
2
2
 
3
3
  //#region src/testing/mock-behaviors/base.ts
4
4
  /**
@@ -1,3 +1,3 @@
1
- import { runHaBridgeWorker } from "../worker-main-BYGgbA7k.js";
1
+ import { runHaBridgeWorker } from "../worker-main-DTbzEKor.js";
2
2
 
3
3
  export { runHaBridgeWorker };
@@ -128,6 +128,18 @@ const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
128
128
  function isHaWireMessage(value) {
129
129
  return typeof value === "object" && value !== null && typeof value.type === "string";
130
130
  }
131
+ /** Extract a readable message from a thrown value. HA's websocket client rejects
132
+ * with a plain `{ code, message }` object, not an Error, so `String(err)` would
133
+ * yield "[object Object]" and hide the real reason. */
134
+ function errText(err) {
135
+ if (err instanceof Error) return err.message;
136
+ if (typeof err === "string") return err;
137
+ if (err && typeof err === "object" && "message" in err) {
138
+ const m = err.message;
139
+ if (typeof m === "string") return m;
140
+ }
141
+ return String(err);
142
+ }
131
143
  var BrokeredAuth = class extends Auth {
132
144
  constructor(mintUrl, data, ticket) {
133
145
  super(data);
@@ -243,7 +255,7 @@ function runHaBridgeWorker(scope) {
243
255
  id: call.id,
244
256
  ok: false,
245
257
  code: "ERROR",
246
- message: err instanceof Error ? err.message : String(err)
258
+ message: errText(err)
247
259
  });
248
260
  }
249
261
  };
@@ -325,7 +337,7 @@ function runHaBridgeWorker(scope) {
325
337
  post({
326
338
  k: "connect_result",
327
339
  ok: false,
328
- error: err instanceof Error ? err.message : String(err)
340
+ error: errText(err)
329
341
  });
330
342
  return;
331
343
  }
@@ -346,7 +358,7 @@ function runHaBridgeWorker(scope) {
346
358
  post({
347
359
  k: "connect_result",
348
360
  ok: false,
349
- error: err instanceof Error ? err.message : String(err)
361
+ error: errText(err)
350
362
  });
351
363
  return;
352
364
  }
@@ -410,7 +422,7 @@ function runHaBridgeWorker(scope) {
410
422
  k: "result",
411
423
  id: msg.id,
412
424
  ok: false,
413
- error: err instanceof Error ? err.message : String(err)
425
+ error: errText(err)
414
426
  });
415
427
  }
416
428
  break;
@@ -448,7 +460,7 @@ function runHaBridgeWorker(scope) {
448
460
  post({
449
461
  k: "sub_end",
450
462
  id: msg.id,
451
- error: err instanceof Error ? err.message : String(err)
463
+ error: errText(err)
452
464
  });
453
465
  }
454
466
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/sync-layer",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Type-safe, reactive state synchronization layer for Home Assistant",
5
5
  "repository": {
6
6
  "type": "git",