@glasshome/sync-layer 0.9.0 → 0.10.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.js CHANGED
@@ -1,6 +1,5 @@
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
- import "home-assistant-js-websocket";
1
+ import { $ as setPrivilegedConn, A as unloadDemoData, B as getAreaView, C as demoTimeMap, D as resetDemo, E as loadDemoHouse, F as MAX_HISTORY_POINTS, G as getEntityView, H as buildEntityView, I as appendHistoryPoint, J as forceResubscribe, K as getEntityViews, L as bulkAppendHistoryPoints, M as demoEnergyPreferences, N as isEnergyEntity, O as startDemoEnergyTicker, P as synthesizeEnergyHistory, Q as privilegedConn, R as buildAreaView, S as demoReplayModel, T as loadDemoData, U as entityViewEquals, V as getAreaViews, W as getAllEntityViews, X as setManagerConnection, Y as registerEntity, Z as setResubscribeHandler, _ as applyDemoServiceCall, a as fetchForecastData, b as demoCallLog, c as normalizeStatisticTime, d as callService, et as setState, f as sendCommand, g as updateEntity, h as turnOn, i as fetchForecast, j as demoAssetUrl, k as stopDemoEnergyTicker, l as batchEntityUpdates, m as turnOff, o as fetchForecasts, p as toggle, q as extractDomain, s as fetchStatisticsDuringPeriod, t as forceResubscribeCalendars, tt as state, u as batchServiceCalls, v as applyVisitorHolds, w as isDemoMode, x as demoModel, y as createDemoFixtures, z as buildDeviceView } from "./track-BAfgOjMk.js";
2
+ import { t as runHaBridgeWorker } from "./worker-main-B6eeHPDQ.js";
4
3
  import { produce } from "solid-js/store";
5
4
  //#region src/core/reducers.ts
6
5
  /**
@@ -136,640 +135,370 @@ function updateStatisticsMetadata(metadata) {
136
135
  }));
137
136
  }
138
137
  //#endregion
139
- //#region src/history/constants.ts
138
+ //#region src/connection/manager.ts
140
139
  /**
141
- * History Constants
142
- *
143
- * Constants and utilities for history fetching and processing.
144
- * Based on Home Assistant frontend implementation.
145
- *
146
- * @packageDocumentation
140
+ * Disconnect from Home Assistant
147
141
  */
148
- const DOMAINS_USE_LAST_UPDATED = [
149
- "climate",
150
- "humidifier",
151
- "water_heater"
152
- ];
153
- const NEED_ATTRIBUTE_DOMAINS = [
154
- "climate",
155
- "humidifier",
156
- "input_datetime",
157
- "water_heater",
158
- "person",
159
- "device_tracker"
160
- ];
161
- const LINE_ATTRIBUTES_TO_KEEP = [
162
- "temperature",
163
- "current_temperature",
164
- "target_temp_low",
165
- "target_temp_high",
166
- "hvac_action",
167
- "humidity",
168
- "mode",
169
- "action",
170
- "current_humidity"
171
- ];
142
+ function disconnect() {
143
+ setState(produce((s) => {
144
+ setPrivilegedConn(null);
145
+ s.connectionState = "disconnected";
146
+ }));
147
+ }
172
148
  /**
173
- * Check if an entity needs attributes in history response
149
+ * Check if currently connected
174
150
  */
175
- function entityIdHistoryNeedsAttributes(entityId) {
176
- const domain = extractDomain(entityId);
177
- if (!state.entities[entityId]) return NEED_ATTRIBUTE_DOMAINS.includes(domain);
178
- return NEED_ATTRIBUTE_DOMAINS.includes(domain);
151
+ function isConnected() {
152
+ return state.connectionState === "connected";
153
+ }
154
+ /**
155
+ * Get the connection's connected flag, for a connection indicator in the UI.
156
+ */
157
+ function getConnectionState() {
158
+ return state.connectionState === "connected";
159
+ }
160
+ async function loadInitialData(conn) {
161
+ try {
162
+ const [states, entityRegistry, deviceRegistry, areaRegistry, floorRegistry, labelRegistry, config] = await Promise.all([
163
+ conn.sendMessagePromise({ type: "get_states" }),
164
+ conn.sendMessagePromise({ type: "config/entity_registry/list" }),
165
+ conn.sendMessagePromise({ type: "config/device_registry/list" }),
166
+ conn.sendMessagePromise({ type: "config/area_registry/list" }),
167
+ conn.sendMessagePromise({ type: "config/floor_registry/list" }),
168
+ conn.sendMessagePromise({ type: "config/label_registry/list" }),
169
+ conn.sendMessagePromise({ type: "get_config" })
170
+ ]);
171
+ bulkUpdateEntities(states);
172
+ bulkUpdateEntityRegistry(entityRegistry);
173
+ setState("config", config);
174
+ setState(produce((s) => {
175
+ for (const device of deviceRegistry) s.devices[device.id] = device;
176
+ for (const areaApi of areaRegistry) {
177
+ const area = {
178
+ id: (areaApi.area_id || areaApi.id) ?? "",
179
+ name: areaApi.name,
180
+ normalized_name: areaApi.normalized_name || areaApi.name.toLowerCase().replace(/\s+/g, "_"),
181
+ aliases: Array.isArray(areaApi.aliases) ? areaApi.aliases : [],
182
+ floor_id: areaApi.floor_id ?? null,
183
+ humidity_entity_id: areaApi.humidity_entity_id ?? null,
184
+ icon: areaApi.icon ?? null,
185
+ labels: Array.isArray(areaApi.labels) ? areaApi.labels : [],
186
+ picture: areaApi.picture ?? null,
187
+ temperature_entity_id: areaApi.temperature_entity_id ?? null,
188
+ created_at: typeof areaApi.created_at === "number" ? (/* @__PURE__ */ new Date(areaApi.created_at * 1e3)).toISOString() : areaApi.created_at,
189
+ modified_at: typeof areaApi.modified_at === "number" ? (/* @__PURE__ */ new Date(areaApi.modified_at * 1e3)).toISOString() : areaApi.modified_at
190
+ };
191
+ s.areas[area.id] = area;
192
+ }
193
+ for (const floor of floorRegistry) s.floors[floor.floor_id] = floor;
194
+ for (const label of labelRegistry) s.labels[label.label_id] = label;
195
+ }));
196
+ } catch (error) {
197
+ console.error("Error loading initial data:", error);
198
+ throw error;
199
+ }
179
200
  }
180
201
  //#endregion
181
- //#region src/history/fetch.ts
202
+ //#region src/query/projections.ts
182
203
  /**
183
- * History Fetching
204
+ * Project entities to IDs only
184
205
  *
185
- * @packageDocumentation
206
+ * @param entities - Array of entity views
207
+ * @returns Array of entity IDs
186
208
  */
209
+ function projectToIds(entities) {
210
+ return entities.map((e) => e.id);
211
+ }
187
212
  /**
188
- * Fetch history for entities during a time period
213
+ * Pick specific fields from entities
214
+ *
215
+ * @param entities - Array of entity views
216
+ * @param keys - Array of field names to include
217
+ * @returns Array of objects with only specified fields
189
218
  */
190
- async function fetchHistory(options) {
191
- const { startTime, endTime, entityIds = [], includeStartTimeState = true, significantChangesOnly = true, minimalResponse = true, noAttributes: noAttributesOverride } = options;
192
- if (isDemoMode()) {
193
- const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
219
+ function projectPick(entities, keys) {
220
+ return entities.map((entity) => {
194
221
  const result = {};
195
- for (const id of entityIds) {
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) => ({
199
- s: p.s,
200
- a: {},
201
- lu: p.lu
202
- }));
203
- }
222
+ for (const key of keys) result[key] = entity[key];
204
223
  return result;
205
- }
206
- const startTimeStr = startTime.toISOString();
207
- const endTimeStr = endTime?.toISOString();
208
- const params = {
209
- type: "history/history_during_period",
210
- start_time: startTimeStr,
211
- minimal_response: minimalResponse,
212
- no_attributes: noAttributesOverride ?? (entityIds.length === 0 ? false : !entityIds.some((entityId) => entityIdHistoryNeedsAttributes(entityId))),
213
- include_start_time_state: includeStartTimeState,
214
- significant_changes_only: significantChangesOnly
215
- };
216
- if (endTimeStr) params.end_time = endTimeStr;
217
- if (entityIds.length !== 0) params.entity_ids = entityIds;
218
- return await sendCommand(params) ?? {};
224
+ });
219
225
  }
220
226
  /**
221
- * Fetch history for a single entity
227
+ * Pluck a single field from entities
228
+ *
229
+ * Supports dot-notation for nested fields (e.g., "attributes.brightness")
230
+ *
231
+ * @param entities - Array of entity views
232
+ * @param key - Field name or dot-path to extract
233
+ * @returns Array of field values
222
234
  */
