@glasshome/sync-layer 0.1.8 → 0.1.10

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.
@@ -10,6 +10,9 @@
10
10
  import type { EntityId } from "../core/types";
11
11
  import type { SyncLayerConnection } from "./types";
12
12
  type UnsubscribeFn = () => Promise<void> | void;
13
+ declare global {
14
+ var __GH_SYNC_LAYER_SUB_MGR__: boolean | undefined;
15
+ }
13
16
  /**
14
17
  * Register an entity for live updates. Returns an unregister function.
15
18
  * Call from useEntity/useEntities hooks via onCleanup.
@@ -46,6 +46,31 @@ export interface GlassHomeState {
46
46
  /** Camera stream data (keyed by entity ID) */
47
47
  streams: Record<EntityId, CameraStreamData>;
48
48
  }
49
+ /**
50
+ * Module-level SolidJS createStore singleton
51
+ *
52
+ * `state` is a read-only reactive proxy.
53
+ * `setState` is used for mutations via produce() or direct path setting.
54
+ *
55
+ * @example
56
+ * ```typescript
57
+ * import { state, setState } from '@glasshome/sync-layer/core/store';
58
+ *
59
+ * // Read state (auto-tracked in SolidJS reactive contexts)
60
+ * const entities = state.entities;
61
+ *
62
+ * // Mutate via produce
63
+ * setState(produce((s) => {
64
+ * s.entities['light.living_room'] = newEntity;
65
+ * }));
66
+ *
67
+ * // Bulk replace
68
+ * setState("entities", reconcile(newEntities));
69
+ * ```
70
+ */
71
+ declare global {
72
+ var __GH_SYNC_LAYER_STORE__: boolean | undefined;
73
+ }
49
74
  export declare const state: Store<GlassHomeState>;
50
75
  export declare const setState: SetStoreFunction<GlassHomeState>;
51
76
  /**
@@ -27,7 +27,7 @@ export declare function unloadDemoData(): void;
27
27
  * When a service call is made in demo mode, this updates the entity
28
28
  * state locally (no WebSocket message sent). State resets on page refresh.
29
29
  */
