@glasshome/sync-layer 0.9.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/demo/ids.d.ts +5 -0
- package/dist/demo/ids.js +361 -0
- package/dist/index.d.ts +32 -52
- package/dist/index.js +101 -73
- package/dist/solid/index.js +1 -1
- package/dist/track-BAfgOjMk.js +4957 -0
- package/package.json +7 -1
- package/dist/track-IkwP6XmO.js +0 -2448
package/dist/track-IkwP6XmO.js
DELETED
|
@@ -1,2448 +0,0 @@
|
|
|
1
|
-
import { createStore, produce, reconcile } from "solid-js/store";
|
|
2
|
-
//#region src/core/store.ts
|
|
3
|
-
const initialState = {
|
|
4
|
-
connectionState: "disconnected",
|
|
5
|
-
connectionError: null,
|
|
6
|
-
hassUrl: null,
|
|
7
|
-
mediaProxyBase: null,
|
|
8
|
-
config: null,
|
|
9
|
-
entities: {},
|
|
10
|
-
entityRegistry: {},
|
|
11
|
-
devices: {},
|
|
12
|
-
areas: {},
|
|
13
|
-
floors: {},
|
|
14
|
-
labels: {},
|
|
15
|
-
statistics: {},
|
|
16
|
-
statisticsMetadata: {},
|
|
17
|
-
history: {},
|
|
18
|
-
forecasts: {},
|
|
19
|
-
calendars: {},
|
|
20
|
-
streams: {}
|
|
21
|
-
};
|
|
22
|
-
if (globalThis.__GH_SYNC_LAYER_STORE__) console.error("[@glasshome/sync-layer] DUPLICATE store module instantiated. This means @glasshome/sync-layer was bundled into >1 chunk. Reads and writes will hit different singletons. Check vendor build externals and relative-vs-bare imports across entry points.");
|
|
23
|
-
globalThis.__GH_SYNC_LAYER_STORE__ = true;
|
|
24
|
-
const storeTuple = createStore(initialState);
|
|
25
|
-
const state = storeTuple[0];
|
|
26
|
-
const setState = storeTuple[1];
|
|
27
|
-
//#endregion
|
|
28
|
-
//#region src/core/privileged-conn.ts
|
|
29
|
-
let privileged = null;
|
|
30
|
-
/** Read the live connection. Null before connect and after disconnect. */
|
|
31
|
-
function privilegedConn() {
|
|
32
|
-
return privileged;
|
|
33
|
-
}
|
|
34
|
-
/** Set by the connection layer on connect/disconnect. Host-side callers only. */
|
|
35
|
-
function setPrivilegedConn(conn) {
|
|
36
|
-
privileged = conn;
|
|
37
|
-
}
|
|
38
|
-
//#endregion
|
|
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;
|
|
60
|
-
/**
|
|
61
|
-
* Register an entity for live updates. Returns an unregister function.
|
|
62
|
-
* Call from useEntity/useEntities hooks via onCleanup.
|
|
63
|
-
*/
|
|
64
|
-
function registerEntity(entityId) {
|
|
65
|
-
refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
|
|
66
|
-
scheduleFlush$1();
|
|
67
|
-
return () => unregisterEntity(entityId);
|
|
68
|
-
}
|
|
69
|
-
/**
|
|
70
|
-
* Set the connection reference used for resubscribing.
|
|
71
|
-
*/
|
|
72
|
-
function setManagerConnection(connection) {
|
|
73
|
-
conn = connection;
|
|
74
|
-
if (refCounts.size > 0) scheduleFlush$1();
|
|
75
|
-
}
|
|
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;
|
|
82
|
-
}
|
|
83
|
-
/**
|
|
84
|
-
* Force a resubscribe with the current active set.
|
|
85
|
-
* Used on reconnect.
|
|
86
|
-
*/
|
|
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();
|
|
95
|
-
}
|
|
96
|
-
function scheduleFlush$1() {
|
|
97
|
-
if (flushScheduled) return;
|
|
98
|
-
flushScheduled = true;
|
|
99
|
-
queueMicrotask(() => {
|
|
100
|
-
flushScheduled = false;
|
|
101
|
-
flush().catch(logFlushError);
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
function scheduleDelayedFlush() {
|
|
105
|
-
if (unregisterTimeout) return;
|
|
106
|
-
unregisterTimeout = setTimeout(() => {
|
|
107
|
-
unregisterTimeout = null;
|
|
108
|
-
flush().catch(logFlushError);
|
|
109
|
-
}, UNREGISTER_DELAY_MS);
|
|
110
|
-
}
|
|
111
|
-
function logFlushError(error) {
|
|
112
|
-
console.error("[@glasshome/sync-layer] entity resubscribe failed:", error);
|
|
113
|
-
}
|
|
114
|
-
async function flush() {
|
|
115
|
-
if (!conn || !onResubscribe) return;
|
|
116
|
-
if (flushInFlight) return;
|
|
117
|
-
flushInFlight = true;
|
|
118
|
-
try {
|
|
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;
|
|
130
|
-
}
|
|
131
|
-
}
|
|
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;
|
|
136
|
-
}
|
|
137
|
-
//#endregion
|
|
138
|
-
//#region src/core/types.ts
|
|
139
|
-
/**
|
|
140
|
-
* Extract domain from entity ID
|
|
141
|
-
*/
|
|
142
|
-
function extractDomain(entityId) {
|
|
143
|
-
return entityId.split(".")[0] ?? "";
|
|
144
|
-
}
|
|
145
|
-
//#endregion
|
|
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,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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));
|
|
433
|
-
const phase1 = rand() * Math.PI * 2;
|
|
434
|
-
const phase2 = rand() * Math.PI * 2;
|
|
435
|
-
const phase3 = rand() * Math.PI * 2;
|
|
436
|
-
const m = lt.minuteOfDay;
|
|
437
|
-
const f = .85 + (.16 * Math.sin(m / 37 * Math.PI * 2 + phase1) + .14 * Math.sin(m / 73 * Math.PI * 2 + phase2) + .05 * Math.sin(m / 11 * Math.PI * 2 + phase3));
|
|
438
|
-
return Math.max(.55, Math.min(1, f));
|
|
439
|
-
}
|
|
440
|
-
function solarPower(lt) {
|
|
441
|
-
if (lt.hourFrac <= SUNRISE_HOUR || lt.hourFrac >= SUNSET_HOUR) return 0;
|
|
442
|
-
const frac = (lt.hourFrac - SUNRISE_HOUR) / 14;
|
|
443
|
-
const shape = Math.sin(frac * Math.PI) ** 1.3;
|
|
444
|
-
return SOLAR_PEAK_W * shape * cloudFactor(lt);
|
|
445
|
-
}
|
|
446
|
-
/** Coarse solar elevation in degrees for sun.sun attributes. */
|
|
447
|
-
function sunElevation(lt) {
|
|
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
|
-
}
|
|
452
|
-
const frac = (lt.hourFrac - SUNRISE_HOUR) / 14;
|
|
453
|
-
return 60 * Math.sin(frac * Math.PI);
|
|
454
|
-
}
|
|
455
|
-
function fridgePower(lt) {
|
|
456
|
-
const phase = mulberry32(hashInt(lt.dayKey * 40503 + 7))() * FRIDGE_CYCLE_MIN;
|
|
457
|
-
return (lt.minuteOfDay + phase) % FRIDGE_CYCLE_MIN < FRIDGE_ON_MIN ? FRIDGE_W : 0;
|
|
458
|
-
}
|
|
459
|
-
/** A square wave: `power` while inside [start, start+durationMin). */
|
|
460
|
-
function windowPower(minuteOfDay, startMin, durationMin, power) {
|
|
461
|
-
return minuteOfDay >= startMin && minuteOfDay < startMin + durationMin ? power : 0;
|
|
462
|
-
}
|
|
463
|
-
function kettlePower(lt) {
|
|
464
|
-
return windowPower(lt.minuteOfDay, 430, 4, 2e3) + windowPower(lt.minuteOfDay, 1e3, 4, 2e3);
|
|
465
|
-
}
|
|
466
|
-
function ovenPower(lt) {
|
|
467
|
-
const start = 1125;
|
|
468
|
-
if (lt.minuteOfDay < start || lt.minuteOfDay >= 1165) return 0;
|
|
469
|
-
return (lt.minuteOfDay - start) % 6.5 < 4 ? 2200 : 0;
|
|
470
|
-
}
|
|
471
|
-
function dishwasherPower(lt) {
|
|
472
|
-
const start = 1270;
|
|
473
|
-
if (lt.minuteOfDay < start || lt.minuteOfDay >= 1335) return 0;
|
|
474
|
-
const into = lt.minuteOfDay - start;
|
|
475
|
-
if (into < 12) return 1900;
|
|
476
|
-
if (into >= 57) return 1900;
|
|
477
|
-
return 120;
|
|
478
|
-
}
|
|
479
|
-
function washerPower(lt) {
|
|
480
|
-
if (![
|
|
481
|
-
1,
|
|
482
|
-
4,
|
|
483
|
-
6
|
|
484
|
-
].includes(lt.dayOfWeek)) return 0;
|
|
485
|
-
const start = 630;
|
|
486
|
-
if (lt.minuteOfDay < start || lt.minuteOfDay >= 680) return 0;
|
|
487
|
-
return lt.minuteOfDay - start < 14 ? 2100 : 250;
|
|
488
|
-
}
|
|
489
|
-
function evChargerPower(lt) {
|
|
490
|
-
if (![
|
|
491
|
-
2,
|
|
492
|
-
4,
|
|
493
|
-
0
|
|
494
|
-
].includes(lt.dayOfWeek)) return 0;
|
|
495
|
-
const start = 1100;
|
|
496
|
-
if (lt.minuteOfDay < start || lt.minuteOfDay >= 1300) return 0;
|
|
497
|
-
const into = lt.minuteOfDay - start;
|
|
498
|
-
return 7200 * (into < 2 ? into / 2 : 1);
|
|
499
|
-
}
|
|
500
|
-
const HEAT_PUMP_PEAK_W = 3200;
|
|
501
|
-
/**
|
|
502
|
-
* A smooth heat-pump load: a slow morning/evening envelope carrying a
|
|
503
|
-
* few-minute shimmer. Its peaks push the home deficit past the battery's power
|
|
504
|
-
* cap, so grid import (and the running cost) visibly rise and fall on live
|
|
505
|
-
* ticks instead of sitting at zero. Gated out of the deep-night window so the
|
|
506
|
-
* quiet 02:00-03:00 baseline is unchanged.
|
|
507
|
-
*/
|
|
508
|
-
function heatPumpPower(lt) {
|
|
509
|
-
const h = lt.hourFrac;
|
|
510
|
-
let envelope = 0;
|
|
511
|
-
if (h >= 6 && h <= 9) envelope = Math.sin((h - 6) / 3 * Math.PI);
|
|
512
|
-
else if (h >= 16 && h <= 23) envelope = Math.sin((h - 16) / 7 * Math.PI);
|
|
513
|
-
else return 0;
|
|
514
|
-
const shimmer = .7 + .3 * Math.sin(lt.minuteOfDay * Math.PI * 2);
|
|
515
|
-
return HEAT_PUMP_PEAK_W * envelope * shimmer;
|
|
516
|
-
}
|
|
517
|
-
/** Smooth occupancy envelope: extra load mornings and evenings. */
|
|
518
|
-
function occupancyEnvelope(lt) {
|
|
519
|
-
const h = lt.hourFrac;
|
|
520
|
-
let e = 0;
|
|
521
|
-
if (h >= 7 && h <= 9) e += 80 + 270 * Math.sin((h - 7) / 2 * Math.PI);
|
|
522
|
-
if (h >= 17.5 && h <= 23) e += 80 + 270 * Math.sin((h - 17.5) / 5.5 * Math.PI);
|
|
523
|
-
return e;
|
|
524
|
-
}
|
|
525
|
-
/** Low-amplitude smooth noise multiplier, ±4%, seeded per day. */
|
|
526
|
-
function homeNoise(lt) {
|
|
527
|
-
const phase = mulberry32(hashInt(lt.dayKey * 22699 + 13))() * Math.PI * 2;
|
|
528
|
-
return 1 + .04 * Math.sin(lt.minuteOfDay / 23 * Math.PI * 2 + phase);
|
|
529
|
-
}
|
|
530
|
-
const socCache = /* @__PURE__ */ new Map();
|
|
531
|
-
/** Net surplus (solar - home) at a given local-time sample, in watts. */
|
|
532
|
-
function netSurplusAt(timestampMs) {
|
|
533
|
-
const lt = localTime(timestampMs);
|
|
534
|
-
return solarPower(lt) - homePower(lt);
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Battery SOC (%) at every {@link SOC_STEP_MIN}-minute step from local
|
|
538
|
-
* midnight. Returns step `i` = SOC after integrating `i` steps. Index 0 is
|
|
539
|
-
* the midnight SOC. Memoized per local day (pure computation).
|
|
540
|
-
*/
|
|
541
|
-
function socPrefix(dayKey, midnightMs) {
|
|
542
|
-
const cached = socCache.get(dayKey);
|
|
543
|
-
if (cached) return cached;
|
|
544
|
-
const steps = Math.ceil(1440 / SOC_STEP_MIN);
|
|
545
|
-
const arr = /* @__PURE__ */ new Float64Array(289);
|
|
546
|
-
arr[0] = MIDNIGHT_SOC;
|
|
547
|
-
const stepMs = SOC_STEP_MIN * 6e4;
|
|
548
|
-
const stepHours = SOC_STEP_MIN / 60;
|
|
549
|
-
for (let i = 0; i < steps; i++) {
|
|
550
|
-
const soc = arr[i] ?? MIDNIGHT_SOC;
|
|
551
|
-
const surplus = netSurplusAt(midnightMs + i * stepMs);
|
|
552
|
-
let chargeW = 0;
|
|
553
|
-
let dischargeW = 0;
|
|
554
|
-
if (surplus > 0) {
|
|
555
|
-
const headroomW = (BATTERY_MAX_SOC - soc) / 100 * BATTERY_CAPACITY_WH / stepHours;
|
|
556
|
-
chargeW = Math.min(surplus, BATTERY_POWER_CAP_W, Math.max(0, headroomW));
|
|
557
|
-
} else if (surplus < 0) {
|
|
558
|
-
const availW = (soc - BATTERY_MIN_SOC) / 100 * BATTERY_CAPACITY_WH / stepHours;
|
|
559
|
-
dischargeW = Math.min(-surplus, BATTERY_POWER_CAP_W, Math.max(0, availW));
|
|
560
|
-
}
|
|
561
|
-
const nextSoc = soc + (chargeW - dischargeW) * stepHours / BATTERY_CAPACITY_WH * 100;
|
|
562
|
-
arr[i + 1] = Math.max(BATTERY_MIN_SOC, Math.min(BATTERY_MAX_SOC, nextSoc));
|
|
563
|
-
}
|
|
564
|
-
socCache.set(dayKey, arr);
|
|
565
|
-
return arr;
|
|
566
|
-
}
|
|
567
|
-
/** Battery SOC (%) at an exact timestamp, linearly interpolated between steps. */
|
|
568
|
-
function batterySoc(lt, midnightMs) {
|
|
569
|
-
const prefix = socPrefix(lt.dayKey, midnightMs);
|
|
570
|
-
const stepFloat = lt.minuteOfDay / SOC_STEP_MIN;
|
|
571
|
-
const i = Math.floor(stepFloat);
|
|
572
|
-
const frac = stepFloat - i;
|
|
573
|
-
const a = prefix[i] ?? MIDNIGHT_SOC;
|
|
574
|
-
return a + ((prefix[i + 1] ?? a) - a) * frac;
|
|
575
|
-
}
|
|
576
|
-
function homePower(lt) {
|
|
577
|
-
return (ALWAYS_ON_W + fridgePower(lt) + kettlePower(lt) + ovenPower(lt) + dishwasherPower(lt) + washerPower(lt) + evChargerPower(lt) + heatPumpPower(lt) + occupancyEnvelope(lt)) * homeNoise(lt);
|
|
578
|
-
}
|
|
579
|
-
/**
|
|
580
|
-
* Compute every instantaneous energy value at a timestamp.
|
|
581
|
-
*
|
|
582
|
-
* Deterministic: equal timestamps always yield deep-equal samples. Obeys the
|
|
583
|
-
* conservation invariant
|
|
584
|
-
* `solar + gridImport + batteryDischarge === home + gridExport + batteryCharge`
|
|
585
|
-
* to within rounding.
|
|
586
|
-
*/
|
|
587
|
-
function simulateEnergy(timestampMs) {
|
|
588
|
-
const lt = localTime(timestampMs);
|
|
589
|
-
const midnightMs = localMidnightMs(timestampMs, lt);
|
|
590
|
-
const solarW = solarPower(lt);
|
|
591
|
-
const fridgeW = fridgePower(lt);
|
|
592
|
-
const ovenW = ovenPower(lt);
|
|
593
|
-
const dishwasherW = dishwasherPower(lt);
|
|
594
|
-
const washerW = washerPower(lt);
|
|
595
|
-
const evChargerW = evChargerPower(lt);
|
|
596
|
-
const alwaysOnW = ALWAYS_ON_W;
|
|
597
|
-
const homeW = homePower(lt);
|
|
598
|
-
const soc = batterySoc(lt, midnightMs);
|
|
599
|
-
const surplus = solarW - homeW;
|
|
600
|
-
let batteryChargeW = 0;
|
|
601
|
-
let batteryDischargeW = 0;
|
|
602
|
-
if (surplus > 0) batteryChargeW = Math.min(surplus, soc < BATTERY_MAX_SOC ? BATTERY_POWER_CAP_W : 0);
|
|
603
|
-
else if (surplus < 0) {
|
|
604
|
-
const avail = soc > BATTERY_MIN_SOC ? BATTERY_POWER_CAP_W : 0;
|
|
605
|
-
batteryDischargeW = Math.min(-surplus, avail);
|
|
606
|
-
}
|
|
607
|
-
const remainingDeficit = homeW - solarW - batteryDischargeW + batteryChargeW;
|
|
608
|
-
const gridImportW = Math.max(0, remainingDeficit);
|
|
609
|
-
const gridExportW = Math.max(0, -remainingDeficit);
|
|
610
|
-
return {
|
|
611
|
-
solarW,
|
|
612
|
-
homeW,
|
|
613
|
-
batteryChargeW,
|
|
614
|
-
batteryDischargeW,
|
|
615
|
-
batterySocPct: soc,
|
|
616
|
-
gridImportW,
|
|
617
|
-
gridExportW,
|
|
618
|
-
fridgeW,
|
|
619
|
-
dishwasherW,
|
|
620
|
-
washerW,
|
|
621
|
-
ovenW,
|
|
622
|
-
evChargerW,
|
|
623
|
-
alwaysOnW
|
|
624
|
-
};
|
|
625
|
-
}
|
|
626
|
-
/** Whether the sun is above the horizon at a timestamp. */
|
|
627
|
-
function isSunUp(timestampMs) {
|
|
628
|
-
const lt = localTime(timestampMs);
|
|
629
|
-
return lt.hourFrac > SUNRISE_HOUR && lt.hourFrac < SUNSET_HOUR;
|
|
630
|
-
}
|
|
631
|
-
/** Next sunrise/sunset ISO strings relative to a timestamp. */
|
|
632
|
-
function sunEvents(timestampMs) {
|
|
633
|
-
const lt = localTime(timestampMs);
|
|
634
|
-
const midnightMs = localMidnightMs(timestampMs, lt);
|
|
635
|
-
const sunriseMs = midnightMs + SUNRISE_HOUR * 36e5;
|
|
636
|
-
const sunsetMs = midnightMs + SUNSET_HOUR * 36e5;
|
|
637
|
-
const nextRising = timestampMs < sunriseMs ? sunriseMs : sunriseMs + 864e5;
|
|
638
|
-
const nextSetting = timestampMs < sunsetMs ? sunsetMs : sunsetMs + 864e5;
|
|
639
|
-
return {
|
|
640
|
-
nextRising: new Date(nextRising).toISOString(),
|
|
641
|
-
nextSetting: new Date(nextSetting).toISOString(),
|
|
642
|
-
elevation: Math.round(sunElevation(lt) * 100) / 100,
|
|
643
|
-
rising: lt.hourFrac < 27 / 2 || lt.hourFrac >= 25.5
|
|
644
|
-
};
|
|
645
|
-
}
|
|
646
|
-
/** The energy sensor entity ids the simulation drives. */
|
|
647
|
-
const ENERGY_ENTITY_IDS = [
|
|
648
|
-
"sensor.solar_power",
|
|
649
|
-
"sensor.grid_import_power",
|
|
650
|
-
"sensor.grid_export_power",
|
|
651
|
-
"sensor.battery_charge_power",
|
|
652
|
-
"sensor.battery_discharge_power",
|
|
653
|
-
"sensor.battery_soc",
|
|
654
|
-
"sensor.home_power",
|
|
655
|
-
"sensor.fridge_power",
|
|
656
|
-
"sensor.dishwasher_power",
|
|
657
|
-
"sensor.washing_machine_power",
|
|
658
|
-
"sensor.oven_power",
|
|
659
|
-
"sensor.ev_charger_power",
|
|
660
|
-
"sensor.always_on_power"
|
|
661
|
-
];
|
|
662
|
-
/** Map an energy entity id to its numeric value from a sample (W, or % for SOC). */
|
|
663
|
-
function energyEntityValue(entityId, sample) {
|
|
664
|
-
switch (entityId) {
|
|
665
|
-
case "sensor.solar_power": return sample.solarW;
|
|
666
|
-
case "sensor.grid_import_power": return sample.gridImportW;
|
|
667
|
-
case "sensor.grid_export_power": return sample.gridExportW;
|
|
668
|
-
case "sensor.battery_charge_power": return sample.batteryChargeW;
|
|
669
|
-
case "sensor.battery_discharge_power": return sample.batteryDischargeW;
|
|
670
|
-
case "sensor.battery_soc": return sample.batterySocPct;
|
|
671
|
-
case "sensor.home_power": return sample.homeW;
|
|
672
|
-
case "sensor.fridge_power": return sample.fridgeW;
|
|
673
|
-
case "sensor.dishwasher_power": return sample.dishwasherW;
|
|
674
|
-
case "sensor.washing_machine_power": return sample.washerW;
|
|
675
|
-
case "sensor.oven_power": return sample.ovenW;
|
|
676
|
-
case "sensor.ev_charger_power": return sample.evChargerW;
|
|
677
|
-
case "sensor.always_on_power": return sample.alwaysOnW;
|
|
678
|
-
default: return;
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
/** Whether an entity id is one of the simulated energy sensors. */
|
|
682
|
-
function isEnergyEntity(entityId) {
|
|
683
|
-
return energyEntityValue(entityId, ZERO_SAMPLE) !== void 0;
|
|
684
|
-
}
|
|
685
|
-
const ZERO_SAMPLE = {
|
|
686
|
-
solarW: 0,
|
|
687
|
-
homeW: 0,
|
|
688
|
-
batteryChargeW: 0,
|
|
689
|
-
batteryDischargeW: 0,
|
|
690
|
-
batterySocPct: 0,
|
|
691
|
-
gridImportW: 0,
|
|
692
|
-
gridExportW: 0,
|
|
693
|
-
fridgeW: 0,
|
|
694
|
-
dishwasherW: 0,
|
|
695
|
-
washerW: 0,
|
|
696
|
-
ovenW: 0,
|
|
697
|
-
evChargerW: 0,
|
|
698
|
-
alwaysOnW: 0
|
|
699
|
-
};
|
|
700
|
-
/** Round to 1 decimal for display-friendly state strings (whole % for SOC). */
|
|
701
|
-
function formatEnergyState(entityId, value) {
|
|
702
|
-
if (entityId === "sensor.battery_soc") return String(Math.round(value));
|
|
703
|
-
return String(Math.round(value));
|
|
704
|
-
}
|
|
705
|
-
/**
|
|
706
|
-
* Synthesize history points for one energy entity by sampling the model across
|
|
707
|
-
* the window at a fixed cadence (default 1-minute). Returns HA-compressed
|
|
708
|
-
* `{ lu, s }` points with `lu` in Unix seconds.
|
|
709
|
-
*/
|
|
710
|
-
function synthesizeEnergyHistory(entityId, startMs, endMs, stepMs = 6e4) {
|
|
711
|
-
const points = [];
|
|
712
|
-
for (let t = startMs; t <= endMs; t += stepMs) {
|
|
713
|
-
const value = energyEntityValue(entityId, simulateEnergy(t));
|
|
714
|
-
if (value === void 0) continue;
|
|
715
|
-
points.push({
|
|
716
|
-
lu: Math.round(t / 1e3),
|
|
717
|
-
s: formatEnergyState(entityId, value)
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
return points;
|
|
721
|
-
}
|
|
722
|
-
/** Bucket length in ms for a statistics period. */
|
|
723
|
-
function bucketMs(period) {
|
|
724
|
-
return period === "hour" ? 36e5 : 864e5;
|
|
725
|
-
}
|
|
726
|
-
/**
|
|
727
|
-
* Synthesize statistics buckets for one energy entity across a window.
|
|
728
|
-
*
|
|
729
|
-
* `mean`/`min`/`max` come from 5-minute power samples in the bucket; `sum` is
|
|
730
|
-
* the energy in Wh (mean W times bucket hours). `sum` is cumulative across the
|
|
731
|
-
* window, matching HA's monotonically increasing meter semantics.
|
|
732
|
-
*/
|
|
733
|
-
function synthesizeEnergyStatistics(entityId, startMs, endMs, period) {
|
|
734
|
-
const span = bucketMs(period);
|
|
735
|
-
const sampleMs = 3e5;
|
|
736
|
-
const buckets = [];
|
|
737
|
-
let cumulativeWh = 0;
|
|
738
|
-
for (let bStart = startMs; bStart < endMs; bStart += span) {
|
|
739
|
-
const bEnd = bStart + span;
|
|
740
|
-
let sum = 0;
|
|
741
|
-
let count = 0;
|
|
742
|
-
let min = Number.POSITIVE_INFINITY;
|
|
743
|
-
let max = Number.NEGATIVE_INFINITY;
|
|
744
|
-
for (let t = bStart; t < bEnd; t += sampleMs) {
|
|
745
|
-
const value = energyEntityValue(entityId, simulateEnergy(t));
|
|
746
|
-
if (value === void 0) continue;
|
|
747
|
-
sum += value;
|
|
748
|
-
count++;
|
|
749
|
-
if (value < min) min = value;
|
|
750
|
-
if (value > max) max = value;
|
|
751
|
-
}
|
|
752
|
-
if (count === 0) continue;
|
|
753
|
-
const mean = sum / count;
|
|
754
|
-
const bucketHours = span / 36e5;
|
|
755
|
-
const energyWh = entityId === "sensor.battery_soc" ? 0 : mean * bucketHours;
|
|
756
|
-
cumulativeWh += energyWh;
|
|
757
|
-
buckets.push({
|
|
758
|
-
start: bStart,
|
|
759
|
-
end: bEnd,
|
|
760
|
-
mean,
|
|
761
|
-
min: min === Number.POSITIVE_INFINITY ? mean : min,
|
|
762
|
-
max: max === Number.NEGATIVE_INFINITY ? mean : max,
|
|
763
|
-
sum: cumulativeWh,
|
|
764
|
-
change: energyWh / 1e3
|
|
765
|
-
});
|
|
766
|
-
}
|
|
767
|
-
return buckets;
|
|
768
|
-
}
|
|
769
|
-
/** The demo energy dashboard preferences wiring the simulated sensors. */
|
|
770
|
-
function demoEnergyPreferences() {
|
|
771
|
-
return {
|
|
772
|
-
energy_sources: [
|
|
773
|
-
{
|
|
774
|
-
type: "solar",
|
|
775
|
-
stat_energy_from: "sensor.solar_power"
|
|
776
|
-
},
|
|
777
|
-
{
|
|
778
|
-
type: "grid",
|
|
779
|
-
flow_from: [{ stat_energy_from: "sensor.grid_import_power" }],
|
|
780
|
-
flow_to: [{ stat_energy_to: "sensor.grid_export_power" }]
|
|
781
|
-
},
|
|
782
|
-
{
|
|
783
|
-
type: "battery",
|
|
784
|
-
stat_energy_from: "sensor.battery_discharge_power",
|
|
785
|
-
stat_energy_to: "sensor.battery_charge_power"
|
|
786
|
-
}
|
|
787
|
-
],
|
|
788
|
-
device_consumption: [
|
|
789
|
-
{
|
|
790
|
-
stat_consumption: "sensor.fridge_power",
|
|
791
|
-
name: "Fridge"
|
|
792
|
-
},
|
|
793
|
-
{
|
|
794
|
-
stat_consumption: "sensor.dishwasher_power",
|
|
795
|
-
name: "Dishwasher"
|
|
796
|
-
},
|
|
797
|
-
{
|
|
798
|
-
stat_consumption: "sensor.washing_machine_power",
|
|
799
|
-
name: "Washing Machine"
|
|
800
|
-
},
|
|
801
|
-
{
|
|
802
|
-
stat_consumption: "sensor.oven_power",
|
|
803
|
-
name: "Oven"
|
|
804
|
-
},
|
|
805
|
-
{
|
|
806
|
-
stat_consumption: "sensor.ev_charger_power",
|
|
807
|
-
name: "EV Charger"
|
|
808
|
-
},
|
|
809
|
-
{
|
|
810
|
-
stat_consumption: "sensor.always_on_power",
|
|
811
|
-
name: "Always On"
|
|
812
|
-
}
|
|
813
|
-
]
|
|
814
|
-
};
|
|
815
|
-
}
|
|
816
|
-
//#endregion
|
|
817
|
-
//#region src/demo/demo-data.ts
|
|
818
|
-
function demoAssetUrl(path) {
|
|
819
|
-
return globalThis.location ? new URL(path, globalThis.location.origin).href : path;
|
|
820
|
-
}
|
|
821
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
822
|
-
let contextCounter = 0;
|
|
823
|
-
function ctx() {
|
|
824
|
-
return {
|
|
825
|
-
id: `demo-${++contextCounter}`,
|
|
826
|
-
parent_id: null,
|
|
827
|
-
user_id: null
|
|
828
|
-
};
|
|
829
|
-
}
|
|
830
|
-
function makeEntity(entity_id, state, attributes = {}) {
|
|
831
|
-
return {
|
|
832
|
-
entity_id,
|
|
833
|
-
state,
|
|
834
|
-
attributes: {
|
|
835
|
-
friendly_name: friendlyName(entity_id),
|
|
836
|
-
...attributes
|
|
837
|
-
},
|
|
838
|
-
last_changed: now,
|
|
839
|
-
last_updated: now,
|
|
840
|
-
context: ctx()
|
|
841
|
-
};
|
|
842
|
-
}
|
|
843
|
-
/**
|
|
844
|
-
* One entity per weather scene the widget can render. Entity IDs use the
|
|
845
|
-
* `weather.demo_<slug>` form so a demo dashboard can reference them directly.
|
|
846
|
-
*/
|
|
847
|
-
const WEATHER_FIXTURES = [
|
|
848
|
-
{
|
|
849
|
-
slug: "sunny",
|
|
850
|
-
state: "sunny",
|
|
851
|
-
temp: 28,
|
|
852
|
-
apparent: 31,
|
|
853
|
-
humidity: 35,
|
|
854
|
-
pressure: 1018,
|
|
855
|
-
wind: 8,
|
|
856
|
-
low: 18
|
|
857
|
-
},
|
|
858
|
-
{
|
|
859
|
-
slug: "clear_night",
|
|
860
|
-
state: "clear-night",
|
|
861
|
-
temp: 14,
|
|
862
|
-
apparent: 12,
|
|
863
|
-
humidity: 55,
|
|
864
|
-
pressure: 1016,
|
|
865
|
-
wind: 5,
|
|
866
|
-
low: 9
|
|
867
|
-
},
|
|
868
|
-
{
|
|
869
|
-
slug: "cloudy",
|
|
870
|
-
state: "cloudy",
|
|
871
|
-
temp: 17,
|
|
872
|
-
apparent: 16,
|
|
873
|
-
humidity: 72,
|
|
874
|
-
pressure: 1010,
|
|
875
|
-
wind: 14,
|
|
876
|
-
low: 11
|
|
877
|
-
},
|
|
878
|
-
{
|
|
879
|
-
slug: "partly_cloudy",
|
|
880
|
-
state: "partlycloudy",
|
|
881
|
-
temp: 23,
|
|
882
|
-
apparent: 24,
|
|
883
|
-
humidity: 50,
|
|
884
|
-
pressure: 1014,
|
|
885
|
-
wind: 12,
|
|
886
|
-
low: 15
|
|
887
|
-
},
|
|
888
|
-
{
|
|
889
|
-
slug: "rainy",
|
|
890
|
-
state: "rainy",
|
|
891
|
-
temp: 12,
|
|
892
|
-
apparent: 10,
|
|
893
|
-
humidity: 88,
|
|
894
|
-
pressure: 1004,
|
|
895
|
-
wind: 18,
|
|
896
|
-
low: 8
|
|
897
|
-
},
|
|
898
|
-
{
|
|
899
|
-
slug: "pouring",
|
|
900
|
-
state: "pouring",
|
|
901
|
-
temp: 11,
|
|
902
|
-
apparent: 8,
|
|
903
|
-
humidity: 95,
|
|
904
|
-
pressure: 998,
|
|
905
|
-
wind: 26,
|
|
906
|
-
low: 7
|
|
907
|
-
},
|
|
908
|
-
{
|
|
909
|
-
slug: "snowy",
|
|
910
|
-
state: "snowy",
|
|
911
|
-
temp: -2,
|
|
912
|
-
apparent: -6,
|
|
913
|
-
humidity: 80,
|
|
914
|
-
pressure: 1020,
|
|
915
|
-
wind: 10,
|
|
916
|
-
low: -7
|
|
917
|
-
},
|
|
918
|
-
{
|
|
919
|
-
slug: "snowy_rainy",
|
|
920
|
-
state: "snowy-rainy",
|
|
921
|
-
temp: 1,
|
|
922
|
-
apparent: -2,
|
|
923
|
-
humidity: 92,
|
|
924
|
-
pressure: 1006,
|
|
925
|
-
wind: 16,
|
|
926
|
-
low: -2
|
|
927
|
-
},
|
|
928
|
-
{
|
|
929
|
-
slug: "lightning",
|
|
930
|
-
state: "lightning",
|
|
931
|
-
temp: 22,
|
|
932
|
-
apparent: 24,
|
|
933
|
-
humidity: 78,
|
|
934
|
-
pressure: 1001,
|
|
935
|
-
wind: 22,
|
|
936
|
-
low: 17
|
|
937
|
-
},
|
|
938
|
-
{
|
|
939
|
-
slug: "lightning_rainy",
|
|
940
|
-
state: "lightning-rainy",
|
|
941
|
-
temp: 19,
|
|
942
|
-
apparent: 18,
|
|
943
|
-
humidity: 90,
|
|
944
|
-
pressure: 996,
|
|
945
|
-
wind: 28,
|
|
946
|
-
low: 14
|
|
947
|
-
},
|
|
948
|
-
{
|
|
949
|
-
slug: "fog",
|
|
950
|
-
state: "fog",
|
|
951
|
-
temp: 8,
|
|
952
|
-
apparent: 6,
|
|
953
|
-
humidity: 98,
|
|
954
|
-
pressure: 1015,
|
|
955
|
-
wind: 4,
|
|
956
|
-
low: 6
|
|
957
|
-
},
|
|
958
|
-
{
|
|
959
|
-
slug: "hail",
|
|
960
|
-
state: "hail",
|
|
961
|
-
temp: 6,
|
|
962
|
-
apparent: 3,
|
|
963
|
-
humidity: 84,
|
|
964
|
-
pressure: 1002,
|
|
965
|
-
wind: 20,
|
|
966
|
-
low: 1
|
|
967
|
-
},
|
|
968
|
-
{
|
|
969
|
-
slug: "windy",
|
|
970
|
-
state: "windy",
|
|
971
|
-
temp: 18,
|
|
972
|
-
apparent: 15,
|
|
973
|
-
humidity: 60,
|
|
974
|
-
pressure: 1009,
|
|
975
|
-
wind: 42,
|
|
976
|
-
low: 12
|
|
977
|
-
},
|
|
978
|
-
{
|
|
979
|
-
slug: "exceptional",
|
|
980
|
-
state: "exceptional",
|
|
981
|
-
temp: 38,
|
|
982
|
-
apparent: 44,
|
|
983
|
-
humidity: 22,
|
|
984
|
-
pressure: 1005,
|
|
985
|
-
wind: 30,
|
|
986
|
-
low: 28
|
|
987
|
-
}
|
|
988
|
-
];
|
|
989
|
-
function makeWeatherEntity(w) {
|
|
990
|
-
const today = /* @__PURE__ */ new Date();
|
|
991
|
-
const day = (n) => new Date(today.getTime() + n * 864e5).toISOString();
|
|
992
|
-
return makeEntity(`weather.demo_${w.slug}`, w.state, {
|
|
993
|
-
temperature: w.temp,
|
|
994
|
-
temperature_unit: "°C",
|
|
995
|
-
apparent_temperature: w.apparent ?? w.temp,
|
|
996
|
-
humidity: w.humidity,
|
|
997
|
-
pressure: w.pressure,
|
|
998
|
-
pressure_unit: "hPa",
|
|
999
|
-
wind_speed: w.wind,
|
|
1000
|
-
wind_speed_unit: "km/h",
|
|
1001
|
-
wind_bearing: w.bearing ?? 220,
|
|
1002
|
-
visibility: 10,
|
|
1003
|
-
visibility_unit: "km",
|
|
1004
|
-
uv_index: w.state === "sunny" ? 8 : 2,
|
|
1005
|
-
forecast: [
|
|
1006
|
-
{
|
|
1007
|
-
datetime: day(0),
|
|
1008
|
-
condition: w.state,
|
|
1009
|
-
temperature: w.temp,
|
|
1010
|
-
templow: w.low
|
|
1011
|
-
},
|
|
1012
|
-
{
|
|
1013
|
-
datetime: day(1),
|
|
1014
|
-
condition: "partlycloudy",
|
|
1015
|
-
temperature: w.temp + 2,
|
|
1016
|
-
templow: w.low
|
|
1017
|
-
},
|
|
1018
|
-
{
|
|
1019
|
-
datetime: day(2),
|
|
1020
|
-
condition: "cloudy",
|
|
1021
|
-
temperature: w.temp + 1,
|
|
1022
|
-
templow: w.low - 1
|
|
1023
|
-
},
|
|
1024
|
-
{
|
|
1025
|
-
datetime: day(3),
|
|
1026
|
-
condition: "sunny",
|
|
1027
|
-
temperature: w.temp + 4,
|
|
1028
|
-
templow: w.low + 1
|
|
1029
|
-
},
|
|
1030
|
-
{
|
|
1031
|
-
datetime: day(4),
|
|
1032
|
-
condition: "rainy",
|
|
1033
|
-
temperature: w.temp - 2,
|
|
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
|
|
1047
|
-
}
|
|
1048
|
-
]
|
|
1049
|
-
});
|
|
1050
|
-
}
|
|
1051
|
-
function friendlyName(entityId) {
|
|
1052
|
-
return (entityId.split(".")[1] ?? entityId).split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
1053
|
-
}
|
|
1054
|
-
function makeRegistryEntry(entity_id, overrides = {}) {
|
|
1055
|
-
const domain = entity_id.split(".")[0] ?? "";
|
|
1056
|
-
return {
|
|
1057
|
-
aliases: [],
|
|
1058
|
-
area_id: null,
|
|
1059
|
-
capabilities: null,
|
|
1060
|
-
categories: {},
|
|
1061
|
-
config_entry_id: "demo_config",
|
|
1062
|
-
config_subentry_id: null,
|
|
1063
|
-
created_at: now,
|
|
1064
|
-
device_class: null,
|
|
1065
|
-
device_id: null,
|
|
1066
|
-
disabled_by: null,
|
|
1067
|
-
entity_category: null,
|
|
1068
|
-
entity_id,
|
|
1069
|
-
has_entity_name: false,
|
|
1070
|
-
hidden_by: null,
|
|
1071
|
-
icon: null,
|
|
1072
|
-
id: `reg-${entity_id.replace(".", "-")}`,
|
|
1073
|
-
labels: [],
|
|
1074
|
-
modified_at: now,
|
|
1075
|
-
name: null,
|
|
1076
|
-
options: {},
|
|
1077
|
-
original_device_class: null,
|
|
1078
|
-
original_icon: null,
|
|
1079
|
-
original_name: null,
|
|
1080
|
-
platform: domain === "weather" ? "met" : "demo",
|
|
1081
|
-
previous_unique_id: null,
|
|
1082
|
-
suggested_object_id: null,
|
|
1083
|
-
supported_features: 0,
|
|
1084
|
-
translation_key: null,
|
|
1085
|
-
unique_id: `demo_${entity_id.replace(".", "_")}`,
|
|
1086
|
-
unit_of_measurement: null,
|
|
1087
|
-
...overrides
|
|
1088
|
-
};
|
|
1089
|
-
}
|
|
1090
|
-
function makeDevice(id, name, areaId, manufacturer = "Demo", model = "Virtual") {
|
|
1091
|
-
return {
|
|
1092
|
-
id,
|
|
1093
|
-
name,
|
|
1094
|
-
name_by_user: null,
|
|
1095
|
-
manufacturer,
|
|
1096
|
-
model,
|
|
1097
|
-
model_id: null,
|
|
1098
|
-
sw_version: "1.0.0",
|
|
1099
|
-
hw_version: null,
|
|
1100
|
-
area_id: areaId,
|
|
1101
|
-
config_entries: ["demo_config"],
|
|
1102
|
-
config_entries_subentries: {},
|
|
1103
|
-
configuration_url: null,
|
|
1104
|
-
connections: [],
|
|
1105
|
-
created_at: now,
|
|
1106
|
-
disabled_by: null,
|
|
1107
|
-
entry_type: null,
|
|
1108
|
-
identifiers: [["demo", id]],
|
|
1109
|
-
labels: [],
|
|
1110
|
-
modified_at: now,
|
|
1111
|
-
primary_config_entry: "demo_config",
|
|
1112
|
-
serial_number: null,
|
|
1113
|
-
via_device_id: null
|
|
1114
|
-
};
|
|
1115
|
-
}
|
|
1116
|
-
function makeArea(id, name, floorId = "ground_floor") {
|
|
1117
|
-
return {
|
|
1118
|
-
id,
|
|
1119
|
-
name,
|
|
1120
|
-
normalized_name: name.toLowerCase().replace(/\s+/g, "_"),
|
|
1121
|
-
created_at: now,
|
|
1122
|
-
modified_at: now,
|
|
1123
|
-
aliases: [],
|
|
1124
|
-
floor_id: floorId,
|
|
1125
|
-
humidity_entity_id: null,
|
|
1126
|
-
icon: null,
|
|
1127
|
-
labels: [],
|
|
1128
|
-
picture: null,
|
|
1129
|
-
temperature_entity_id: null
|
|
1130
|
-
};
|
|
1131
|
-
}
|
|
1132
|
-
/**
|
|
1133
|
-
* Create ~30 curated apartment entity fixtures.
|
|
1134
|
-
*
|
|
1135
|
-
* Entities span lights, sensors, binary sensors, climate, covers,
|
|
1136
|
-
* locks, switches, media player, weather, camera, scenes, and buttons
|
|
1137
|
-
* across 6 areas: living_room, bedroom, kitchen, entry, utility, garage.
|
|
1138
|
-
*/
|
|
1139
|
-
function createDemoFixtures() {
|
|
1140
|
-
contextCounter = 0;
|
|
1141
|
-
const areas = {
|
|
1142
|
-
living_room: {
|
|
1143
|
-
...makeArea("living_room", "Living Room"),
|
|
1144
|
-
temperature_entity_id: "sensor.temperature_living",
|
|
1145
|
-
humidity_entity_id: "sensor.humidity_living"
|
|
1146
|
-
},
|
|
1147
|
-
bedroom: makeArea("bedroom", "Bedroom"),
|
|
1148
|
-
kitchen: makeArea("kitchen", "Kitchen"),
|
|
1149
|
-
entry: makeArea("entry", "Entry"),
|
|
1150
|
-
utility: makeArea("utility", "Utility"),
|
|
1151
|
-
garage: makeArea("garage", "Garage")
|
|
1152
|
-
};
|
|
1153
|
-
const devices = {
|
|
1154
|
-
living_room_lights: makeDevice("living_room_lights", "Living Room Lights", "living_room", "Philips", "Hue"),
|
|
1155
|
-
kitchen_lights: makeDevice("kitchen_lights", "Kitchen Lights", "kitchen", "IKEA", "Tradfri"),
|
|
1156
|
-
bedroom_lights: makeDevice("bedroom_lights", "Bedroom Lights", "bedroom", "Philips", "Hue"),
|
|
1157
|
-
hallway_lights: makeDevice("hallway_lights", "Hallway Lights", "entry", "IKEA", "Tradfri"),
|
|
1158
|
-
bathroom_lights: makeDevice("bathroom_lights", "Bathroom Lights", null),
|
|
1159
|
-
thermostat_lr: makeDevice("thermostat_lr", "Living Room Thermostat", "living_room", "Nest", "Learning Thermostat"),
|
|
1160
|
-
thermostat_br: makeDevice("thermostat_br", "Bedroom AC", "bedroom", "Daikin", "Split AC"),
|
|
1161
|
-
blinds_lr: makeDevice("blinds_lr", "Living Room Blinds", "living_room", "Somfy", "Roller Shade"),
|
|
1162
|
-
curtains_br: makeDevice("curtains_br", "Bedroom Curtains", "bedroom", "IKEA", "Fyrtur"),
|
|
1163
|
-
garage_door_opener: makeDevice("garage_door_opener", "Garage Door Opener", "garage", "Chamberlain", "MyQ"),
|
|
1164
|
-
shutters_lr: makeDevice("shutters_lr", "Living Room Shutters", "living_room", "Somfy", "Venetian Shutter"),
|
|
1165
|
-
blinds_kitchen: makeDevice("blinds_kitchen", "Kitchen Blinds", "kitchen", "IKEA", "Praktlysing"),
|
|
1166
|
-
lock_front: makeDevice("lock_front", "Front Door Lock", "entry", "Yale", "Assure Lock 2"),
|
|
1167
|
-
lock_back: makeDevice("lock_back", "Back Door Lock", "entry", "August", "Smart Lock Pro"),
|
|
1168
|
-
speaker_lr: makeDevice("speaker_lr", "Living Room Speaker", "living_room", "Sonos", "One"),
|
|
1169
|
-
camera_front: makeDevice("camera_front", "Front Door Camera", "entry", "Ring", "Video Doorbell"),
|
|
1170
|
-
door_sensor: makeDevice("door_sensor", "Front Door Sensor", "entry", "Aqara", "Door Sensor"),
|
|
1171
|
-
motion_sensor: makeDevice("motion_sensor", "Hallway Motion Sensor", "entry", "Aqara", "Motion Sensor")
|
|
1172
|
-
};
|
|
1173
|
-
const entities = {};
|
|
1174
|
-
const entityRegistry = {};
|
|
1175
|
-
function add(e, regOverrides = {}) {
|
|
1176
|
-
entities[e.entity_id] = e;
|
|
1177
|
-
entityRegistry[e.entity_id] = makeRegistryEntry(e.entity_id, regOverrides);
|
|
1178
|
-
}
|
|
1179
|
-
add(makeEntity("light.living_room_main", "on", {
|
|
1180
|
-
brightness: 179,
|
|
1181
|
-
color_temp_kelvin: 3e3,
|
|
1182
|
-
color_mode: "color_temp",
|
|
1183
|
-
supported_color_modes: ["color_temp", "xy"]
|
|
1184
|
-
}), {
|
|
1185
|
-
device_id: "living_room_lights",
|
|
1186
|
-
area_id: "living_room",
|
|
1187
|
-
supported_features: 44
|
|
1188
|
-
});
|
|
1189
|
-
add(makeEntity("light.kitchen_counter", "off", { supported_color_modes: ["brightness"] }), {
|
|
1190
|
-
device_id: "kitchen_lights",
|
|
1191
|
-
area_id: "kitchen",
|
|
1192
|
-
supported_features: 1
|
|
1193
|
-
});
|
|
1194
|
-
add(makeEntity("light.bedroom_ceiling", "on", {
|
|
1195
|
-
brightness: 77,
|
|
1196
|
-
color_temp_kelvin: 2700,
|
|
1197
|
-
color_mode: "color_temp",
|
|
1198
|
-
supported_color_modes: ["color_temp"]
|
|
1199
|
-
}), {
|
|
1200
|
-
device_id: "bedroom_lights",
|
|
1201
|
-
area_id: "bedroom",
|
|
1202
|
-
supported_features: 44
|
|
1203
|
-
});
|
|
1204
|
-
add(makeEntity("light.bathroom", "off", { supported_color_modes: ["brightness"] }), { device_id: "bathroom_lights" });
|
|
1205
|
-
add(makeEntity("light.hallway", "on", {
|
|
1206
|
-
brightness: 128,
|
|
1207
|
-
color_mode: "brightness",
|
|
1208
|
-
supported_color_modes: ["brightness"]
|
|
1209
|
-
}), {
|
|
1210
|
-
device_id: "hallway_lights",
|
|
1211
|
-
area_id: "entry",
|
|
1212
|
-
supported_features: 1
|
|
1213
|
-
});
|
|
1214
|
-
add(makeEntity("light.studio_rgb", "on", {
|
|
1215
|
-
brightness: 205,
|
|
1216
|
-
hs_color: [265, 66],
|
|
1217
|
-
color_mode: "hs",
|
|
1218
|
-
supported_color_modes: ["hs", "color_temp"]
|
|
1219
|
-
}), {
|
|
1220
|
-
device_id: "studio_lights",
|
|
1221
|
-
area_id: "living_room",
|
|
1222
|
-
supported_features: 44
|
|
1223
|
-
});
|
|
1224
|
-
add(makeEntity("light.desk_rgb", "on", {
|
|
1225
|
-
brightness: 200,
|
|
1226
|
-
hs_color: [172, 79],
|
|
1227
|
-
color_mode: "hs",
|
|
1228
|
-
supported_color_modes: ["hs", "color_temp"]
|
|
1229
|
-
}), {
|
|
1230
|
-
device_id: "desk_lights",
|
|
1231
|
-
area_id: "bedroom",
|
|
1232
|
-
supported_features: 44
|
|
1233
|
-
});
|
|
1234
|
-
add(makeEntity("sensor.temperature_living", "22.5", {
|
|
1235
|
-
unit_of_measurement: "°C",
|
|
1236
|
-
device_class: "temperature",
|
|
1237
|
-
state_class: "measurement"
|
|
1238
|
-
}), {
|
|
1239
|
-
area_id: "living_room",
|
|
1240
|
-
device_class: "temperature",
|
|
1241
|
-
unit_of_measurement: "°C"
|
|
1242
|
-
});
|
|
1243
|
-
add(makeEntity("sensor.humidity_living", "45", {
|
|
1244
|
-
unit_of_measurement: "%",
|
|
1245
|
-
device_class: "humidity",
|
|
1246
|
-
state_class: "measurement"
|
|
1247
|
-
}), {
|
|
1248
|
-
area_id: "living_room",
|
|
1249
|
-
device_class: "humidity",
|
|
1250
|
-
unit_of_measurement: "%"
|
|
1251
|
-
});
|
|
1252
|
-
add(makeEntity("sensor.temperature_outdoor", "-2", {
|
|
1253
|
-
unit_of_measurement: "°C",
|
|
1254
|
-
device_class: "temperature",
|
|
1255
|
-
state_class: "measurement"
|
|
1256
|
-
}), {
|
|
1257
|
-
device_class: "temperature",
|
|
1258
|
-
unit_of_measurement: "°C"
|
|
1259
|
-
});
|
|
1260
|
-
add(makeEntity("sensor.power_consumption", "1.2", {
|
|
1261
|
-
unit_of_measurement: "kW",
|
|
1262
|
-
device_class: "power",
|
|
1263
|
-
state_class: "measurement"
|
|
1264
|
-
}), {
|
|
1265
|
-
device_class: "power",
|
|
1266
|
-
unit_of_measurement: "kW"
|
|
1267
|
-
});
|
|
1268
|
-
add(makeEntity("sensor.battery_door_sensor", "87", {
|
|
1269
|
-
unit_of_measurement: "%",
|
|
1270
|
-
device_class: "battery",
|
|
1271
|
-
state_class: "measurement"
|
|
1272
|
-
}), {
|
|
1273
|
-
device_id: "door_sensor",
|
|
1274
|
-
area_id: "entry",
|
|
1275
|
-
device_class: "battery",
|
|
1276
|
-
unit_of_measurement: "%"
|
|
1277
|
-
});
|
|
1278
|
-
add(makeEntity("sensor.electricity_maps_co2_intensity", "214", {
|
|
1279
|
-
unit_of_measurement: "gCO2eq/kWh",
|
|
1280
|
-
state_class: "measurement"
|
|
1281
|
-
}), { unit_of_measurement: "gCO2eq/kWh" });
|
|
1282
|
-
add(makeEntity("sensor.electricity_maps_fossil_fuel_percentage", "31", {
|
|
1283
|
-
unit_of_measurement: "%",
|
|
1284
|
-
state_class: "measurement"
|
|
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
|
-
}
|
|
1304
|
-
add(makeEntity("sensor.nordpool_current_price", "0.18", {
|
|
1305
|
-
unit_of_measurement: "EUR/kWh",
|
|
1306
|
-
state_class: "measurement"
|
|
1307
|
-
}), { unit_of_measurement: "EUR/kWh" });
|
|
1308
|
-
add(makeEntity("binary_sensor.front_door", "off", { device_class: "door" }), {
|
|
1309
|
-
device_id: "door_sensor",
|
|
1310
|
-
area_id: "entry",
|
|
1311
|
-
device_class: "door"
|
|
1312
|
-
});
|
|
1313
|
-
add(makeEntity("binary_sensor.motion_hallway", "off", { device_class: "motion" }), {
|
|
1314
|
-
device_id: "motion_sensor",
|
|
1315
|
-
area_id: "entry",
|
|
1316
|
-
device_class: "motion"
|
|
1317
|
-
});
|
|
1318
|
-
add(makeEntity("binary_sensor.window_bedroom", "off", { device_class: "window" }), {
|
|
1319
|
-
area_id: "bedroom",
|
|
1320
|
-
device_class: "window"
|
|
1321
|
-
});
|
|
1322
|
-
add(makeEntity("climate.living_room_thermostat", "heat", {
|
|
1323
|
-
temperature: 22,
|
|
1324
|
-
current_temperature: 21.8,
|
|
1325
|
-
hvac_modes: [
|
|
1326
|
-
"off",
|
|
1327
|
-
"heat",
|
|
1328
|
-
"cool",
|
|
1329
|
-
"auto"
|
|
1330
|
-
],
|
|
1331
|
-
hvac_action: "heating",
|
|
1332
|
-
min_temp: 7,
|
|
1333
|
-
max_temp: 35
|
|
1334
|
-
}), {
|
|
1335
|
-
device_id: "thermostat_lr",
|
|
1336
|
-
area_id: "living_room",
|
|
1337
|
-
supported_features: 385
|
|
1338
|
-
});
|
|
1339
|
-
add(makeEntity("climate.bedroom_ac", "off", {
|
|
1340
|
-
temperature: 20,
|
|
1341
|
-
current_temperature: 22.1,
|
|
1342
|
-
hvac_modes: [
|
|
1343
|
-
"off",
|
|
1344
|
-
"cool",
|
|
1345
|
-
"heat",
|
|
1346
|
-
"dry",
|
|
1347
|
-
"fan_only"
|
|
1348
|
-
],
|
|
1349
|
-
hvac_action: "idle",
|
|
1350
|
-
min_temp: 16,
|
|
1351
|
-
max_temp: 30
|
|
1352
|
-
}), {
|
|
1353
|
-
device_id: "thermostat_br",
|
|
1354
|
-
area_id: "bedroom",
|
|
1355
|
-
supported_features: 385
|
|
1356
|
-
});
|
|
1357
|
-
add(makeEntity("water_heater.boiler", "eco", {
|
|
1358
|
-
temperature: 50,
|
|
1359
|
-
current_temperature: 47.5,
|
|
1360
|
-
operation_list: [
|
|
1361
|
-
"off",
|
|
1362
|
-
"eco",
|
|
1363
|
-
"performance"
|
|
1364
|
-
],
|
|
1365
|
-
min_temp: 43,
|
|
1366
|
-
max_temp: 60,
|
|
1367
|
-
away_mode: "off"
|
|
1368
|
-
}), {
|
|
1369
|
-
device_id: "boiler",
|
|
1370
|
-
area_id: "utility",
|
|
1371
|
-
supported_features: 7
|
|
1372
|
-
});
|
|
1373
|
-
add(makeEntity("water_heater.heat_pump_tank", "heat_pump", {
|
|
1374
|
-
temperature: 55,
|
|
1375
|
-
current_temperature: 52.3,
|
|
1376
|
-
operation_list: [
|
|
1377
|
-
"off",
|
|
1378
|
-
"eco",
|
|
1379
|
-
"heat_pump",
|
|
1380
|
-
"high_demand"
|
|
1381
|
-
],
|
|
1382
|
-
min_temp: 43,
|
|
1383
|
-
max_temp: 60
|
|
1384
|
-
}), {
|
|
1385
|
-
device_id: "hp_tank",
|
|
1386
|
-
area_id: "garage",
|
|
1387
|
-
supported_features: 3
|
|
1388
|
-
});
|
|
1389
|
-
add(makeEntity("fan.bedroom_ceiling", "on", {
|
|
1390
|
-
percentage: 200 / 3,
|
|
1391
|
-
percentage_step: 100 / 3,
|
|
1392
|
-
current_direction: "forward"
|
|
1393
|
-
}), {
|
|
1394
|
-
device_id: "ceiling_fan",
|
|
1395
|
-
area_id: "bedroom",
|
|
1396
|
-
supported_features: 5
|
|
1397
|
-
});
|
|
1398
|
-
add(makeEntity("fan.air_purifier", "on", {
|
|
1399
|
-
percentage: 40,
|
|
1400
|
-
preset_modes: [
|
|
1401
|
-
"auto",
|
|
1402
|
-
"sleep",
|
|
1403
|
-
"turbo"
|
|
1404
|
-
],
|
|
1405
|
-
preset_mode: "auto",
|
|
1406
|
-
oscillating: false
|
|
1407
|
-
}), {
|
|
1408
|
-
device_id: "purifier",
|
|
1409
|
-
area_id: "living_room",
|
|
1410
|
-
supported_features: 11
|
|
1411
|
-
});
|
|
1412
|
-
add(makeEntity("cover.living_room_blinds", "open", {
|
|
1413
|
-
current_position: 80,
|
|
1414
|
-
device_class: "shade"
|
|
1415
|
-
}), {
|
|
1416
|
-
device_id: "blinds_lr",
|
|
1417
|
-
area_id: "living_room",
|
|
1418
|
-
device_class: "shade",
|
|
1419
|
-
supported_features: 15
|
|
1420
|
-
});
|
|
1421
|
-
add(makeEntity("cover.bedroom_curtains", "closed", {
|
|
1422
|
-
current_position: 0,
|
|
1423
|
-
device_class: "curtain"
|
|
1424
|
-
}), {
|
|
1425
|
-
device_id: "curtains_br",
|
|
1426
|
-
area_id: "bedroom",
|
|
1427
|
-
device_class: "curtain",
|
|
1428
|
-
supported_features: 15
|
|
1429
|
-
});
|
|
1430
|
-
add(makeEntity("cover.garage_door", "closed", { device_class: "garage" }), {
|
|
1431
|
-
device_id: "garage_door_opener",
|
|
1432
|
-
area_id: "garage",
|
|
1433
|
-
device_class: "garage",
|
|
1434
|
-
supported_features: 11
|
|
1435
|
-
});
|
|
1436
|
-
add(makeEntity("cover.driveway_gate", "open", {
|
|
1437
|
-
device_class: "gate",
|
|
1438
|
-
assumed_state: true
|
|
1439
|
-
}), {
|
|
1440
|
-
area_id: "garage",
|
|
1441
|
-
device_class: "gate",
|
|
1442
|
-
supported_features: 3
|
|
1443
|
-
});
|
|
1444
|
-
add(makeEntity("cover.living_room_shutters", "open", {
|
|
1445
|
-
current_position: 45,
|
|
1446
|
-
current_tilt_position: 30,
|
|
1447
|
-
device_class: "shutter"
|
|
1448
|
-
}), {
|
|
1449
|
-
device_id: "shutters_lr",
|
|
1450
|
-
area_id: "living_room",
|
|
1451
|
-
device_class: "shutter",
|
|
1452
|
-
supported_features: 255
|
|
1453
|
-
});
|
|
1454
|
-
add(makeEntity("cover.kitchen_blinds", "open", {
|
|
1455
|
-
current_position: 100,
|
|
1456
|
-
device_class: "blind"
|
|
1457
|
-
}), {
|
|
1458
|
-
device_id: "blinds_kitchen",
|
|
1459
|
-
area_id: "kitchen",
|
|
1460
|
-
device_class: "blind",
|
|
1461
|
-
supported_features: 127
|
|
1462
|
-
});
|
|
1463
|
-
add(makeEntity("lock.front_door_lock", "locked", {}), {
|
|
1464
|
-
device_id: "lock_front",
|
|
1465
|
-
area_id: "entry",
|
|
1466
|
-
supported_features: 1
|
|
1467
|
-
});
|
|
1468
|
-
add(makeEntity("lock.back_door_lock", "locked", {}), {
|
|
1469
|
-
device_id: "lock_back",
|
|
1470
|
-
area_id: "entry",
|
|
1471
|
-
supported_features: 1
|
|
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" });
|
|
1476
|
-
add(makeEntity("switch.coffee_machine", "off", { device_class: "outlet" }), {
|
|
1477
|
-
area_id: "kitchen",
|
|
1478
|
-
device_class: "outlet"
|
|
1479
|
-
});
|
|
1480
|
-
add(makeEntity("switch.fan_living_room", "on", { device_class: "switch" }), {
|
|
1481
|
-
area_id: "living_room",
|
|
1482
|
-
device_class: "switch"
|
|
1483
|
-
});
|
|
1484
|
-
add(makeEntity("media_player.living_room_speaker", "playing", {
|
|
1485
|
-
media_title: "Lo-fi Beats",
|
|
1486
|
-
media_artist: "Chill Station",
|
|
1487
|
-
media_content_type: "music",
|
|
1488
|
-
entity_picture: DEMO_ALBUM_COVER,
|
|
1489
|
-
volume_level: .4,
|
|
1490
|
-
is_volume_muted: false,
|
|
1491
|
-
supported_features: 152509
|
|
1492
|
-
}), {
|
|
1493
|
-
device_id: "speaker_lr",
|
|
1494
|
-
area_id: "living_room",
|
|
1495
|
-
supported_features: 152509
|
|
1496
|
-
});
|
|
1497
|
-
for (const w of WEATHER_FIXTURES) add(makeWeatherEntity(w), { platform: "met" });
|
|
1498
|
-
add(makeEntity("camera.front_door_camera", "streaming", {
|
|
1499
|
-
entity_picture: demoAssetUrl("/demo/camera/front-door.webp"),
|
|
1500
|
-
frontend_stream_type: "hls",
|
|
1501
|
-
access_token: "demo-token"
|
|
1502
|
-
}), {
|
|
1503
|
-
device_id: "camera_front",
|
|
1504
|
-
area_id: "entry",
|
|
1505
|
-
supported_features: 2
|
|
1506
|
-
});
|
|
1507
|
-
add(makeEntity("scene.movie_night", "scening", { entity_id: [
|
|
1508
|
-
"light.living_room_main",
|
|
1509
|
-
"cover.living_room_blinds",
|
|
1510
|
-
"media_player.living_room_speaker"
|
|
1511
|
-
] }), { area_id: "living_room" });
|
|
1512
|
-
add(makeEntity("scene.good_morning", "scening", { entity_id: [
|
|
1513
|
-
"light.bedroom_ceiling",
|
|
1514
|
-
"cover.bedroom_curtains",
|
|
1515
|
-
"switch.coffee_machine"
|
|
1516
|
-
] }), {});
|
|
1517
|
-
add(makeEntity("button.restart_home_assistant", "unknown", { device_class: "restart" }), {
|
|
1518
|
-
entity_category: "config",
|
|
1519
|
-
device_class: "restart"
|
|
1520
|
-
});
|
|
1521
|
-
add(makeEntity("button.update_firmware", "unknown", { device_class: "update" }), {
|
|
1522
|
-
entity_category: "config",
|
|
1523
|
-
device_class: "update"
|
|
1524
|
-
});
|
|
1525
|
-
add(makeEntity("sensor.door_lock_battery", "92", {
|
|
1526
|
-
unit_of_measurement: "%",
|
|
1527
|
-
device_class: "battery",
|
|
1528
|
-
state_class: "measurement"
|
|
1529
|
-
}), {
|
|
1530
|
-
device_id: "lock_front",
|
|
1531
|
-
area_id: "entry",
|
|
1532
|
-
device_class: "battery",
|
|
1533
|
-
unit_of_measurement: "%"
|
|
1534
|
-
});
|
|
1535
|
-
add(makeEntity("sensor.motion_sensor_battery", "65", {
|
|
1536
|
-
unit_of_measurement: "%",
|
|
1537
|
-
device_class: "battery",
|
|
1538
|
-
state_class: "measurement"
|
|
1539
|
-
}), {
|
|
1540
|
-
device_id: "motion_sensor",
|
|
1541
|
-
area_id: "entry",
|
|
1542
|
-
device_class: "battery",
|
|
1543
|
-
unit_of_measurement: "%"
|
|
1544
|
-
});
|
|
1545
|
-
const nowMs = Date.now();
|
|
1546
|
-
const sample = simulateEnergy(nowMs);
|
|
1547
|
-
const sun = sunEvents(nowMs);
|
|
1548
|
-
add(makeEntity("sun.sun", isSunUp(nowMs) ? "above_horizon" : "below_horizon", {
|
|
1549
|
-
friendly_name: "Sun",
|
|
1550
|
-
elevation: sun.elevation,
|
|
1551
|
-
next_rising: sun.nextRising,
|
|
1552
|
-
next_setting: sun.nextSetting,
|
|
1553
|
-
rising: sun.rising
|
|
1554
|
-
}), {});
|
|
1555
|
-
for (const s of [
|
|
1556
|
-
{
|
|
1557
|
-
id: "sensor.solar_power",
|
|
1558
|
-
name: "Solar Production",
|
|
1559
|
-
deviceClass: "power",
|
|
1560
|
-
unit: "W"
|
|
1561
|
-
},
|
|
1562
|
-
{
|
|
1563
|
-
id: "sensor.grid_import_power",
|
|
1564
|
-
name: "Grid Import",
|
|
1565
|
-
deviceClass: "power",
|
|
1566
|
-
unit: "W"
|
|
1567
|
-
},
|
|
1568
|
-
{
|
|
1569
|
-
id: "sensor.grid_export_power",
|
|
1570
|
-
name: "Grid Export",
|
|
1571
|
-
deviceClass: "power",
|
|
1572
|
-
unit: "W"
|
|
1573
|
-
},
|
|
1574
|
-
{
|
|
1575
|
-
id: "sensor.battery_charge_power",
|
|
1576
|
-
name: "Battery Charge",
|
|
1577
|
-
deviceClass: "power",
|
|
1578
|
-
unit: "W"
|
|
1579
|
-
},
|
|
1580
|
-
{
|
|
1581
|
-
id: "sensor.battery_discharge_power",
|
|
1582
|
-
name: "Battery Discharge",
|
|
1583
|
-
deviceClass: "power",
|
|
1584
|
-
unit: "W"
|
|
1585
|
-
},
|
|
1586
|
-
{
|
|
1587
|
-
id: "sensor.battery_soc",
|
|
1588
|
-
name: "Battery",
|
|
1589
|
-
deviceClass: "battery",
|
|
1590
|
-
unit: "%"
|
|
1591
|
-
},
|
|
1592
|
-
{
|
|
1593
|
-
id: "sensor.home_power",
|
|
1594
|
-
name: "Home Consumption",
|
|
1595
|
-
deviceClass: "power",
|
|
1596
|
-
unit: "W"
|
|
1597
|
-
},
|
|
1598
|
-
{
|
|
1599
|
-
id: "sensor.fridge_power",
|
|
1600
|
-
name: "Fridge",
|
|
1601
|
-
deviceClass: "power",
|
|
1602
|
-
unit: "W"
|
|
1603
|
-
},
|
|
1604
|
-
{
|
|
1605
|
-
id: "sensor.dishwasher_power",
|
|
1606
|
-
name: "Dishwasher",
|
|
1607
|
-
deviceClass: "power",
|
|
1608
|
-
unit: "W"
|
|
1609
|
-
},
|
|
1610
|
-
{
|
|
1611
|
-
id: "sensor.washing_machine_power",
|
|
1612
|
-
name: "Washing Machine",
|
|
1613
|
-
deviceClass: "power",
|
|
1614
|
-
unit: "W"
|
|
1615
|
-
},
|
|
1616
|
-
{
|
|
1617
|
-
id: "sensor.oven_power",
|
|
1618
|
-
name: "Oven",
|
|
1619
|
-
deviceClass: "power",
|
|
1620
|
-
unit: "W"
|
|
1621
|
-
},
|
|
1622
|
-
{
|
|
1623
|
-
id: "sensor.ev_charger_power",
|
|
1624
|
-
name: "EV Charger",
|
|
1625
|
-
deviceClass: "power",
|
|
1626
|
-
unit: "W"
|
|
1627
|
-
},
|
|
1628
|
-
{
|
|
1629
|
-
id: "sensor.always_on_power",
|
|
1630
|
-
name: "Always On",
|
|
1631
|
-
deviceClass: "power",
|
|
1632
|
-
unit: "W"
|
|
1633
|
-
}
|
|
1634
|
-
]) {
|
|
1635
|
-
const value = energyEntityValue(s.id, sample) ?? 0;
|
|
1636
|
-
add(makeEntity(s.id, formatEnergyState(s.id, value), {
|
|
1637
|
-
friendly_name: s.name,
|
|
1638
|
-
unit_of_measurement: s.unit,
|
|
1639
|
-
device_class: s.deviceClass,
|
|
1640
|
-
state_class: "measurement"
|
|
1641
|
-
}), {
|
|
1642
|
-
area_id: "utility",
|
|
1643
|
-
device_class: s.deviceClass,
|
|
1644
|
-
unit_of_measurement: s.unit
|
|
1645
|
-
});
|
|
1646
|
-
}
|
|
1647
|
-
return {
|
|
1648
|
-
entities,
|
|
1649
|
-
entityRegistry,
|
|
1650
|
-
areas,
|
|
1651
|
-
devices
|
|
1652
|
-
};
|
|
1653
|
-
}
|
|
1654
|
-
//#endregion
|
|
1655
|
-
//#region src/demo/demo-provider.ts
|
|
1656
|
-
/**
|
|
1657
|
-
* Demo Mode Provider
|
|
1658
|
-
*
|
|
1659
|
-
* Loads demo fixtures into the SolidJS store and provides
|
|
1660
|
-
* optimistic local state updates for service calls (toggle, turnOn, turnOff).
|
|
1661
|
-
*
|
|
1662
|
-
* @packageDocumentation
|
|
1663
|
-
*/
|
|
1664
|
-
let _isDemoMode = false;
|
|
1665
|
-
/**
|
|
1666
|
-
* Whether demo mode is currently active.
|
|
1667
|
-
*/
|
|
1668
|
-
function isDemoMode() {
|
|
1669
|
-
return _isDemoMode;
|
|
1670
|
-
}
|
|
1671
|
-
/**
|
|
1672
|
-
* Load demo fixtures into the SolidJS store.
|
|
1673
|
-
*
|
|
1674
|
-
* Populates entities, entity registry, areas, and devices.
|
|
1675
|
-
* Sets connection state to "connected" (pretend connected).
|
|
1676
|
-
*/
|
|
1677
|
-
async function loadDemoData() {
|
|
1678
|
-
const fixtures = createDemoFixtures();
|
|
1679
|
-
setState("entities", reconcile(fixtures.entities));
|
|
1680
|
-
setState("entityRegistry", reconcile(fixtures.entityRegistry));
|
|
1681
|
-
setState("areas", reconcile(fixtures.areas));
|
|
1682
|
-
setState("devices", reconcile(fixtures.devices));
|
|
1683
|
-
setState("hassUrl", "https://demo.home-assistant.local");
|
|
1684
|
-
setState("connectionState", "connected");
|
|
1685
|
-
_isDemoMode = true;
|
|
1686
|
-
startDemoEnergyTicker();
|
|
1687
|
-
}
|
|
1688
|
-
/**
|
|
1689
|
-
* Unload demo data and reset to disconnected state.
|
|
1690
|
-
*/
|
|
1691
|
-
function unloadDemoData() {
|
|
1692
|
-
stopDemoEnergyTicker();
|
|
1693
|
-
setState("entities", reconcile({}));
|
|
1694
|
-
setState("entityRegistry", reconcile({}));
|
|
1695
|
-
setState("areas", reconcile({}));
|
|
1696
|
-
setState("devices", reconcile({}));
|
|
1697
|
-
setState("hassUrl", null);
|
|
1698
|
-
setState("connectionState", "disconnected");
|
|
1699
|
-
_isDemoMode = false;
|
|
1700
|
-
}
|
|
1701
|
-
const ENERGY_TICK_MS = 5e3;
|
|
1702
|
-
let _energyTicker = null;
|
|
1703
|
-
/** Write the current energy sample into the store as string states (like real HA). */
|
|
1704
|
-
function tickEnergy() {
|
|
1705
|
-
const nowMs = Date.now();
|
|
1706
|
-
const sample = simulateEnergy(nowMs);
|
|
1707
|
-
const sun = sunEvents(nowMs);
|
|
1708
|
-
const nowIso = new Date(nowMs).toISOString();
|
|
1709
|
-
setState(produce((s) => {
|
|
1710
|
-
for (const entityId of ENERGY_ENTITY_IDS) {
|
|
1711
|
-
const e = s.entities[entityId];
|
|
1712
|
-
if (!e) continue;
|
|
1713
|
-
const value = energyEntityValue(entityId, sample);
|
|
1714
|
-
if (value === void 0) continue;
|
|
1715
|
-
const next = formatEnergyState(entityId, value);
|
|
1716
|
-
if (e.state !== next) e.last_changed = nowIso;
|
|
1717
|
-
e.state = next;
|
|
1718
|
-
e.last_updated = nowIso;
|
|
1719
|
-
}
|
|
1720
|
-
const sunEntity = s.entities["sun.sun"];
|
|
1721
|
-
if (sunEntity) {
|
|
1722
|
-
const nextState = isSunUp(nowMs) ? "above_horizon" : "below_horizon";
|
|
1723
|
-
if (sunEntity.state !== nextState) sunEntity.last_changed = nowIso;
|
|
1724
|
-
sunEntity.state = nextState;
|
|
1725
|
-
sunEntity.last_updated = nowIso;
|
|
1726
|
-
sunEntity.attributes.elevation = sun.elevation;
|
|
1727
|
-
sunEntity.attributes.next_rising = sun.nextRising;
|
|
1728
|
-
sunEntity.attributes.next_setting = sun.nextSetting;
|
|
1729
|
-
sunEntity.attributes.rising = sun.rising;
|
|
1730
|
-
}
|
|
1731
|
-
}));
|
|
1732
|
-
}
|
|
1733
|
-
/**
|
|
1734
|
-
* Start the demo energy ticker. Updates the energy sensors and `sun.sun`
|
|
1735
|
-
* every 5 seconds. Idempotent: clears any prior interval first. SSR-safe.
|
|
1736
|
-
*/
|
|
1737
|
-
function startDemoEnergyTicker() {
|
|
1738
|
-
stopDemoEnergyTicker();
|
|
1739
|
-
if (typeof setInterval !== "function") return;
|
|
1740
|
-
tickEnergy();
|
|
1741
|
-
_energyTicker = setInterval(tickEnergy, ENERGY_TICK_MS);
|
|
1742
|
-
}
|
|
1743
|
-
/** Stop the demo energy ticker if running. */
|
|
1744
|
-
function stopDemoEnergyTicker() {
|
|
1745
|
-
if (_energyTicker !== null) {
|
|
1746
|
-
clearInterval(_energyTicker);
|
|
1747
|
-
_energyTicker = null;
|
|
1748
|
-
}
|
|
1749
|
-
}
|
|
1750
|
-
/**
|
|
1751
|
-
* Apply an optimistic state update for demo mode.
|
|
1752
|
-
*
|
|
1753
|
-
* When a service call is made in demo mode, this updates the entity
|
|
1754
|
-
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
1755
|
-
*/
|
|
1756
|
-
function applyDemoServiceCall(_domain, service, serviceData = {}, target = {}) {
|
|
1757
|
-
if (!_isDemoMode) return;
|
|
1758
|
-
const _serviceData = serviceData;
|
|
1759
|
-
const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
|
|
1760
|
-
for (const entityId of entityIds) {
|
|
1761
|
-
if (!state.entities[entityId]) continue;
|
|
1762
|
-
const entityDomain = extractDomain(entityId);
|
|
1763
|
-
setState(produce((s) => {
|
|
1764
|
-
const e = s.entities[entityId];
|
|
1765
|
-
if (!e) return;
|
|
1766
|
-
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1767
|
-
e.last_changed = now;
|
|
1768
|
-
e.last_updated = now;
|
|
1769
|
-
if (service === "turn_on") applyTurnOn(e, entityDomain, _serviceData);
|
|
1770
|
-
else if (service === "press" && entityDomain === "button") e.state = now;
|
|
1771
|
-
else if (service === "turn_off") applyTurnOff(e, entityDomain);
|
|
1772
|
-
else if (service === "toggle") {
|
|
1773
|
-
if (e.state === "on" || e.state === "playing" || e.state === "open") applyTurnOff(e, entityDomain);
|
|
1774
|
-
else applyTurnOn(e, entityDomain, _serviceData);
|
|
1775
|
-
} else if (service === "lock") e.state = "locked";
|
|
1776
|
-
else if (service === "unlock") e.state = "unlocked";
|
|
1777
|
-
else if (service === "open_cover") {
|
|
1778
|
-
e.state = "open";
|
|
1779
|
-
if ("current_position" in e.attributes) e.attributes.current_position = 100;
|
|
1780
|
-
} else if (service === "close_cover") {
|
|
1781
|
-
e.state = "closed";
|
|
1782
|
-
if ("current_position" in e.attributes) e.attributes.current_position = 0;
|
|
1783
|
-
} else if (service === "set_cover_position" && _serviceData.position != null) {
|
|
1784
|
-
e.attributes.current_position = _serviceData.position;
|
|
1785
|
-
e.state = _serviceData.position > 0 ? "open" : "closed";
|
|
1786
|
-
} else if (service === "open_cover_tilt") e.attributes.current_tilt_position = 100;
|
|
1787
|
-
else if (service === "close_cover_tilt") e.attributes.current_tilt_position = 0;
|
|
1788
|
-
else if (service === "set_cover_tilt_position" && _serviceData.tilt_position != null) e.attributes.current_tilt_position = _serviceData.tilt_position;
|
|
1789
|
-
else if (service === "set_temperature") {
|
|
1790
|
-
if (_serviceData.temperature != null) e.attributes.temperature = _serviceData.temperature;
|
|
1791
|
-
if (_serviceData.target_temp_low != null) e.attributes.target_temp_low = _serviceData.target_temp_low;
|
|
1792
|
-
if (_serviceData.target_temp_high != null) e.attributes.target_temp_high = _serviceData.target_temp_high;
|
|
1793
|
-
} else if (service === "set_hvac_mode" && _serviceData.hvac_mode != null) e.state = _serviceData.hvac_mode;
|
|
1794
|
-
else if (service === "set_fan_mode" && _serviceData.fan_mode != null) e.attributes.fan_mode = _serviceData.fan_mode;
|
|
1795
|
-
else if (service === "set_preset_mode" && _serviceData.preset_mode != null) {
|
|
1796
|
-
e.attributes.preset_mode = _serviceData.preset_mode;
|
|
1797
|
-
if (entityDomain === "fan") e.state = "on";
|
|
1798
|
-
} else if (service === "set_percentage" && _serviceData.percentage != null) {
|
|
1799
|
-
e.attributes.percentage = _serviceData.percentage;
|
|
1800
|
-
e.state = _serviceData.percentage > 0 ? "on" : "off";
|
|
1801
|
-
} else if (service === "oscillate" && _serviceData.oscillating != null) e.attributes.oscillating = _serviceData.oscillating;
|
|
1802
|
-
else if (service === "set_direction" && _serviceData.direction != null) e.attributes.current_direction = _serviceData.direction;
|
|
1803
|
-
else if (service === "set_operation_mode" && _serviceData.operation_mode != null) e.state = _serviceData.operation_mode;
|
|
1804
|
-
else if (service === "set_away_mode" && _serviceData.away_mode != null) e.attributes.away_mode = _serviceData.away_mode ? "on" : "off";
|
|
1805
|
-
else if (service === "volume_set" && _serviceData.volume_level != null) e.attributes.volume_level = _serviceData.volume_level;
|
|
1806
|
-
else if (service === "media_play_pause") e.state = e.state === "playing" ? "paused" : "playing";
|
|
1807
|
-
else if (service === "media_next_track" || service === "media_previous_track") applyTrackSkip(e, service === "media_next_track" ? 1 : -1);
|
|
1808
|
-
else if (service === "select_source" && _serviceData.source != null) e.attributes.source = _serviceData.source;
|
|
1809
|
-
}));
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1812
|
-
function applyTurnOn(e, domain, serviceData) {
|
|
1813
|
-
if (domain === "light") {
|
|
1814
|
-
e.state = "on";
|
|
1815
|
-
if (serviceData.brightness_pct !== void 0) {
|
|
1816
|
-
const pct = Math.max(0, Math.min(100, serviceData.brightness_pct));
|
|
1817
|
-
e.attributes.brightness = Math.round(pct / 100 * 255);
|
|
1818
|
-
} else e.attributes.brightness = serviceData.brightness ?? e.attributes.brightness ?? 255;
|
|
1819
|
-
if (serviceData.color_temp_kelvin) e.attributes.color_temp_kelvin = serviceData.color_temp_kelvin;
|
|
1820
|
-
if (serviceData.hs_color) e.attributes.hs_color = serviceData.hs_color;
|
|
1821
|
-
} else if (domain === "scene") e.state = (/* @__PURE__ */ new Date()).toISOString();
|
|
1822
|
-
else if (domain === "media_player") e.state = "playing";
|
|
1823
|
-
else if (domain === "cover") {
|
|
1824
|
-
e.state = "open";
|
|
1825
|
-
if ("current_position" in e.attributes) e.attributes.current_position = 100;
|
|
1826
|
-
} else e.state = "on";
|
|
1827
|
-
}
|
|
1828
|
-
const DEMO_TRACKS = [
|
|
1829
|
-
{
|
|
1830
|
-
title: "Lo-fi Beats",
|
|
1831
|
-
artist: "Chill Station"
|
|
1832
|
-
},
|
|
1833
|
-
{
|
|
1834
|
-
title: "Golden Hour",
|
|
1835
|
-
artist: "Analog Sunset"
|
|
1836
|
-
},
|
|
1837
|
-
{
|
|
1838
|
-
title: "Night Drive",
|
|
1839
|
-
artist: "Neon Coast"
|
|
1840
|
-
}
|
|
1841
|
-
];
|
|
1842
|
-
function applyTrackSkip(e, delta) {
|
|
1843
|
-
const current = DEMO_TRACKS.findIndex((t) => t.title === e.attributes.media_title);
|
|
1844
|
-
const next = DEMO_TRACKS[(current + delta + DEMO_TRACKS.length) % DEMO_TRACKS.length];
|
|
1845
|
-
if (!next) return;
|
|
1846
|
-
e.attributes.media_title = next.title;
|
|
1847
|
-
e.attributes.media_artist = next.artist;
|
|
1848
|
-
}
|
|
1849
|
-
function applyTurnOff(e, domain) {
|
|
1850
|
-
if (domain === "light") {
|
|
1851
|
-
e.state = "off";
|
|
1852
|
-
delete e.attributes.brightness;
|
|
1853
|
-
} else if (domain === "media_player") e.state = "paused";
|
|
1854
|
-
else if (domain === "cover") {
|
|
1855
|
-
e.state = "closed";
|
|
1856
|
-
if ("current_position" in e.attributes) e.attributes.current_position = 0;
|
|
1857
|
-
} else e.state = "off";
|
|
1858
|
-
}
|
|
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
|
|
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 };
|