223
- async function fetchEntityHistory(entityId, options) {
224
- const { startTime, endTime, includeStartTimeState = true, significantChangesOnly = true, minimalResponse = false, noAttributes = false } = options;
225
- try {
226
- const entityHistory = (await fetchHistory({
227
- startTime,
228
- endTime,
229
- entityIds: [entityId],
230
- includeStartTimeState,
231
- significantChangesOnly,
232
- minimalResponse,
233
- noAttributes
234
- }))[entityId] ?? [];
235
- return {
236
- entityId,
237
- timeline: entityHistory.map((s) => ({
238
- timestamp: s.lu,
239
- state: s.s,
240
- attributes: s.a,
241
- lastChanged: s.lc,
242
- lastUpdated: s.lu
243
- })),
244
- entityHistory,
245
- loading: false,
246
- error: null,
247
- lastFetched: Date.now()
248
- };
249
- } catch (error) {
250
- return {
251
- entityId,
252
- timeline: [],
253
- entityHistory: [],
254
- loading: false,
255
- error: error instanceof Error ? error : new Error(String(error)),
256
- lastFetched: null
257
- };
258
- }
235
+ function projectPluck(entities, key) {
236
+ return entities.map((entity) => getNestedValue(entity, key));
259
237
  }
260
238
  /**
261
- * Convert compressed history state to timeline state
239
+ * Map entities using a custom function
240
+ *
241
+ * @param entities - Array of entity views
242
+ * @param mapper - Function to transform each entity
243
+ * @returns Array of mapped values
262
244
  */
263
- function historyStateToTimeline(s) {
264
- return {
265
- timestamp: s.lu,
266
- state: s.s,
267
- attributes: s.a,
268
- lastChanged: s.lc,
269
- lastUpdated: s.lu
270
- };
271
- }
272
- //#endregion
273
- //#region src/history/query.ts
274
- /** Hard cap on retained points per entity timeline. */
275
- const MAX_HISTORY_POINTS = 5760;
276
- /** Live tracker count per entity. Ref-counted because a timeline is shared: two
277
- * widgets on the same entity must not let the first unmount delete the store
278
- * data the second is still rendering (appendHistoryPoint would then silently
279
- * drop every update and the survivor's chart would freeze). */
280
- const trackedEntities = /* @__PURE__ */ new Map();
281
- /** Check if an entity has active history tracking. */
282
- function isHistoryTracked(entityId) {
283
- return trackedEntities.has(entityId);
284
- }
285
- /** Start tracking history for an entity. Fetches initial backfill. */
286
- async function trackEntityHistory(entityId, options) {
287
- trackedEntities.set(entityId, (trackedEntities.get(entityId) ?? 0) + 1);
288
- const historyData = await fetchEntityHistory(entityId, options);
289
- setState(produce((s) => {
290
- s.history[entityId] = historyData;
291
- }));
292
- return historyData;
293
- }
294
- /** Release one tracker. The entity's data is cleared only when the last one goes. */
295
- function untrackEntityHistory(entityId) {
296
- const remaining = (trackedEntities.get(entityId) ?? 0) - 1;
297
- if (remaining > 0) {
298
- trackedEntities.set(entityId, remaining);
299
- return;
300
- }
301
- trackedEntities.delete(entityId);
302
- setState(produce((s) => {
303
- delete s.history[entityId];
304
- }));
245
+ function projectMap(entities, mapper) {
246
+ return entities.map(mapper);
305
247
  }
306
248
  /**
307
- * Append a state change to an entity's history.
308
- * Called by the entity subscription handler when a tracked entity changes.
249
+ * Get nested value from object using dot-notation
250
+ *
251
+ * @param obj - Object to extract value from
252
+ * @param path - Dot-notation path (e.g., "attributes.brightness")
253
+ * @returns Value at path or undefined
254
+ *
255
+ * @example
256
+ * ```typescript
257
+ * const entity = { attributes: { brightness: 255 } };
258
+ * getNestedValue(entity, "attributes.brightness"); // 255
259
+ * ```
309
260
  */
310
- function appendHistoryPoint(entityId, stateValue, attributes, lastUpdated, lastChanged) {
311
- setState(produce((s) => {
312
- const history = s.history[entityId];
313
- if (!history) return;
314
- const point = {
315
- s: stateValue,
316
- a: attributes,
317
- lu: lastUpdated,
318
- lc: lastChanged
319
- };
320
- history.entityHistory.push(point);
321
- history.timeline.push({
322
- timestamp: lastUpdated,
323
- state: stateValue,
324
- attributes,
325
- lastChanged,
326
- lastUpdated
327
- });
328
- }));
329
- }
330
- /**
331
- * Append multiple history points in a single setState(produce(...)).
332
- * Called by the rAF-batched subscription flush.
333
- */
334
- function bulkAppendHistoryPoints(points) {
335
- if (points.length === 0) return;
336
- setState(produce((s) => {
337
- const touched = /* @__PURE__ */ new Set();
338
- for (const { entityId, stateValue, attributes, lastUpdated, lastChanged } of points) {
339
- const history = s.history[entityId];
340
- if (!history) continue;
341
- history.entityHistory.push({
342
- s: stateValue,
343
- a: attributes,
344
- lu: lastUpdated,
345
- lc: lastChanged
346
- });
347
- history.timeline.push({
348
- timestamp: lastUpdated,
349
- state: stateValue,
350
- attributes,
351
- lastChanged,
352
- lastUpdated
353
- });
354
- touched.add(entityId);
355
- }
356
- for (const entityId of touched) {
357
- const history = s.history[entityId];
358
- if (!history) continue;
359
- if (history.entityHistory.length > 5760) history.entityHistory.splice(0, history.entityHistory.length - MAX_HISTORY_POINTS);
360
- if (history.timeline.length > 5760) history.timeline.splice(0, history.timeline.length - MAX_HISTORY_POINTS);
361
- }
362
- }));
261
+ function getNestedValue(obj, path) {
262
+ const parts = path.split(".");
263
+ let current = obj;
264
+ for (const part of parts) {
265
+ if (current === null || current === void 0) return;
266
+ current = current[part];
267
+ }
268
+ return current;
363
269
  }
364
270
  //#endregion
365
- //#region src/connection/subscriptions.ts
366
- /** Registry subscription cleanup functions (entity sub managed by subscription-manager) */
367
- let registrySubscriptions = [];
368
- /** Buffered messages waiting for next animation frame */
369
- let messageBuffer = [];
370
- let rafScheduled = false;
371
- function bufferMessage(message) {
372
- messageBuffer.push(message);
373
- if (!rafScheduled) {
374
- rafScheduled = true;
375
- (typeof requestAnimationFrame === "function" ? requestAnimationFrame : queueMicrotask)(flushMessageBuffer);
271
+ //#region src/query/area-builder.ts
272
+ var AreaQueryBuilderImpl = class AreaQueryBuilderImpl {
273
+ queryState;
274
+ constructor(initialState) {
275
+ this.queryState = initialState ?? {
276
+ filters: [],
277
+ sorts: []
278
+ };
376
279
  }
377
- }
378
- function flushMessageBuffer() {
379
- rafScheduled = false;
380
- const messages = messageBuffer;
381
- messageBuffer = [];
382
- if (messages.length === 0) return;
383
- setState(produce((s) => {
384
- for (const message of messages) {
385
- if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
386
- if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
387
- if (message.r) for (const entityId of message.r) delete s.entities[entityId];
388
- }
389
- }));
390
- const historyPoints = [];
391
- for (const message of messages) {
392
- if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) collectHistoryFromCompressed(historyPoints, entityId, compressedState);
393
- if (message.c) {
394
- for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) collectHistoryFromDiff(historyPoints, entityId, diff["+"]);
395
- }
280
+ byFloor(floorId) {
281
+ return this.clone({ filters: [...this.queryState.filters, {
282
+ type: "floor",
283
+ value: floorId
284
+ }] });
396
285
  }
397
- if (historyPoints.length > 0) bulkAppendHistoryPoints(historyPoints);
286
+ byLabel(labelId) {
287
+ return this.clone({ filters: [...this.queryState.filters, {
288
+ type: "label",
289
+ value: labelId
290
+ }] });
291
+ }
292
+ where(predicate) {
293
+ return this.clone({ filters: [...this.queryState.filters, {
294
+ type: "predicate",
295
+ value: predicate
296
+ }] });
297
+ }
298
+ orderBy(key, direction = "asc") {
299
+ return this.clone({ sorts: [...this.queryState.sorts, {
300
+ key,
301
+ direction
302
+ }] });
303
+ }
304
+ get() {
305
+ return this.execute();
306
+ }
307
+ first() {
308
+ return this.execute()[0];
309
+ }
310
+ subscribe(callback) {
311
+ callback(this.get());
312
+ return () => {};
313
+ }
314
+ clone(updates) {
315
+ return new AreaQueryBuilderImpl({
316
+ ...this.queryState,
317
+ ...updates
318
+ });
319
+ }
320
+ execute() {
321
+ let areas = getAreaViews();
322
+ for (const filter of this.queryState.filters) if (filter.type === "floor") areas = areas.filter((area) => area.floorId === filter.value);
323
+ else if (filter.type === "label") areas = areas.filter((area) => area.labels.includes(filter.value));
324
+ else if (filter.type === "predicate") areas = areas.filter(filter.value);
325
+ if (this.queryState.sorts.length > 0) areas = this.sortAreas(areas);
326
+ return areas;
327
+ }
328
+ sortAreas(areas) {
329
+ return [...areas].sort((a, b) => {
330
+ for (const sort of this.queryState.sorts) {
331
+ const aValue = getNestedValue(a, sort.key);
332
+ const bValue = getNestedValue(b, sort.key);
333
+ let comparison = 0;
334
+ if (aValue < bValue) comparison = -1;
335
+ else if (aValue > bValue) comparison = 1;
336
+ if (comparison !== 0) return sort.direction === "asc" ? comparison : -comparison;
337
+ }
338
+ return 0;
339
+ });
340
+ }
341
+ };
342
+ /**
343
+ * Create a query builder for areas
344
+ */
345
+ function areas() {
346
+ return new AreaQueryBuilderImpl();
398
347
  }
399
348
  /**
400
- * Subscribe to entity state updates with an entity_ids filter.
401
- * Called by the subscription manager on flush.
349
+ * Create a query for a single area
402
350
  */
403
- async function subscribeEntities(connection, entityIds) {
404
- const subscribeMsg = { type: "subscribe_entities" };
405
- if (entityIds.length > 0) subscribeMsg.entity_ids = entityIds;
406
- return await connection.subscribeMessage((message) => {
407
- bufferMessage(message);
408
- }, subscribeMsg);
351
+ function area(areaId) {
352
+ return getAreaView(areaId);
409
353
  }
354
+ //#endregion
355
+ //#region src/entities/index.ts
410
356
  /**
411
- * Subscribe to all relevant events from Home Assistant
357
+ * Entity Index Queries
358
+ *
359
+ * Provides utilities for querying entities by various criteria.
360
+ * In the SolidJS port, indices are NOT stored in state -- they are
361
+ * computed on the fly here (and will be derived reactively in Plan 03).
362
+ *
363
+ * @packageDocumentation
412
364
  */
413
- async function subscribeToUpdates(conn) {
414
- await cleanupSubscriptions();
415
- setManagerConnection(conn);
416
- setResubscribeHandler(subscribeEntities);
417
- const registryUpdatedSub = await conn.subscribeEvents((event) => {
418
- handleRegistryUpdate(event);
419
- }, "entity_registry_updated");
420
- registrySubscriptions.push(() => registryUpdatedSub());
421
- const deviceRegistrySub = await conn.subscribeEvents((event) => {
422
- handleDeviceRegistryUpdate(event);
423
- }, "device_registry_updated");
424
- registrySubscriptions.push(() => deviceRegistrySub());
425
- const areaRegistrySub = await conn.subscribeEvents((event) => {
426
- handleAreaRegistryUpdate(event);
427
- }, "area_registry_updated");
428
- registrySubscriptions.push(() => areaRegistrySub());
429
- const floorRegistrySub = await conn.subscribeEvents((event) => {
430
- handleFloorRegistryUpdate(event);
431
- }, "floor_registry_updated");
432
- registrySubscriptions.push(() => floorRegistrySub());
433
- const labelRegistrySub = await conn.subscribeEvents((event) => {
434
- handleLabelRegistryUpdate(event);
435
- }, "label_registry_updated");
436
- registrySubscriptions.push(() => labelRegistrySub());
437
- const statisticsSub = await conn.subscribeEvents((event) => {
438
- handleStatisticsUpdate(event);
439
- }, "recorder_5min_statistics_generated");
440
- registrySubscriptions.push(() => statisticsSub());
441
- const coreConfigSub = await conn.subscribeEvents(() => {
442
- handleCoreConfigUpdate();
443
- }, "core_config_updated");
444
- registrySubscriptions.push(() => coreConfigSub());
365
+ /**
366
+ * Get all entity IDs
367
+ */
368
+ function getAllEntityIds() {
369
+ return Object.keys(state.entities);
445
370
  }
446
371
  /**
447
- * Clean up all active subscriptions
372
+ * Get entities by domain
448
373
  */
449
- async function cleanupSubscriptions() {
450
- const subscriptions = [...registrySubscriptions];
451
- for (const unsub of subscriptions) await unsub();
452
- registrySubscriptions = [];
374
+ function getEntitiesByDomain(domain) {
375
+ return Object.keys(state.entities).filter((id) => extractDomain(id) === domain);
453
376
  }
454
- async function handleCoreConfigUpdate() {
455
- const conn = privilegedConn();
456
- if (!conn) return;
457
- try {
458
- const config = await conn.sendMessagePromise({ type: "get_config" });
459
- if (config) setState("config", config);
460
- } catch (error) {
461
- console.error("Error refreshing core config:", error);
377
+ /**
378
+ * Get entities via devices in area
379
+ *
380
+ * Only includes entities that inherit area from device (entity.area_id is null).
381
+ */
382
+ function getEntitiesViaDevices(areaId) {
383
+ const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
384
+ const entityIds = [];
385
+ for (const device of devicesInArea) {
386
+ const deviceEntities = getEntitiesByDevice(device.id);
387
+ for (const entityId of deviceEntities) if (!state.entityRegistry[entityId]?.area_id) entityIds.push(entityId);
462
388
  }
389
+ return entityIds;
463
390
  }
464
- async function handleRegistryUpdate(event) {
465
- const { action, entity_id } = event.data;
466
- if (action === "create" || action === "update") {
467
- const conn = privilegedConn();
468
- if (!conn) return;
469
- try {
470
- const entry = await conn.sendMessagePromise({
471
- type: "config/entity_registry/get",
472
- entity_id
473
- });
474
- if (entry) updateEntityRegistry(entry);
475
- } catch (error) {
476
- console.error("Error fetching registry entry:", error);
477
- }
478
- } else if (action === "remove") removeEntityRegistry(entity_id);
479
- }
480
- async function handleDeviceRegistryUpdate(event) {
481
- const { action, device_id } = event.data;
482
- if (action === "create" || action === "update") {
483
- const conn = privilegedConn();
484
- if (!conn) return;
485
- try {
486
- const device = (await conn.sendMessagePromise({ type: "config/device_registry/list" })).find((d) => d.id === device_id);
487
- if (device) updateDevice(device);
488
- } catch (error) {
489
- console.error("Error fetching device:", error);
490
- }
491
- } else if (action === "remove") removeDevice(device_id);
391
+ /**
392
+ * Get entities by area
393
+ *
394
+ * Returns entities with direct area_id assignments and entities that inherit their area from devices.
395
+ */
396
+ function getEntitiesByArea(areaId) {
397
+ const directEntityIds = [];
398
+ for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
399
+ const deviceEntityIds = getEntitiesViaDevices(areaId);
400
+ return Array.from(/* @__PURE__ */ new Set([...directEntityIds, ...deviceEntityIds]));
492
401
  }
493
- async function handleAreaRegistryUpdate(event) {
494
- const { action, area_id } = event.data;
495
- if (action === "create" || action === "update") {
496
- const conn = privilegedConn();
497
- if (!conn) return;
498
- try {
499
- const area = (await conn.sendMessagePromise({ type: "config/area_registry/list" })).find((a) => a.id === area_id);
500
- if (area) updateArea(area);
501
- } catch (error) {
502
- console.error("Error fetching area:", error);
503
- }
504
- } else if (action === "remove") removeArea(area_id);
402
+ /**
403
+ * Get entities by device
404
+ */
405
+ function getEntitiesByDevice(deviceId) {
406
+ const entityIds = [];
407
+ for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.device_id === deviceId) entityIds.push(entityId);
408
+ return entityIds;
505
409
  }
506
- async function handleFloorRegistryUpdate(event) {
507
- const { action, floor_id } = event.data;
508
- if (action === "create" || action === "update") {
509
- const conn = privilegedConn();
510
- if (!conn) return;
511
- try {
512
- const floor = (await conn.sendMessagePromise({ type: "config/floor_registry/list" })).find((f) => f.floor_id === floor_id);
513
- if (floor) updateFloor(floor);
514
- } catch (error) {
515
- console.error("Error fetching floor:", error);
516
- }
517
- } else if (action === "remove") removeFloor(floor_id);
518
- }
519
- async function handleLabelRegistryUpdate(event) {
520
- const { action, label_id } = event.data;
521
- if (action === "create" || action === "update") {
522
- const conn = privilegedConn();
523
- if (!conn) return;
524
- try {
525
- const label = (await conn.sendMessagePromise({ type: "config/label_registry/list" })).find((l) => l.label_id === label_id);
526
- if (label) updateLabel(label);
527
- } catch (error) {
528
- console.error("Error fetching label:", error);
529
- }
530
- } else if (action === "remove") removeLabel(label_id);
531
- }
532
- async function handleStatisticsUpdate(event) {
533
- const { statistic_ids } = event.data;
534
- if (!statistic_ids || statistic_ids.length === 0) return;
535
- const conn = privilegedConn();
536
- if (!conn) return;
537
- try {
538
- const metadata = await conn.sendMessagePromise({
539
- type: "recorder/get_statistics_metadata",
540
- statistic_ids
541
- });
542
- if (metadata && Array.isArray(metadata)) updateStatisticsMetadata(metadata);
543
- } catch (error) {
544
- console.error("Error fetching statistics metadata:", error);
545
- }
546
- }
547
- function timestampToIso(timestamp) {
548
- if (timestamp === void 0) return (/* @__PURE__ */ new Date()).toISOString();
549
- if (typeof timestamp === "string") return timestamp;
550
- return (/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString();
551
- }
552
- function collectHistoryFromCompressed(points, entityId, compressed) {
553
- if (!isHistoryTracked(entityId)) return;
554
- const stateVal = compressed.s ?? compressed.state ?? "unknown";
555
- const attributes = compressed.a ?? compressed.attributes ?? {};
556
- const lu = compressed.lu ?? (compressed.last_updated ? new Date(compressed.last_updated).getTime() / 1e3 : Date.now() / 1e3);
557
- const lc = compressed.lc ?? (compressed.last_changed ? new Date(compressed.last_changed).getTime() / 1e3 : void 0);
558
- points.push({
559
- entityId,
560
- stateValue: stateVal,
561
- attributes,
562
- lastUpdated: lu,
563
- lastChanged: lc
564
- });
565
- }
566
- function collectHistoryFromDiff(points, entityId, additions) {
567
- if (!isHistoryTracked(entityId)) return;
568
- const stateVal = additions.s ?? additions.state;
569
- if (stateVal === void 0) return;
570
- const lu = additions.lu ?? Date.now() / 1e3;
571
- const lc = additions.lc;
572
- const attributes = additions.a ?? additions.attributes ?? {};
573
- points.push({
574
- entityId,
575
- stateValue: stateVal,
576
- attributes,
577
- lastUpdated: lu,
578
- lastChanged: lc
579
- });
580
- }
581
- function shallowEqualAttributes(existing, incoming) {
582
- const incomingKeys = Object.keys(incoming);
583
- for (const key of incomingKeys) if (existing[key] !== incoming[key]) return false;
584
- return true;
585
- }
586
- function applyCompressedState(s, entityId, compressed) {
587
- const stateVal = compressed.s ?? compressed.state ?? "unknown";
588
- const attributes = compressed.a ?? compressed.attributes ?? {};
589
- const context = compressed.c ?? compressed.context ?? {
590
- id: "",
591
- parent_id: null,
592
- user_id: null
593
- };
594
- const lastChanged = timestampToIso(compressed.lc ?? compressed.last_changed);
595
- const lastUpdated = timestampToIso(compressed.lu ?? compressed.last_updated);
596
- const existing = s.entities[entityId];
597
- if (existing) {
598
- const stateChanged = existing.state !== stateVal;
599
- const changedAt = existing.last_changed !== lastChanged;
600
- if (stateChanged) existing.state = stateVal;
601
- if (changedAt) existing.last_changed = lastChanged;
602
- if (stateChanged || changedAt) existing.last_updated = lastUpdated;
603
- if (!shallowEqualAttributes(existing.attributes, attributes)) Object.assign(existing.attributes, attributes);
604
- if (existing.context.id !== context.id) existing.context = context;
605
- } else s.entities[entityId] = {
606
- entity_id: entityId,
607
- state: stateVal,
608
- attributes,
609
- last_changed: lastChanged,
610
- last_updated: lastUpdated,
611
- context
612
- };
613
- }
614
- function applyStateDiff(s, entityId, diff) {
615
- const existing = s.entities[entityId];
616
- if (!existing) return;
617
- const additions = diff["+"];
618
- if (additions) {
619
- let stateChanged = false;
620
- if (additions.s !== void 0 && existing.state !== additions.s) {
621
- existing.state = additions.s;
622
- stateChanged = true;
623
- }
624
- if (additions.a) {
625
- for (const [key, value] of Object.entries(additions.a)) if (existing.attributes[key] !== value) {
626
- existing.attributes[key] = value;
627
- stateChanged = true;
628
- }
629
- }
630
- if (additions.lc !== void 0) {
631
- const lcIso = timestampToIso(additions.lc);
632
- if (existing.last_changed !== lcIso) {
633
- existing.last_changed = lcIso;
634
- stateChanged = true;
635
- }
636
- }
637
- if (additions.lu !== void 0 && stateChanged) existing.last_updated = timestampToIso(additions.lu);
638
- if (additions.c && existing.context.id !== additions.c.id) existing.context = additions.c;
639
- }
640
- const removals = diff["-"];
641
- if (removals?.a) for (const key of removals.a) delete existing.attributes[key];
642
- }
643
- //#endregion
644
- //#region src/connection/manager.ts
645
- let activeConn = null;
646
- let heartbeatInterval = null;
647
- let suspendTimeout = null;
648
- let visiblePromiseResolve = null;
649
- let visibilityHandler = null;
650
- let pageshowHandler = null;
651
- function clearSuspendTimeout() {
652
- if (suspendTimeout !== null) {
653
- clearTimeout(suspendTimeout);
654
- suspendTimeout = null;
655
- }
656
- }
657
- function resolveVisiblePromise() {
658
- if (visiblePromiseResolve) {
659
- visiblePromiseResolve();
660
- visiblePromiseResolve = null;
661
- }
662
- }
663
- function stopHeartbeat() {
664
- if (heartbeatInterval !== null) {
665
- clearInterval(heartbeatInterval);
666
- heartbeatInterval = null;
410
+ /**
411
+ * Get entities by floor
412
+ */
413
+ function getEntitiesByFloor(floorId) {
414
+ const entityIds = [];
415
+ const areasOnFloor = Object.values(state.areas).filter((area) => area.floor_id === floorId);
416
+ for (const area of areasOnFloor) {
417
+ const areaEntities = getEntitiesByArea(area.id);
418
+ entityIds.push(...areaEntities);
667
419
  }
668
- }
669
- function teardownLifecycleListeners() {
670
- if (typeof document !== "undefined" && visibilityHandler) document.removeEventListener("visibilitychange", visibilityHandler);
671
- if (typeof window !== "undefined" && pageshowHandler) window.removeEventListener("pageshow", pageshowHandler);
672
- visibilityHandler = null;
673
- pageshowHandler = null;
674
- clearSuspendTimeout();
675
- resolveVisiblePromise();
420
+ return entityIds;
676
421
  }
677
422
  /**
678
- * Disconnect from Home Assistant
423
+ * Get entities by label
679
424
  */
680
- function disconnect() {
681
- stopHeartbeat();
682
- teardownLifecycleListeners();
683
- activeConn?.close();
684
- activeConn = null;
685
- setState(produce((s) => {
686
- setPrivilegedConn(null);
687
- s.connectionState = "disconnected";
688
- }));
425
+ function getEntitiesByLabel(labelId) {
426
+ const entityIds = [];
427
+ for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.labels?.includes(labelId)) entityIds.push(entityId);
428
+ return entityIds;
689
429
  }
430
+ //#endregion
431
+ //#region src/query/memoization.ts
690
432
  /**
691
- * Check if currently connected
433
+ * Build a unique signature for a query
692
434
  */
693
- function isConnected() {
694
- return state.connectionState === "connected";
435
+ function buildQuerySignature(query) {
436
+ const sig = {
437
+ index: query.indexFilters.map((f) => `${f.type}:${f.value}`).join(","),
438
+ predicates: query.predicateFilters.length,
439
+ sorts: query.sorts.map((s) => `${s.key}:${s.direction}`).join(","),
440
+ projection: query.projection ? JSON.stringify(query.projection) : null
441
+ };
442
+ return JSON.stringify(sig);
695
443
  }
696
444
  /**
697
- * Get the underlying socket's connected flag.
698
- *
699
- * Exposed so apps can render a connection indicator in the UI. Distinct from
700
- * `isConnected()` which reflects the manager's internal lifecycle state.
445
+ * Execute query (no caching -- SolidJS createMemo handles this in Plan 03)
701
446
  */
702
- function getConnectionState() {
703
- return activeConn ? activeConn.connected : state.connectionState === "connected";
704
- }
705
- async function loadInitialData(conn) {
706
- try {
707
- const [states, entityRegistry, deviceRegistry, areaRegistry, floorRegistry, labelRegistry, config] = await Promise.all([
708
- conn.sendMessagePromise({ type: "get_states" }),
709
- conn.sendMessagePromise({ type: "config/entity_registry/list" }),
710
- conn.sendMessagePromise({ type: "config/device_registry/list" }),
711
- conn.sendMessagePromise({ type: "config/area_registry/list" }),
712
- conn.sendMessagePromise({ type: "config/floor_registry/list" }),
713
- conn.sendMessagePromise({ type: "config/label_registry/list" }),
714
- conn.sendMessagePromise({ type: "get_config" })
715
- ]);
716
- bulkUpdateEntities(states);
717
- bulkUpdateEntityRegistry(entityRegistry);
718
- setState("config", config);
719
- setState(produce((s) => {
720
- for (const device of deviceRegistry) s.devices[device.id] = device;
721
- for (const areaApi of areaRegistry) {
722
- const area = {
723
- id: areaApi.area_id || areaApi.id,
724
- name: areaApi.name,
725
- normalized_name: areaApi.normalized_name || areaApi.name.toLowerCase().replace(/\s+/g, "_"),
726
- aliases: Array.isArray(areaApi.aliases) ? areaApi.aliases : [],
727
- floor_id: areaApi.floor_id ?? null,
728
- humidity_entity_id: areaApi.humidity_entity_id ?? null,
729
- icon: areaApi.icon ?? null,
730
- labels: Array.isArray(areaApi.labels) ? areaApi.labels : [],
731
- picture: areaApi.picture ?? null,
732
- temperature_entity_id: areaApi.temperature_entity_id ?? null,
733
- created_at: typeof areaApi.created_at === "number" ? (/* @__PURE__ */ new Date(areaApi.created_at * 1e3)).toISOString() : areaApi.created_at,
734
- modified_at: typeof areaApi.modified_at === "number" ? (/* @__PURE__ */ new Date(areaApi.modified_at * 1e3)).toISOString() : areaApi.modified_at
735
- };
736
- s.areas[area.id] = area;
737
- }
738
- for (const floor of floorRegistry) s.floors[floor.floor_id] = floor;
739
- for (const label of labelRegistry) s.labels[label.label_id] = label;
740
- }));
741
- } catch (error) {
742
- console.error("Error loading initial data:", error);
743
- throw error;
744
- }
447
+ function memoizedExecute(_signature, compute) {
448
+ return compute();
745
449
  }
746
450
  //#endregion
747
- //#region src/query/area-builder.ts
748
- var AreaQueryBuilderImpl = class AreaQueryBuilderImpl {
451
+ //#region src/query/builder.ts
452
+ var EntitiesQueryBuilderImpl = class EntitiesQueryBuilderImpl {
749
453
  queryState;
750
454
  constructor(initialState) {
751
455
  this.queryState = initialState ?? {
752
- filters: [],
753
- sorts: []
456
+ indexFilters: [],
457
+ predicateFilters: [],
458
+ sorts: [],
459
+ projection: void 0,
460
+ searchText: void 0,
461
+ take: void 0,
462
+ skip: void 0,
463
+ distinct: void 0
754
464
  };
755
465
  }
466
+ byArea(areaId) {
467
+ return this.clone({ indexFilters: [...this.queryState.indexFilters, {
468
+ type: "area",
469
+ value: areaId
470
+ }] });
471
+ }
472
+ byDomain(domain) {
473
+ return this.clone({ indexFilters: [...this.queryState.indexFilters, {
474
+ type: "domain",
475
+ value: domain
476
+ }] });
477
+ }
478
+ byDevice(deviceId) {
479
+ return this.clone({ indexFilters: [...this.queryState.indexFilters, {
480
+ type: "device",
481
+ value: deviceId
482
+ }] });
483
+ }
756
484
  byFloor(floorId) {
757
- return this.clone({ filters: [...this.queryState.filters, {
485
+ return this.clone({ indexFilters: [...this.queryState.indexFilters, {
758
486
  type: "floor",
759
487
  value: floorId
760
488
  }] });
761
489
  }
762
490
  byLabel(labelId) {
763
- return this.clone({ filters: [...this.queryState.filters, {
491
+ return this.clone({ indexFilters: [...this.queryState.indexFilters, {
764
492
  type: "label",
765
493
  value: labelId
766
494
  }] });
767
495
  }
768
496
  where(predicate) {
769
- return this.clone({ filters: [...this.queryState.filters, {
770
- type: "predicate",
771
- value: predicate
772
- }] });
497
+ return this.clone({ predicateFilters: [...this.queryState.predicateFilters, predicate] });
498
+ }
499
+ search(searchText) {
500
+ const searchLower = searchText.toLowerCase();
501
+ return this.where((e) => e.id.toLowerCase().includes(searchLower) || e.name.toLowerCase().includes(searchLower) || e.friendlyName.toLowerCase().includes(searchLower));
773
502
  }
774
503
  orderBy(key, direction = "asc") {
775
504
  return this.clone({ sorts: [...this.queryState.sorts, {
@@ -777,519 +506,645 @@ var AreaQueryBuilderImpl = class AreaQueryBuilderImpl {
777
506
  direction
778
507
  }] });
779
508
  }
509
+ ids() {
510
+ return this.clone({ projection: { type: "ids" } });
511
+ }
512
+ pick(keys) {
513
+ return this.clone({ projection: {
514
+ type: "pick",
515
+ keys
516
+ } });
517
+ }
518
+ pluck(key) {
519
+ return this.clone({ projection: {
520
+ type: "pluck",
521
+ key
522
+ } });
523
+ }
524
+ map(mapper) {
525
+ return this.clone({ projection: {
526
+ type: "map",
527
+ mapper
528
+ } });
529
+ }
530
+ take(limit) {
531
+ return this.clone({ take: limit });
532
+ }
533
+ skip(count) {
534
+ return this.clone({ skip: count });
535
+ }
536
+ distinct(key) {
537
+ return this.clone({ distinct: key });
538
+ }
780
539
  get() {
781
- return this.execute();
540
+ return memoizedExecute(buildQuerySignature(this.queryState), () => this.execute());
782
541
  }
783
542
  first() {
784
- return this.execute()[0];
543
+ return this.get()[0];
785
544
  }
786
545
  subscribe(callback) {
787
546
  callback(this.get());
788
547
  return () => {};
789
548
  }
790
549
  clone(updates) {
791
- return new AreaQueryBuilderImpl({
550
+ return new EntitiesQueryBuilderImpl({
792
551
  ...this.queryState,
793
552
  ...updates
794
553
  });
795
554
  }
796
555
  execute() {
797
- let areas = getAreaViews();
798
- for (const filter of this.queryState.filters) if (filter.type === "floor") areas = areas.filter((area) => area.floorId === filter.value);
799
- else if (filter.type === "label") areas = areas.filter((area) => area.labels.includes(filter.value));
800
- else if (filter.type === "predicate") areas = areas.filter(filter.value);
801
- if (this.queryState.sorts.length > 0) areas = this.sortAreas(areas);
802
- return areas;
556
+ const entityIds = this.getEntityIdsFromIndices();
557
+ let entities = getEntityViews(entityIds);
558
+ for (const filter of this.queryState.predicateFilters) entities = entities.filter(filter);
559
+ if (this.queryState.sorts.length > 0) entities = this.sortEntities(entities);
560
+ if (this.queryState.distinct) entities = this.applyDistinct(entities);
561
+ if (this.queryState.skip !== void 0) entities = entities.slice(this.queryState.skip);
562
+ if (this.queryState.take !== void 0) entities = entities.slice(0, this.queryState.take);
563
+ if (this.queryState.projection) return this.applyProjection(entities);
564
+ return entities;
803
565
  }
804
- sortAreas(areas) {
805
- return [...areas].sort((a, b) => {
566
+ getEntityIdsFromIndices() {
567
+ if (this.queryState.indexFilters.length === 0) return getAllEntityIds();
568
+ const idSets = this.queryState.indexFilters.map((filter) => this.getIdsForFilter(filter));
569
+ return this.intersectArrays(idSets);
570
+ }
571
+ getIdsForFilter(filter) {
572
+ switch (filter.type) {
573
+ case "domain": return getEntitiesByDomain(filter.value);
574
+ case "area": return getEntitiesByArea(filter.value);
575
+ case "device": return getEntitiesByDevice(filter.value);
576
+ case "floor": return getEntitiesByFloor(filter.value);
577
+ case "label": return getEntitiesByLabel(filter.value);
578
+ default: return [];
579
+ }
580
+ }
581
+ intersectArrays(arrays) {
582
+ if (arrays.length === 0) return [];
583
+ if (arrays.length === 1) return arrays[0] ?? [];
584
+ let result = arrays[0] ?? [];
585
+ for (let i = 1; i < arrays.length; i++) {
586
+ const set = new Set(arrays[i]);
587
+ result = result.filter((item) => set.has(item));
588
+ }
589
+ return result;
590
+ }
591
+ sortEntities(entities) {
592
+ const sorted = [...entities];
593
+ sorted.sort((a, b) => {
806
594
  for (const sort of this.queryState.sorts) {
807
- const aValue = this.getNestedValue(a, sort.key);
808
- const bValue = this.getNestedValue(b, sort.key);
809
- let comparison = 0;
810
- if (aValue < bValue) comparison = -1;
811
- else if (aValue > bValue) comparison = 1;
595
+ const aVal = getNestedValue(a, sort.key);
596
+ const bVal = getNestedValue(b, sort.key);
597
+ const comparison = this.compareValues(aVal, bVal);
812
598
  if (comparison !== 0) return sort.direction === "asc" ? comparison : -comparison;
813
599
  }
814
600
  return 0;
815
601
  });
602
+ return sorted;
603
+ }
604
+ compareValues(a, b) {
605
+ if (a === b) return 0;
606
+ if (a === null || a === void 0) return 1;
607
+ if (b === null || b === void 0) return -1;
608
+ if (typeof a === "string" && typeof b === "string") return a.localeCompare(b);
609
+ if (typeof a === "number" && typeof b === "number") return a - b;
610
+ return String(a).localeCompare(String(b));
611
+ }
612
+ applyProjection(entities) {
613
+ if (!this.queryState.projection) return entities;
614
+ switch (this.queryState.projection.type) {
615
+ case "ids": return projectToIds(entities);
616
+ case "pick": return projectPick(entities, this.queryState.projection.keys);
617
+ case "pluck": return projectPluck(entities, this.queryState.projection.key);
618
+ case "map": return projectMap(entities, this.queryState.projection.mapper);
619
+ default: return entities;
620
+ }
621
+ }
622
+ applyDistinct(entities) {
623
+ if (!this.queryState.distinct) return entities;
624
+ const seen = /* @__PURE__ */ new Set();
625
+ const result = [];
626
+ for (const entity of entities) {
627
+ const value = getNestedValue(entity, this.queryState.distinct);
628
+ const key = JSON.stringify(value);
629
+ if (!seen.has(key)) {
630
+ seen.add(key);
631
+ result.push(entity);
632
+ }
633
+ }
634
+ return result;
635
+ }
636
+ };
637
+ var EntityQueryBuilderImpl = class {
638
+ entityId;
639
+ constructor(entityId) {
640
+ this.entityId = entityId;
641
+ }
642
+ get() {
643
+ return getEntityView(this.entityId);
644
+ }
645
+ pluck(key) {
646
+ const view = this.get();
647
+ return view ? view[key] : void 0;
816
648
  }
817
- getNestedValue(obj, key) {
818
- if (key in obj) return obj[key];
819
- const parts = String(key).split(".");
820
- let value = obj;
821
- for (const part of parts) value = value?.[part];
822
- return value;
649
+ subscribe(callback) {
650
+ callback(this.get());
651
+ return () => {};
823
652
  }
824
653
  };
825
654
  /**
826
- * Create a query builder for areas
655
+ * Create a query builder for multiple entities
827
656
  */
828
- function areas() {
829
- return new AreaQueryBuilderImpl();
657
+ function entities() {
658
+ return new EntitiesQueryBuilderImpl();
830
659
  }
831
660
  /**
832
- * Create a query for a single area
661
+ * Create a query builder for a single entity
833
662
  */
834
- function area(areaId) {
835
- return getAreaView(areaId);
663
+ function entity(entityId) {
664
+ return new EntityQueryBuilderImpl(entityId);
836
665
  }
837
666
  //#endregion
838
- //#region src/entities/index.ts
839
- /**
840
- * Entity Index Queries
841
- *
842
- * Provides utilities for querying entities by various criteria.
843
- * In the SolidJS port, indices are NOT stored in state -- they are
844
- * computed on the fly here (and will be derived reactively in Plan 03).
845
- *
846
- * @packageDocumentation
847
- */
667
+ //#region src/query/helpers.ts
848
668
  /**
849
- * Get all entity IDs
669
+ * Create a search query builder
850
670
  */
851
- function getAllEntityIds() {
852
- return Object.keys(state.entities);
671
+ function searchEntities(searchText) {
672
+ const searchLower = searchText.toLowerCase();
673
+ return entities().where((e) => e.id.toLowerCase().includes(searchLower) || e.name.toLowerCase().includes(searchLower) || e.friendlyName.toLowerCase().includes(searchLower));
853
674
  }
854
675
  /**
855
- * Get entities by domain
676
+ * Create a query for entities in an area by domain
856
677
  */
857
- function getEntitiesByDomain(domain) {
858
- return Object.keys(state.entities).filter((id) => extractDomain(id) === domain);
678
+ function entitiesByAreaAndDomain(areaId, domain) {
679
+ return entities().byArea(areaId).byDomain(domain);
859
680
  }
860
681
  /**
861
- * Get entities via devices in area
862
- *
863
- * Only includes entities that inherit area from device (entity.area_id is null).
682
+ * Create a query for entities matching a predicate
864
683
  */
865
- function getEntitiesViaDevices(areaId) {
866
- const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
867
- const entityIds = [];
868
- for (const device of devicesInArea) {
869
- const deviceEntities = getEntitiesByDevice(device.id);
870
- for (const entityId of deviceEntities) if (!state.entityRegistry[entityId]?.area_id) entityIds.push(entityId);
871
- }
872
- return entityIds;
684
+ function entitiesWhere(predicate) {
685
+ return entities().where(predicate);
873
686
  }
687
+ //#endregion
688
+ //#region src/camera/fetch.ts
874
689
  /**
875
- * Get entities by area
690
+ * Camera Stream Fetching
876
691
  *
877
- * Returns entities with direct area_id assignments and entities that inherit their area from devices.
692
+ * @packageDocumentation
878
693
  */
879
- function getEntitiesByArea(areaId) {
880
- const directEntityIds = [];
881
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
882
- const deviceEntityIds = getEntitiesViaDevices(areaId);
883
- return Array.from(/* @__PURE__ */ new Set([...directEntityIds, ...deviceEntityIds]));
884
- }
694
+ /** The demo's front door feed, served by the dashboard itself so the demo never reaches a third party. */
695
+ const DEMO_HLS_STREAM = "/demo/camera/front-door.m3u8";
885
696
  /**
886
- * Get entities by device
697
+ * Fetch stream for a camera entity.
698
+ * Returns the dashboard's own demo HLS feed in demo mode.
887
699
  */
888
- function getEntitiesByDevice(deviceId) {
889
- const entityIds = [];
890
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.device_id === deviceId) entityIds.push(entityId);
891
- return entityIds;
700
+ async function fetchStream(entityId, format = "hls") {
701
+ if (isDemoMode()) return format === "hls" ? demoAssetUrl(DEMO_HLS_STREAM) : null;
702
+ if (!privilegedConn()) return null;
703
+ return (await sendCommand({
704
+ type: "camera/stream",
705
+ entity_id: entityId,
706
+ format
707
+ }))?.url ?? null;
892
708
  }
893
709
  /**
894
- * Get entities by floor
710
+ * Transform relative stream URL to absolute URL
895
711
  */
896
- function getEntitiesByFloor(floorId) {
897
- const entityIds = [];
898
- const areasOnFloor = Object.values(state.areas).filter((area) => area.floor_id === floorId);
899
- for (const area of areasOnFloor) {
900
- const areaEntities = getEntitiesByArea(area.id);
901
- entityIds.push(...areaEntities);
712
+ function transformStreamUrl(streamUrl) {
713
+ if (!streamUrl) return null;
714
+ if (streamUrl.startsWith("http://") || streamUrl.startsWith("https://")) return streamUrl;
715
+ if (streamUrl.startsWith("/")) {
716
+ const hassUrl = state.hassUrl;
717
+ if (!hassUrl) {
718
+ console.warn("[fetchStreamData] No Home Assistant URL available to resolve relative stream URL");
719
+ return streamUrl;
720
+ }
721
+ return `${hassUrl.replace(/\/$/, "")}${streamUrl}`;
902
722
  }
903
- return entityIds;
904
- }
905
- /**
906
- * Get entities by label
907
- */
908
- function getEntitiesByLabel(labelId) {
909
- const entityIds = [];
910
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.labels?.includes(labelId)) entityIds.push(entityId);
911
- return entityIds;
723
+ return streamUrl;
912
724
  }
913
- //#endregion
914
- //#region src/query/memoization.ts
915
725
  /**
916
- * Build a unique signature for a query
726
+ * Fetch stream data for a camera entity
917
727
  */
918
- function buildQuerySignature(query) {
919
- const sig = {
920
- index: query.indexFilters.map((f) => `${f.type}:${f.value}`).join(","),
921
- predicates: query.predicateFilters.length,
922
- sorts: query.sorts.map((s) => `${s.key}:${s.direction}`).join(","),
923
- projection: query.projection ? JSON.stringify(query.projection) : null
924
- };
925
- return JSON.stringify(sig);
926
- }
927
- /**
928
- * Execute query (no caching -- SolidJS createMemo handles this in Plan 03)
929
- */
930
- function memoizedExecute(_signature, compute) {
931
- return compute();
728
+ async function fetchStreamData(entityId, options = {}) {
729
+ const { format = "hls" } = options;
730
+ try {
731
+ return {
732
+ entityId,
733
+ stream: {
734
+ url: transformStreamUrl(await fetchStream(entityId, format)),
735
+ loading: false,
736
+ error: null,
737
+ lastFetched: Date.now(),
738
+ format,
739
+ expiresAt: null
740
+ },
741
+ loading: false,
742
+ error: null,
743
+ lastFetched: Date.now()
744
+ };
745
+ } catch (error) {
746
+ return {
747
+ entityId,
748
+ stream: {
749
+ url: null,
750
+ loading: false,
751
+ error: error instanceof Error ? error : new Error(String(error)),
752
+ lastFetched: null,
753
+ format,
754
+ expiresAt: null
755
+ },
756
+ loading: false,
757
+ error: error instanceof Error ? error : new Error(String(error)),
758
+ lastFetched: null
759
+ };
760
+ }
932
761
  }
933
762
  //#endregion
934
- //#region src/query/projections.ts
763
+ //#region src/camera/query.ts
935
764
  /**
936
- * Project entities to IDs only
765
+ * Camera Stream Query Builder
937
766
  *
938
- * @param entities - Array of entity views
939
- * @returns Array of entity IDs
767
+ * @packageDocumentation
940
768
  */
941
- function projectToIds(entities) {
942
- return entities.map((e) => e.id);
769
+ const DEFAULT_REFRESH_INTERVAL = 24e4;
770
+ function needsStreamRefresh(streamData, refreshInterval = DEFAULT_REFRESH_INTERVAL) {
771
+ if (!streamData || !streamData.lastFetched) return true;
772
+ return Date.now() - streamData.lastFetched >= refreshInterval;
943
773
  }
944
774
  /**
945
- * Pick specific fields from entities
946
- *
947
- * @param entities - Array of entity views
948
- * @param keys - Array of field names to include
949
- * @returns Array of objects with only specified fields
775
+ * Get stream for a camera entity
950
776
  */
951
- function projectPick(entities, keys) {
952
- return entities.map((entity) => {
953
- const result = {};
954
- for (const key of keys) result[key] = entity[key];
955
- return result;
777
+ async function getStream(entityId, options = {}) {
778
+ const { format = "hls", autoRefresh = true, refreshInterval } = options;
779
+ const cached = state.streams[entityId];
780
+ if (cached && cached.stream.format === format) {
781
+ if (autoRefresh && needsStreamRefresh(cached, refreshInterval)) fetchStreamData(entityId, {
782
+ format,
783
+ autoRefresh,
784
+ refreshInterval
785
+ }).then((streamData) => {
786
+ setState(produce((s) => {
787
+ s.streams[entityId] = streamData;
788
+ }));
789
+ });
790
+ return cached;
791
+ }
792
+ const streamData = await fetchStreamData(entityId, {
793
+ format,
794
+ autoRefresh,
795
+ refreshInterval
956
796
  });
797
+ setState(produce((s) => {
798
+ s.streams[entityId] = streamData;
799
+ }));
800
+ return streamData;
957
801
  }
958
802
  /**
959
- * Pluck a single field from entities
960
- *
961
- * Supports dot-notation for nested fields (e.g., "attributes.brightness")
962
- *
963
- * @param entities - Array of entity views
964
- * @param key - Field name or dot-path to extract
965
- * @returns Array of field values
803
+ * Refresh stream for a camera entity
966
804
  */
967
- function projectPluck(entities, key) {
968
- return entities.map((entity) => getNestedValue(entity, key));
805
+ async function refreshStream(entityId, format = "hls") {
806
+ setState(produce((s) => {
807
+ delete s.streams[entityId];
808
+ }));
809
+ return getStream(entityId, { format });
810
+ }
811
+ //#endregion
812
+ //#region src/energy/prefs.ts
813
+ function isRecord(value) {
814
+ return typeof value === "object" && value !== null;
969
815
  }
970
816
  /**
971
- * Map entities using a custom function
817
+ * Fetch the HA energy dashboard preferences.
972
818
  *
973
- * @param entities - Array of entity views
974
- * @param mapper - Function to transform each entity
975
- * @returns Array of mapped values
819
+ * Returns `null` when no energy dashboard is configured (HA raises an error
820
+ * for `energy/get_prefs` in that case); callers treat `null` as "not
821
+ * configured".
976
822
  */
977
- function projectMap(entities, mapper) {
978
- return entities.map(mapper);
823
+ async function fetchEnergyPreferences(connection) {
824
+ if (isDemoMode()) {
825
+ const prefs = demoEnergyPreferences();
826
+ return {
827
+ ...prefs,
828
+ energy_sources: prefs.energy_sources,
829
+ device_consumption: prefs.device_consumption
830
+ };
831
+ }
832
+ let response;
833
+ try {
834
+ response = await connection.sendMessagePromise({ type: "energy/get_prefs" });
835
+ } catch {
836
+ return null;
837
+ }
838
+ if (!isRecord(response)) return null;
839
+ const sources = Array.isArray(response.energy_sources) ? response.energy_sources.filter(isRecord) : [];
840
+ const devices = Array.isArray(response.device_consumption) ? response.device_consumption.filter(isRecord) : [];
841
+ return {
842
+ ...response,
843
+ energy_sources: sources,
844
+ device_consumption: devices
845
+ };
979
846
  }
847
+ //#endregion
848
+ //#region src/media/url.ts
980
849
  /**
981
- * Get nested value from object using dot-notation
850
+ * Resolve an HA media reference (e.g. an entity's `entity_picture`, a camera
851
+ * proxy path) to a URL the page can actually load.
982
852
  *
983
- * @param obj - Object to extract value from
984
- * @param path - Dot-notation path (e.g., "attributes.brightness")
985
- * @returns Value at path or undefined
853
+ * HA gives these as origin-relative paths (`/api/media_player_proxy/...?token=`)
854
+ * carrying their own signed token. A bare `<img src>` would resolve them against
855
+ * the dash origin and 404. When the host set `mediaProxyBase`, route through the
856
+ * same-origin media proxy so the page CSP (`img-src 'self'`) covers it in every
857
+ * mode, LAN and remote. Otherwise fall back to prefixing `hassUrl` directly
858
+ * (the LAN-only path, used until a transport sets the proxy base).
986
859
  *
987
- * @example
988
- * ```typescript
989
- * const entity = { attributes: { brightness: 255 } };
990
- * getNestedValue(entity, "attributes.brightness"); // 255
991
- * ```
860
+ * Absolute URLs and non-path values pass through untouched.
992
861
  */
993
- function getNestedValue(obj, path) {
994
- const parts = path.split(".");
995
- let current = obj;
996
- for (const part of parts) {
997
- if (current === null || current === void 0) return;
998
- current = current[part];
999
- }
1000
- return current;
862
+ function hassMediaUrl(path) {
863
+ if (!path) return void 0;
864
+ if (/^https?:\/\//i.test(path)) return path;
865
+ if (!path.startsWith("/")) return path;
866
+ const base = state.mediaProxyBase;
867
+ if (base) return `${base}?path=${encodeURIComponent(path)}`;
868
+ return state.hassUrl ? `${state.hassUrl}${path}` : path;
1001
869
  }
1002
870
  //#endregion
1003
- //#region src/query/builder.ts
1004
- var EntitiesQueryBuilderImpl = class EntitiesQueryBuilderImpl {
1005
- queryState;
1006
- constructor(initialState) {
1007
- this.queryState = initialState ?? {
1008
- indexFilters: [],
1009
- predicateFilters: [],
1010
- sorts: [],
1011
- projection: void 0,
1012
- searchText: void 0,
1013
- take: void 0,
1014
- skip: void 0,
1015
- distinct: void 0
871
+ //#region src/camera/webrtc.ts
872
+ /**
873
+ * Get WebRTC client configuration (STUN/TURN servers) from HA
874
+ */
875
+ async function getWebRtcClientConfig(entityId) {
876
+ const conn = privilegedConn();
877
+ if (!conn) throw new Error("No connection available");
878
+ return conn.sendMessagePromise({
879
+ type: "camera/webrtc/get_client_config",
880
+ entity_id: entityId
881
+ });
882
+ }
883
+ /**
884
+ * Start WebRTC session with HA.
885
+ * Returns a promise that resolves when the answer SDP is received.
886
+ * ICE candidates from HA are forwarded to the provided callback.
887
+ * Local ICE candidates should be sent via sendWebRtcCandidate().
888
+ */
889
+ function startWebRtcSession(entityId, sdpOffer, onCandidate) {
890
+ const conn = privilegedConn();
891
+ if (!conn) return Promise.reject(/* @__PURE__ */ new Error("Not connected"));
892
+ return new Promise((resolve, reject) => {
893
+ const session = {
894
+ sessionId: null,
895
+ unsubscribe: null
1016
896
  };
1017
- }
1018
- byArea(areaId) {
1019
- return this.clone({ indexFilters: [...this.queryState.indexFilters, {
1020
- type: "area",
1021
- value: areaId
1022
- }] });
1023
- }
1024
- byDomain(domain) {
1025
- return this.clone({ indexFilters: [...this.queryState.indexFilters, {
1026
- type: "domain",
1027
- value: domain
1028
- }] });
1029
- }
1030
- byDevice(deviceId) {
1031
- return this.clone({ indexFilters: [...this.queryState.indexFilters, {
1032
- type: "device",
1033
- value: deviceId
1034
- }] });
1035
- }
1036
- byFloor(floorId) {
1037
- return this.clone({ indexFilters: [...this.queryState.indexFilters, {
1038
- type: "floor",
1039
- value: floorId
1040
- }] });
1041
- }
1042
- byLabel(labelId) {
1043
- return this.clone({ indexFilters: [...this.queryState.indexFilters, {
1044
- type: "label",
1045
- value: labelId
1046
- }] });
1047
- }
1048
- where(predicate) {
1049
- return this.clone({ predicateFilters: [...this.queryState.predicateFilters, predicate] });
1050
- }
1051
- search(searchText) {
1052
- const searchLower = searchText.toLowerCase();
1053
- return this.where((e) => e.id.toLowerCase().includes(searchLower) || e.name.toLowerCase().includes(searchLower) || e.friendlyName.toLowerCase().includes(searchLower));
1054
- }
1055
- orderBy(key, direction = "asc") {
1056
- return this.clone({ sorts: [...this.queryState.sorts, {
1057
- key,
1058
- direction
1059
- }] });
1060
- }
1061
- ids() {
1062
- return this.clone({ projection: { type: "ids" } });
1063
- }
1064
- pick(keys) {
1065
- return this.clone({ projection: {
1066
- type: "pick",
1067
- keys
1068
- } });
1069
- }
1070
- pluck(key) {
1071
- return this.clone({ projection: {
1072
- type: "pluck",
1073
- key
1074
- } });
1075
- }
1076
- map(mapper) {
1077
- return this.clone({ projection: {
1078
- type: "map",
1079
- mapper
1080
- } });
1081
- }
1082
- take(limit) {
1083
- return this.clone({ take: limit });
1084
- }
1085
- skip(count) {
1086
- return this.clone({ skip: count });
1087
- }
1088
- distinct(key) {
1089
- return this.clone({ distinct: key });
1090
- }
1091
- get() {
1092
- return memoizedExecute(buildQuerySignature(this.queryState), () => this.execute());
1093
- }
1094
- first() {
1095
- return this.get()[0];
1096
- }
1097
- subscribe(callback) {
1098
- callback(this.get());
1099
- return () => {};
1100
- }
1101
- clone(updates) {
1102
- return new EntitiesQueryBuilderImpl({
1103
- ...this.queryState,
1104
- ...updates
1105
- });
1106
- }
1107
- execute() {
1108
- const entityIds = this.getEntityIdsFromIndices();
1109
- let entities = getEntityViews(entityIds);
1110
- for (const filter of this.queryState.predicateFilters) entities = entities.filter(filter);
1111
- if (this.queryState.sorts.length > 0) entities = this.sortEntities(entities);
1112
- if (this.queryState.distinct) entities = this.applyDistinct(entities);
1113
- if (this.queryState.skip !== void 0) entities = entities.slice(this.queryState.skip);
1114
- if (this.queryState.take !== void 0) entities = entities.slice(0, this.queryState.take);
1115
- if (this.queryState.projection) return this.applyProjection(entities);
1116
- return entities;
1117
- }
1118
- getEntityIdsFromIndices() {
1119
- if (this.queryState.indexFilters.length === 0) return getAllEntityIds();
1120
- const idSets = this.queryState.indexFilters.map((filter) => this.getIdsForFilter(filter));
1121
- return this.intersectArrays(idSets);
1122
- }
1123
- getIdsForFilter(filter) {
1124
- switch (filter.type) {
1125
- case "domain": return getEntitiesByDomain(filter.value);
1126
- case "area": return getEntitiesByArea(filter.value);
1127
- case "device": return getEntitiesByDevice(filter.value);
1128
- case "floor": return getEntitiesByFloor(filter.value);
1129
- case "label": return getEntitiesByLabel(filter.value);
1130
- default: return [];
1131
- }
1132
- }
1133
- intersectArrays(arrays) {
1134
- if (arrays.length === 0) return [];
1135
- if (arrays.length === 1) return arrays[0] ?? [];
1136
- let result = arrays[0] ?? [];
1137
- for (let i = 1; i < arrays.length; i++) {
1138
- const set = new Set(arrays[i]);
1139
- result = result.filter((item) => set.has(item));
1140
- }
1141
- return result;
1142
- }
1143
- sortEntities(entities) {
1144
- const sorted = [...entities];
1145
- sorted.sort((a, b) => {
1146
- for (const sort of this.queryState.sorts) {
1147
- const aVal = getNestedValue(a, sort.key);
1148
- const bVal = getNestedValue(b, sort.key);
1149
- const comparison = this.compareValues(aVal, bVal);
1150
- if (comparison !== 0) return sort.direction === "asc" ? comparison : -comparison;
897
+ let answered = false;
898
+ const timeout = setTimeout(() => {
899
+ if (!answered) {
900
+ session.unsubscribe?.().catch(logUnsubscribeError);
901
+ reject(/* @__PURE__ */ new Error("WebRTC offer timed out"));
1151
902
  }
1152
- return 0;
1153
- });
1154
- return sorted;
1155
- }
1156
- compareValues(a, b) {
1157
- if (a === b) return 0;
1158
- if (a === null || a === void 0) return 1;
1159
- if (b === null || b === void 0) return -1;
1160
- if (typeof a === "string" && typeof b === "string") return a.localeCompare(b);
1161
- if (typeof a === "number" && typeof b === "number") return a - b;
1162
- return String(a).localeCompare(String(b));
1163
- }
1164
- applyProjection(entities) {
1165
- if (!this.queryState.projection) return entities;
1166
- switch (this.queryState.projection.type) {
1167
- case "ids": return projectToIds(entities);
1168
- case "pick": return projectPick(entities, this.queryState.projection.keys);
1169
- case "pluck": return projectPluck(entities, this.queryState.projection.key);
1170
- case "map": return projectMap(entities, this.queryState.projection.mapper);
1171
- default: return entities;
1172
- }
1173
- }
1174
- applyDistinct(entities) {
1175
- if (!this.queryState.distinct) return entities;
1176
- const seen = /* @__PURE__ */ new Set();
1177
- const result = [];
1178
- for (const entity of entities) {
1179
- const value = getNestedValue(entity, this.queryState.distinct);
1180
- const key = JSON.stringify(value);
1181
- if (!seen.has(key)) {
1182
- seen.add(key);
1183
- result.push(entity);
903
+ }, 15e3);
904
+ conn.subscribeMessage((event) => {
905
+ switch (event.type) {
906
+ case "session":
907
+ session.sessionId = event.session_id ?? null;
908
+ break;
909
+ case "answer":
910
+ if (event.answer) {
911
+ answered = true;
912
+ clearTimeout(timeout);
913
+ resolve({
914
+ answer: event.answer,
915
+ session
916
+ });
917
+ }
918
+ break;
919
+ case "candidate":
920
+ if (event.candidate) onCandidate(event.candidate);
921
+ break;
922
+ case "error":
923
+ clearTimeout(timeout);
924
+ session.unsubscribe?.().catch(logUnsubscribeError);
925
+ reject(new Error(event.message || event.code || "WebRTC offer failed"));
1184
926
  }
1185
- }
1186
- return result;
1187
- }
1188
- };
1189
- var EntityQueryBuilderImpl = class {
1190
- entityId;
1191
- constructor(entityId) {
1192
- this.entityId = entityId;
1193
- }
1194
- get() {
1195
- return getEntityView(this.entityId);
1196
- }
1197
- pluck(key) {
1198
- const view = this.get();
1199
- return view ? view[key] : void 0;
1200
- }
1201
- subscribe(callback) {
1202
- callback(this.get());
1203
- return () => {};
1204
- }
1205
- };
1206
- /**
1207
- * Create a query builder for multiple entities
1208
- */
1209
- function entities() {
1210
- return new EntitiesQueryBuilderImpl();
1211
- }
1212
- /**
1213
- * Create a query builder for a single entity
1214
- */
1215
- function entity(entityId) {
1216
- return new EntityQueryBuilderImpl(entityId);
1217
- }
1218
- //#endregion
1219
- //#region src/query/helpers.ts
1220
- /**
1221
- * Create a search query builder
1222
- */
1223
- function searchEntities(searchText) {
1224
- const searchLower = searchText.toLowerCase();
1225
- return entities().where((e) => e.id.toLowerCase().includes(searchLower) || e.name.toLowerCase().includes(searchLower) || e.friendlyName.toLowerCase().includes(searchLower));
927
+ }, {
928
+ type: "camera/webrtc/offer",
929
+ entity_id: entityId,
930
+ offer: sdpOffer
931
+ }).then((unsub) => {
932
+ session.unsubscribe = unsub;
933
+ }).catch((err) => {
934
+ clearTimeout(timeout);
935
+ reject(err);
936
+ });
937
+ });
1226
938
  }
1227
939
  /**
1228
- * Create a query for entities in an area by domain
940
+ * Send a local ICE candidate to HA
1229
941
  */
1230
- function entitiesByAreaAndDomain(areaId, domain) {
1231
- return entities().byArea(areaId).byDomain(domain);
942
+ async function sendWebRtcCandidate(entityId, sessionId, candidate) {
943
+ const conn = privilegedConn();
944
+ if (!conn) throw new Error("Not connected");
945
+ await conn.sendMessagePromise({
946
+ type: "camera/webrtc/candidate",
947
+ entity_id: entityId,
948
+ session_id: sessionId,
949
+ candidate
950
+ });
1232
951
  }
1233
- /**
1234
- * Create a query for entities matching a predicate
1235
- */
1236
- function entitiesWhere(predicate) {
1237
- return entities().where(predicate);
952
+ function logUnsubscribeError(error) {
953
+ console.error("[@glasshome/sync-layer] WebRTC unsubscribe failed:", error);
1238
954
  }
1239
955
  //#endregion
1240
- //#region src/camera/fetch.ts
956
+ //#region src/history/constants.ts
1241
957
  /**
1242
- * Camera Stream Fetching
958
+ * History Constants
959
+ *
960
+ * Constants and utilities for history fetching and processing.
961
+ * Based on Home Assistant frontend implementation.
1243
962
  *
1244
963
  * @packageDocumentation
1245
964
  */
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";
965
+ const DOMAINS_USE_LAST_UPDATED = [
966
+ "climate",
967
+ "humidifier",
968
+ "water_heater"
969
+ ];
970
+ const NEED_ATTRIBUTE_DOMAINS = [
971
+ "climate",
972
+ "humidifier",
973
+ "input_datetime",
974
+ "water_heater",
975
+ "person",
976
+ "device_tracker"
977
+ ];
978
+ const LINE_ATTRIBUTES_TO_KEEP = [
979
+ "temperature",
980
+ "current_temperature",
981
+ "target_temp_low",
982
+ "target_temp_high",
983
+ "hvac_action",
984
+ "humidity",
985
+ "mode",
986
+ "action",
987
+ "current_humidity"
988
+ ];
1248
989
  /**
1249
- * Fetch stream for a camera entity.
1250
- * Returns the dashboard's own demo HLS feed in demo mode.
990
+ * Check if an entity needs attributes in history response
1251
991
  */
1252
- async function fetchStream(entityId, format = "hls") {
1253
- if (isDemoMode()) return format === "hls" ? demoAssetUrl(DEMO_HLS_STREAM) : null;
1254
- if (!privilegedConn()) return null;
1255
- return (await sendCommand({
1256
- type: "camera/stream",
1257
- entity_id: entityId,
1258
- format
1259
- }))?.url ?? null;
992
+ function entityIdHistoryNeedsAttributes(entityId) {
993
+ const domain = extractDomain(entityId);
994
+ if (!state.entities[entityId]) return NEED_ATTRIBUTE_DOMAINS.includes(domain);
995
+ return NEED_ATTRIBUTE_DOMAINS.includes(domain);
996
+ }
997
+ //#endregion
998
+ //#region src/demo/sim/history.ts
999
+ const DAY_MS = 864e5;
1000
+ const STEP_MS = 3e5;
1001
+ const TICK_WALL_MS = 1e3;
1002
+ /** Unix seconds, matching the energy branch's wire format (`synthesizeEnergyHistory`). */
1003
+ function toSeconds(ms) {
1004
+ return Math.round(ms / 1e3);
1005
+ }
1006
+ function pushPoint(track, point) {
1007
+ track.lastState = point.s;
1008
+ const last = track.points.at(-1);
1009
+ if (last && last.lu === point.lu) track.points[track.points.length - 1] = point;
1010
+ else track.points.push(point);
1011
+ }
1012
+ /**
1013
+ * Replays the demo house once from `startMs` to `endMs` (sim time) and reports each entity's state changes.
1014
+ * `toWallMs` stamps `lu` on the caller's clock.
1015
+ */
1016
+ function demoHistory(entityIds, startMs, endMs, toWallMs = (ms) => ms) {
1017
+ const result = {};
1018
+ for (const id of entityIds) result[id] = [];
1019
+ const live = demoModel();
1020
+ if (!live) return result;
1021
+ const tracks = [];
1022
+ for (const entityId of new Set(entityIds)) {
1023
+ const device = live.deviceOf(entityId);
1024
+ if (device) tracks.push({
1025
+ entityId,
1026
+ deviceKey: device.key,
1027
+ points: [],
1028
+ lastState: void 0
1029
+ });
1030
+ }
1031
+ if (tracks.length === 0) return result;
1032
+ const liveNowMs = live.nowMs;
1033
+ const endC = toWallMs(liveNowMs) - toWallMs(endMs) <= TICK_WALL_MS ? liveNowMs : endMs;
1034
+ const startC = Math.max(startMs, endC - DAY_MS);
1035
+ if (startC >= endC) return result;
1036
+ const log = demoCallLog();
1037
+ const modelStartMs = log.length > 0 ? Math.min(startC, log[0].simMs) : startC;
1038
+ const replayModel = demoReplayModel(modelStartMs);
1039
+ if (!replayModel) return result;
1040
+ const replay = replayModel;
1041
+ function record(simMs) {
1042
+ if (simMs < startC) return;
1043
+ for (const track of tracks) {
1044
+ const p = replay.projectDevice(track.deviceKey)[track.entityId];
1045
+ if (!p || p.state === track.lastState) continue;
1046
+ pushPoint(track, {
1047
+ s: p.state,
1048
+ a: p.attributes,
1049
+ lu: toSeconds(toWallMs(simMs))
1050
+ });
1051
+ }
1052
+ }
1053
+ record(replay.nowMs);
1054
+ let logIdx = 0;
1055
+ while (replay.nowMs < endC) {
1056
+ const nextLogMs = logIdx < log.length ? log[logIdx].simMs : Number.POSITIVE_INFINITY;
1057
+ const windowBoundary = replay.nowMs < startC ? startC : Number.POSITIVE_INFINITY;
1058
+ const targetMs = Math.min(replay.nowMs + STEP_MS, endC, nextLogMs, windowBoundary);
1059
+ replay.advanceTo(targetMs, STEP_MS);
1060
+ record(replay.nowMs);
1061
+ if (logIdx < log.length && log[logIdx].simMs === replay.nowMs) {
1062
+ const entry = log[logIdx];
1063
+ replay.dispatch(entry.call);
1064
+ applyVisitorHolds(replay, entry.call.entityIds);
1065
+ logIdx++;
1066
+ record(replay.nowMs);
1067
+ }
1068
+ }
1069
+ for (const track of tracks) {
1070
+ if (endC === liveNowMs) {
1071
+ const p = live.projectDevice(track.deviceKey)[track.entityId];
1072
+ if (p && p.state !== track.lastState) pushPoint(track, {
1073
+ s: p.state,
1074
+ a: p.attributes,
1075
+ lu: toSeconds(toWallMs(endC))
1076
+ });
1077
+ }
1078
+ result[track.entityId] = track.points.length > 5760 ? track.points.slice(-MAX_HISTORY_POINTS) : track.points;
1079
+ }
1080
+ return result;
1260
1081
  }
1082
+ //#endregion
1083
+ //#region src/history/fetch.ts
1261
1084
  /**
1262
- * Transform relative stream URL to absolute URL
1085
+ * History Fetching
1086
+ *
1087
+ * @packageDocumentation
1263
1088
  */
1264
- function transformStreamUrl(streamUrl) {
1265
- if (!streamUrl) return null;
1266
- if (streamUrl.startsWith("http://") || streamUrl.startsWith("https://")) return streamUrl;
1267
- if (streamUrl.startsWith("/")) {
1268
- const hassUrl = state.hassUrl;
1269
- if (!hassUrl) {
1270
- console.warn("[fetchStreamData] No Home Assistant URL available to resolve relative stream URL");
1271
- return streamUrl;
1272
- }
1273
- return `${hassUrl.replace(/\/$/, "")}${streamUrl}`;
1089
+ /**
1090
+ * Fetch history for entities during a time period
1091
+ */
1092
+ async function fetchHistory(options) {
1093
+ const { startTime, endTime, entityIds = [], includeStartTimeState = true, significantChangesOnly = true, minimalResponse = true, noAttributes: noAttributesOverride } = options;
1094
+ const timeMap = demoTimeMap();
1095
+ if (timeMap) {
1096
+ const startMs = timeMap.toSim(startTime.getTime());
1097
+ const endMs = timeMap.toSim((endTime ?? /* @__PURE__ */ new Date()).getTime());
1098
+ const energyStepMs = timeMap.toSim(6e4) - timeMap.toSim(0);
1099
+ const toWallSeconds = (simSeconds) => Math.round(timeMap.toWall(simSeconds * 1e3) / 1e3);
1100
+ const replayed = demoHistory(entityIds.filter((id) => !isEnergyEntity(id)), startMs, endMs, timeMap.toWall);
1101
+ const result = {};
1102
+ for (const id of entityIds) result[id] = isEnergyEntity(id) ? synthesizeEnergyHistory(id, startMs, endMs, energyStepMs).map((p) => ({
1103
+ s: p.s,
1104
+ a: {},
1105
+ lu: toWallSeconds(p.lu)
1106
+ })) : replayed[id] ?? [];
1107
+ return result;
1274
1108
  }
1275
- return streamUrl;
1109
+ const startTimeStr = startTime.toISOString();
1110
+ const endTimeStr = endTime?.toISOString();
1111
+ const params = {
1112
+ type: "history/history_during_period",
1113
+ start_time: startTimeStr,
1114
+ minimal_response: minimalResponse,
1115
+ no_attributes: noAttributesOverride ?? (entityIds.length === 0 ? false : !entityIds.some((entityId) => entityIdHistoryNeedsAttributes(entityId))),
1116
+ include_start_time_state: includeStartTimeState,
1117
+ significant_changes_only: significantChangesOnly
1118
+ };
1119
+ if (endTimeStr) params.end_time = endTimeStr;
1120
+ if (entityIds.length !== 0) params.entity_ids = entityIds;
1121
+ return await sendCommand(params) ?? {};
1276
1122
  }
1277
1123
  /**
1278
- * Fetch stream data for a camera entity
1124
+ * Fetch history for a single entity
1279
1125
  */
1280
- async function fetchStreamData(entityId, options = {}) {
1281
- const { format = "hls" } = options;
1126
+ async function fetchEntityHistory(entityId, options) {
1127
+ const { startTime, endTime, includeStartTimeState = true, significantChangesOnly = true, minimalResponse = false, noAttributes = false } = options;
1282
1128
  try {
1129
+ const entityHistory = (await fetchHistory({
1130
+ startTime,
1131
+ endTime,
1132
+ entityIds: [entityId],
1133
+ includeStartTimeState,
1134
+ significantChangesOnly,
1135
+ minimalResponse,
1136
+ noAttributes
1137
+ }))[entityId] ?? [];
1283
1138
  return {
1284
1139
  entityId,
1285
- stream: {
1286
- url: transformStreamUrl(await fetchStream(entityId, format)),
1287
- loading: false,
1288
- error: null,
1289
- lastFetched: Date.now(),
1290
- format,
1291
- expiresAt: null
1292
- },
1140
+ timeline: entityHistory.map((s) => ({
1141
+ timestamp: s.lu,
1142
+ state: s.s,
1143
+ attributes: s.a,
1144
+ lastChanged: s.lc,
1145
+ lastUpdated: s.lu
1146
+ })),
1147
+ entityHistory,
1293
1148
  loading: false,
1294
1149
  error: null,
1295
1150
  lastFetched: Date.now()
@@ -1297,262 +1152,114 @@ async function fetchStreamData(entityId, options = {}) {
1297
1152
  } catch (error) {
1298
1153
  return {
1299
1154
  entityId,
1300
- stream: {
1301
- url: null,
1302
- loading: false,
1303
- error: error instanceof Error ? error : new Error(String(error)),
1304
- lastFetched: null,
1305
- format,
1306
- expiresAt: null
1307
- },
1155
+ timeline: [],
1156
+ entityHistory: [],
1308
1157
  loading: false,
1309
1158
  error: error instanceof Error ? error : new Error(String(error)),
1310
1159
  lastFetched: null
1311
1160
  };
1312
1161
  }
1313
1162
  }
1314
- //#endregion
1315
- //#region src/camera/query.ts
1316
1163
  /**
1317
- * Camera Stream Query Builder
1318
- *
1319
- * @packageDocumentation
1164
+ * Convert compressed history state to timeline state
1320
1165
  */
1321
- const DEFAULT_REFRESH_INTERVAL = 24e4;
1322
- function needsStreamRefresh(streamData, refreshInterval = DEFAULT_REFRESH_INTERVAL) {
1323
- if (!streamData || !streamData.lastFetched) return true;
1324
- return Date.now() - streamData.lastFetched >= refreshInterval;
1166
+ function historyStateToTimeline(s) {
1167
+ return {
1168
+ timestamp: s.lu,
1169
+ state: s.s,
1170
+ attributes: s.a,
1171
+ lastChanged: s.lc,
1172
+ lastUpdated: s.lu
1173
+ };
1325
1174
  }
1326
- /**
1327
- * Get stream for a camera entity
1328
- */
1329
- async function getStream(entityId, options = {}) {
1330
- const { format = "hls", autoRefresh = true, refreshInterval } = options;
1331
- const cached = state.streams[entityId];
1332
- if (cached && cached.stream.format === format) {
1333
- if (autoRefresh && needsStreamRefresh(cached, refreshInterval)) fetchStreamData(entityId, {
1334
- format,
1335
- autoRefresh,
1336
- refreshInterval
1337
- }).then((streamData) => {
1338
- setState(produce((s) => {
1339
- s.streams[entityId] = streamData;
1340
- }));
1341
- });
1342
- return cached;
1343
- }
1344
- const streamData = await fetchStreamData(entityId, {
1345
- format,
1346
- autoRefresh,
1347
- refreshInterval
1348
- });
1175
+ //#endregion
1176
+ //#region src/history/query.ts
1177
+ /** Live tracker count per entity. Ref-counted because a timeline is shared: two
1178
+ * widgets on the same entity must not let the first unmount delete the store
1179
+ * data the second is still rendering (appendHistoryPoint would then silently
1180
+ * drop every update and the survivor's chart would freeze). */
1181
+ const trackedEntities = /* @__PURE__ */ new Map();
1182
+ /** Check if an entity has active history tracking. */
1183
+ function isHistoryTracked(entityId) {
1184
+ return trackedEntities.has(entityId);
1185
+ }
1186
+ /** Start tracking history for an entity. Fetches initial backfill. */
1187
+ async function trackEntityHistory(entityId, options) {
1188
+ trackedEntities.set(entityId, (trackedEntities.get(entityId) ?? 0) + 1);
1189
+ const historyData = await fetchEntityHistory(entityId, options);
1349
1190
  setState(produce((s) => {
1350
- s.streams[entityId] = streamData;
1191
+ s.history[entityId] = historyData;
1351
1192
  }));
1352
- return streamData;
1193
+ return historyData;
1353
1194
  }
1354
- /**
1355
- * Refresh stream for a camera entity
1356
- */
1357
- async function refreshStream(entityId, format = "hls") {
1195
+ /** Release one tracker. The entity's data is cleared only when the last one goes. */
1196
+ function untrackEntityHistory(entityId) {
1197
+ const remaining = (trackedEntities.get(entityId) ?? 0) - 1;
1198
+ if (remaining > 0) {
1199
+ trackedEntities.set(entityId, remaining);
1200
+ return;
1201
+ }
1202
+ trackedEntities.delete(entityId);
1358
1203
  setState(produce((s) => {
1359
- delete s.streams[entityId];
1204
+ delete s.history[entityId];
1360
1205
  }));
1361
- return getStream(entityId, { format });
1362
1206
  }
1363
1207
  //#endregion
1364
- //#region src/energy/prefs.ts
1365
- function isRecord(value) {
1366
- return typeof value === "object" && value !== null;
1367
- }
1208
+ //#region src/weather/query.ts
1368
1209
  /**
1369
- * Fetch the HA energy dashboard preferences.
1210
+ * Weather Forecast Query Builder
1370
1211
  *
1371
- * Returns `null` when no energy dashboard is configured (HA raises an error
1372
- * for `energy/get_prefs` in that case); callers treat `null` as "not
1373
- * configured".
1212
+ * @packageDocumentation
1374
1213
  */
1375
- async function fetchEnergyPreferences(connection) {
1376
- if (isDemoMode()) {
1377
- const prefs = demoEnergyPreferences();
1378
- return {
1379
- ...prefs,
1380
- energy_sources: prefs.energy_sources,
1381
- device_consumption: prefs.device_consumption
1382
- };
1383
- }
1384
- let response;
1385
- try {
1386
- response = await connection.sendMessagePromise({ type: "energy/get_prefs" });
1387
- } catch {
1388
- return null;
1389
- }
1390
- if (!isRecord(response)) return null;
1391
- const sources = Array.isArray(response.energy_sources) ? response.energy_sources.filter(isRecord) : [];
1392
- const devices = Array.isArray(response.device_consumption) ? response.device_consumption.filter(isRecord) : [];
1393
- return {
1394
- ...response,
1395
- energy_sources: sources,
1396
- device_consumption: devices
1214
+ /**
1215
+ * Get forecast for a single entity and type
1216
+ */
1217
+ async function getForecast(entityId, type) {
1218
+ const cached = state.forecasts[entityId];
1219
+ const cachedForecast = cached?.forecasts[type];
1220
+ if (cached && cachedForecast && cached.lastFetched[type]) return {
1221
+ entityId,
1222
+ type,
1223
+ forecast: cachedForecast,
1224
+ loading: cached.loading[type] ?? false,
1225
+ error: cached.errors[type] ?? null,
1226
+ lastFetched: cached.lastFetched[type] ?? null
1397
1227
  };
1228
+ const forecastData = await fetchForecastData(entityId, type);
1229
+ setState(produce((s) => {
1230
+ if (!s.forecasts[entityId]) s.forecasts[entityId] = {
1231
+ entityId,
1232
+ forecasts: {},
1233
+ loading: {},
1234
+ errors: {},
1235
+ lastFetched: {}
1236
+ };
1237
+ s.forecasts[entityId].forecasts[type] = forecastData.forecast;
1238
+ s.forecasts[entityId].loading[type] = forecastData.loading;
1239
+ s.forecasts[entityId].errors[type] = forecastData.error;
1240
+ s.forecasts[entityId].lastFetched[type] = forecastData.lastFetched;
1241
+ }));
1242
+ return forecastData;
1398
1243
  }
1399
- //#endregion
1400
- //#region src/media/url.ts
1401
1244
  /**
1402
- * Resolve an HA media reference (e.g. an entity's `entity_picture`, a camera
1403
- * proxy path) to a URL the page can actually load.
1404
- *
1405
- * HA gives these as origin-relative paths (`/api/media_player_proxy/...?token=`)
1406
- * carrying their own signed token. A bare `<img src>` would resolve them against
1407
- * the dash origin and 404. When the host set `mediaProxyBase`, route through the
1408
- * same-origin media proxy so the page CSP (`img-src 'self'`) covers it in every
1409
- * mode, LAN and remote. Otherwise fall back to prefixing `hassUrl` directly
1410
- * (the LAN-only path, used until a transport sets the proxy base).
1411
- *
1412
- * Absolute URLs and non-path values pass through untouched.
1413
- */
1414
- function hassMediaUrl(path) {
1415
- if (!path) return void 0;
1416
- if (/^https?:\/\//i.test(path)) return path;
1417
- if (!path.startsWith("/")) return path;
1418
- const base = state.mediaProxyBase;
1419
- if (base) return `${base}?path=${encodeURIComponent(path)}`;
1420
- return state.hassUrl ? `${state.hassUrl}${path}` : path;
1421
- }
1422
- //#endregion
1423
- //#region src/camera/webrtc.ts
1424
- /**
1425
- * Get WebRTC client configuration (STUN/TURN servers) from HA
1426
- */
1427
- async function getWebRtcClientConfig(entityId) {
1428
- const conn = privilegedConn();
1429
- if (!conn) throw new Error("No connection available");
1430
- return conn.sendMessagePromise({
1431
- type: "camera/webrtc/get_client_config",
1432
- entity_id: entityId
1433
- });
1434
- }
1435
- /**
1436
- * Start WebRTC session with HA.
1437
- * Returns a promise that resolves when the answer SDP is received.
1438
- * ICE candidates from HA are forwarded to the provided callback.
1439
- * Local ICE candidates should be sent via sendWebRtcCandidate().
1440
- */
1441
- function startWebRtcSession(entityId, sdpOffer, onCandidate) {
1442
- const conn = privilegedConn();
1443
- if (!conn) return Promise.reject(/* @__PURE__ */ new Error("Not connected"));
1444
- return new Promise((resolve, reject) => {
1445
- const session = {
1446
- sessionId: null,
1447
- unsubscribe: null
1448
- };
1449
- let answered = false;
1450
- const timeout = setTimeout(() => {
1451
- if (!answered) {
1452
- session.unsubscribe?.();
1453
- reject(/* @__PURE__ */ new Error("WebRTC offer timed out"));
1454
- }
1455
- }, 15e3);
1456
- conn.subscribeMessage((event) => {
1457
- switch (event.type) {
1458
- case "session":
1459
- session.sessionId = event.session_id ?? null;
1460
- break;
1461
- case "answer":
1462
- if (event.answer) {
1463
- answered = true;
1464
- clearTimeout(timeout);
1465
- resolve({
1466
- answer: event.answer,
1467
- session
1468
- });
1469
- }
1470
- break;
1471
- case "candidate":
1472
- if (event.candidate) onCandidate(event.candidate);
1473
- break;
1474
- case "error":
1475
- clearTimeout(timeout);
1476
- session.unsubscribe?.();
1477
- reject(new Error(event.message || event.code || "WebRTC offer failed"));
1478
- }
1479
- }, {
1480
- type: "camera/webrtc/offer",
1481
- entity_id: entityId,
1482
- offer: sdpOffer
1483
- }).then((unsub) => {
1484
- session.unsubscribe = unsub;
1485
- }).catch((err) => {
1486
- clearTimeout(timeout);
1487
- reject(err);
1488
- });
1489
- });
1490
- }
1491
- /**
1492
- * Send a local ICE candidate to HA
1493
- */
1494
- async function sendWebRtcCandidate(entityId, sessionId, candidate) {
1495
- const conn = privilegedConn();
1496
- if (!conn) throw new Error("Not connected");
1497
- await conn.sendMessagePromise({
1498
- type: "camera/webrtc/candidate",
1499
- entity_id: entityId,
1500
- session_id: sessionId,
1501
- candidate
1502
- });
1503
- }
1504
- //#endregion
1505
- //#region src/weather/query.ts
1506
- /**
1507
- * Weather Forecast Query Builder
1508
- *
1509
- * @packageDocumentation
1510
- */
1511
- /**
1512
- * Get forecast for a single entity and type
1513
- */
1514
- async function getForecast(entityId, type) {
1515
- const cached = state.forecasts[entityId];
1516
- if (cached?.forecasts[type] && cached.lastFetched[type]) return {
1517
- entityId,
1518
- type,
1519
- forecast: cached.forecasts[type],
1520
- loading: cached.loading[type] ?? false,
1521
- error: cached.errors[type] ?? null,
1522
- lastFetched: cached.lastFetched[type] ?? null
1523
- };
1524
- const forecastData = await fetchForecastData(entityId, type);
1525
- setState(produce((s) => {
1526
- if (!s.forecasts[entityId]) s.forecasts[entityId] = {
1527
- entityId,
1528
- forecasts: {},
1529
- loading: {},
1530
- errors: {},
1531
- lastFetched: {}
1532
- };
1533
- s.forecasts[entityId].forecasts[type] = forecastData.forecast;
1534
- s.forecasts[entityId].loading[type] = forecastData.loading;
1535
- s.forecasts[entityId].errors[type] = forecastData.error;
1536
- s.forecasts[entityId].lastFetched[type] = forecastData.lastFetched;
1537
- }));
1538
- return forecastData;
1539
- }
1540
- /**
1541
- * Get forecasts for a single entity and multiple types
1245
+ * Get forecasts for a single entity and multiple types
1542
1246
  */
1543
1247
  async function getForecasts(entityId, types) {
1544
1248
  const cachedData = {};
1545
1249
  const typesToFetch = [];
1546
1250
  const forecastsRecord = state.forecasts[entityId];
1547
- for (const type of types) if (forecastsRecord?.forecasts[type] && forecastsRecord.lastFetched[type]) cachedData[type] = {
1548
- entityId,
1549
- type,
1550
- forecast: forecastsRecord.forecasts[type],
1551
- loading: forecastsRecord.loading[type] ?? false,
1552
- error: forecastsRecord.errors[type] ?? null,
1553
- lastFetched: forecastsRecord.lastFetched[type] ?? null
1554
- };
1555
- else typesToFetch.push(type);
1251
+ for (const type of types) {
1252
+ const cachedForecast = forecastsRecord?.forecasts[type];
1253
+ if (forecastsRecord && cachedForecast && forecastsRecord.lastFetched[type]) cachedData[type] = {
1254
+ entityId,
1255
+ type,
1256
+ forecast: cachedForecast,
1257
+ loading: forecastsRecord.loading[type] ?? false,
1258
+ error: forecastsRecord.errors[type] ?? null,
1259
+ lastFetched: forecastsRecord.lastFetched[type] ?? null
1260
+ };
1261
+ else typesToFetch.push(type);
1262
+ }
1556
1263
  let fetchedData;
1557
1264
  if (typesToFetch.length > 0) {
1558
1265
  fetchedData = await fetchForecasts(entityId, typesToFetch);
@@ -1571,11 +1278,14 @@ async function getForecasts(entityId, types) {
1571
1278
  s.forecasts[entityId].lastFetched[type] = fetchedData.lastFetched[type] ?? null;
1572
1279
  }
1573
1280
  }));
1574
- for (const type of types) if (cachedData[type]) {
1575
- fetchedData.forecasts[type] = cachedData[type].forecast;
1576
- fetchedData.loading[type] = cachedData[type].loading;
1577
- fetchedData.errors[type] = cachedData[type].error;
1578
- fetchedData.lastFetched[type] = cachedData[type].lastFetched;
1281
+ for (const type of types) {
1282
+ const cached = cachedData[type];
1283
+ if (cached) {
1284
+ fetchedData.forecasts[type] = cached.forecast;
1285
+ fetchedData.loading[type] = cached.loading;
1286
+ fetchedData.errors[type] = cached.error;
1287
+ fetchedData.lastFetched[type] = cached.lastFetched;
1288
+ }
1579
1289
  }
1580
1290
  } else {
1581
1291
  fetchedData = {
@@ -1587,6 +1297,7 @@ async function getForecasts(entityId, types) {
1587
1297
  };
1588
1298
  for (const type of types) {
1589
1299
  const cached = cachedData[type];
1300
+ if (!cached) continue;
1590
1301
  fetchedData.forecasts[type] = cached.forecast;
1591
1302
  fetchedData.loading[type] = cached.loading;
1592
1303
  fetchedData.errors[type] = cached.error;
@@ -1782,6 +1493,285 @@ function createHaBridge(worker, events = {}) {
1782
1493
  };
1783
1494
  }
1784
1495
  //#endregion
1496
+ //#region src/connection/subscriptions.ts
1497
+ /** Registry subscription cleanup functions (entity sub managed by subscription-manager) */
1498
+ let registrySubscriptions = [];
1499
+ /** Buffered messages waiting for next animation frame */
1500
+ let messageBuffer = [];
1501
+ let rafScheduled = false;
1502
+ function bufferMessage(message) {
1503
+ messageBuffer.push(message);
1504
+ if (!rafScheduled) {
1505
+ rafScheduled = true;
1506
+ (typeof requestAnimationFrame === "function" ? requestAnimationFrame : queueMicrotask)(flushMessageBuffer);
1507
+ }
1508
+ }
1509
+ function flushMessageBuffer() {
1510
+ rafScheduled = false;
1511
+ const messages = messageBuffer;
1512
+ messageBuffer = [];
1513
+ if (messages.length === 0) return;
1514
+ setState(produce((s) => {
1515
+ for (const message of messages) {
1516
+ if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
1517
+ if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
1518
+ if (message.r) for (const entityId of message.r) delete s.entities[entityId];
1519
+ }
1520
+ }));
1521
+ const historyPoints = [];
1522
+ for (const message of messages) {
1523
+ if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) collectHistoryFromCompressed(historyPoints, entityId, compressedState);
1524
+ if (message.c) {
1525
+ for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) collectHistoryFromDiff(historyPoints, entityId, diff["+"]);
1526
+ }
1527
+ }
1528
+ if (historyPoints.length > 0) bulkAppendHistoryPoints(historyPoints);
1529
+ }
1530
+ /**
1531
+ * Subscribe to entity state updates with an entity_ids filter.
1532
+ * Called by the subscription manager on flush.
1533
+ */
1534
+ async function subscribeEntities(connection, entityIds) {
1535
+ const subscribeMsg = { type: "subscribe_entities" };
1536
+ if (entityIds.length > 0) subscribeMsg.entity_ids = entityIds;
1537
+ return await connection.subscribeMessage((message) => {
1538
+ bufferMessage(message);
1539
+ }, subscribeMsg);
1540
+ }
1541
+ /**
1542
+ * Subscribe to all relevant events from Home Assistant
1543
+ */
1544
+ async function subscribeToUpdates(conn) {
1545
+ await cleanupSubscriptions();
1546
+ setManagerConnection(conn);
1547
+ setResubscribeHandler(subscribeEntities);
1548
+ const registryUpdatedSub = await conn.subscribeEvents((event) => {
1549
+ handleRegistryUpdate(event);
1550
+ }, "entity_registry_updated");
1551
+ registrySubscriptions.push(() => registryUpdatedSub());
1552
+ const deviceRegistrySub = await conn.subscribeEvents((event) => {
1553
+ handleDeviceRegistryUpdate(event);
1554
+ }, "device_registry_updated");
1555
+ registrySubscriptions.push(() => deviceRegistrySub());
1556
+ const areaRegistrySub = await conn.subscribeEvents((event) => {
1557
+ handleAreaRegistryUpdate(event);
1558
+ }, "area_registry_updated");
1559
+ registrySubscriptions.push(() => areaRegistrySub());
1560
+ const floorRegistrySub = await conn.subscribeEvents((event) => {
1561
+ handleFloorRegistryUpdate(event);
1562
+ }, "floor_registry_updated");
1563
+ registrySubscriptions.push(() => floorRegistrySub());
1564
+ const labelRegistrySub = await conn.subscribeEvents((event) => {
1565
+ handleLabelRegistryUpdate(event);
1566
+ }, "label_registry_updated");
1567
+ registrySubscriptions.push(() => labelRegistrySub());
1568
+ const statisticsSub = await conn.subscribeEvents((event) => {
1569
+ handleStatisticsUpdate(event);
1570
+ }, "recorder_5min_statistics_generated");
1571
+ registrySubscriptions.push(() => statisticsSub());
1572
+ const coreConfigSub = await conn.subscribeEvents(() => {
1573
+ handleCoreConfigUpdate();
1574
+ }, "core_config_updated");
1575
+ registrySubscriptions.push(() => coreConfigSub());
1576
+ }
1577
+ /**
1578
+ * Clean up all active subscriptions
1579
+ */
1580
+ async function cleanupSubscriptions() {
1581
+ const subscriptions = [...registrySubscriptions];
1582
+ for (const unsub of subscriptions) await unsub();
1583
+ registrySubscriptions = [];
1584
+ }
1585
+ async function handleCoreConfigUpdate() {
1586
+ const conn = privilegedConn();
1587
+ if (!conn) return;
1588
+ try {
1589
+ const config = await conn.sendMessagePromise({ type: "get_config" });
1590
+ if (config) setState("config", config);
1591
+ } catch (error) {
1592
+ console.error("Error refreshing core config:", error);
1593
+ }
1594
+ }
1595
+ async function handleRegistryUpdate(event) {
1596
+ const { action, entity_id } = event.data;
1597
+ if (action === "create" || action === "update") {
1598
+ const conn = privilegedConn();
1599
+ if (!conn) return;
1600
+ try {
1601
+ const entry = await conn.sendMessagePromise({
1602
+ type: "config/entity_registry/get",
1603
+ entity_id
1604
+ });
1605
+ if (entry) updateEntityRegistry(entry);
1606
+ } catch (error) {
1607
+ console.error("Error fetching registry entry:", error);
1608
+ }
1609
+ } else if (action === "remove") removeEntityRegistry(entity_id);
1610
+ }
1611
+ async function handleDeviceRegistryUpdate(event) {
1612
+ const { action, device_id } = event.data;
1613
+ if (action === "create" || action === "update") {
1614
+ const conn = privilegedConn();
1615
+ if (!conn) return;
1616
+ try {
1617
+ const device = (await conn.sendMessagePromise({ type: "config/device_registry/list" })).find((d) => d.id === device_id);
1618
+ if (device) updateDevice(device);
1619
+ } catch (error) {
1620
+ console.error("Error fetching device:", error);
1621
+ }
1622
+ } else if (action === "remove") removeDevice(device_id);
1623
+ }
1624
+ async function handleAreaRegistryUpdate(event) {
1625
+ const { action, area_id } = event.data;
1626
+ if (action === "create" || action === "update") {
1627
+ const conn = privilegedConn();
1628
+ if (!conn) return;
1629
+ try {
1630
+ const area = (await conn.sendMessagePromise({ type: "config/area_registry/list" })).find((a) => a.id === area_id);
1631
+ if (area) updateArea(area);
1632
+ } catch (error) {
1633
+ console.error("Error fetching area:", error);
1634
+ }
1635
+ } else if (action === "remove") removeArea(area_id);
1636
+ }
1637
+ async function handleFloorRegistryUpdate(event) {
1638
+ const { action, floor_id } = event.data;
1639
+ if (action === "create" || action === "update") {
1640
+ const conn = privilegedConn();
1641
+ if (!conn) return;
1642
+ try {
1643
+ const floor = (await conn.sendMessagePromise({ type: "config/floor_registry/list" })).find((f) => f.floor_id === floor_id);
1644
+ if (floor) updateFloor(floor);
1645
+ } catch (error) {
1646
+ console.error("Error fetching floor:", error);
1647
+ }
1648
+ } else if (action === "remove") removeFloor(floor_id);
1649
+ }
1650
+ async function handleLabelRegistryUpdate(event) {
1651
+ const { action, label_id } = event.data;
1652
+ if (action === "create" || action === "update") {
1653
+ const conn = privilegedConn();
1654
+ if (!conn) return;
1655
+ try {
1656
+ const label = (await conn.sendMessagePromise({ type: "config/label_registry/list" })).find((l) => l.label_id === label_id);
1657
+ if (label) updateLabel(label);
1658
+ } catch (error) {
1659
+ console.error("Error fetching label:", error);
1660
+ }
1661
+ } else if (action === "remove") removeLabel(label_id);
1662
+ }
1663
+ async function handleStatisticsUpdate(event) {
1664
+ const { statistic_ids } = event.data;
1665
+ if (!statistic_ids || statistic_ids.length === 0) return;
1666
+ const conn = privilegedConn();
1667
+ if (!conn) return;
1668
+ try {
1669
+ const metadata = await conn.sendMessagePromise({
1670
+ type: "recorder/get_statistics_metadata",
1671
+ statistic_ids
1672
+ });
1673
+ if (metadata && Array.isArray(metadata)) updateStatisticsMetadata(metadata);
1674
+ } catch (error) {
1675
+ console.error("Error fetching statistics metadata:", error);
1676
+ }
1677
+ }
1678
+ function timestampToIso(timestamp) {
1679
+ if (timestamp === void 0) return (/* @__PURE__ */ new Date()).toISOString();
1680
+ if (typeof timestamp === "string") return timestamp;
1681
+ return (/* @__PURE__ */ new Date(timestamp * 1e3)).toISOString();
1682
+ }
1683
+ function collectHistoryFromCompressed(points, entityId, compressed) {
1684
+ if (!isHistoryTracked(entityId)) return;
1685
+ const stateVal = compressed.s ?? compressed.state ?? "unknown";
1686
+ const attributes = compressed.a ?? compressed.attributes ?? {};
1687
+ const lu = compressed.lu ?? (compressed.last_updated ? new Date(compressed.last_updated).getTime() / 1e3 : Date.now() / 1e3);
1688
+ const lc = compressed.lc ?? (compressed.last_changed ? new Date(compressed.last_changed).getTime() / 1e3 : void 0);
1689
+ points.push({
1690
+ entityId,
1691
+ stateValue: stateVal,
1692
+ attributes,
1693
+ lastUpdated: lu,
1694
+ lastChanged: lc
1695
+ });
1696
+ }
1697
+ function collectHistoryFromDiff(points, entityId, additions) {
1698
+ if (!isHistoryTracked(entityId)) return;
1699
+ const stateVal = additions.s ?? additions.state;
1700
+ if (stateVal === void 0) return;
1701
+ const lu = additions.lu ?? Date.now() / 1e3;
1702
+ const lc = additions.lc;
1703
+ const attributes = additions.a ?? additions.attributes ?? {};
1704
+ points.push({
1705
+ entityId,
1706
+ stateValue: stateVal,
1707
+ attributes,
1708
+ lastUpdated: lu,
1709
+ lastChanged: lc
1710
+ });
1711
+ }
1712
+ function shallowEqualAttributes(existing, incoming) {
1713
+ const incomingKeys = Object.keys(incoming);
1714
+ for (const key of incomingKeys) if (existing[key] !== incoming[key]) return false;
1715
+ return true;
1716
+ }
1717
+ function applyCompressedState(s, entityId, compressed) {
1718
+ const stateVal = compressed.s ?? compressed.state ?? "unknown";
1719
+ const attributes = compressed.a ?? compressed.attributes ?? {};
1720
+ const context = compressed.c ?? compressed.context ?? {
1721
+ id: "",
1722
+ parent_id: null,
1723
+ user_id: null
1724
+ };
1725
+ const lastChanged = timestampToIso(compressed.lc ?? compressed.last_changed);
1726
+ const lastUpdated = timestampToIso(compressed.lu ?? compressed.last_updated);
1727
+ const existing = s.entities[entityId];
1728
+ if (existing) {
1729
+ const stateChanged = existing.state !== stateVal;
1730
+ const changedAt = existing.last_changed !== lastChanged;
1731
+ if (stateChanged) existing.state = stateVal;
1732
+ if (changedAt) existing.last_changed = lastChanged;
1733
+ if (stateChanged || changedAt) existing.last_updated = lastUpdated;
1734
+ if (!shallowEqualAttributes(existing.attributes, attributes)) Object.assign(existing.attributes, attributes);
1735
+ if (existing.context.id !== context.id) existing.context = context;
1736
+ } else s.entities[entityId] = {
1737
+ entity_id: entityId,
1738
+ state: stateVal,
1739
+ attributes,
1740
+ last_changed: lastChanged,
1741
+ last_updated: lastUpdated,
1742
+ context
1743
+ };
1744
+ }
1745
+ function applyStateDiff(s, entityId, diff) {
1746
+ const existing = s.entities[entityId];
1747
+ if (!existing) return;
1748
+ const additions = diff["+"];
1749
+ if (additions) {
1750
+ let stateChanged = false;
1751
+ if (additions.s !== void 0 && existing.state !== additions.s) {
1752
+ existing.state = additions.s;
1753
+ stateChanged = true;
1754
+ }
1755
+ if (additions.a) {
1756
+ for (const [key, value] of Object.entries(additions.a)) if (existing.attributes[key] !== value) {
1757
+ existing.attributes[key] = value;
1758
+ stateChanged = true;
1759
+ }
1760
+ }
1761
+ if (additions.lc !== void 0) {
1762
+ const lcIso = timestampToIso(additions.lc);
1763
+ if (existing.last_changed !== lcIso) {
1764
+ existing.last_changed = lcIso;
1765
+ stateChanged = true;
1766
+ }
1767
+ }
1768
+ if (additions.lu !== void 0 && stateChanged) existing.last_updated = timestampToIso(additions.lu);
1769
+ if (additions.c && existing.context.id !== additions.c.id) existing.context = additions.c;
1770
+ }
1771
+ const removals = diff["-"];
1772
+ if (removals?.a) for (const key of removals.a) delete existing.attributes[key];
1773
+ }
1774
+ //#endregion
1785
1775
  //#region src/connection/bridged.ts
1786
1776
  /**
1787
1777
  * Wire an HA bridge (worker-backed connection) into the store. The bridge's
@@ -1847,7 +1837,7 @@ function detachBridgeFromStore() {
1847
1837
  * host's link with one the widget controls — every subsequent service call is
1848
1838
  * intercepted and every entity the dashboard renders can be fabricated.
1849
1839
  * - disconnect / detachBridgeFromStore: drop the household's connection.
1850
- * - loadDemoData / unloadDemoData: overwrite live state with fixtures.
1840
+ * - loadDemoData / loadDemoHouse / resetDemo / unloadDemoData: overwrite live state with the demo house.
1851
1841
  */
1852
1842
  let unclaimed = {
1853
1843
  attachBridgeToStore,
@@ -1857,6 +1847,8 @@ let unclaimed = {
1857
1847
  createHaBridge,
1858
1848
  disconnect,
1859
1849
  loadDemoData,
1850
+ loadDemoHouse,
1851
+ resetDemo,
1860
1852
  unloadDemoData
1861
1853
  };
1862
1854
  /**