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