@glasshome/sync-layer 0.8.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -862,7 +862,7 @@ declare function entitiesWhere(predicate: (entity: EntityView) => boolean): Enti
862
862
  //#region src/camera/fetch.d.ts
863
863
  /**
864
864
  * Fetch stream for a camera entity.
865
- * Returns a public demo HLS stream when not connected.
865
+ * Returns the dashboard's own demo HLS feed in demo mode.
866
866
  */
867
867
  declare function fetchStream(entityId: EntityId, format?: StreamFormat): Promise<string | null>;
868
868
  /**
@@ -1207,6 +1207,7 @@ declare function applyDemoServiceCall(_domain: string, service: string, _service
1207
1207
  //#endregion
1208
1208
  //#region src/worker/protocol.d.ts
1209
1209
  type ConnState = "connected" | "disconnected" | "reconnecting";
1210
+ type ConnReason = "invalid_auth" | "ping_timeout";
1210
1211
  //#endregion
1211
1212
  //#region src/worker/bridge-client.d.ts
1212
1213
  /**
@@ -1225,7 +1226,7 @@ interface BridgeConnectOptions {
1225
1226
  proxyTicket?: string;
1226
1227
  }
1227
1228
  interface BridgeEvents {
1228
- onConnState?: (state: ConnState) => void;
1229
+ onConnState?: (state: ConnState, reason?: ConnReason) => void;
1229
1230
  onReadyAfterReconnect?: () => void;
1230
1231
  onInvalidAuth?: () => void;
1231
1232
  onDenial?: (denial: {
@@ -1264,7 +1265,7 @@ declare function createHaBridge(worker: Worker, events?: BridgeEvents): HaBridge
1264
1265
  */
1265
1266
  declare function attachBridgeToStore(bridge: HaBridge, hassUrl: string, mediaProxyBase?: string): Promise<void>;
1266
1267
  /** Map worker connection-state messages onto the store. */
1267
- declare function applyBridgeConnState(state: "connected" | "disconnected" | "reconnecting"): void;
1268
+ declare function applyBridgeConnState(state: "connecting" | "connected" | "disconnected" | "reconnecting"): void;
1268
1269
  /** Re-sync after the worker reports a completed reconnect. */
1269
1270
  declare function reloadAfterBridgeReconnect(bridge: HaBridge): Promise<void>;
1270
1271
  /** Detach on shutdown; the caller terminates the worker itself. */
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { A as extractDomain, B as state, C as startDemoEnergyTicker, D as demoEnergyPreferences, E as createDemoFixtures, F as forceResubscribeCalendars, L as privilegedConn, M as registerEntity, N as setManagerConnection, O as isEnergyEntity, P as setResubscribeHandler, R as setPrivilegedConn, S as loadDemoData, T as unloadDemoData, _ as turnOff, a as getAreaView, b as applyDemoServiceCall, c as entityViewEquals, d as getEntityViews, f as batchEntityUpdates, g as toggle, h as sendCommand, i as buildDeviceView, j as forceResubscribe, k as synthesizeEnergyHistory, l as getAllEntityViews, m as callService, n as normalizeStatisticTime, o as getAreaViews, p as batchServiceCalls, r as buildAreaView, s as buildEntityView, t as fetchStatisticsDuringPeriod, u as getEntityView, v as turnOn, w as stopDemoEnergyTicker, x as isDemoMode, y as updateEntity, z as setState } from "./statistics-DyCCTwv2.js";
2
- import { t as runHaBridgeWorker } from "./worker-main-D_wyGAIG.js";
1
+ import { A as isDemoMode, B as extractDomain, C as setManagerConnection, D as fetchForecastData, E as fetchForecast, F as createDemoFixtures, G as setState, I as demoAssetUrl, K as state, L as demoEnergyPreferences, M as startDemoEnergyTicker, N as stopDemoEnergyTicker, O as fetchForecasts, P as unloadDemoData, R as isEnergyEntity, S as registerEntity, U as privilegedConn, V as forceResubscribeCalendars, W as setPrivilegedConn, _ as toggle, a as getAreaView, b as updateEntity, c as entityViewEquals, d as getEntityViews, f as numericSensorHistory, g as sendCommand, h as callService, i as buildDeviceView, j as loadDemoData, k as applyDemoServiceCall, l as getAllEntityViews, m as batchServiceCalls, n as normalizeStatisticTime, o as getAreaViews, p as batchEntityUpdates, r as buildAreaView, s as buildEntityView, t as fetchStatisticsDuringPeriod, u as getEntityView, v as turnOff, w as setResubscribeHandler, x as forceResubscribe, y as turnOn, z as synthesizeEnergyHistory } from "./statistics-C5j1DLGM.js";
2
+ import { t as runHaBridgeWorker } from "./worker-main-DamaVb7B.js";
3
3
  import "home-assistant-js-websocket";
4
4
  import { produce } from "solid-js/store";
5
5
  //#region src/core/reducers.ts
