@glasshome/sync-layer 0.9.0 → 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 +20 -48
- package/dist/index.js +1141 -1177
- package/dist/{service-C8H42I-Q.d.ts → service-B1gegd-N.d.ts} +2 -2
- package/dist/solid/index.d.ts +1 -1
- package/dist/solid/index.js +103 -1
- package/dist/{statistics-C5j1DLGM.js → track-IkwP6XmO.js} +737 -826
- package/dist/worker/worker-main.js +1 -1
- package/dist/{worker-main-DamaVb7B.js → worker-main-B6eeHPDQ.js} +4 -3
- package/package.json +6 -4
|
@@ -36,191 +36,103 @@ function setPrivilegedConn(conn) {
|
|
|
36
36
|
privileged = conn;
|
|
37
37
|
}
|
|
38
38
|
//#endregion
|
|
39
|
-
//#region src/
|
|
39
|
+
//#region src/connection/subscription-manager.ts
|
|
40
|
+
if (globalThis.__GH_SYNC_LAYER_SUB_MGR__) console.error("[@glasshome/sync-layer] DUPLICATE subscription-manager module instantiated. registerEntity() and the connection setter will hit different singletons → subscribe_entities will never be sent. Cause: relative import of subscription-manager from a separate entry point.");
|
|
41
|
+
globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
|
|
42
|
+
/** Ref counts — how many consumers need each entity */
|
|
43
|
+
const refCounts = /* @__PURE__ */ new Map();
|
|
44
|
+
/** Current HA subscription unsub function */
|
|
45
|
+
let currentEntityUnsub = null;
|
|
46
|
+
/** Snapshot of entity IDs in the current HA subscription */
|
|
47
|
+
let subscribedIds = /* @__PURE__ */ new Set();
|
|
48
|
+
/** Connection reference for resubscribing */
|
|
49
|
+
let conn = null;
|
|
50
|
+
/** Pending flush timer (microtask for register, timeout for unregister) */
|
|
51
|
+
let flushScheduled = false;
|
|
52
|
+
/** Delayed unregister timeout */
|
|
53
|
+
let unregisterTimeout = null;
|
|
54
|
+
/** Callback invoked on resubscribe — set by subscriptions.ts */
|
|
55
|
+
let onResubscribe = null;
|
|
56
|
+
/** Delay before unregistered entities are actually removed from subscription */
|
|
57
|
+
const UNREGISTER_DELAY_MS = 5e3;
|
|
58
|
+
/** Guard against concurrent flush() calls */
|
|
59
|
+
let flushInFlight = false;
|
|
40
60
|
/**
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* upstream `calendar/event/subscribe` (HA pushes an initial snapshot, then
|
|
44
|
-
* debounced updates); trackers declare desired windows and a reconciler keeps
|
|
45
|
-
* the subscription matched to the union. Data lands in `state.calendars`;
|
|
46
|
-
* read-scoping for widget consumers arrives with WS-8 like every other store
|
|
47
|
-
* read.
|
|
61
|
+
* Register an entity for live updates. Returns an unregister function.
|
|
62
|
+
* Call from useEntity/useEntities hooks via onCleanup.
|
|
48
63
|
*/
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const DAY_MS$1 = 864e5;
|
|
54
|
-
const trackers = /* @__PURE__ */ new Map();
|
|
55
|
-
const active$1 = /* @__PURE__ */ new Map();
|
|
56
|
-
/** Per-entity reconcile chain; serializes subscribe/unsubscribe per calendar. */
|
|
57
|
-
const chains$1 = /* @__PURE__ */ new Map();
|
|
58
|
-
let untrackTimer$1 = null;
|
|
59
|
-
let rollTimer = null;
|
|
60
|
-
/** Day-quantized union of all tracker windows, so equal requests within a day
|
|
61
|
-
reconcile to the same subscription instead of churning. */
|
|
62
|
-
function windowFor(list) {
|
|
63
|
-
let behind = 0;
|
|
64
|
-
let ahead = 0;
|
|
65
|
-
for (const t of list) {
|
|
66
|
-
if (t.daysBehind > behind) behind = t.daysBehind;
|
|
67
|
-
if (t.daysAhead > ahead) ahead = t.daysAhead;
|
|
68
|
-
}
|
|
69
|
-
const dayStart = /* @__PURE__ */ new Date();
|
|
70
|
-
dayStart.setHours(0, 0, 0, 0);
|
|
71
|
-
return {
|
|
72
|
-
start: (/* @__PURE__ */ new Date(dayStart.getTime() - behind * DAY_MS$1)).toISOString(),
|
|
73
|
-
end: new Date(dayStart.getTime() + (ahead + 1) * DAY_MS$1).toISOString()
|
|
74
|
-
};
|
|
64
|
+
function registerEntity(entityId) {
|
|
65
|
+
refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
|
|
66
|
+
scheduleFlush$1();
|
|
67
|
+
return () => unregisterEntity(entityId);
|
|
75
68
|
}
|
|
76
69
|
/**
|
|
77
|
-
*
|
|
78
|
-
* function. Safe while disconnected: the subscription activates on the next
|
|
79
|
-
* `forceResubscribeCalendars()` (called by the connection layer on connect
|
|
80
|
-
* and reconnect).
|
|
70
|
+
* Set the connection reference used for resubscribing.
|
|
81
71
|
*/
|
|
82
|
-
function
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
daysAhead: options?.daysAhead ?? DEFAULT_DAYS_AHEAD
|
|
86
|
-
};
|
|
87
|
-
const list = trackers.get(entityId) ?? [];
|
|
88
|
-
list.push(tracker);
|
|
89
|
-
trackers.set(entityId, list);
|
|
90
|
-
scheduleFlush$1();
|
|
91
|
-
ensureRollTimer();
|
|
92
|
-
let untracked = false;
|
|
93
|
-
return () => {
|
|
94
|
-
if (untracked) return;
|
|
95
|
-
untracked = true;
|
|
96
|
-
untrackCalendarEvents(entityId, tracker);
|
|
97
|
-
};
|
|
72
|
+
function setManagerConnection(connection) {
|
|
73
|
+
conn = connection;
|
|
74
|
+
if (refCounts.size > 0) scheduleFlush$1();
|
|
98
75
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (untrackTimer$1) return;
|
|
106
|
-
untrackTimer$1 = setTimeout(() => {
|
|
107
|
-
untrackTimer$1 = null;
|
|
108
|
-
flushAll$1();
|
|
109
|
-
}, UNTRACK_FLUSH_DELAY_MS$1);
|
|
76
|
+
/**
|
|
77
|
+
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
78
|
+
* the manager into the actual WS subscription lifecycle.
|
|
79
|
+
*/
|
|
80
|
+
function setResubscribeHandler(handler) {
|
|
81
|
+
onResubscribe = handler;
|
|
110
82
|
}
|
|
111
83
|
/**
|
|
112
|
-
*
|
|
113
|
-
*
|
|
114
|
-
* an unchanged window is no proof the old subscription is alive, so each
|
|
115
|
-
* tracked entity resubscribes (HA re-pushes the snapshot, refreshing the
|
|
116
|
-
* store through any missed changes).
|
|
84
|
+
* Force a resubscribe with the current active set.
|
|
85
|
+
* Used on reconnect.
|
|
117
86
|
*/
|
|
118
|
-
async function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
87
|
+
async function forceResubscribe() {
|
|
88
|
+
await flush();
|
|
89
|
+
}
|
|
90
|
+
function unregisterEntity(entityId) {
|
|
91
|
+
const count = (refCounts.get(entityId) ?? 1) - 1;
|
|
92
|
+
if (count <= 0) refCounts.delete(entityId);
|
|
93
|
+
else refCounts.set(entityId, count);
|
|
94
|
+
scheduleDelayedFlush();
|
|
125
95
|
}
|
|
126
96
|
function scheduleFlush$1() {
|
|
97
|
+
if (flushScheduled) return;
|
|
98
|
+
flushScheduled = true;
|
|
127
99
|
queueMicrotask(() => {
|
|
128
|
-
|
|
100
|
+
flushScheduled = false;
|
|
101
|
+
flush().catch(logFlushError);
|
|
129
102
|
});
|
|
130
103
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
104
|
+
function scheduleDelayedFlush() {
|
|
105
|
+
if (unregisterTimeout) return;
|
|
106
|
+
unregisterTimeout = setTimeout(() => {
|
|
107
|
+
unregisterTimeout = null;
|
|
108
|
+
flush().catch(logFlushError);
|
|
109
|
+
}, UNREGISTER_DELAY_MS);
|
|
135
110
|
}
|
|
136
|
-
function
|
|
137
|
-
|
|
138
|
-
chains$1.set(entityId, next.catch(() => {}));
|
|
139
|
-
return next;
|
|
111
|
+
function logFlushError(error) {
|
|
112
|
+
console.error("[@glasshome/sync-layer] entity resubscribe failed:", error);
|
|
140
113
|
}
|
|
141
|
-
async function
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
if (current) {
|
|
146
|
-
active$1.delete(entityId);
|
|
147
|
-
try {
|
|
148
|
-
await current.unsub?.();
|
|
149
|
-
} catch {}
|
|
150
|
-
}
|
|
151
|
-
chains$1.delete(entityId);
|
|
152
|
-
setState(produce((s) => {
|
|
153
|
-
delete s.calendars[entityId];
|
|
154
|
-
}));
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
const conn = privilegedConn();
|
|
158
|
-
if (!conn) return;
|
|
159
|
-
const win = windowFor(list);
|
|
160
|
-
if (current?.unsub && current.start === win.start && current.end === win.end) return;
|
|
161
|
-
setState(produce((s) => {
|
|
162
|
-
s.calendars[entityId] = {
|
|
163
|
-
entityId,
|
|
164
|
-
events: s.calendars[entityId]?.events ?? [],
|
|
165
|
-
loading: true,
|
|
166
|
-
error: null,
|
|
167
|
-
start: win.start,
|
|
168
|
-
end: win.end
|
|
169
|
-
};
|
|
170
|
-
}));
|
|
171
|
-
const old = current?.unsub ?? null;
|
|
172
|
-
const entry = {
|
|
173
|
-
start: win.start,
|
|
174
|
-
end: win.end,
|
|
175
|
-
unsub: null
|
|
176
|
-
};
|
|
177
|
-
active$1.set(entityId, entry);
|
|
114
|
+
async function flush() {
|
|
115
|
+
if (!conn || !onResubscribe) return;
|
|
116
|
+
if (flushInFlight) return;
|
|
117
|
+
flushInFlight = true;
|
|
178
118
|
try {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
start: win.start,
|
|
191
|
-
end: win.end
|
|
192
|
-
});
|
|
193
|
-
} catch (err) {
|
|
194
|
-
active$1.delete(entityId);
|
|
195
|
-
setState(produce((s) => {
|
|
196
|
-
const rec = s.calendars[entityId];
|
|
197
|
-
if (!rec) return;
|
|
198
|
-
rec.loading = false;
|
|
199
|
-
rec.error = err instanceof Error ? err : new Error(String(err));
|
|
200
|
-
}));
|
|
119
|
+
const newIds = new Set(refCounts.keys());
|
|
120
|
+
if (setsEqual(newIds, subscribedIds)) return;
|
|
121
|
+
const oldUnsub = currentEntityUnsub;
|
|
122
|
+
const entityIds = [...newIds];
|
|
123
|
+
currentEntityUnsub = await onResubscribe(conn, entityIds);
|
|
124
|
+
subscribedIds = newIds;
|
|
125
|
+
if (oldUnsub) try {
|
|
126
|
+
await oldUnsub();
|
|
127
|
+
} catch {}
|
|
128
|
+
} finally {
|
|
129
|
+
flushInFlight = false;
|
|
201
130
|
}
|
|
202
|
-
if (old) try {
|
|
203
|
-
await old();
|
|
204
|
-
} catch {}
|
|
205
|
-
}
|
|
206
|
-
/** One timer for the whole module: at local midnight the day-quantized
|
|
207
|
-
windows shift, so a reconcile slides every subscription forward. Only
|
|
208
|
-
armed while something is tracked — an idle app holds no timer. */
|
|
209
|
-
function ensureRollTimer() {
|
|
210
|
-
if (rollTimer || trackers.size === 0) return;
|
|
211
|
-
const now = /* @__PURE__ */ new Date();
|
|
212
|
-
const next = new Date(now);
|
|
213
|
-
next.setHours(24, 0, 30, 0);
|
|
214
|
-
rollTimer = setTimeout(() => {
|
|
215
|
-
rollTimer = null;
|
|
216
|
-
flushAll$1().then(ensureRollTimer);
|
|
217
|
-
}, next.getTime() - now.getTime());
|
|
218
131
|
}
|
|
219
|
-
function
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
}
|
|
132
|
+
function setsEqual(a, b) {
|
|
133
|
+
if (a.size !== b.size) return false;
|
|
134
|
+
for (const id of a) if (!b.has(id)) return false;
|
|
135
|
+
return true;
|
|
224
136
|
}
|
|
225
137
|
//#endregion
|
|
226
138
|
//#region src/core/types.ts
|
|
@@ -231,40 +143,269 @@ function extractDomain(entityId) {
|
|
|
231
143
|
return entityId.split(".")[0] ?? "";
|
|
232
144
|
}
|
|
233
145
|
//#endregion
|
|
234
|
-
//#region src/
|
|
235
|
-
const DEMO_ALBUM_COVER = "data:image/webp;base64,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";
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
function
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
146
|
+
//#region src/entities/views.ts
|
|
147
|
+
/**
|
|
148
|
+
* Get icon for an entity from registry or attributes
|
|
149
|
+
*/
|
|
150
|
+
function resolveIcon(entity, registry) {
|
|
151
|
+
if (registry?.icon) return {
|
|
152
|
+
icon: registry.icon,
|
|
153
|
+
source: "registry"
|
|
154
|
+
};
|
|
155
|
+
if (entity.attributes?.icon) return {
|
|
156
|
+
icon: entity.attributes.icon,
|
|
157
|
+
source: "attribute"
|
|
158
|
+
};
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Build an EntityView from runtime state and registry metadata
|
|
163
|
+
*/
|
|
164
|
+
function buildEntityView(entity, registry) {
|
|
165
|
+
const domain = extractDomain(entity.entity_id);
|
|
166
|
+
const attributes = entity.attributes ?? {};
|
|
167
|
+
const friendlyName = attributes.friendly_name ?? registry?.name ?? entity.entity_id;
|
|
168
|
+
const name = registry?.name ?? friendlyName;
|
|
169
|
+
const iconResolution = resolveIcon(entity, registry);
|
|
170
|
+
const { device_class: _dc, unit_of_measurement: _uom, friendly_name: _fn, icon: _icon, ...restAttributes } = attributes;
|
|
171
|
+
let areaId = registry?.area_id ?? null;
|
|
172
|
+
if (areaId === null && registry?.device_id) {
|
|
173
|
+
const device = state.devices[registry.device_id];
|
|
174
|
+
if (device) areaId = device.area_id ?? null;
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
id: entity.entity_id,
|
|
178
|
+
domain,
|
|
179
|
+
state: entity.state,
|
|
180
|
+
attributes: restAttributes,
|
|
181
|
+
lastChanged: new Date(entity.last_changed),
|
|
182
|
+
lastUpdated: new Date(entity.last_updated),
|
|
183
|
+
context: {
|
|
184
|
+
id: entity.context?.id ?? "",
|
|
185
|
+
parentId: entity.context?.parent_id ?? null,
|
|
186
|
+
userId: entity.context?.user_id ?? null
|
|
187
|
+
},
|
|
188
|
+
name,
|
|
189
|
+
friendlyName,
|
|
190
|
+
areaId,
|
|
191
|
+
deviceId: registry?.device_id ?? null,
|
|
192
|
+
platform: registry?.platform ?? "unknown",
|
|
193
|
+
uniqueId: registry?.unique_id ?? null,
|
|
194
|
+
isDisabled: registry?.disabled_by !== null && registry?.disabled_by !== void 0,
|
|
195
|
+
isHidden: registry?.hidden_by !== null && registry?.hidden_by !== void 0,
|
|
196
|
+
icon: iconResolution?.icon ?? null,
|
|
197
|
+
iconSource: iconResolution?.source ?? "default",
|
|
198
|
+
entityCategory: registry?.entity_category ?? null,
|
|
199
|
+
labels: registry?.labels ?? [],
|
|
200
|
+
aliases: registry?.aliases ?? [],
|
|
201
|
+
deviceClass: registry?.device_class ?? attributes.device_class ?? null,
|
|
202
|
+
unitOfMeasurement: registry?.unit_of_measurement ?? attributes.unit_of_measurement ?? null,
|
|
203
|
+
supportedFeatures: registry?.supported_features ?? attributes.supported_features
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Get or build an EntityView
|
|
208
|
+
*/
|
|
209
|
+
function getEntityView(entityId) {
|
|
210
|
+
const entity = state.entities[entityId];
|
|
211
|
+
if (!entity) return;
|
|
212
|
+
const registry = state.entityRegistry[entityId];
|
|
213
|
+
return buildEntityView(entity, registry);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Get multiple entity views
|
|
217
|
+
*/
|
|
218
|
+
function getEntityViews(entityIds) {
|
|
219
|
+
const views = [];
|
|
220
|
+
for (const id of entityIds) {
|
|
221
|
+
const view = getEntityView(id);
|
|
222
|
+
if (view) views.push(view);
|
|
223
|
+
}
|
|
224
|
+
return views;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Get all entity views
|
|
228
|
+
*/
|
|
229
|
+
function getAllEntityViews() {
|
|
230
|
+
return getEntityViews(Object.keys(state.entities));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Shallow equality check for EntityView.
|
|
234
|
+
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
235
|
+
*/
|
|
236
|
+
function entityViewEquals(a, b) {
|
|
237
|
+
if (a === b) return true;
|
|
238
|
+
if (!a || !b) return false;
|
|
239
|
+
return a.id === b.id && a.state === b.state && a.lastChanged.getTime() === b.lastChanged.getTime() && a.name === b.name && a.friendlyName === b.friendlyName && a.areaId === b.areaId && a.deviceId === b.deviceId && a.icon === b.icon && a.isDisabled === b.isDisabled && a.isHidden === b.isHidden && a.attributes === b.attributes;
|
|
240
|
+
}
|
|
241
|
+
//#endregion
|
|
242
|
+
//#region src/entities/area-views.ts
|
|
243
|
+
/**
|
|
244
|
+
* Area View Builder
|
|
245
|
+
*
|
|
246
|
+
* Constructs unified AreaView objects by combining registry data with computed
|
|
247
|
+
* entities and devices.
|
|
248
|
+
*
|
|
249
|
+
* Membership (which entities/devices belong to an area) is registry-driven and
|
|
250
|
+
* cheap; live entity *views* read entity state and are therefore exposed as a
|
|
251
|
+
* lazy `entities` getter. Callers that only need names, ids, or counts never
|
|
252
|
+
* touch entity state, so building the whole-area list does not recompute on
|
|
253
|
+
* every entity state tick.
|
|
254
|
+
*
|
|
255
|
+
* @packageDocumentation
|
|
256
|
+
*/
|
|
257
|
+
/** The area an entity belongs to: its own `area_id` wins; otherwise it inherits
|
|
258
|
+
* the area of its device. Null when neither resolves. */
|
|
259
|
+
function entityAreaId(entry) {
|
|
260
|
+
if (entry.area_id) return entry.area_id;
|
|
261
|
+
if (entry.device_id) return state.devices[entry.device_id]?.area_id ?? null;
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
/** One pass over the device + entity registries → membership per area.
|
|
265
|
+
* O(devices + entities) for the whole home, vs the O(areas × (devices +
|
|
266
|
+
* entities)) of resolving each area independently. */
|
|
267
|
+
function computeAreaMembership() {
|
|
268
|
+
const byArea = /* @__PURE__ */ new Map();
|
|
269
|
+
const ensure = (areaId) => {
|
|
270
|
+
let m = byArea.get(areaId);
|
|
271
|
+
if (!m) {
|
|
272
|
+
m = {
|
|
273
|
+
devices: [],
|
|
274
|
+
entityIds: []
|
|
275
|
+
};
|
|
276
|
+
byArea.set(areaId, m);
|
|
277
|
+
}
|
|
278
|
+
return m;
|
|
279
|
+
};
|
|
280
|
+
for (const device of Object.values(state.devices)) if (device.area_id) ensure(device.area_id).devices.push(device);
|
|
281
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) {
|
|
282
|
+
const areaId = entityAreaId(entry);
|
|
283
|
+
if (areaId) ensure(areaId).entityIds.push(entityId);
|
|
284
|
+
}
|
|
285
|
+
return byArea;
|
|
286
|
+
}
|
|
287
|
+
/** Membership for a single area, without indexing every other area. */
|
|
288
|
+
function membershipFor(areaId) {
|
|
289
|
+
const devices = [];
|
|
290
|
+
for (const device of Object.values(state.devices)) if (device.area_id === areaId) devices.push(device);
|
|
291
|
+
const entityIds = [];
|
|
292
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entityAreaId(entry) === areaId) entityIds.push(entityId);
|
|
293
|
+
return {
|
|
294
|
+
devices,
|
|
295
|
+
entityIds
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Build a DeviceView from registry data
|
|
300
|
+
*/
|
|
301
|
+
function buildDeviceView(device) {
|
|
302
|
+
return {
|
|
303
|
+
id: device.id,
|
|
304
|
+
name: device.name ?? "",
|
|
305
|
+
nameByUser: device.name_by_user ?? null,
|
|
306
|
+
manufacturer: device.manufacturer ?? null,
|
|
307
|
+
model: device.model ?? null,
|
|
308
|
+
swVersion: device.sw_version ?? null,
|
|
309
|
+
areaId: device.area_id ?? null,
|
|
310
|
+
configEntries: device.config_entries ?? [],
|
|
311
|
+
connections: device.connections ?? [],
|
|
312
|
+
identifiers: device.identifiers ?? [],
|
|
313
|
+
disabledBy: device.disabled_by ?? null
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
/** Assemble an AreaView from pre-resolved membership. `entities` is a lazy
|
|
317
|
+
* getter: materializing live EntityViews reads entity state, so deferring it
|
|
318
|
+
* keeps callers that only need names/ids/counts off the per-state-tick
|
|
319
|
+
* recompute path. The getter stays reactive — read inside a tracking scope it
|
|
320
|
+
* tracks those entities' state and updates like any other store read. */
|
|
321
|
+
function assembleAreaView(areaId, membership) {
|
|
322
|
+
const area = state.areas[areaId];
|
|
323
|
+
if (!area) throw new Error(`Area ${areaId} not found`);
|
|
324
|
+
const devices = membership.devices.map((device) => buildDeviceView(device));
|
|
325
|
+
const entityIds = membership.entityIds;
|
|
326
|
+
return {
|
|
327
|
+
id: areaId,
|
|
328
|
+
name: area.name,
|
|
329
|
+
normalizedName: area.normalized_name ?? area.name.toLowerCase(),
|
|
330
|
+
aliases: Array.isArray(area.aliases) ? area.aliases : [],
|
|
331
|
+
floorId: area.floor_id ?? null,
|
|
332
|
+
icon: area.icon ?? null,
|
|
333
|
+
picture: area.picture ?? null,
|
|
334
|
+
labels: Array.isArray(area.labels) ? area.labels : [],
|
|
335
|
+
temperatureEntityId: area.temperature_entity_id ?? null,
|
|
336
|
+
humidityEntityId: area.humidity_entity_id ?? null,
|
|
337
|
+
createdAt: area.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
338
|
+
modifiedAt: area.modified_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
339
|
+
devices,
|
|
340
|
+
entityIds,
|
|
341
|
+
deviceIds: devices.map((d) => d.id),
|
|
342
|
+
get entities() {
|
|
343
|
+
return entityIds.length > 0 ? getEntityViews(entityIds) : [];
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Build an AreaView for a single area from registry data.
|
|
349
|
+
*/
|
|
350
|
+
function buildAreaView(areaId) {
|
|
351
|
+
return assembleAreaView(areaId, membershipFor(areaId));
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Get all area views. Membership is resolved in a single registry pass; entity
|
|
355
|
+
* views stay lazy per area (see `assembleAreaView`).
|
|
356
|
+
*/
|
|
357
|
+
function getAreaViews() {
|
|
358
|
+
const membership = computeAreaMembership();
|
|
359
|
+
return Object.keys(state.areas).map((areaId) => assembleAreaView(areaId, membership.get(areaId) ?? {
|
|
360
|
+
devices: [],
|
|
361
|
+
entityIds: []
|
|
362
|
+
}));
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Get area view
|
|
366
|
+
*/
|
|
367
|
+
function getAreaView(areaId) {
|
|
368
|
+
try {
|
|
369
|
+
return buildAreaView(areaId);
|
|
370
|
+
} catch {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/demo/demo-album-cover.ts
|
|
376
|
+
const DEMO_ALBUM_COVER = "data:image/webp;base64,UklGRkQqAABXRUJQVlA4IDgqAACQ4gCdASpAAUABPrFQoEynJKMrJ5QcoWAWCU2DebOqk3Yg0pyMZc19bp75H+tZ6fX3Xq02cSLsHBqwh+o3+wbzrnV92l9dil3ZfrdbGzkJxWfPPOJ3FzTwUPMi+l2o3inFi93ATZpsReQlnN/h9UQaNMl0igvPehaYxv9+fjjc+t20PTqrF6T8q1AdYhoxKPtHaU+AmukJ9c/yRa0SbFO+7CI4isgNrbwo0odhHx7fvmgDzHgoE9/kfIvTPa+RdSRJFdQlt0Q0dktx4ES/qt/SHhZNpSCOg4oEXADX+HY1KqNaR1HR2CMl3vJvNgh6vSfi1/hXh29K9V6JxVSwOuZBF1k5M/rUqTe3k8Ft6DsHeXuAuzxIA/7AmRkJIV4fs8hMJwfTnrVpE8d+kgK80TZB1tqPQvCUgqn82BIJLz+JYPvHkr2cpNq/q68twT6GqMGJtCdI5KzowOH3FT8+ynELNk9PHeCqo3dtyxa0GKBFFh3z47qelHqrNWw7vx59Exwp9tDbK+2cZqq7LhSdEus26nTx9+7yXGUEPrBRD1U5e5oys6QIIDQHAzwoCuqNAciyO0LOomfWz7DYSc3IfRfUqGwTYtTWN/4cKUovu6kjHVgoanoUkkcCBOKpNO+MxGszt1uYCkorxQOmbqPb/j2PuFd+KRNXdOdnHCXwtbiIIwkMMMR2jZKuB3Va0a7AS4kWyrf5kZKpP0O0ATz1NtXoJEAYa7C0jJiFe7BSP9xEoYkjsAu82dVWFFYbCqKaZONhUQ+iUufG33f9pZkRkoTtrfSCG5JS07rjbNpRxA8fw/+6Je+X3sjrfh3UQw4iQw02FSPn5MDPBw/lNf4htp6NrIAmMdZ7hkL9JT9KiX3SaBPF/7nakRoKf/etiMjPSzk79Q5lorKDDFkVCOoKbVFo3OOV32OxFK0Dlvq+kxY9GfhZdaVMFqUNuIgq96MN6c7yigebpPy2qZCuQuH+5pZei+vHw30jnLOho+k4neXqZgH5AcKqqOX0XVRTRbFKJJGfqZbMMvblJQT35NTGR7/njfCpy+Wgecw+kOlPRRNWX6fl+4SyQ0lvluhW4jpA8QNQ9t6XEVs3oZAwkxkO8uIs7NQnDOdWA2OWJpHDWSdMERVvkbDnrvm6mIR8Lc2qW060sxUdYXyx4VXImuX1p27iaQKecCPUEuEYEntSTWpXbHM5cgdGTcV30RP6cdZvqtwCPYuXXJlUrmTOat5fgt0mMJFGHGnOdiMT8LgC+iWrFp/xvx9qvcT+dGnyXzs+YkdRpy6d+j8JN5STOFgvEV0D06YaGHc0/EA/ge9S8W8FflM4sGjGVT3m+ZH58b7fjCZ8SLIboml8tHU+Y1gIgG3zDeWHFSVAhGmhweCaYXt6wr5DfGP0IzYR8OnC3DUyKj+B+QHeu6fg16kumV9w8P6hHvtRdbG7MwAKRp/O0+jbsXnPyYrJr9d/9cV3o5DunpMIKSW1wt9NyiifHQnAWgleuWXgD2P7BZA/ABvRka9LxWOaG8fMFzeYc3y4WkYvMlmuB1lxpIWH4kAJWZr3tIpfkhcTsoq54Sv/lB7c5SQTRkf1xRGwdV8cOF/8zMewedFOi09j4JU0jec1+fABhT3VTaC5px8QXNGhXgSuwV/225MYi/+d3NNCttUkG9t5eLmwX7Zc2sPqHo+fazRPPumR7bxwg/r8m5MI00GutyCGu6sTU6jL37vBy3pkcXQThE71Z/Wpup7rBqEfGi8aDa4qLwBGgSGjPvV02pPLKV/3jJ93s2YrTontsfUzpmn8f/+E5WIPFxvkgMoTA9DFU8UW8UCChMDhv185az6P89f8A1p/9FVn0G/ctJc/0faT3LYJx8Zdr1KUGXn8aVNKVWrAW5VFgdWPrDjv97vFVYQDp2BlZ+rEZnXXlfy9VqU/24pgy32yycuDZ3y+aDVnYzL78W4xF9d0c64GClK///p2m7krUv6t+21rzrawP/+eqb55hdKaW/ggeJIpWlTvS7Q9wB7BjDBjSARWKpCVsEpX5+NE+sNLSVnQHOAyEZ+KK6ZQ97hshVTnj1IUb9NvYxktUYWFd1sTx/2ci9+0C+Q8+sT4A7P+RHlihSEP1P+9WWyZ/wkl3VoAihyP60Ztci5gjSZ6jly9URHe9YeJn1MwjLNyvCrHdr/I2p+y6e2TXMOOe+gbJQygJc/NVm238B79ku8NRf9VM9f7g9o5crB9V0PQIwO6Sl65vF4dV5bI54L7DhRSLZRNc2QF0R3nbac7wxf8ABiHzKVvV16um1mMxIQ6BG3sdAh/93hN4LHsuO5oUh38XQLSJlYOo0A+HRaAHzn7mvE+mW/NiQ+eopUxFDiRUnB7waYRZAZqXduFImdbM3mxHBTq9Hc98z+HSaw30tRs3casDQKI44fUEzXF57me78T7IAIbRTke4AAA/vI7yvPlwD9nEnJet6YkK3jrHDlM6c8K9nX6TRXHKas6ixDZhyWVOd5lKMjnfQuQXRzuppd1q3YhGF2/GQq+j1dCND5N45eA2Ffvl6OfwZ320tT6CxnyNIAkeq6kwdKsVN95ru1Y8QzoEoFUI5caYkPuoz1ix6bndFG4U6XDMDwRPwX1mos4xepVYftLL94b/bey+xtcdw00E3gRlIKjUyYR3WT0aHKsgNqYnQO6DOj0YkYBohUjPoiVWw3O0h6lmIzVxrRyb1qiHSrZmU9fqY1ZwNxatpH2txtrwgR9QKE1gKBj32OfjU19ILKInZZanrBHMlUC4xz9m8Em27lBz1Wn9bmJpOjfISdJSj9ZmjXG+ViWKnxswCMNqKFay09DBJwAgrrsCTrffq1ppumi5dJ7eauQdI1Igb5xnEtb1wMk01Ssq00O5xF7IqeH2vcJVMq37ABp1c5F+pXzYTQrAsQExyE2mv+ZrALauDIw8hl3yoXd2h69qBp2QfnQwAbqbSGNvJGdI8CeH3o3r5kQp9EOJN07lGwR3VZV+iHyDZAytokW+KnOFLj50k1Kt15YsiGIHC0UAM8hvzGFei93lwgiCL13RLsBYm2E6h30kvbfsuK+Qj8cZLSHPTW3rHyM4n3hzGEG2N53emYHqIccOFrWLayCKc8c21V+H7cAeXLVbeS5MgGq9kcqOfxntZodpGbIfvCWa37vMPbD55dZ9X98yjwJxW66szqDWkIOscIHJTvPDHkonFhTuc4A7oFqSI4W0XIM6QqDOwzBNl6VertEi0vAmcR13IiGmg0No8Ug4ksDY+T4GS8AcqckcdePOa7DQIucpEAF2LlQ/s+ZHCKazF3lnuY/ssKCP2qah/b4a03S21JCnC0BMrRU+fXpcO1w/mqo0/PGf3FBZBGmGqzFe9kDur/8ZBI6vmXk2F7f74gyxzKFOCb5oKuHz7nHJu7SgGF6q/jtsi+8nr0egwO6nU7+SgDNKuDR5orsZIeVqqt4xFr8Q+wPDA7ADW7umcGmINvRZ88AJkxLK9eFtvxmbjzMRRZFAM9r9vAISoat9kp81YmyWooA42caiZLbVJOz6k7RovIv21uOEDO5C1LIwlq7WNUOna3DrLW8EtfQK8WRt0aFTKO2lRBJ9QlJDljGs0LKZ8GBpiGxZRX9zRx7gPQM3eo1Ep61HgksO0XVgzEsDwsqSZLNi4xCb4GdjVEPADLugMmUg4STOKLCFC6Vy7bwDACJ+dJbhdovICf428gPJEaKbWegofpR6XhvzkD00eZLwzAC01RCfZC/LOmHe9xzkYJiqRJsaeqKS1l9bn0vhl2FXDz3Zq0JPQEE1SYec6UQRQDDdo3ESIMBNwbvue/wEP2GF3N9tAr3vIcT28ryWn9/te4uKtgf96ht4HpxsGTGpsQaEvKUjo7t9vyL5HexSdfG6n89j9TwMNC+JFsl+Eh75dfeffXspMb/DupCoGZb9Jy+GId7pzIFq9DerVuBoEArfyiEYckttcrpxhnZRE7ayCePdKnlfjVVLIcNCRXmLAmbM5AvqF2JEgKg9tBz2rrVyZKEhc8lZDrk43WAj1aw4qfiTiAxpGZn/1Qx1zxQ6zmGsKWpksjmZuPIuNHEMLMc7aW+v0KRyIlzRT3JO/ZhEUkXn5GgA77Zf2XaF9jsm65gPXoH8B3EPHYp1SW47xFVh7IFBHfc+23spHo+aM4t+eqm+53PznTMjx2zYmToAW9we2J3EMR/pZEuRNcZtosotzaUFgTxnDEEwFw7Kvi0cGJSo/gKbtYXjP68zFiFEwvsWJAlFKANQ9QvXtxtVl7fL5jLIserfw+mPjn7PBKasl1Wd+Wa11t9X/va+hqQgyBIB7aHSKra8EEEX4PEIfYZXpl1aRHVtOLseVedAd4eneOjBa8hgKiVEJUIYmwOrG08xCgMMzxrPrv3iq0ZpY38AflgoOjlpCSUY6ov5Er01hfGYGOHCriZWlv6SsaD2BKWZqhtrCZiSmegD63sc1Wdts+ORTgJZiYhKEBSRiFpsY2NlMrchkaOFG0hEeGucjAPdv3u4PgVP5UqeJCauYmUzpE3wvwQxKg9x3gw2Pqgl5FEXh1tJrVu3MXJ9kwZxCRvNmJ1kiFsu+2I670/3xip8d7gZAp+BrzQZLzD62noG910fSW5yGmPzsUccKFrXBP1r9mK996b9hMQsQfpfAdRWzemz7OpztjNPQxjKl+vvUH9WteiXMOFhYPKjROaWDx9FXYmjVi8Xpi6W4GoAZoO8OFOewjpIH032RqqmZ9PH10tH/ots7RMVJ+OkoT0cniU7qDxH7oY7c1Ly+je2rH7gILQ42Ic5tGY8LNm1TD/SPXoceWV43jN+69ZX7nQS+IlJqPsx6C6LCH2A5gwH2XOIQf5/gTUug1Ejhxy55nJtAXJaIQEaNsNpF43aj/+PKBuK2GfBdjASSV+lfQAvoYpJpA2mnQZZXmFelTr0IBV3507L3RZ3fl8LN86L2ZGJ13oXbJk7eaVi9drb2kYlZ/V7o+mGQFUnX3YR0VJFE4xEw6dqVExtzJiI5EDCRkpyr4mEXevYnDJswn98a2rPnQr2asq0ZXtMOSGD7eiGBRwUZQRWtBfqNylqS9gjBVxh9Mfod9B2RNGd/MTNCA77pOoKKkrIztClwf8lLNpkiZNsGJMe4/wYFgB/Krwzj1WZ1xjP41fc1FQxNmCJnPNvjJqMWlHo+E2ALOP+WZ0uzL7oHb0pKcJCE9Nc7bUp67GtPi350Wwpk6Yciv911LgtwtOey8OslQKwAij5YY/pD5LM0LopnQJtHpFLaI40/3z+bU+YidMk6gXDtFwXGKJUoAuWosig6U77KpsLtmaJWkm9E21xRMxpREGMNqh31ZNjLZyZ1d4EKFw+ihq7WGJQyNU+0dzdjcgUWK3ckCmIDH8OSWC7cKChd5tCmg2fsgzlSXRGsyBSfhNl5QUVmVugMa26fSXgopjA7d3ITd6m8EAssf1k+DQj/DJflWJ14VV2o8RQHo5dVmtVQtjcFc9NZ7gpzyujObey2nT6uhCzA81lIc/Pb6UrRq8UcsvqK42CzOkvTvRzGm8HBszMlug3XjO+xYw5lygnHU3qvahbiM3Td+xi2cUH9429sXOt2OeUffbdLl5gY1IIXtQoLdH3lZIruy83E13/SRoYmEzUwtJ9FEvVTXoY62eykxLmy+qjIAKna9GJsLyJ1HAwTe9PXWEEialemlSRsJjjyjNm9WUzpRNntVM7DJ1jpk/FQl9b1eS+ZW72tBal/DImlnqKqOBgq/kl40DPeisyNYYfUHOHgV86pO/y2VY+2cKbbRn9q9XC9ByjyrYw36Wx/dhLeGr0Jg4+B4+493ZgPO4U0FkT5t8a1xrDrVO0ysynddLnYw75/VkkzMDu2PN/jkMjpIFPA9roG/98SQr/pC80+gHVhfYlF+4y1GoDZpNBZ0PphYD6IRi1nHOPYqjCyb/GARF92VJ0TYbOftVFDfnh327wa5sp/3Kptf9l4eVfmn38/fswmIQxoIrSLpzH6OAeN7HpeP2gJ+pfZvTS1k2g+Z8SIm9AUPJJODhW0b7AEf5HereA+UZ2ROCS0t2Bg2FkLwUT2b+W/Rq4Eem435leDrdORXSv2BwcmY1Zjv4IkQwVXvZrdaghtH6x5kC9jSF7tVLIzvVBcjPYO+pcQ5no3g08JdmIp6LUlPEaxpMYT6m8SAJdC0BUnH52v4TearavVTCR+mxb6668gNWx7VECB54PA/Pw809ICgkg4iAwYxhxq8hUf/d6z7TOWhn34JbDyAErXwHS596v5gJ3SGfQpHFxwEf1+N5lOWnhQf/y95al9HSOkReAwFpqzFudkati7OJtCOGlG8+C8plUswbb1vhgxYvK72PMBbCVjtIjJTO93JU+03MrCgLc8sQ1/X2CMGXdpgdml7f4esW44uNrSGTueHtExGn9l5mHB8cVPJdkcaK3InOHtKLy+Qb3hHSrKHAxjrPanH3fp4LojCx0hwd/2a4uNA47WA1lTJqJ2A8ziaWBXfK0j11UjFQG40iGZp0VpZGartPMt7Yc3p+pDB5+drCbqwyjnm6IK2ZQCk/UDFLvb0x08dOauAtOI9d5G6lEo2KgqZ8vIrEQQj5byoEMVvLS4IBgysa92UECyJxIWStU3wHHhy6rLB+v1smcqcheRB6QEibmEe8uSTf/CfT06UqXKg8vEBmg/NyeJIzZOv3wI9WYRDkl5xY2+3wq1YVZdcKcMIcjmqNRMQc7VF1wbHWlzs9j8ncrWetY4J9bKIPKimfiesAA2/gzqYqIMsRiA7nl+8+FJKe90cyUBi7iS7mOWAO8ME9QifssrTMWs751+IXwdWH62bErkkSVEfZqanY9R19rqkz2UhajtbV+ulPMvv85CZUA6cs3sKi3yXUpNjphjlPFvj2HsupVjMuLVWa2/vDdMO48yyOtYTm42uAMVPA/fL8CYQ/4bfe2i45btv2YIxyNvxLzjMjHWl0i1wN7wqFzXbvIG8Tzvp0pQ6j+6EFY/dsvU5aLR+hd7B7D+jz+cEl0rDFl+gGsQtnN+ynQNfjpts5XB+aKZo+d9WRwRtSwyVlJBNVFfVP2mqQo0liagBPoSssepe1AaOjkZIw4lVZuwyMnkkkVCGc7IZ8niG2eLWC81tCM59/LTrhFyB/Y1U3GVxG63ASwA2b8frtl93cIsOtQjY+zDizof+Z5jHIws0De5tKE3RZleMCa1FWH5xYYRXNhJ2D1Y7oIWpXyntHP7+6GJjNOQbzkc7+hmjcTIrChusitr/62X6/GHNuI9pTt0pNC/VUgRfqUKiF4B+ImqcgYitpUqK+66Q+kGN25egmD4RHPNQLu3wIz5FU2Ixs8NlUij2eh3xfI+EdQyiMzPtRMgL59pMto6zrjnnl0q8Xvjmo8UHJ5yImlgbdih2ooYg9SXwaXErD6qYQwytGCiJ0J8DKAZdq3I750ovmYQMJ34HkDcrUuJDB8bWkJzZDAndXeS/J30XboEuoS9s86kGlPAtydhQtUuhwGJN5A17V0RZdq32Htxbdy41ZQhkAIwYYQ3NXgWLAxVZ5v9WVACCKCuarLFdik/Jn0w2gco7e0RdYAQ+t8Oe4voJUvrVBbB98iVSj/Fsh31FpdEOVFQW1DzUuvZqE9mXD1Qxd5U4fOh0qH9bFRH4+uZ7TvZFRrwDYQ31oDIG20dO6v0k9LZ9rfmOG2jAQA8F8aSEqo/4UBQUmAU5aGonyjqBAeakAfWu5kJ35YaXI5/O8R9TYgPe0r0lpWnu+hPg3rDl93L+oUl7KhqhD/HoUvu05p0LmLiQDy5O+0F/6Ugxsif9mN6hORta29ibUGu1TXurQk3ePBzb4Vk77s+ofGOZGZPPcbYCII7iqHU2z6el8n4IiwMfeSvmmyWoK5uUzYKMpPthhR0phNgjMG6n0vq8UMieFR6Tio9tN48I6pmRAbxpmpa2xC8xn5xGfwde7nv0dQdH7DchM/hUaQEg9FijrwpC49PBPEib1Ub8g5a4JOvW65zhTt1y0F0sZtfXjjfFO3o2wWgnU207iz2M//XX51MKzrFWUTgQ9sjSzOcOYLtl/bI2HAEBtWsItFWQBH9a9W7UuchhChOFpJGMVR7UtJbjRDYF7hNf4E5aGuvcuv+1nCeof7cCQTzopG1DZJaQYpcWWO1PijJCDBK2IEG2v5nDP9TDrAGE9/Ky6sESwQgQI9ZxvxkxIoIqhFfSC5q5ITFS4D1LhUZuXuOTOaQpN42H1kZdygPQSkxVhv/p2cfcgE0oale/NOB9Pz835drJiyo1HiFrqCz8viNDZDWm2fIAeq+QlMvBIFWKbh0SYQRo+mSgAde2SbZ3LSfiMvG9qKUv7ElSF0gnt22bqBA74sh/a5/TLUwAE6aXGAszVcaFRwYvQT2g1iW7usMI1DdecRYNY4+BoWH9ExRMErJPXoKuWb8BjIY4SOFvu2EDXOb9RcCVOAKiTOfg9wCibVbEQAoL+JpTXPJi5Ro4mZIxh/xCT6J035ukjsix7/RJJ4X34Z5NsKnT/jIUxRNecd10vYDAPfuIF4hLZrDUX5l+1qMfPLX/SggHjTYK7JQsucvZXnVRZAV/QvhKXAmUfOSQnJPGvv/SE/OgLKBC2lCnLR90Cb2yddlP7JdH/477UXney0HSjJpZaakbAj+1QXThB8ndGTlyFPlDv9y+MtliprJghLObfAY0BX+aPmUMyKXLMAnPQhvgGx3U/4Dqa/Gy7+ALVvERXlsVvZFVgTgpFfEm+ByBQehtkx4q8hjGJgdgXWLgMfN5s6Z1fS/AfBnLxfCeMlxVJ46YiwvuobVPPjpmQejiMoqowSby6DsgHGOeuyIaSe4jryKv3QglN5TZhy4wkpaN4vibQfzPj/EivflclEULMtHEa0JsjqrD8b7aogafDpZxsh7Jz2yT7Ry368hCMwbw3qeCYpDwPyjBOMv1qAberFzbnWjZh9Ll0r5v/H2cQXgn9qkO/Kgba369xpXq/IYT6DSnqQHvhXjdKKxEtGOpVDgcUSmh0wZJ4We0vXQfuWlBhjVHPmRLWQ9IsjXdQYEWZQE/YzVVHNKiJAeU638SITqaTunOoaSzyrbjBqKUfQuInESVJkMxBhTOVOJ7J9+tl5iRXPowDG6JPe/fqCIrZDfLERr4bOtar71NKp2SAinanjyS8sHN/5858amLVggMVOcOz7ZKS/VHNYPDY9aFR5/g2OPguPlZAkfS/NM39eI3wCZ9K0dqPlL5gMlZlNzhNolJg6QvE5l5DUjThMihMREpAtdEPMTFqKozZbAJ2kpPcw6SbGbGAmAfwI5/eUhEoCfSuNV8dd63aHpQin0S3KmbIXi5hkKoNgfMEBxCJVg7HFIWzVk/Bz0j82o0XE12rwqmGkN3g6Tb0jHOf6XGW/FZP0lm2tU8FI3uoDcNsJi9zXNvKaV93H0fJ0PJJVMJ+k9rTec7Y7QLlteQ1oHr9GG5pCAmryZgFkIQxnoNj9sTi+zYb1y+tVEpaXgfGv52Zgi6cfo5VIyJAY18kAdunW2i+EtEYzjEEnzJXurayItgkyETPTqc28cY+khQqPZQMEaKqOwnSBawx5JGPxVM3XL45FBf/9wAiwyMABivJDy3lGnfLbhlbdUYf7RgMu6qRqV7XdNQw/CWXbu/97YX1bF3r6AYw70ZFCGgqnAtDMkT+k4TDSM9qslAKc9k1YOKwm6LUC/fK3h5qBUn5EV5duzo6VDQPBsQfK/TgHH87+mpY3qcL8nr6oMY0U72HFKC/TulOMGOsWxhy51JTXpDPm0ONGxf0fZFhHf7b12g4LeljT3+tgTUpJgN5WtDJnWaWm4PwmkjZKvaLemQ/wjkmyra3UlYBdipqI4Nwi8Z1WOieHchdyE1pclto613/D71A9mUFVkTpKNdtFGEezVJUWAymJ1/KhrmNU0Xe+we1WO3lXCO5QnLxH5mSNHM9go0ImPIEDq+wUQdrBwazi12rnR17APh7hiuLdP5dN5QwLBHH6l8URwVw+YQlfVgDFkVcHaW2Nq1N7yYIYNlXdSBCFAG9ghiBo/vLCJO0PdAv4hjG9+j4A5weOdXW/Wuos8pegNGLh4Q+M6cmhSY0AL8jEU4JQFHdEtydfF6cp/3D6Iy9O+wXRj7e0Q+gDvoW/0lJh4FmQDauBBo3KSSjJzMbVi03452X0WiNAVZ12XLuiVv4M6Y8EiU76//nR/9/eCfI0T91ffp+gDqMDgxeGraxUS3xSp2q35tRDUC+qmvIk/UMQGKUFjOGaDeBHrt+e13+hxRJ2p1kuvWWMIPex4XreBeCRE/UYEaQu49/0c7plTB+JVHMUjO64MtTlm1Co2LbupJxuoZVxgT8/2s7ENcuMFij1eSpUJ3QtsrR76/W4VG1rYN4LnKMA7YA9Q/9rLeMP3riWMgFqXqgZvBGvRQanP3tyb9beMAbfz7BqRDOj7ST8xtT5naIyrn4470QgL7//ix7gL0T8DvJzTF+crqCZcdO20MQCm8kYbkw8zjwHm6xOPnM1c1d9qpm4059FeLiTUp1DNWRwSPtaX1Ct68BdUrI8StULQ7uRZleFySLja7UGZ9PcraB5+qsEwmTyxvAUov84vs/P2q2tThN/UyDGljeiRMdeyjzwVZXhviue21rbLaOsRtSCOmPPFkHvbDjuWrbAXeDGyoF/b3Ly7yK+0G17uUlwSH1c9qpa0zZ+KJ6h8TidKgPPeY0Kf/lfGK0ZSGAu0Ek5ZAdutvJoUTTpwLiCBs4zAr/YTQM4Df/724Ut2gf1ds7asn1nnjA8WC+D2//ytw1S51ezqZMp+7NQ8MMXLTSDABObE6fI8BG7Gett4U37FC6fTKNtuooXhtZPxnOE+Yv39/yWSFND1sjMHZ3LWxF7FGQNN+EUZrCYQb8MKaR9D/4gKOVXbnGhU2BwkJvWwM3zqT/tAaD7G+moNHqfITW5ifGU5U1qZ0SYmd/svFrJgF7hWstGIgh4W3F5fpP4Z8JIqpzxPfeEWBC8VJGUp2Fa6bHozTQWY7R5JZjjaWsDAJ9M5EzaNLuxuvL1+0S9dct5nBLbnFEjhk7aJqVWsQLYqcA7rq1semeZMyME4i1COOktSvGq78cOc3FYDWynuISORFtP/vigXsmKPERNR4jNWZzaF/DIvqyGugGaCheXeWqylYv2pEA75ob5Qxy6bUE84HswFoSp09ROICAEb1/K5/ME4wCBm36SDktlgYyO8PvYdaLkTcKgnBfm1RW+TSW8LcIdokPAxVXiVRUGig5ieZIwdLn3Bl8CV4Iif50nHZ0ZHl6erLzerMz2L1/MGYMRu3r7x35RN3kBHTv6xSWoxCM5SuMVRRus65brKRid5ZjdnNqDySgQtCiVoxLhOPbN3+jPYsTLmqkjbz6xYrdVkV59ABuyl2jXK7yw19i3KHFSDfstSlCScVoK8NZrjW+btQlifrJOVvD4y3yRWIr1Y86+2YlSZS8PXjTFk+YP+i4VaOct1VKqz/xh7WDedfrmDp2lSDomxPteSdL5bx0DGSCmTPGBbXCREpXyd438BTVoWdnvX1g3uPcORENslabeH1vrfu2e3Fit9wmRTuy+K10KZwWxZ8gM7m7HRnHkMlvOt9BXJwqyx3VGGuMcl1wmpIubVJOjE1R13/ZFpEXlpfAa2y170HVu/yTFZe09cqTSCrEuhnqDsLVujJDkOp6xwhuEtBlwfXSmuUa81kH8WmJvaH2MGH6+P4lEJrF7TZTgEo8X+NRtHcfJ05t8+tEF6xLF3M8x98ipgeI7RNT2opQDqeBVhiegUaOd5DlHiWa6Pb3kP1Lr0lXuU6euGa2Z7y+nSUQaSV1TyaR8PjTg8pjF/LHFg9ZmGlm/fgjNUZsDg8W1BokYp4GfeJirz3kalaxFGlG7cGUCQJXtRdf/DkMwlTj0naQd2IQrJqoRu1cFV/ORA8i+8n+ET/uv6OJdjZ84clD5/RTqmpLQarFF3z8FPDIZp9zm0w82lIKeD+G6w5QU2jHR9f1pfW7z7t0hwuJzA6QJ/vWaQPHIGm/JOBg289Vpaydz/wpJEOGFwFjnOJDse85tZwb3ZEnUPPa6RfVlwQonMfDbvTJDnm1edtj6ZvRfo9giW+4kh2mPwoQd4fLV5+gmfhsNhb6cTPNxR321xot+o+1Pruxxt5otV4DBn7xjDWwJUJYsDFsyrL8cz8C+wvCuNgP2PBTW9489QMisIgpUzN6wvrK7OXP2lZ7OgK0zremoHFPTif7oTQ+MCGKN7Lm+pY7mUsvffKdMX+XojYho846fnNpyJ18iT6XQyZUI4FvrQSFV57112tDgEy+TayBvB6aBVP0aqQoAdKdy8WJBD9+F0AbAdmx+6zlb4nXqP/kPNQPrOSeJVwIpjKesUa3kQCohNL+kXfFZQOKfh7JqtEUV5w7YD05fLZhOeT9M4kxAicMyPTkIsGyjqjPFZr1QTFSUBLGuyX6eAZoLhmHdM9+Zf/A+AXtAOaANY2FmEIe4+4pcyRCwhDp7xTubQI7nbbt4qvtE9Lwo4wkeFPIpnLHrkwWIce+WnyIIfY8c2VqrYnVU+hETEVf3YNc4qHfYyVivG+moG4GyoolhbhjB9gBx3bA8bN7qcSEO5K3mbRK+xl9PD5LRgiAuHJ5oFRmq2cidaWW7RTrT2yBM3H8BlTBVy65sJuhfDYl0FW0g5oNlcElgR7iuwqTmzgCb1aq6ZebQYWL7zq5e5j+ao1ElZvCEbbnvLZWJeT9ft+lWP5RqZfuKmKGy3BY3pfmtvt9xPICuJobDavxSS3CF0tiJrfT6tpASWj0hRg/FP3wliODA6oe3Ou8zK486aHkP4r+wwTLwFTSWq2x3Sz5LOZ+NRwXyPWTms1icZVswU4gLbjBjw67prMfYfl4428xaZUB6iL1KUVG7GZzFSUA/Do+IP0BNmwZMq5hs7FEUdBlQo5ciiwPjdYFBzlSIoSQVh4xg0mqp6fe2tmRJG9c/Smg+tUf+c8I9bgPyuf+qcc0iTSzcCHWL1S8NgI1xSi5DsZgsL7a0qqXdSR9OmK+Wp+vngRgYRJzxU0wC96/CZ3WX/N3bogwxblBrxTaRU/XJrGwQuSebiFE9uMTrhIqJk0to0vMOvNe6wgcjOelPa8FuIHR2Tk9GS9onJagfeXgVsNgnJwEjGU2CTidTykTvZkmtN6LEfFuFPq6/NAKBGncmOSpKbm8XGDvz0IqcaXy38x46ArXGfle1WJUXEWFa+N4mRqmF5FroTgSqGsTCks401sb1f4W0iiA2YlJdOriQ8hEnXUHLHvlpAjicJsKNQQLqw7eTTK7OPU+phwyx7bCfdXLVcb33trDBZokxQUQ7kp2U3ne9V1FCU2DWSXp90kICFARRHYWJDkqbYD4lQOtH66T+Ga0NGOHBbFSo/xJop9xa5emXLASabF0ovx/yWgW8cIrlcFvifY0nM7z+pQ4tp0XKVitViy2NH6fBwRgZpS21g1ttwsFY5Qh4KnMkgTTr72TBRK/MQiQcvwdo0LlIh0OwTMkyN2S5yfSfgAvM4xp3bx9EE+jjmQEZfAsY+u0CEameHj+ZCanpsxYfgQiGx6NoZkge3oXVtUMxLBqtmFEmVipxQhz1ekC1KreCpOMDQuBCQiHaRqBVxc1RiMH3/75qCUEbFuMDX1s1A8A0+tg6WrmcN22JiNnAl+fYIOtXFfNU9w1dgurMuEtZ43oi3cu4lNBcV9nbK14A6RA4y/sydX1+RAJ4oCz9BYBWu/HpKaRKijJJo4QQbzxEh+Y4izPQOZhIvsCBQ8EaAg790Cg7d8K1WBcG8Giw5Ifq2jxAToM4zjqyBsjstJgJEbiTYDImkLR1etkbrwTfrn9klxoL7/MMRaOQ5hPjz07SYnE9KTWET7HNa9lqdoATxMAHsJ+Ig0WJnghXYko6HRb0spZ/DDe1bW2UzliDpRgSCaYZYUhUdZ8Uk059cEECHkgs5RYwyKgj7jWAkuc0nfxNGXWevLnQKo/+GteV+/EXiuZEJx+eyOeDdM38xrwLv7zx503glWwbovmoock2Lf3NSMnZtKpRU+3nyANOwfNU31emHID/rk1zidwtHZWsTQ+vlHvUefP9f002FQ9PynmAQXulm3Q8X7O06v5/5WHwBq18wquYKstnpmeVak9ECHZH3Cmcnu/H2HLrRBS8wOeHSh6sarQs9iYnt4KJ6P1FJizGJmlLxiFJZ6aPWhWriEpGbybp65tEYzVTBz0wB8TACaiLY4cs6g9wUAU9Zg3ZVlf5XlBD/S4fHqRV6Tpfv8u4ZyMF/Wb/EWGTwQ/2ElL2egYk/F+cz7d7b2rMJUBbOLgnWUUqXr4BsoSVBhfLwBkJe4+IBtfTxQ/LKd+M9ss+mxmTjoZhfOEsrsTBY/kLjok/u08g7OhW7EASMKVXg7qvB6hozoShKGNji3tron3hOEJ+GdwstNcqrgAAA==";
|
|
377
|
+
//#endregion
|
|
378
|
+
//#region src/demo/energy-sim.ts
|
|
379
|
+
const SOLAR_PEAK_W = 5200;
|
|
380
|
+
const SUNRISE_HOUR = 6.5;
|
|
381
|
+
const SUNSET_HOUR = 20.5;
|
|
382
|
+
const ALWAYS_ON_W = 285;
|
|
383
|
+
const FRIDGE_W = 95;
|
|
384
|
+
const FRIDGE_CYCLE_MIN = 45;
|
|
385
|
+
const FRIDGE_ON_MIN = 18;
|
|
386
|
+
const BATTERY_CAPACITY_WH = 1e4;
|
|
387
|
+
const BATTERY_MIN_SOC = 10;
|
|
388
|
+
const BATTERY_MAX_SOC = 100;
|
|
389
|
+
const BATTERY_POWER_CAP_W = 3e3;
|
|
390
|
+
const MIDNIGHT_SOC = 55;
|
|
391
|
+
const SOC_STEP_MIN = 5;
|
|
392
|
+
/** Integer hash -> uint32, used to seed mulberry32 deterministically. */
|
|
393
|
+
function hashInt(n) {
|
|
394
|
+
let h = n | 0;
|
|
395
|
+
h = Math.imul(h ^ h >>> 16, 73244475);
|
|
396
|
+
h = Math.imul(h ^ h >>> 16, 73244475);
|
|
397
|
+
h = (h ^ h >>> 16) >>> 0;
|
|
398
|
+
return h;
|
|
399
|
+
}
|
|
400
|
+
/** Deterministic [0,1) generator seeded by an integer. */
|
|
401
|
+
function mulberry32(seed) {
|
|
402
|
+
let a = seed >>> 0;
|
|
403
|
+
return () => {
|
|
404
|
+
a |= 0;
|
|
405
|
+
a = a + 1831565813 | 0;
|
|
406
|
+
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
407
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
408
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
268
409
|
};
|
|
269
410
|
}
|
|
270
411
|
function localTime(timestampMs) {
|
|
@@ -998,15 +1139,17 @@ function makeArea(id, name, floorId = "ground_floor") {
|
|
|
998
1139
|
function createDemoFixtures() {
|
|
999
1140
|
contextCounter = 0;
|
|
1000
1141
|
const areas = {
|
|
1001
|
-
living_room:
|
|
1142
|
+
living_room: {
|
|
1143
|
+
...makeArea("living_room", "Living Room"),
|
|
1144
|
+
temperature_entity_id: "sensor.temperature_living",
|
|
1145
|
+
humidity_entity_id: "sensor.humidity_living"
|
|
1146
|
+
},
|
|
1002
1147
|
bedroom: makeArea("bedroom", "Bedroom"),
|
|
1003
1148
|
kitchen: makeArea("kitchen", "Kitchen"),
|
|
1004
1149
|
entry: makeArea("entry", "Entry"),
|
|
1005
1150
|
utility: makeArea("utility", "Utility"),
|
|
1006
1151
|
garage: makeArea("garage", "Garage")
|
|
1007
1152
|
};
|
|
1008
|
-
areas.living_room.temperature_entity_id = "sensor.temperature_living";
|
|
1009
|
-
areas.living_room.humidity_entity_id = "sensor.humidity_living";
|
|
1010
1153
|
const devices = {
|
|
1011
1154
|
living_room_lights: makeDevice("living_room_lights", "Living Room Lights", "living_room", "Philips", "Hue"),
|
|
1012
1155
|
kitchen_lights: makeDevice("kitchen_lights", "Kitchen Lights", "kitchen", "IKEA", "Tradfri"),
|
|
@@ -1610,8 +1753,9 @@ function stopDemoEnergyTicker() {
|
|
|
1610
1753
|
* When a service call is made in demo mode, this updates the entity
|
|
1611
1754
|
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
1612
1755
|
*/
|
|
1613
|
-
function applyDemoServiceCall(_domain, service,
|
|
1756
|
+
function applyDemoServiceCall(_domain, service, serviceData = {}, target = {}) {
|
|
1614
1757
|
if (!_isDemoMode) return;
|
|
1758
|
+
const _serviceData = serviceData;
|
|
1615
1759
|
const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
|
|
1616
1760
|
for (const entityId of entityIds) {
|
|
1617
1761
|
if (!state.entities[entityId]) continue;
|
|
@@ -1665,7 +1809,7 @@ function applyDemoServiceCall(_domain, service, _serviceData = {}, target = {})
|
|
|
1665
1809
|
}));
|
|
1666
1810
|
}
|
|
1667
1811
|
}
|
|
1668
|
-
function applyTurnOn(e, domain, serviceData
|
|
1812
|
+
function applyTurnOn(e, domain, serviceData) {
|
|
1669
1813
|
if (domain === "light") {
|
|
1670
1814
|
e.state = "on";
|
|
1671
1815
|
if (serviceData.brightness_pct !== void 0) {
|
|
@@ -1713,6 +1857,242 @@ function applyTurnOff(e, domain) {
|
|
|
1713
1857
|
} else e.state = "off";
|
|
1714
1858
|
}
|
|
1715
1859
|
//#endregion
|
|
1860
|
+
//#region src/commands/service.ts
|
|
1861
|
+
/**
|
|
1862
|
+
* Call a Home Assistant service
|
|
1863
|
+
*/
|
|
1864
|
+
async function callService(domain, service, serviceData = {}, target = {}) {
|
|
1865
|
+
const connection = privilegedConn();
|
|
1866
|
+
if (!connection) {
|
|
1867
|
+
applyDemoServiceCall(domain, service, serviceData, target);
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
try {
|
|
1871
|
+
await connection.sendMessagePromise({
|
|
1872
|
+
type: "call_service",
|
|
1873
|
+
domain,
|
|
1874
|
+
service,
|
|
1875
|
+
service_data: serviceData,
|
|
1876
|
+
target
|
|
1877
|
+
});
|
|
1878
|
+
} catch (error) {
|
|
1879
|
+
throw new Error(`Service call failed: ${messageOf(error)}`);
|
|
1880
|
+
}
|
|
1881
|
+
}
|
|
1882
|
+
function messageOf(error) {
|
|
1883
|
+
if (typeof error === "object" && error !== null && "message" in error) {
|
|
1884
|
+
const { message } = error;
|
|
1885
|
+
if (typeof message === "string" && message) return message;
|
|
1886
|
+
}
|
|
1887
|
+
return String(error);
|
|
1888
|
+
}
|
|
1889
|
+
/**
|
|
1890
|
+
* Turn on entity or entities
|
|
1891
|
+
*/
|
|
1892
|
+
async function turnOn(entityId, serviceData = {}) {
|
|
1893
|
+
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
1894
|
+
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
1895
|
+
const domain = firstEntityId.split(".")[0];
|
|
1896
|
+
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
1897
|
+
await callService(domain, "turn_on", serviceData, { entity_id: entityId });
|
|
1898
|
+
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Turn off entity or entities
|
|
1901
|
+
*/
|
|
1902
|
+
async function turnOff(entityId, serviceData = {}) {
|
|
1903
|
+
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
1904
|
+
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
1905
|
+
const domain = firstEntityId.split(".")[0];
|
|
1906
|
+
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
1907
|
+
await callService(domain, "turn_off", serviceData, { entity_id: entityId });
|
|
1908
|
+
}
|
|
1909
|
+
/**
|
|
1910
|
+
* Toggle entity or entities
|
|
1911
|
+
*/
|
|
1912
|
+
async function toggle(entityId, serviceData = {}) {
|
|
1913
|
+
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
1914
|
+
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
1915
|
+
const domain = firstEntityId.split(".")[0];
|
|
1916
|
+
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
1917
|
+
await callService(domain, "toggle", serviceData, { entity_id: entityId });
|
|
1918
|
+
}
|
|
1919
|
+
/**
|
|
1920
|
+
* Update entity registry entry
|
|
1921
|
+
*/
|
|
1922
|
+
async function updateEntity(entityId, updates) {
|
|
1923
|
+
const connection = privilegedConn();
|
|
1924
|
+
if (!connection) throw new Error("Not connected to Home Assistant.");
|
|
1925
|
+
try {
|
|
1926
|
+
const result = await connection.sendMessagePromise({
|
|
1927
|
+
type: "config/entity_registry/update",
|
|
1928
|
+
entity_id: entityId,
|
|
1929
|
+
...updates
|
|
1930
|
+
});
|
|
1931
|
+
if (result && typeof result === "object" && "_mockError" in result) throw new Error(`Entity registry entry not found: ${entityId}`);
|
|
1932
|
+
return result;
|
|
1933
|
+
} catch (error) {
|
|
1934
|
+
const message = messageOf(error);
|
|
1935
|
+
if (message.includes("Entity registry entry not found")) throw error;
|
|
1936
|
+
throw new Error(`Entity update failed: ${message}`);
|
|
1937
|
+
}
|
|
1938
|
+
}
|
|
1939
|
+
/**
|
|
1940
|
+
* Send a generic WebSocket command
|
|
1941
|
+
*/
|
|
1942
|
+
async function sendCommand(command) {
|
|
1943
|
+
const connection = privilegedConn();
|
|
1944
|
+
if (!connection) throw new Error("Not connected");
|
|
1945
|
+
try {
|
|
1946
|
+
return await connection.sendMessagePromise(command);
|
|
1947
|
+
} catch (error) {
|
|
1948
|
+
throw new Error(`Command failed: ${messageOf(error)}`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
/**
|
|
1952
|
+
* Execute multiple service calls in parallel
|
|
1953
|
+
*/
|
|
1954
|
+
async function batchServiceCalls(calls) {
|
|
1955
|
+
await Promise.all(calls.map((call) => callService(call.domain, call.service, call.serviceData, call.target)));
|
|
1956
|
+
}
|
|
1957
|
+
/**
|
|
1958
|
+
* Execute multiple entity updates in parallel
|
|
1959
|
+
*/
|
|
1960
|
+
async function batchEntityUpdates(updates) {
|
|
1961
|
+
return Promise.all(updates.map((u) => updateEntity(u.entityId, u.updates)));
|
|
1962
|
+
}
|
|
1963
|
+
//#endregion
|
|
1964
|
+
//#region src/demo/sensor-history.ts
|
|
1965
|
+
const DAY_MS$1 = 864e5;
|
|
1966
|
+
function seedOf(entityId) {
|
|
1967
|
+
let h = 0;
|
|
1968
|
+
for (let i = 0; i < entityId.length; i++) h = h * 31 + entityId.charCodeAt(i) | 0;
|
|
1969
|
+
return Math.abs(h) % 1e3 / 1e3;
|
|
1970
|
+
}
|
|
1971
|
+
/**
|
|
1972
|
+
* A slow daily swing plus a faster ripple, pinned so the last point is the reading
|
|
1973
|
+
* the demo shows now. Deterministic per entity, so previews repeat.
|
|
1974
|
+
*/
|
|
1975
|
+
function synthesizeSensorHistory(entityId, current, startMs, endMs, stepMs = 9e5) {
|
|
1976
|
+
const amplitude = Math.max(Math.abs(current) * .08, .6);
|
|
1977
|
+
const phase = seedOf(entityId) * 2 * Math.PI;
|
|
1978
|
+
const wave = (t) => Math.sin(2 * Math.PI * t / DAY_MS$1 + phase) + .15 * Math.sin(2 * Math.PI * t * 3 / DAY_MS$1 + 2 * phase);
|
|
1979
|
+
const decimals = Number.isInteger(current) ? 1 : 2;
|
|
1980
|
+
const points = [];
|
|
1981
|
+
for (let t = startMs; t <= endMs; t += stepMs) {
|
|
1982
|
+
const value = current + amplitude * (wave(t) - wave(endMs));
|
|
1983
|
+
points.push({
|
|
1984
|
+
lu: Math.round(t / 1e3),
|
|
1985
|
+
s: value.toFixed(decimals)
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
const last = points.at(-1);
|
|
1989
|
+
if (last) last.s = String(current);
|
|
1990
|
+
return points;
|
|
1991
|
+
}
|
|
1992
|
+
function currentReading(id) {
|
|
1993
|
+
if (!id.startsWith("sensor.")) return void 0;
|
|
1994
|
+
const current = Number(state.entities[id]?.state);
|
|
1995
|
+
return Number.isFinite(current) ? current : void 0;
|
|
1996
|
+
}
|
|
1997
|
+
/** History for a demo sensor with a numeric reading; undefined for anything else. */
|
|
1998
|
+
function numericSensorHistory(id, startMs, endMs) {
|
|
1999
|
+
const current = currentReading(id);
|
|
2000
|
+
return current === void 0 ? void 0 : synthesizeSensorHistory(id, current, startMs, endMs);
|
|
2001
|
+
}
|
|
2002
|
+
/** Hour or day buckets of the same curve, shaped like recorder statistics. */
|
|
2003
|
+
function numericSensorStatistics(id, startMs, endMs, period) {
|
|
2004
|
+
const points = numericSensorHistory(id, startMs, endMs);
|
|
2005
|
+
if (!points) return [];
|
|
2006
|
+
const span = period === "hour" ? 36e5 : DAY_MS$1;
|
|
2007
|
+
const buckets = [];
|
|
2008
|
+
for (let start = startMs; start < endMs; start += span) {
|
|
2009
|
+
const end = start + span;
|
|
2010
|
+
const values = points.filter((p) => p.lu * 1e3 >= start && p.lu * 1e3 < end).map((p) => Number(p.s));
|
|
2011
|
+
if (values.length === 0) continue;
|
|
2012
|
+
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
2013
|
+
buckets.push({
|
|
2014
|
+
start,
|
|
2015
|
+
end,
|
|
2016
|
+
mean,
|
|
2017
|
+
min: Math.min(...values),
|
|
2018
|
+
max: Math.max(...values),
|
|
2019
|
+
sum: 0,
|
|
2020
|
+
change: 0
|
|
2021
|
+
});
|
|
2022
|
+
}
|
|
2023
|
+
return buckets;
|
|
2024
|
+
}
|
|
2025
|
+
//#endregion
|
|
2026
|
+
//#region src/history/statistics.ts
|
|
2027
|
+
const STATISTIC_TYPES = [
|
|
2028
|
+
"mean",
|
|
2029
|
+
"sum",
|
|
2030
|
+
"min",
|
|
2031
|
+
"max",
|
|
2032
|
+
"state",
|
|
2033
|
+
"change"
|
|
2034
|
+
];
|
|
2035
|
+
/**
|
|
2036
|
+
* HA returns start/end as ISO strings or ms numbers depending on version.
|
|
2037
|
+
* Normalize both shapes to ms epoch numbers; unparseable values become NaN.
|
|
2038
|
+
*/
|
|
2039
|
+
function normalizeStatisticTime(value) {
|
|
2040
|
+
if (typeof value === "number") return value;
|
|
2041
|
+
if (typeof value === "string") return new Date(value).getTime();
|
|
2042
|
+
return NaN;
|
|
2043
|
+
}
|
|
2044
|
+
function normalizeNumber(value) {
|
|
2045
|
+
return typeof value === "number" ? value : void 0;
|
|
2046
|
+
}
|
|
2047
|
+
function normalizeStatisticValue(raw) {
|
|
2048
|
+
const value = {
|
|
2049
|
+
start: normalizeStatisticTime(raw.start),
|
|
2050
|
+
end: normalizeStatisticTime(raw.end)
|
|
2051
|
+
};
|
|
2052
|
+
const mean = normalizeNumber(raw.mean);
|
|
2053
|
+
if (mean !== void 0) value.mean = mean;
|
|
2054
|
+
const sum = normalizeNumber(raw.sum);
|
|
2055
|
+
if (sum !== void 0) value.sum = sum;
|
|
2056
|
+
const min = normalizeNumber(raw.min);
|
|
2057
|
+
if (min !== void 0) value.min = min;
|
|
2058
|
+
const max = normalizeNumber(raw.max);
|
|
2059
|
+
if (max !== void 0) value.max = max;
|
|
2060
|
+
const stateVal = normalizeNumber(raw.state);
|
|
2061
|
+
if (stateVal !== void 0) value.state = stateVal;
|
|
2062
|
+
const change = normalizeNumber(raw.change);
|
|
2063
|
+
if (change !== void 0) value.change = change;
|
|
2064
|
+
return value;
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Fetch long-term statistics for one or more statistic ids during a period.
|
|
2068
|
+
*
|
|
2069
|
+
* Statistic ids with no data are returned as empty arrays so callers never
|
|
2070
|
+
* hit `undefined`.
|
|
2071
|
+
*/
|
|
2072
|
+
async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
|
|
2073
|
+
const { startTime, endTime, period } = options;
|
|
2074
|
+
if (isDemoMode()) {
|
|
2075
|
+
const result = {};
|
|
2076
|
+
const statPeriod = period === "hour" || period === "day" ? period : "day";
|
|
2077
|
+
const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
|
|
2078
|
+
for (const id of statisticIds) result[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : numericSensorStatistics(id, startTime.getTime(), endMs, statPeriod);
|
|
2079
|
+
return result;
|
|
2080
|
+
}
|
|
2081
|
+
const params = {
|
|
2082
|
+
type: "recorder/statistics_during_period",
|
|
2083
|
+
start_time: startTime.toISOString(),
|
|
2084
|
+
statistic_ids: statisticIds,
|
|
2085
|
+
period,
|
|
2086
|
+
types: STATISTIC_TYPES
|
|
2087
|
+
};
|
|
2088
|
+
if (endTime) params.end_time = endTime.toISOString();
|
|
2089
|
+
const response = await connection.sendMessagePromise(params);
|
|
2090
|
+
const result = {};
|
|
2091
|
+
for (const id of statisticIds) result[id] = [];
|
|
2092
|
+
if (response && typeof response === "object") for (const [id, buckets] of Object.entries(response)) result[id] = Array.isArray(buckets) ? buckets.map(normalizeStatisticValue) : [];
|
|
2093
|
+
return result;
|
|
2094
|
+
}
|
|
2095
|
+
//#endregion
|
|
1716
2096
|
//#region src/weather/fetch.ts
|
|
1717
2097
|
/**
|
|
1718
2098
|
* Weather Forecast Fetching
|
|
@@ -1878,660 +2258,191 @@ async function fetchForecastData(entityId, type) {
|
|
|
1878
2258
|
}
|
|
1879
2259
|
}
|
|
1880
2260
|
//#endregion
|
|
1881
|
-
//#region src/
|
|
2261
|
+
//#region src/calendar/track.ts
|
|
1882
2262
|
/**
|
|
1883
|
-
* Ref-counted live
|
|
1884
|
-
* tracked
|
|
1885
|
-
* pushes
|
|
1886
|
-
*
|
|
2263
|
+
* Ref-counted live calendar tracking, the per-entity analog of
|
|
2264
|
+
* `connection/subscription-manager`. Each tracked calendar entity holds one
|
|
2265
|
+
* upstream `calendar/event/subscribe` (HA pushes an initial snapshot, then
|
|
2266
|
+
* debounced updates); trackers declare desired windows and a reconciler keeps
|
|
2267
|
+
* the subscription matched to the union. Data lands in `state.calendars`;
|
|
2268
|
+
* read-scoping for widget consumers arrives with WS-8 like every other store
|
|
2269
|
+
* read.
|
|
1887
2270
|
*/
|
|
2271
|
+
const DEFAULT_DAYS_BEHIND = 7;
|
|
2272
|
+
const DEFAULT_DAYS_AHEAD = 35;
|
|
1888
2273
|
/** Debounce for untrack, so dashboard switches don't churn subscriptions. */
|
|
1889
2274
|
const UNTRACK_FLUSH_DELAY_MS = 5e3;
|
|
1890
|
-
const
|
|
2275
|
+
const DAY_MS = 864e5;
|
|
2276
|
+
const trackers = /* @__PURE__ */ new Map();
|
|
1891
2277
|
const active = /* @__PURE__ */ new Map();
|
|
2278
|
+
/** Per-entity reconcile chain; serializes subscribe/unsubscribe per calendar. */
|
|
1892
2279
|
const chains = /* @__PURE__ */ new Map();
|
|
1893
2280
|
let untrackTimer = null;
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
2281
|
+
let rollTimer = null;
|
|
2282
|
+
/** Day-quantized union of all tracker windows, so equal requests within a day
|
|
2283
|
+
reconcile to the same subscription instead of churning. */
|
|
2284
|
+
function windowFor(list) {
|
|
2285
|
+
let behind = 0;
|
|
2286
|
+
let ahead = 0;
|
|
2287
|
+
for (const t of list) {
|
|
2288
|
+
if (t.daysBehind > behind) behind = t.daysBehind;
|
|
2289
|
+
if (t.daysAhead > ahead) ahead = t.daysAhead;
|
|
2290
|
+
}
|
|
2291
|
+
const dayStart = /* @__PURE__ */ new Date();
|
|
2292
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
2293
|
+
return {
|
|
2294
|
+
start: (/* @__PURE__ */ new Date(dayStart.getTime() - behind * DAY_MS)).toISOString(),
|
|
2295
|
+
end: new Date(dayStart.getTime() + (ahead + 1) * DAY_MS).toISOString()
|
|
2296
|
+
};
|
|
1898
2297
|
}
|
|
1899
2298
|
/**
|
|
1900
|
-
*
|
|
1901
|
-
* disconnected: the subscription
|
|
2299
|
+
* Start tracking live events for a calendar entity. Returns the untrack
|
|
2300
|
+
* function. Safe while disconnected: the subscription activates on the next
|
|
2301
|
+
* `forceResubscribeCalendars()` (called by the connection layer on connect
|
|
2302
|
+
* and reconnect).
|
|
1902
2303
|
*/
|
|
1903
|
-
function
|
|
1904
|
-
const
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
2304
|
+
function trackCalendarEvents(entityId, options) {
|
|
2305
|
+
const tracker = {
|
|
2306
|
+
daysBehind: options?.daysBehind ?? DEFAULT_DAYS_BEHIND,
|
|
2307
|
+
daysAhead: options?.daysAhead ?? DEFAULT_DAYS_AHEAD
|
|
2308
|
+
};
|
|
2309
|
+
const list = trackers.get(entityId) ?? [];
|
|
2310
|
+
list.push(tracker);
|
|
2311
|
+
trackers.set(entityId, list);
|
|
2312
|
+
scheduleFlush();
|
|
2313
|
+
ensureRollTimer();
|
|
2314
|
+
let untracked = false;
|
|
1910
2315
|
return () => {
|
|
1911
2316
|
if (untracked) return;
|
|
1912
2317
|
untracked = true;
|
|
1913
|
-
|
|
1914
|
-
if (n > 0) counts.set(key, n);
|
|
1915
|
-
else counts.delete(key);
|
|
1916
|
-
if (untrackTimer) return;
|
|
1917
|
-
untrackTimer = setTimeout(() => {
|
|
1918
|
-
untrackTimer = null;
|
|
1919
|
-
flushAll();
|
|
1920
|
-
}, UNTRACK_FLUSH_DELAY_MS);
|
|
2318
|
+
untrackCalendarEvents(entityId, tracker);
|
|
1921
2319
|
};
|
|
1922
2320
|
}
|
|
1923
|
-
|
|
1924
|
-
const
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
const rec = s.forecasts[entityId] ??= {
|
|
1935
|
-
entityId,
|
|
1936
|
-
forecasts: {},
|
|
1937
|
-
loading: {},
|
|
1938
|
-
errors: {},
|
|
1939
|
-
lastFetched: {}
|
|
1940
|
-
};
|
|
1941
|
-
if (forecast) {
|
|
1942
|
-
rec.forecasts[type] = forecast;
|
|
1943
|
-
rec.lastFetched[type] = Date.now();
|
|
1944
|
-
}
|
|
1945
|
-
rec.loading[type] = false;
|
|
1946
|
-
rec.errors[type] = error;
|
|
1947
|
-
}));
|
|
1948
|
-
}
|
|
1949
|
-
async function runReconcile(key) {
|
|
1950
|
-
const [entityId, type] = parse(key);
|
|
1951
|
-
const current = active.get(key);
|
|
1952
|
-
if (!counts.has(key)) {
|
|
1953
|
-
active.delete(key);
|
|
1954
|
-
chains.delete(key);
|
|
1955
|
-
try {
|
|
1956
|
-
await current?.unsub?.();
|
|
1957
|
-
} catch {}
|
|
1958
|
-
return;
|
|
1959
|
-
}
|
|
1960
|
-
if (current?.unsub) return;
|
|
1961
|
-
if (isDemoMode()) {
|
|
1962
|
-
active.set(key, { unsub: () => {} });
|
|
1963
|
-
write(entityId, type, buildDemoForecast(entityId, type), null);
|
|
1964
|
-
return;
|
|
1965
|
-
}
|
|
1966
|
-
const conn = privilegedConn();
|
|
1967
|
-
if (!conn) return;
|
|
1968
|
-
const entry = { unsub: null };
|
|
1969
|
-
active.set(key, entry);
|
|
1970
|
-
try {
|
|
1971
|
-
entry.unsub = await conn.subscribeMessage((msg) => write(entityId, type, Array.isArray(msg?.forecast) ? msg.forecast : [], null), {
|
|
1972
|
-
type: "weather/subscribe_forecast",
|
|
1973
|
-
entity_id: entityId,
|
|
1974
|
-
forecast_type: type
|
|
1975
|
-
});
|
|
1976
|
-
} catch (err) {
|
|
1977
|
-
active.delete(key);
|
|
1978
|
-
write(entityId, type, null, err instanceof Error ? err : new Error(String(err)));
|
|
1979
|
-
}
|
|
1980
|
-
}
|
|
1981
|
-
//#endregion
|
|
1982
|
-
//#region src/connection/subscription-manager.ts
|
|
1983
|
-
if (globalThis.__GH_SYNC_LAYER_SUB_MGR__) console.error("[@glasshome/sync-layer] DUPLICATE subscription-manager module instantiated. registerEntity() and the connection setter will hit different singletons → subscribe_entities will never be sent. Cause: relative import of subscription-manager from a separate entry point.");
|
|
1984
|
-
globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
|
|
1985
|
-
/** Ref counts — how many consumers need each entity */
|
|
1986
|
-
const refCounts = /* @__PURE__ */ new Map();
|
|
1987
|
-
/** Current HA subscription unsub function */
|
|
1988
|
-
let currentEntityUnsub = null;
|
|
1989
|
-
/** Snapshot of entity IDs in the current HA subscription */
|
|
1990
|
-
let subscribedIds = /* @__PURE__ */ new Set();
|
|
1991
|
-
/** Connection reference for resubscribing */
|
|
1992
|
-
let conn = null;
|
|
1993
|
-
/** Pending flush timer (microtask for register, timeout for unregister) */
|
|
1994
|
-
let flushScheduled = false;
|
|
1995
|
-
/** Delayed unregister timeout */
|
|
1996
|
-
let unregisterTimeout = null;
|
|
1997
|
-
/** Callback invoked on resubscribe — set by subscriptions.ts */
|
|
1998
|
-
let onResubscribe = null;
|
|
1999
|
-
/** Delay before unregistered entities are actually removed from subscription */
|
|
2000
|
-
const UNREGISTER_DELAY_MS = 5e3;
|
|
2001
|
-
/** Guard against concurrent flush() calls */
|
|
2002
|
-
let flushInFlight = false;
|
|
2003
|
-
/**
|
|
2004
|
-
* Register an entity for live updates. Returns an unregister function.
|
|
2005
|
-
* Call from useEntity/useEntities hooks via onCleanup.
|
|
2006
|
-
*/
|
|
2007
|
-
function registerEntity(entityId) {
|
|
2008
|
-
refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
|
|
2009
|
-
scheduleFlush();
|
|
2010
|
-
return () => unregisterEntity(entityId);
|
|
2011
|
-
}
|
|
2012
|
-
/**
|
|
2013
|
-
* Set the connection reference used for resubscribing.
|
|
2014
|
-
*/
|
|
2015
|
-
function setManagerConnection(connection) {
|
|
2016
|
-
conn = connection;
|
|
2017
|
-
if (refCounts.size > 0) scheduleFlush();
|
|
2018
|
-
}
|
|
2019
|
-
/**
|
|
2020
|
-
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
2021
|
-
* the manager into the actual WS subscription lifecycle.
|
|
2022
|
-
*/
|
|
2023
|
-
function setResubscribeHandler(handler) {
|
|
2024
|
-
onResubscribe = handler;
|
|
2321
|
+
function untrackCalendarEvents(entityId, tracker) {
|
|
2322
|
+
const list = trackers.get(entityId);
|
|
2323
|
+
if (!list) return;
|
|
2324
|
+
const i = list.indexOf(tracker);
|
|
2325
|
+
if (i !== -1) list.splice(i, 1);
|
|
2326
|
+
if (list.length === 0) trackers.delete(entityId);
|
|
2327
|
+
if (untrackTimer) return;
|
|
2328
|
+
untrackTimer = setTimeout(() => {
|
|
2329
|
+
untrackTimer = null;
|
|
2330
|
+
flushAll();
|
|
2331
|
+
}, UNTRACK_FLUSH_DELAY_MS);
|
|
2025
2332
|
}
|
|
2026
2333
|
/**
|
|
2027
|
-
*
|
|
2028
|
-
*
|
|
2334
|
+
* Reconcile every tracked calendar, dropping current subscription handles
|
|
2335
|
+
* first. The connection layer calls this on connect and after reconnect:
|
|
2336
|
+
* an unchanged window is no proof the old subscription is alive, so each
|
|
2337
|
+
* tracked entity resubscribes (HA re-pushes the snapshot, refreshing the
|
|
2338
|
+
* store through any missed changes).
|
|
2029
2339
|
*/
|
|
2030
|
-
async function
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
scheduleDelayedFlush();
|
|
2340
|
+
async function forceResubscribeCalendars() {
|
|
2341
|
+
for (const entry of active.values()) {
|
|
2342
|
+
const dead = entry.unsub;
|
|
2343
|
+
entry.unsub = null;
|
|
2344
|
+
if (dead) Promise.resolve().then(dead).catch(() => {});
|
|
2345
|
+
}
|
|
2346
|
+
await flushAll();
|
|
2038
2347
|
}
|
|
2039
2348
|
function scheduleFlush() {
|
|
2040
|
-
if (flushScheduled) return;
|
|
2041
|
-
flushScheduled = true;
|
|
2042
2349
|
queueMicrotask(() => {
|
|
2043
|
-
|
|
2044
|
-
flush();
|
|
2350
|
+
flushAll();
|
|
2045
2351
|
});
|
|
2046
2352
|
}
|
|
2047
|
-
function
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
flush();
|
|
2052
|
-
}, UNREGISTER_DELAY_MS);
|
|
2053
|
-
}
|
|
2054
|
-
async function flush() {
|
|
2055
|
-
if (!conn || !onResubscribe) return;
|
|
2056
|
-
if (flushInFlight) return;
|
|
2057
|
-
flushInFlight = true;
|
|
2058
|
-
try {
|
|
2059
|
-
const newIds = new Set(refCounts.keys());
|
|
2060
|
-
if (setsEqual(newIds, subscribedIds)) return;
|
|
2061
|
-
const oldUnsub = currentEntityUnsub;
|
|
2062
|
-
const entityIds = [...newIds];
|
|
2063
|
-
currentEntityUnsub = await onResubscribe(conn, entityIds);
|
|
2064
|
-
subscribedIds = newIds;
|
|
2065
|
-
if (oldUnsub) try {
|
|
2066
|
-
await oldUnsub();
|
|
2067
|
-
} catch {}
|
|
2068
|
-
} finally {
|
|
2069
|
-
flushInFlight = false;
|
|
2070
|
-
}
|
|
2353
|
+
async function flushAll() {
|
|
2354
|
+
const ids = /* @__PURE__ */ new Set([...trackers.keys(), ...active.keys()]);
|
|
2355
|
+
await Promise.all([...ids].map(reconcileEntity));
|
|
2356
|
+
if (trackers.size === 0) stopRollTimer();
|
|
2071
2357
|
}
|
|
2072
|
-
function
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
return
|
|
2358
|
+
function reconcileEntity(entityId) {
|
|
2359
|
+
const next = (chains.get(entityId) ?? Promise.resolve()).then(() => runReconcile(entityId));
|
|
2360
|
+
chains.set(entityId, next.catch(() => {}));
|
|
2361
|
+
return next;
|
|
2076
2362
|
}
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2363
|
+
async function runReconcile(entityId) {
|
|
2364
|
+
const list = trackers.get(entityId);
|
|
2365
|
+
const current = active.get(entityId);
|
|
2366
|
+
if (!list || list.length === 0) {
|
|
2367
|
+
if (current) {
|
|
2368
|
+
active.delete(entityId);
|
|
2369
|
+
try {
|
|
2370
|
+
await current.unsub?.();
|
|
2371
|
+
} catch {}
|
|
2372
|
+
}
|
|
2373
|
+
chains.delete(entityId);
|
|
2374
|
+
setState(produce((s) => {
|
|
2375
|
+
delete s.calendars[entityId];
|
|
2376
|
+
}));
|
|
2086
2377
|
return;
|
|
2087
2378
|
}
|
|
2379
|
+
const conn = privilegedConn();
|
|
2380
|
+
if (!conn) return;
|
|
2381
|
+
const win = windowFor(list);
|
|
2382
|
+
if (current?.unsub && current.start === win.start && current.end === win.end) return;
|
|
2383
|
+
setState(produce((s) => {
|
|
2384
|
+
s.calendars[entityId] = {
|
|
2385
|
+
entityId,
|
|
2386
|
+
events: s.calendars[entityId]?.events ?? [],
|
|
2387
|
+
loading: true,
|
|
2388
|
+
error: null,
|
|
2389
|
+
start: win.start,
|
|
2390
|
+
end: win.end
|
|
2391
|
+
};
|
|
2392
|
+
}));
|
|
2393
|
+
const old = current?.unsub ?? null;
|
|
2394
|
+
const entry = {
|
|
2395
|
+
start: win.start,
|
|
2396
|
+
end: win.end,
|
|
2397
|
+
unsub: null
|
|
2398
|
+
};
|
|
2399
|
+
active.set(entityId, entry);
|
|
2088
2400
|
try {
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
}
|
|
2098
|
-
|
|
2099
|
-
throw new Error(`Service call failed: ${error.message || String(error)}`);
|
|
2100
|
-
}
|
|
2101
|
-
}
|
|
2102
|
-
/**
|
|
2103
|
-
* Turn on entity or entities
|
|
2104
|
-
*/
|
|
2105
|
-
async function turnOn(entityId, serviceData = {}) {
|
|
2106
|
-
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
2107
|
-
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
2108
|
-
const domain = firstEntityId.split(".")[0];
|
|
2109
|
-
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
2110
|
-
await callService(domain, "turn_on", serviceData, { entity_id: entityId });
|
|
2111
|
-
}
|
|
2112
|
-
/**
|
|
2113
|
-
* Turn off entity or entities
|
|
2114
|
-
*/
|
|
2115
|
-
async function turnOff(entityId, serviceData = {}) {
|
|
2116
|
-
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
2117
|
-
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
2118
|
-
const domain = firstEntityId.split(".")[0];
|
|
2119
|
-
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
2120
|
-
await callService(domain, "turn_off", serviceData, { entity_id: entityId });
|
|
2121
|
-
}
|
|
2122
|
-
/**
|
|
2123
|
-
* Toggle entity or entities
|
|
2124
|
-
*/
|
|
2125
|
-
async function toggle(entityId, serviceData = {}) {
|
|
2126
|
-
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
2127
|
-
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
2128
|
-
const domain = firstEntityId.split(".")[0];
|
|
2129
|
-
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
2130
|
-
await callService(domain, "toggle", serviceData, { entity_id: entityId });
|
|
2131
|
-
}
|
|
2132
|
-
/**
|
|
2133
|
-
* Update entity registry entry
|
|
2134
|
-
*/
|
|
2135
|
-
async function updateEntity(entityId, updates) {
|
|
2136
|
-
const connection = privilegedConn();
|
|
2137
|
-
if (!connection) throw new Error("Not connected to Home Assistant. Call initConnection() first.");
|
|
2138
|
-
try {
|
|
2139
|
-
const result = await connection.sendMessagePromise({
|
|
2140
|
-
type: "config/entity_registry/update",
|
|
2401
|
+
entry.unsub = await conn.subscribeMessage((msg) => {
|
|
2402
|
+
setState(produce((s) => {
|
|
2403
|
+
const rec = s.calendars[entityId];
|
|
2404
|
+
if (!rec) return;
|
|
2405
|
+
rec.events = msg.events ?? [];
|
|
2406
|
+
rec.loading = false;
|
|
2407
|
+
rec.error = null;
|
|
2408
|
+
}));
|
|
2409
|
+
}, {
|
|
2410
|
+
type: "calendar/event/subscribe",
|
|
2141
2411
|
entity_id: entityId,
|
|
2142
|
-
|
|
2412
|
+
start: win.start,
|
|
2413
|
+
end: win.end
|
|
2143
2414
|
});
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2415
|
+
} catch (err) {
|
|
2416
|
+
active.delete(entityId);
|
|
2417
|
+
setState(produce((s) => {
|
|
2418
|
+
const rec = s.calendars[entityId];
|
|
2419
|
+
if (!rec) return;
|
|
2420
|
+
rec.loading = false;
|
|
2421
|
+
rec.error = err instanceof Error ? err : new Error(String(err));
|
|
2422
|
+
}));
|
|
2149
2423
|
}
|
|
2424
|
+
if (old) try {
|
|
2425
|
+
await old();
|
|
2426
|
+
} catch {}
|
|
2150
2427
|
}
|
|
2151
|
-
/**
|
|
2152
|
-
|
|
2153
|
-
*/
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
}
|
|
2163
|
-
/**
|
|
2164
|
-
* Execute multiple service calls in parallel
|
|
2165
|
-
*/
|
|
2166
|
-
async function batchServiceCalls(calls) {
|
|
2167
|
-
await Promise.all(calls.map((call) => callService(call.domain, call.service, call.serviceData, call.target)));
|
|
2168
|
-
}
|
|
2169
|
-
/**
|
|
2170
|
-
* Execute multiple entity updates in parallel
|
|
2171
|
-
*/
|
|
2172
|
-
async function batchEntityUpdates(updates) {
|
|
2173
|
-
return Promise.all(updates.map((u) => updateEntity(u.entityId, u.updates)));
|
|
2174
|
-
}
|
|
2175
|
-
//#endregion
|
|
2176
|
-
//#region src/demo/sensor-history.ts
|
|
2177
|
-
const DAY_MS = 864e5;
|
|
2178
|
-
function seedOf(entityId) {
|
|
2179
|
-
let h = 0;
|
|
2180
|
-
for (let i = 0; i < entityId.length; i++) h = h * 31 + entityId.charCodeAt(i) | 0;
|
|
2181
|
-
return Math.abs(h) % 1e3 / 1e3;
|
|
2182
|
-
}
|
|
2183
|
-
/**
|
|
2184
|
-
* A slow daily swing plus a faster ripple, pinned so the last point is the reading
|
|
2185
|
-
* the demo shows now. Deterministic per entity, so previews repeat.
|
|
2186
|
-
*/
|
|
2187
|
-
function synthesizeSensorHistory(entityId, current, startMs, endMs, stepMs = 9e5) {
|
|
2188
|
-
const amplitude = Math.max(Math.abs(current) * .08, .6);
|
|
2189
|
-
const phase = seedOf(entityId) * 2 * Math.PI;
|
|
2190
|
-
const wave = (t) => Math.sin(2 * Math.PI * t / DAY_MS + phase) + .15 * Math.sin(2 * Math.PI * t * 3 / DAY_MS + 2 * phase);
|
|
2191
|
-
const decimals = Number.isInteger(current) ? 1 : 2;
|
|
2192
|
-
const points = [];
|
|
2193
|
-
for (let t = startMs; t <= endMs; t += stepMs) {
|
|
2194
|
-
const value = current + amplitude * (wave(t) - wave(endMs));
|
|
2195
|
-
points.push({
|
|
2196
|
-
lu: Math.round(t / 1e3),
|
|
2197
|
-
s: value.toFixed(decimals)
|
|
2198
|
-
});
|
|
2199
|
-
}
|
|
2200
|
-
const last = points.at(-1);
|
|
2201
|
-
if (last) last.s = String(current);
|
|
2202
|
-
return points;
|
|
2203
|
-
}
|
|
2204
|
-
function currentReading(id) {
|
|
2205
|
-
if (!id.startsWith("sensor.")) return void 0;
|
|
2206
|
-
const current = Number(state.entities[id]?.state);
|
|
2207
|
-
return Number.isFinite(current) ? current : void 0;
|
|
2208
|
-
}
|
|
2209
|
-
/** History for a demo sensor with a numeric reading; undefined for anything else. */
|
|
2210
|
-
function numericSensorHistory(id, startMs, endMs) {
|
|
2211
|
-
const current = currentReading(id);
|
|
2212
|
-
return current === void 0 ? void 0 : synthesizeSensorHistory(id, current, startMs, endMs);
|
|
2213
|
-
}
|
|
2214
|
-
/** Hour or day buckets of the same curve, shaped like recorder statistics. */
|
|
2215
|
-
function numericSensorStatistics(id, startMs, endMs, period) {
|
|
2216
|
-
const points = numericSensorHistory(id, startMs, endMs);
|
|
2217
|
-
if (!points) return [];
|
|
2218
|
-
const span = period === "hour" ? 36e5 : DAY_MS;
|
|
2219
|
-
const buckets = [];
|
|
2220
|
-
for (let start = startMs; start < endMs; start += span) {
|
|
2221
|
-
const end = start + span;
|
|
2222
|
-
const values = points.filter((p) => p.lu * 1e3 >= start && p.lu * 1e3 < end).map((p) => Number(p.s));
|
|
2223
|
-
if (values.length === 0) continue;
|
|
2224
|
-
const mean = values.reduce((a, b) => a + b, 0) / values.length;
|
|
2225
|
-
buckets.push({
|
|
2226
|
-
start,
|
|
2227
|
-
end,
|
|
2228
|
-
mean,
|
|
2229
|
-
min: Math.min(...values),
|
|
2230
|
-
max: Math.max(...values),
|
|
2231
|
-
sum: 0,
|
|
2232
|
-
change: 0
|
|
2233
|
-
});
|
|
2234
|
-
}
|
|
2235
|
-
return buckets;
|
|
2236
|
-
}
|
|
2237
|
-
//#endregion
|
|
2238
|
-
//#region src/entities/views.ts
|
|
2239
|
-
/**
|
|
2240
|
-
* Get icon for an entity from registry or attributes
|
|
2241
|
-
*/
|
|
2242
|
-
function resolveIcon(entity, registry) {
|
|
2243
|
-
if (registry?.icon) return {
|
|
2244
|
-
icon: registry.icon,
|
|
2245
|
-
source: "registry"
|
|
2246
|
-
};
|
|
2247
|
-
if (entity.attributes?.icon) return {
|
|
2248
|
-
icon: entity.attributes.icon,
|
|
2249
|
-
source: "attribute"
|
|
2250
|
-
};
|
|
2251
|
-
return null;
|
|
2252
|
-
}
|
|
2253
|
-
/**
|
|
2254
|
-
* Build an EntityView from runtime state and registry metadata
|
|
2255
|
-
*/
|
|
2256
|
-
function buildEntityView(entity, registry) {
|
|
2257
|
-
const domain = extractDomain(entity.entity_id);
|
|
2258
|
-
const attributes = entity.attributes ?? {};
|
|
2259
|
-
const friendlyName = attributes.friendly_name ?? registry?.name ?? entity.entity_id;
|
|
2260
|
-
const name = registry?.name ?? friendlyName;
|
|
2261
|
-
const iconResolution = resolveIcon(entity, registry);
|
|
2262
|
-
const { device_class: _dc, unit_of_measurement: _uom, friendly_name: _fn, icon: _icon, ...restAttributes } = attributes;
|
|
2263
|
-
let areaId = registry?.area_id ?? null;
|
|
2264
|
-
if (areaId === null && registry?.device_id) {
|
|
2265
|
-
const device = state.devices[registry.device_id];
|
|
2266
|
-
if (device) areaId = device.area_id ?? null;
|
|
2267
|
-
}
|
|
2268
|
-
return {
|
|
2269
|
-
id: entity.entity_id,
|
|
2270
|
-
domain,
|
|
2271
|
-
state: entity.state,
|
|
2272
|
-
attributes: restAttributes,
|
|
2273
|
-
lastChanged: new Date(entity.last_changed),
|
|
2274
|
-
lastUpdated: new Date(entity.last_updated),
|
|
2275
|
-
context: {
|
|
2276
|
-
id: entity.context?.id ?? "",
|
|
2277
|
-
parentId: entity.context?.parent_id ?? null,
|
|
2278
|
-
userId: entity.context?.user_id ?? null
|
|
2279
|
-
},
|
|
2280
|
-
name,
|
|
2281
|
-
friendlyName,
|
|
2282
|
-
areaId,
|
|
2283
|
-
deviceId: registry?.device_id ?? null,
|
|
2284
|
-
platform: registry?.platform ?? "unknown",
|
|
2285
|
-
uniqueId: registry?.unique_id ?? null,
|
|
2286
|
-
isDisabled: registry?.disabled_by !== null && registry?.disabled_by !== void 0,
|
|
2287
|
-
isHidden: registry?.hidden_by !== null && registry?.hidden_by !== void 0,
|
|
2288
|
-
icon: iconResolution?.icon ?? null,
|
|
2289
|
-
iconSource: iconResolution?.source ?? "default",
|
|
2290
|
-
entityCategory: registry?.entity_category ?? null,
|
|
2291
|
-
labels: registry?.labels ?? [],
|
|
2292
|
-
aliases: registry?.aliases ?? [],
|
|
2293
|
-
deviceClass: registry?.device_class ?? attributes.device_class ?? null,
|
|
2294
|
-
unitOfMeasurement: registry?.unit_of_measurement ?? attributes.unit_of_measurement ?? null,
|
|
2295
|
-
supportedFeatures: registry?.supported_features ?? attributes.supported_features
|
|
2296
|
-
};
|
|
2297
|
-
}
|
|
2298
|
-
/**
|
|
2299
|
-
* Get or build an EntityView
|
|
2300
|
-
*/
|
|
2301
|
-
function getEntityView(entityId) {
|
|
2302
|
-
const entity = state.entities[entityId];
|
|
2303
|
-
if (!entity) return;
|
|
2304
|
-
const registry = state.entityRegistry[entityId];
|
|
2305
|
-
return buildEntityView(entity, registry);
|
|
2306
|
-
}
|
|
2307
|
-
/**
|
|
2308
|
-
* Get multiple entity views
|
|
2309
|
-
*/
|
|
2310
|
-
function getEntityViews(entityIds) {
|
|
2311
|
-
const views = [];
|
|
2312
|
-
for (const id of entityIds) {
|
|
2313
|
-
const view = getEntityView(id);
|
|
2314
|
-
if (view) views.push(view);
|
|
2315
|
-
}
|
|
2316
|
-
return views;
|
|
2317
|
-
}
|
|
2318
|
-
/**
|
|
2319
|
-
* Get all entity views
|
|
2320
|
-
*/
|
|
2321
|
-
function getAllEntityViews() {
|
|
2322
|
-
return getEntityViews(Object.keys(state.entities));
|
|
2323
|
-
}
|
|
2324
|
-
/**
|
|
2325
|
-
* Shallow equality check for EntityView.
|
|
2326
|
-
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
2327
|
-
*/
|
|
2328
|
-
function entityViewEquals(a, b) {
|
|
2329
|
-
if (a === b) return true;
|
|
2330
|
-
if (!a || !b) return false;
|
|
2331
|
-
return a.id === b.id && a.state === b.state && a.lastChanged.getTime() === b.lastChanged.getTime() && a.name === b.name && a.friendlyName === b.friendlyName && a.areaId === b.areaId && a.deviceId === b.deviceId && a.icon === b.icon && a.isDisabled === b.isDisabled && a.isHidden === b.isHidden && a.attributes === b.attributes;
|
|
2332
|
-
}
|
|
2333
|
-
//#endregion
|
|
2334
|
-
//#region src/entities/area-views.ts
|
|
2335
|
-
/**
|
|
2336
|
-
* Area View Builder
|
|
2337
|
-
*
|
|
2338
|
-
* Constructs unified AreaView objects by combining registry data with computed
|
|
2339
|
-
* entities and devices.
|
|
2340
|
-
*
|
|
2341
|
-
* Membership (which entities/devices belong to an area) is registry-driven and
|
|
2342
|
-
* cheap; live entity *views* read entity state and are therefore exposed as a
|
|
2343
|
-
* lazy `entities` getter. Callers that only need names, ids, or counts never
|
|
2344
|
-
* touch entity state, so building the whole-area list does not recompute on
|
|
2345
|
-
* every entity state tick.
|
|
2346
|
-
*
|
|
2347
|
-
* @packageDocumentation
|
|
2348
|
-
*/
|
|
2349
|
-
/** The area an entity belongs to: its own `area_id` wins; otherwise it inherits
|
|
2350
|
-
* the area of its device. Null when neither resolves. */
|
|
2351
|
-
function entityAreaId(entry) {
|
|
2352
|
-
if (entry.area_id) return entry.area_id;
|
|
2353
|
-
if (entry.device_id) return state.devices[entry.device_id]?.area_id ?? null;
|
|
2354
|
-
return null;
|
|
2355
|
-
}
|
|
2356
|
-
/** One pass over the device + entity registries → membership per area.
|
|
2357
|
-
* O(devices + entities) for the whole home, vs the O(areas × (devices +
|
|
2358
|
-
* entities)) of resolving each area independently. */
|
|
2359
|
-
function computeAreaMembership() {
|
|
2360
|
-
const byArea = /* @__PURE__ */ new Map();
|
|
2361
|
-
const ensure = (areaId) => {
|
|
2362
|
-
let m = byArea.get(areaId);
|
|
2363
|
-
if (!m) {
|
|
2364
|
-
m = {
|
|
2365
|
-
devices: [],
|
|
2366
|
-
entityIds: []
|
|
2367
|
-
};
|
|
2368
|
-
byArea.set(areaId, m);
|
|
2369
|
-
}
|
|
2370
|
-
return m;
|
|
2371
|
-
};
|
|
2372
|
-
for (const device of Object.values(state.devices)) if (device.area_id) ensure(device.area_id).devices.push(device);
|
|
2373
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) {
|
|
2374
|
-
const areaId = entityAreaId(entry);
|
|
2375
|
-
if (areaId) ensure(areaId).entityIds.push(entityId);
|
|
2376
|
-
}
|
|
2377
|
-
return byArea;
|
|
2378
|
-
}
|
|
2379
|
-
/** Membership for a single area, without indexing every other area. */
|
|
2380
|
-
function membershipFor(areaId) {
|
|
2381
|
-
const devices = [];
|
|
2382
|
-
for (const device of Object.values(state.devices)) if (device.area_id === areaId) devices.push(device);
|
|
2383
|
-
const entityIds = [];
|
|
2384
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entityAreaId(entry) === areaId) entityIds.push(entityId);
|
|
2385
|
-
return {
|
|
2386
|
-
devices,
|
|
2387
|
-
entityIds
|
|
2388
|
-
};
|
|
2389
|
-
}
|
|
2390
|
-
/**
|
|
2391
|
-
* Build a DeviceView from registry data
|
|
2392
|
-
*/
|
|
2393
|
-
function buildDeviceView(device) {
|
|
2394
|
-
return {
|
|
2395
|
-
id: device.id,
|
|
2396
|
-
name: device.name ?? "",
|
|
2397
|
-
nameByUser: device.name_by_user ?? null,
|
|
2398
|
-
manufacturer: device.manufacturer ?? null,
|
|
2399
|
-
model: device.model ?? null,
|
|
2400
|
-
swVersion: device.sw_version ?? null,
|
|
2401
|
-
areaId: device.area_id ?? null,
|
|
2402
|
-
configEntries: device.config_entries ?? [],
|
|
2403
|
-
connections: device.connections ?? [],
|
|
2404
|
-
identifiers: device.identifiers ?? [],
|
|
2405
|
-
disabledBy: device.disabled_by ?? null
|
|
2406
|
-
};
|
|
2407
|
-
}
|
|
2408
|
-
/** Assemble an AreaView from pre-resolved membership. `entities` is a lazy
|
|
2409
|
-
* getter: materializing live EntityViews reads entity state, so deferring it
|
|
2410
|
-
* keeps callers that only need names/ids/counts off the per-state-tick
|
|
2411
|
-
* recompute path. The getter stays reactive — read inside a tracking scope it
|
|
2412
|
-
* tracks those entities' state and updates like any other store read. */
|
|
2413
|
-
function assembleAreaView(areaId, membership) {
|
|
2414
|
-
const area = state.areas[areaId];
|
|
2415
|
-
if (!area) throw new Error(`Area ${areaId} not found`);
|
|
2416
|
-
const devices = membership.devices.map((device) => buildDeviceView(device));
|
|
2417
|
-
const entityIds = membership.entityIds;
|
|
2418
|
-
return {
|
|
2419
|
-
id: areaId,
|
|
2420
|
-
name: area.name,
|
|
2421
|
-
normalizedName: area.normalized_name ?? area.name.toLowerCase(),
|
|
2422
|
-
aliases: Array.isArray(area.aliases) ? area.aliases : [],
|
|
2423
|
-
floorId: area.floor_id ?? null,
|
|
2424
|
-
icon: area.icon ?? null,
|
|
2425
|
-
picture: area.picture ?? null,
|
|
2426
|
-
labels: Array.isArray(area.labels) ? area.labels : [],
|
|
2427
|
-
temperatureEntityId: area.temperature_entity_id ?? null,
|
|
2428
|
-
humidityEntityId: area.humidity_entity_id ?? null,
|
|
2429
|
-
createdAt: area.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2430
|
-
modifiedAt: area.modified_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2431
|
-
devices,
|
|
2432
|
-
entityIds,
|
|
2433
|
-
deviceIds: devices.map((d) => d.id),
|
|
2434
|
-
get entities() {
|
|
2435
|
-
return entityIds.length > 0 ? getEntityViews(entityIds) : [];
|
|
2436
|
-
}
|
|
2437
|
-
};
|
|
2438
|
-
}
|
|
2439
|
-
/**
|
|
2440
|
-
* Build an AreaView for a single area from registry data.
|
|
2441
|
-
*/
|
|
2442
|
-
function buildAreaView(areaId) {
|
|
2443
|
-
return assembleAreaView(areaId, membershipFor(areaId));
|
|
2444
|
-
}
|
|
2445
|
-
/**
|
|
2446
|
-
* Get all area views. Membership is resolved in a single registry pass; entity
|
|
2447
|
-
* views stay lazy per area (see `assembleAreaView`).
|
|
2448
|
-
*/
|
|
2449
|
-
function getAreaViews() {
|
|
2450
|
-
const membership = computeAreaMembership();
|
|
2451
|
-
return Object.keys(state.areas).map((areaId) => assembleAreaView(areaId, membership.get(areaId) ?? {
|
|
2452
|
-
devices: [],
|
|
2453
|
-
entityIds: []
|
|
2454
|
-
}));
|
|
2455
|
-
}
|
|
2456
|
-
/**
|
|
2457
|
-
* Get area view
|
|
2458
|
-
*/
|
|
2459
|
-
function getAreaView(areaId) {
|
|
2460
|
-
try {
|
|
2461
|
-
return buildAreaView(areaId);
|
|
2462
|
-
} catch {
|
|
2463
|
-
return;
|
|
2464
|
-
}
|
|
2465
|
-
}
|
|
2466
|
-
//#endregion
|
|
2467
|
-
//#region src/history/statistics.ts
|
|
2468
|
-
const STATISTIC_TYPES = [
|
|
2469
|
-
"mean",
|
|
2470
|
-
"sum",
|
|
2471
|
-
"min",
|
|
2472
|
-
"max",
|
|
2473
|
-
"state",
|
|
2474
|
-
"change"
|
|
2475
|
-
];
|
|
2476
|
-
/**
|
|
2477
|
-
* HA returns start/end as ISO strings or ms numbers depending on version.
|
|
2478
|
-
* Normalize both shapes to ms epoch numbers; unparseable values become NaN.
|
|
2479
|
-
*/
|
|
2480
|
-
function normalizeStatisticTime(value) {
|
|
2481
|
-
if (typeof value === "number") return value;
|
|
2482
|
-
if (typeof value === "string") return new Date(value).getTime();
|
|
2483
|
-
return NaN;
|
|
2484
|
-
}
|
|
2485
|
-
function normalizeNumber(value) {
|
|
2486
|
-
return typeof value === "number" ? value : void 0;
|
|
2487
|
-
}
|
|
2488
|
-
function normalizeStatisticValue(raw) {
|
|
2489
|
-
const value = {
|
|
2490
|
-
start: normalizeStatisticTime(raw.start),
|
|
2491
|
-
end: normalizeStatisticTime(raw.end)
|
|
2492
|
-
};
|
|
2493
|
-
const mean = normalizeNumber(raw.mean);
|
|
2494
|
-
if (mean !== void 0) value.mean = mean;
|
|
2495
|
-
const sum = normalizeNumber(raw.sum);
|
|
2496
|
-
if (sum !== void 0) value.sum = sum;
|
|
2497
|
-
const min = normalizeNumber(raw.min);
|
|
2498
|
-
if (min !== void 0) value.min = min;
|
|
2499
|
-
const max = normalizeNumber(raw.max);
|
|
2500
|
-
if (max !== void 0) value.max = max;
|
|
2501
|
-
const stateVal = normalizeNumber(raw.state);
|
|
2502
|
-
if (stateVal !== void 0) value.state = stateVal;
|
|
2503
|
-
const change = normalizeNumber(raw.change);
|
|
2504
|
-
if (change !== void 0) value.change = change;
|
|
2505
|
-
return value;
|
|
2428
|
+
/** One timer for the whole module: at local midnight the day-quantized
|
|
2429
|
+
windows shift, so a reconcile slides every subscription forward. Only
|
|
2430
|
+
armed while something is tracked — an idle app holds no timer. */
|
|
2431
|
+
function ensureRollTimer() {
|
|
2432
|
+
if (rollTimer || trackers.size === 0) return;
|
|
2433
|
+
const now = /* @__PURE__ */ new Date();
|
|
2434
|
+
const next = new Date(now);
|
|
2435
|
+
next.setHours(24, 0, 30, 0);
|
|
2436
|
+
rollTimer = setTimeout(() => {
|
|
2437
|
+
rollTimer = null;
|
|
2438
|
+
flushAll().then(ensureRollTimer);
|
|
2439
|
+
}, next.getTime() - now.getTime());
|
|
2506
2440
|
}
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
* hit `undefined`.
|
|
2512
|
-
*/
|
|
2513
|
-
async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
|
|
2514
|
-
const { startTime, endTime, period } = options;
|
|
2515
|
-
if (isDemoMode()) {
|
|
2516
|
-
const result = {};
|
|
2517
|
-
const statPeriod = period === "hour" || period === "day" ? period : "day";
|
|
2518
|
-
const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
|
|
2519
|
-
for (const id of statisticIds) result[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : numericSensorStatistics(id, startTime.getTime(), endMs, statPeriod);
|
|
2520
|
-
return result;
|
|
2441
|
+
function stopRollTimer() {
|
|
2442
|
+
if (rollTimer) {
|
|
2443
|
+
clearTimeout(rollTimer);
|
|
2444
|
+
rollTimer = null;
|
|
2521
2445
|
}
|
|
2522
|
-
const params = {
|
|
2523
|
-
type: "recorder/statistics_during_period",
|
|
2524
|
-
start_time: startTime.toISOString(),
|
|
2525
|
-
statistic_ids: statisticIds,
|
|
2526
|
-
period,
|
|
2527
|
-
types: STATISTIC_TYPES
|
|
2528
|
-
};
|
|
2529
|
-
if (endTime) params.end_time = endTime.toISOString();
|
|
2530
|
-
const response = await connection.sendMessagePromise(params);
|
|
2531
|
-
const result = {};
|
|
2532
|
-
for (const id of statisticIds) result[id] = [];
|
|
2533
|
-
if (response && typeof response === "object") for (const [id, buckets] of Object.entries(response)) result[id] = Array.isArray(buckets) ? buckets.map(normalizeStatisticValue) : [];
|
|
2534
|
-
return result;
|
|
2535
2446
|
}
|
|
2536
2447
|
//#endregion
|
|
2537
|
-
export {
|
|
2448
|
+
export { buildDeviceView as A, registerEntity as B, unloadDemoData as C, isEnergyEntity as D, demoEnergyPreferences as E, getAllEntityViews as F, setState as G, setResubscribeHandler as H, getEntityView as I, state as K, getEntityViews as L, getAreaViews as M, buildEntityView as N, synthesizeEnergyHistory as O, entityViewEquals as P, extractDomain as R, stopDemoEnergyTicker as S, demoAssetUrl as T, privilegedConn as U, setManagerConnection as V, setPrivilegedConn as W, updateEntity as _, fetchForecastData as a, loadDemoData as b, normalizeStatisticTime as c, batchServiceCalls as d, callService as f, turnOn as g, turnOff as h, fetchForecast as i, getAreaView as j, buildAreaView as k, numericSensorHistory as l, toggle as m, trackCalendarEvents as n, fetchForecasts as o, sendCommand as p, buildDemoForecast as r, fetchStatisticsDuringPeriod as s, forceResubscribeCalendars as t, batchEntityUpdates as u, applyDemoServiceCall as v, createDemoFixtures as w, startDemoEnergyTicker as x, isDemoMode as y, forceResubscribe as z };
|