@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
|
@@ -36,193 +36,6 @@ function setPrivilegedConn(conn) {
|
|
|
36
36
|
privileged = conn;
|
|
37
37
|
}
|
|
38
38
|
//#endregion
|
|
39
|
-
//#region src/calendar/track.ts
|
|
40
|
-
/**
|
|
41
|
-
* Ref-counted live calendar tracking, the per-entity analog of
|
|
42
|
-
* `connection/subscription-manager`. Each tracked calendar entity holds one
|
|
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.
|
|
48
|
-
*/
|
|
49
|
-
const DEFAULT_DAYS_BEHIND = 7;
|
|
50
|
-
const DEFAULT_DAYS_AHEAD = 35;
|
|
51
|
-
/** Debounce for untrack, so dashboard switches don't churn subscriptions. */
|
|
52
|
-
const UNTRACK_FLUSH_DELAY_MS = 5e3;
|
|
53
|
-
const DAY_MS = 864e5;
|
|
54
|
-
const trackers = /* @__PURE__ */ new Map();
|
|
55
|
-
const active = /* @__PURE__ */ new Map();
|
|
56
|
-
/** Per-entity reconcile chain; serializes subscribe/unsubscribe per calendar. */
|
|
57
|
-
const chains = /* @__PURE__ */ new Map();
|
|
58
|
-
let untrackTimer = 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)).toISOString(),
|
|
73
|
-
end: new Date(dayStart.getTime() + (ahead + 1) * DAY_MS).toISOString()
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Start tracking live events for a calendar entity. Returns the untrack
|
|
78
|
-
* function. Safe while disconnected: the subscription activates on the next
|
|
79
|
-
* `forceResubscribeCalendars()` (called by the connection layer on connect
|
|
80
|
-
* and reconnect).
|
|
81
|
-
*/
|
|
82
|
-
function trackCalendarEvents(entityId, options) {
|
|
83
|
-
const tracker = {
|
|
84
|
-
daysBehind: options?.daysBehind ?? DEFAULT_DAYS_BEHIND,
|
|
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
|
-
};
|
|
98
|
-
}
|
|
99
|
-
function untrackCalendarEvents(entityId, tracker) {
|
|
100
|
-
const list = trackers.get(entityId);
|
|
101
|
-
if (!list) return;
|
|
102
|
-
const i = list.indexOf(tracker);
|
|
103
|
-
if (i !== -1) list.splice(i, 1);
|
|
104
|
-
if (list.length === 0) trackers.delete(entityId);
|
|
105
|
-
if (untrackTimer) return;
|
|
106
|
-
untrackTimer = setTimeout(() => {
|
|
107
|
-
untrackTimer = null;
|
|
108
|
-
flushAll();
|
|
109
|
-
}, UNTRACK_FLUSH_DELAY_MS);
|
|
110
|
-
}
|
|
111
|
-
/**
|
|
112
|
-
* Reconcile every tracked calendar, dropping current subscription handles
|
|
113
|
-
* first. The connection layer calls this on connect and after reconnect:
|
|
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).
|
|
117
|
-
*/
|
|
118
|
-
async function forceResubscribeCalendars() {
|
|
119
|
-
for (const entry of active.values()) {
|
|
120
|
-
const dead = entry.unsub;
|
|
121
|
-
entry.unsub = null;
|
|
122
|
-
if (dead) Promise.resolve().then(dead).catch(() => {});
|
|
123
|
-
}
|
|
124
|
-
await flushAll();
|
|
125
|
-
}
|
|
126
|
-
function scheduleFlush$1() {
|
|
127
|
-
queueMicrotask(() => {
|
|
128
|
-
flushAll();
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
async function flushAll() {
|
|
132
|
-
const ids = /* @__PURE__ */ new Set([...trackers.keys(), ...active.keys()]);
|
|
133
|
-
await Promise.all([...ids].map(reconcileEntity));
|
|
134
|
-
if (trackers.size === 0) stopRollTimer();
|
|
135
|
-
}
|
|
136
|
-
function reconcileEntity(entityId) {
|
|
137
|
-
const next = (chains.get(entityId) ?? Promise.resolve()).then(() => runReconcile(entityId));
|
|
138
|
-
chains.set(entityId, next.catch(() => {}));
|
|
139
|
-
return next;
|
|
140
|
-
}
|
|
141
|
-
async function runReconcile(entityId) {
|
|
142
|
-
const list = trackers.get(entityId);
|
|
143
|
-
const current = active.get(entityId);
|
|
144
|
-
if (!list || list.length === 0) {
|
|
145
|
-
if (current) {
|
|
146
|
-
active.delete(entityId);
|
|
147
|
-
try {
|
|
148
|
-
await current.unsub?.();
|
|
149
|
-
} catch {}
|
|
150
|
-
}
|
|
151
|
-
chains.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.set(entityId, entry);
|
|
178
|
-
try {
|
|
179
|
-
entry.unsub = await conn.subscribeMessage((msg) => {
|
|
180
|
-
setState(produce((s) => {
|
|
181
|
-
const rec = s.calendars[entityId];
|
|
182
|
-
if (!rec) return;
|
|
183
|
-
rec.events = msg.events ?? [];
|
|
184
|
-
rec.loading = false;
|
|
185
|
-
rec.error = null;
|
|
186
|
-
}));
|
|
187
|
-
}, {
|
|
188
|
-
type: "calendar/event/subscribe",
|
|
189
|
-
entity_id: entityId,
|
|
190
|
-
start: win.start,
|
|
191
|
-
end: win.end
|
|
192
|
-
});
|
|
193
|
-
} catch (err) {
|
|
194
|
-
active.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
|
-
}));
|
|
201
|
-
}
|
|
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().then(ensureRollTimer);
|
|
217
|
-
}, next.getTime() - now.getTime());
|
|
218
|
-
}
|
|
219
|
-
function stopRollTimer() {
|
|
220
|
-
if (rollTimer) {
|
|
221
|
-
clearTimeout(rollTimer);
|
|
222
|
-
rollTimer = null;
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
//#endregion
|
|
226
39
|
//#region src/connection/subscription-manager.ts
|
|
227
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.");
|
|
228
41
|
globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
|
|
@@ -250,7 +63,7 @@ let flushInFlight = false;
|
|
|
250
63
|
*/
|
|
251
64
|
function registerEntity(entityId) {
|
|
252
65
|
refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
|
|
253
|
-
scheduleFlush();
|
|
66
|
+
scheduleFlush$1();
|
|
254
67
|
return () => unregisterEntity(entityId);
|
|
255
68
|
}
|
|
256
69
|
/**
|
|
@@ -258,7 +71,7 @@ function registerEntity(entityId) {
|
|
|
258
71
|
*/
|
|
259
72
|
function setManagerConnection(connection) {
|
|
260
73
|
conn = connection;
|
|
261
|
-
if (refCounts.size > 0) scheduleFlush();
|
|
74
|
+
if (refCounts.size > 0) scheduleFlush$1();
|
|
262
75
|
}
|
|
263
76
|
/**
|
|
264
77
|
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
@@ -280,21 +93,24 @@ function unregisterEntity(entityId) {
|
|
|
280
93
|
else refCounts.set(entityId, count);
|
|
281
94
|
scheduleDelayedFlush();
|
|
282
95
|
}
|
|
283
|
-
function scheduleFlush() {
|
|
96
|
+
function scheduleFlush$1() {
|
|
284
97
|
if (flushScheduled) return;
|
|
285
98
|
flushScheduled = true;
|
|
286
99
|
queueMicrotask(() => {
|
|
287
100
|
flushScheduled = false;
|
|
288
|
-
flush();
|
|
101
|
+
flush().catch(logFlushError);
|
|
289
102
|
});
|
|
290
103
|
}
|
|
291
104
|
function scheduleDelayedFlush() {
|
|
292
105
|
if (unregisterTimeout) return;
|
|
293
106
|
unregisterTimeout = setTimeout(() => {
|
|
294
107
|
unregisterTimeout = null;
|
|
295
|
-
flush();
|
|
108
|
+
flush().catch(logFlushError);
|
|
296
109
|
}, UNREGISTER_DELAY_MS);
|
|
297
110
|
}
|
|
111
|
+
function logFlushError(error) {
|
|
112
|
+
console.error("[@glasshome/sync-layer] entity resubscribe failed:", error);
|
|
113
|
+
}
|
|
298
114
|
async function flush() {
|
|
299
115
|
if (!conn || !onResubscribe) return;
|
|
300
116
|
if (flushInFlight) return;
|
|
@@ -327,61 +143,293 @@ function extractDomain(entityId) {
|
|
|
327
143
|
return entityId.split(".")[0] ?? "";
|
|
328
144
|
}
|
|
329
145
|
//#endregion
|
|
330
|
-
//#region src/
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
const BATTERY_CAPACITY_WH = 1e4;
|
|
339
|
-
const BATTERY_MIN_SOC = 10;
|
|
340
|
-
const BATTERY_MAX_SOC = 100;
|
|
341
|
-
const BATTERY_POWER_CAP_W = 3e3;
|
|
342
|
-
const MIDNIGHT_SOC = 55;
|
|
343
|
-
const SOC_STEP_MIN = 5;
|
|
344
|
-
/** Integer hash -> uint32, used to seed mulberry32 deterministically. */
|
|
345
|
-
function hashInt(n) {
|
|
346
|
-
let h = n | 0;
|
|
347
|
-
h = Math.imul(h ^ h >>> 16, 73244475);
|
|
348
|
-
h = Math.imul(h ^ h >>> 16, 73244475);
|
|
349
|
-
h = (h ^ h >>> 16) >>> 0;
|
|
350
|
-
return h;
|
|
351
|
-
}
|
|
352
|
-
/** Deterministic [0,1) generator seeded by an integer. */
|
|
353
|
-
function mulberry32(seed) {
|
|
354
|
-
let a = seed >>> 0;
|
|
355
|
-
return () => {
|
|
356
|
-
a |= 0;
|
|
357
|
-
a = a + 1831565813 | 0;
|
|
358
|
-
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
359
|
-
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
360
|
-
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
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"
|
|
361
154
|
};
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
const hours = d.getHours();
|
|
366
|
-
const minutes = d.getMinutes();
|
|
367
|
-
const seconds = d.getSeconds();
|
|
368
|
-
const hourFrac = hours + minutes / 60 + seconds / 3600;
|
|
369
|
-
const minuteOfDay = hours * 60 + minutes + seconds / 60;
|
|
370
|
-
const offsetMs = d.getTimezoneOffset() * 6e4;
|
|
371
|
-
const dayKey = Math.floor((timestampMs - offsetMs) / 864e5);
|
|
372
|
-
return {
|
|
373
|
-
hourFrac,
|
|
374
|
-
minuteOfDay,
|
|
375
|
-
dayOfWeek: d.getDay(),
|
|
376
|
-
dayKey
|
|
155
|
+
if (entity.attributes?.icon) return {
|
|
156
|
+
icon: entity.attributes.icon,
|
|
157
|
+
source: "attribute"
|
|
377
158
|
};
|
|
159
|
+
return null;
|
|
378
160
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
const
|
|
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,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";
|
|
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;
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
function localTime(timestampMs) {
|
|
412
|
+
const d = new Date(timestampMs);
|
|
413
|
+
const hours = d.getHours();
|
|
414
|
+
const minutes = d.getMinutes();
|
|
415
|
+
const seconds = d.getSeconds();
|
|
416
|
+
const hourFrac = hours + minutes / 60 + seconds / 3600;
|
|
417
|
+
const minuteOfDay = hours * 60 + minutes + seconds / 60;
|
|
418
|
+
const offsetMs = d.getTimezoneOffset() * 6e4;
|
|
419
|
+
const dayKey = Math.floor((timestampMs - offsetMs) / 864e5);
|
|
420
|
+
return {
|
|
421
|
+
hourFrac,
|
|
422
|
+
minuteOfDay,
|
|
423
|
+
dayOfWeek: d.getDay(),
|
|
424
|
+
dayKey
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function localMidnightMs(timestampMs, lt) {
|
|
428
|
+
return timestampMs - lt.minuteOfDay * 6e4;
|
|
429
|
+
}
|
|
430
|
+
/** Smooth multiplicative cloud factor in [0.55, 1.0], seeded per day. */
|
|
431
|
+
function cloudFactor(lt) {
|
|
432
|
+
const rand = mulberry32(hashInt(lt.dayKey * 2654435761));
|
|
385
433
|
const phase1 = rand() * Math.PI * 2;
|
|
386
434
|
const phase2 = rand() * Math.PI * 2;
|
|
387
435
|
const phase3 = rand() * Math.PI * 2;
|
|
@@ -397,7 +445,10 @@ function solarPower(lt) {
|
|
|
397
445
|
}
|
|
398
446
|
/** Coarse solar elevation in degrees for sun.sun attributes. */
|
|
399
447
|
function sunElevation(lt) {
|
|
400
|
-
if (lt.hourFrac <= SUNRISE_HOUR || lt.hourFrac >= SUNSET_HOUR)
|
|
448
|
+
if (lt.hourFrac <= SUNRISE_HOUR || lt.hourFrac >= SUNSET_HOUR) {
|
|
449
|
+
const hoursFromHorizon = Math.min(Math.abs(lt.hourFrac - SUNRISE_HOUR), Math.abs(lt.hourFrac - SUNSET_HOUR), Math.abs(lt.hourFrac + 24 - SUNSET_HOUR));
|
|
450
|
+
return -12 * Math.min(1, hoursFromHorizon / 1.5);
|
|
451
|
+
}
|
|
401
452
|
const frac = (lt.hourFrac - SUNRISE_HOUR) / 14;
|
|
402
453
|
return 60 * Math.sin(frac * Math.PI);
|
|
403
454
|
}
|
|
@@ -588,7 +639,8 @@ function sunEvents(timestampMs) {
|
|
|
588
639
|
return {
|
|
589
640
|
nextRising: new Date(nextRising).toISOString(),
|
|
590
641
|
nextSetting: new Date(nextSetting).toISOString(),
|
|
591
|
-
elevation: Math.round(sunElevation(lt) * 100) / 100
|
|
642
|
+
elevation: Math.round(sunElevation(lt) * 100) / 100,
|
|
643
|
+
rising: lt.hourFrac < 27 / 2 || lt.hourFrac >= 25.5
|
|
592
644
|
};
|
|
593
645
|
}
|
|
594
646
|
/** The energy sensor entity ids the simulation drives. */
|
|
@@ -763,6 +815,9 @@ function demoEnergyPreferences() {
|
|
|
763
815
|
}
|
|
764
816
|
//#endregion
|
|
765
817
|
//#region src/demo/demo-data.ts
|
|
818
|
+
function demoAssetUrl(path) {
|
|
819
|
+
return globalThis.location ? new URL(path, globalThis.location.origin).href : path;
|
|
820
|
+
}
|
|
766
821
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
767
822
|
let contextCounter = 0;
|
|
768
823
|
function ctx() {
|
|
@@ -977,6 +1032,18 @@ function makeWeatherEntity(w) {
|
|
|
977
1032
|
condition: "rainy",
|
|
978
1033
|
temperature: w.temp - 2,
|
|
979
1034
|
templow: w.low - 2
|
|
1035
|
+
},
|
|
1036
|
+
{
|
|
1037
|
+
datetime: day(5),
|
|
1038
|
+
condition: "cloudy",
|
|
1039
|
+
temperature: w.temp - 1,
|
|
1040
|
+
templow: w.low - 2
|
|
1041
|
+
},
|
|
1042
|
+
{
|
|
1043
|
+
datetime: day(6),
|
|
1044
|
+
condition: "partlycloudy",
|
|
1045
|
+
temperature: w.temp + 1,
|
|
1046
|
+
templow: w.low
|
|
980
1047
|
}
|
|
981
1048
|
]
|
|
982
1049
|
});
|
|
@@ -1072,15 +1139,17 @@ function makeArea(id, name, floorId = "ground_floor") {
|
|
|
1072
1139
|
function createDemoFixtures() {
|
|
1073
1140
|
contextCounter = 0;
|
|
1074
1141
|
const areas = {
|
|
1075
|
-
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
|
+
},
|
|
1076
1147
|
bedroom: makeArea("bedroom", "Bedroom"),
|
|
1077
1148
|
kitchen: makeArea("kitchen", "Kitchen"),
|
|
1078
1149
|
entry: makeArea("entry", "Entry"),
|
|
1079
1150
|
utility: makeArea("utility", "Utility"),
|
|
1080
1151
|
garage: makeArea("garage", "Garage")
|
|
1081
1152
|
};
|
|
1082
|
-
areas.living_room.temperature_entity_id = "sensor.temperature_living";
|
|
1083
|
-
areas.living_room.humidity_entity_id = "sensor.humidity_living";
|
|
1084
1153
|
const devices = {
|
|
1085
1154
|
living_room_lights: makeDevice("living_room_lights", "Living Room Lights", "living_room", "Philips", "Hue"),
|
|
1086
1155
|
kitchen_lights: makeDevice("kitchen_lights", "Kitchen Lights", "kitchen", "IKEA", "Tradfri"),
|
|
@@ -1214,6 +1283,24 @@ function createDemoFixtures() {
|
|
|
1214
1283
|
unit_of_measurement: "%",
|
|
1215
1284
|
state_class: "measurement"
|
|
1216
1285
|
}), { unit_of_measurement: "%" });
|
|
1286
|
+
for (const [zone, co2, fossil] of [[
|
|
1287
|
+
"de",
|
|
1288
|
+
"384",
|
|
1289
|
+
"52"
|
|
1290
|
+
], [
|
|
1291
|
+
"pl",
|
|
1292
|
+
"692",
|
|
1293
|
+
"81"
|
|
1294
|
+
]]) {
|
|
1295
|
+
add(makeEntity(`sensor.electricity_maps_co2_intensity_${zone}`, co2, {
|
|
1296
|
+
unit_of_measurement: "gCO2eq/kWh",
|
|
1297
|
+
state_class: "measurement"
|
|
1298
|
+
}), { unit_of_measurement: "gCO2eq/kWh" });
|
|
1299
|
+
add(makeEntity(`sensor.electricity_maps_fossil_fuel_percentage_${zone}`, fossil, {
|
|
1300
|
+
unit_of_measurement: "%",
|
|
1301
|
+
state_class: "measurement"
|
|
1302
|
+
}), { unit_of_measurement: "%" });
|
|
1303
|
+
}
|
|
1217
1304
|
add(makeEntity("sensor.nordpool_current_price", "0.18", {
|
|
1218
1305
|
unit_of_measurement: "EUR/kWh",
|
|
1219
1306
|
state_class: "measurement"
|
|
@@ -1383,6 +1470,9 @@ function createDemoFixtures() {
|
|
|
1383
1470
|
area_id: "entry",
|
|
1384
1471
|
supported_features: 1
|
|
1385
1472
|
});
|
|
1473
|
+
add(makeEntity("lock.side_gate", "unlocked", {}), { supported_features: 1 });
|
|
1474
|
+
add(makeEntity("binary_sensor.patio_door", "on", { device_class: "door" }), { device_class: "door" });
|
|
1475
|
+
add(makeEntity("binary_sensor.motion_kitchen", "on", { device_class: "motion" }), { device_class: "motion" });
|
|
1386
1476
|
add(makeEntity("switch.coffee_machine", "off", { device_class: "outlet" }), {
|
|
1387
1477
|
area_id: "kitchen",
|
|
1388
1478
|
device_class: "outlet"
|
|
@@ -1395,17 +1485,18 @@ function createDemoFixtures() {
|
|
|
1395
1485
|
media_title: "Lo-fi Beats",
|
|
1396
1486
|
media_artist: "Chill Station",
|
|
1397
1487
|
media_content_type: "music",
|
|
1488
|
+
entity_picture: DEMO_ALBUM_COVER,
|
|
1398
1489
|
volume_level: .4,
|
|
1399
1490
|
is_volume_muted: false,
|
|
1400
|
-
supported_features:
|
|
1491
|
+
supported_features: 152509
|
|
1401
1492
|
}), {
|
|
1402
1493
|
device_id: "speaker_lr",
|
|
1403
1494
|
area_id: "living_room",
|
|
1404
|
-
supported_features:
|
|
1495
|
+
supported_features: 152509
|
|
1405
1496
|
});
|
|
1406
1497
|
for (const w of WEATHER_FIXTURES) add(makeWeatherEntity(w), { platform: "met" });
|
|
1407
1498
|
add(makeEntity("camera.front_door_camera", "streaming", {
|
|
1408
|
-
entity_picture: "/
|
|
1499
|
+
entity_picture: demoAssetUrl("/demo/camera/front-door.webp"),
|
|
1409
1500
|
frontend_stream_type: "hls",
|
|
1410
1501
|
access_token: "demo-token"
|
|
1411
1502
|
}), {
|
|
@@ -1458,7 +1549,8 @@ function createDemoFixtures() {
|
|
|
1458
1549
|
friendly_name: "Sun",
|
|
1459
1550
|
elevation: sun.elevation,
|
|
1460
1551
|
next_rising: sun.nextRising,
|
|
1461
|
-
next_setting: sun.nextSetting
|
|
1552
|
+
next_setting: sun.nextSetting,
|
|
1553
|
+
rising: sun.rising
|
|
1462
1554
|
}), {});
|
|
1463
1555
|
for (const s of [
|
|
1464
1556
|
{
|
|
@@ -1634,6 +1726,7 @@ function tickEnergy() {
|
|
|
1634
1726
|
sunEntity.attributes.elevation = sun.elevation;
|
|
1635
1727
|
sunEntity.attributes.next_rising = sun.nextRising;
|
|
1636
1728
|
sunEntity.attributes.next_setting = sun.nextSetting;
|
|
1729
|
+
sunEntity.attributes.rising = sun.rising;
|
|
1637
1730
|
}
|
|
1638
1731
|
}));
|
|
1639
1732
|
}
|
|
@@ -1660,8 +1753,9 @@ function stopDemoEnergyTicker() {
|
|
|
1660
1753
|
* When a service call is made in demo mode, this updates the entity
|
|
1661
1754
|
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
1662
1755
|
*/
|
|
1663
|
-
function applyDemoServiceCall(_domain, service,
|
|
1756
|
+
function applyDemoServiceCall(_domain, service, serviceData = {}, target = {}) {
|
|
1664
1757
|
if (!_isDemoMode) return;
|
|
1758
|
+
const _serviceData = serviceData;
|
|
1665
1759
|
const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
|
|
1666
1760
|
for (const entityId of entityIds) {
|
|
1667
1761
|
if (!state.entities[entityId]) continue;
|
|
@@ -1715,7 +1809,7 @@ function applyDemoServiceCall(_domain, service, _serviceData = {}, target = {})
|
|
|
1715
1809
|
}));
|
|
1716
1810
|
}
|
|
1717
1811
|
}
|
|
1718
|
-
function applyTurnOn(e, domain, serviceData
|
|
1812
|
+
function applyTurnOn(e, domain, serviceData) {
|
|
1719
1813
|
if (domain === "light") {
|
|
1720
1814
|
e.state = "on";
|
|
1721
1815
|
if (serviceData.brightness_pct !== void 0) {
|
|
@@ -1774,9 +1868,7 @@ async function callService(domain, service, serviceData = {}, target = {}) {
|
|
|
1774
1868
|
return;
|
|
1775
1869
|
}
|
|
1776
1870
|
try {
|
|
1777
|
-
|
|
1778
|
-
if (mockConnection && typeof mockConnection.callService === "function") await mockConnection.callService(domain, service, serviceData, target);
|
|
1779
|
-
else await connection.sendMessagePromise({
|
|
1871
|
+
await connection.sendMessagePromise({
|
|
1780
1872
|
type: "call_service",
|
|
1781
1873
|
domain,
|
|
1782
1874
|
service,
|
|
@@ -1784,8 +1876,15 @@ async function callService(domain, service, serviceData = {}, target = {}) {
|
|
|
1784
1876
|
target
|
|
1785
1877
|
});
|
|
1786
1878
|
} catch (error) {
|
|
1787
|
-
throw new Error(`Service call failed: ${
|
|
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;
|
|
1788
1886
|
}
|
|
1887
|
+
return String(error);
|
|
1789
1888
|
}
|
|
1790
1889
|
/**
|
|
1791
1890
|
* Turn on entity or entities
|
|
@@ -1822,7 +1921,7 @@ async function toggle(entityId, serviceData = {}) {
|
|
|
1822
1921
|
*/
|
|
1823
1922
|
async function updateEntity(entityId, updates) {
|
|
1824
1923
|
const connection = privilegedConn();
|
|
1825
|
-
if (!connection) throw new Error("Not connected to Home Assistant.
|
|
1924
|
+
if (!connection) throw new Error("Not connected to Home Assistant.");
|
|
1826
1925
|
try {
|
|
1827
1926
|
const result = await connection.sendMessagePromise({
|
|
1828
1927
|
type: "config/entity_registry/update",
|
|
@@ -1832,8 +1931,9 @@ async function updateEntity(entityId, updates) {
|
|
|
1832
1931
|
if (result && typeof result === "object" && "_mockError" in result) throw new Error(`Entity registry entry not found: ${entityId}`);
|
|
1833
1932
|
return result;
|
|
1834
1933
|
} catch (error) {
|
|
1835
|
-
|
|
1836
|
-
|
|
1934
|
+
const message = messageOf(error);
|
|
1935
|
+
if (message.includes("Entity registry entry not found")) throw error;
|
|
1936
|
+
throw new Error(`Entity update failed: ${message}`);
|
|
1837
1937
|
}
|
|
1838
1938
|
}
|
|
1839
1939
|
/**
|
|
@@ -1845,7 +1945,7 @@ async function sendCommand(command) {
|
|
|
1845
1945
|
try {
|
|
1846
1946
|
return await connection.sendMessagePromise(command);
|
|
1847
1947
|
} catch (error) {
|
|
1848
|
-
throw new Error(`Command failed: ${
|
|
1948
|
+
throw new Error(`Command failed: ${messageOf(error)}`);
|
|
1849
1949
|
}
|
|
1850
1950
|
}
|
|
1851
1951
|
/**
|
|
@@ -1855,239 +1955,72 @@ async function batchServiceCalls(calls) {
|
|
|
1855
1955
|
await Promise.all(calls.map((call) => callService(call.domain, call.service, call.serviceData, call.target)));
|
|
1856
1956
|
}
|
|
1857
1957
|
/**
|
|
1858
|
-
* Execute multiple entity updates in parallel
|
|
1859
|
-
*/
|
|
1860
|
-
async function batchEntityUpdates(updates) {
|
|
1861
|
-
return Promise.all(updates.map((u) => updateEntity(u.entityId, u.updates)));
|
|
1862
|
-
}
|
|
1863
|
-
//#endregion
|
|
1864
|
-
//#region src/entities/views.ts
|
|
1865
|
-
/**
|
|
1866
|
-
* Get icon for an entity from registry or attributes
|
|
1867
|
-
*/
|
|
1868
|
-
function resolveIcon(entity, registry) {
|
|
1869
|
-
if (registry?.icon) return {
|
|
1870
|
-
icon: registry.icon,
|
|
1871
|
-
source: "registry"
|
|
1872
|
-
};
|
|
1873
|
-
if (entity.attributes?.icon) return {
|
|
1874
|
-
icon: entity.attributes.icon,
|
|
1875
|
-
source: "attribute"
|
|
1876
|
-
};
|
|
1877
|
-
return null;
|
|
1878
|
-
}
|
|
1879
|
-
/**
|
|
1880
|
-
* Build an EntityView from runtime state and registry metadata
|
|
1881
|
-
*/
|
|
1882
|
-
function buildEntityView(entity, registry) {
|
|
1883
|
-
const domain = extractDomain(entity.entity_id);
|
|
1884
|
-
const attributes = entity.attributes ?? {};
|
|
1885
|
-
const friendlyName = attributes.friendly_name ?? registry?.name ?? entity.entity_id;
|
|
1886
|
-
const name = registry?.name ?? friendlyName;
|
|
1887
|
-
const iconResolution = resolveIcon(entity, registry);
|
|
1888
|
-
const { device_class: _dc, unit_of_measurement: _uom, friendly_name: _fn, icon: _icon, ...restAttributes } = attributes;
|
|
1889
|
-
let areaId = registry?.area_id ?? null;
|
|
1890
|
-
if (areaId === null && registry?.device_id) {
|
|
1891
|
-
const device = state.devices[registry.device_id];
|
|
1892
|
-
if (device) areaId = device.area_id ?? null;
|
|
1893
|
-
}
|
|
1894
|
-
return {
|
|
1895
|
-
id: entity.entity_id,
|
|
1896
|
-
domain,
|
|
1897
|
-
state: entity.state,
|
|
1898
|
-
attributes: restAttributes,
|
|
1899
|
-
lastChanged: new Date(entity.last_changed),
|
|
1900
|
-
lastUpdated: new Date(entity.last_updated),
|
|
1901
|
-
context: {
|
|
1902
|
-
id: entity.context?.id ?? "",
|
|
1903
|
-
parentId: entity.context?.parent_id ?? null,
|
|
1904
|
-
userId: entity.context?.user_id ?? null
|
|
1905
|
-
},
|
|
1906
|
-
name,
|
|
1907
|
-
friendlyName,
|
|
1908
|
-
areaId,
|
|
1909
|
-
deviceId: registry?.device_id ?? null,
|
|
1910
|
-
platform: registry?.platform ?? "unknown",
|
|
1911
|
-
uniqueId: registry?.unique_id ?? null,
|
|
1912
|
-
isDisabled: registry?.disabled_by !== null && registry?.disabled_by !== void 0,
|
|
1913
|
-
isHidden: registry?.hidden_by !== null && registry?.hidden_by !== void 0,
|
|
1914
|
-
icon: iconResolution?.icon ?? null,
|
|
1915
|
-
iconSource: iconResolution?.source ?? "default",
|
|
1916
|
-
entityCategory: registry?.entity_category ?? null,
|
|
1917
|
-
labels: registry?.labels ?? [],
|
|
1918
|
-
aliases: registry?.aliases ?? [],
|
|
1919
|
-
deviceClass: registry?.device_class ?? attributes.device_class ?? null,
|
|
1920
|
-
unitOfMeasurement: registry?.unit_of_measurement ?? attributes.unit_of_measurement ?? null,
|
|
1921
|
-
supportedFeatures: registry?.supported_features ?? attributes.supported_features
|
|
1922
|
-
};
|
|
1923
|
-
}
|
|
1924
|
-
/**
|
|
1925
|
-
* Get or build an EntityView
|
|
1926
|
-
*/
|
|
1927
|
-
function getEntityView(entityId) {
|
|
1928
|
-
const entity = state.entities[entityId];
|
|
1929
|
-
if (!entity) return;
|
|
1930
|
-
const registry = state.entityRegistry[entityId];
|
|
1931
|
-
return buildEntityView(entity, registry);
|
|
1932
|
-
}
|
|
1933
|
-
/**
|
|
1934
|
-
* Get multiple entity views
|
|
1935
|
-
*/
|
|
1936
|
-
function getEntityViews(entityIds) {
|
|
1937
|
-
const views = [];
|
|
1938
|
-
for (const id of entityIds) {
|
|
1939
|
-
const view = getEntityView(id);
|
|
1940
|
-
if (view) views.push(view);
|
|
1941
|
-
}
|
|
1942
|
-
return views;
|
|
1943
|
-
}
|
|
1944
|
-
/**
|
|
1945
|
-
* Get all entity views
|
|
1946
|
-
*/
|
|
1947
|
-
function getAllEntityViews() {
|
|
1948
|
-
return getEntityViews(Object.keys(state.entities));
|
|
1949
|
-
}
|
|
1950
|
-
/**
|
|
1951
|
-
* Shallow equality check for EntityView.
|
|
1952
|
-
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
1953
|
-
*/
|
|
1954
|
-
function entityViewEquals(a, b) {
|
|
1955
|
-
if (a === b) return true;
|
|
1956
|
-
if (!a || !b) return false;
|
|
1957
|
-
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;
|
|
1958
|
-
}
|
|
1959
|
-
//#endregion
|
|
1960
|
-
//#region src/entities/area-views.ts
|
|
1961
|
-
/**
|
|
1962
|
-
* Area View Builder
|
|
1963
|
-
*
|
|
1964
|
-
* Constructs unified AreaView objects by combining registry data with computed
|
|
1965
|
-
* entities and devices.
|
|
1966
|
-
*
|
|
1967
|
-
* Membership (which entities/devices belong to an area) is registry-driven and
|
|
1968
|
-
* cheap; live entity *views* read entity state and are therefore exposed as a
|
|
1969
|
-
* lazy `entities` getter. Callers that only need names, ids, or counts never
|
|
1970
|
-
* touch entity state, so building the whole-area list does not recompute on
|
|
1971
|
-
* every entity state tick.
|
|
1972
|
-
*
|
|
1973
|
-
* @packageDocumentation
|
|
1974
|
-
*/
|
|
1975
|
-
/** The area an entity belongs to: its own `area_id` wins; otherwise it inherits
|
|
1976
|
-
* the area of its device. Null when neither resolves. */
|
|
1977
|
-
function entityAreaId(entry) {
|
|
1978
|
-
if (entry.area_id) return entry.area_id;
|
|
1979
|
-
if (entry.device_id) return state.devices[entry.device_id]?.area_id ?? null;
|
|
1980
|
-
return null;
|
|
1981
|
-
}
|
|
1982
|
-
/** One pass over the device + entity registries → membership per area.
|
|
1983
|
-
* O(devices + entities) for the whole home, vs the O(areas × (devices +
|
|
1984
|
-
* entities)) of resolving each area independently. */
|
|
1985
|
-
function computeAreaMembership() {
|
|
1986
|
-
const byArea = /* @__PURE__ */ new Map();
|
|
1987
|
-
const ensure = (areaId) => {
|
|
1988
|
-
let m = byArea.get(areaId);
|
|
1989
|
-
if (!m) {
|
|
1990
|
-
m = {
|
|
1991
|
-
devices: [],
|
|
1992
|
-
entityIds: []
|
|
1993
|
-
};
|
|
1994
|
-
byArea.set(areaId, m);
|
|
1995
|
-
}
|
|
1996
|
-
return m;
|
|
1997
|
-
};
|
|
1998
|
-
for (const device of Object.values(state.devices)) if (device.area_id) ensure(device.area_id).devices.push(device);
|
|
1999
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) {
|
|
2000
|
-
const areaId = entityAreaId(entry);
|
|
2001
|
-
if (areaId) ensure(areaId).entityIds.push(entityId);
|
|
2002
|
-
}
|
|
2003
|
-
return byArea;
|
|
2004
|
-
}
|
|
2005
|
-
/** Membership for a single area, without indexing every other area. */
|
|
2006
|
-
function membershipFor(areaId) {
|
|
2007
|
-
const devices = [];
|
|
2008
|
-
for (const device of Object.values(state.devices)) if (device.area_id === areaId) devices.push(device);
|
|
2009
|
-
const entityIds = [];
|
|
2010
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entityAreaId(entry) === areaId) entityIds.push(entityId);
|
|
2011
|
-
return {
|
|
2012
|
-
devices,
|
|
2013
|
-
entityIds
|
|
2014
|
-
};
|
|
2015
|
-
}
|
|
2016
|
-
/**
|
|
2017
|
-
* Build a DeviceView from registry data
|
|
2018
|
-
*/
|
|
2019
|
-
function buildDeviceView(device) {
|
|
2020
|
-
return {
|
|
2021
|
-
id: device.id,
|
|
2022
|
-
name: device.name ?? "",
|
|
2023
|
-
nameByUser: device.name_by_user ?? null,
|
|
2024
|
-
manufacturer: device.manufacturer ?? null,
|
|
2025
|
-
model: device.model ?? null,
|
|
2026
|
-
swVersion: device.sw_version ?? null,
|
|
2027
|
-
areaId: device.area_id ?? null,
|
|
2028
|
-
configEntries: device.config_entries ?? [],
|
|
2029
|
-
connections: device.connections ?? [],
|
|
2030
|
-
identifiers: device.identifiers ?? [],
|
|
2031
|
-
disabledBy: device.disabled_by ?? null
|
|
2032
|
-
};
|
|
2033
|
-
}
|
|
2034
|
-
/** Assemble an AreaView from pre-resolved membership. `entities` is a lazy
|
|
2035
|
-
* getter: materializing live EntityViews reads entity state, so deferring it
|
|
2036
|
-
* keeps callers that only need names/ids/counts off the per-state-tick
|
|
2037
|
-
* recompute path. The getter stays reactive — read inside a tracking scope it
|
|
2038
|
-
* tracks those entities' state and updates like any other store read. */
|
|
2039
|
-
function assembleAreaView(areaId, membership) {
|
|
2040
|
-
const area = state.areas[areaId];
|
|
2041
|
-
if (!area) throw new Error(`Area ${areaId} not found`);
|
|
2042
|
-
const devices = membership.devices.map((device) => buildDeviceView(device));
|
|
2043
|
-
const entityIds = membership.entityIds;
|
|
2044
|
-
return {
|
|
2045
|
-
id: areaId,
|
|
2046
|
-
name: area.name,
|
|
2047
|
-
normalizedName: area.normalized_name ?? area.name.toLowerCase(),
|
|
2048
|
-
aliases: Array.isArray(area.aliases) ? area.aliases : [],
|
|
2049
|
-
floorId: area.floor_id ?? null,
|
|
2050
|
-
icon: area.icon ?? null,
|
|
2051
|
-
picture: area.picture ?? null,
|
|
2052
|
-
labels: Array.isArray(area.labels) ? area.labels : [],
|
|
2053
|
-
temperatureEntityId: area.temperature_entity_id ?? null,
|
|
2054
|
-
humidityEntityId: area.humidity_entity_id ?? null,
|
|
2055
|
-
createdAt: area.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2056
|
-
modifiedAt: area.modified_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
2057
|
-
devices,
|
|
2058
|
-
entityIds,
|
|
2059
|
-
deviceIds: devices.map((d) => d.id),
|
|
2060
|
-
get entities() {
|
|
2061
|
-
return entityIds.length > 0 ? getEntityViews(entityIds) : [];
|
|
2062
|
-
}
|
|
2063
|
-
};
|
|
2064
|
-
}
|
|
2065
|
-
/**
|
|
2066
|
-
* Build an AreaView for a single area from registry data.
|
|
2067
|
-
*/
|
|
2068
|
-
function buildAreaView(areaId) {
|
|
2069
|
-
return assembleAreaView(areaId, membershipFor(areaId));
|
|
2070
|
-
}
|
|
2071
|
-
/**
|
|
2072
|
-
* Get all area views. Membership is resolved in a single registry pass; entity
|
|
2073
|
-
* views stay lazy per area (see `assembleAreaView`).
|
|
2074
|
-
*/
|
|
2075
|
-
function getAreaViews() {
|
|
2076
|
-
const membership = computeAreaMembership();
|
|
2077
|
-
return Object.keys(state.areas).map((areaId) => assembleAreaView(areaId, membership.get(areaId) ?? {
|
|
2078
|
-
devices: [],
|
|
2079
|
-
entityIds: []
|
|
2080
|
-
}));
|
|
2081
|
-
}
|
|
2082
|
-
/**
|
|
2083
|
-
* Get area view
|
|
1958
|
+
* Execute multiple entity updates in parallel
|
|
2084
1959
|
*/
|
|
2085
|
-
function
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
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
|
+
});
|
|
2090
2022
|
}
|
|
2023
|
+
return buckets;
|
|
2091
2024
|
}
|
|
2092
2025
|
//#endregion
|
|
2093
2026
|
//#region src/history/statistics.ts
|
|
@@ -2142,7 +2075,7 @@ async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
|
|
|
2142
2075
|
const result = {};
|
|
2143
2076
|
const statPeriod = period === "hour" || period === "day" ? period : "day";
|
|
2144
2077
|
const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
|
|
2145
|
-
for (const id of statisticIds) result[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) :
|
|
2078
|
+
for (const id of statisticIds) result[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : numericSensorStatistics(id, startTime.getTime(), endMs, statPeriod);
|
|
2146
2079
|
return result;
|
|
2147
2080
|
}
|
|
2148
2081
|
const params = {
|
|
@@ -2160,4 +2093,356 @@ async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
|
|
|
2160
2093
|
return result;
|
|
2161
2094
|
}
|
|
2162
2095
|
//#endregion
|
|
2163
|
-
|
|
2096
|
+
//#region src/weather/fetch.ts
|
|
2097
|
+
/**
|
|
2098
|
+
* Weather Forecast Fetching
|
|
2099
|
+
*
|
|
2100
|
+
* @packageDocumentation
|
|
2101
|
+
*/
|
|
2102
|
+
const DEMO_RAIN_CHANCE = {
|
|
2103
|
+
sunny: 0,
|
|
2104
|
+
"clear-night": 0,
|
|
2105
|
+
partlycloudy: 10,
|
|
2106
|
+
cloudy: 20,
|
|
2107
|
+
windy: 10,
|
|
2108
|
+
fog: 10,
|
|
2109
|
+
rainy: 80,
|
|
2110
|
+
pouring: 95,
|
|
2111
|
+
lightning: 60,
|
|
2112
|
+
"lightning-rainy": 85,
|
|
2113
|
+
snowy: 70,
|
|
2114
|
+
"snowy-rainy": 75,
|
|
2115
|
+
hail: 70,
|
|
2116
|
+
exceptional: 40
|
|
2117
|
+
};
|
|
2118
|
+
/**
|
|
2119
|
+
* In demo mode, derive forecasts from the entity's `forecast` attribute.
|
|
2120
|
+
* Hourly and daily are synthesised from the same source (daily entries
|
|
2121
|
+
* expanded into 24 hourly steps).
|
|
2122
|
+
*/
|
|
2123
|
+
function buildDemoForecast(entityId, type) {
|
|
2124
|
+
const entity = state.entities[entityId];
|
|
2125
|
+
const raw = entity?.attributes?.forecast ?? [];
|
|
2126
|
+
if (raw.length === 0) return [];
|
|
2127
|
+
if (type === "daily" || type === "twice_daily") return raw.map((d) => {
|
|
2128
|
+
const chance = DEMO_RAIN_CHANCE[d.condition ?? ""] ?? 10;
|
|
2129
|
+
const templow = d.templow;
|
|
2130
|
+
return {
|
|
2131
|
+
...d,
|
|
2132
|
+
temp_high: d.temp_high ?? d.temperature,
|
|
2133
|
+
temp_low: d.temp_low ?? templow,
|
|
2134
|
+
precipitation_probability: d.precipitation_probability ?? chance
|
|
2135
|
+
};
|
|
2136
|
+
});
|
|
2137
|
+
const start = /* @__PURE__ */ new Date();
|
|
2138
|
+
start.setMinutes(0, 0, 0);
|
|
2139
|
+
const today = raw[0];
|
|
2140
|
+
const tomorrow = raw[1] ?? today;
|
|
2141
|
+
const tHi = today?.temperature ?? 18;
|
|
2142
|
+
const tLo = today?.templow ?? tHi - 5;
|
|
2143
|
+
const tNext = tomorrow?.temperature ?? tHi;
|
|
2144
|
+
const out = [];
|
|
2145
|
+
for (let i = 0; i < 24; i++) {
|
|
2146
|
+
const dt = new Date(start.getTime() + i * 60 * 60 * 1e3);
|
|
2147
|
+
const hour = dt.getHours();
|
|
2148
|
+
const dayMix = i < 12 ? 0 : (i - 12) / 12;
|
|
2149
|
+
const baseHi = tHi * (1 - dayMix) + tNext * dayMix;
|
|
2150
|
+
const phase = (hour - 6) / 24 * Math.PI * 2;
|
|
2151
|
+
const swing = (baseHi - tLo) / 2;
|
|
2152
|
+
const mid = (baseHi + tLo) / 2;
|
|
2153
|
+
const source = i < 8 ? today : tomorrow;
|
|
2154
|
+
const night = hour < 6 || hour >= 21;
|
|
2155
|
+
const condition = source?.condition ?? entity?.state ?? "cloudy";
|
|
2156
|
+
out.push({
|
|
2157
|
+
datetime: dt.toISOString(),
|
|
2158
|
+
temperature: Math.round((mid + Math.sin(phase) * swing) * 10) / 10,
|
|
2159
|
+
condition: night && condition === "sunny" ? "clear-night" : condition,
|
|
2160
|
+
precipitation_probability: DEMO_RAIN_CHANCE[condition] ?? 10
|
|
2161
|
+
});
|
|
2162
|
+
}
|
|
2163
|
+
return out;
|
|
2164
|
+
}
|
|
2165
|
+
/**
|
|
2166
|
+
* Fetch forecast for a single entity and type
|
|
2167
|
+
*/
|
|
2168
|
+
async function fetchForecast(entityId, type) {
|
|
2169
|
+
if (isDemoMode()) return buildDemoForecast(entityId, type);
|
|
2170
|
+
const conn = privilegedConn();
|
|
2171
|
+
if (!conn) throw new Error("Not connected");
|
|
2172
|
+
return new Promise((resolve, reject) => {
|
|
2173
|
+
let resolved = false;
|
|
2174
|
+
let unsubscribe = null;
|
|
2175
|
+
const timeout = setTimeout(() => {
|
|
2176
|
+
if (!resolved && unsubscribe) {
|
|
2177
|
+
resolved = true;
|
|
2178
|
+
unsubscribe().then(() => reject(/* @__PURE__ */ new Error("Forecast subscription timeout"))).catch(() => reject(/* @__PURE__ */ new Error("Forecast subscription timeout")));
|
|
2179
|
+
}
|
|
2180
|
+
}, 1e4);
|
|
2181
|
+
conn.subscribeMessage((event) => {
|
|
2182
|
+
if (resolved) return;
|
|
2183
|
+
if (event && typeof event === "object" && "forecast" in event) {
|
|
2184
|
+
resolved = true;
|
|
2185
|
+
clearTimeout(timeout);
|
|
2186
|
+
const forecast = event.forecast;
|
|
2187
|
+
const forecastArray = Array.isArray(forecast) ? forecast : [];
|
|
2188
|
+
if (unsubscribe) unsubscribe().then(() => resolve(forecastArray)).catch(() => resolve(forecastArray));
|
|
2189
|
+
else resolve(forecastArray);
|
|
2190
|
+
}
|
|
2191
|
+
}, {
|
|
2192
|
+
type: "weather/subscribe_forecast",
|
|
2193
|
+
entity_id: entityId,
|
|
2194
|
+
forecast_type: type === "twice_daily" ? "twice_daily" : type
|
|
2195
|
+
}).then((unsubFn) => {
|
|
2196
|
+
unsubscribe = unsubFn;
|
|
2197
|
+
}).catch((error) => {
|
|
2198
|
+
resolved = true;
|
|
2199
|
+
clearTimeout(timeout);
|
|
2200
|
+
reject(error);
|
|
2201
|
+
});
|
|
2202
|
+
});
|
|
2203
|
+
}
|
|
2204
|
+
/**
|
|
2205
|
+
* Fetch forecasts for a single entity and multiple types
|
|
2206
|
+
*/
|
|
2207
|
+
async function fetchForecasts(entityId, types) {
|
|
2208
|
+
const forecasts = {};
|
|
2209
|
+
const loading = {};
|
|
2210
|
+
const errors = {};
|
|
2211
|
+
const lastFetched = {};
|
|
2212
|
+
const fetchPromises = types.map(async (type) => {
|
|
2213
|
+
loading[type] = true;
|
|
2214
|
+
errors[type] = null;
|
|
2215
|
+
try {
|
|
2216
|
+
const forecast = await fetchForecast(entityId, type);
|
|
2217
|
+
forecasts[type] = forecast;
|
|
2218
|
+
lastFetched[type] = Date.now();
|
|
2219
|
+
loading[type] = false;
|
|
2220
|
+
} catch (error) {
|
|
2221
|
+
errors[type] = error instanceof Error ? error : new Error(String(error));
|
|
2222
|
+
forecasts[type] = [];
|
|
2223
|
+
loading[type] = false;
|
|
2224
|
+
lastFetched[type] = null;
|
|
2225
|
+
}
|
|
2226
|
+
});
|
|
2227
|
+
await Promise.all(fetchPromises);
|
|
2228
|
+
return {
|
|
2229
|
+
entityId,
|
|
2230
|
+
forecasts,
|
|
2231
|
+
loading,
|
|
2232
|
+
errors,
|
|
2233
|
+
lastFetched
|
|
2234
|
+
};
|
|
2235
|
+
}
|
|
2236
|
+
/**
|
|
2237
|
+
* Fetch forecast data with error handling
|
|
2238
|
+
*/
|
|
2239
|
+
async function fetchForecastData(entityId, type) {
|
|
2240
|
+
try {
|
|
2241
|
+
return {
|
|
2242
|
+
entityId,
|
|
2243
|
+
type,
|
|
2244
|
+
forecast: await fetchForecast(entityId, type),
|
|
2245
|
+
loading: false,
|
|
2246
|
+
error: null,
|
|
2247
|
+
lastFetched: Date.now()
|
|
2248
|
+
};
|
|
2249
|
+
} catch (error) {
|
|
2250
|
+
return {
|
|
2251
|
+
entityId,
|
|
2252
|
+
type,
|
|
2253
|
+
forecast: [],
|
|
2254
|
+
loading: false,
|
|
2255
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
2256
|
+
lastFetched: null
|
|
2257
|
+
};
|
|
2258
|
+
}
|
|
2259
|
+
}
|
|
2260
|
+
//#endregion
|
|
2261
|
+
//#region src/calendar/track.ts
|
|
2262
|
+
/**
|
|
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.
|
|
2270
|
+
*/
|
|
2271
|
+
const DEFAULT_DAYS_BEHIND = 7;
|
|
2272
|
+
const DEFAULT_DAYS_AHEAD = 35;
|
|
2273
|
+
/** Debounce for untrack, so dashboard switches don't churn subscriptions. */
|
|
2274
|
+
const UNTRACK_FLUSH_DELAY_MS = 5e3;
|
|
2275
|
+
const DAY_MS = 864e5;
|
|
2276
|
+
const trackers = /* @__PURE__ */ new Map();
|
|
2277
|
+
const active = /* @__PURE__ */ new Map();
|
|
2278
|
+
/** Per-entity reconcile chain; serializes subscribe/unsubscribe per calendar. */
|
|
2279
|
+
const chains = /* @__PURE__ */ new Map();
|
|
2280
|
+
let untrackTimer = null;
|
|
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
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
/**
|
|
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).
|
|
2303
|
+
*/
|
|
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;
|
|
2315
|
+
return () => {
|
|
2316
|
+
if (untracked) return;
|
|
2317
|
+
untracked = true;
|
|
2318
|
+
untrackCalendarEvents(entityId, tracker);
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
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);
|
|
2332
|
+
}
|
|
2333
|
+
/**
|
|
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).
|
|
2339
|
+
*/
|
|
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();
|
|
2347
|
+
}
|
|
2348
|
+
function scheduleFlush() {
|
|
2349
|
+
queueMicrotask(() => {
|
|
2350
|
+
flushAll();
|
|
2351
|
+
});
|
|
2352
|
+
}
|
|
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();
|
|
2357
|
+
}
|
|
2358
|
+
function reconcileEntity(entityId) {
|
|
2359
|
+
const next = (chains.get(entityId) ?? Promise.resolve()).then(() => runReconcile(entityId));
|
|
2360
|
+
chains.set(entityId, next.catch(() => {}));
|
|
2361
|
+
return next;
|
|
2362
|
+
}
|
|
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
|
+
}));
|
|
2377
|
+
return;
|
|
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);
|
|
2400
|
+
try {
|
|
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",
|
|
2411
|
+
entity_id: entityId,
|
|
2412
|
+
start: win.start,
|
|
2413
|
+
end: win.end
|
|
2414
|
+
});
|
|
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
|
+
}));
|
|
2423
|
+
}
|
|
2424
|
+
if (old) try {
|
|
2425
|
+
await old();
|
|
2426
|
+
} catch {}
|
|
2427
|
+
}
|
|
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());
|
|
2440
|
+
}
|
|
2441
|
+
function stopRollTimer() {
|
|
2442
|
+
if (rollTimer) {
|
|
2443
|
+
clearTimeout(rollTimer);
|
|
2444
|
+
rollTimer = null;
|
|
2445
|
+
}
|
|
2446
|
+
}
|
|
2447
|
+
//#endregion
|
|
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 };
|