@@ -193,8 +193,9 @@ async function fetchHistory(options) {
193
193
  const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
194
194
  const result = {};
195
195
  for (const id of entityIds) {
196
- if (!isEnergyEntity(id)) continue;
197
- result[id] = synthesizeEnergyHistory(id, startTime.getTime(), endMs).map((p) => ({
196
+ const points = isEnergyEntity(id) ? synthesizeEnergyHistory(id, startTime.getTime(), endMs) : numericSensorHistory(id, startTime.getTime(), endMs);
197
+ if (!points) continue;
198
+ result[id] = points.map((p) => ({
198
199
  s: p.s,
199
200
  a: {},
200
201
  lu: p.lu
@@ -1242,14 +1243,14 @@ function entitiesWhere(predicate) {
1242
1243
  *
1243
1244
  * @packageDocumentation
1244
1245
  */
1245
- /** Public HLS test stream for demo/disconnected mode */
1246
- const DEMO_HLS_STREAM = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
1246
+ /** The demo's front door feed, served by the dashboard itself so the demo never reaches a third party. */
1247
+ const DEMO_HLS_STREAM = "/demo/camera/front-door.m3u8";
1247
1248
  /**
1248
1249
  * Fetch stream for a camera entity.
1249
- * Returns a public demo HLS stream when not connected.
1250
+ * Returns the dashboard's own demo HLS feed in demo mode.
1250
1251
  */
1251
1252
  async function fetchStream(entityId, format = "hls") {
1252
- if (isDemoMode()) return format === "hls" ? DEMO_HLS_STREAM : null;
1253
+ if (isDemoMode()) return format === "hls" ? demoAssetUrl(DEMO_HLS_STREAM) : null;
1253
1254
  if (!privilegedConn()) return null;
1254
1255
  return (await sendCommand({
1255
1256
  type: "camera/stream",
@@ -1501,148 +1502,6 @@ async function sendWebRtcCandidate(entityId, sessionId, candidate) {
1501
1502
  });
1502
1503
  }
1503
1504
  //#endregion
1504
- //#region src/weather/fetch.ts
1505
- /**
1506
- * Weather Forecast Fetching
1507
- *
1508
- * @packageDocumentation
1509
- */
1510
- /**
1511
- * In demo mode, derive forecasts from the entity's `forecast` attribute.
1512
- * Hourly and daily are synthesised from the same source (daily entries
1513
- * expanded into 12 hourly steps for the chart).
1514
- */
1515
- function buildDemoForecast(entityId, type) {
1516
- const entity = state.entities[entityId];
1517
- const raw = entity?.attributes?.forecast ?? [];
1518
- if (raw.length === 0) return [];
1519
- if (type === "daily" || type === "twice_daily") return raw.map((d) => {
1520
- const templow = d.templow;
1521
- return {
1522
- ...d,
1523
- temp_high: d.temp_high ?? d.temperature,
1524
- temp_low: d.temp_low ?? templow
1525
- };
1526
- });
1527
- const start = /* @__PURE__ */ new Date();
1528
- const today = raw[0];
1529
- const tomorrow = raw[1] ?? today;
1530
- const tHi = today?.temperature ?? 18;
1531
- const tLo = today?.templow ?? tHi - 5;
1532
- const tNext = tomorrow?.temperature ?? tHi;
1533
- const out = [];
1534
- for (let i = 0; i < 24; i++) {
1535
- const dt = new Date(start.getTime() + i * 60 * 60 * 1e3);
1536
- const hour = dt.getHours();
1537
- const dayMix = i < 12 ? 0 : (i - 12) / 12;
1538
- const baseHi = tHi * (1 - dayMix) + tNext * dayMix;
1539
- const phase = (hour - 6) / 24 * Math.PI * 2;
1540
- const swing = (baseHi - tLo) / 2;
1541
- const mid = (baseHi + tLo) / 2;
1542
- out.push({
1543
- datetime: dt.toISOString(),
1544
- temperature: Math.round((mid + Math.sin(phase) * swing) * 10) / 10,
1545
- condition: today?.condition ?? entity?.state ?? "cloudy"
1546
- });
1547
- }
1548
- return out;
1549
- }
1550
- /**
1551
- * Fetch forecast for a single entity and type
1552
- */
1553
- async function fetchForecast(entityId, type) {
1554
- if (isDemoMode()) return buildDemoForecast(entityId, type);
1555
- const conn = privilegedConn();
1556
- if (!conn) throw new Error("Not connected");
1557
- return new Promise((resolve, reject) => {
1558
- let resolved = false;
1559
- let unsubscribe = null;
1560
- const timeout = setTimeout(() => {
1561
- if (!resolved && unsubscribe) {
1562
- resolved = true;
1563
- unsubscribe().then(() => reject(/* @__PURE__ */ new Error("Forecast subscription timeout"))).catch(() => reject(/* @__PURE__ */ new Error("Forecast subscription timeout")));
1564
- }
1565
- }, 1e4);
1566
- conn.subscribeMessage((event) => {
1567
- if (resolved) return;
1568
- if (event && typeof event === "object" && "forecast" in event) {
1569
- resolved = true;
1570
- clearTimeout(timeout);
1571
- const forecast = event.forecast;
1572
- const forecastArray = Array.isArray(forecast) ? forecast : [];
1573
- if (unsubscribe) unsubscribe().then(() => resolve(forecastArray)).catch(() => resolve(forecastArray));
1574
- else resolve(forecastArray);
1575
- }
1576
- }, {
1577
- type: "weather/subscribe_forecast",
1578
- entity_id: entityId,
1579
- forecast_type: type === "twice_daily" ? "twice_daily" : type
1580
- }).then((unsubFn) => {
1581
- unsubscribe = unsubFn;
1582
- }).catch((error) => {
1583
- resolved = true;
1584
- clearTimeout(timeout);
1585
- reject(error);
1586
- });
1587
- });
1588
- }
1589
- /**
1590
- * Fetch forecasts for a single entity and multiple types
1591
- */
1592
- async function fetchForecasts(entityId, types) {
1593
- const forecasts = {};
1594
- const loading = {};
1595
- const errors = {};
1596
- const lastFetched = {};
1597
- const fetchPromises = types.map(async (type) => {
1598
- loading[type] = true;
1599
- errors[type] = null;
1600
- try {
1601
- const forecast = await fetchForecast(entityId, type);
1602
- forecasts[type] = forecast;
1603
- lastFetched[type] = Date.now();
1604
- loading[type] = false;
1605
- } catch (error) {
1606
- errors[type] = error instanceof Error ? error : new Error(String(error));
1607
- forecasts[type] = [];
1608
- loading[type] = false;
1609
- lastFetched[type] = null;
1610
- }
1611
- });
1612
- await Promise.all(fetchPromises);
1613
- return {
1614
- entityId,
1615
- forecasts,
1616
- loading,
1617
- errors,
1618
- lastFetched
1619
- };
1620
- }
1621
- /**
1622
- * Fetch forecast data with error handling
1623
- */
1624
- async function fetchForecastData(entityId, type) {
1625
- try {
1626
- return {
1627
- entityId,
1628
- type,
1629
- forecast: await fetchForecast(entityId, type),
1630
- loading: false,
1631
- error: null,
1632
- lastFetched: Date.now()
1633
- };
1634
- } catch (error) {
1635
- return {
1636
- entityId,
1637
- type,
1638
- forecast: [],
1639
- loading: false,
1640
- error: error instanceof Error ? error : new Error(String(error)),
1641
- lastFetched: null
1642
- };
1643
- }
1644
- }
1645
- //#endregion
1646
1505
  //#region src/weather/query.ts
1647
1506
  /**
1648
1507
  * Weather Forecast Query Builder
@@ -1797,7 +1656,7 @@ function createHaBridge(worker, events = {}) {
1797
1656
  break;
1798
1657
  case "conn":
1799
1658
  connState = msg.state;
1800
- events.onConnState?.(msg.state);
1659
+ events.onConnState?.(msg.state, msg.reason);
1801
1660
  if (msg.reason === "invalid_auth") events.onInvalidAuth?.();
1802
1661
  break;
1803
1662
  case "ready_after_reconnect":
@@ -1,7 +1,7 @@
1
1
  import { a as toggle, g as CalendarWindowOptions, h as CalendarEventsData, o as turnOff, r as callService, s as turnOn } from "../service-C8H42I-Q.js";
2
2
  import { Accessor, Resource } from "solid-js";
3
3
  import { HassConfig, HassUnitSystem } from "@glasshome/ha-types";
4
- import { AreaView, CameraStreamData, ConnectionState, EntityHistoryData, EntityView, GlassHomeState, HassEntity, StatisticValue, StatisticsQueryOptions, WeatherForecastsData } from "@glasshome/sync-layer";
4
+ import { AreaView, CameraStreamData, ConnectionState, EntityHistoryData, EntityView, ForecastType, GlassHomeState, HassEntity, StatisticValue, StatisticsQueryOptions, WeatherForecastsData } from "@glasshome/sync-layer";
5
5
  //#region src/solid/hooks.d.ts
6
6
  /**
7
7
  * Get a reactive EntityView for an entity ID.
@@ -96,9 +96,10 @@ declare function useCamera(entityId: Accessor<string> | string): {
96
96
  */
97
97
  declare function useCalendarEvents(entityId: Accessor<string> | string, options?: CalendarWindowOptions): Accessor<CalendarEventsData | undefined>;
98
98
  /**
99
- * Get reactive weather forecast data.
99
+ * Get reactive weather forecast data. With `types`, the forecast stays live
100
+ * while the caller is mounted: Home Assistant pushes each new one, no polling.
100
101
  */
101
- declare function useForecast(entityId: Accessor<string> | string): Accessor<WeatherForecastsData | undefined>;
102
+ declare function useForecast(entityId: Accessor<string> | string, types?: ForecastType[]): Accessor<WeatherForecastsData | undefined>;
102
103
  /**
103
104
  * Escape hatch for direct store access with a selector.
104
105
  *
@@ -1,4 +1,4 @@
1
- import { A as extractDomain, B as state, I as trackCalendarEvents, L as privilegedConn, M as registerEntity, _ as turnOff, c as entityViewEquals, g as toggle, m as callService, o as getAreaViews, r as buildAreaView, t as fetchStatisticsDuringPeriod, u as getEntityView, v as turnOn, x as isDemoMode } from "../statistics-DyCCTwv2.js";
1
+ import { A as isDemoMode, B as extractDomain, H as trackCalendarEvents, K as state, S as registerEntity, T as trackForecast, U as privilegedConn, _ as toggle, c as entityViewEquals, h as callService, o as getAreaViews, r as buildAreaView, t as fetchStatisticsDuringPeriod, u as getEntityView, v as turnOff, y as turnOn } from "../statistics-C5j1DLGM.js";
2
2
  import { createEffect, createMemo, createResource, onCleanup } from "solid-js";
3
3
  //#region src/solid/hooks.ts
4
4
  /**
@@ -228,10 +228,16 @@ function useCalendarEvents(entityId, options) {
228
228
  return createMemo(() => state.calendars[getId()]);
229
229
  }
230
230
  /**
231
- * Get reactive weather forecast data.
231
+ * Get reactive weather forecast data. With `types`, the forecast stays live
232
+ * while the caller is mounted: Home Assistant pushes each new one, no polling.
232
233
  */
233
- function useForecast(entityId) {
234
+ function useForecast(entityId, types) {
234
235
  const getId = typeof entityId === "function" ? entityId : () => entityId;
236
+ createEffect(() => {
237
+ const id = getId();
238
+ if (!id || !types) return;
239
+ for (const type of types) onCleanup(trackForecast(id, type));
240
+ });
235
241
  return createMemo(() => state.forecasts[getId()]);
236
242
  }
237
243
  /**
@@ -49,13 +49,13 @@ function setPrivilegedConn(conn) {
49
49
  const DEFAULT_DAYS_BEHIND = 7;
50
50
  const DEFAULT_DAYS_AHEAD = 35;
51
51
  /** Debounce for untrack, so dashboard switches don't churn subscriptions. */
52
- const UNTRACK_FLUSH_DELAY_MS = 5e3;
53
- const DAY_MS = 864e5;
52
+ const UNTRACK_FLUSH_DELAY_MS$1 = 5e3;
53
+ const DAY_MS$1 = 864e5;
54
54
  const trackers = /* @__PURE__ */ new Map();
55
- const active = /* @__PURE__ */ new Map();
55
+ const active$1 = /* @__PURE__ */ new Map();
56
56
  /** Per-entity reconcile chain; serializes subscribe/unsubscribe per calendar. */
57
- const chains = /* @__PURE__ */ new Map();
58
- let untrackTimer = null;
57
+ const chains$1 = /* @__PURE__ */ new Map();
58
+ let untrackTimer$1 = null;
59
59
  let rollTimer = null;
60
60
  /** Day-quantized union of all tracker windows, so equal requests within a day
61
61
  reconcile to the same subscription instead of churning. */
@@ -69,8 +69,8 @@ function windowFor(list) {
69
69
  const dayStart = /* @__PURE__ */ new Date();
70
70
  dayStart.setHours(0, 0, 0, 0);
71
71
  return {
72
- start: (/* @__PURE__ */ new Date(dayStart.getTime() - behind * DAY_MS)).toISOString(),
73
- end: new Date(dayStart.getTime() + (ahead + 1) * DAY_MS).toISOString()
72
+ start: (/* @__PURE__ */ new Date(dayStart.getTime() - behind * DAY_MS$1)).toISOString(),
73
+ end: new Date(dayStart.getTime() + (ahead + 1) * DAY_MS$1).toISOString()
74
74
  };
75
75
  }
76
76
  /**
@@ -102,11 +102,11 @@ function untrackCalendarEvents(entityId, tracker) {
102
102
  const i = list.indexOf(tracker);
103
103
  if (i !== -1) list.splice(i, 1);
104
104
  if (list.length === 0) trackers.delete(entityId);
105
- if (untrackTimer) return;
106
- untrackTimer = setTimeout(() => {
107
- untrackTimer = null;
108
- flushAll();
109
- }, UNTRACK_FLUSH_DELAY_MS);
105
+ if (untrackTimer$1) return;
106
+ untrackTimer$1 = setTimeout(() => {
107
+ untrackTimer$1 = null;
108
+ flushAll$1();
109
+ }, UNTRACK_FLUSH_DELAY_MS$1);
110
110
  }
111
111
  /**
112
112
  * Reconcile every tracked calendar, dropping current subscription handles
@@ -116,39 +116,39 @@ function untrackCalendarEvents(entityId, tracker) {
116
116
  * store through any missed changes).
117
117
  */
118
118
  async function forceResubscribeCalendars() {
119
- for (const entry of active.values()) {
119
+ for (const entry of active$1.values()) {
120
120
  const dead = entry.unsub;
121
121
  entry.unsub = null;
122
122
  if (dead) Promise.resolve().then(dead).catch(() => {});
123
123
  }
124
- await flushAll();
124
+ await flushAll$1();
125
125
  }
126
126
  function scheduleFlush$1() {
127
127
  queueMicrotask(() => {
128
- flushAll();
128
+ flushAll$1();
129
129
  });
130
130
  }
131
- async function flushAll() {
132
- const ids = /* @__PURE__ */ new Set([...trackers.keys(), ...active.keys()]);
131
+ async function flushAll$1() {
132
+ const ids = /* @__PURE__ */ new Set([...trackers.keys(), ...active$1.keys()]);
133
133
  await Promise.all([...ids].map(reconcileEntity));
134
134
  if (trackers.size === 0) stopRollTimer();
135
135
  }
136
136
  function reconcileEntity(entityId) {
137
- const next = (chains.get(entityId) ?? Promise.resolve()).then(() => runReconcile(entityId));
138
- chains.set(entityId, next.catch(() => {}));
137
+ const next = (chains$1.get(entityId) ?? Promise.resolve()).then(() => runReconcile$1(entityId));
138
+ chains$1.set(entityId, next.catch(() => {}));
139
139
  return next;
140
140
  }
141
- async function runReconcile(entityId) {
141
+ async function runReconcile$1(entityId) {
142
142
  const list = trackers.get(entityId);
143
- const current = active.get(entityId);
143
+ const current = active$1.get(entityId);
144
144
  if (!list || list.length === 0) {
145
145
  if (current) {
146
- active.delete(entityId);
146
+ active$1.delete(entityId);
147
147
  try {
148
148
  await current.unsub?.();
149
149
  } catch {}
150
150
  }
151
- chains.delete(entityId);
151
+ chains$1.delete(entityId);
152
152
  setState(produce((s) => {
153
153
  delete s.calendars[entityId];
154
154
  }));
@@ -174,7 +174,7 @@ async function runReconcile(entityId) {
174
174
  end: win.end,
175
175
  unsub: null
176
176
  };
177
- active.set(entityId, entry);
177
+ active$1.set(entityId, entry);
178
178
  try {
179
179
  entry.unsub = await conn.subscribeMessage((msg) => {
180
180
  setState(produce((s) => {
@@ -191,7 +191,7 @@ async function runReconcile(entityId) {
191
191
  end: win.end
192
192
  });
193
193
  } catch (err) {
194
- active.delete(entityId);
194
+ active$1.delete(entityId);
195
195
  setState(produce((s) => {
196
196
  const rec = s.calendars[entityId];
197
197
  if (!rec) return;
@@ -213,7 +213,7 @@ function ensureRollTimer() {
213
213
  next.setHours(24, 0, 30, 0);
214
214
  rollTimer = setTimeout(() => {
215
215
  rollTimer = null;
216
- flushAll().then(ensureRollTimer);
216
+ flushAll$1().then(ensureRollTimer);
217
217
  }, next.getTime() - now.getTime());
218
218
  }
219
219
  function stopRollTimer() {
@@ -223,102 +223,6 @@ function stopRollTimer() {
223
223
  }
224
224
  }
225
225
  //#endregion
226
- //#region src/connection/subscription-manager.ts
227
- 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.");
228
- globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
229
- /** Ref counts — how many consumers need each entity */
230
- const refCounts = /* @__PURE__ */ new Map();
231
- /** Current HA subscription unsub function */
232
- let currentEntityUnsub = null;
233
- /** Snapshot of entity IDs in the current HA subscription */
234
- let subscribedIds = /* @__PURE__ */ new Set();
235
- /** Connection reference for resubscribing */
236
- let conn = null;
237
- /** Pending flush timer (microtask for register, timeout for unregister) */
238
- let flushScheduled = false;
239
- /** Delayed unregister timeout */
240
- let unregisterTimeout = null;
241
- /** Callback invoked on resubscribe — set by subscriptions.ts */
242
- let onResubscribe = null;
243
- /** Delay before unregistered entities are actually removed from subscription */
244
- const UNREGISTER_DELAY_MS = 5e3;
245
- /** Guard against concurrent flush() calls */
246
- let flushInFlight = false;
247
- /**
248
- * Register an entity for live updates. Returns an unregister function.
249
- * Call from useEntity/useEntities hooks via onCleanup.
250
- */
251
- function registerEntity(entityId) {
252
- refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
253
- scheduleFlush();
254
- return () => unregisterEntity(entityId);
255
- }
256
- /**
257
- * Set the connection reference used for resubscribing.
258
- */
259
- function setManagerConnection(connection) {
260
- conn = connection;
261
- if (refCounts.size > 0) scheduleFlush();
262
- }
263
- /**
264
- * Set the resubscribe callback. Called by subscriptions.ts to wire
265
- * the manager into the actual WS subscription lifecycle.
266
- */
267
- function setResubscribeHandler(handler) {
268
- onResubscribe = handler;
269
- }
270
- /**
271
- * Force a resubscribe with the current active set.
272
- * Used on reconnect.
273
- */
274
- async function forceResubscribe() {
275
- await flush();
276
- }
277
- function unregisterEntity(entityId) {
278
- const count = (refCounts.get(entityId) ?? 1) - 1;
279
- if (count <= 0) refCounts.delete(entityId);
280
- else refCounts.set(entityId, count);
281
- scheduleDelayedFlush();
282
- }
283
- function scheduleFlush() {
284
- if (flushScheduled) return;
285
- flushScheduled = true;
286
- queueMicrotask(() => {
287
- flushScheduled = false;
288
- flush();
289
- });
290
- }
291
- function scheduleDelayedFlush() {
292
- if (unregisterTimeout) return;
293
- unregisterTimeout = setTimeout(() => {
294
- unregisterTimeout = null;
295
- flush();
296
- }, UNREGISTER_DELAY_MS);
297
- }
298
- async function flush() {
299
- if (!conn || !onResubscribe) return;
300
- if (flushInFlight) return;
301
- flushInFlight = true;
302
- try {
303
- const newIds = new Set(refCounts.keys());
304
- if (setsEqual(newIds, subscribedIds)) return;
305
- const oldUnsub = currentEntityUnsub;
306
- const entityIds = [...newIds];
307
- currentEntityUnsub = await onResubscribe(conn, entityIds);
308
- subscribedIds = newIds;
309
- if (oldUnsub) try {
310
- await oldUnsub();
311
- } catch {}
312
- } finally {
313
- flushInFlight = false;
314
- }
315
- }
316
- function setsEqual(a, b) {
317
- if (a.size !== b.size) return false;
318
- for (const id of a) if (!b.has(id)) return false;
319
- return true;
320
- }
321
- //#endregion
322
226
  //#region src/core/types.ts
323
227
  /**
324
228
  * Extract domain from entity ID
@@ -327,6 +231,9 @@ function extractDomain(entityId) {
327
231
  return entityId.split(".")[0] ?? "";
328
232
  }
329
233
  //#endregion
234
+ //#region src/demo/demo-album-cover.ts
235
+ const DEMO_ALBUM_COVER = "data:image/webp;base64,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";
236
+ //#endregion
330
237
  //#region src/demo/energy-sim.ts
331
238
  const SOLAR_PEAK_W = 5200;
332
239
  const SUNRISE_HOUR = 6.5;
@@ -397,7 +304,10 @@ function solarPower(lt) {
397
304
  }
398
305
  /** Coarse solar elevation in degrees for sun.sun attributes. */
399
306
  function sunElevation(lt) {
400
- if (lt.hourFrac <= SUNRISE_HOUR || lt.hourFrac >= SUNSET_HOUR) return -12;
307
+ if (lt.hourFrac <= SUNRISE_HOUR || lt.hourFrac >= SUNSET_HOUR) {
308
+ const hoursFromHorizon = Math.min(Math.abs(lt.hourFrac - SUNRISE_HOUR), Math.abs(lt.hourFrac - SUNSET_HOUR), Math.abs(lt.hourFrac + 24 - SUNSET_HOUR));
309
+ return -12 * Math.min(1, hoursFromHorizon / 1.5);
310
+ }
401
311
  const frac = (lt.hourFrac - SUNRISE_HOUR) / 14;
402
312
  return 60 * Math.sin(frac * Math.PI);
403
313
  }
@@ -588,7 +498,8 @@ function sunEvents(timestampMs) {
588
498
  return {
589
499
  nextRising: new Date(nextRising).toISOString(),
590
500
  nextSetting: new Date(nextSetting).toISOString(),
591
- elevation: Math.round(sunElevation(lt) * 100) / 100
501
+ elevation: Math.round(sunElevation(lt) * 100) / 100,
502
+ rising: lt.hourFrac < 27 / 2 || lt.hourFrac >= 25.5
592
503
  };
593
504
  }
594
505
  /** The energy sensor entity ids the simulation drives. */
@@ -763,6 +674,9 @@ function demoEnergyPreferences() {
763
674
  }
764
675
  //#endregion
765
676
  //#region src/demo/demo-data.ts
677
+ function demoAssetUrl(path) {
678
+ return globalThis.location ? new URL(path, globalThis.location.origin).href : path;
679
+ }
766
680
  const now = (/* @__PURE__ */ new Date()).toISOString();
767
681
  let contextCounter = 0;
768
682
  function ctx() {
@@ -977,6 +891,18 @@ function makeWeatherEntity(w) {
977
891
  condition: "rainy",
978
892
  temperature: w.temp - 2,
979
893
  templow: w.low - 2
894
+ },
895
+ {
896
+ datetime: day(5),
897
+ condition: "cloudy",
898
+ temperature: w.temp - 1,
899
+ templow: w.low - 2
900
+ },
901
+ {
902
+ datetime: day(6),
903
+ condition: "partlycloudy",
904
+ temperature: w.temp + 1,
905
+ templow: w.low
980
906
  }
981
907
  ]
982
908
  });
@@ -1214,6 +1140,24 @@ function createDemoFixtures() {
1214
1140
  unit_of_measurement: "%",
1215
1141
  state_class: "measurement"
1216
1142
  }), { unit_of_measurement: "%" });
1143
+ for (const [zone, co2, fossil] of [[
1144
+ "de",
1145
+ "384",
1146
+ "52"
1147
+ ], [
1148
+ "pl",
1149
+ "692",
1150
+ "81"
1151
+ ]]) {
1152
+ add(makeEntity(`sensor.electricity_maps_co2_intensity_${zone}`, co2, {
1153
+ unit_of_measurement: "gCO2eq/kWh",
1154
+ state_class: "measurement"
1155
+ }), { unit_of_measurement: "gCO2eq/kWh" });
1156
+ add(makeEntity(`sensor.electricity_maps_fossil_fuel_percentage_${zone}`, fossil, {
1157
+ unit_of_measurement: "%",
1158
+ state_class: "measurement"
1159
+ }), { unit_of_measurement: "%" });
1160
+ }
1217
1161
  add(makeEntity("sensor.nordpool_current_price", "0.18", {
1218
1162
  unit_of_measurement: "EUR/kWh",
1219
1163
  state_class: "measurement"
@@ -1383,6 +1327,9 @@ function createDemoFixtures() {
1383
1327
  area_id: "entry",
1384
1328
  supported_features: 1
1385
1329
  });
1330
+ add(makeEntity("lock.side_gate", "unlocked", {}), { supported_features: 1 });
1331
+ add(makeEntity("binary_sensor.patio_door", "on", { device_class: "door" }), { device_class: "door" });
1332
+ add(makeEntity("binary_sensor.motion_kitchen", "on", { device_class: "motion" }), { device_class: "motion" });
1386
1333
  add(makeEntity("switch.coffee_machine", "off", { device_class: "outlet" }), {
1387
1334
  area_id: "kitchen",
1388
1335
  device_class: "outlet"
@@ -1395,17 +1342,18 @@ function createDemoFixtures() {
1395
1342
  media_title: "Lo-fi Beats",
1396
1343
  media_artist: "Chill Station",
1397
1344
  media_content_type: "music",
1345
+ entity_picture: DEMO_ALBUM_COVER,
1398
1346
  volume_level: .4,
1399
1347
  is_volume_muted: false,
1400
- supported_features: 152461
1348
+ supported_features: 152509
1401
1349
  }), {
1402
1350
  device_id: "speaker_lr",
1403
1351
  area_id: "living_room",
1404
- supported_features: 152461
1352
+ supported_features: 152509
1405
1353
  });
1406
1354
  for (const w of WEATHER_FIXTURES) add(makeWeatherEntity(w), { platform: "met" });
1407
1355
  add(makeEntity("camera.front_door_camera", "streaming", {
1408
- entity_picture: "/api/camera_proxy/camera.front_door_camera",
1356
+ entity_picture: demoAssetUrl("/demo/camera/front-door.webp"),
1409
1357
  frontend_stream_type: "hls",
1410
1358
  access_token: "demo-token"
1411
1359
  }), {
@@ -1458,7 +1406,8 @@ function createDemoFixtures() {
1458
1406
  friendly_name: "Sun",
1459
1407
  elevation: sun.elevation,
1460
1408
  next_rising: sun.nextRising,
1461
- next_setting: sun.nextSetting
1409
+ next_setting: sun.nextSetting,
1410
+ rising: sun.rising
1462
1411
  }), {});
1463
1412
  for (const s of [
1464
1413
  {
@@ -1634,6 +1583,7 @@ function tickEnergy() {
1634
1583
  sunEntity.attributes.elevation = sun.elevation;
1635
1584
  sunEntity.attributes.next_rising = sun.nextRising;
1636
1585
  sunEntity.attributes.next_setting = sun.nextSetting;
1586
+ sunEntity.attributes.rising = sun.rising;
1637
1587
  }
1638
1588
  }));
1639
1589
  }
@@ -1763,6 +1713,368 @@ function applyTurnOff(e, domain) {
1763
1713
  } else e.state = "off";
1764
1714
  }
1765
1715
  //#endregion
1716
+ //#region src/weather/fetch.ts
1717
+ /**
1718
+ * Weather Forecast Fetching
1719
+ *
1720
+ * @packageDocumentation
1721
+ */
1722
+ const DEMO_RAIN_CHANCE = {
1723
+ sunny: 0,
1724
+ "clear-night": 0,
1725
+ partlycloudy: 10,
1726
+ cloudy: 20,
1727
+ windy: 10,
1728
+ fog: 10,
1729
+ rainy: 80,
1730
+ pouring: 95,
1731
+ lightning: 60,
1732
+ "lightning-rainy": 85,
1733
+ snowy: 70,
1734
+ "snowy-rainy": 75,
1735
+ hail: 70,
1736
+ exceptional: 40
1737
+ };
1738
+ /**
1739
+ * In demo mode, derive forecasts from the entity's `forecast` attribute.
1740
+ * Hourly and daily are synthesised from the same source (daily entries
1741
+ * expanded into 24 hourly steps).
1742
+ */
1743
+ function buildDemoForecast(entityId, type) {
1744
+ const entity = state.entities[entityId];
1745
+ const raw = entity?.attributes?.forecast ?? [];
1746
+ if (raw.length === 0) return [];
1747
+ if (type === "daily" || type === "twice_daily") return raw.map((d) => {
1748
+ const chance = DEMO_RAIN_CHANCE[d.condition ?? ""] ?? 10;
1749
+ const templow = d.templow;
1750
+ return {
1751
+ ...d,
1752
+ temp_high: d.temp_high ?? d.temperature,
1753
+ temp_low: d.temp_low ?? templow,
1754
+ precipitation_probability: d.precipitation_probability ?? chance
1755
+ };
1756
+ });
1757
+ const start = /* @__PURE__ */ new Date();
1758
+ start.setMinutes(0, 0, 0);
1759
+ const today = raw[0];
1760
+ const tomorrow = raw[1] ?? today;
1761
+ const tHi = today?.temperature ?? 18;
1762
+ const tLo = today?.templow ?? tHi - 5;
1763
+ const tNext = tomorrow?.temperature ?? tHi;
1764
+ const out = [];
1765
+ for (let i = 0; i < 24; i++) {
1766
+ const dt = new Date(start.getTime() + i * 60 * 60 * 1e3);
1767
+ const hour = dt.getHours();
1768
+ const dayMix = i < 12 ? 0 : (i - 12) / 12;
1769
+ const baseHi = tHi * (1 - dayMix) + tNext * dayMix;
1770
+ const phase = (hour - 6) / 24 * Math.PI * 2;
1771
+ const swing = (baseHi - tLo) / 2;
1772
+ const mid = (baseHi + tLo) / 2;
1773
+ const source = i < 8 ? today : tomorrow;
1774
+ const night = hour < 6 || hour >= 21;
1775
+ const condition = source?.condition ?? entity?.state ?? "cloudy";
1776
+ out.push({
1777
+ datetime: dt.toISOString(),
1778
+ temperature: Math.round((mid + Math.sin(phase) * swing) * 10) / 10,
1779
+ condition: night && condition === "sunny" ? "clear-night" : condition,
1780
+ precipitation_probability: DEMO_RAIN_CHANCE[condition] ?? 10
1781
+ });
1782
+ }
1783
+ return out;
1784
+ }
1785
+ /**
1786
+ * Fetch forecast for a single entity and type
1787
+ */
1788
+ async function fetchForecast(entityId, type) {
1789
+ if (isDemoMode()) return buildDemoForecast(entityId, type);
1790
+ const conn = privilegedConn();
1791
+ if (!conn) throw new Error("Not connected");
1792
+ return new Promise((resolve, reject) => {
1793
+ let resolved = false;
1794
+ let unsubscribe = null;
1795
+ const timeout = setTimeout(() => {
1796
+ if (!resolved && unsubscribe) {
1797
+ resolved = true;
1798
+ unsubscribe().then(() => reject(/* @__PURE__ */ new Error("Forecast subscription timeout"))).catch(() => reject(/* @__PURE__ */ new Error("Forecast subscription timeout")));
1799
+ }
1800
+ }, 1e4);
1801
+ conn.subscribeMessage((event) => {
1802
+ if (resolved) return;
1803
+ if (event && typeof event === "object" && "forecast" in event) {
1804
+ resolved = true;
1805
+ clearTimeout(timeout);
1806
+ const forecast = event.forecast;
1807
+ const forecastArray = Array.isArray(forecast) ? forecast : [];
1808
+ if (unsubscribe) unsubscribe().then(() => resolve(forecastArray)).catch(() => resolve(forecastArray));
1809
+ else resolve(forecastArray);
1810
+ }
1811
+ }, {
1812
+ type: "weather/subscribe_forecast",
1813
+ entity_id: entityId,
1814
+ forecast_type: type === "twice_daily" ? "twice_daily" : type
1815
+ }).then((unsubFn) => {
1816
+ unsubscribe = unsubFn;
1817
+ }).catch((error) => {
1818
+ resolved = true;
1819
+ clearTimeout(timeout);
1820
+ reject(error);
1821
+ });
1822
+ });
1823
+ }
1824
+ /**
1825
+ * Fetch forecasts for a single entity and multiple types
1826
+ */
1827
+ async function fetchForecasts(entityId, types) {
1828
+ const forecasts = {};
1829
+ const loading = {};
1830
+ const errors = {};
1831
+ const lastFetched = {};
1832
+ const fetchPromises = types.map(async (type) => {
1833
+ loading[type] = true;
1834
+ errors[type] = null;
1835
+ try {
1836
+ const forecast = await fetchForecast(entityId, type);
1837
+ forecasts[type] = forecast;
1838
+ lastFetched[type] = Date.now();
1839
+ loading[type] = false;
1840
+ } catch (error) {
1841
+ errors[type] = error instanceof Error ? error : new Error(String(error));
1842
+ forecasts[type] = [];
1843
+ loading[type] = false;
1844
+ lastFetched[type] = null;
1845
+ }
1846
+ });
1847
+ await Promise.all(fetchPromises);
1848
+ return {
1849
+ entityId,
1850
+ forecasts,
1851
+ loading,
1852
+ errors,
1853
+ lastFetched
1854
+ };
1855
+ }
1856
+ /**
1857
+ * Fetch forecast data with error handling
1858
+ */
1859
+ async function fetchForecastData(entityId, type) {
1860
+ try {
1861
+ return {
1862
+ entityId,
1863
+ type,
1864
+ forecast: await fetchForecast(entityId, type),
1865
+ loading: false,
1866
+ error: null,
1867
+ lastFetched: Date.now()
1868
+ };
1869
+ } catch (error) {
1870
+ return {
1871
+ entityId,
1872
+ type,
1873
+ forecast: [],
1874
+ loading: false,
1875
+ error: error instanceof Error ? error : new Error(String(error)),
1876
+ lastFetched: null
1877
+ };
1878
+ }
1879
+ }
1880
+ //#endregion
1881
+ //#region src/weather/track.ts
1882
+ /**
1883
+ * Ref-counted live forecasts, the weather analog of `calendar/track`. Each
1884
+ * tracked (entity, type) holds one `weather/subscribe_forecast`; Home Assistant
1885
+ * pushes the current forecast, then every new one. Data lands in
1886
+ * `state.forecasts`, the same place `getForecasts` writes.
1887
+ */
1888
+ /** Debounce for untrack, so dashboard switches don't churn subscriptions. */
1889
+ const UNTRACK_FLUSH_DELAY_MS = 5e3;
1890
+ const counts = /* @__PURE__ */ new Map();
1891
+ const active = /* @__PURE__ */ new Map();
1892
+ const chains = /* @__PURE__ */ new Map();
1893
+ let untrackTimer = null;
1894
+ const keyOf = (entityId, type) => `${entityId}|${type}`;
1895
+ function parse(key) {
1896
+ const i = key.lastIndexOf("|");
1897
+ return [key.slice(0, i), key.slice(i + 1)];
1898
+ }
1899
+ /**
1900
+ * Keep a forecast live while tracked. Returns the untrack function. Safe while
1901
+ * disconnected: the subscription starts on the next `forceResubscribeForecasts()`.
1902
+ */
1903
+ function trackForecast(entityId, type) {
1904
+ const key = keyOf(entityId, type);
1905
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1906
+ queueMicrotask(() => {
1907
+ reconcile$1(key);
1908
+ });
1909
+ let untracked = false;
1910
+ return () => {
1911
+ if (untracked) return;
1912
+ untracked = true;
1913
+ const n = (counts.get(key) ?? 1) - 1;
1914
+ if (n > 0) counts.set(key, n);
1915
+ else counts.delete(key);
1916
+ if (untrackTimer) return;
1917
+ untrackTimer = setTimeout(() => {
1918
+ untrackTimer = null;
1919
+ flushAll();
1920
+ }, UNTRACK_FLUSH_DELAY_MS);
1921
+ };
1922
+ }
1923
+ async function flushAll() {
1924
+ const keys = /* @__PURE__ */ new Set([...counts.keys(), ...active.keys()]);
1925
+ await Promise.all([...keys].map(reconcile$1));
1926
+ }
1927
+ function reconcile$1(key) {
1928
+ const next = (chains.get(key) ?? Promise.resolve()).then(() => runReconcile(key));
1929
+ chains.set(key, next.catch(() => {}));
1930
+ return next;
1931
+ }
1932
+ function write(entityId, type, forecast, error) {
1933
+ setState(produce((s) => {
1934
+ const rec = s.forecasts[entityId] ??= {
1935
+ entityId,
1936
+ forecasts: {},
1937
+ loading: {},
1938
+ errors: {},
1939
+ lastFetched: {}
1940
+ };
1941
+ if (forecast) {
1942
+ rec.forecasts[type] = forecast;
1943
+ rec.lastFetched[type] = Date.now();
1944
+ }
1945
+ rec.loading[type] = false;
1946
+ rec.errors[type] = error;
1947
+ }));
1948
+ }
1949
+ async function runReconcile(key) {
1950
+ const [entityId, type] = parse(key);
1951
+ const current = active.get(key);
1952
+ if (!counts.has(key)) {
1953
+ active.delete(key);
1954
+ chains.delete(key);
1955
+ try {
1956
+ await current?.unsub?.();
1957
+ } catch {}
1958
+ return;
1959
+ }
1960
+ if (current?.unsub) return;
1961
+ if (isDemoMode()) {
1962
+ active.set(key, { unsub: () => {} });
1963
+ write(entityId, type, buildDemoForecast(entityId, type), null);
1964
+ return;
1965
+ }
1966
+ const conn = privilegedConn();
1967
+ if (!conn) return;
1968
+ const entry = { unsub: null };
1969
+ active.set(key, entry);
1970
+ try {
1971
+ entry.unsub = await conn.subscribeMessage((msg) => write(entityId, type, Array.isArray(msg?.forecast) ? msg.forecast : [], null), {
1972
+ type: "weather/subscribe_forecast",
1973
+ entity_id: entityId,
1974
+ forecast_type: type
1975
+ });
1976
+ } catch (err) {
1977
+ active.delete(key);
1978
+ write(entityId, type, null, err instanceof Error ? err : new Error(String(err)));
1979
+ }
1980
+ }
1981
+ //#endregion
1982
+ //#region src/connection/subscription-manager.ts
1983
+ 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.");
1984
+ globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
1985
+ /** Ref counts — how many consumers need each entity */
1986
+ const refCounts = /* @__PURE__ */ new Map();
1987
+ /** Current HA subscription unsub function */
1988
+ let currentEntityUnsub = null;
1989
+ /** Snapshot of entity IDs in the current HA subscription */
1990
+ let subscribedIds = /* @__PURE__ */ new Set();
1991
+ /** Connection reference for resubscribing */
1992
+ let conn = null;
1993
+ /** Pending flush timer (microtask for register, timeout for unregister) */
1994
+ let flushScheduled = false;
1995
+ /** Delayed unregister timeout */
1996
+ let unregisterTimeout = null;
1997
+ /** Callback invoked on resubscribe — set by subscriptions.ts */
1998
+ let onResubscribe = null;
1999
+ /** Delay before unregistered entities are actually removed from subscription */
2000
+ const UNREGISTER_DELAY_MS = 5e3;
2001
+ /** Guard against concurrent flush() calls */
2002
+ let flushInFlight = false;
2003
+ /**
2004
+ * Register an entity for live updates. Returns an unregister function.
2005
+ * Call from useEntity/useEntities hooks via onCleanup.
2006
+ */
2007
+ function registerEntity(entityId) {
2008
+ refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
2009
+ scheduleFlush();
2010
+ return () => unregisterEntity(entityId);
2011
+ }
2012
+ /**
2013
+ * Set the connection reference used for resubscribing.
2014
+ */
2015
+ function setManagerConnection(connection) {
2016
+ conn = connection;
2017
+ if (refCounts.size > 0) scheduleFlush();
2018
+ }
2019
+ /**
2020
+ * Set the resubscribe callback. Called by subscriptions.ts to wire
2021
+ * the manager into the actual WS subscription lifecycle.
2022
+ */
2023
+ function setResubscribeHandler(handler) {
2024
+ onResubscribe = handler;
2025
+ }
2026
+ /**
2027
+ * Force a resubscribe with the current active set.
2028
+ * Used on reconnect.
2029
+ */
2030
+ async function forceResubscribe() {
2031
+ await flush();
2032
+ }
2033
+ function unregisterEntity(entityId) {
2034
+ const count = (refCounts.get(entityId) ?? 1) - 1;
2035
+ if (count <= 0) refCounts.delete(entityId);
2036
+ else refCounts.set(entityId, count);
2037
+ scheduleDelayedFlush();
2038
+ }
2039
+ function scheduleFlush() {
2040
+ if (flushScheduled) return;
2041
+ flushScheduled = true;
2042
+ queueMicrotask(() => {
2043
+ flushScheduled = false;
2044
+ flush();
2045
+ });
2046
+ }
2047
+ function scheduleDelayedFlush() {
2048
+ if (unregisterTimeout) return;
2049
+ unregisterTimeout = setTimeout(() => {
2050
+ unregisterTimeout = null;
2051
+ flush();
2052
+ }, UNREGISTER_DELAY_MS);
2053
+ }
2054
+ async function flush() {
2055
+ if (!conn || !onResubscribe) return;
2056
+ if (flushInFlight) return;
2057
+ flushInFlight = true;
2058
+ try {
2059
+ const newIds = new Set(refCounts.keys());
2060
+ if (setsEqual(newIds, subscribedIds)) return;
2061
+ const oldUnsub = currentEntityUnsub;
2062
+ const entityIds = [...newIds];
2063
+ currentEntityUnsub = await onResubscribe(conn, entityIds);
2064
+ subscribedIds = newIds;
2065
+ if (oldUnsub) try {
2066
+ await oldUnsub();
2067
+ } catch {}
2068
+ } finally {
2069
+ flushInFlight = false;
2070
+ }
2071
+ }
2072
+ function setsEqual(a, b) {
2073
+ if (a.size !== b.size) return false;
2074
+ for (const id of a) if (!b.has(id)) return false;
2075
+ return true;
2076
+ }
2077
+ //#endregion
1766
2078
  //#region src/commands/service.ts
1767
2079
  /**
1768
2080
  * Call a Home Assistant service
@@ -1861,6 +2173,68 @@ async function batchEntityUpdates(updates) {
1861
2173
  return Promise.all(updates.map((u) => updateEntity(u.entityId, u.updates)));
1862
2174
  }
1863
2175
  //#endregion
2176
+ //#region src/demo/sensor-history.ts
2177
+ const DAY_MS = 864e5;
2178
+ function seedOf(entityId) {
2179
+ let h = 0;
2180
+ for (let i = 0; i < entityId.length; i++) h = h * 31 + entityId.charCodeAt(i) | 0;
2181
+ return Math.abs(h) % 1e3 / 1e3;
2182
+ }
2183
+ /**
2184
+ * A slow daily swing plus a faster ripple, pinned so the last point is the reading
2185
+ * the demo shows now. Deterministic per entity, so previews repeat.
2186
+ */
2187
+ function synthesizeSensorHistory(entityId, current, startMs, endMs, stepMs = 9e5) {
2188
+ const amplitude = Math.max(Math.abs(current) * .08, .6);
2189
+ const phase = seedOf(entityId) * 2 * Math.PI;
2190
+ const wave = (t) => Math.sin(2 * Math.PI * t / DAY_MS + phase) + .15 * Math.sin(2 * Math.PI * t * 3 / DAY_MS + 2 * phase);
2191
+ const decimals = Number.isInteger(current) ? 1 : 2;
2192
+ const points = [];
2193
+ for (let t = startMs; t <= endMs; t += stepMs) {
2194
+ const value = current + amplitude * (wave(t) - wave(endMs));
2195
+ points.push({
2196
+ lu: Math.round(t / 1e3),
2197
+ s: value.toFixed(decimals)
2198
+ });
2199
+ }
2200
+ const last = points.at(-1);
2201
+ if (last) last.s = String(current);
2202
+ return points;
2203
+ }
2204
+ function currentReading(id) {
2205
+ if (!id.startsWith("sensor.")) return void 0;
2206
+ const current = Number(state.entities[id]?.state);
2207
+ return Number.isFinite(current) ? current : void 0;
2208
+ }
2209
+ /** History for a demo sensor with a numeric reading; undefined for anything else. */
2210
+ function numericSensorHistory(id, startMs, endMs) {
2211
+ const current = currentReading(id);
2212
+ return current === void 0 ? void 0 : synthesizeSensorHistory(id, current, startMs, endMs);
2213
+ }
2214
+ /** Hour or day buckets of the same curve, shaped like recorder statistics. */
2215
+ function numericSensorStatistics(id, startMs, endMs, period) {
2216
+ const points = numericSensorHistory(id, startMs, endMs);
2217
+ if (!points) return [];
2218
+ const span = period === "hour" ? 36e5 : DAY_MS;
2219
+ const buckets = [];
2220
+ for (let start = startMs; start < endMs; start += span) {
2221
+ const end = start + span;
2222
+ const values = points.filter((p) => p.lu * 1e3 >= start && p.lu * 1e3 < end).map((p) => Number(p.s));
2223
+ if (values.length === 0) continue;
2224
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
2225
+ buckets.push({
2226
+ start,
2227
+ end,
2228
+ mean,
2229
+ min: Math.min(...values),
2230
+ max: Math.max(...values),
2231
+ sum: 0,
2232
+ change: 0
2233
+ });
2234
+ }
2235
+ return buckets;
2236
+ }
2237
+ //#endregion
1864
2238
  //#region src/entities/views.ts
1865
2239
  /**
1866
2240
  * Get icon for an entity from registry or attributes
@@ -2142,7 +2516,7 @@ async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
2142
2516
  const result = {};
2143
2517
  const statPeriod = period === "hour" || period === "day" ? period : "day";
2144
2518
  const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
2145
- for (const id of statisticIds) result[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : [];
2519
+ for (const id of statisticIds) result[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : numericSensorStatistics(id, startTime.getTime(), endMs, statPeriod);
2146
2520
  return result;
2147
2521
  }
2148
2522
  const params = {
@@ -2160,4 +2534,4 @@ async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
2160
2534
  return result;
2161
2535
  }
2162
2536
  //#endregion
2163
- export { extractDomain as A, state as B, startDemoEnergyTicker as C, demoEnergyPreferences as D, createDemoFixtures as E, forceResubscribeCalendars as F, trackCalendarEvents as I, privilegedConn as L, registerEntity as M, setManagerConnection as N, isEnergyEntity as O, setResubscribeHandler as P, setPrivilegedConn as R, loadDemoData as S, unloadDemoData as T, turnOff as _, getAreaView as a, applyDemoServiceCall as b, entityViewEquals as c, getEntityViews as d, batchEntityUpdates as f, toggle as g, sendCommand as h, buildDeviceView as i, forceResubscribe as j, synthesizeEnergyHistory as k, getAllEntityViews as l, callService as m, normalizeStatisticTime as n, getAreaViews as o, batchServiceCalls as p, buildAreaView as r, buildEntityView as s, fetchStatisticsDuringPeriod as t, getEntityView as u, turnOn as v, stopDemoEnergyTicker as w, isDemoMode as x, updateEntity as y, setState as z };
2537
+ export { isDemoMode as A, extractDomain as B, setManagerConnection as C, fetchForecastData as D, fetchForecast as E, createDemoFixtures as F, setState as G, trackCalendarEvents as H, demoAssetUrl as I, state as K, demoEnergyPreferences as L, startDemoEnergyTicker as M, stopDemoEnergyTicker as N, fetchForecasts as O, unloadDemoData as P, isEnergyEntity as R, registerEntity as S, trackForecast as T, privilegedConn as U, forceResubscribeCalendars as V, setPrivilegedConn as W, toggle as _, getAreaView as a, updateEntity as b, entityViewEquals as c, getEntityViews as d, numericSensorHistory as f, sendCommand as g, callService as h, buildDeviceView as i, loadDemoData as j, applyDemoServiceCall as k, getAllEntityViews as l, batchServiceCalls as m, normalizeStatisticTime as n, getAreaViews as o, batchEntityUpdates as p, buildAreaView as r, buildEntityView as s, fetchStatisticsDuringPeriod as t, getEntityView as u, turnOff as v, setResubscribeHandler as w, forceResubscribe as x, turnOn as y, synthesizeEnergyHistory as z };
@@ -1,2 +1,2 @@
1
- import { t as runHaBridgeWorker } from "../worker-main-D_wyGAIG.js";
1
+ import { t as runHaBridgeWorker } from "../worker-main-DamaVb7B.js";
2
2
  export { runHaBridgeWorker };
@@ -214,7 +214,14 @@ function runHaBridgeWorker(scope) {
214
214
  stopHeartbeat();
215
215
  heartbeat = setInterval(() => {
216
216
  const timer = new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("ping timeout")), HEARTBEAT_TIMEOUT_MS));
217
- Promise.race([c.ping(), timer]).catch(() => c.reconnect());
217
+ Promise.race([c.ping(), timer]).catch(() => {
218
+ post({
219
+ k: "conn",
220
+ state: "reconnecting",
221
+ reason: "ping_timeout"
222
+ });
223
+ c.reconnect();
224
+ });
218
225
  }, HEARTBEAT_INTERVAL_MS);
219
226
  }
220
227
  function stopHeartbeat() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/sync-layer",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Type-safe, reactive state synchronization layer for Home Assistant",
5
5
  "repository": {
6
6
  "type": "git",
@@ -51,11 +51,11 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "home-assistant-js-websocket": "^9.6.0",
54
- "@glasshome/widget-contract": "0.2.1"
54
+ "@glasshome/widget-contract": "0.3.0"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@glasshome/ha-types": "0.1.2",
58
- "@types/bun": "latest",
58
+ "@types/bun": "^1.4.0",
59
59
  "solid-js": "^1.9.0",
60
60
  "tsdown": "^0.22.14",
61
61
  "typescript": "^7.0.2"