@glasshome/sync-layer 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,4959 @@
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/history/points.ts
376
+ /** Hard cap on retained points per entity timeline. */
377
+ const MAX_HISTORY_POINTS = 5760;
378
+ /**
379
+ * Append a state change to an entity's history.
380
+ * Called by the entity subscription handler when a tracked entity changes.
381
+ */
382
+ function appendHistoryPoint(entityId, stateValue, attributes, lastUpdated, lastChanged) {
383
+ setState(produce((s) => {
384
+ const history = s.history[entityId];
385
+ if (!history) return;
386
+ const point = {
387
+ s: stateValue,
388
+ a: attributes,
389
+ lu: lastUpdated,
390
+ lc: lastChanged
391
+ };
392
+ history.entityHistory.push(point);
393
+ history.timeline.push({
394
+ timestamp: lastUpdated,
395
+ state: stateValue,
396
+ attributes,
397
+ lastChanged,
398
+ lastUpdated
399
+ });
400
+ }));
401
+ }
402
+ /**
403
+ * Append multiple history points in a single setState(produce(...)).
404
+ * Called by the rAF-batched subscription flush.
405
+ */
406
+ function bulkAppendHistoryPoints(points) {
407
+ if (points.length === 0) return;
408
+ setState(produce((s) => {
409
+ const touched = /* @__PURE__ */ new Set();
410
+ for (const { entityId, stateValue, attributes, lastUpdated, lastChanged } of points) {
411
+ const history = s.history[entityId];
412
+ if (!history) continue;
413
+ history.entityHistory.push({
414
+ s: stateValue,
415
+ a: attributes,
416
+ lu: lastUpdated,
417
+ lc: lastChanged
418
+ });
419
+ history.timeline.push({
420
+ timestamp: lastUpdated,
421
+ state: stateValue,
422
+ attributes,
423
+ lastChanged,
424
+ lastUpdated
425
+ });
426
+ touched.add(entityId);
427
+ }
428
+ for (const entityId of touched) {
429
+ const history = s.history[entityId];
430
+ if (!history) continue;
431
+ if (history.entityHistory.length > 5760) history.entityHistory.splice(0, history.entityHistory.length - MAX_HISTORY_POINTS);
432
+ if (history.timeline.length > 5760) history.timeline.splice(0, history.timeline.length - MAX_HISTORY_POINTS);
433
+ }
434
+ }));
435
+ }
436
+ //#endregion
437
+ //#region src/demo/demo-album-cover.ts
438
+ const DEMO_ALBUM_COVER = "data:image/webp;base64,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";
439
+ //#endregion
440
+ //#region src/demo/world/sun.ts
441
+ const RAD$1 = Math.PI / 180;
442
+ function declinationAndEquation(ms) {
443
+ const d = ms / 864e5 + 2440587.5 - 2451545;
444
+ const g = (357.529 + .98560028 * d) * RAD$1;
445
+ const q = 280.459 + .98564736 * d;
446
+ const l = (q + 1.915 * Math.sin(g) + .02 * Math.sin(2 * g)) * RAD$1;
447
+ const e = (23.439 - 36e-8 * d) * RAD$1;
448
+ const ra = Math.atan2(Math.cos(e) * Math.sin(l), Math.cos(l)) / RAD$1 / 15;
449
+ return {
450
+ decl: Math.asin(Math.sin(e) * Math.sin(l)),
451
+ eqTimeMin: ((q / 15 - (ra % 24 + 24) % 24) * 60 + 720) % 1440 - 720
452
+ };
453
+ }
454
+ function solarElevation(ms, lat, lon) {
455
+ const { decl, eqTimeMin } = declinationAndEquation(ms);
456
+ const hourAngle = ((ms / 6e4 % 1440 + eqTimeMin + lon * 4) / 4 - 180) * RAD$1;
457
+ const phi = lat * RAD$1;
458
+ const sinEl = Math.sin(phi) * Math.sin(decl) + Math.cos(phi) * Math.cos(decl) * Math.cos(hourAngle);
459
+ return Math.asin(sinEl) / RAD$1;
460
+ }
461
+ const STEP_MS = 6e4;
462
+ function nextCrossing(ms, lat, lon, rising) {
463
+ let prev = solarElevation(ms, lat, lon);
464
+ for (let t = ms + STEP_MS; t < ms + 1728e5; t += STEP_MS) {
465
+ const el = solarElevation(t, lat, lon);
466
+ if (rising ? prev < -.833 && el >= -.833 : prev >= -.833 && el < -.833) return t;
467
+ prev = el;
468
+ }
469
+ return ms + 864e5;
470
+ }
471
+ function sunTimes(ms, lat, lon) {
472
+ return {
473
+ risingMs: nextCrossing(ms, lat, lon, true),
474
+ settingMs: nextCrossing(ms, lat, lon, false)
475
+ };
476
+ }
477
+ //#endregion
478
+ //#region src/demo/world/local-time.ts
479
+ const formatters = /* @__PURE__ */ new Map();
480
+ function formatter(timeZone) {
481
+ let f = formatters.get(timeZone);
482
+ if (!f) {
483
+ f = new Intl.DateTimeFormat("en-US", {
484
+ timeZone,
485
+ hourCycle: "h23",
486
+ year: "numeric",
487
+ month: "2-digit",
488
+ day: "2-digit",
489
+ hour: "2-digit",
490
+ minute: "2-digit",
491
+ second: "2-digit",
492
+ weekday: "short"
493
+ });
494
+ formatters.set(timeZone, f);
495
+ }
496
+ return f;
497
+ }
498
+ const WEEKDAYS = {
499
+ Sun: 0,
500
+ Mon: 1,
501
+ Tue: 2,
502
+ Wed: 3,
503
+ Thu: 4,
504
+ Fri: 5,
505
+ Sat: 6
506
+ };
507
+ function parts(ms, timeZone) {
508
+ const out = {};
509
+ for (const p of formatter(timeZone).formatToParts(ms)) out[p.type] = p.value;
510
+ return {
511
+ year: Number(out.year),
512
+ month: Number(out.month),
513
+ day: Number(out.day),
514
+ hour: Number(out.hour),
515
+ minute: Number(out.minute),
516
+ second: Number(out.second),
517
+ weekday: WEEKDAYS[out.weekday ?? "Sun"] ?? 0
518
+ };
519
+ }
520
+ function utcOffsetMinutes(ms, timeZone) {
521
+ const p = parts(ms, timeZone);
522
+ const asUtc = Date.UTC(p.year, p.month - 1, p.day, p.hour, p.minute, p.second);
523
+ return Math.round((asUtc - Math.floor(ms / 1e3) * 1e3) / 6e4);
524
+ }
525
+ function localTime$1(ms, timeZone) {
526
+ const p = parts(ms, timeZone);
527
+ const minuteOfDay = p.hour * 60 + p.minute + p.second / 60;
528
+ const startOfYear = Date.UTC(p.year, 0, 1);
529
+ const dayUtc = Date.UTC(p.year, p.month - 1, p.day);
530
+ const dateKey = `${p.year}-${String(p.month).padStart(2, "0")}-${String(p.day).padStart(2, "0")}`;
531
+ const midnightMs = dayUtc - utcOffsetMinutes(dayUtc, timeZone) * 6e4;
532
+ return {
533
+ hour: minuteOfDay / 60,
534
+ minuteOfDay,
535
+ weekday: p.weekday,
536
+ dayOfYear: Math.round((dayUtc - startOfYear) / 864e5) + 1,
537
+ dateKey,
538
+ midnightMs
539
+ };
540
+ }
541
+ //#endregion
542
+ //#region src/demo/world/noise.ts
543
+ function noise(seed, key) {
544
+ let h = 2166136261 ^ seed;
545
+ for (let i = 0; i < key.length; i++) {
546
+ h ^= key.charCodeAt(i);
547
+ h = Math.imul(h, 16777619);
548
+ }
549
+ h ^= h >>> 13;
550
+ h = Math.imul(h, 1540483477);
551
+ h ^= h >>> 15;
552
+ return (h >>> 0) / 4294967296;
553
+ }
554
+ //#endregion
555
+ //#region src/demo/world/world.ts
556
+ const LATITUDE = 48;
557
+ function worldFor(timeZone, seed, atMs) {
558
+ return {
559
+ timeZone,
560
+ seed,
561
+ latitude: LATITUDE,
562
+ longitude: utcOffsetMinutes(atMs, timeZone) / 4
563
+ };
564
+ }
565
+ let current = {
566
+ timeZone: "UTC",
567
+ seed: 1,
568
+ latitude: LATITUDE,
569
+ longitude: 0
570
+ };
571
+ function setWorld(w) {
572
+ current = w;
573
+ }
574
+ function getWorld() {
575
+ return current;
576
+ }
577
+ function outdoorTempC(ms, w) {
578
+ const lt = localTime$1(ms, w.timeZone);
579
+ const seasonal = 10 - 11 * Math.cos((lt.dayOfYear - 20) / 365 * 2 * Math.PI);
580
+ const diurnal = 5 * Math.cos((lt.hour - 15) / 24 * 2 * Math.PI);
581
+ const dayDrift = (noise(w.seed, `temp:${lt.dateKey}`) - .5) * 6;
582
+ return seasonal + diurnal + dayDrift;
583
+ }
584
+ function cloudCover(ms, w) {
585
+ const lt = localTime$1(ms, w.timeZone);
586
+ const base = noise(w.seed, `cloud:${lt.dateKey}`);
587
+ const hourly = noise(w.seed, `cloud:${lt.dateKey}:${Math.floor(lt.hour)}`);
588
+ return Math.min(1, Math.max(0, base * .7 + hourly * .3));
589
+ }
590
+ //#endregion
591
+ //#region src/demo/energy-sim.ts
592
+ /**
593
+ * Demo Energy Simulation
594
+ *
595
+ * A pure, deterministic model of a solar home: solar production, household
596
+ * load with appliance events, a battery that charges on surplus and covers
597
+ * deficits, and grid import/export as the residual.
598
+ *
599
+ * `simulateEnergy(timestampMs)` is the single source of truth. Live ticks,
600
+ * day history, and long-term statistics are all derived from it, so every
601
+ * widget agrees with every other widget. There is no hidden mutable state:
602
+ * the only module-level data is a memoization cache of a pure computation
603
+ * (battery SOC integrated from local midnight).
604
+ *
605
+ * @packageDocumentation
606
+ */
607
+ const SOLAR_PEAK_W = 5200;
608
+ const ALWAYS_ON_W = 285;
609
+ const FRIDGE_W = 95;
610
+ const FRIDGE_CYCLE_MIN = 45;
611
+ const FRIDGE_ON_MIN = 18;
612
+ const BATTERY_CAPACITY_WH = 1e4;
613
+ const BATTERY_MIN_SOC = 10;
614
+ const BATTERY_MAX_SOC = 100;
615
+ const BATTERY_POWER_CAP_W = 3e3;
616
+ const MIDNIGHT_SOC = 55;
617
+ const SOC_STEP_MIN = 5;
618
+ /** Integer hash -> uint32, used to seed mulberry32 deterministically. */
619
+ function hashInt(n) {
620
+ let h = n | 0;
621
+ h = Math.imul(h ^ h >>> 16, 73244475);
622
+ h = Math.imul(h ^ h >>> 16, 73244475);
623
+ h = (h ^ h >>> 16) >>> 0;
624
+ return h;
625
+ }
626
+ /** Deterministic [0,1) generator seeded by an integer. */
627
+ function mulberry32(seed) {
628
+ let a = seed >>> 0;
629
+ return () => {
630
+ a |= 0;
631
+ a = a + 1831565813 | 0;
632
+ let t = Math.imul(a ^ a >>> 15, 1 | a);
633
+ t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
634
+ return ((t ^ t >>> 14) >>> 0) / 4294967296;
635
+ };
636
+ }
637
+ function localTime(timestampMs) {
638
+ const lt = localTime$1(timestampMs, getWorld().timeZone);
639
+ return {
640
+ hourFrac: lt.hour,
641
+ minuteOfDay: lt.minuteOfDay,
642
+ dayOfWeek: lt.weekday,
643
+ dayKey: Math.round(lt.midnightMs / 864e5),
644
+ midnightMs: lt.midnightMs
645
+ };
646
+ }
647
+ const RAD = Math.PI / 180;
648
+ function solarPower(timestampMs) {
649
+ const w = getWorld();
650
+ const elevation = solarElevation(timestampMs, w.latitude, w.longitude);
651
+ if (elevation <= 0) return 0;
652
+ const shape = Math.sin(elevation * RAD) ** 1.3;
653
+ return SOLAR_PEAK_W * shape * (1 - .7 * cloudCover(timestampMs, w));
654
+ }
655
+ function fridgePower(lt) {
656
+ const phase = mulberry32(hashInt(lt.dayKey * 40503 + 7))() * FRIDGE_CYCLE_MIN;
657
+ return (lt.minuteOfDay + phase) % FRIDGE_CYCLE_MIN < FRIDGE_ON_MIN ? FRIDGE_W : 0;
658
+ }
659
+ /** A square wave: `power` while inside [start, start+durationMin). */
660
+ function windowPower(minuteOfDay, startMin, durationMin, power) {
661
+ return minuteOfDay >= startMin && minuteOfDay < startMin + durationMin ? power : 0;
662
+ }
663
+ function kettlePower(lt) {
664
+ return windowPower(lt.minuteOfDay, 430, 4, 2e3) + windowPower(lt.minuteOfDay, 1e3, 4, 2e3);
665
+ }
666
+ function ovenPower(lt) {
667
+ const start = 1125;
668
+ if (lt.minuteOfDay < start || lt.minuteOfDay >= 1165) return 0;
669
+ return (lt.minuteOfDay - start) % 6.5 < 4 ? 2200 : 0;
670
+ }
671
+ function dishwasherPower(lt) {
672
+ const start = 1270;
673
+ if (lt.minuteOfDay < start || lt.minuteOfDay >= 1335) return 0;
674
+ const into = lt.minuteOfDay - start;
675
+ if (into < 12) return 1900;
676
+ if (into >= 57) return 1900;
677
+ return 120;
678
+ }
679
+ function washerPower(lt) {
680
+ if (![
681
+ 1,
682
+ 4,
683
+ 6
684
+ ].includes(lt.dayOfWeek)) return 0;
685
+ const start = 630;
686
+ if (lt.minuteOfDay < start || lt.minuteOfDay >= 680) return 0;
687
+ return lt.minuteOfDay - start < 14 ? 2100 : 250;
688
+ }
689
+ function evChargerPower(lt) {
690
+ if (![
691
+ 2,
692
+ 4,
693
+ 0
694
+ ].includes(lt.dayOfWeek)) return 0;
695
+ const start = 1100;
696
+ if (lt.minuteOfDay < start || lt.minuteOfDay >= 1300) return 0;
697
+ const into = lt.minuteOfDay - start;
698
+ return 7200 * (into < 2 ? into / 2 : 1);
699
+ }
700
+ const HEAT_PUMP_PEAK_W = 3200;
701
+ /**
702
+ * A smooth heat-pump load: a slow morning/evening envelope carrying a
703
+ * few-minute shimmer. Its peaks push the home deficit past the battery's power
704
+ * cap, so grid import (and the running cost) visibly rise and fall on live
705
+ * ticks instead of sitting at zero. Gated out of the deep-night window so the
706
+ * quiet 02:00-03:00 baseline is unchanged.
707
+ */
708
+ function heatPumpPower(lt) {
709
+ const h = lt.hourFrac;
710
+ let envelope = 0;
711
+ if (h >= 6 && h <= 9) envelope = Math.sin((h - 6) / 3 * Math.PI);
712
+ else if (h >= 16 && h <= 23) envelope = Math.sin((h - 16) / 7 * Math.PI);
713
+ else return 0;
714
+ const shimmer = .7 + .3 * Math.sin(lt.minuteOfDay * Math.PI * 2);
715
+ return HEAT_PUMP_PEAK_W * envelope * shimmer;
716
+ }
717
+ /** Smooth occupancy envelope: extra load mornings and evenings. */
718
+ function occupancyEnvelope(lt) {
719
+ const h = lt.hourFrac;
720
+ let e = 0;
721
+ if (h >= 7 && h <= 9) e += 80 + 270 * Math.sin((h - 7) / 2 * Math.PI);
722
+ if (h >= 17.5 && h <= 23) e += 80 + 270 * Math.sin((h - 17.5) / 5.5 * Math.PI);
723
+ return e;
724
+ }
725
+ /** Low-amplitude smooth noise multiplier, ±4%, seeded per day. */
726
+ function homeNoise(lt) {
727
+ const phase = mulberry32(hashInt(lt.dayKey * 22699 + 13))() * Math.PI * 2;
728
+ return 1 + .04 * Math.sin(lt.minuteOfDay / 23 * Math.PI * 2 + phase);
729
+ }
730
+ const socCache = /* @__PURE__ */ new Map();
731
+ /** Net surplus (solar - home) at a given local-time sample, in watts. */
732
+ function netSurplusAt(timestampMs) {
733
+ const lt = localTime(timestampMs);
734
+ return solarPower(timestampMs) - homePower(lt);
735
+ }
736
+ /**
737
+ * Battery SOC (%) at every {@link SOC_STEP_MIN}-minute step from local
738
+ * midnight. Returns step `i` = SOC after integrating `i` steps. Index 0 is
739
+ * the midnight SOC. Memoized per local day (pure computation).
740
+ */
741
+ function socPrefix(dayKey, midnightMs) {
742
+ const cached = socCache.get(dayKey);
743
+ if (cached) return cached;
744
+ const steps = Math.ceil(1440 / SOC_STEP_MIN);
745
+ const arr = /* @__PURE__ */ new Float64Array(289);
746
+ arr[0] = MIDNIGHT_SOC;
747
+ const stepMs = SOC_STEP_MIN * 6e4;
748
+ const stepHours = SOC_STEP_MIN / 60;
749
+ for (let i = 0; i < steps; i++) {
750
+ const soc = arr[i] ?? MIDNIGHT_SOC;
751
+ const surplus = netSurplusAt(midnightMs + i * stepMs);
752
+ let chargeW = 0;
753
+ let dischargeW = 0;
754
+ if (surplus > 0) {
755
+ const headroomW = (BATTERY_MAX_SOC - soc) / 100 * BATTERY_CAPACITY_WH / stepHours;
756
+ chargeW = Math.min(surplus, BATTERY_POWER_CAP_W, Math.max(0, headroomW));
757
+ } else if (surplus < 0) {
758
+ const availW = (soc - BATTERY_MIN_SOC) / 100 * BATTERY_CAPACITY_WH / stepHours;
759
+ dischargeW = Math.min(-surplus, BATTERY_POWER_CAP_W, Math.max(0, availW));
760
+ }
761
+ const nextSoc = soc + (chargeW - dischargeW) * stepHours / BATTERY_CAPACITY_WH * 100;
762
+ arr[i + 1] = Math.max(BATTERY_MIN_SOC, Math.min(BATTERY_MAX_SOC, nextSoc));
763
+ }
764
+ socCache.set(dayKey, arr);
765
+ return arr;
766
+ }
767
+ /** Battery SOC (%) at an exact timestamp, linearly interpolated between steps. */
768
+ function batterySoc(lt, midnightMs) {
769
+ const prefix = socPrefix(lt.dayKey, midnightMs);
770
+ const stepFloat = lt.minuteOfDay / SOC_STEP_MIN;
771
+ const i = Math.floor(stepFloat);
772
+ const frac = stepFloat - i;
773
+ const a = prefix[i] ?? MIDNIGHT_SOC;
774
+ return a + ((prefix[i + 1] ?? a) - a) * frac;
775
+ }
776
+ function homePower(lt) {
777
+ return (ALWAYS_ON_W + fridgePower(lt) + kettlePower(lt) + ovenPower(lt) + dishwasherPower(lt) + washerPower(lt) + evChargerPower(lt) + heatPumpPower(lt) + occupancyEnvelope(lt)) * homeNoise(lt);
778
+ }
779
+ /**
780
+ * Compute every instantaneous energy value at a timestamp.
781
+ *
782
+ * Deterministic: equal timestamps always yield deep-equal samples. Obeys the
783
+ * conservation invariant
784
+ * `solar + gridImport + batteryDischarge === home + gridExport + batteryCharge`
785
+ * to within rounding.
786
+ */
787
+ function simulateEnergy(timestampMs) {
788
+ const lt = localTime(timestampMs);
789
+ const midnightMs = lt.midnightMs;
790
+ const solarW = solarPower(timestampMs);
791
+ const fridgeW = fridgePower(lt);
792
+ const ovenW = ovenPower(lt);
793
+ const dishwasherW = dishwasherPower(lt);
794
+ const washerW = washerPower(lt);
795
+ const evChargerW = evChargerPower(lt);
796
+ const alwaysOnW = ALWAYS_ON_W;
797
+ const homeW = homePower(lt);
798
+ const soc = batterySoc(lt, midnightMs);
799
+ const surplus = solarW - homeW;
800
+ let batteryChargeW = 0;
801
+ let batteryDischargeW = 0;
802
+ if (surplus > 0) batteryChargeW = Math.min(surplus, soc < BATTERY_MAX_SOC ? BATTERY_POWER_CAP_W : 0);
803
+ else if (surplus < 0) {
804
+ const avail = soc > BATTERY_MIN_SOC ? BATTERY_POWER_CAP_W : 0;
805
+ batteryDischargeW = Math.min(-surplus, avail);
806
+ }
807
+ const remainingDeficit = homeW - solarW - batteryDischargeW + batteryChargeW;
808
+ const gridImportW = Math.max(0, remainingDeficit);
809
+ const gridExportW = Math.max(0, -remainingDeficit);
810
+ return {
811
+ solarW,
812
+ homeW,
813
+ batteryChargeW,
814
+ batteryDischargeW,
815
+ batterySocPct: soc,
816
+ gridImportW,
817
+ gridExportW,
818
+ fridgeW,
819
+ dishwasherW,
820
+ washerW,
821
+ ovenW,
822
+ evChargerW,
823
+ alwaysOnW
824
+ };
825
+ }
826
+ /** Map an energy entity id to its numeric value from a sample (W, or % for SOC). */
827
+ function energyEntityValue(entityId, sample) {
828
+ switch (entityId) {
829
+ case "sensor.solar_power": return sample.solarW;
830
+ case "sensor.grid_import_power": return sample.gridImportW;
831
+ case "sensor.grid_export_power": return sample.gridExportW;
832
+ case "sensor.battery_charge_power": return sample.batteryChargeW;
833
+ case "sensor.battery_discharge_power": return sample.batteryDischargeW;
834
+ case "sensor.battery_soc": return sample.batterySocPct;
835
+ case "sensor.home_power": return sample.homeW;
836
+ case "sensor.fridge_power": return sample.fridgeW;
837
+ case "sensor.dishwasher_power": return sample.dishwasherW;
838
+ case "sensor.washing_machine_power": return sample.washerW;
839
+ case "sensor.oven_power": return sample.ovenW;
840
+ case "sensor.ev_charger_power": return sample.evChargerW;
841
+ case "sensor.always_on_power": return sample.alwaysOnW;
842
+ default: return;
843
+ }
844
+ }
845
+ /** Whether an entity id is one of the simulated energy sensors. */
846
+ function isEnergyEntity(entityId) {
847
+ return energyEntityValue(entityId, ZERO_SAMPLE) !== void 0;
848
+ }
849
+ const ZERO_SAMPLE = {
850
+ solarW: 0,
851
+ homeW: 0,
852
+ batteryChargeW: 0,
853
+ batteryDischargeW: 0,
854
+ batterySocPct: 0,
855
+ gridImportW: 0,
856
+ gridExportW: 0,
857
+ fridgeW: 0,
858
+ dishwasherW: 0,
859
+ washerW: 0,
860
+ ovenW: 0,
861
+ evChargerW: 0,
862
+ alwaysOnW: 0
863
+ };
864
+ /** Round to 1 decimal for display-friendly state strings (whole % for SOC). */
865
+ function formatEnergyState(entityId, value) {
866
+ if (entityId === "sensor.battery_soc") return String(Math.round(value));
867
+ return String(Math.round(value));
868
+ }
869
+ /**
870
+ * Synthesize history points for one energy entity by sampling the model across
871
+ * the window at a fixed cadence (default 1-minute). Returns HA-compressed
872
+ * `{ lu, s }` points with `lu` in Unix seconds.
873
+ */
874
+ function synthesizeEnergyHistory(entityId, startMs, endMs, stepMs = 6e4) {
875
+ const points = [];
876
+ for (let t = startMs; t <= endMs; t += stepMs) {
877
+ const value = energyEntityValue(entityId, simulateEnergy(t));
878
+ if (value === void 0) continue;
879
+ points.push({
880
+ lu: Math.round(t / 1e3),
881
+ s: formatEnergyState(entityId, value)
882
+ });
883
+ }
884
+ return points;
885
+ }
886
+ /** Bucket length in ms for a statistics period. */
887
+ function bucketMs(period) {
888
+ return period === "hour" ? 36e5 : 864e5;
889
+ }
890
+ /**
891
+ * Synthesize statistics buckets for one energy entity across a window.
892
+ *
893
+ * `mean`/`min`/`max` come from 5-minute power samples in the bucket; `sum` is
894
+ * the energy in Wh (mean W times bucket hours). `sum` is cumulative across the
895
+ * window, matching HA's monotonically increasing meter semantics.
896
+ */
897
+ function synthesizeEnergyStatistics(entityId, startMs, endMs, period) {
898
+ const span = bucketMs(period);
899
+ const sampleMs = 3e5;
900
+ const buckets = [];
901
+ let cumulativeWh = 0;
902
+ for (let bStart = startMs; bStart < endMs; bStart += span) {
903
+ const bEnd = bStart + span;
904
+ let sum = 0;
905
+ let count = 0;
906
+ let min = Number.POSITIVE_INFINITY;
907
+ let max = Number.NEGATIVE_INFINITY;
908
+ for (let t = bStart; t < bEnd; t += sampleMs) {
909
+ const value = energyEntityValue(entityId, simulateEnergy(t));
910
+ if (value === void 0) continue;
911
+ sum += value;
912
+ count++;
913
+ if (value < min) min = value;
914
+ if (value > max) max = value;
915
+ }
916
+ if (count === 0) continue;
917
+ const mean = sum / count;
918
+ const bucketHours = span / 36e5;
919
+ const energyWh = entityId === "sensor.battery_soc" ? 0 : mean * bucketHours;
920
+ cumulativeWh += energyWh;
921
+ buckets.push({
922
+ start: bStart,
923
+ end: bEnd,
924
+ mean,
925
+ min: min === Number.POSITIVE_INFINITY ? mean : min,
926
+ max: max === Number.NEGATIVE_INFINITY ? mean : max,
927
+ sum: cumulativeWh,
928
+ change: energyWh / 1e3
929
+ });
930
+ }
931
+ return buckets;
932
+ }
933
+ /** The demo energy dashboard preferences wiring the simulated sensors. */
934
+ function demoEnergyPreferences() {
935
+ return {
936
+ energy_sources: [
937
+ {
938
+ type: "solar",
939
+ stat_energy_from: "sensor.solar_power"
940
+ },
941
+ {
942
+ type: "grid",
943
+ flow_from: [{ stat_energy_from: "sensor.grid_import_power" }],
944
+ flow_to: [{ stat_energy_to: "sensor.grid_export_power" }]
945
+ },
946
+ {
947
+ type: "battery",
948
+ stat_energy_from: "sensor.battery_discharge_power",
949
+ stat_energy_to: "sensor.battery_charge_power"
950
+ }
951
+ ],
952
+ device_consumption: [
953
+ {
954
+ stat_consumption: "sensor.fridge_power",
955
+ name: "Fridge"
956
+ },
957
+ {
958
+ stat_consumption: "sensor.dishwasher_power",
959
+ name: "Dishwasher"
960
+ },
961
+ {
962
+ stat_consumption: "sensor.washing_machine_power",
963
+ name: "Washing Machine"
964
+ },
965
+ {
966
+ stat_consumption: "sensor.oven_power",
967
+ name: "Oven"
968
+ },
969
+ {
970
+ stat_consumption: "sensor.ev_charger_power",
971
+ name: "EV Charger"
972
+ },
973
+ {
974
+ stat_consumption: "sensor.always_on_power",
975
+ name: "Always On"
976
+ }
977
+ ]
978
+ };
979
+ }
980
+ //#endregion
981
+ //#region src/demo/kinds/types.ts
982
+ /** Registry override wins; otherwise domain.device-key[_suffix], the generator's default naming. */
983
+ function entityIdFor(domain, device, role, suffix) {
984
+ const explicit = device.ids?.[role];
985
+ if (explicit) return explicit;
986
+ const base = device.key;
987
+ return `${domain}.${suffix ? `${base}_${suffix}` : base}`;
988
+ }
989
+ //#endregion
990
+ //#region src/demo/kinds/actuators.ts
991
+ const round1$1 = (n) => Math.round(n * 10) / 10;
992
+ const round2 = (n) => Math.round(n * 100) / 100;
993
+ const round10$1 = (n) => Math.round(n / 10) * 10;
994
+ const roundInt$1 = (n) => Math.round(n);
995
+ const clamp = (n, lo, hi) => Math.min(hi, Math.max(lo, n));
996
+ function lightColorModes(p) {
997
+ if (p.color) return p.colorTemp ? ["hs", "color_temp"] : ["hs"];
998
+ if (p.colorTemp) return ["color_temp"];
999
+ if (p.dimmable) return ["brightness"];
1000
+ return ["onoff"];
1001
+ }
1002
+ function lightSupportedFeatures(p) {
1003
+ return p.supportedFeatures ?? (p.color || p.colorTemp ? 44 : p.dimmable ? 1 : 0);
1004
+ }
1005
+ function applyLightOn(s, data) {
1006
+ const next = {
1007
+ ...s,
1008
+ on: true
1009
+ };
1010
+ if (data.brightness_pct != null) next.brightness = roundInt$1(clamp(Number(data.brightness_pct), 0, 100) / 100 * 255);
1011
+ else if (data.brightness != null) next.brightness = Number(data.brightness);
1012
+ if (data.color_temp_kelvin != null) {
1013
+ next.colorTempKelvin = Number(data.color_temp_kelvin);
1014
+ next.colorMode = "color_temp";
1015
+ }
1016
+ if (data.hs_color != null) {
1017
+ next.hsColor = data.hs_color;
1018
+ next.colorMode = "hs";
1019
+ }
1020
+ return next;
1021
+ }
1022
+ const light = {
1023
+ entities(device) {
1024
+ const p = device.params;
1025
+ return [{
1026
+ entityId: entityIdFor("light", device, "light"),
1027
+ name: device.name,
1028
+ supportedFeatures: lightSupportedFeatures(p),
1029
+ primary: true
1030
+ }];
1031
+ },
1032
+ apply(state, event, device) {
1033
+ const p = device.params;
1034
+ const s = state ?? {
1035
+ on: false,
1036
+ brightness: 255,
1037
+ colorMode: lightColorModes(p)[0],
1038
+ watts: p.watts ?? 0
1039
+ };
1040
+ if (event.type !== "call") return s;
1041
+ switch (event.service) {
1042
+ case "turn_on": return applyLightOn(s, event.data);
1043
+ case "turn_off": return {
1044
+ ...s,
1045
+ on: false
1046
+ };
1047
+ case "toggle": return s.on ? {
1048
+ ...s,
1049
+ on: false
1050
+ } : applyLightOn(s, event.data);
1051
+ default: return s;
1052
+ }
1053
+ },
1054
+ project(state, device) {
1055
+ const s = state;
1056
+ const p = device.params;
1057
+ const id = entityIdFor("light", device, "light");
1058
+ const attributes = {
1059
+ supported_color_modes: lightColorModes(p),
1060
+ supported_features: lightSupportedFeatures(p)
1061
+ };
1062
+ if (s.on) {
1063
+ attributes.color_mode = s.colorMode;
1064
+ attributes.brightness = roundInt$1(s.brightness);
1065
+ if (s.colorMode === "color_temp" && s.colorTempKelvin != null) attributes.color_temp_kelvin = s.colorTempKelvin;
1066
+ if (s.colorMode === "hs" && s.hsColor) attributes.hs_color = s.hsColor;
1067
+ }
1068
+ return { [id]: {
1069
+ state: s.on ? "on" : "off",
1070
+ attributes
1071
+ } };
1072
+ },
1073
+ powerW(state) {
1074
+ const s = state;
1075
+ return s.on ? s.watts * s.brightness / 255 : 0;
1076
+ },
1077
+ handles: [
1078
+ "turn_on",
1079
+ "turn_off",
1080
+ "toggle"
1081
+ ]
1082
+ };
1083
+ const KWH_PER_WATT_MS = 1 / 36e8;
1084
+ function switchPowerId(device, p) {
1085
+ return p.legacyPowerId ?? entityIdFor("sensor", device, "power", "power");
1086
+ }
1087
+ function legacyWatts(id, nowMs) {
1088
+ return Number(formatEnergyState(id, energyEntityValue(id, simulateEnergy(nowMs)) ?? 0));
1089
+ }
1090
+ function switchWatts(s, p, nowMs) {
1091
+ if (p.legacyPowerId) return legacyWatts(p.legacyPowerId, nowMs);
1092
+ return s.on ? s.watts : 0;
1093
+ }
1094
+ function nextSwitchState(s, event, p, nowMs) {
1095
+ if (event.type === "tick") {
1096
+ if (!p.energy) return s;
1097
+ return {
1098
+ ...s,
1099
+ energyKwh: s.energyKwh + switchWatts(s, p, nowMs) * event.dtMs * KWH_PER_WATT_MS
1100
+ };
1101
+ }
1102
+ if (event.type !== "call") return s;
1103
+ switch (event.service) {
1104
+ case "turn_on": return {
1105
+ ...s,
1106
+ on: true
1107
+ };
1108
+ case "turn_off": return {
1109
+ ...s,
1110
+ on: false
1111
+ };
1112
+ case "toggle": return {
1113
+ ...s,
1114
+ on: !s.on
1115
+ };
1116
+ default: return s;
1117
+ }
1118
+ }
1119
+ const switchKind = {
1120
+ entities(device) {
1121
+ const p = device.params;
1122
+ const seeds = [{
1123
+ entityId: entityIdFor("switch", device, "switch"),
1124
+ name: device.name,
1125
+ deviceClass: p.deviceClass,
1126
+ primary: true
1127
+ }];
1128
+ if (p.power || p.legacyPowerId) seeds.push({
1129
+ entityId: switchPowerId(device, p),
1130
+ name: "Power",
1131
+ deviceClass: "power",
1132
+ unit: "W"
1133
+ });
1134
+ if (p.energy) seeds.push({
1135
+ entityId: entityIdFor("sensor", device, "energy", "energy"),
1136
+ name: "Energy",
1137
+ deviceClass: "energy",
1138
+ unit: "kWh"
1139
+ });
1140
+ return seeds;
1141
+ },
1142
+ apply(state, event, device, ctx) {
1143
+ const p = device.params;
1144
+ const s = state ?? {
1145
+ on: false,
1146
+ watts: p.watts ?? 0,
1147
+ energyKwh: round2(ctx.noise(`${device.key}:energy`) * 200),
1148
+ lastWatts: 0
1149
+ };
1150
+ const next = nextSwitchState(s, event, p, ctx.nowMs);
1151
+ const lastWatts = switchWatts(next, p, ctx.nowMs);
1152
+ return next === s && s.lastWatts === lastWatts ? s : {
1153
+ ...next,
1154
+ lastWatts
1155
+ };
1156
+ },
1157
+ project(state, device, ctx) {
1158
+ const s = state;
1159
+ const p = device.params;
1160
+ const watts = switchWatts(s, p, ctx.nowMs);
1161
+ const on = p.legacyPowerId ? watts > 5 : s.on;
1162
+ const out = { [entityIdFor("switch", device, "switch")]: {
1163
+ state: on ? "on" : "off",
1164
+ attributes: p.deviceClass ? { device_class: p.deviceClass } : {}
1165
+ } };
1166
+ if (p.power || p.legacyPowerId) out[switchPowerId(device, p)] = {
1167
+ state: String(p.legacyPowerId ? watts : round10$1(watts)),
1168
+ attributes: {
1169
+ unit_of_measurement: "W",
1170
+ device_class: "power",
1171
+ state_class: "measurement"
1172
+ }
1173
+ };
1174
+ if (p.energy) out[entityIdFor("sensor", device, "energy", "energy")] = {
1175
+ state: s.energyKwh.toFixed(2),
1176
+ attributes: {
1177
+ unit_of_measurement: "kWh",
1178
+ device_class: "energy",
1179
+ state_class: "total_increasing"
1180
+ }
1181
+ };
1182
+ return out;
1183
+ },
1184
+ powerW(state) {
1185
+ return state.lastWatts;
1186
+ },
1187
+ handles: [
1188
+ "turn_on",
1189
+ "turn_off",
1190
+ "toggle"
1191
+ ]
1192
+ };
1193
+ function fanSupportedFeatures(p) {
1194
+ let sf = 1;
1195
+ if (p.oscillate) sf |= 2;
1196
+ if (p.direction) sf |= 4;
1197
+ if (p.presets) sf |= 8;
1198
+ return sf;
1199
+ }
1200
+ const fan = {
1201
+ entities(device) {
1202
+ const p = device.params;
1203
+ return [{
1204
+ entityId: entityIdFor("fan", device, "fan"),
1205
+ name: device.name,
1206
+ supportedFeatures: fanSupportedFeatures(p),
1207
+ primary: true
1208
+ }];
1209
+ },
1210
+ apply(state, event, device) {
1211
+ const p = device.params;
1212
+ const s = state ?? {
1213
+ on: false,
1214
+ percentage: 0,
1215
+ oscillating: p.oscillate ? false : void 0,
1216
+ direction: p.direction ? "forward" : void 0,
1217
+ watts: p.watts ?? 0
1218
+ };
1219
+ if (event.type !== "call") return s;
1220
+ const data = event.data;
1221
+ switch (event.service) {
1222
+ case "turn_on": return {
1223
+ ...s,
1224
+ on: true,
1225
+ percentage: data.percentage != null ? Number(data.percentage) : s.percentage > 0 ? s.percentage : 100
1226
+ };
1227
+ case "turn_off": return {
1228
+ ...s,
1229
+ on: false
1230
+ };
1231
+ case "toggle": return s.on ? {
1232
+ ...s,
1233
+ on: false
1234
+ } : {
1235
+ ...s,
1236
+ on: true,
1237
+ percentage: s.percentage > 0 ? s.percentage : 100
1238
+ };
1239
+ case "set_percentage": {
1240
+ const percentage = clamp(Number(data.percentage), 0, 100);
1241
+ return {
1242
+ ...s,
1243
+ percentage,
1244
+ on: percentage > 0
1245
+ };
1246
+ }
1247
+ case "set_preset_mode": return typeof data.preset_mode === "string" ? {
1248
+ ...s,
1249
+ presetMode: data.preset_mode,
1250
+ on: true
1251
+ } : s;
1252
+ case "oscillate": return data.oscillating != null ? {
1253
+ ...s,
1254
+ oscillating: Boolean(data.oscillating)
1255
+ } : s;
1256
+ case "set_direction": return typeof data.direction === "string" ? {
1257
+ ...s,
1258
+ direction: data.direction
1259
+ } : s;
1260
+ default: return s;
1261
+ }
1262
+ },
1263
+ project(state, device) {
1264
+ const s = state;
1265
+ const p = device.params;
1266
+ const id = entityIdFor("fan", device, "fan");
1267
+ const attributes = {
1268
+ percentage: roundInt$1(s.percentage),
1269
+ supported_features: fanSupportedFeatures(p)
1270
+ };
1271
+ if (p.presets) {
1272
+ attributes.preset_modes = p.presets;
1273
+ attributes.preset_mode = s.presetMode;
1274
+ }
1275
+ if (p.oscillate) attributes.oscillating = s.oscillating ?? false;
1276
+ if (p.direction) attributes.current_direction = s.direction ?? "forward";
1277
+ return { [id]: {
1278
+ state: s.on ? "on" : "off",
1279
+ attributes
1280
+ } };
1281
+ },
1282
+ powerW(state) {
1283
+ const s = state;
1284
+ return s.on ? s.watts : 0;
1285
+ },
1286
+ handles: [
1287
+ "turn_on",
1288
+ "turn_off",
1289
+ "toggle",
1290
+ "set_percentage",
1291
+ "set_preset_mode",
1292
+ "oscillate",
1293
+ "set_direction"
1294
+ ]
1295
+ };
1296
+ function coverSupportedFeatures(p) {
1297
+ if (p.supportedFeatures != null) return p.supportedFeatures;
1298
+ let sf = 11;
1299
+ if (p.position) sf |= 4;
1300
+ if (p.tilt) sf |= 240;
1301
+ return sf;
1302
+ }
1303
+ function coverRestLabel(position) {
1304
+ return (position ?? 0) > 0 ? "open" : "closed";
1305
+ }
1306
+ function tickCover(s, dtMs, travelMs) {
1307
+ if (s.position === void 0 || s.target === void 0 || s.position === s.target) return {
1308
+ ...s,
1309
+ state: coverRestLabel(s.position)
1310
+ };
1311
+ const dir = s.target > s.position ? 1 : -1;
1312
+ const step = 100 / travelMs * dtMs;
1313
+ let next = s.position + dir * step;
1314
+ if (dir > 0 && next >= s.target || dir < 0 && next <= s.target) next = s.target;
1315
+ const reached = next === s.target;
1316
+ return {
1317
+ ...s,
1318
+ position: next,
1319
+ state: reached ? coverRestLabel(next) : dir > 0 ? "opening" : "closing"
1320
+ };
1321
+ }
1322
+ const cover = {
1323
+ entities(device) {
1324
+ const p = device.params;
1325
+ return [{
1326
+ entityId: entityIdFor("cover", device, "cover"),
1327
+ name: device.name,
1328
+ deviceClass: p.deviceClass,
1329
+ supportedFeatures: coverSupportedFeatures(p),
1330
+ primary: true
1331
+ }];
1332
+ },
1333
+ apply(state, event, device) {
1334
+ const p = device.params;
1335
+ const s = state ?? {
1336
+ state: "closed",
1337
+ position: p.position ? 0 : void 0,
1338
+ target: p.position ? 0 : void 0,
1339
+ tiltPosition: p.tilt ? 0 : void 0
1340
+ };
1341
+ if (event.type === "tick") return p.position ? tickCover(s, event.dtMs, p.travelMs ?? 8e3) : s;
1342
+ if (event.type !== "call") return s;
1343
+ const data = event.data;
1344
+ switch (event.service) {
1345
+ case "open_cover": return p.position ? {
1346
+ ...s,
1347
+ target: 100
1348
+ } : {
1349
+ ...s,
1350
+ state: "open"
1351
+ };
1352
+ case "close_cover": return p.position ? {
1353
+ ...s,
1354
+ target: 0
1355
+ } : {
1356
+ ...s,
1357
+ state: "closed"
1358
+ };
1359
+ case "stop_cover": return p.position ? {
1360
+ ...s,
1361
+ target: s.position
1362
+ } : s;
1363
+ case "set_cover_position": return p.position && data.position != null ? {
1364
+ ...s,
1365
+ target: clamp(Number(data.position), 0, 100)
1366
+ } : s;
1367
+ case "toggle":
1368
+ if (p.position) {
1369
+ const atOpen = s.state === "open" || s.state === "opening";
1370
+ return {
1371
+ ...s,
1372
+ target: atOpen ? 0 : 100
1373
+ };
1374
+ }
1375
+ return {
1376
+ ...s,
1377
+ state: s.state === "open" ? "closed" : "open"
1378
+ };
1379
+ case "open_cover_tilt": return p.tilt ? {
1380
+ ...s,
1381
+ tiltPosition: 100
1382
+ } : s;
1383
+ case "close_cover_tilt": return p.tilt ? {
1384
+ ...s,
1385
+ tiltPosition: 0
1386
+ } : s;
1387
+ case "set_cover_tilt_position": return p.tilt && data.tilt_position != null ? {
1388
+ ...s,
1389
+ tiltPosition: clamp(Number(data.tilt_position), 0, 100)
1390
+ } : s;
1391
+ case "stop_cover_tilt": return s;
1392
+ default: return s;
1393
+ }
1394
+ },
1395
+ project(state, device) {
1396
+ const s = state;
1397
+ const p = device.params;
1398
+ const id = entityIdFor("cover", device, "cover");
1399
+ const attributes = { supported_features: coverSupportedFeatures(p) };
1400
+ if (p.position) attributes.current_position = roundInt$1(s.position ?? 0);
1401
+ if (p.tilt) attributes.current_tilt_position = roundInt$1(s.tiltPosition ?? 0);
1402
+ if (p.deviceClass) attributes.device_class = p.deviceClass;
1403
+ return { [id]: {
1404
+ state: s.state,
1405
+ attributes
1406
+ } };
1407
+ },
1408
+ handles: [
1409
+ "open_cover",
1410
+ "close_cover",
1411
+ "stop_cover",
1412
+ "set_cover_position",
1413
+ "toggle",
1414
+ "open_cover_tilt",
1415
+ "close_cover_tilt",
1416
+ "set_cover_tilt_position",
1417
+ "stop_cover_tilt"
1418
+ ]
1419
+ };
1420
+ const lock = {
1421
+ entities(device) {
1422
+ const p = device.params;
1423
+ return [{
1424
+ entityId: entityIdFor("lock", device, "lock"),
1425
+ name: device.name,
1426
+ supportedFeatures: p.supportedFeatures,
1427
+ primary: true
1428
+ }];
1429
+ },
1430
+ apply(state, event, device) {
1431
+ const s = state ?? { locked: !device.params.unlocked };
1432
+ if (event.type !== "call") return s;
1433
+ switch (event.service) {
1434
+ case "lock": return { locked: true };
1435
+ case "unlock": return { locked: false };
1436
+ default: return s;
1437
+ }
1438
+ },
1439
+ project(state, device) {
1440
+ const s = state;
1441
+ const p = device.params;
1442
+ const id = entityIdFor("lock", device, "lock");
1443
+ const attributes = p.supportedFeatures != null ? { supported_features: p.supportedFeatures } : {};
1444
+ return { [id]: {
1445
+ state: s.locked ? "locked" : "unlocked",
1446
+ attributes
1447
+ } };
1448
+ },
1449
+ handles: ["lock", "unlock"]
1450
+ };
1451
+ const CLIMATE_ACTIVE_MODES = /* @__PURE__ */ new Set([
1452
+ "heat",
1453
+ "cool",
1454
+ "heat_cool",
1455
+ "auto"
1456
+ ]);
1457
+ function climateHvacAction(s) {
1458
+ if (s.hvacMode === "off") return "off";
1459
+ if (s.hvacMode === "heat") return s.currentTemp < s.targetTemp - .1 ? "heating" : "idle";
1460
+ if (s.hvacMode === "cool") return s.currentTemp > s.targetTemp + .1 ? "cooling" : "idle";
1461
+ if (s.currentTemp < s.targetTemp - .1) return "heating";
1462
+ if (s.currentTemp > s.targetTemp + .1) return "cooling";
1463
+ return "idle";
1464
+ }
1465
+ function climateSupportedFeatures(p) {
1466
+ if (p.supportedFeatures != null) return p.supportedFeatures;
1467
+ let sf = 1;
1468
+ if (p.fanModes) sf |= 8;
1469
+ if (p.presets) sf |= 16;
1470
+ return sf;
1471
+ }
1472
+ const climate = {
1473
+ entities(device) {
1474
+ const p = device.params;
1475
+ return [{
1476
+ entityId: entityIdFor("climate", device, "climate"),
1477
+ name: device.name,
1478
+ supportedFeatures: climateSupportedFeatures(p),
1479
+ primary: true
1480
+ }];
1481
+ },
1482
+ apply(state, event, device, ctx) {
1483
+ const p = device.params;
1484
+ const s = state ?? {
1485
+ hvacMode: "heat",
1486
+ targetTemp: 21,
1487
+ currentTemp: 21,
1488
+ watts: p.watts ?? 0
1489
+ };
1490
+ if (event.type === "tick") {
1491
+ const heating = CLIMATE_ACTIVE_MODES.has(s.hvacMode);
1492
+ const ambient = 20 + (outdoorTempC(ctx.nowMs, ctx.world) - 20) * .15;
1493
+ const targetAmbient = heating ? s.targetTemp : ambient;
1494
+ const tauMs = heating ? 4e4 : 12e5;
1495
+ const decay = Math.exp(-event.dtMs / tauMs);
1496
+ return {
1497
+ ...s,
1498
+ currentTemp: targetAmbient + (s.currentTemp - targetAmbient) * decay
1499
+ };
1500
+ }
1501
+ if (event.type !== "call") return s;
1502
+ const data = event.data;
1503
+ switch (event.service) {
1504
+ case "set_temperature": {
1505
+ const next = { ...s };
1506
+ if (data.temperature != null) next.targetTemp = Number(data.temperature);
1507
+ if (data.target_temp_low != null) next.targetTempLow = Number(data.target_temp_low);
1508
+ if (data.target_temp_high != null) next.targetTempHigh = Number(data.target_temp_high);
1509
+ return next;
1510
+ }
1511
+ case "set_hvac_mode": return typeof data.hvac_mode === "string" ? {
1512
+ ...s,
1513
+ hvacMode: data.hvac_mode
1514
+ } : s;
1515
+ case "set_fan_mode": return typeof data.fan_mode === "string" ? {
1516
+ ...s,
1517
+ fanMode: data.fan_mode
1518
+ } : s;
1519
+ case "set_preset_mode": return typeof data.preset_mode === "string" ? {
1520
+ ...s,
1521
+ presetMode: data.preset_mode
1522
+ } : s;
1523
+ default: return s;
1524
+ }
1525
+ },
1526
+ project(state, device) {
1527
+ const s = state;
1528
+ const p = device.params;
1529
+ const id = entityIdFor("climate", device, "climate");
1530
+ const attributes = {
1531
+ temperature: round1$1(s.targetTemp),
1532
+ current_temperature: round1$1(s.currentTemp),
1533
+ hvac_modes: p.modes ?? [
1534
+ "off",
1535
+ "heat",
1536
+ "cool",
1537
+ "auto"
1538
+ ],
1539
+ hvac_action: climateHvacAction(s),
1540
+ min_temp: 7,
1541
+ max_temp: 35,
1542
+ supported_features: climateSupportedFeatures(p)
1543
+ };
1544
+ if (s.targetTempLow != null) attributes.target_temp_low = round1$1(s.targetTempLow);
1545
+ if (s.targetTempHigh != null) attributes.target_temp_high = round1$1(s.targetTempHigh);
1546
+ if (p.fanModes) {
1547
+ attributes.fan_modes = p.fanModes;
1548
+ attributes.fan_mode = s.fanMode ?? p.fanModes[0];
1549
+ }
1550
+ if (p.presets) {
1551
+ attributes.preset_modes = p.presets;
1552
+ attributes.preset_mode = s.presetMode ?? p.presets[0];
1553
+ }
1554
+ return { [id]: {
1555
+ state: s.hvacMode,
1556
+ attributes
1557
+ } };
1558
+ },
1559
+ powerW(state) {
1560
+ const s = state;
1561
+ const action = climateHvacAction(s);
1562
+ return action === "heating" || action === "cooling" ? s.watts : 0;
1563
+ },
1564
+ handles: [
1565
+ "set_temperature",
1566
+ "set_hvac_mode",
1567
+ "set_fan_mode",
1568
+ "set_preset_mode"
1569
+ ]
1570
+ };
1571
+ const waterHeater = {
1572
+ entities(device) {
1573
+ const p = device.params;
1574
+ return [{
1575
+ entityId: entityIdFor("water_heater", device, "water_heater"),
1576
+ name: device.name,
1577
+ supportedFeatures: p.supportedFeatures,
1578
+ primary: true
1579
+ }];
1580
+ },
1581
+ apply(state, event, device) {
1582
+ const p = device.params;
1583
+ const s = state ?? {
1584
+ mode: p.modes[0] ?? "off",
1585
+ targetTemp: round1$1(p.min + (p.max - p.min) * .5),
1586
+ currentTemp: round1$1(p.min + (p.max - p.min) * .4),
1587
+ awayMode: false
1588
+ };
1589
+ if (event.type !== "call") return s;
1590
+ const data = event.data;
1591
+ switch (event.service) {
1592
+ case "set_temperature": return data.temperature != null ? {
1593
+ ...s,
1594
+ targetTemp: Number(data.temperature)
1595
+ } : s;
1596
+ case "set_operation_mode": return typeof data.operation_mode === "string" ? {
1597
+ ...s,
1598
+ mode: data.operation_mode
1599
+ } : s;
1600
+ case "set_away_mode": return data.away_mode != null ? {
1601
+ ...s,
1602
+ awayMode: Boolean(data.away_mode)
1603
+ } : s;
1604
+ default: return s;
1605
+ }
1606
+ },
1607
+ project(state, device) {
1608
+ const s = state;
1609
+ const p = device.params;
1610
+ return { [entityIdFor("water_heater", device, "water_heater")]: {
1611
+ state: s.mode,
1612
+ attributes: {
1613
+ temperature: round1$1(s.targetTemp),
1614
+ current_temperature: round1$1(s.currentTemp),
1615
+ operation_list: p.modes,
1616
+ min_temp: p.min,
1617
+ max_temp: p.max,
1618
+ away_mode: s.awayMode ? "on" : "off",
1619
+ ...p.supportedFeatures != null ? { supported_features: p.supportedFeatures } : {}
1620
+ }
1621
+ } };
1622
+ },
1623
+ handles: [
1624
+ "set_temperature",
1625
+ "set_operation_mode",
1626
+ "set_away_mode"
1627
+ ]
1628
+ };
1629
+ const MEDIA_DURATION_SEC = 180;
1630
+ const MEDIA_SUPPORTED_FEATURES = 152509;
1631
+ function skipMediaTrack(s, nowMs, trackCount, delta) {
1632
+ return {
1633
+ ...s,
1634
+ trackIndex: (s.trackIndex + delta + trackCount) % trackCount,
1635
+ positionSec: 0,
1636
+ positionUpdatedMs: nowMs
1637
+ };
1638
+ }
1639
+ const ACTUATORS = {
1640
+ light,
1641
+ switch: switchKind,
1642
+ fan,
1643
+ cover,
1644
+ lock,
1645
+ climate,
1646
+ water_heater: waterHeater,
1647
+ media_player: {
1648
+ entities(device) {
1649
+ return [{
1650
+ entityId: entityIdFor("media_player", device, "media_player"),
1651
+ name: device.name,
1652
+ supportedFeatures: MEDIA_SUPPORTED_FEATURES,
1653
+ primary: true
1654
+ }];
1655
+ },
1656
+ apply(state, event, device, ctx) {
1657
+ const p = device.params;
1658
+ const s = state ?? {
1659
+ playing: false,
1660
+ trackIndex: 0,
1661
+ volume: .4,
1662
+ muted: false,
1663
+ source: p.sources?.[0],
1664
+ positionSec: 0,
1665
+ positionUpdatedMs: ctx.nowMs
1666
+ };
1667
+ if (event.type === "tick") {
1668
+ if (!s.playing) return s;
1669
+ const elapsedSec = s.positionSec + event.dtMs / 1e3;
1670
+ const skipped = Math.floor(elapsedSec / MEDIA_DURATION_SEC);
1671
+ const positionSec = elapsedSec - skipped * MEDIA_DURATION_SEC;
1672
+ const trackIndex = (s.trackIndex + skipped) % p.tracks.length;
1673
+ return {
1674
+ ...s,
1675
+ positionSec,
1676
+ trackIndex,
1677
+ positionUpdatedMs: ctx.nowMs
1678
+ };
1679
+ }
1680
+ if (event.type !== "call") return s;
1681
+ const data = event.data;
1682
+ switch (event.service) {
1683
+ case "media_play_pause": return {
1684
+ ...s,
1685
+ playing: !s.playing
1686
+ };
1687
+ case "media_play": return {
1688
+ ...s,
1689
+ playing: true
1690
+ };
1691
+ case "media_pause": return {
1692
+ ...s,
1693
+ playing: false
1694
+ };
1695
+ case "media_next_track": return skipMediaTrack(s, ctx.nowMs, p.tracks.length, 1);
1696
+ case "media_previous_track": return skipMediaTrack(s, ctx.nowMs, p.tracks.length, -1);
1697
+ case "volume_set": return data.volume_level != null ? {
1698
+ ...s,
1699
+ volume: Number(data.volume_level)
1700
+ } : s;
1701
+ case "select_source": return typeof data.source === "string" ? {
1702
+ ...s,
1703
+ source: data.source
1704
+ } : s;
1705
+ default: return s;
1706
+ }
1707
+ },
1708
+ project(state, device) {
1709
+ const s = state;
1710
+ const p = device.params;
1711
+ const id = entityIdFor("media_player", device, "media_player");
1712
+ const track = p.tracks[s.trackIndex] ?? p.tracks[0];
1713
+ const attributes = {
1714
+ media_title: track?.title,
1715
+ media_artist: track?.artist,
1716
+ media_content_type: "music",
1717
+ entity_picture: DEMO_ALBUM_COVER,
1718
+ volume_level: s.volume,
1719
+ is_volume_muted: s.muted,
1720
+ media_duration: MEDIA_DURATION_SEC,
1721
+ media_position: roundInt$1(s.positionSec),
1722
+ media_position_updated_at: new Date(s.positionUpdatedMs).toISOString(),
1723
+ supported_features: MEDIA_SUPPORTED_FEATURES
1724
+ };
1725
+ if (p.sources) {
1726
+ attributes.source_list = p.sources;
1727
+ attributes.source = s.source ?? p.sources[0];
1728
+ }
1729
+ return { [id]: {
1730
+ state: s.playing ? "playing" : "paused",
1731
+ attributes
1732
+ } };
1733
+ },
1734
+ handles: [
1735
+ "media_play_pause",
1736
+ "media_play",
1737
+ "media_pause",
1738
+ "media_next_track",
1739
+ "media_previous_track",
1740
+ "volume_set",
1741
+ "select_source"
1742
+ ]
1743
+ },
1744
+ button: {
1745
+ entities(device) {
1746
+ const p = device.params;
1747
+ return [{
1748
+ entityId: entityIdFor("button", device, "button"),
1749
+ name: device.name,
1750
+ category: p.category,
1751
+ deviceClass: p.deviceClass,
1752
+ primary: true
1753
+ }];
1754
+ },
1755
+ apply(state, event, device, ctx) {
1756
+ const s = state ?? {};
1757
+ if (event.type !== "call" || event.service !== "press") return s;
1758
+ return { lastPressedMs: ctx.nowMs };
1759
+ },
1760
+ project(state, device) {
1761
+ const s = state;
1762
+ const p = device.params;
1763
+ const id = entityIdFor("button", device, "button");
1764
+ const value = s.lastPressedMs != null ? new Date(s.lastPressedMs).toISOString() : "unknown";
1765
+ return { [id]: {
1766
+ state: value,
1767
+ attributes: p.deviceClass ? { device_class: p.deviceClass } : {}
1768
+ } };
1769
+ },
1770
+ handles: ["press"]
1771
+ },
1772
+ scene: {
1773
+ entities(device) {
1774
+ return [{
1775
+ entityId: entityIdFor("scene", device, "scene"),
1776
+ name: device.name,
1777
+ primary: true
1778
+ }];
1779
+ },
1780
+ apply(state, event, device, ctx) {
1781
+ const s = state ?? {};
1782
+ if (event.type !== "call" || event.service !== "turn_on") return s;
1783
+ return { lastActivatedMs: ctx.nowMs };
1784
+ },
1785
+ project(state, device) {
1786
+ const s = state;
1787
+ const id = entityIdFor("scene", device, "scene");
1788
+ const value = s.lastActivatedMs != null ? new Date(s.lastActivatedMs).toISOString() : "unknown";
1789
+ return { [id]: {
1790
+ state: value,
1791
+ attributes: {}
1792
+ } };
1793
+ },
1794
+ effects(state, event, device) {
1795
+ if (event.type !== "call" || event.service !== "turn_on") return [];
1796
+ return device.params.targets;
1797
+ },
1798
+ handles: ["turn_on"]
1799
+ }
1800
+ };
1801
+ //#endregion
1802
+ //#region src/demo/assets.ts
1803
+ function demoAssetUrl(path) {
1804
+ return globalThis.location ? new URL(path, globalThis.location.origin).href : path;
1805
+ }
1806
+ //#endregion
1807
+ //#region src/demo/kinds/readings.ts
1808
+ const round1 = (n) => Math.round(n * 10) / 10;
1809
+ const roundInt = (n) => Math.round(n);
1810
+ const round10 = (n) => Math.round(n / 10) * 10;
1811
+ /** Appliance power ids owned by appliance devices; energy_meter never owns them. */
1812
+ const APPLIANCE_ENERGY_IDS = [
1813
+ "sensor.fridge_power",
1814
+ "sensor.dishwasher_power",
1815
+ "sensor.washing_machine_power",
1816
+ "sensor.oven_power",
1817
+ "sensor.ev_charger_power"
1818
+ ];
1819
+ const SENSOR_UNIT = {
1820
+ temperature: "°C",
1821
+ outdoor_temperature: "°C",
1822
+ humidity: "%",
1823
+ illuminance: "lx",
1824
+ battery: "%",
1825
+ signal: "dBm",
1826
+ fixed: void 0,
1827
+ power: "W"
1828
+ };
1829
+ const SENSOR_DEVICE_CLASS = {
1830
+ temperature: "temperature",
1831
+ outdoor_temperature: "temperature",
1832
+ humidity: "humidity",
1833
+ illuminance: "illuminance",
1834
+ battery: "battery",
1835
+ signal: "signal_strength",
1836
+ fixed: void 0,
1837
+ power: "power"
1838
+ };
1839
+ function sensorUnit(p) {
1840
+ return p.unit ?? SENSOR_UNIT[p.reading];
1841
+ }
1842
+ function sensorDeviceClass(p) {
1843
+ return p.deviceClass ?? SENSOR_DEVICE_CLASS[p.reading];
1844
+ }
1845
+ function isLegacyEnergyId(id) {
1846
+ return ENERGY_METER_ENTITY_IDS.includes(id) || APPLIANCE_ENERGY_IDS.includes(id);
1847
+ }
1848
+ function sensorValue(id, p, nowMs, noiseVal, outdoorC) {
1849
+ switch (p.reading) {
1850
+ case "temperature": return round1(20.5 + (outdoorC - 20.5) * .1 + (noiseVal - .5) * .4).toFixed(1);
1851
+ case "outdoor_temperature": return round1(outdoorC).toFixed(1);
1852
+ case "humidity": return roundInt(45 + (noiseVal - .5) * 30).toString();
1853
+ case "illuminance": return roundInt(Math.max(0, 400 * noiseVal)).toString();
1854
+ case "battery": return roundInt(35 + noiseVal * 65).toString();
1855
+ case "signal": return roundInt(-85 + noiseVal * 40).toString();
1856
+ case "power":
1857
+ if (isLegacyEnergyId(id)) return formatEnergyState(id, energyEntityValue(id, simulateEnergy(nowMs)) ?? 0);
1858
+ return round10(p.value ?? 0).toString();
1859
+ default: return String(p.value ?? 0);
1860
+ }
1861
+ }
1862
+ const sensor = {
1863
+ entities(device) {
1864
+ const p = device.params;
1865
+ const seed = {
1866
+ entityId: entityIdFor("sensor", device, "sensor"),
1867
+ name: device.name,
1868
+ unit: sensorUnit(p),
1869
+ deviceClass: sensorDeviceClass(p),
1870
+ primary: true
1871
+ };
1872
+ if (p.reading === "signal") seed.category = "diagnostic";
1873
+ return [seed];
1874
+ },
1875
+ apply() {},
1876
+ project(_state, device, ctx) {
1877
+ const p = device.params;
1878
+ const id = entityIdFor("sensor", device, "sensor");
1879
+ const lt = localTime$1(ctx.nowMs, ctx.world.timeZone);
1880
+ const noiseVal = ctx.noise(`${device.key}:${p.reading}:${lt.dateKey}`);
1881
+ const outdoorC = outdoorTempC(ctx.nowMs, ctx.world);
1882
+ const attributes = {};
1883
+ const unit = sensorUnit(p);
1884
+ const deviceClass = sensorDeviceClass(p);
1885
+ if (unit) attributes.unit_of_measurement = unit;
1886
+ if (deviceClass) attributes.device_class = deviceClass;
1887
+ return { [id]: {
1888
+ state: sensorValue(id, p, ctx.nowMs, noiseVal, outdoorC),
1889
+ attributes
1890
+ } };
1891
+ },
1892
+ publishMs: 3e4
1893
+ };
1894
+ const MOTION_AUTO_OFF_MS = 12e4;
1895
+ const binarySensor = {
1896
+ entities(device) {
1897
+ const p = device.params;
1898
+ return [{
1899
+ entityId: entityIdFor("binary_sensor", device, "binary_sensor"),
1900
+ name: device.name,
1901
+ deviceClass: p.deviceClass,
1902
+ primary: true
1903
+ }];
1904
+ },
1905
+ apply(state, event, device) {
1906
+ const p = device.params;
1907
+ const s = state ?? { on: p.initial ?? false };
1908
+ if (event.type === "tick") {
1909
+ if (s.autoOffRemainingMs == null) return s;
1910
+ const remaining = s.autoOffRemainingMs - event.dtMs;
1911
+ return remaining <= 0 ? { on: false } : {
1912
+ ...s,
1913
+ autoOffRemainingMs: remaining
1914
+ };
1915
+ }
1916
+ if (event.type !== "call" || event.service !== "demo_set") return s;
1917
+ const on = Boolean(event.data.on);
1918
+ return p.deviceClass === "motion" && on ? {
1919
+ on: true,
1920
+ autoOffRemainingMs: MOTION_AUTO_OFF_MS
1921
+ } : { on };
1922
+ },
1923
+ project(state, device) {
1924
+ const s = state;
1925
+ const p = device.params;
1926
+ return { [entityIdFor("binary_sensor", device, "binary_sensor")]: {
1927
+ state: s.on ? "on" : "off",
1928
+ attributes: { device_class: p.deviceClass }
1929
+ } };
1930
+ },
1931
+ handles: ["demo_set"]
1932
+ };
1933
+ function sunCacheValid(state, nowMs, lat, lon, dateKey) {
1934
+ return state != null && state.dateKey === dateKey && state.latKey === lat && state.lonKey === lon && nowMs < state.risingMs && nowMs < state.settingMs;
1935
+ }
1936
+ function sunTimesFor(state, nowMs, lat, lon, dateKey) {
1937
+ if (sunCacheValid(state, nowMs, lat, lon, dateKey)) return state;
1938
+ const { risingMs, settingMs } = sunTimes(nowMs, lat, lon);
1939
+ return {
1940
+ dateKey,
1941
+ latKey: lat,
1942
+ lonKey: lon,
1943
+ risingMs,
1944
+ settingMs
1945
+ };
1946
+ }
1947
+ const sun = {
1948
+ entities(device) {
1949
+ return [{
1950
+ entityId: entityIdFor("sun", device, "sun"),
1951
+ name: "Sun",
1952
+ primary: true
1953
+ }];
1954
+ },
1955
+ apply(state, event, device, ctx) {
1956
+ const lt = localTime$1(ctx.nowMs, ctx.world.timeZone);
1957
+ return sunTimesFor(state, ctx.nowMs, ctx.world.latitude, ctx.world.longitude, lt.dateKey);
1958
+ },
1959
+ project(state, device, ctx) {
1960
+ const s = state;
1961
+ const id = entityIdFor("sun", device, "sun");
1962
+ const elevation = solarElevation(ctx.nowMs, ctx.world.latitude, ctx.world.longitude);
1963
+ return { [id]: {
1964
+ state: elevation > -.833 ? "above_horizon" : "below_horizon",
1965
+ attributes: {
1966
+ elevation: round1(elevation),
1967
+ next_rising: new Date(s.risingMs).toISOString(),
1968
+ next_setting: new Date(s.settingMs).toISOString(),
1969
+ rising: solarElevation(ctx.nowMs + 6e5, ctx.world.latitude, ctx.world.longitude) > elevation
1970
+ }
1971
+ } };
1972
+ }
1973
+ };
1974
+ function weatherCondition(cloud, sunUp, noiseVal) {
1975
+ if (cloud > .85) return noiseVal < .3 ? "rainy" : "cloudy";
1976
+ if (cloud > .6) return "cloudy";
1977
+ if (cloud > .3) return "partlycloudy";
1978
+ return sunUp ? "sunny" : "clear-night";
1979
+ }
1980
+ function weatherForecastDay(nowMs, world, dayOffset, ctxNoise) {
1981
+ const noonMs = localTime$1(nowMs, world.timeZone).midnightMs + dayOffset * 864e5 + 432e5;
1982
+ const cloud = cloudCover(noonMs, world);
1983
+ const sunUp = solarElevation(noonMs, world.latitude, world.longitude) > -.833;
1984
+ const targetDateKey = localTime$1(noonMs, world.timeZone).dateKey;
1985
+ const noiseVal = ctxNoise(`weather:condition:${targetDateKey}`);
1986
+ return {
1987
+ datetime: new Date(noonMs).toISOString(),
1988
+ condition: weatherCondition(cloud, sunUp, noiseVal),
1989
+ temperature: round1(outdoorTempC(noonMs, world)),
1990
+ templow: round1(outdoorTempC(noonMs - 288e5, world))
1991
+ };
1992
+ }
1993
+ function weatherForecastHourly(nowMs, world, hours = 24) {
1994
+ const firstHourMs = Math.floor(nowMs / 36e5) * 36e5;
1995
+ return Array.from({ length: hours }, (_, i) => {
1996
+ const t = firstHourMs + i * 36e5;
1997
+ const sunUp = solarElevation(t, world.latitude, world.longitude) > -.833;
1998
+ const noiseVal = noise(world.seed, `weather:condition:${localTime$1(t, world.timeZone).dateKey}`);
1999
+ return {
2000
+ datetime: new Date(t).toISOString(),
2001
+ condition: weatherCondition(cloudCover(t, world), sunUp, noiseVal),
2002
+ temperature: round1(outdoorTempC(t, world))
2003
+ };
2004
+ });
2005
+ }
2006
+ const weather = {
2007
+ entities(device) {
2008
+ return [{
2009
+ entityId: entityIdFor("weather", device, "weather"),
2010
+ name: device.name,
2011
+ primary: true
2012
+ }];
2013
+ },
2014
+ apply() {},
2015
+ project(_state, device, ctx) {
2016
+ const id = entityIdFor("weather", device, "weather");
2017
+ const cloud = cloudCover(ctx.nowMs, ctx.world);
2018
+ const sunUp = solarElevation(ctx.nowMs, ctx.world.latitude, ctx.world.longitude) > -.833;
2019
+ const dateKey = localTime$1(ctx.nowMs, ctx.world.timeZone).dateKey;
2020
+ const noiseVal = ctx.noise(`weather:condition:${dateKey}`);
2021
+ const forecast = Array.from({ length: 7 }, (_, i) => weatherForecastDay(ctx.nowMs, ctx.world, i, (key) => ctx.noise(key)));
2022
+ return { [id]: {
2023
+ state: weatherCondition(cloud, sunUp, noiseVal),
2024
+ attributes: {
2025
+ temperature: round1(outdoorTempC(ctx.nowMs, ctx.world)),
2026
+ temperature_unit: "°C",
2027
+ humidity: roundInt(40 + cloud * 30),
2028
+ forecast
2029
+ }
2030
+ } };
2031
+ },
2032
+ publishMs: 3e4
2033
+ };
2034
+ /** One fixed fixture per weather scene the widget can showcase. */
2035
+ const WEATHER_FIXTURES = [
2036
+ {
2037
+ slug: "sunny",
2038
+ state: "sunny",
2039
+ temp: 28,
2040
+ apparent: 31,
2041
+ humidity: 35,
2042
+ pressure: 1018,
2043
+ wind: 8,
2044
+ low: 18
2045
+ },
2046
+ {
2047
+ slug: "clear_night",
2048
+ state: "clear-night",
2049
+ temp: 14,
2050
+ apparent: 12,
2051
+ humidity: 55,
2052
+ pressure: 1016,
2053
+ wind: 5,
2054
+ low: 9
2055
+ },
2056
+ {
2057
+ slug: "cloudy",
2058
+ state: "cloudy",
2059
+ temp: 17,
2060
+ apparent: 16,
2061
+ humidity: 72,
2062
+ pressure: 1010,
2063
+ wind: 14,
2064
+ low: 11
2065
+ },
2066
+ {
2067
+ slug: "partly_cloudy",
2068
+ state: "partlycloudy",
2069
+ temp: 23,
2070
+ apparent: 24,
2071
+ humidity: 50,
2072
+ pressure: 1014,
2073
+ wind: 12,
2074
+ low: 15
2075
+ },
2076
+ {
2077
+ slug: "rainy",
2078
+ state: "rainy",
2079
+ temp: 12,
2080
+ apparent: 10,
2081
+ humidity: 88,
2082
+ pressure: 1004,
2083
+ wind: 18,
2084
+ low: 8
2085
+ },
2086
+ {
2087
+ slug: "pouring",
2088
+ state: "pouring",
2089
+ temp: 11,
2090
+ apparent: 8,
2091
+ humidity: 95,
2092
+ pressure: 998,
2093
+ wind: 26,
2094
+ low: 7
2095
+ },
2096
+ {
2097
+ slug: "snowy",
2098
+ state: "snowy",
2099
+ temp: -2,
2100
+ apparent: -6,
2101
+ humidity: 80,
2102
+ pressure: 1020,
2103
+ wind: 10,
2104
+ low: -7
2105
+ },
2106
+ {
2107
+ slug: "snowy_rainy",
2108
+ state: "snowy-rainy",
2109
+ temp: 1,
2110
+ apparent: -2,
2111
+ humidity: 92,
2112
+ pressure: 1006,
2113
+ wind: 16,
2114
+ low: -2
2115
+ },
2116
+ {
2117
+ slug: "lightning",
2118
+ state: "lightning",
2119
+ temp: 22,
2120
+ apparent: 24,
2121
+ humidity: 78,
2122
+ pressure: 1001,
2123
+ wind: 22,
2124
+ low: 17
2125
+ },
2126
+ {
2127
+ slug: "lightning_rainy",
2128
+ state: "lightning-rainy",
2129
+ temp: 19,
2130
+ apparent: 18,
2131
+ humidity: 90,
2132
+ pressure: 996,
2133
+ wind: 28,
2134
+ low: 14
2135
+ },
2136
+ {
2137
+ slug: "fog",
2138
+ state: "fog",
2139
+ temp: 8,
2140
+ apparent: 6,
2141
+ humidity: 98,
2142
+ pressure: 1015,
2143
+ wind: 4,
2144
+ low: 6
2145
+ },
2146
+ {
2147
+ slug: "hail",
2148
+ state: "hail",
2149
+ temp: 6,
2150
+ apparent: 3,
2151
+ humidity: 84,
2152
+ pressure: 1002,
2153
+ wind: 20,
2154
+ low: 1
2155
+ },
2156
+ {
2157
+ slug: "windy",
2158
+ state: "windy",
2159
+ temp: 18,
2160
+ apparent: 15,
2161
+ humidity: 60,
2162
+ pressure: 1009,
2163
+ wind: 42,
2164
+ low: 12
2165
+ },
2166
+ {
2167
+ slug: "exceptional",
2168
+ state: "exceptional",
2169
+ temp: 38,
2170
+ apparent: 44,
2171
+ humidity: 22,
2172
+ pressure: 1005,
2173
+ wind: 30,
2174
+ low: 28
2175
+ }
2176
+ ];
2177
+ function buildShowcaseForecast(w, midnightMs) {
2178
+ const day = (n) => new Date(midnightMs + n * 864e5).toISOString();
2179
+ return [
2180
+ {
2181
+ datetime: day(0),
2182
+ condition: w.state,
2183
+ temperature: w.temp,
2184
+ templow: w.low
2185
+ },
2186
+ {
2187
+ datetime: day(1),
2188
+ condition: "partlycloudy",
2189
+ temperature: w.temp + 2,
2190
+ templow: w.low
2191
+ },
2192
+ {
2193
+ datetime: day(2),
2194
+ condition: "cloudy",
2195
+ temperature: w.temp + 1,
2196
+ templow: w.low - 1
2197
+ },
2198
+ {
2199
+ datetime: day(3),
2200
+ condition: "sunny",
2201
+ temperature: w.temp + 4,
2202
+ templow: w.low + 1
2203
+ },
2204
+ {
2205
+ datetime: day(4),
2206
+ condition: "rainy",
2207
+ temperature: w.temp - 2,
2208
+ templow: w.low - 2
2209
+ },
2210
+ {
2211
+ datetime: day(5),
2212
+ condition: "cloudy",
2213
+ temperature: w.temp - 1,
2214
+ templow: w.low - 2
2215
+ },
2216
+ {
2217
+ datetime: day(6),
2218
+ condition: "partlycloudy",
2219
+ temperature: w.temp + 1,
2220
+ templow: w.low
2221
+ }
2222
+ ];
2223
+ }
2224
+ const weatherShowcase = {
2225
+ entities(device) {
2226
+ return [{
2227
+ entityId: entityIdFor("weather", device, "weather"),
2228
+ name: device.name,
2229
+ primary: true
2230
+ }];
2231
+ },
2232
+ apply() {},
2233
+ project(_state, device, ctx) {
2234
+ const w = device.params;
2235
+ return { [entityIdFor("weather", device, "weather")]: {
2236
+ state: w.state,
2237
+ attributes: {
2238
+ temperature: w.temp,
2239
+ temperature_unit: "°C",
2240
+ apparent_temperature: w.apparent ?? w.temp,
2241
+ humidity: w.humidity,
2242
+ pressure: w.pressure,
2243
+ pressure_unit: "hPa",
2244
+ wind_speed: w.wind,
2245
+ wind_speed_unit: "km/h",
2246
+ wind_bearing: w.bearing ?? 220,
2247
+ visibility: 10,
2248
+ visibility_unit: "km",
2249
+ uv_index: w.state === "sunny" ? 8 : 2,
2250
+ forecast: buildShowcaseForecast(w, localTime$1(ctx.nowMs, ctx.world.timeZone).midnightMs)
2251
+ }
2252
+ } };
2253
+ },
2254
+ publishMs: 6e4
2255
+ };
2256
+ const ENERGY_METER_ENTITY_IDS = [
2257
+ "sensor.solar_power",
2258
+ "sensor.grid_import_power",
2259
+ "sensor.grid_export_power",
2260
+ "sensor.battery_charge_power",
2261
+ "sensor.battery_discharge_power",
2262
+ "sensor.battery_soc",
2263
+ "sensor.home_power",
2264
+ "sensor.always_on_power"
2265
+ ];
2266
+ const ENERGY_METER_META = {
2267
+ "sensor.solar_power": {
2268
+ name: "Solar Production",
2269
+ deviceClass: "power",
2270
+ unit: "W"
2271
+ },
2272
+ "sensor.grid_import_power": {
2273
+ name: "Grid Import",
2274
+ deviceClass: "power",
2275
+ unit: "W"
2276
+ },
2277
+ "sensor.grid_export_power": {
2278
+ name: "Grid Export",
2279
+ deviceClass: "power",
2280
+ unit: "W"
2281
+ },
2282
+ "sensor.battery_charge_power": {
2283
+ name: "Battery Charge",
2284
+ deviceClass: "power",
2285
+ unit: "W"
2286
+ },
2287
+ "sensor.battery_discharge_power": {
2288
+ name: "Battery Discharge",
2289
+ deviceClass: "power",
2290
+ unit: "W"
2291
+ },
2292
+ "sensor.battery_soc": {
2293
+ name: "Battery",
2294
+ deviceClass: "battery",
2295
+ unit: "%"
2296
+ },
2297
+ "sensor.home_power": {
2298
+ name: "Home Consumption",
2299
+ deviceClass: "power",
2300
+ unit: "W"
2301
+ },
2302
+ "sensor.always_on_power": {
2303
+ name: "Always On",
2304
+ deviceClass: "power",
2305
+ unit: "W"
2306
+ }
2307
+ };
2308
+ const energyMeter = {
2309
+ entities() {
2310
+ return ENERGY_METER_ENTITY_IDS.map((id) => {
2311
+ const meta = ENERGY_METER_META[id];
2312
+ return {
2313
+ entityId: id,
2314
+ name: meta.name,
2315
+ deviceClass: meta.deviceClass,
2316
+ unit: meta.unit,
2317
+ primary: id === "sensor.home_power"
2318
+ };
2319
+ });
2320
+ },
2321
+ apply() {},
2322
+ project(_state, _device, ctx) {
2323
+ const sample = simulateEnergy(ctx.nowMs);
2324
+ const out = {};
2325
+ for (const id of ENERGY_METER_ENTITY_IDS) {
2326
+ const meta = ENERGY_METER_META[id];
2327
+ out[id] = {
2328
+ state: formatEnergyState(id, energyEntityValue(id, sample) ?? 0),
2329
+ attributes: {
2330
+ unit_of_measurement: meta.unit,
2331
+ device_class: meta.deviceClass,
2332
+ state_class: "measurement"
2333
+ }
2334
+ };
2335
+ }
2336
+ return out;
2337
+ },
2338
+ publishMs: 5e3
2339
+ };
2340
+ const person = {
2341
+ entities(device) {
2342
+ return [{
2343
+ entityId: entityIdFor("person", device, "person"),
2344
+ name: device.name,
2345
+ primary: true
2346
+ }];
2347
+ },
2348
+ apply(state, event) {
2349
+ const s = state ?? { home: true };
2350
+ if (event.type !== "call" || event.service !== "demo_set") return s;
2351
+ return event.data.home != null ? { home: Boolean(event.data.home) } : s;
2352
+ },
2353
+ project(state, device) {
2354
+ const s = state;
2355
+ return { [entityIdFor("person", device, "person")]: {
2356
+ state: s.home ? "home" : "not_home",
2357
+ attributes: {}
2358
+ } };
2359
+ },
2360
+ handles: ["demo_set"]
2361
+ };
2362
+ function bumpMinor(version) {
2363
+ const parts = version.split(".").map(Number);
2364
+ const [major, minor, patch] = [
2365
+ parts[0] ?? 1,
2366
+ parts[1] ?? 0,
2367
+ parts[2] ?? 0
2368
+ ];
2369
+ return `${major}.${minor + 1}.${patch}`;
2370
+ }
2371
+ const READINGS = {
2372
+ sensor,
2373
+ binary_sensor: binarySensor,
2374
+ sun,
2375
+ weather,
2376
+ weather_showcase: weatherShowcase,
2377
+ energy_meter: energyMeter,
2378
+ person,
2379
+ update: {
2380
+ entities(device) {
2381
+ return [{
2382
+ entityId: entityIdFor("update", device, "update", "firmware"),
2383
+ name: `${device.name} Firmware`,
2384
+ deviceClass: "firmware",
2385
+ category: "diagnostic",
2386
+ primary: true
2387
+ }];
2388
+ },
2389
+ apply() {},
2390
+ project(_state, device, ctx) {
2391
+ const p = device.params;
2392
+ const id = entityIdFor("update", device, "update", "firmware");
2393
+ const installed = p.installedVersion ?? "1.0.0";
2394
+ const available = ctx.noise(`${device.key}:update`) < 1 / 12;
2395
+ const latest = available ? bumpMinor(installed) : installed;
2396
+ return { [id]: {
2397
+ state: available ? "on" : "off",
2398
+ attributes: {
2399
+ installed_version: installed,
2400
+ latest_version: latest,
2401
+ device_class: "firmware"
2402
+ }
2403
+ } };
2404
+ }
2405
+ },
2406
+ camera: {
2407
+ entities(device) {
2408
+ return [{
2409
+ entityId: entityIdFor("camera", device, "camera"),
2410
+ name: device.name,
2411
+ supportedFeatures: 2,
2412
+ primary: true
2413
+ }];
2414
+ },
2415
+ apply() {},
2416
+ project(_state, device) {
2417
+ return { [entityIdFor("camera", device, "camera")]: {
2418
+ state: "streaming",
2419
+ attributes: {
2420
+ entity_picture: demoAssetUrl("/demo/camera/front-door.webp"),
2421
+ frontend_stream_type: "hls",
2422
+ access_token: "demo-token",
2423
+ supported_features: 2
2424
+ }
2425
+ } };
2426
+ }
2427
+ }
2428
+ };
2429
+ //#endregion
2430
+ //#region src/demo/kinds/index.ts
2431
+ const KINDS = {
2432
+ ...ACTUATORS,
2433
+ ...READINGS
2434
+ };
2435
+ //#endregion
2436
+ //#region src/demo/house/builders.ts
2437
+ const PRIMARY_ROLE = {
2438
+ light: "light",
2439
+ switch: "switch",
2440
+ fan: "fan",
2441
+ cover: "cover",
2442
+ lock: "lock",
2443
+ climate: "climate",
2444
+ water_heater: "water_heater",
2445
+ media_player: "media_player",
2446
+ button: "button",
2447
+ scene: "scene",
2448
+ sensor: "sensor",
2449
+ binary_sensor: "binary_sensor",
2450
+ sun: "sun",
2451
+ weather: "weather",
2452
+ weather_showcase: "weather",
2453
+ energy_meter: "sensor",
2454
+ person: "person",
2455
+ update: "update",
2456
+ camera: "camera"
2457
+ };
2458
+ const HUE = {
2459
+ manufacturer: "Signify",
2460
+ model: "Hue White Ambiance",
2461
+ integration: "hue",
2462
+ transport: "zigbee"
2463
+ };
2464
+ const SHELLY_RELAY = {
2465
+ manufacturer: "Shelly",
2466
+ model: "Plus 1PM",
2467
+ integration: "shelly",
2468
+ transport: "wifi"
2469
+ };
2470
+ const SHELLY_PLUG = {
2471
+ manufacturer: "Shelly",
2472
+ model: "Plus Plug S",
2473
+ integration: "shelly",
2474
+ transport: "wifi"
2475
+ };
2476
+ const ZIGBEE_BATTERY = {
2477
+ integration: "zha",
2478
+ transport: "zigbee",
2479
+ battery: true
2480
+ };
2481
+ function slug(text) {
2482
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2483
+ }
2484
+ function definedOnly(o) {
2485
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== void 0));
2486
+ }
2487
+ const DEFAULT_TRACKS = [
2488
+ {
2489
+ title: "Lo-fi Beats",
2490
+ artist: "Chill Station"
2491
+ },
2492
+ {
2493
+ title: "Sunday Morning",
2494
+ artist: "Maroon Coast"
2495
+ },
2496
+ {
2497
+ title: "Night Drive",
2498
+ artist: "Neon Avenue"
2499
+ }
2500
+ ];
2501
+ function titleCase(text) {
2502
+ return text.replace(/\b\w/g, (c) => c.toUpperCase());
2503
+ }
2504
+ /** Builders bound to one room (`null` for whole-house devices); keys are the name's slug, room-prefixed unless it already is. */
2505
+ function inRoom(roomId) {
2506
+ const keyOf = (name, opts) => {
2507
+ if (opts.key) return opts.key;
2508
+ const own = slug(name);
2509
+ return !roomId || own.startsWith(`${roomId}_`) ? own : `${roomId}_${own}`;
2510
+ };
2511
+ function make(kind, name, params, hw, opts = {}) {
2512
+ const ids = {};
2513
+ if (opts.id) ids[PRIMARY_ROLE[kind]] = opts.id;
2514
+ if (opts.batteryId) ids.battery = opts.batteryId;
2515
+ return {
2516
+ key: keyOf(name, opts),
2517
+ kind,
2518
+ name,
2519
+ areaId: roomId,
2520
+ manufacturer: opts.manufacturer ?? hw.manufacturer,
2521
+ model: opts.model ?? hw.model,
2522
+ params: definedOnly({
2523
+ ...params,
2524
+ integration: hw.integration,
2525
+ transport: hw.transport,
2526
+ battery: hw.battery
2527
+ }),
2528
+ ...Object.keys(ids).length > 0 ? { ids } : {},
2529
+ ...opts.entityName ? { entityName: opts.entityName } : {},
2530
+ ...opts.deviceId ? { deviceId: opts.deviceId } : {}
2531
+ };
2532
+ }
2533
+ return {
2534
+ light: (name, opts = {}) => {
2535
+ const model = opts.color ? "Hue White and Color Ambiance" : HUE.model;
2536
+ const { dimmable = true, color, colorTemp, watts = 9, supportedFeatures } = opts;
2537
+ return make("light", name, {
2538
+ dimmable,
2539
+ color,
2540
+ colorTemp,
2541
+ watts,
2542
+ supportedFeatures
2543
+ }, {
2544
+ ...HUE,
2545
+ model
2546
+ }, opts);
2547
+ },
2548
+ switchDevice: (name, opts = {}) => make("switch", name, {
2549
+ watts: opts.watts ?? 0,
2550
+ deviceClass: opts.deviceClass
2551
+ }, SHELLY_RELAY, opts),
2552
+ plug: (name, watts, opts = {}) => make("switch", name, {
2553
+ watts,
2554
+ power: true,
2555
+ energy: true,
2556
+ legacyPowerId: opts.legacyPowerId
2557
+ }, SHELLY_PLUG, opts),
2558
+ appliance: (name, watts, opts = {}) => make("switch", name, {
2559
+ watts,
2560
+ power: true,
2561
+ legacyPowerId: opts.legacyPowerId
2562
+ }, SHELLY_RELAY, opts),
2563
+ fan: (name, opts = {}) => make("fan", name, {
2564
+ presets: opts.presets,
2565
+ oscillate: opts.oscillate,
2566
+ direction: opts.direction,
2567
+ watts: opts.watts ?? 40
2568
+ }, {
2569
+ manufacturer: "Dreo",
2570
+ model: "Smart Fan",
2571
+ integration: "demo",
2572
+ transport: "wifi"
2573
+ }, opts),
2574
+ cover: (name, opts = {}) => make("cover", name, {
2575
+ position: opts.position ?? true,
2576
+ tilt: opts.tilt,
2577
+ deviceClass: opts.deviceClass ?? "shade",
2578
+ travelMs: opts.travelMs,
2579
+ supportedFeatures: opts.supportedFeatures
2580
+ }, {
2581
+ manufacturer: "IKEA",
2582
+ model: "Fyrtur",
2583
+ integration: "zha",
2584
+ transport: "zigbee"
2585
+ }, opts),
2586
+ lock: (name, opts = {}) => make("lock", name, {
2587
+ supportedFeatures: opts.supportedFeatures,
2588
+ unlocked: opts.unlocked
2589
+ }, {
2590
+ manufacturer: "Yale",
2591
+ model: "Assure Lock 2",
2592
+ ...ZIGBEE_BATTERY
2593
+ }, opts),
2594
+ climate: (name, opts = {}) => make("climate", name, {
2595
+ modes: opts.modes,
2596
+ fanModes: opts.fanModes,
2597
+ presets: opts.presets,
2598
+ watts: opts.watts ?? 1500,
2599
+ supportedFeatures: opts.supportedFeatures
2600
+ }, {
2601
+ manufacturer: "Google",
2602
+ model: "Nest Learning Thermostat",
2603
+ integration: "demo",
2604
+ transport: "wifi"
2605
+ }, opts),
2606
+ waterHeater: (name, opts) => make("water_heater", name, {
2607
+ modes: opts.modes,
2608
+ min: opts.min,
2609
+ max: opts.max,
2610
+ supportedFeatures: opts.supportedFeatures
2611
+ }, {
2612
+ manufacturer: "Vaillant",
2613
+ model: "uniSTOR",
2614
+ integration: "demo",
2615
+ transport: "wifi"
2616
+ }, opts),
2617
+ mediaPlayer: (name, opts = {}) => make("media_player", name, {
2618
+ sources: opts.sources,
2619
+ tracks: opts.tracks ?? DEFAULT_TRACKS
2620
+ }, {
2621
+ manufacturer: "Sonos",
2622
+ model: "One",
2623
+ integration: "demo",
2624
+ transport: "wifi"
2625
+ }, opts),
2626
+ button: (name, opts = {}) => make("button", name, {
2627
+ category: opts.category,
2628
+ deviceClass: opts.deviceClass
2629
+ }, {
2630
+ manufacturer: "Home Assistant",
2631
+ model: "Core",
2632
+ integration: "demo"
2633
+ }, opts),
2634
+ scene: (name, targets, opts = {}) => make("scene", name, { targets }, {
2635
+ manufacturer: "Home Assistant",
2636
+ model: "Scene",
2637
+ integration: "demo"
2638
+ }, opts),
2639
+ climateSensor: (name, opts = {}) => {
2640
+ const base = keyOf(name, opts);
2641
+ const hw = {
2642
+ manufacturer: "Aqara",
2643
+ model: "Temperature and Humidity Sensor",
2644
+ ...ZIGBEE_BATTERY
2645
+ };
2646
+ const reading = (role, entityName, id) => {
2647
+ const value = role === "temperature" && opts.outdoor ? "outdoor_temperature" : role;
2648
+ const own = {
2649
+ ...opts,
2650
+ key: `${base}_${role}`,
2651
+ id,
2652
+ deviceId: base,
2653
+ entityName
2654
+ };
2655
+ return make("sensor", name, { reading: value }, hw, own);
2656
+ };
2657
+ return [reading("temperature", "Temperature", opts.temperatureId), reading("humidity", "Humidity", opts.humidityId)];
2658
+ },
2659
+ motion: (name, opts = {}) => make("binary_sensor", name, {
2660
+ deviceClass: "motion",
2661
+ initial: opts.initial
2662
+ }, {
2663
+ manufacturer: "Aqara",
2664
+ model: "Motion Sensor P1",
2665
+ ...ZIGBEE_BATTERY
2666
+ }, opts),
2667
+ contact: (deviceClass, name, opts = {}) => make("binary_sensor", name, {
2668
+ deviceClass,
2669
+ initial: opts.initial
2670
+ }, {
2671
+ manufacturer: "Aqara",
2672
+ model: "Door and Window Sensor",
2673
+ ...ZIGBEE_BATTERY
2674
+ }, opts),
2675
+ smoke: (name, opts = {}) => make("binary_sensor", name, { deviceClass: "smoke" }, {
2676
+ manufacturer: "Heiman",
2677
+ model: "Smoke Sensor",
2678
+ ...ZIGBEE_BATTERY
2679
+ }, opts),
2680
+ leak: (name, opts = {}) => make("binary_sensor", name, { deviceClass: "moisture" }, {
2681
+ manufacturer: "Aqara",
2682
+ model: "Water Leak Sensor",
2683
+ ...ZIGBEE_BATTERY
2684
+ }, opts),
2685
+ camera: (name, opts = {}) => make("camera", name, {}, {
2686
+ manufacturer: "Ring",
2687
+ model: "Video Doorbell",
2688
+ integration: "demo",
2689
+ transport: "wifi"
2690
+ }, opts),
2691
+ fixedSensor: (name, opts) => make("sensor", name, {
2692
+ reading: "fixed",
2693
+ value: opts.value,
2694
+ unit: opts.unit,
2695
+ deviceClass: opts.deviceClass
2696
+ }, {
2697
+ manufacturer: "Home Assistant",
2698
+ model: "Sensor",
2699
+ integration: "demo"
2700
+ }, opts),
2701
+ sun: () => make("sun", "Sun", {}, {
2702
+ manufacturer: "Home Assistant",
2703
+ model: "Sun",
2704
+ integration: "sun"
2705
+ }),
2706
+ weather: (name, opts = {}) => make("weather", name, {}, {
2707
+ manufacturer: "Met.no",
2708
+ model: "Forecast",
2709
+ integration: "met"
2710
+ }, opts),
2711
+ weatherShowcase: (fixture) => make("weather_showcase", titleCase(`Demo ${fixture.slug.replace(/_/g, " ")}`), { ...fixture }, {
2712
+ manufacturer: "Met.no",
2713
+ model: "Forecast",
2714
+ integration: "met"
2715
+ }, { key: `demo_${fixture.slug}` }),
2716
+ energyMeter: (name, opts = {}) => make("energy_meter", name, {}, {
2717
+ manufacturer: "Shelly",
2718
+ model: "Pro 3EM",
2719
+ integration: "shelly",
2720
+ transport: "wifi"
2721
+ }, opts),
2722
+ person: (name, opts) => make("person", name, { template: opts.template }, {
2723
+ manufacturer: "Home Assistant",
2724
+ model: "Person",
2725
+ integration: "person"
2726
+ }, opts),
2727
+ phone: (name, opts = {}) => make("sensor", name, { reading: "battery" }, {
2728
+ manufacturer: "Apple",
2729
+ model: "iPhone",
2730
+ integration: "demo"
2731
+ }, {
2732
+ ...opts,
2733
+ entityName: "Battery"
2734
+ })
2735
+ };
2736
+ }
2737
+ function room(id, name, floor, build) {
2738
+ return {
2739
+ id,
2740
+ name,
2741
+ floor,
2742
+ devices: build(inRoom(id)).flat()
2743
+ };
2744
+ }
2745
+ function wholeHouse(build) {
2746
+ return build(inRoom(null)).flat();
2747
+ }
2748
+ //#endregion
2749
+ //#region src/demo/house/generate.ts
2750
+ const SIDE_NAME = {
2751
+ signal: "Signal strength",
2752
+ battery: "Battery",
2753
+ firmware: "Firmware"
2754
+ };
2755
+ const registryIdOf = (d) => d.deviceId ?? d.key;
2756
+ function sideRoles(owner) {
2757
+ const transport = owner.params.transport;
2758
+ const roles = [];
2759
+ if (transport === "zigbee" || transport === "wifi") roles.push("signal");
2760
+ if (transport === "zigbee" && owner.params.battery) roles.push("battery");
2761
+ if (transport === "wifi" || owner.kind === "light") roles.push("firmware");
2762
+ return roles;
2763
+ }
2764
+ function sideSpec(owner, role) {
2765
+ const deviceId = registryIdOf(owner);
2766
+ const base = {
2767
+ name: owner.name,
2768
+ areaId: owner.areaId,
2769
+ manufacturer: owner.manufacturer,
2770
+ model: owner.model,
2771
+ deviceId,
2772
+ entityName: SIDE_NAME[role]
2773
+ };
2774
+ const integration = owner.params.integration;
2775
+ switch (role) {
2776
+ case "signal": return {
2777
+ ...base,
2778
+ key: `${deviceId}_signal_strength`,
2779
+ kind: "sensor",
2780
+ params: {
2781
+ reading: "signal",
2782
+ integration
2783
+ }
2784
+ };
2785
+ case "battery": {
2786
+ const batteryId = owner.ids?.battery;
2787
+ return {
2788
+ ...base,
2789
+ key: `${deviceId}_battery`,
2790
+ kind: "sensor",
2791
+ params: {
2792
+ reading: "battery",
2793
+ integration
2794
+ },
2795
+ ...batteryId ? { ids: { sensor: batteryId } } : {}
2796
+ };
2797
+ }
2798
+ case "firmware": return {
2799
+ ...base,
2800
+ key: `${deviceId}_firmware`,
2801
+ kind: "update",
2802
+ params: { integration },
2803
+ ids: { update: `update.${deviceId}_firmware` }
2804
+ };
2805
+ }
2806
+ }
2807
+ function peopleSpecs(house) {
2808
+ const b = inRoom(null);
2809
+ return house.people.flatMap((p) => [b.person(p.name, {
2810
+ key: `person_${p.id}`,
2811
+ id: `person.${p.id}`,
2812
+ template: p.template
2813
+ }), b.phone(`${p.name}'s Phone`, {
2814
+ key: `phone_${p.id}`,
2815
+ id: `sensor.phone_${p.id}_battery`
2816
+ })]);
2817
+ }
2818
+ function place(declared) {
2819
+ const groups = /* @__PURE__ */ new Map();
2820
+ for (const d of declared) {
2821
+ const id = registryIdOf(d);
2822
+ groups.set(id, [...groups.get(id) ?? [], d]);
2823
+ }
2824
+ const placed = [];
2825
+ for (const [deviceId, specs] of groups) {
2826
+ const owner = specs[0];
2827
+ if (!owner) continue;
2828
+ for (const spec of specs) placed.push({
2829
+ spec,
2830
+ deviceId,
2831
+ side: false,
2832
+ seeds: KINDS[spec.kind].entities(spec)
2833
+ });
2834
+ for (const role of sideRoles(owner)) {
2835
+ const spec = sideSpec(owner, role);
2836
+ const category = role === "battery" && owner.ids?.battery ? void 0 : "diagnostic";
2837
+ placed.push({
2838
+ spec,
2839
+ deviceId,
2840
+ side: true,
2841
+ seeds: KINDS[spec.kind].entities(spec),
2842
+ category
2843
+ });
2844
+ }
2845
+ }
2846
+ return placed;
2847
+ }
2848
+ function resolveTarget(t, placed) {
2849
+ const entityIds = t.entityIds ?? placed.filter((p) => !p.side && (t.area === void 0 || p.spec.areaId === t.area)).flatMap((p) => p.seeds.filter((s) => s.primary && s.entityId.startsWith(`${t.domain}.`)).map((s) => s.entityId));
2850
+ return {
2851
+ domain: t.domain,
2852
+ service: t.service,
2853
+ data: t.data ?? {},
2854
+ entityIds
2855
+ };
2856
+ }
2857
+ function resolveScenes(placed) {
2858
+ return placed.map((p) => {
2859
+ if (p.spec.kind !== "scene") return p;
2860
+ const targets = p.spec.params.targets.map((t) => resolveTarget(t, placed));
2861
+ return {
2862
+ ...p,
2863
+ spec: {
2864
+ ...p.spec,
2865
+ params: {
2866
+ ...p.spec.params,
2867
+ targets
2868
+ }
2869
+ }
2870
+ };
2871
+ });
2872
+ }
2873
+ function assertUnique(label, values) {
2874
+ const seen = /* @__PURE__ */ new Set();
2875
+ for (const v of values) {
2876
+ if (seen.has(v)) throw new Error(`demo house: duplicate ${label} ${v}`);
2877
+ seen.add(v);
2878
+ }
2879
+ }
2880
+ function assertTargetsExist(placed, known) {
2881
+ for (const p of placed) {
2882
+ if (p.spec.kind !== "scene") continue;
2883
+ for (const t of p.spec.params.targets) {
2884
+ const missing = t.entityIds.find((id) => !known.has(id));
2885
+ if (missing) throw new Error(`demo house: scene ${p.spec.key} targets unknown ${missing}`);
2886
+ }
2887
+ }
2888
+ }
2889
+ function toCategory(c) {
2890
+ return c ?? null;
2891
+ }
2892
+ function entityEntry(p, seed, name, createdIso) {
2893
+ const integration = p.spec.params.integration;
2894
+ const platform = typeof integration === "string" ? integration : "demo";
2895
+ return {
2896
+ aliases: [],
2897
+ area_id: null,
2898
+ capabilities: null,
2899
+ categories: {},
2900
+ config_entry_id: `${platform}_entry`,
2901
+ config_subentry_id: null,
2902
+ created_at: createdIso,
2903
+ device_class: seed.deviceClass ?? null,
2904
+ device_id: p.deviceId,
2905
+ disabled_by: null,
2906
+ entity_category: toCategory(p.side ? p.category : seed.category),
2907
+ entity_id: seed.entityId,
2908
+ has_entity_name: true,
2909
+ hidden_by: null,
2910
+ icon: null,
2911
+ id: `reg-${seed.entityId.replace(".", "-")}`,
2912
+ labels: [],
2913
+ modified_at: createdIso,
2914
+ name,
2915
+ options: {},
2916
+ original_device_class: seed.deviceClass ?? null,
2917
+ original_icon: null,
2918
+ original_name: name,
2919
+ platform,
2920
+ previous_unique_id: null,
2921
+ suggested_object_id: null,
2922
+ supported_features: seed.supportedFeatures ?? 0,
2923
+ translation_key: null,
2924
+ unique_id: `demo_${seed.entityId.replace(".", "_")}`,
2925
+ unit_of_measurement: seed.unit ?? null
2926
+ };
2927
+ }
2928
+ function deviceEntry(owner, id, createdIso) {
2929
+ const integration = owner.params.integration;
2930
+ const platform = typeof integration === "string" ? integration : "demo";
2931
+ const entry = `${platform}_entry`;
2932
+ return {
2933
+ id,
2934
+ name: owner.name,
2935
+ name_by_user: null,
2936
+ manufacturer: owner.manufacturer,
2937
+ model: owner.model,
2938
+ model_id: null,
2939
+ sw_version: "1.0.0",
2940
+ hw_version: null,
2941
+ area_id: owner.areaId,
2942
+ config_entries: [entry],
2943
+ config_entries_subentries: {},
2944
+ configuration_url: null,
2945
+ connections: [],
2946
+ created_at: createdIso,
2947
+ disabled_by: null,
2948
+ entry_type: null,
2949
+ identifiers: [[platform, id]],
2950
+ labels: [],
2951
+ modified_at: createdIso,
2952
+ primary_config_entry: entry,
2953
+ serial_number: null,
2954
+ via_device_id: null
2955
+ };
2956
+ }
2957
+ function areaEntry(room, placed, createdIso) {
2958
+ const reading = (...readings) => placed.find((p) => !p.side && p.spec.areaId === room.id && p.spec.kind === "sensor" && readings.includes(String(p.spec.params.reading)))?.seeds[0]?.entityId ?? null;
2959
+ return {
2960
+ id: room.id,
2961
+ name: room.name,
2962
+ normalized_name: room.name.toLowerCase().replace(/\s+/g, "_"),
2963
+ created_at: createdIso,
2964
+ modified_at: createdIso,
2965
+ aliases: [],
2966
+ floor_id: room.floor,
2967
+ humidity_entity_id: reading("humidity"),
2968
+ icon: null,
2969
+ labels: [],
2970
+ picture: null,
2971
+ temperature_entity_id: reading("temperature", "outdoor_temperature")
2972
+ };
2973
+ }
2974
+ function generateHouse(house, createdIso) {
2975
+ const placed = resolveScenes(place([
2976
+ ...house.rooms.flatMap((r) => r.devices),
2977
+ ...house.whole,
2978
+ ...peopleSpecs(house)
2979
+ ]));
2980
+ assertUnique("device key", placed.map((p) => p.spec.key));
2981
+ const entityIds = placed.flatMap((p) => p.seeds.map((s) => s.entityId));
2982
+ assertUnique("entity id", entityIds);
2983
+ assertTargetsExist(placed, new Set(entityIds));
2984
+ const devices = {};
2985
+ const entityRegistry = {};
2986
+ const roomOf = {};
2987
+ const friendlyNames = {};
2988
+ for (const p of placed) {
2989
+ const owner = devices[p.deviceId] ?? (devices[p.deviceId] = deviceEntry(p.spec, p.deviceId, createdIso));
2990
+ for (const seed of p.seeds) {
2991
+ const name = seed.primary ? p.spec.entityName ?? null : seed.name;
2992
+ entityRegistry[seed.entityId] = entityEntry(p, seed, name, createdIso);
2993
+ friendlyNames[seed.entityId] = name ? `${owner.name} ${name}` : owner.name ?? seed.entityId;
2994
+ if (p.spec.areaId) roomOf[seed.entityId] = p.spec.areaId;
2995
+ }
2996
+ }
2997
+ const areas = Object.fromEntries(house.rooms.map((r) => [r.id, areaEntry(r, placed, createdIso)]));
2998
+ const floors = Object.fromEntries(house.floors.map((f) => [f.id, {
2999
+ floor_id: f.id,
3000
+ name: f.name,
3001
+ level: f.level,
3002
+ icon: null,
3003
+ aliases: [],
3004
+ created_at: createdIso,
3005
+ modified_at: createdIso
3006
+ }]));
3007
+ return {
3008
+ devices: placed.map((p) => p.spec),
3009
+ registry: {
3010
+ entityRegistry,
3011
+ devices,
3012
+ areas,
3013
+ floors
3014
+ },
3015
+ entityIds,
3016
+ roomOf,
3017
+ friendlyNames
3018
+ };
3019
+ }
3020
+ //#endregion
3021
+ //#region src/demo/house/house.ts
3022
+ const LIVING_LIGHTS = {
3023
+ domain: "light",
3024
+ service: "turn_on",
3025
+ data: { brightness_pct: 20 },
3026
+ area: "living_room"
3027
+ };
3028
+ const ALL_LIGHTS_OFF = {
3029
+ domain: "light",
3030
+ service: "turn_off"
3031
+ };
3032
+ const ALL_LOCKS_LOCKED = {
3033
+ domain: "lock",
3034
+ service: "lock"
3035
+ };
3036
+ const HOUSE = {
3037
+ version: 1,
3038
+ floors: [{
3039
+ id: "ground",
3040
+ name: "Ground floor",
3041
+ level: 0
3042
+ }, {
3043
+ id: "upstairs",
3044
+ name: "Upstairs",
3045
+ level: 1
3046
+ }],
3047
+ rooms: [
3048
+ room("living_room", "Living Room", "ground", (b) => [
3049
+ b.light("Living Room Main", {
3050
+ id: "light.living_room_main",
3051
+ colorTemp: true,
3052
+ watts: 40
3053
+ }),
3054
+ b.light("Studio RGB", {
3055
+ id: "light.studio_rgb",
3056
+ color: true,
3057
+ colorTemp: true
3058
+ }),
3059
+ b.light("Floor Lamp", { colorTemp: true }),
3060
+ b.light("Reading Lamp"),
3061
+ b.light("Shelf Strip", { color: true }),
3062
+ b.climate("Living Room Thermostat", {
3063
+ id: "climate.living_room_thermostat",
3064
+ supportedFeatures: 385
3065
+ }),
3066
+ b.climateSensor("Living Room Climate", {
3067
+ temperatureId: "sensor.temperature_living",
3068
+ humidityId: "sensor.humidity_living"
3069
+ }),
3070
+ b.cover("Living Room Blinds", {
3071
+ id: "cover.living_room_blinds",
3072
+ manufacturer: "Somfy",
3073
+ model: "Roller Shade"
3074
+ }),
3075
+ b.cover("Living Room Shutters", {
3076
+ id: "cover.living_room_shutters",
3077
+ tilt: true,
3078
+ deviceClass: "shutter",
3079
+ manufacturer: "Somfy",
3080
+ model: "Venetian Shutter"
3081
+ }),
3082
+ b.switchDevice("Living Room Fan", {
3083
+ id: "switch.fan_living_room",
3084
+ watts: 45,
3085
+ deviceClass: "switch"
3086
+ }),
3087
+ b.mediaPlayer("Living Room Speaker", {
3088
+ id: "media_player.living_room_speaker",
3089
+ sources: [
3090
+ "Spotify",
3091
+ "AirPlay",
3092
+ "TV"
3093
+ ]
3094
+ }),
3095
+ b.plug("TV", 120),
3096
+ b.fan("Air Purifier", {
3097
+ id: "fan.air_purifier",
3098
+ presets: [
3099
+ "auto",
3100
+ "sleep",
3101
+ "turbo"
3102
+ ],
3103
+ oscillate: true,
3104
+ manufacturer: "Xiaomi",
3105
+ model: "Air Purifier 4"
3106
+ }),
3107
+ b.motion("Living Room Motion"),
3108
+ b.contact("window", "Living Room Left Window"),
3109
+ b.contact("window", "Living Room Right Window"),
3110
+ b.scene("Movie Night", [
3111
+ LIVING_LIGHTS,
3112
+ {
3113
+ domain: "cover",
3114
+ service: "close_cover",
3115
+ entityIds: ["cover.living_room_blinds"]
3116
+ },
3117
+ {
3118
+ domain: "media_player",
3119
+ service: "media_play",
3120
+ entityIds: ["media_player.living_room_speaker"]
3121
+ }
3122
+ ], { key: "movie_night" })
3123
+ ]),
3124
+ room("kitchen", "Kitchen", "ground", (b) => [
3125
+ b.light("Kitchen Counter", { id: "light.kitchen_counter" }),
3126
+ b.light("Kitchen Pendant"),
3127
+ b.light("Kitchen Spots"),
3128
+ b.cover("Kitchen Blinds", {
3129
+ id: "cover.kitchen_blinds",
3130
+ deviceClass: "blind",
3131
+ model: "Praktlysing",
3132
+ supportedFeatures: 127
3133
+ }),
3134
+ b.switchDevice("Coffee Machine", {
3135
+ id: "switch.coffee_machine",
3136
+ watts: 1200,
3137
+ deviceClass: "outlet"
3138
+ }),
3139
+ b.appliance("Dishwasher", 1800, { legacyPowerId: "sensor.dishwasher_power" }),
3140
+ b.appliance("Oven", 2200, { legacyPowerId: "sensor.oven_power" }),
3141
+ b.appliance("Fridge", 150, { legacyPowerId: "sensor.fridge_power" }),
3142
+ b.plug("Kettle", 2e3),
3143
+ b.climateSensor("Kitchen Climate"),
3144
+ b.motion("Kitchen Motion"),
3145
+ b.smoke("Kitchen Smoke"),
3146
+ b.leak("Kitchen Leak")
3147
+ ]),
3148
+ room("dining", "Dining Room", "ground", (b) => [
3149
+ b.light("Dining Table"),
3150
+ b.light("Sideboard", { dimmable: false }),
3151
+ b.contact("window", "Dining Room Window"),
3152
+ b.climateSensor("Dining Room Climate"),
3153
+ b.scene("Dinner", [{
3154
+ domain: "light",
3155
+ service: "turn_on",
3156
+ data: { brightness_pct: 60 },
3157
+ entityIds: ["light.dining_table"]
3158
+ }, {
3159
+ domain: "light",
3160
+ service: "turn_on",
3161
+ data: { brightness_pct: 40 },
3162
+ entityIds: ["light.kitchen_pendant"]
3163
+ }], { key: "dinner" })
3164
+ ]),
3165
+ room("entry", "Entry", "ground", (b) => [
3166
+ b.light("Hallway", { id: "light.hallway" }),
3167
+ b.lock("Front Door Lock", {
3168
+ id: "lock.front_door_lock",
3169
+ batteryId: "sensor.door_lock_battery",
3170
+ supportedFeatures: 1
3171
+ }),
3172
+ b.contact("door", "Front Door", {
3173
+ id: "binary_sensor.front_door",
3174
+ batteryId: "sensor.battery_door_sensor"
3175
+ }),
3176
+ b.motion("Hallway Motion", {
3177
+ id: "binary_sensor.motion_hallway",
3178
+ batteryId: "sensor.motion_sensor_battery"
3179
+ }),
3180
+ b.camera("Front Door Camera", { id: "camera.front_door_camera" })
3181
+ ]),
3182
+ room("utility", "Utility", "ground", (b) => [
3183
+ b.appliance("Washing Machine", 2e3, { legacyPowerId: "sensor.washing_machine_power" }),
3184
+ b.appliance("Dryer", 2500),
3185
+ b.waterHeater("Boiler", {
3186
+ id: "water_heater.boiler",
3187
+ modes: [
3188
+ "off",
3189
+ "eco",
3190
+ "performance"
3191
+ ],
3192
+ min: 43,
3193
+ max: 60,
3194
+ supportedFeatures: 7
3195
+ }),
3196
+ b.waterHeater("Heat Pump Tank", {
3197
+ id: "water_heater.heat_pump_tank",
3198
+ modes: [
3199
+ "off",
3200
+ "eco",
3201
+ "heat_pump",
3202
+ "high_demand"
3203
+ ],
3204
+ min: 43,
3205
+ max: 60,
3206
+ supportedFeatures: 3
3207
+ }),
3208
+ b.leak("Utility Leak"),
3209
+ b.light("Utility Ceiling", { dimmable: false })
3210
+ ]),
3211
+ room("garage", "Garage", "ground", (b) => [
3212
+ b.cover("Garage Door", {
3213
+ id: "cover.garage_door",
3214
+ position: false,
3215
+ deviceClass: "garage",
3216
+ manufacturer: "Chamberlain",
3217
+ model: "MyQ"
3218
+ }),
3219
+ b.cover("Driveway Gate", {
3220
+ id: "cover.driveway_gate",
3221
+ position: false,
3222
+ deviceClass: "gate",
3223
+ manufacturer: "Nice",
3224
+ model: "Robus",
3225
+ supportedFeatures: 3
3226
+ }),
3227
+ b.lock("Back Door Lock", {
3228
+ id: "lock.back_door_lock",
3229
+ manufacturer: "August",
3230
+ model: "Smart Lock Pro",
3231
+ supportedFeatures: 1
3232
+ }),
3233
+ b.plug("EV Charger", 7200, {
3234
+ legacyPowerId: "sensor.ev_charger_power",
3235
+ manufacturer: "Wallbox",
3236
+ model: "Pulsar Plus"
3237
+ }),
3238
+ b.light("Garage Ceiling", { dimmable: false }),
3239
+ b.motion("Garage Motion")
3240
+ ]),
3241
+ room("office", "Office", "ground", (b) => [
3242
+ b.light("Office Ceiling"),
3243
+ b.light("Desk Lamp", { colorTemp: true }),
3244
+ b.plug("Monitor", 35),
3245
+ b.plug("Printer", 15),
3246
+ b.climateSensor("Office Climate"),
3247
+ b.climate("Office Heat Pump", {
3248
+ modes: [
3249
+ "off",
3250
+ "heat",
3251
+ "cool",
3252
+ "auto"
3253
+ ],
3254
+ fanModes: [
3255
+ "auto",
3256
+ "low",
3257
+ "medium",
3258
+ "high"
3259
+ ],
3260
+ presets: [
3261
+ "home",
3262
+ "away",
3263
+ "eco"
3264
+ ],
3265
+ manufacturer: "Mitsubishi",
3266
+ model: "Wall Split"
3267
+ }),
3268
+ b.contact("window", "Office Window"),
3269
+ b.cover("Office Blinds")
3270
+ ]),
3271
+ room("bedroom", "Bedroom", "upstairs", (b) => [
3272
+ b.light("Bedroom Ceiling", {
3273
+ id: "light.bedroom_ceiling",
3274
+ colorTemp: true
3275
+ }),
3276
+ b.light("Desk RGB", {
3277
+ id: "light.desk_rgb",
3278
+ color: true,
3279
+ colorTemp: true
3280
+ }),
3281
+ b.light("Bedroom Bedside Left"),
3282
+ b.light("Bedroom Bedside Right"),
3283
+ b.climate("Bedroom AC", {
3284
+ id: "climate.bedroom_ac",
3285
+ modes: [
3286
+ "off",
3287
+ "cool",
3288
+ "heat",
3289
+ "dry",
3290
+ "fan_only"
3291
+ ],
3292
+ manufacturer: "Daikin",
3293
+ model: "Split AC",
3294
+ supportedFeatures: 385
3295
+ }),
3296
+ b.fan("Bedroom Ceiling Fan", {
3297
+ id: "fan.bedroom_ceiling",
3298
+ direction: true
3299
+ }),
3300
+ b.cover("Bedroom Curtains", {
3301
+ id: "cover.bedroom_curtains",
3302
+ deviceClass: "curtain"
3303
+ }),
3304
+ b.contact("window", "Bedroom Window", { id: "binary_sensor.window_bedroom" }),
3305
+ b.climateSensor("Bedroom Climate"),
3306
+ b.motion("Bedroom Motion")
3307
+ ]),
3308
+ room("bathroom", "Bathroom", "upstairs", (b) => [
3309
+ b.light("Bathroom Ceiling", {
3310
+ id: "light.bathroom",
3311
+ supportedFeatures: 0
3312
+ }),
3313
+ b.light("Bathroom Mirror"),
3314
+ b.fan("Bathroom Extractor", { watts: 20 }),
3315
+ b.climateSensor("Bathroom Climate"),
3316
+ b.leak("Bathroom Leak"),
3317
+ b.contact("window", "Bathroom Window")
3318
+ ]),
3319
+ room("kids_room", "Kids Room", "upstairs", (b) => [
3320
+ b.light("Kids Room Ceiling"),
3321
+ b.light("Night Light", { color: true }),
3322
+ b.cover("Kids Room Blinds"),
3323
+ b.climateSensor("Kids Room Climate"),
3324
+ b.contact("window", "Kids Room Window"),
3325
+ b.motion("Kids Room Motion")
3326
+ ]),
3327
+ room("guest_room", "Guest Room", "upstairs", (b) => [
3328
+ b.light("Guest Room Ceiling"),
3329
+ b.light("Guest Room Bedside"),
3330
+ b.climateSensor("Guest Room Climate"),
3331
+ b.contact("window", "Guest Room Window")
3332
+ ]),
3333
+ room("landing", "Landing", "upstairs", (b) => [
3334
+ b.light("Landing Ceiling"),
3335
+ b.motion("Landing Motion"),
3336
+ b.smoke("Landing Smoke")
3337
+ ]),
3338
+ room("garden", "Garden", "ground", (b) => [
3339
+ b.light("Path Lights", { dimmable: false }),
3340
+ b.light("Terrace"),
3341
+ b.switchDevice("Irrigation", { watts: 10 }),
3342
+ b.plug("Pond Pump", 60),
3343
+ b.climateSensor("Weather Station", {
3344
+ temperatureId: "sensor.temperature_outdoor",
3345
+ outdoor: true
3346
+ }),
3347
+ b.motion("Garden Motion"),
3348
+ b.contact("door", "Garden Gate")
3349
+ ])
3350
+ ],
3351
+ people: [
3352
+ {
3353
+ id: "alex",
3354
+ name: "Alex",
3355
+ template: "commuter"
3356
+ },
3357
+ {
3358
+ id: "sam",
3359
+ name: "Sam",
3360
+ template: "home_office"
3361
+ },
3362
+ {
3363
+ id: "robin",
3364
+ name: "Robin",
3365
+ template: "student"
3366
+ }
3367
+ ],
3368
+ whole: wholeHouse((b) => [
3369
+ b.sun(),
3370
+ b.weather("Home"),
3371
+ b.energyMeter("Home", {
3372
+ key: "energy_meter",
3373
+ entityName: "Consumption"
3374
+ }),
3375
+ b.fixedSensor("Power Consumption", {
3376
+ value: 1.2,
3377
+ unit: "kW",
3378
+ deviceClass: "power"
3379
+ }),
3380
+ b.fixedSensor("Electricity Maps", {
3381
+ id: "sensor.electricity_maps_co2_intensity",
3382
+ entityName: "CO2 intensity",
3383
+ value: 214,
3384
+ unit: "gCO2eq/kWh"
3385
+ }),
3386
+ b.fixedSensor("Electricity Maps", {
3387
+ key: "electricity_maps_fossil_fuel_percentage",
3388
+ deviceId: "electricity_maps",
3389
+ entityName: "Fossil fuel percentage",
3390
+ value: 31,
3391
+ unit: "%"
3392
+ }),
3393
+ b.fixedSensor("Nordpool", {
3394
+ id: "sensor.nordpool_current_price",
3395
+ entityName: "Current price",
3396
+ value: .18,
3397
+ unit: "EUR/kWh"
3398
+ }),
3399
+ ...["de", "pl"].flatMap((zone, i) => [b.fixedSensor("Electricity Maps", {
3400
+ key: `electricity_maps_${zone}`,
3401
+ id: `sensor.electricity_maps_co2_intensity_${zone}`,
3402
+ entityName: "CO2 intensity",
3403
+ value: [384, 692][i] ?? 0,
3404
+ unit: "gCO2eq/kWh"
3405
+ }), b.fixedSensor("Electricity Maps", {
3406
+ key: `electricity_maps_fossil_fuel_percentage_${zone}`,
3407
+ deviceId: `electricity_maps_${zone}`,
3408
+ entityName: "Fossil fuel percentage",
3409
+ value: [52, 81][i] ?? 0,
3410
+ unit: "%"
3411
+ })]),
3412
+ b.lock("Side Gate", {
3413
+ id: "lock.side_gate",
3414
+ unlocked: true,
3415
+ supportedFeatures: 1
3416
+ }),
3417
+ b.contact("door", "Patio Door", {
3418
+ id: "binary_sensor.patio_door",
3419
+ initial: true
3420
+ }),
3421
+ b.motion("Pantry Motion", {
3422
+ id: "binary_sensor.motion_kitchen",
3423
+ initial: true
3424
+ }),
3425
+ b.button("Restart Home Assistant", {
3426
+ category: "config",
3427
+ deviceClass: "restart"
3428
+ }),
3429
+ b.button("Update Firmware", {
3430
+ category: "config",
3431
+ deviceClass: "update"
3432
+ }),
3433
+ b.scene("Good Morning", [
3434
+ {
3435
+ domain: "cover",
3436
+ service: "open_cover",
3437
+ entityIds: ["cover.bedroom_curtains"]
3438
+ },
3439
+ {
3440
+ domain: "light",
3441
+ service: "turn_on",
3442
+ entityIds: ["light.kitchen_counter"]
3443
+ },
3444
+ {
3445
+ domain: "switch",
3446
+ service: "turn_on",
3447
+ entityIds: ["switch.coffee_machine"]
3448
+ }
3449
+ ]),
3450
+ b.scene("Good Night", [
3451
+ ALL_LIGHTS_OFF,
3452
+ ALL_LOCKS_LOCKED,
3453
+ {
3454
+ domain: "cover",
3455
+ service: "close_cover"
3456
+ }
3457
+ ]),
3458
+ b.scene("Away", [ALL_LIGHTS_OFF, ALL_LOCKS_LOCKED]),
3459
+ ...WEATHER_FIXTURES.map((w) => b.weatherShowcase(w))
3460
+ ])
3461
+ };
3462
+ //#endregion
3463
+ //#region src/demo/sim/log.ts
3464
+ const PREFIX = "glasshome.demo.log.v";
3465
+ const MAX_AGE_MS = 864e5;
3466
+ function logKey(houseVersion) {
3467
+ return `${PREFIX}${houseVersion}`;
3468
+ }
3469
+ function isServiceCall(v) {
3470
+ if (typeof v !== "object" || v === null) return false;
3471
+ const c = v;
3472
+ return typeof c.domain === "string" && typeof c.service === "string" && typeof c.data === "object" && c.data !== null && Array.isArray(c.entityIds) && c.entityIds.every((id) => typeof id === "string");
3473
+ }
3474
+ function isLoggedCall(v) {
3475
+ if (typeof v !== "object" || v === null) return false;
3476
+ const c = v;
3477
+ return typeof c.simMs === "number" && isServiceCall(c.call);
3478
+ }
3479
+ function pruneLog(entries, nowMs) {
3480
+ const cutoff = nowMs - MAX_AGE_MS;
3481
+ return entries.filter((e) => e.simMs >= cutoff).sort((a, b) => a.simMs - b.simMs).slice(-500);
3482
+ }
3483
+ function removeOtherVersions(storage, keep) {
3484
+ const stale = [];
3485
+ for (let i = 0; i < storage.length; i++) {
3486
+ const key = storage.key(i);
3487
+ if (key && key.startsWith(PREFIX) && key !== keep) stale.push(key);
3488
+ }
3489
+ for (const key of stale) storage.removeItem(key);
3490
+ }
3491
+ function readLog(storage, houseVersion, nowMs) {
3492
+ if (!storage) return [];
3493
+ const key = logKey(houseVersion);
3494
+ try {
3495
+ removeOtherVersions(storage, key);
3496
+ } catch {}
3497
+ try {
3498
+ const raw = storage.getItem(key);
3499
+ if (!raw) return [];
3500
+ const parsed = JSON.parse(raw);
3501
+ if (!Array.isArray(parsed)) return [];
3502
+ return pruneLog(parsed.filter(isLoggedCall), nowMs);
3503
+ } catch {
3504
+ return [];
3505
+ }
3506
+ }
3507
+ function appendLog(storage, houseVersion, entry, nowMs) {
3508
+ if (!storage) return;
3509
+ const key = logKey(houseVersion);
3510
+ let existing = [];
3511
+ try {
3512
+ const raw = storage.getItem(key);
3513
+ if (raw) {
3514
+ const parsed = JSON.parse(raw);
3515
+ if (Array.isArray(parsed)) existing = parsed.filter(isLoggedCall);
3516
+ }
3517
+ } catch {
3518
+ existing = [];
3519
+ }
3520
+ try {
3521
+ storage.setItem(key, JSON.stringify(pruneLog([...existing, entry], nowMs)));
3522
+ } catch {}
3523
+ }
3524
+ function clearLog(storage, houseVersion) {
3525
+ if (!storage) return;
3526
+ try {
3527
+ storage.removeItem(logKey(houseVersion));
3528
+ } catch {}
3529
+ }
3530
+ //#endregion
3531
+ //#region src/demo/sim/model.ts
3532
+ const MAX_EFFECT_DEPTH = 4;
3533
+ function createDemoModel(devices, kinds, opts) {
3534
+ const stepMs = opts.stepMs ?? 1e3;
3535
+ const log = opts.log ?? (() => {});
3536
+ let nowMs = opts.startMs;
3537
+ const ctx = () => ({
3538
+ nowMs,
3539
+ world: opts.world,
3540
+ noise: (k) => noise(opts.world.seed, k)
3541
+ });
3542
+ const byKey = new Map(devices.map((d) => [d.key, d]));
3543
+ const byEntity = /* @__PURE__ */ new Map();
3544
+ const states = /* @__PURE__ */ new Map();
3545
+ const holds = /* @__PURE__ */ new Map();
3546
+ let queue = [];
3547
+ let queueSeq = 0;
3548
+ const driver = opts.driver?.();
3549
+ for (const d of devices) {
3550
+ for (const seed of kinds[d.kind].entities(d)) byEntity.set(seed.entityId, d);
3551
+ states.set(d.key, kinds[d.kind].apply(void 0, { type: "init" }, d, ctx()));
3552
+ }
3553
+ function applyTo(d, event, depth) {
3554
+ const kind = kinds[d.kind];
3555
+ const next = kind.apply(states.get(d.key), event, d, ctx());
3556
+ states.set(d.key, next);
3557
+ if (depth >= MAX_EFFECT_DEPTH || !kind.effects) return;
3558
+ for (const call of kind.effects(next, event, d)) dispatchAt(call, depth + 1);
3559
+ }
3560
+ function dispatchAt(call, depth) {
3561
+ let handled = 0;
3562
+ for (const entityId of call.entityIds) {
3563
+ const d = byEntity.get(entityId);
3564
+ const kind = d ? kinds[d.kind] : void 0;
3565
+ if (!d || !kind || kind.handles && !kind.handles.includes(call.service)) {
3566
+ log(`demo: unmocked ${call.domain}.${call.service} on ${entityId}`);
3567
+ continue;
3568
+ }
3569
+ applyTo(d, {
3570
+ type: "call",
3571
+ entityId,
3572
+ service: call.service,
3573
+ data: call.data
3574
+ }, depth);
3575
+ handled++;
3576
+ }
3577
+ return handled;
3578
+ }
3579
+ function flushDue() {
3580
+ if (queue.length === 0 || (queue[0]?.atMs ?? Infinity) > nowMs) return;
3581
+ const due = queue.filter((q) => q.atMs <= nowMs);
3582
+ queue = queue.filter((q) => q.atMs > nowMs);
3583
+ for (const { call } of due) {
3584
+ const entityIds = call.entityIds.filter((id) => !holds.has(id));
3585
+ if (entityIds.length > 0) dispatchAt({
3586
+ ...call,
3587
+ entityIds
3588
+ }, 0);
3589
+ }
3590
+ }
3591
+ const model = {
3592
+ get nowMs() {
3593
+ return nowMs;
3594
+ },
3595
+ dispatch: (call) => dispatchAt(call, 0),
3596
+ advanceTo(ms, step = stepMs) {
3597
+ while (nowMs < ms) {
3598
+ const nextEvent = Math.min(queue[0]?.atMs ?? Infinity, driver?.nextAtMs() ?? Infinity);
3599
+ const dtMs = Math.min(step, ms - nowMs, Math.max(1, nextEvent - nowMs));
3600
+ const prev = nowMs;
3601
+ nowMs += dtMs;
3602
+ for (const d of devices) applyTo(d, {
3603
+ type: "tick",
3604
+ dtMs
3605
+ }, MAX_EFFECT_DEPTH);
3606
+ flushDue();
3607
+ driver?.step(model, prev, nowMs);
3608
+ flushDue();
3609
+ }
3610
+ },
3611
+ project() {
3612
+ const out = {};
3613
+ for (const d of devices) Object.assign(out, kinds[d.kind].project(states.get(d.key), d, ctx()));
3614
+ return out;
3615
+ },
3616
+ projectDevice(key) {
3617
+ const d = byKey.get(key);
3618
+ return d ? kinds[d.kind].project(states.get(d.key), d, ctx()) : {};
3619
+ },
3620
+ deviceOf: (entityId) => byEntity.get(entityId),
3621
+ hold: (entityId, until) => void holds.set(entityId, until),
3622
+ isHeld: (entityId) => holds.has(entityId),
3623
+ clearHolds(kind) {
3624
+ for (const [id, until] of holds) if (until === kind) holds.delete(id);
3625
+ },
3626
+ schedule(atMs, call) {
3627
+ queue.push({
3628
+ atMs,
3629
+ seq: queueSeq++,
3630
+ call
3631
+ });
3632
+ queue.sort((a, b) => a.atMs - b.atMs || a.seq - b.seq);
3633
+ }
3634
+ };
3635
+ driver?.settle(model);
3636
+ return model;
3637
+ }
3638
+ //#endregion
3639
+ //#region src/demo/sim/schedule.ts
3640
+ const at = (startMin, activity, room) => ({
3641
+ startMin,
3642
+ activity,
3643
+ room
3644
+ });
3645
+ const ADULT_WEEKEND = [
3646
+ at(0, "sleep", "bedroom"),
3647
+ at(510, "wake", "bathroom"),
3648
+ at(540, "cook", "kitchen"),
3649
+ at(600, "relax", "living_room"),
3650
+ at(780, "away", null),
3651
+ at(1020, "relax", "garden"),
3652
+ at(1140, "cook", "kitchen"),
3653
+ at(1200, "eat", "dining"),
3654
+ at(1260, "relax", "living_room"),
3655
+ at(1410, "sleep", "bedroom")
3656
+ ];
3657
+ const TEMPLATES = {
3658
+ commuter: {
3659
+ weekday: [
3660
+ at(0, "sleep", "bedroom"),
3661
+ at(390, "wake", "bathroom"),
3662
+ at(410, "eat", "kitchen"),
3663
+ at(460, "away", null),
3664
+ at(1050, "relax", "living_room"),
3665
+ at(1110, "cook", "kitchen"),
3666
+ at(1170, "eat", "dining"),
3667
+ at(1230, "relax", "living_room"),
3668
+ at(1350, "bath", "bathroom"),
3669
+ at(1380, "sleep", "bedroom")
3670
+ ],
3671
+ weekend: ADULT_WEEKEND
3672
+ },
3673
+ home_office: {
3674
+ weekday: [
3675
+ at(0, "sleep", "bedroom"),
3676
+ at(420, "wake", "bathroom"),
3677
+ at(450, "eat", "kitchen"),
3678
+ at(480, "work", "office"),
3679
+ at(750, "cook", "kitchen"),
3680
+ at(800, "work", "office"),
3681
+ at(1080, "relax", "living_room"),
3682
+ at(1170, "eat", "dining"),
3683
+ at(1230, "relax", "living_room"),
3684
+ at(1380, "sleep", "bedroom")
3685
+ ],
3686
+ weekend: ADULT_WEEKEND
3687
+ },
3688
+ student: {
3689
+ weekday: [
3690
+ at(0, "sleep", "kids_room"),
3691
+ at(420, "wake", "bathroom"),
3692
+ at(440, "eat", "kitchen"),
3693
+ at(470, "away", null),
3694
+ at(960, "relax", "kids_room"),
3695
+ at(1170, "eat", "dining"),
3696
+ at(1230, "relax", "kids_room"),
3697
+ at(1320, "sleep", "kids_room")
3698
+ ],
3699
+ weekend: [
3700
+ at(0, "sleep", "kids_room"),
3701
+ at(570, "wake", "bathroom"),
3702
+ at(600, "relax", "kids_room"),
3703
+ at(840, "away", null),
3704
+ at(1080, "relax", "living_room"),
3705
+ at(1200, "eat", "dining"),
3706
+ at(1260, "relax", "kids_room"),
3707
+ at(1380, "sleep", "kids_room")
3708
+ ]
3709
+ }
3710
+ };
3711
+ const SHIFT_SPAN_MIN = 40;
3712
+ const MINUTE_MS = 6e4;
3713
+ const DAY_AHEAD_MS = 1296e5;
3714
+ const LIGHTS_BELOW_ELEVATION = 6;
3715
+ const RELOCK_AFTER_MS = 2 * MINUTE_MS;
3716
+ function dayPlan(person, dateKey, weekday, seed) {
3717
+ const template = TEMPLATES[person.template];
3718
+ const base = weekday === 0 || weekday === 6 ? template.weekend : template.weekday;
3719
+ let previous = -1;
3720
+ return base.map((slot, i) => {
3721
+ const shifted = slot.startMin + Math.round((noise(seed, person.id + dateKey + i) - .5) * SHIFT_SPAN_MIN);
3722
+ const startMin = Math.max(shifted, previous + 1);
3723
+ previous = startMin;
3724
+ return {
3725
+ ...slot,
3726
+ startMin
3727
+ };
3728
+ });
3729
+ }
3730
+ function dayOf(person, ms, world) {
3731
+ const lt = localTime$1(ms, world.timeZone);
3732
+ return {
3733
+ midnightMs: lt.midnightMs,
3734
+ plan: dayPlan(person, lt.dateKey, lt.weekday, world.seed)
3735
+ };
3736
+ }
3737
+ function lastSlot(plan) {
3738
+ const last = plan[plan.length - 1];
3739
+ if (!last) throw new Error("demo schedule: empty day plan");
3740
+ return last;
3741
+ }
3742
+ function occupancyAt(person, ms, world) {
3743
+ const today = dayOf(person, ms, world);
3744
+ const minute = (ms - today.midnightMs) / MINUTE_MS;
3745
+ let current;
3746
+ for (let i = today.plan.length - 1; i >= 0; i--) {
3747
+ const slot = today.plan[i];
3748
+ if (slot && slot.startMin <= minute) {
3749
+ current = slot;
3750
+ break;
3751
+ }
3752
+ }
3753
+ if (current) return {
3754
+ slot: current,
3755
+ startMs: today.midnightMs + current.startMin * MINUTE_MS
3756
+ };
3757
+ const yesterday = dayOf(person, today.midnightMs - 1, world);
3758
+ const last = lastSlot(yesterday.plan);
3759
+ return {
3760
+ slot: last,
3761
+ startMs: yesterday.midnightMs + last.startMin * MINUTE_MS
3762
+ };
3763
+ }
3764
+ function transitionsOf(person, day, world) {
3765
+ let from = lastSlot(dayOf(person, day.midnightMs - 1, world).plan);
3766
+ const out = [];
3767
+ for (const to of day.plan) {
3768
+ if (to.activity !== from.activity || to.room !== from.room) out.push({
3769
+ atMs: day.midnightMs + to.startMin * MINUTE_MS,
3770
+ person,
3771
+ from,
3772
+ to
3773
+ });
3774
+ from = to;
3775
+ }
3776
+ return out;
3777
+ }
3778
+ const OVEN = "switch.kitchen_oven";
3779
+ const COFFEE_MACHINE = "switch.coffee_machine";
3780
+ const FRONT_DOOR = "lock.front_door_lock";
3781
+ const GOOD_NIGHT = "scene.good_night";
3782
+ const AWAY = "scene.away";
3783
+ const ACTIONS = {
3784
+ sleep: { lights: "none" },
3785
+ wake: {
3786
+ lights: "first",
3787
+ appliance: {
3788
+ id: COFFEE_MACHINE,
3789
+ minutes: 10,
3790
+ templates: ["commuter", "home_office"]
3791
+ }
3792
+ },
3793
+ cook: {
3794
+ lights: "all",
3795
+ appliance: {
3796
+ id: OVEN,
3797
+ minutes: 40
3798
+ }
3799
+ },
3800
+ eat: { lights: "all" },
3801
+ work: { lights: "all" },
3802
+ away: { lights: "none" },
3803
+ relax: { lights: "first" },
3804
+ bath: { lights: "all" }
3805
+ };
3806
+ const PICK_LIGHTS = {
3807
+ none: () => [],
3808
+ first: (lights) => lights.slice(0, 1),
3809
+ all: (lights) => lights
3810
+ };
3811
+ function primaryId(d) {
3812
+ return KINDS[d.kind].entities(d).find((e) => e.primary)?.entityId;
3813
+ }
3814
+ function gearByRoom(generated) {
3815
+ const rooms = /* @__PURE__ */ new Map();
3816
+ for (const d of generated.devices) {
3817
+ const id = primaryId(d);
3818
+ if (!d.areaId || !id) continue;
3819
+ const gear = rooms.get(d.areaId) ?? {
3820
+ lights: [],
3821
+ motion: [],
3822
+ covers: []
3823
+ };
3824
+ rooms.set(d.areaId, gear);
3825
+ if (d.kind === "light" && d.params.scheduled !== false) gear.lights.push(id);
3826
+ if (d.kind === "binary_sensor" && d.params.deviceClass === "motion") gear.motion.push(id);
3827
+ if (d.kind === "cover") gear.covers.push(id);
3828
+ }
3829
+ return rooms;
3830
+ }
3831
+ const call = (domain, service, entityIds, data = {}) => ({
3832
+ domain,
3833
+ service,
3834
+ data,
3835
+ entityIds
3836
+ });
3837
+ const isAwakeAtHome = (s) => s.activity !== "sleep" && s.activity !== "away";
3838
+ const touchesEvent = (t) => [t.from.activity, t.to.activity].some((a) => a === "away" || a === "sleep");
3839
+ function createDriver(house, generated, world) {
3840
+ const people = [...house.people].sort((a, b) => a.id.localeCompare(b.id));
3841
+ const rooms = gearByRoom(generated);
3842
+ const known = new Set(generated.entityIds);
3843
+ const where = /* @__PURE__ */ new Map();
3844
+ const pending = /* @__PURE__ */ new Map();
3845
+ const nextDayAnchor = /* @__PURE__ */ new Map();
3846
+ const runEnds = /* @__PURE__ */ new Map();
3847
+ const gear = (room) => room ? rooms.get(room) : void 0;
3848
+ function issue(model, c) {
3849
+ const entityIds = c.entityIds.filter((id) => known.has(id) && !model.isHeld(id));
3850
+ if (entityIds.length > 0) model.dispatch({
3851
+ ...c,
3852
+ entityIds
3853
+ });
3854
+ }
3855
+ function later(model, atMs, c) {
3856
+ const entityIds = c.entityIds.filter((id) => known.has(id));
3857
+ if (entityIds.length > 0) model.schedule(atMs, {
3858
+ ...c,
3859
+ entityIds
3860
+ });
3861
+ }
3862
+ const occupants = (room, except) => [...where].some(([id, s]) => id !== except && s.room === room);
3863
+ const nobodyHome = (slots) => [...slots].every((s) => s.activity === "away");
3864
+ const nobodyAwake = (slots) => ![...slots].some(isAwakeAtHome);
3865
+ function runAppliance(model, person, to, startMs, nowMs) {
3866
+ const appliance = ACTIONS[to.activity].appliance;
3867
+ if (!appliance || appliance.templates && !appliance.templates.includes(person.template)) return;
3868
+ const endMs = startMs + appliance.minutes * MINUTE_MS;
3869
+ if (endMs <= nowMs || !known.has(appliance.id)) return;
3870
+ issue(model, call("switch", "turn_on", [appliance.id]));
3871
+ runEnds.set(appliance.id, Math.max(runEnds.get(appliance.id) ?? 0, endMs));
3872
+ }
3873
+ function enter(model, person, to, atMs) {
3874
+ const room = gear(to.room);
3875
+ if (room) {
3876
+ issue(model, call("binary_sensor", "demo_set", room.motion, { on: true }));
3877
+ if (solarElevation(atMs, world.latitude, world.longitude) < LIGHTS_BELOW_ELEVATION) issue(model, call("light", "turn_on", PICK_LIGHTS[ACTIONS[to.activity].lights](room.lights)));
3878
+ }
3879
+ issue(model, call("person", "demo_set", [`person.${person.id}`], { home: to.activity !== "away" }));
3880
+ }
3881
+ function household(model, t, before) {
3882
+ const intoAway = t.to.activity === "away" && t.from.activity !== "away";
3883
+ const backFromAway = t.from.activity === "away" && t.to.activity !== "away";
3884
+ if (intoAway && nobodyHome(where.values())) issue(model, call("scene", "turn_on", [AWAY]));
3885
+ if (backFromAway && nobodyHome(before.values())) {
3886
+ issue(model, call("lock", "unlock", [FRONT_DOOR]));
3887
+ later(model, t.atMs + RELOCK_AFTER_MS, call("lock", "lock", [FRONT_DOOR]));
3888
+ }
3889
+ if (t.to.activity === "sleep" && t.from.activity !== "sleep" && nobodyAwake(where.values())) issue(model, call("scene", "turn_on", [GOOD_NIGHT]));
3890
+ if (t.to.activity === "wake" && t.from.activity === "sleep" && nobodyAwake(before.values()) && solarElevation(t.atMs, world.latitude, world.longitude) > 0) issue(model, call("cover", "open_cover", gear(t.from.room)?.covers ?? []));
3891
+ }
3892
+ function apply(model, t) {
3893
+ const before = new Map(where);
3894
+ where.set(t.person.id, t.to);
3895
+ model.clearHolds("boundary");
3896
+ if (touchesEvent(t)) model.clearHolds("event");
3897
+ const left = t.from.room;
3898
+ if (left && left !== t.to.room && !occupants(left, t.person.id)) issue(model, call("light", "turn_off", gear(left)?.lights ?? []));
3899
+ enter(model, t.person, t.to, t.atMs);
3900
+ runAppliance(model, t.person, t.to, t.atMs, t.atMs);
3901
+ household(model, t, before);
3902
+ }
3903
+ function track(person, fromMs) {
3904
+ const today = dayOf(person, fromMs, world);
3905
+ pending.set(person.id, transitionsOf(person, today, world).filter((t) => t.atMs > fromMs));
3906
+ nextDayAnchor.set(person.id, today.midnightMs + DAY_AHEAD_MS);
3907
+ }
3908
+ function upcoming(person) {
3909
+ const queue = pending.get(person.id) ?? [];
3910
+ pending.set(person.id, queue);
3911
+ while (queue.length === 0) {
3912
+ const next = dayOf(person, nextDayAnchor.get(person.id) ?? 0, world);
3913
+ queue.push(...transitionsOf(person, next, world));
3914
+ nextDayAnchor.set(person.id, next.midnightMs + DAY_AHEAD_MS);
3915
+ }
3916
+ return queue;
3917
+ }
3918
+ function dueBy(toMs) {
3919
+ const due = [];
3920
+ for (const [id, endMs] of runEnds) {
3921
+ if (endMs > toMs) continue;
3922
+ runEnds.delete(id);
3923
+ due.push({
3924
+ atMs: endMs,
3925
+ order: 0,
3926
+ key: id,
3927
+ run: (m) => issue(m, call("switch", "turn_off", [id]))
3928
+ });
3929
+ }
3930
+ for (const person of people) {
3931
+ const queue = upcoming(person);
3932
+ while ((queue[0]?.atMs ?? Infinity) <= toMs) {
3933
+ const t = queue.shift();
3934
+ if (t) due.push({
3935
+ atMs: t.atMs,
3936
+ order: 1,
3937
+ key: person.id,
3938
+ run: (m) => apply(m, t)
3939
+ });
3940
+ upcoming(person);
3941
+ }
3942
+ }
3943
+ return due.sort((a, b) => a.atMs - b.atMs || a.order - b.order || a.key.localeCompare(b.key));
3944
+ }
3945
+ return {
3946
+ settle(model) {
3947
+ const now = model.nowMs;
3948
+ const at = new Map(people.map((p) => [p.id, occupancyAt(p, now, world)]));
3949
+ for (const person of people) {
3950
+ const occ = at.get(person.id);
3951
+ if (occ) where.set(person.id, occ.slot);
3952
+ track(person, now);
3953
+ }
3954
+ for (const person of people) {
3955
+ const occ = at.get(person.id);
3956
+ if (!occ) continue;
3957
+ enter(model, person, occ.slot, now);
3958
+ runAppliance(model, person, occ.slot, occ.startMs, now);
3959
+ }
3960
+ if (nobodyHome(where.values())) issue(model, call("scene", "turn_on", [AWAY]));
3961
+ else if (nobodyAwake(where.values())) issue(model, call("scene", "turn_on", [GOOD_NIGHT]));
3962
+ },
3963
+ nextAtMs() {
3964
+ let next = Infinity;
3965
+ for (const endMs of runEnds.values()) next = Math.min(next, endMs);
3966
+ for (const person of people) next = Math.min(next, upcoming(person)[0]?.atMs ?? Infinity);
3967
+ return next;
3968
+ },
3969
+ step(model, _fromMs, toMs) {
3970
+ for (const d of dueBy(toMs)) d.run(model);
3971
+ }
3972
+ };
3973
+ }
3974
+ //#endregion
3975
+ //#region src/demo/demo-provider.ts
3976
+ const DEMO_HASS_URL = "https://demo.home-assistant.local";
3977
+ const TICK_MS = 1e3;
3978
+ const FINE_STEP_MS = 1e3;
3979
+ const COARSE_STEP_MS = 6e4;
3980
+ const FINE_GAP_MS = 12e4;
3981
+ const COARSE_GAP_MS = 216e5;
3982
+ const LONG_STEP_MS = 3e5;
3983
+ const MAX_SPEED = 3600;
3984
+ const VISITOR_HOLDS = {
3985
+ light: "boundary",
3986
+ switch: "boundary",
3987
+ fan: "boundary",
3988
+ climate: "event",
3989
+ lock: "event",
3990
+ cover: "event",
3991
+ water_heater: "event"
3992
+ };
3993
+ let session = null;
3994
+ let ticker = null;
3995
+ let contextCounter = 0;
3996
+ function isDemoMode() {
3997
+ return session !== null;
3998
+ }
3999
+ /** Internal: the running demo model, for history replay. */
4000
+ function demoModel() {
4001
+ return session?.model ?? null;
4002
+ }
4003
+ /** Internal: visitor calls applied this session, for persistence and history replay. */
4004
+ function demoCallLog() {
4005
+ return session?.log ?? [];
4006
+ }
4007
+ /** Internal: a fresh model for history replay, built exactly like the running one but starting at `startMs`. */
4008
+ function demoReplayModel(startMs) {
4009
+ const s = session;
4010
+ if (!s) return null;
4011
+ return buildModel(s.generated, startMs, getWorld());
4012
+ }
4013
+ /** Internal: apply the provider's visitor-hold mapping to a model, for history replay of logged calls. */
4014
+ function applyVisitorHolds(model, entityIds) {
4015
+ holdVisitorEntities(model, entityIds);
4016
+ }
4017
+ function catchUp(model, simMs) {
4018
+ const gap = simMs - model.nowMs;
4019
+ model.advanceTo(simMs, gap <= FINE_GAP_MS ? FINE_STEP_MS : gap <= COARSE_GAP_MS ? COARSE_STEP_MS : LONG_STEP_MS);
4020
+ }
4021
+ function advanceSession(s, simMs) {
4022
+ if (!Number.isFinite(simMs)) return;
4023
+ if (simMs - s.model.nowMs > COARSE_GAP_MS) {
4024
+ s.log = pruneLog(s.log, simMs);
4025
+ s.model = replayedModel(s.generated, getWorld(), s.log, simMs);
4026
+ return;
4027
+ }
4028
+ catchUp(s.model, simMs);
4029
+ }
4030
+ /** Builds from the oldest logged call, replays each with holds, catches up to `targetMs`. */
4031
+ function replayedModel(generated, world, log, targetMs) {
4032
+ const oldest = log[0];
4033
+ const model = buildModel(generated, oldest ? Math.min(oldest.simMs, targetMs) : targetMs, world);
4034
+ for (const entry of log) {
4035
+ catchUp(model, entry.simMs);
4036
+ model.dispatch(entry.call);
4037
+ holdVisitorEntities(model, entry.call.entityIds);
4038
+ }
4039
+ catchUp(model, targetMs);
4040
+ return model;
4041
+ }
4042
+ function holdVisitorEntities(model, entityIds) {
4043
+ for (const entityId of entityIds) {
4044
+ const until = VISITOR_HOLDS[extractDomain(entityId)];
4045
+ if (until) model.hold(entityId, until);
4046
+ }
4047
+ }
4048
+ function visitorTimeZone() {
4049
+ try {
4050
+ return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
4051
+ } catch {
4052
+ return "UTC";
4053
+ }
4054
+ }
4055
+ function demoStorage() {
4056
+ try {
4057
+ return globalThis.localStorage;
4058
+ } catch {
4059
+ return;
4060
+ }
4061
+ }
4062
+ const TIME_PATTERN = /^([01]?\d|2[0-3]):([0-5]\d)$/;
4063
+ function parseDemoOverrides(search, visitorZone, nowMs) {
4064
+ const params = new URLSearchParams(search);
4065
+ const out = {};
4066
+ const time = TIME_PATTERN.exec(params.get("demo-time") ?? "");
4067
+ if (time) {
4068
+ const minutes = Number(time[1]) * 60 + Number(time[2]);
4069
+ out.pinnedMs = localTime$1(nowMs, visitorZone).midnightMs + minutes * 6e4;
4070
+ }
4071
+ const seed = params.get("demo-seed");
4072
+ if (seed && /^\d+$/.test(seed)) out.seed = Number(seed);
4073
+ const speed = Number(params.get("demo-speed") ?? NaN);
4074
+ if (Number.isFinite(speed) && speed > 0) out.speed = Math.min(speed, MAX_SPEED);
4075
+ return out;
4076
+ }
4077
+ function resolveClock(opts) {
4078
+ const clock = opts.clock;
4079
+ if (clock !== "live") {
4080
+ const startMs = Date.parse(clock.pinned);
4081
+ if (Number.isNaN(startMs)) throw new Error(`invalid demo pinned time: ${clock.pinned}`);
4082
+ return {
4083
+ startMs,
4084
+ timeZone: clock.timeZone ?? "UTC",
4085
+ seed: clock.seed,
4086
+ live: false,
4087
+ speed: 0,
4088
+ persist: false
4089
+ };
4090
+ }
4091
+ const timeZone = visitorTimeZone();
4092
+ const nowMs = Date.now();
4093
+ const o = parseDemoOverrides(opts.search ?? globalThis.location?.search ?? "", timeZone, nowMs);
4094
+ const seed = o.seed ?? 1;
4095
+ const hasOverride = o.pinnedMs !== void 0 || o.seed !== void 0 || o.speed !== void 0;
4096
+ if (o.pinnedMs !== void 0 && o.speed === void 0) return {
4097
+ startMs: o.pinnedMs,
4098
+ timeZone,
4099
+ seed,
4100
+ live: false,
4101
+ speed: 0,
4102
+ persist: false
4103
+ };
4104
+ return {
4105
+ startMs: o.pinnedMs ?? nowMs,
4106
+ timeZone,
4107
+ seed,
4108
+ live: true,
4109
+ speed: o.speed ?? 1,
4110
+ persist: !hasOverride
4111
+ };
4112
+ }
4113
+ function nextContext() {
4114
+ contextCounter += 1;
4115
+ return {
4116
+ id: `demo-${contextCounter}`,
4117
+ parent_id: null,
4118
+ user_id: null
4119
+ };
4120
+ }
4121
+ function buildModel(generated, startMs, world) {
4122
+ return createDemoModel(generated.devices, KINDS, {
4123
+ startMs,
4124
+ world,
4125
+ driver: () => createDriver(HOUSE, generated, world)
4126
+ });
4127
+ }
4128
+ function attributesOf(generated, entityId, p) {
4129
+ return {
4130
+ friendly_name: generated.friendlyNames[entityId] ?? entityId,
4131
+ ...p.attributes
4132
+ };
4133
+ }
4134
+ function toEntities(generated, projections, iso) {
4135
+ const entities = {};
4136
+ for (const [entityId, p] of Object.entries(projections)) entities[entityId] = {
4137
+ entity_id: entityId,
4138
+ state: p.state,
4139
+ attributes: attributesOf(generated, entityId, p),
4140
+ last_changed: iso,
4141
+ last_updated: iso,
4142
+ context: nextContext()
4143
+ };
4144
+ return entities;
4145
+ }
4146
+ function sameValue(a, b) {
4147
+ if (a === b) return true;
4148
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
4149
+ return JSON.stringify(a) === JSON.stringify(b);
4150
+ }
4151
+ function sameProjection(a, b) {
4152
+ if (a.state !== b.state) return false;
4153
+ const keys = Object.keys(a.attributes);
4154
+ if (keys.length !== Object.keys(b.attributes).length) return false;
4155
+ return keys.every((k) => k in b.attributes && sameValue(a.attributes[k], b.attributes[k]));
4156
+ }
4157
+ function publish(s, force) {
4158
+ const wallMs = Date.now();
4159
+ const due = [];
4160
+ for (const [entityId, p] of Object.entries(s.model.project())) {
4161
+ const last = s.published.get(entityId);
4162
+ if (last && sameProjection(last.projection, p)) continue;
4163
+ if (s.live && last && wallMs - last.wallMs < (s.publishMs.get(entityId) ?? 0) && !force.has(entityId)) continue;
4164
+ due.push([entityId, p]);
4165
+ }
4166
+ if (due.length === 0) return;
4167
+ const simMs = s.model.nowMs;
4168
+ const iso = new Date(simMs).toISOString();
4169
+ const luSeconds = Math.round(timeMapOf(s).toWall(simMs) / 1e3);
4170
+ const points = [];
4171
+ setState(produce((st) => {
4172
+ for (const [entityId, p] of due) {
4173
+ const attributes = attributesOf(s.generated, entityId, p);
4174
+ const e = st.entities[entityId];
4175
+ const changed = e?.state !== p.state;
4176
+ if (!e) st.entities[entityId] = {
4177
+ entity_id: entityId,
4178
+ state: p.state,
4179
+ attributes,
4180
+ last_changed: iso,
4181
+ last_updated: iso,
4182
+ context: nextContext()
4183
+ };
4184
+ else {
4185
+ if (changed) e.last_changed = iso;
4186
+ e.state = p.state;
4187
+ for (const key of Object.keys(e.attributes)) if (!(key in attributes)) delete e.attributes[key];
4188
+ for (const [key, value] of Object.entries(attributes)) if (!sameValue(e.attributes[key], value)) e.attributes[key] = value;
4189
+ e.last_updated = iso;
4190
+ e.context = nextContext();
4191
+ }
4192
+ s.published.set(entityId, {
4193
+ projection: p,
4194
+ wallMs
4195
+ });
4196
+ points.push({
4197
+ entityId,
4198
+ stateValue: p.state,
4199
+ attributes: p.attributes,
4200
+ lastUpdated: luSeconds,
4201
+ ...changed ? { lastChanged: luSeconds } : {}
4202
+ });
4203
+ }
4204
+ }));
4205
+ bulkAppendHistoryPoints(points);
4206
+ }
4207
+ /** Internal: advance the running demo to `simMs` and publish what changed. */
4208
+ function advanceDemoTo(simMs) {
4209
+ const s = session;
4210
+ if (!s) return;
4211
+ advanceSession(s, simMs);
4212
+ publish(s, /* @__PURE__ */ new Set());
4213
+ }
4214
+ function liveSimMs(s) {
4215
+ return s.startMs + (Date.now() - s.wallStartMs) * s.speed;
4216
+ }
4217
+ /** Internal: maps wall-clock instants to the session's sim clock; a pinned session's wall now is its model's now. */
4218
+ function demoTimeMap() {
4219
+ return session ? timeMapOf(session) : null;
4220
+ }
4221
+ function timeMapOf(s) {
4222
+ const anchorWallMs = s.live ? s.wallStartMs : Date.now();
4223
+ const anchorSimMs = s.live ? s.startMs : s.model.nowMs;
4224
+ const speed = s.live ? s.speed : 1;
4225
+ return {
4226
+ toSim: (wallMs) => anchorSimMs + (wallMs - anchorWallMs) * speed,
4227
+ toWall: (simMs) => anchorWallMs + (simMs - anchorSimMs) / speed
4228
+ };
4229
+ }
4230
+ function tick() {
4231
+ const s = session;
4232
+ if (s?.live) advanceDemoTo(liveSimMs(s));
4233
+ }
4234
+ async function loadDemoHouse(opts) {
4235
+ const clock = resolveClock(opts);
4236
+ stopDemoEnergyTicker();
4237
+ const world = worldFor(clock.timeZone, clock.seed, clock.startMs);
4238
+ setWorld(world);
4239
+ const generated = generateHouse(HOUSE, new Date(clock.startMs).toISOString());
4240
+ contextCounter = 0;
4241
+ const log = clock.persist ? readLog(demoStorage(), HOUSE.version, clock.startMs) : [];
4242
+ const model = replayedModel(generated, world, log, clock.startMs);
4243
+ const publishMs = /* @__PURE__ */ new Map();
4244
+ for (const entityId of generated.entityIds) {
4245
+ const kind = model.deviceOf(entityId)?.kind;
4246
+ publishMs.set(entityId, kind ? KINDS[kind].publishMs ?? 0 : 0);
4247
+ }
4248
+ const projections = model.project();
4249
+ const wallMs = Date.now();
4250
+ const published = /* @__PURE__ */ new Map();
4251
+ for (const [entityId, projection] of Object.entries(projections)) published.set(entityId, {
4252
+ projection,
4253
+ wallMs
4254
+ });
4255
+ const { registry } = generated;
4256
+ setState("entities", reconcile(toEntities(generated, projections, new Date(clock.startMs).toISOString())));
4257
+ setState("entityRegistry", reconcile(registry.entityRegistry));
4258
+ setState("areas", reconcile(registry.areas));
4259
+ setState("floors", reconcile(registry.floors));
4260
+ setState("devices", reconcile(registry.devices));
4261
+ setState("hassUrl", DEMO_HASS_URL);
4262
+ setState("connectionState", "connected");
4263
+ session = {
4264
+ generated,
4265
+ model,
4266
+ live: clock.live,
4267
+ startMs: clock.startMs,
4268
+ wallStartMs: wallMs,
4269
+ speed: clock.speed,
4270
+ published,
4271
+ publishMs,
4272
+ log: [...log],
4273
+ persist: clock.persist
4274
+ };
4275
+ if (clock.live) startTicker();
4276
+ }
4277
+ function unloadDemoData() {
4278
+ stopDemoEnergyTicker();
4279
+ session = null;
4280
+ setWorld(worldFor("UTC", 1, 0));
4281
+ setState("entities", reconcile({}));
4282
+ setState("entityRegistry", reconcile({}));
4283
+ setState("areas", reconcile({}));
4284
+ setState("floors", reconcile({}));
4285
+ setState("devices", reconcile({}));
4286
+ setState("hassUrl", null);
4287
+ setState("connectionState", "disconnected");
4288
+ }
4289
+ async function resetDemo() {
4290
+ clearLog(demoStorage(), HOUSE.version);
4291
+ unloadDemoData();
4292
+ await loadDemoHouse({ clock: "live" });
4293
+ }
4294
+ /** @deprecated since 0.10.0, use loadDemoHouse({ clock: "live" }) */
4295
+ async function loadDemoData() {
4296
+ await loadDemoHouse({ clock: "live" });
4297
+ }
4298
+ function startTicker() {
4299
+ if (ticker !== null || typeof setInterval !== "function") return;
4300
+ ticker = setInterval(tick, TICK_MS);
4301
+ }
4302
+ /** @deprecated since 0.10.0, the demo ticker starts with loadDemoHouse */
4303
+ function startDemoEnergyTicker() {
4304
+ if (session?.live) startTicker();
4305
+ }
4306
+ /** @deprecated since 0.10.0, the demo ticker stops with unloadDemoData */
4307
+ function stopDemoEnergyTicker() {
4308
+ if (ticker !== null) {
4309
+ clearInterval(ticker);
4310
+ ticker = null;
4311
+ }
4312
+ }
4313
+ function targetIds(target) {
4314
+ if (Array.isArray(target.entity_id)) return target.entity_id;
4315
+ return target.entity_id ? [target.entity_id] : [];
4316
+ }
4317
+ /** @deprecated since 0.10.0, service calls reach the demo through callService */
4318
+ function applyDemoServiceCall(domain, service, serviceData = {}, target = {}) {
4319
+ const s = session;
4320
+ if (!s) return;
4321
+ const entityIds = targetIds(target);
4322
+ if (entityIds.length === 0) return;
4323
+ if (s.live) advanceSession(s, liveSimMs(s));
4324
+ const call = {
4325
+ domain,
4326
+ service,
4327
+ data: serviceData,
4328
+ entityIds
4329
+ };
4330
+ if (s.model.dispatch(call) === 0) return;
4331
+ holdVisitorEntities(s.model, entityIds);
4332
+ const entry = {
4333
+ simMs: s.model.nowMs,
4334
+ call
4335
+ };
4336
+ s.log.push(entry);
4337
+ if (s.log.length > 500) s.log.splice(0, s.log.length - 500);
4338
+ if (s.persist) appendLog(demoStorage(), HOUSE.version, entry, Date.now());
4339
+ publish(s, new Set(entityIds));
4340
+ }
4341
+ /** @deprecated since 0.10.0, use loadDemoHouse */
4342
+ function createDemoFixtures() {
4343
+ const nowMs = Date.now();
4344
+ const iso = new Date(nowMs).toISOString();
4345
+ const generated = generateHouse(HOUSE, iso);
4346
+ const world = worldFor("UTC", 1, nowMs);
4347
+ const savedWorld = getWorld();
4348
+ const savedCounter = contextCounter;
4349
+ setWorld(world);
4350
+ contextCounter = 0;
4351
+ try {
4352
+ const entities = toEntities(generated, buildModel(generated, nowMs, world).project(), iso);
4353
+ const { entityRegistry, areas, devices } = generated.registry;
4354
+ return {
4355
+ entities,
4356
+ entityRegistry,
4357
+ areas,
4358
+ devices
4359
+ };
4360
+ } finally {
4361
+ setWorld(savedWorld);
4362
+ contextCounter = savedCounter;
4363
+ }
4364
+ }
4365
+ //#endregion
4366
+ //#region src/commands/service.ts
4367
+ /**
4368
+ * Call a Home Assistant service
4369
+ */
4370
+ async function callService(domain, service, serviceData = {}, target = {}) {
4371
+ const connection = privilegedConn();
4372
+ if (!connection) {
4373
+ applyDemoServiceCall(domain, service, serviceData, target);
4374
+ return;
4375
+ }
4376
+ try {
4377
+ await connection.sendMessagePromise({
4378
+ type: "call_service",
4379
+ domain,
4380
+ service,
4381
+ service_data: serviceData,
4382
+ target
4383
+ });
4384
+ } catch (error) {
4385
+ throw new Error(`Service call failed: ${messageOf(error)}`);
4386
+ }
4387
+ }
4388
+ function messageOf(error) {
4389
+ if (typeof error === "object" && error !== null && "message" in error) {
4390
+ const { message } = error;
4391
+ if (typeof message === "string" && message) return message;
4392
+ }
4393
+ return String(error);
4394
+ }
4395
+ /**
4396
+ * Turn on entity or entities
4397
+ */
4398
+ async function turnOn(entityId, serviceData = {}) {
4399
+ const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
4400
+ if (!firstEntityId) throw new Error("Entity ID is required");
4401
+ const domain = firstEntityId.split(".")[0];
4402
+ if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
4403
+ await callService(domain, "turn_on", serviceData, { entity_id: entityId });
4404
+ }
4405
+ /**
4406
+ * Turn off entity or entities
4407
+ */
4408
+ async function turnOff(entityId, serviceData = {}) {
4409
+ const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
4410
+ if (!firstEntityId) throw new Error("Entity ID is required");
4411
+ const domain = firstEntityId.split(".")[0];
4412
+ if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
4413
+ await callService(domain, "turn_off", serviceData, { entity_id: entityId });
4414
+ }
4415
+ /**
4416
+ * Toggle entity or entities
4417
+ */
4418
+ async function toggle(entityId, serviceData = {}) {
4419
+ const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
4420
+ if (!firstEntityId) throw new Error("Entity ID is required");
4421
+ const domain = firstEntityId.split(".")[0];
4422
+ if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
4423
+ await callService(domain, "toggle", serviceData, { entity_id: entityId });
4424
+ }
4425
+ /**
4426
+ * Update entity registry entry
4427
+ */
4428
+ async function updateEntity(entityId, updates) {
4429
+ const connection = privilegedConn();
4430
+ if (!connection) throw new Error("Not connected to Home Assistant.");
4431
+ try {
4432
+ const result = await connection.sendMessagePromise({
4433
+ type: "config/entity_registry/update",
4434
+ entity_id: entityId,
4435
+ ...updates
4436
+ });
4437
+ if (result && typeof result === "object" && "_mockError" in result) throw new Error(`Entity registry entry not found: ${entityId}`);
4438
+ return result;
4439
+ } catch (error) {
4440
+ const message = messageOf(error);
4441
+ if (message.includes("Entity registry entry not found")) throw error;
4442
+ throw new Error(`Entity update failed: ${message}`);
4443
+ }
4444
+ }
4445
+ /**
4446
+ * Send a generic WebSocket command
4447
+ */
4448
+ async function sendCommand(command) {
4449
+ const connection = privilegedConn();
4450
+ if (!connection) throw new Error("Not connected");
4451
+ try {
4452
+ return await connection.sendMessagePromise(command);
4453
+ } catch (error) {
4454
+ throw new Error(`Command failed: ${messageOf(error)}`);
4455
+ }
4456
+ }
4457
+ /**
4458
+ * Execute multiple service calls in parallel
4459
+ */
4460
+ async function batchServiceCalls(calls) {
4461
+ await Promise.all(calls.map((call) => callService(call.domain, call.service, call.serviceData, call.target)));
4462
+ }
4463
+ /**
4464
+ * Execute multiple entity updates in parallel
4465
+ */
4466
+ async function batchEntityUpdates(updates) {
4467
+ return Promise.all(updates.map((u) => updateEntity(u.entityId, u.updates)));
4468
+ }
4469
+ //#endregion
4470
+ //#region src/demo/sensor-history.ts
4471
+ const DAY_MS$1 = 864e5;
4472
+ function seedOf(entityId) {
4473
+ let h = 0;
4474
+ for (let i = 0; i < entityId.length; i++) h = h * 31 + entityId.charCodeAt(i) | 0;
4475
+ return Math.abs(h) % 1e3 / 1e3;
4476
+ }
4477
+ /**
4478
+ * A slow daily swing plus a faster ripple, pinned so the last point is the reading
4479
+ * the demo shows now. Deterministic per entity, so previews repeat.
4480
+ */
4481
+ function synthesizeSensorHistory(entityId, current, startMs, endMs, stepMs = 9e5) {
4482
+ const amplitude = Math.max(Math.abs(current) * .08, .6);
4483
+ const phase = seedOf(entityId) * 2 * Math.PI;
4484
+ 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);
4485
+ const decimals = Number.isInteger(current) ? 1 : 2;
4486
+ const points = [];
4487
+ for (let t = startMs; t <= endMs; t += stepMs) {
4488
+ const value = current + amplitude * (wave(t) - wave(endMs));
4489
+ points.push({
4490
+ lu: Math.round(t / 1e3),
4491
+ s: value.toFixed(decimals)
4492
+ });
4493
+ }
4494
+ const last = points.at(-1);
4495
+ if (last) last.s = String(current);
4496
+ return points;
4497
+ }
4498
+ function currentReading(id) {
4499
+ if (!id.startsWith("sensor.")) return void 0;
4500
+ const current = Number(state.entities[id]?.state);
4501
+ return Number.isFinite(current) ? current : void 0;
4502
+ }
4503
+ /** History for a demo sensor with a numeric reading; undefined for anything else. */
4504
+ function numericSensorHistory(id, startMs, endMs) {
4505
+ const current = currentReading(id);
4506
+ return current === void 0 ? void 0 : synthesizeSensorHistory(id, current, startMs, endMs);
4507
+ }
4508
+ /** Hour or day buckets of the same curve, shaped like recorder statistics. */
4509
+ function numericSensorStatistics(id, startMs, endMs, period) {
4510
+ const points = numericSensorHistory(id, startMs, endMs);
4511
+ if (!points) return [];
4512
+ const span = period === "hour" ? 36e5 : DAY_MS$1;
4513
+ const buckets = [];
4514
+ for (let start = startMs; start < endMs; start += span) {
4515
+ const end = start + span;
4516
+ const values = points.filter((p) => p.lu * 1e3 >= start && p.lu * 1e3 < end).map((p) => Number(p.s));
4517
+ if (values.length === 0) continue;
4518
+ const mean = values.reduce((a, b) => a + b, 0) / values.length;
4519
+ buckets.push({
4520
+ start,
4521
+ end,
4522
+ mean,
4523
+ min: Math.min(...values),
4524
+ max: Math.max(...values),
4525
+ sum: 0,
4526
+ change: 0
4527
+ });
4528
+ }
4529
+ return buckets;
4530
+ }
4531
+ //#endregion
4532
+ //#region src/history/statistics.ts
4533
+ const STATISTIC_TYPES = [
4534
+ "mean",
4535
+ "sum",
4536
+ "min",
4537
+ "max",
4538
+ "state",
4539
+ "change"
4540
+ ];
4541
+ /**
4542
+ * HA returns start/end as ISO strings or ms numbers depending on version.
4543
+ * Normalize both shapes to ms epoch numbers; unparseable values become NaN.
4544
+ */
4545
+ function normalizeStatisticTime(value) {
4546
+ if (typeof value === "number") return value;
4547
+ if (typeof value === "string") return new Date(value).getTime();
4548
+ return NaN;
4549
+ }
4550
+ function normalizeNumber(value) {
4551
+ return typeof value === "number" ? value : void 0;
4552
+ }
4553
+ function normalizeStatisticValue(raw) {
4554
+ const value = {
4555
+ start: normalizeStatisticTime(raw.start),
4556
+ end: normalizeStatisticTime(raw.end)
4557
+ };
4558
+ const mean = normalizeNumber(raw.mean);
4559
+ if (mean !== void 0) value.mean = mean;
4560
+ const sum = normalizeNumber(raw.sum);
4561
+ if (sum !== void 0) value.sum = sum;
4562
+ const min = normalizeNumber(raw.min);
4563
+ if (min !== void 0) value.min = min;
4564
+ const max = normalizeNumber(raw.max);
4565
+ if (max !== void 0) value.max = max;
4566
+ const stateVal = normalizeNumber(raw.state);
4567
+ if (stateVal !== void 0) value.state = stateVal;
4568
+ const change = normalizeNumber(raw.change);
4569
+ if (change !== void 0) value.change = change;
4570
+ return value;
4571
+ }
4572
+ /**
4573
+ * Fetch long-term statistics for one or more statistic ids during a period.
4574
+ *
4575
+ * Statistic ids with no data are returned as empty arrays so callers never
4576
+ * hit `undefined`.
4577
+ */
4578
+ async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
4579
+ const { startTime, endTime, period } = options;
4580
+ if (isDemoMode()) {
4581
+ const result = {};
4582
+ const statPeriod = period === "hour" || period === "day" ? period : "day";
4583
+ const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
4584
+ for (const id of statisticIds) result[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : numericSensorStatistics(id, startTime.getTime(), endMs, statPeriod);
4585
+ return result;
4586
+ }
4587
+ const params = {
4588
+ type: "recorder/statistics_during_period",
4589
+ start_time: startTime.toISOString(),
4590
+ statistic_ids: statisticIds,
4591
+ period,
4592
+ types: STATISTIC_TYPES
4593
+ };
4594
+ if (endTime) params.end_time = endTime.toISOString();
4595
+ const response = await connection.sendMessagePromise(params);
4596
+ const result = {};
4597
+ for (const id of statisticIds) result[id] = [];
4598
+ if (response && typeof response === "object") for (const [id, buckets] of Object.entries(response)) result[id] = Array.isArray(buckets) ? buckets.map(normalizeStatisticValue) : [];
4599
+ return result;
4600
+ }
4601
+ //#endregion
4602
+ //#region src/weather/fetch.ts
4603
+ /**
4604
+ * Weather Forecast Fetching
4605
+ *
4606
+ * @packageDocumentation
4607
+ */
4608
+ const DEMO_RAIN_CHANCE = {
4609
+ sunny: 0,
4610
+ "clear-night": 0,
4611
+ partlycloudy: 10,
4612
+ cloudy: 20,
4613
+ windy: 10,
4614
+ fog: 10,
4615
+ rainy: 80,
4616
+ pouring: 95,
4617
+ lightning: 60,
4618
+ "lightning-rainy": 85,
4619
+ snowy: 70,
4620
+ "snowy-rainy": 75,
4621
+ hail: 70,
4622
+ exceptional: 40
4623
+ };
4624
+ /**
4625
+ * In demo mode, derive forecasts from the entity's `forecast` attribute.
4626
+ * Hourly and daily are synthesised from the same source (daily entries
4627
+ * expanded into 24 hourly steps).
4628
+ */
4629
+ function buildDemoForecast(entityId, type) {
4630
+ const entity = state.entities[entityId];
4631
+ const raw = entity?.attributes?.forecast ?? [];
4632
+ if (raw.length === 0) return [];
4633
+ if (type === "daily" || type === "twice_daily") return raw.map((d) => {
4634
+ const chance = DEMO_RAIN_CHANCE[d.condition ?? ""] ?? 10;
4635
+ const templow = d.templow;
4636
+ return {
4637
+ ...d,
4638
+ temp_high: d.temp_high ?? d.temperature,
4639
+ temp_low: d.temp_low ?? templow,
4640
+ precipitation_probability: d.precipitation_probability ?? chance
4641
+ };
4642
+ });
4643
+ const model = demoModel();
4644
+ if (model?.deviceOf(entityId)?.kind === "weather") return weatherForecastHourly(model.nowMs, getWorld()).map((h) => ({
4645
+ ...h,
4646
+ precipitation_probability: DEMO_RAIN_CHANCE[String(h.condition)] ?? 10
4647
+ }));
4648
+ const start = /* @__PURE__ */ new Date();
4649
+ start.setMinutes(0, 0, 0);
4650
+ const today = raw[0];
4651
+ const tomorrow = raw[1] ?? today;
4652
+ const tHi = today?.temperature ?? 18;
4653
+ const tLo = today?.templow ?? tHi - 5;
4654
+ const tNext = tomorrow?.temperature ?? tHi;
4655
+ const out = [];
4656
+ for (let i = 0; i < 24; i++) {
4657
+ const dt = new Date(start.getTime() + i * 60 * 60 * 1e3);
4658
+ const hour = dt.getHours();
4659
+ const dayMix = i < 12 ? 0 : (i - 12) / 12;
4660
+ const baseHi = tHi * (1 - dayMix) + tNext * dayMix;
4661
+ const phase = (hour - 6) / 24 * Math.PI * 2;
4662
+ const swing = (baseHi - tLo) / 2;
4663
+ const mid = (baseHi + tLo) / 2;
4664
+ const source = i < 8 ? today : tomorrow;
4665
+ const night = hour < 6 || hour >= 21;
4666
+ const condition = source?.condition ?? entity?.state ?? "cloudy";
4667
+ out.push({
4668
+ datetime: dt.toISOString(),
4669
+ temperature: Math.round((mid + Math.sin(phase) * swing) * 10) / 10,
4670
+ condition: night && condition === "sunny" ? "clear-night" : condition,
4671
+ precipitation_probability: DEMO_RAIN_CHANCE[condition] ?? 10
4672
+ });
4673
+ }
4674
+ return out;
4675
+ }
4676
+ /**
4677
+ * Fetch forecast for a single entity and type
4678
+ */
4679
+ async function fetchForecast(entityId, type) {
4680
+ if (isDemoMode()) return buildDemoForecast(entityId, type);
4681
+ const conn = privilegedConn();
4682
+ if (!conn) throw new Error("Not connected");
4683
+ return new Promise((resolve, reject) => {
4684
+ let resolved = false;
4685
+ let unsubscribe = null;
4686
+ const timeout = setTimeout(() => {
4687
+ if (!resolved && unsubscribe) {
4688
+ resolved = true;
4689
+ unsubscribe().then(() => reject(/* @__PURE__ */ new Error("Forecast subscription timeout"))).catch(() => reject(/* @__PURE__ */ new Error("Forecast subscription timeout")));
4690
+ }
4691
+ }, 1e4);
4692
+ conn.subscribeMessage((event) => {
4693
+ if (resolved) return;
4694
+ if (event && typeof event === "object" && "forecast" in event) {
4695
+ resolved = true;
4696
+ clearTimeout(timeout);
4697
+ const forecast = event.forecast;
4698
+ const forecastArray = Array.isArray(forecast) ? forecast : [];
4699
+ if (unsubscribe) unsubscribe().then(() => resolve(forecastArray)).catch(() => resolve(forecastArray));
4700
+ else resolve(forecastArray);
4701
+ }
4702
+ }, {
4703
+ type: "weather/subscribe_forecast",
4704
+ entity_id: entityId,
4705
+ forecast_type: type === "twice_daily" ? "twice_daily" : type
4706
+ }).then((unsubFn) => {
4707
+ unsubscribe = unsubFn;
4708
+ }).catch((error) => {
4709
+ resolved = true;
4710
+ clearTimeout(timeout);
4711
+ reject(error);
4712
+ });
4713
+ });
4714
+ }
4715
+ /**
4716
+ * Fetch forecasts for a single entity and multiple types
4717
+ */
4718
+ async function fetchForecasts(entityId, types) {
4719
+ const forecasts = {};
4720
+ const loading = {};
4721
+ const errors = {};
4722
+ const lastFetched = {};
4723
+ const fetchPromises = types.map(async (type) => {
4724
+ loading[type] = true;
4725
+ errors[type] = null;
4726
+ try {
4727
+ const forecast = await fetchForecast(entityId, type);
4728
+ forecasts[type] = forecast;
4729
+ lastFetched[type] = Date.now();
4730
+ loading[type] = false;
4731
+ } catch (error) {
4732
+ errors[type] = error instanceof Error ? error : new Error(String(error));
4733
+ forecasts[type] = [];
4734
+ loading[type] = false;
4735
+ lastFetched[type] = null;
4736
+ }
4737
+ });
4738
+ await Promise.all(fetchPromises);
4739
+ return {
4740
+ entityId,
4741
+ forecasts,
4742
+ loading,
4743
+ errors,
4744
+ lastFetched
4745
+ };
4746
+ }
4747
+ /**
4748
+ * Fetch forecast data with error handling
4749
+ */
4750
+ async function fetchForecastData(entityId, type) {
4751
+ try {
4752
+ return {
4753
+ entityId,
4754
+ type,
4755
+ forecast: await fetchForecast(entityId, type),
4756
+ loading: false,
4757
+ error: null,
4758
+ lastFetched: Date.now()
4759
+ };
4760
+ } catch (error) {
4761
+ return {
4762
+ entityId,
4763
+ type,
4764
+ forecast: [],
4765
+ loading: false,
4766
+ error: error instanceof Error ? error : new Error(String(error)),
4767
+ lastFetched: null
4768
+ };
4769
+ }
4770
+ }
4771
+ //#endregion
4772
+ //#region src/calendar/track.ts
4773
+ /**
4774
+ * Ref-counted live calendar tracking, the per-entity analog of
4775
+ * `connection/subscription-manager`. Each tracked calendar entity holds one
4776
+ * upstream `calendar/event/subscribe` (HA pushes an initial snapshot, then
4777
+ * debounced updates); trackers declare desired windows and a reconciler keeps
4778
+ * the subscription matched to the union. Data lands in `state.calendars`;
4779
+ * read-scoping for widget consumers arrives with WS-8 like every other store
4780
+ * read.
4781
+ */
4782
+ const DEFAULT_DAYS_BEHIND = 7;
4783
+ const DEFAULT_DAYS_AHEAD = 35;
4784
+ /** Debounce for untrack, so dashboard switches don't churn subscriptions. */
4785
+ const UNTRACK_FLUSH_DELAY_MS = 5e3;
4786
+ const DAY_MS = 864e5;
4787
+ const trackers = /* @__PURE__ */ new Map();
4788
+ const active = /* @__PURE__ */ new Map();
4789
+ /** Per-entity reconcile chain; serializes subscribe/unsubscribe per calendar. */
4790
+ const chains = /* @__PURE__ */ new Map();
4791
+ let untrackTimer = null;
4792
+ let rollTimer = null;
4793
+ /** Day-quantized union of all tracker windows, so equal requests within a day
4794
+ reconcile to the same subscription instead of churning. */
4795
+ function windowFor(list) {
4796
+ let behind = 0;
4797
+ let ahead = 0;
4798
+ for (const t of list) {
4799
+ if (t.daysBehind > behind) behind = t.daysBehind;
4800
+ if (t.daysAhead > ahead) ahead = t.daysAhead;
4801
+ }
4802
+ const dayStart = /* @__PURE__ */ new Date();
4803
+ dayStart.setHours(0, 0, 0, 0);
4804
+ return {
4805
+ start: (/* @__PURE__ */ new Date(dayStart.getTime() - behind * DAY_MS)).toISOString(),
4806
+ end: new Date(dayStart.getTime() + (ahead + 1) * DAY_MS).toISOString()
4807
+ };
4808
+ }
4809
+ /**
4810
+ * Start tracking live events for a calendar entity. Returns the untrack
4811
+ * function. Safe while disconnected: the subscription activates on the next
4812
+ * `forceResubscribeCalendars()` (called by the connection layer on connect
4813
+ * and reconnect).
4814
+ */
4815
+ function trackCalendarEvents(entityId, options) {
4816
+ const tracker = {
4817
+ daysBehind: options?.daysBehind ?? DEFAULT_DAYS_BEHIND,
4818
+ daysAhead: options?.daysAhead ?? DEFAULT_DAYS_AHEAD
4819
+ };
4820
+ const list = trackers.get(entityId) ?? [];
4821
+ list.push(tracker);
4822
+ trackers.set(entityId, list);
4823
+ scheduleFlush();
4824
+ ensureRollTimer();
4825
+ let untracked = false;
4826
+ return () => {
4827
+ if (untracked) return;
4828
+ untracked = true;
4829
+ untrackCalendarEvents(entityId, tracker);
4830
+ };
4831
+ }
4832
+ function untrackCalendarEvents(entityId, tracker) {
4833
+ const list = trackers.get(entityId);
4834
+ if (!list) return;
4835
+ const i = list.indexOf(tracker);
4836
+ if (i !== -1) list.splice(i, 1);
4837
+ if (list.length === 0) trackers.delete(entityId);
4838
+ if (untrackTimer) return;
4839
+ untrackTimer = setTimeout(() => {
4840
+ untrackTimer = null;
4841
+ flushAll();
4842
+ }, UNTRACK_FLUSH_DELAY_MS);
4843
+ }
4844
+ /**
4845
+ * Reconcile every tracked calendar, dropping current subscription handles
4846
+ * first. The connection layer calls this on connect and after reconnect:
4847
+ * an unchanged window is no proof the old subscription is alive, so each
4848
+ * tracked entity resubscribes (HA re-pushes the snapshot, refreshing the
4849
+ * store through any missed changes).
4850
+ */
4851
+ async function forceResubscribeCalendars() {
4852
+ for (const entry of active.values()) {
4853
+ const dead = entry.unsub;
4854
+ entry.unsub = null;
4855
+ if (dead) Promise.resolve().then(dead).catch(() => {});
4856
+ }
4857
+ await flushAll();
4858
+ }
4859
+ function scheduleFlush() {
4860
+ queueMicrotask(() => {
4861
+ flushAll();
4862
+ });
4863
+ }
4864
+ async function flushAll() {
4865
+ const ids = /* @__PURE__ */ new Set([...trackers.keys(), ...active.keys()]);
4866
+ await Promise.all([...ids].map(reconcileEntity));
4867
+ if (trackers.size === 0) stopRollTimer();
4868
+ }
4869
+ function reconcileEntity(entityId) {
4870
+ const next = (chains.get(entityId) ?? Promise.resolve()).then(() => runReconcile(entityId));
4871
+ chains.set(entityId, next.catch(() => {}));
4872
+ return next;
4873
+ }
4874
+ async function runReconcile(entityId) {
4875
+ const list = trackers.get(entityId);
4876
+ const current = active.get(entityId);
4877
+ if (!list || list.length === 0) {
4878
+ if (current) {
4879
+ active.delete(entityId);
4880
+ try {
4881
+ await current.unsub?.();
4882
+ } catch {}
4883
+ }
4884
+ chains.delete(entityId);
4885
+ setState(produce((s) => {
4886
+ delete s.calendars[entityId];
4887
+ }));
4888
+ return;
4889
+ }
4890
+ const conn = privilegedConn();
4891
+ if (!conn) return;
4892
+ const win = windowFor(list);
4893
+ if (current?.unsub && current.start === win.start && current.end === win.end) return;
4894
+ setState(produce((s) => {
4895
+ s.calendars[entityId] = {
4896
+ entityId,
4897
+ events: s.calendars[entityId]?.events ?? [],
4898
+ loading: true,
4899
+ error: null,
4900
+ start: win.start,
4901
+ end: win.end
4902
+ };
4903
+ }));
4904
+ const old = current?.unsub ?? null;
4905
+ const entry = {
4906
+ start: win.start,
4907
+ end: win.end,
4908
+ unsub: null
4909
+ };
4910
+ active.set(entityId, entry);
4911
+ try {
4912
+ entry.unsub = await conn.subscribeMessage((msg) => {
4913
+ setState(produce((s) => {
4914
+ const rec = s.calendars[entityId];
4915
+ if (!rec) return;
4916
+ rec.events = msg.events ?? [];
4917
+ rec.loading = false;
4918
+ rec.error = null;
4919
+ }));
4920
+ }, {
4921
+ type: "calendar/event/subscribe",
4922
+ entity_id: entityId,
4923
+ start: win.start,
4924
+ end: win.end
4925
+ });
4926
+ } catch (err) {
4927
+ active.delete(entityId);
4928
+ setState(produce((s) => {
4929
+ const rec = s.calendars[entityId];
4930
+ if (!rec) return;
4931
+ rec.loading = false;
4932
+ rec.error = err instanceof Error ? err : new Error(String(err));
4933
+ }));
4934
+ }
4935
+ if (old) try {
4936
+ await old();
4937
+ } catch {}
4938
+ }
4939
+ /** One timer for the whole module: at local midnight the day-quantized
4940
+ windows shift, so a reconcile slides every subscription forward. Only
4941
+ armed while something is tracked — an idle app holds no timer. */
4942
+ function ensureRollTimer() {
4943
+ if (rollTimer || trackers.size === 0) return;
4944
+ const now = /* @__PURE__ */ new Date();
4945
+ const next = new Date(now);
4946
+ next.setHours(24, 0, 30, 0);
4947
+ rollTimer = setTimeout(() => {
4948
+ rollTimer = null;
4949
+ flushAll().then(ensureRollTimer);
4950
+ }, next.getTime() - now.getTime());
4951
+ }
4952
+ function stopRollTimer() {
4953
+ if (rollTimer) {
4954
+ clearTimeout(rollTimer);
4955
+ rollTimer = null;
4956
+ }
4957
+ }
4958
+ //#endregion
4959
+ export { setPrivilegedConn as $, unloadDemoData as A, getAreaView as B, demoTimeMap as C, resetDemo as D, loadDemoHouse as E, MAX_HISTORY_POINTS as F, getEntityView as G, buildEntityView as H, appendHistoryPoint as I, forceResubscribe as J, getEntityViews as K, bulkAppendHistoryPoints as L, demoEnergyPreferences as M, isEnergyEntity as N, startDemoEnergyTicker as O, synthesizeEnergyHistory as P, privilegedConn as Q, buildAreaView as R, demoReplayModel as S, loadDemoData as T, entityViewEquals as U, getAreaViews as V, getAllEntityViews as W, setManagerConnection as X, registerEntity as Y, setResubscribeHandler as Z, applyDemoServiceCall as _, fetchForecastData as a, demoCallLog as b, normalizeStatisticTime as c, callService as d, setState as et, sendCommand as f, updateEntity as g, turnOn as h, fetchForecast as i, demoAssetUrl as j, stopDemoEnergyTicker as k, batchEntityUpdates as l, turnOff as m, trackCalendarEvents as n, fetchForecasts as o, toggle as p, extractDomain as q, buildDemoForecast as r, fetchStatisticsDuringPeriod as s, forceResubscribeCalendars as t, state as tt, batchServiceCalls as u, applyVisitorHolds as v, isDemoMode as w, demoModel as x, createDemoFixtures as y, buildDeviceView as z };