30
- export declare function applyDemoServiceCall(domain: string, service: string, _serviceData?: Record<string, any>, target?: {
30
+ export declare function applyDemoServiceCall(_domain: string, service: string, _serviceData?: Record<string, any>, target?: {
31
31
  entity_id?: string | string[];
32
32
  }): void;
33
33
  export { createDemoFixtures } from "./demo-data";
@@ -18,28 +18,8 @@ const initialState = {
18
18
  forecasts: {},
19
19
  streams: {}
20
20
  };
21
- /**
22
- * Module-level SolidJS createStore singleton
23
- *
24
- * `state` is a read-only reactive proxy.
25
- * `setState` is used for mutations via produce() or direct path setting.
26
- *
27
- * @example
28
- * ```typescript
29
- * import { state, setState } from '@glasshome/sync-layer/core/store';
30
- *
31
- * // Read state (auto-tracked in SolidJS reactive contexts)
32
- * const entities = state.entities;
33
- *
34
- * // Mutate via produce
35
- * setState(produce((s) => {
36
- * s.entities['light.living_room'] = newEntity;
37
- * }));
38
- *
39
- * // Bulk replace
40
- * setState("entities", reconcile(newEntities));
41
- * ```
42
- */
21
+ if (globalThis.__GH_SYNC_LAYER_STORE__) console.error("[@glasshome/sync-layer] DUPLICATE store module instantiated. This means @glasshome/sync-layer was bundled into >1 chunk. Reads and writes will hit different singletons. Check vendor build externals and relative-vs-bare imports across entry points.");
22
+ globalThis.__GH_SYNC_LAYER_STORE__ = true;
43
23
  const storeTuple = createStore(initialState);
44
24
  const state = storeTuple[0];
45
25
  const setState = storeTuple[1];
@@ -74,6 +54,202 @@ function makeEntity(entity_id, state$1, attributes = {}) {
74
54
  context: ctx()
75
55
  };
76
56
  }
57
+ /**
58
+ * One entity per weather scene the widget can render. Entity IDs use the
59
+ * `weather.demo_<slug>` form so a demo dashboard can reference them directly.
60
+ */
61
+ const WEATHER_FIXTURES = [
62
+ {
63
+ slug: "sunny",
64
+ state: "sunny",
65
+ temp: 28,
66
+ apparent: 31,
67
+ humidity: 35,
68
+ pressure: 1018,
69
+ wind: 8,
70
+ low: 18
71
+ },
72
+ {
73
+ slug: "clear_night",
74
+ state: "clear-night",
75
+ temp: 14,
76
+ apparent: 12,
77
+ humidity: 55,
78
+ pressure: 1016,
79
+ wind: 5,
80
+ low: 9
81
+ },
82
+ {
83
+ slug: "cloudy",
84
+ state: "cloudy",
85
+ temp: 17,
86
+ apparent: 16,
87
+ humidity: 72,
88
+ pressure: 1010,
89
+ wind: 14,
90
+ low: 11
91
+ },
92
+ {
93
+ slug: "partly_cloudy",
94
+ state: "partlycloudy",
95
+ temp: 23,
96
+ apparent: 24,
97
+ humidity: 50,
98
+ pressure: 1014,
99
+ wind: 12,
100
+ low: 15
101
+ },
102
+ {
103
+ slug: "rainy",
104
+ state: "rainy",
105
+ temp: 12,
106
+ apparent: 10,
107
+ humidity: 88,
108
+ pressure: 1004,
109
+ wind: 18,
110
+ low: 8
111
+ },
112
+ {
113
+ slug: "pouring",
114
+ state: "pouring",
115
+ temp: 11,
116
+ apparent: 8,
117
+ humidity: 95,
118
+ pressure: 998,
119
+ wind: 26,
120
+ low: 7
121
+ },
122
+ {
123
+ slug: "snowy",
124
+ state: "snowy",
125
+ temp: -2,
126
+ apparent: -6,
127
+ humidity: 80,
128
+ pressure: 1020,
129
+ wind: 10,
130
+ low: -7
131
+ },
132
+ {
133
+ slug: "snowy_rainy",
134
+ state: "snowy-rainy",
135
+ temp: 1,
136
+ apparent: -2,
137
+ humidity: 92,
138
+ pressure: 1006,
139
+ wind: 16,
140
+ low: -2
141
+ },
142
+ {
143
+ slug: "lightning",
144
+ state: "lightning",
145
+ temp: 22,
146
+ apparent: 24,
147
+ humidity: 78,
148
+ pressure: 1001,
149
+ wind: 22,
150
+ low: 17
151
+ },
152
+ {
153
+ slug: "lightning_rainy",
154
+ state: "lightning-rainy",
155
+ temp: 19,
156
+ apparent: 18,
157
+ humidity: 90,
158
+ pressure: 996,
159
+ wind: 28,
160
+ low: 14
161
+ },
162
+ {
163
+ slug: "fog",
164
+ state: "fog",
165
+ temp: 8,
166
+ apparent: 6,
167
+ humidity: 98,
168
+ pressure: 1015,
169
+ wind: 4,
170
+ low: 6
171
+ },
172
+ {
173
+ slug: "hail",
174
+ state: "hail",
175
+ temp: 6,
176
+ apparent: 3,
177
+ humidity: 84,
178
+ pressure: 1002,
179
+ wind: 20,
180
+ low: 1
181
+ },
182
+ {
183
+ slug: "windy",
184
+ state: "windy",
185
+ temp: 18,
186
+ apparent: 15,
187
+ humidity: 60,
188
+ pressure: 1009,
189
+ wind: 42,
190
+ low: 12
191
+ },
192
+ {
193
+ slug: "exceptional",
194
+ state: "exceptional",
195
+ temp: 38,
196
+ apparent: 44,
197
+ humidity: 22,
198
+ pressure: 1005,
199
+ wind: 30,
200
+ low: 28
201
+ }
202
+ ];
203
+ function makeWeatherEntity(w) {
204
+ const today = /* @__PURE__ */ new Date();
205
+ const day = (n) => new Date(today.getTime() + n * 864e5).toISOString();
206
+ return makeEntity(`weather.demo_${w.slug}`, w.state, {
207
+ temperature: w.temp,
208
+ temperature_unit: "°C",
209
+ apparent_temperature: w.apparent ?? w.temp,
210
+ humidity: w.humidity,
211
+ pressure: w.pressure,
212
+ pressure_unit: "hPa",
213
+ wind_speed: w.wind,
214
+ wind_speed_unit: "km/h",
215
+ wind_bearing: w.bearing ?? 220,
216
+ visibility: 10,
217
+ visibility_unit: "km",
218
+ uv_index: w.state === "sunny" ? 8 : 2,
219
+ forecast: [
220
+ {
221
+ datetime: day(0),
222
+ condition: w.state,
223
+ temperature: w.temp,
224
+ templow: w.low
225
+ },
226
+ {
227
+ datetime: day(1),
228
+ condition: "partlycloudy",
229
+ temperature: w.temp + 2,
230
+ templow: w.low
231
+ },
232
+ {
233
+ datetime: day(2),
234
+ condition: "cloudy",
235
+ temperature: w.temp + 1,
236
+ templow: w.low - 1
237
+ },
238
+ {
239
+ datetime: day(3),
240
+ condition: "sunny",
241
+ temperature: w.temp + 4,
242
+ templow: w.low + 1
243
+ },
244
+ {
245
+ datetime: day(4),
246
+ condition: "rainy",
247
+ temperature: w.temp - 2,
248
+ templow: w.low - 2
249
+ }
250
+ ]
251
+ });
252
+ }
77
253
  function friendlyName(entityId) {
78
254
  const objectId = entityId.split(".")[1] ?? entityId;
79
255
  return objectId.split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
@@ -372,48 +548,7 @@ function createDemoFixtures() {
372
548
  area_id: "living_room",
373
549
  supported_features: 152461
374
550
  });
375
- add(makeEntity("weather.home_forecast", "partlycloudy", {
376
- temperature: 3,
377
- temperature_unit: "°C",
378
- humidity: 72,
379
- pressure: 1013,
380
- pressure_unit: "hPa",
381
- wind_speed: 12,
382
- wind_speed_unit: "km/h",
383
- wind_bearing: 220,
384
- forecast: [
385
- {
386
- datetime: "2026-03-07T00:00:00Z",
387
- condition: "cloudy",
388
- temperature: 4,
389
- templow: -1
390
- },
391
- {
392
- datetime: "2026-03-08T00:00:00Z",
393
- condition: "sunny",
394
- temperature: 7,
395
- templow: 1
396
- },
397
- {
398
- datetime: "2026-03-09T00:00:00Z",
399
- condition: "rainy",
400
- temperature: 5,
401
- templow: 2
402
- },
403
- {
404
- datetime: "2026-03-10T00:00:00Z",
405
- condition: "partlycloudy",
406
- temperature: 6,
407
- templow: 0
408
- },
409
- {
410
- datetime: "2026-03-11T00:00:00Z",
411
- condition: "snowy",
412
- temperature: 1,
413
- templow: -3
414
- }
415
- ]
416
- }), { platform: "met" });
551
+ for (const w of WEATHER_FIXTURES) add(makeWeatherEntity(w), { platform: "met" });
417
552
  add(makeEntity("camera.front_door_camera", "streaming", {
418
553
  entity_picture: "/api/camera_proxy/camera.front_door_camera",
419
554
  frontend_stream_type: "hls",
package/dist/index.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  export { addDebugIncomingMessageListener, addDebugOutgoingMessageListener, } from "./connection/adapter";
10
10
  export type { ConnectionOptions } from "./connection/manager";
11
11
  export { disconnect, getConnection, initConnection, isConnected } from "./connection/manager";
12
+ export { registerEntity } from "./connection/subscription-manager";
12
13
  export type { SyncLayerConnection } from "./connection/types";
13
14
  export type { OAuthOptions } from "./connection/auth";
14
15
  export { authenticateWithOAuth, authenticateWithToken, isAuthValid, refreshAuth, } from "./connection/auth";
package/dist/index.js CHANGED
@@ -1,5 +1,4 @@
1
- import { createDemoFixtures, resetStore, setState, state } from "./demo-data-gELwUOS_.js";
2
- import { forceResubscribe, setManagerConnection, setResubscribeHandler } from "./subscription-manager-gR0qpDYZ.js";
1
+ import { createDemoFixtures, resetStore, setState, state } from "./demo-data-D1ofmMa4.js";
3
2
  import { callService as callService$1, createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
4
3
  import { produce, reconcile } from "solid-js/store";
5
4
 
@@ -63,16 +62,16 @@ function notifyIncomingListeners(message) {
63
62
  * await syncLayer.connect({ connection: wrappedConn });
64
63
  * ```
65
64
  */
66
- function wrapHAConnection(conn) {
65
+ function wrapHAConnection(conn$1) {
67
66
  return {
68
67
  connect: async () => {
69
68
  return Promise.resolve();
70
69
  },
71
70
  disconnect: () => {
72
- conn.close();
71
+ conn$1.close();
73
72
  },
74
73
  get connected$() {
75
- return conn.connected;
74
+ return conn$1.connected;
76
75
  },
77
76
  authenticate: async () => {
78
77
  return Promise.resolve();
@@ -85,18 +84,18 @@ function wrapHAConnection(conn) {
85
84
  notifyIncomingListeners(event);
86
85
  callback(event);
87
86
  };
88
- return conn.subscribeEvents(wrappedCallback, eventType);
87
+ return conn$1.subscribeEvents(wrappedCallback, eventType);
89
88
  },
90
89
  subscribeMessage: async (callback, message) => {
91
90
  const wrappedCallback = (msg) => {
92
91
  notifyIncomingListeners(msg);
93
92
  callback(msg);
94
93
  };
95
- return conn.subscribeMessage(wrappedCallback, message);
94
+ return conn$1.subscribeMessage(wrappedCallback, message);
96
95
  },
97
96
  sendMessagePromise: async (message) => {
98
97
  notifyOutgoingListeners(message);
99
- const result = await conn.sendMessagePromise(message);
98
+ const result = await conn$1.sendMessagePromise(message);
100
99
  notifyIncomingListeners(result);
101
100
  return result;
102
101
  },
@@ -395,6 +394,103 @@ async function refreshAuth(auth) {
395
394
  return auth;
396
395
  }
397
396
 
397
+ //#endregion
398
+ //#region src/connection/subscription-manager.ts
399
+ if (globalThis.__GH_SYNC_LAYER_SUB_MGR__) console.error("[@glasshome/sync-layer] DUPLICATE subscription-manager module instantiated. registerEntity() and the connection setter will hit different singletons → subscribe_entities will never be sent. Cause: relative import of subscription-manager from a separate entry point.");
400
+ globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
401
+ /** Ref counts — how many consumers need each entity */
402
+ const refCounts = /* @__PURE__ */ new Map();
403
+ /** Current HA subscription unsub function */
404
+ let currentEntityUnsub = null;
405
+ /** Snapshot of entity IDs in the current HA subscription */
406
+ let subscribedIds = /* @__PURE__ */ new Set();
407
+ /** Connection reference for resubscribing */
408
+ let conn = null;
409
+ /** Pending flush timer (microtask for register, timeout for unregister) */
410
+ let flushScheduled = false;
411
+ /** Delayed unregister timeout */
412
+ let unregisterTimeout = null;
413
+ /** Callback invoked on resubscribe — set by subscriptions.ts */
414
+ let onResubscribe = null;
415
+ /** Delay before unregistered entities are actually removed from subscription */
416
+ const UNREGISTER_DELAY_MS = 5e3;
417
+ /** Guard against concurrent flush() calls */
418
+ let flushInFlight = false;
419
+ /**
420
+ * Register an entity for live updates. Returns an unregister function.
421
+ * Call from useEntity/useEntities hooks via onCleanup.
422
+ */
423
+ function registerEntity(entityId) {
424
+ refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
425
+ scheduleFlush();
426
+ return () => unregisterEntity(entityId);
427
+ }
428
+ /**
429
+ * Set the connection reference used for resubscribing.
430
+ */
431
+ function setManagerConnection(connection) {
432
+ conn = connection;
433
+ if (refCounts.size > 0) scheduleFlush();
434
+ }
435
+ /**
436
+ * Set the resubscribe callback. Called by subscriptions.ts to wire
437
+ * the manager into the actual WS subscription lifecycle.
438
+ */
439
+ function setResubscribeHandler(handler) {
440
+ onResubscribe = handler;
441
+ }
442
+ /**
443
+ * Force a resubscribe with the current active set.
444
+ * Used on reconnect.
445
+ */
446
+ async function forceResubscribe() {
447
+ await flush();
448
+ }
449
+ function unregisterEntity(entityId) {
450
+ const count = (refCounts.get(entityId) ?? 1) - 1;
451
+ if (count <= 0) refCounts.delete(entityId);
452
+ else refCounts.set(entityId, count);
453
+ scheduleDelayedFlush();
454
+ }
455
+ function scheduleFlush() {
456
+ if (flushScheduled) return;
457
+ flushScheduled = true;
458
+ queueMicrotask(() => {
459
+ flushScheduled = false;
460
+ flush();
461
+ });
462
+ }
463
+ function scheduleDelayedFlush() {
464
+ if (unregisterTimeout) return;
465
+ unregisterTimeout = setTimeout(() => {
466
+ unregisterTimeout = null;
467
+ flush();
468
+ }, UNREGISTER_DELAY_MS);
469
+ }
470
+ async function flush() {
471
+ if (!conn || !onResubscribe) return;
472
+ if (flushInFlight) return;
473
+ flushInFlight = true;
474
+ try {
475
+ const newIds = new Set(refCounts.keys());
476
+ if (setsEqual(newIds, subscribedIds)) return;
477
+ const oldUnsub = currentEntityUnsub;
478
+ const entityIds = [...newIds];
479
+ currentEntityUnsub = await onResubscribe(conn, entityIds);
480
+ subscribedIds = newIds;
481
+ if (oldUnsub) try {
482
+ await oldUnsub();
483
+ } catch {}
484
+ } finally {
485
+ flushInFlight = false;
486
+ }
487
+ }
488
+ function setsEqual(a, b) {
489
+ if (a.size !== b.size) return false;
490
+ for (const id of a) if (!b.has(id)) return false;
491
+ return true;
492
+ }
493
+
398
494
  //#endregion
399
495
  //#region src/core/types.ts
400
496
  /**
@@ -447,7 +543,7 @@ function unloadDemoData() {
447
543
  * When a service call is made in demo mode, this updates the entity
448
544
  * state locally (no WebSocket message sent). State resets on page refresh.
449
545
  */
450
- function applyDemoServiceCall(domain, service, _serviceData = {}, target = {}) {
546
+ function applyDemoServiceCall(_domain, service, _serviceData = {}, target = {}) {
451
547
  if (!_isDemoMode) return;
452
548
  const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
453
549
  for (const entityId of entityIds) {
@@ -835,31 +931,31 @@ async function subscribeEntities(connection, entityIds) {
835
931
  /**
836
932
  * Subscribe to all relevant events from Home Assistant
837
933
  */
838
- async function subscribeToUpdates(conn) {
934
+ async function subscribeToUpdates(conn$1) {
839
935
  await cleanupSubscriptions();
840
- setManagerConnection(conn);
936
+ setManagerConnection(conn$1);
841
937
  setResubscribeHandler(subscribeEntities);
842
- const registryUpdatedSub = await conn.subscribeEvents((event) => {
938
+ const registryUpdatedSub = await conn$1.subscribeEvents((event) => {
843
939
  handleRegistryUpdate(event);
844
940
  }, "entity_registry_updated");
845
941
  registrySubscriptions.push(() => registryUpdatedSub());
846
- const deviceRegistrySub = await conn.subscribeEvents((event) => {
942
+ const deviceRegistrySub = await conn$1.subscribeEvents((event) => {
847
943
  handleDeviceRegistryUpdate(event);
848
944
  }, "device_registry_updated");
849
945
  registrySubscriptions.push(() => deviceRegistrySub());
850
- const areaRegistrySub = await conn.subscribeEvents((event) => {
946
+ const areaRegistrySub = await conn$1.subscribeEvents((event) => {
851
947
  handleAreaRegistryUpdate(event);
852
948
  }, "area_registry_updated");
853
949
  registrySubscriptions.push(() => areaRegistrySub());
854
- const floorRegistrySub = await conn.subscribeEvents((event) => {
950
+ const floorRegistrySub = await conn$1.subscribeEvents((event) => {
855
951
  handleFloorRegistryUpdate(event);
856
952
  }, "floor_registry_updated");
857
953
  registrySubscriptions.push(() => floorRegistrySub());
858
- const labelRegistrySub = await conn.subscribeEvents((event) => {
954
+ const labelRegistrySub = await conn$1.subscribeEvents((event) => {
859
955
  handleLabelRegistryUpdate(event);
860
956
  }, "label_registry_updated");
861
957
  registrySubscriptions.push(() => labelRegistrySub());
862
- const statisticsSub = await conn.subscribeEvents((event) => {
958
+ const statisticsSub = await conn$1.subscribeEvents((event) => {
863
959
  handleStatisticsUpdate(event);
864
960
  }, "recorder_5min_statistics_generated");
865
961
  registrySubscriptions.push(() => statisticsSub());
@@ -875,10 +971,10 @@ async function cleanupSubscriptions() {
875
971
  async function handleRegistryUpdate(event) {
876
972
  const { action, entity_id } = event.data;
877
973
  if (action === "create" || action === "update") {
878
- const conn = state.conn;
879
- if (!conn) return;
974
+ const conn$1 = state.conn;
975
+ if (!conn$1) return;
880
976
  try {
881
- const entry = await conn.sendMessagePromise({
977
+ const entry = await conn$1.sendMessagePromise({
882
978
  type: "config/entity_registry/get",
883
979
  entity_id
884
980
  });
@@ -891,10 +987,10 @@ async function handleRegistryUpdate(event) {
891
987
  async function handleDeviceRegistryUpdate(event) {
892
988
  const { action, device_id } = event.data;
893
989
  if (action === "create" || action === "update") {
894
- const conn = state.conn;
895
- if (!conn) return;
990
+ const conn$1 = state.conn;
991
+ if (!conn$1) return;
896
992
  try {
897
- const devices = await conn.sendMessagePromise({ type: "config/device_registry/list" });
993
+ const devices = await conn$1.sendMessagePromise({ type: "config/device_registry/list" });
898
994
  const device = devices.find((d) => d.id === device_id);
899
995
  if (device) updateDevice(device);
900
996
  } catch (error) {
@@ -905,10 +1001,10 @@ async function handleDeviceRegistryUpdate(event) {
905
1001
  async function handleAreaRegistryUpdate(event) {
906
1002
  const { action, area_id } = event.data;
907
1003
  if (action === "create" || action === "update") {
908
- const conn = state.conn;
909
- if (!conn) return;
1004
+ const conn$1 = state.conn;
1005
+ if (!conn$1) return;
910
1006
  try {
911
- const areas$1 = await conn.sendMessagePromise({ type: "config/area_registry/list" });
1007
+ const areas$1 = await conn$1.sendMessagePromise({ type: "config/area_registry/list" });
912
1008
  const area$1 = areas$1.find((a) => a.id === area_id);
913
1009
  if (area$1) updateArea(area$1);
914
1010
  } catch (error) {
@@ -919,10 +1015,10 @@ async function handleAreaRegistryUpdate(event) {
919
1015
  async function handleFloorRegistryUpdate(event) {
920
1016
  const { action, floor_id } = event.data;
921
1017
  if (action === "create" || action === "update") {
922
- const conn = state.conn;
923
- if (!conn) return;
1018
+ const conn$1 = state.conn;
1019
+ if (!conn$1) return;
924
1020
  try {
925
- const floors = await conn.sendMessagePromise({ type: "config/floor_registry/list" });
1021
+ const floors = await conn$1.sendMessagePromise({ type: "config/floor_registry/list" });
926
1022
  const floor = floors.find((f) => f.floor_id === floor_id);
927
1023
  if (floor) updateFloor(floor);
928
1024
  } catch (error) {
@@ -933,10 +1029,10 @@ async function handleFloorRegistryUpdate(event) {
933
1029
  async function handleLabelRegistryUpdate(event) {
934
1030
  const { action, label_id } = event.data;
935
1031
  if (action === "create" || action === "update") {
936
- const conn = state.conn;
937
- if (!conn) return;
1032
+ const conn$1 = state.conn;
1033
+ if (!conn$1) return;
938
1034
  try {
939
- const labels = await conn.sendMessagePromise({ type: "config/label_registry/list" });
1035
+ const labels = await conn$1.sendMessagePromise({ type: "config/label_registry/list" });
940
1036
  const label = labels.find((l) => l.label_id === label_id);
941
1037
  if (label) updateLabel(label);
942
1038
  } catch (error) {
@@ -947,10 +1043,10 @@ async function handleLabelRegistryUpdate(event) {
947
1043
  async function handleStatisticsUpdate(event) {
948
1044
  const { statistic_ids } = event.data;
949
1045
  if (!statistic_ids || statistic_ids.length === 0) return;
950
- const conn = state.conn;
951
- if (!conn) return;
1046
+ const conn$1 = state.conn;
1047
+ if (!conn$1) return;
952
1048
  try {
953
- const metadata = await conn.sendMessagePromise({
1049
+ const metadata = await conn$1.sendMessagePromise({
954
1050
  type: "recorder/get_statistics_metadata",
955
1051
  statistic_ids
956
1052
  });
@@ -1071,21 +1167,21 @@ async function initConnection(options) {
1071
1167
  if (typeof options.auth === "string") auth = authenticateWithToken(options.url, options.auth);
1072
1168
  else if (typeof options.auth === "object" && "clientId" in options.auth) auth = await authenticateWithOAuth(options.auth);
1073
1169
  else auth = options.auth;
1074
- const conn = await createConnection({
1170
+ const conn$1 = await createConnection({
1075
1171
  auth,
1076
1172
  ...options.createSocket ? { createSocket: options.createSocket } : {}
1077
1173
  });
1078
- const wrappedConn = wrapHAConnection(conn);
1174
+ const wrappedConn = wrapHAConnection(conn$1);
1079
1175
  setState(produce((s) => {
1080
- s.conn = conn;
1176
+ s.conn = conn$1;
1081
1177
  s.hassUrl = options.url.replace(/\/$/, "");
1082
1178
  s.connectionState = "connected";
1083
1179
  }));
1084
- setupEventHandlers(conn, options);
1085
- await loadInitialData(conn);
1180
+ setupEventHandlers(conn$1, options);
1181
+ await loadInitialData(conn$1);
1086
1182
  await subscribeToUpdates(wrappedConn);
1087
1183
  options.onConnect?.();
1088
- return conn;
1184
+ return conn$1;
1089
1185
  } catch (error) {
1090
1186
  const err = error instanceof Error ? error : new Error(String(error));
1091
1187
  setState(produce((s) => {
@@ -1100,8 +1196,8 @@ async function initConnection(options) {
1100
1196
  * Disconnect from Home Assistant
1101
1197
  */
1102
1198
  function disconnect() {
1103
- const conn = state.conn;
1104
- if (conn) conn.close();
1199
+ const conn$1 = state.conn;
1200
+ if (conn$1) conn$1.close();
1105
1201
  setState(produce((s) => {
1106
1202
  s.conn = null;
1107
1203
  s.connectionState = "disconnected";
@@ -1119,40 +1215,40 @@ function getConnection() {
1119
1215
  function isConnected() {
1120
1216
  return state.connectionState === "connected";
1121
1217
  }
1122
- function setupEventHandlers(conn, options) {
1123
- conn.addEventListener("disconnected", () => {
1218
+ function setupEventHandlers(conn$1, options) {
1219
+ conn$1.addEventListener("disconnected", () => {
1124
1220
  setState(produce((s) => {
1125
1221
  s.connectionState = "disconnected";
1126
1222
  }));
1127
1223
  options.onDisconnect?.();
1128
1224
  });
1129
- conn.addEventListener("reconnect-error", () => {
1225
+ conn$1.addEventListener("reconnect-error", () => {
1130
1226
  setState(produce((s) => {
1131
1227
  s.connectionState = "reconnecting";
1132
1228
  }));
1133
1229
  });
1134
- conn.addEventListener("ready", async () => {
1230
+ conn$1.addEventListener("ready", async () => {
1135
1231
  const wasReconnecting = state.connectionState === "reconnecting";
1136
1232
  setState(produce((s) => {
1137
1233
  s.connectionState = "connected";
1138
1234
  s.connectionError = null;
1139
1235
  }));
1140
1236
  if (wasReconnecting) {
1141
- await loadInitialData(conn);
1237
+ await loadInitialData(conn$1);
1142
1238
  await forceResubscribe();
1143
1239
  options.onReconnect?.();
1144
1240
  }
1145
1241
  });
1146
1242
  }
1147
- async function loadInitialData(conn) {
1243
+ async function loadInitialData(conn$1) {
1148
1244
  try {
1149
1245
  const [states, entityRegistry, deviceRegistry, areaRegistry, floorRegistry, labelRegistry] = await Promise.all([
1150
- conn.sendMessagePromise({ type: "get_states" }),
1151
- conn.sendMessagePromise({ type: "config/entity_registry/list" }),
1152
- conn.sendMessagePromise({ type: "config/device_registry/list" }),
1153
- conn.sendMessagePromise({ type: "config/area_registry/list" }),
1154
- conn.sendMessagePromise({ type: "config/floor_registry/list" }),
1155
- conn.sendMessagePromise({ type: "config/label_registry/list" })
1246
+ conn$1.sendMessagePromise({ type: "get_states" }),
1247
+ conn$1.sendMessagePromise({ type: "config/entity_registry/list" }),
1248
+ conn$1.sendMessagePromise({ type: "config/device_registry/list" }),
1249
+ conn$1.sendMessagePromise({ type: "config/area_registry/list" }),
1250
+ conn$1.sendMessagePromise({ type: "config/floor_registry/list" }),
1251
+ conn$1.sendMessagePromise({ type: "config/label_registry/list" })
1156
1252
  ]);
1157
1253
  bulkUpdateEntities(states);
1158
1254
  bulkUpdateEntityRegistry(entityRegistry);
@@ -1538,7 +1634,7 @@ function buildQuerySignature(query) {
1538
1634
  /**
1539
1635
  * Execute query (no caching -- SolidJS createMemo handles this in Plan 03)
1540
1636
  */
1541
- function memoizedExecute(signature, compute) {
1637
+ function memoizedExecute(_signature, compute) {
1542
1638
  return compute();
1543
1639
  }
1544
1640
 
@@ -1980,9 +2076,9 @@ async function refreshStream(entityId, format = "hls") {
1980
2076
  * Get WebRTC client configuration (STUN/TURN servers) from HA
1981
2077
  */
1982
2078
  async function getWebRtcClientConfig(entityId) {
1983
- const conn = state.conn;
1984
- if (!conn) throw new Error("No connection available");
1985
- return conn.sendMessagePromise({
2079
+ const conn$1 = state.conn;
2080
+ if (!conn$1) throw new Error("No connection available");
2081
+ return conn$1.sendMessagePromise({
1986
2082
  type: "camera/webrtc/get_client_config",
1987
2083
  entity_id: entityId
1988
2084
  });
@@ -1994,8 +2090,8 @@ async function getWebRtcClientConfig(entityId) {
1994
2090
  * Local ICE candidates should be sent via sendWebRtcCandidate().
1995
2091
  */
1996
2092
  function startWebRtcSession(entityId, sdpOffer, onCandidate) {
1997
- const conn = state.conn;
1998
- if (!conn) return Promise.reject(new Error("Not connected"));
2093
+ const conn$1 = state.conn;
2094
+ if (!conn$1) return Promise.reject(new Error("Not connected"));
1999
2095
  return new Promise((resolve, reject) => {
2000
2096
  const session = {
2001
2097
  sessionId: null,
@@ -2008,7 +2104,7 @@ function startWebRtcSession(entityId, sdpOffer, onCandidate) {
2008
2104
  reject(new Error("WebRTC offer timed out"));
2009
2105
  }
2010
2106
  }, 15e3);
2011
- conn.subscribeMessage((event) => {
2107
+ conn$1.subscribeMessage((event) => {
2012
2108
  switch (event.type) {
2013
2109
  case "session":
2014
2110
  session.sessionId = event.session_id ?? null;
@@ -2048,9 +2144,9 @@ function startWebRtcSession(entityId, sdpOffer, onCandidate) {
2048
2144
  * Send a local ICE candidate to HA
2049
2145
  */
2050
2146
  async function sendWebRtcCandidate(entityId, sessionId, candidate) {
2051
- const conn = state.conn;
2052
- if (!conn) throw new Error("Not connected");
2053
- await conn.sendMessagePromise({
2147
+ const conn$1 = state.conn;
2148
+ if (!conn$1) throw new Error("Not connected");
2149
+ await conn$1.sendMessagePromise({
2054
2150
  type: "camera/webrtc/candidate",
2055
2151
  entity_id: entityId,
2056
2152
  session_id: sessionId,
@@ -2061,11 +2157,52 @@ async function sendWebRtcCandidate(entityId, sessionId, candidate) {
2061
2157
  //#endregion
2062
2158
  //#region src/weather/fetch.ts
2063
2159
  /**
2160
+ * In demo mode, derive forecasts from the entity's `forecast` attribute.
2161
+ * Hourly and daily are synthesised from the same source (daily entries
2162
+ * expanded into 12 hourly steps for the chart).
2163
+ */
2164
+ function buildDemoForecast(entityId, type) {
2165
+ const entity$1 = state.entities[entityId];
2166
+ const raw = entity$1?.attributes?.forecast ?? [];
2167
+ if (raw.length === 0) return [];
2168
+ if (type === "daily" || type === "twice_daily") return raw.map((d) => {
2169
+ const templow = d.templow;
2170
+ return {
2171
+ ...d,
2172
+ temp_high: d.temp_high ?? d.temperature,
2173
+ temp_low: d.temp_low ?? templow
2174
+ };
2175
+ });
2176
+ const start = /* @__PURE__ */ new Date();
2177
+ const today = raw[0];
2178
+ const tomorrow = raw[1] ?? today;
2179
+ const tHi = today?.temperature ?? 18;
2180
+ const tLo = today?.templow ?? tHi - 5;
2181
+ const tNext = tomorrow?.temperature ?? tHi;
2182
+ const out = [];
2183
+ for (let i = 0; i < 24; i++) {
2184
+ const dt = new Date(start.getTime() + i * 60 * 60 * 1e3);
2185
+ const hour = dt.getHours();
2186
+ const dayMix = i < 12 ? 0 : (i - 12) / 12;
2187
+ const baseHi = tHi * (1 - dayMix) + tNext * dayMix;
2188
+ const phase = (hour - 6) / 24 * Math.PI * 2;
2189
+ const swing = (baseHi - tLo) / 2;
2190
+ const mid = (baseHi + tLo) / 2;
2191
+ out.push({
2192
+ datetime: dt.toISOString(),
2193
+ temperature: Math.round((mid + Math.sin(phase) * swing) * 10) / 10,
2194
+ condition: today?.condition ?? entity$1?.state ?? "cloudy"
2195
+ });
2196
+ }
2197
+ return out;
2198
+ }
2199
+ /**
2064
2200
  * Fetch forecast for a single entity and type
2065
2201
  */
2066
2202
  async function fetchForecast(entityId, type) {
2067
- const conn = state.conn;
2068
- if (!conn) throw new Error("Not connected");
2203
+ if (isDemoMode()) return buildDemoForecast(entityId, type);
2204
+ const conn$1 = state.conn;
2205
+ if (!conn$1) throw new Error("Not connected");
2069
2206
  return new Promise((resolve, reject) => {
2070
2207
  let resolved = false;
2071
2208
  let unsubscribe = null;
@@ -2075,7 +2212,7 @@ async function fetchForecast(entityId, type) {
2075
2212
  unsubscribe().then(() => reject(new Error("Forecast subscription timeout"))).catch(() => reject(new Error("Forecast subscription timeout")));
2076
2213
  }
2077
2214
  }, 1e4);
2078
- conn.subscribeMessage((event) => {
2215
+ conn$1.subscribeMessage((event) => {
2079
2216
  if (resolved) return;
2080
2217
  if (event && typeof event === "object" && "forecast" in event) {
2081
2218
  resolved = true;
@@ -2279,4 +2416,4 @@ async function refreshForecasts(entityId, types) {
2279
2416
  const VERSION = "0.1.0";
2280
2417
 
2281
2418
  //#endregion
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 };
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 };
@@ -11,7 +11,7 @@ export declare function buildQuerySignature(query: QueryState): string;
11
11
  /**
12
12
  * Execute query (no caching -- SolidJS createMemo handles this in Plan 03)
13
13
  */
14
- export declare function memoizedExecute<T>(signature: string, compute: () => T): T;
14
+ export declare function memoizedExecute<T>(_signature: string, compute: () => T): T;
15
15
  /**
16
16
  * Check if cache should be invalidated
17
17
  */
@@ -1,6 +1,5 @@
1
- import { registerEntity } from "../subscription-manager-gR0qpDYZ.js";
2
1
  import { createEffect, createMemo, onCleanup } from "solid-js";
3
- import { buildAreaView, callService, entityViewEquals, extractDomain, getEntityView, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
2
+ import { buildAreaView, callService, entityViewEquals, extractDomain, getEntityView, registerEntity, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
4
3
 
5
4
  //#region src/solid/hooks.ts
6
5
  /**
@@ -107,7 +106,12 @@ function useAreas() {
107
106
  }).filter((v) => v !== void 0);
108
107
  }, void 0, { equals: (a, b) => {
109
108
  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;
109
+ for (let i = 0; i < a.length; i++) {
110
+ const aItem = a[i];
111
+ const bItem = b[i];
112
+ if (!aItem || !bItem) return false;
113
+ if (aItem.id !== bItem.id || aItem.modifiedAt !== bItem.modifiedAt || aItem.entities.length !== bItem.entities.length) return false;
114
+ }
111
115
  return true;
112
116
  } });
113
117
  }
@@ -1,4 +1,4 @@
1
- import { createDemoFixtures, state } from "../demo-data-gELwUOS_.js";
1
+ import { createDemoFixtures, state } from "../demo-data-D1ofmMa4.js";
2
2
 
3
3
  //#region src/testing/mock-behaviors/base.ts
4
4
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/sync-layer",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Type-safe, reactive state synchronization layer for Home Assistant",
5
5
  "repository": {
6
6
  "type": "git",
@@ -44,7 +44,7 @@
44
44
  "dist"
45
45
  ],
46
46
  "scripts": {
47
- "build": "tsdown",
47
+ "build": "tsdown && tsc --noEmit",
48
48
  "dev": "tsdown --watch",
49
49
  "test": "bun test",
50
50
  "lint": "tsc --noEmit"
@@ -53,7 +53,9 @@
53
53
  "home-assistant-js-websocket": "^9.6.0"
54
54
  },
55
55
  "devDependencies": {
56
+ "@glasshome/ha-types": "0.1.0",
56
57
  "@types/bun": "latest",
58
+ "solid-js": "^1.9.0",
57
59
  "tsdown": "^0.2.0",
58
60
  "typescript": "^5"
59
61
  },
@@ -1,96 +0,0 @@
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 };