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