@glasshome/sync-layer 0.8.2 → 0.9.1

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