@glasshome/sync-layer 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,144 +1,8 @@
1
- import { runHaBridgeWorker } from "./worker-main-Cf-k4f2N.js";
2
- import { createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
3
- import { createStore, produce, reconcile } from "solid-js/store";
1
+ import { applyDemoServiceCall, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, callService, createDemoFixtures, demoEnergyPreferences, entityViewEquals, extractDomain, fetchStatisticsDuringPeriod, forceResubscribe, getAllEntityViews, getAreaView, getAreaViews, getEntityView, getEntityViews, isDemoMode, isEnergyEntity, loadDemoData, normalizeStatisticTime, privilegedConn, registerEntity, sendCommand, setManagerConnection, setPrivilegedConn, setResubscribeHandler, setState, startDemoEnergyTicker, state, stopDemoEnergyTicker, synthesizeEnergyHistory, toggle, turnOff, turnOn, unloadDemoData, updateEntity } from "./statistics-C6JQBDVf.js";
2
+ import { runHaBridgeWorker } from "./worker-main-DTbzEKor.js";
3
+ import { createConnection, getAuth } from "home-assistant-js-websocket";
4
+ import { produce } from "solid-js/store";
4
5
 
5
- //#region src/connection/adapter.ts
6
- /**
7
- * Set of debug listeners for outgoing messages.
8
- * Supports multiple concurrent observers (e.g., WebSocket event recorder + network monitor).
9
- */
10
- const debugOutgoingMessageListeners = /* @__PURE__ */ new Set();
11
- /**
12
- * Set of debug listeners for incoming messages.
13
- * Captures subscription callbacks and sendMessagePromise responses.
14
- */
15
- const debugIncomingMessageListeners = /* @__PURE__ */ new Set();
16
- /**
17
- * Register a listener for all outgoing WebSocket messages.
18
- * Returns an unsubscribe function.
19
- *
20
- * @param listener - Function called with each outgoing message object
21
- * @returns Unsubscribe function to remove the listener
22
- */
23
- function addDebugOutgoingMessageListener(listener) {
24
- debugOutgoingMessageListeners.add(listener);
25
- return () => {
26
- debugOutgoingMessageListeners.delete(listener);
27
- };
28
- }
29
- /**
30
- * Register a listener for all incoming WebSocket messages.
31
- * Returns an unsubscribe function. Receives subscription updates,
32
- * event callbacks, and sendMessagePromise responses.
33
- *
34
- * @param listener - Function called with each incoming message object
35
- * @returns Unsubscribe function to remove the listener
36
- */
37
- function addDebugIncomingMessageListener(listener) {
38
- debugIncomingMessageListeners.add(listener);
39
- return () => {
40
- debugIncomingMessageListeners.delete(listener);
41
- };
42
- }
43
- function notifyOutgoingListeners(message) {
44
- if (debugOutgoingMessageListeners.size > 0) for (const listener of debugOutgoingMessageListeners) listener(message);
45
- }
46
- function notifyIncomingListeners(message) {
47
- if (debugIncomingMessageListeners.size > 0) for (const listener of debugIncomingMessageListeners) listener(message);
48
- }
49
- /**
50
- * Wrap HA Connection to SyncLayerConnection interface
51
- *
52
- * This adapter makes the real HA Connection compatible with our unified interface,
53
- * allowing it to be used interchangeably with MockConnection.
54
- *
55
- * @param conn - Home Assistant WebSocket connection
56
- * @returns Wrapped connection implementing SyncLayerConnection
57
- *
58
- * @example
59
- * ```typescript
60
- * const haConn = await createConnection({ auth });
61
- * const wrappedConn = wrapHAConnection(haConn);
62
- * await syncLayer.connect({ connection: wrappedConn });
63
- * ```
64
- */
65
- function wrapHAConnection(conn$1) {
66
- return {
67
- connect: async () => {
68
- return Promise.resolve();
69
- },
70
- disconnect: () => {
71
- conn$1.close();
72
- },
73
- get connected$() {
74
- return conn$1.connected;
75
- },
76
- authenticate: async () => {
77
- return Promise.resolve();
78
- },
79
- get authState() {
80
- return "authenticated";
81
- },
82
- subscribeEvents: async (callback, eventType) => {
83
- const wrappedCallback = (event) => {
84
- notifyIncomingListeners(event);
85
- callback(event);
86
- };
87
- return conn$1.subscribeEvents(wrappedCallback, eventType);
88
- },
89
- subscribeMessage: async (callback, message) => {
90
- const wrappedCallback = (msg) => {
91
- notifyIncomingListeners(msg);
92
- callback(msg);
93
- };
94
- return conn$1.subscribeMessage(wrappedCallback, message);
95
- },
96
- sendMessagePromise: async (message) => {
97
- notifyOutgoingListeners(message);
98
- const result = await conn$1.sendMessagePromise(message);
99
- notifyIncomingListeners(result);
100
- return result;
101
- },
102
- addEventListener: (handler) => {
103
- return addDebugIncomingMessageListener(handler);
104
- }
105
- };
106
- }
107
-
108
- //#endregion
109
- //#region src/core/store.ts
110
- const initialState = {
111
- conn: null,
112
- connectionState: "disconnected",
113
- connectionError: null,
114
- hassUrl: null,
115
- mediaProxyBase: null,
116
- config: null,
117
- entities: {},
118
- entityRegistry: {},
119
- devices: {},
120
- areas: {},
121
- floors: {},
122
- labels: {},
123
- statistics: {},
124
- statisticsMetadata: {},
125
- history: {},
126
- forecasts: {},
127
- streams: {}
128
- };
129
- 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.");
130
- globalThis.__GH_SYNC_LAYER_STORE__ = true;
131
- const storeTuple = createStore(initialState);
132
- const state = storeTuple[0];
133
- const setState = storeTuple[1];
134
- /**
135
- * Reset store to initial state
136
- */
137
- function resetStore() {
138
- setState(reconcile(initialState));
139
- }
140
-
141
- //#endregion
142
6
  //#region src/core/reducers.ts
143
7
  /**
144
8
  * Bulk update multiple entities
@@ -347,1729 +211,42 @@ async function authenticateWithOAuth(options) {
347
211
  }
348
212
  };
349
213
  saveAuth(authObj);
350
- }
351
- }
352
- });
353
- return auth$1;
354
- } catch {}
355
- let finalAuthCode = authCode;
356
- if (!finalAuthCode && getAuthCode) finalAuthCode = await getAuthCode();
357
- const auth = await getAuth({
358
- hassUrl: url,
359
- clientId: clientId ?? null,
360
- redirectUrl: redirectUri,
361
- authCode: finalAuthCode,
362
- limitHassInstance,
363
- async loadTokens() {
364
- if (loadAuth) {
365
- const saved = await loadAuth();
366
- if (saved?.data) return saved.data;
367
- }
368
- return null;
369
- },
370
- saveTokens(tokens) {
371
- if (saveAuth && tokens) {
372
- const authObj = {
373
- hassUrl: url,
374
- clientId,
375
- redirectUrl: redirectUri,
376
- data: tokens,
377
- async refreshAccessToken() {
378
- return Promise.resolve();
379
- }
380
- };
381
- saveAuth(authObj);
382
- }
383
- }
384
- });
385
- return auth;
386
- }
387
- /**
388
- * Authenticate using a long-lived token
389
- *
390
- * @param url - Home Assistant instance URL
391
- * @param token - Long-lived access token
392
- * @returns Authenticated Auth object
393
- *
394
- * @example
395
- * ```typescript
396
- * const auth = authenticateWithToken(
397
- * 'https://homeassistant.local:8123',
398
- * 'your_long_lived_token'
399
- * );
400
- * ```
401
- */
402
- function authenticateWithToken(url, token) {
403
- return createLongLivedTokenAuth(url, token);
404
- }
405
- /**
406
- * Check if auth object is valid
407
- *
408
- * @param auth - Auth object to check
409
- * @returns True if auth is valid
410
- */
411
- async function isAuthValid(auth) {
412
- try {
413
- await auth.refreshAccessToken();
414
- return true;
415
- } catch {
416
- return false;
417
- }
418
- }
419
- /**
420
- * Refresh auth tokens
421
- *
422
- * @param auth - Auth object to refresh
423
- * @returns Refreshed Auth object
424
- */
425
- async function refreshAuth(auth) {
426
- await auth.refreshAccessToken();
427
- return auth;
428
- }
429
-
430
- //#endregion
431
- //#region src/connection/subscription-manager.ts
432
- 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.");
433
- globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
434
- /** Ref counts — how many consumers need each entity */
435
- const refCounts = /* @__PURE__ */ new Map();
436
- /** Current HA subscription unsub function */
437
- let currentEntityUnsub = null;
438
- /** Snapshot of entity IDs in the current HA subscription */
439
- let subscribedIds = /* @__PURE__ */ new Set();
440
- /** Connection reference for resubscribing */
441
- let conn = null;
442
- /** Pending flush timer (microtask for register, timeout for unregister) */
443
- let flushScheduled = false;
444
- /** Delayed unregister timeout */
445
- let unregisterTimeout = null;
446
- /** Callback invoked on resubscribe — set by subscriptions.ts */
447
- let onResubscribe = null;
448
- /** Delay before unregistered entities are actually removed from subscription */
449
- const UNREGISTER_DELAY_MS = 5e3;
450
- /** Guard against concurrent flush() calls */
451
- let flushInFlight = false;
452
- /**
453
- * Register an entity for live updates. Returns an unregister function.
454
- * Call from useEntity/useEntities hooks via onCleanup.
455
- */
456
- function registerEntity(entityId) {
457
- refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
458
- scheduleFlush();
459
- return () => unregisterEntity(entityId);
460
- }
461
- /**
462
- * Set the connection reference used for resubscribing.
463
- */
464
- function setManagerConnection(connection) {
465
- conn = connection;
466
- if (refCounts.size > 0) scheduleFlush();
467
- }
468
- /**
469
- * Set the resubscribe callback. Called by subscriptions.ts to wire
470
- * the manager into the actual WS subscription lifecycle.
471
- */
472
- function setResubscribeHandler(handler) {
473
- onResubscribe = handler;
474
- }
475
- /**
476
- * Force a resubscribe with the current active set.
477
- * Used on reconnect.
478
- */
479
- async function forceResubscribe() {
480
- await flush();
481
- }
482
- function unregisterEntity(entityId) {
483
- const count = (refCounts.get(entityId) ?? 1) - 1;
484
- if (count <= 0) refCounts.delete(entityId);
485
- else refCounts.set(entityId, count);
486
- scheduleDelayedFlush();
487
- }
488
- function scheduleFlush() {
489
- if (flushScheduled) return;
490
- flushScheduled = true;
491
- queueMicrotask(() => {
492
- flushScheduled = false;
493
- flush();
494
- });
495
- }
496
- function scheduleDelayedFlush() {
497
- if (unregisterTimeout) return;
498
- unregisterTimeout = setTimeout(() => {
499
- unregisterTimeout = null;
500
- flush();
501
- }, UNREGISTER_DELAY_MS);
502
- }
503
- async function flush() {
504
- if (!conn || !onResubscribe) return;
505
- if (flushInFlight) return;
506
- flushInFlight = true;
507
- try {
508
- const newIds = new Set(refCounts.keys());
509
- if (setsEqual(newIds, subscribedIds)) return;
510
- const oldUnsub = currentEntityUnsub;
511
- const entityIds = [...newIds];
512
- currentEntityUnsub = await onResubscribe(conn, entityIds);
513
- subscribedIds = newIds;
514
- if (oldUnsub) try {
515
- await oldUnsub();
516
- } catch {}
517
- } finally {
518
- flushInFlight = false;
519
- }
520
- }
521
- function setsEqual(a, b) {
522
- if (a.size !== b.size) return false;
523
- for (const id of a) if (!b.has(id)) return false;
524
- return true;
525
- }
526
-
527
- //#endregion
528
- //#region src/core/types.ts
529
- /**
530
- * Extract domain from entity ID
531
- */
532
- function extractDomain(entityId) {
533
- return entityId.split(".")[0] ?? "";
534
- }
535
-
536
- //#endregion
537
- //#region src/demo/energy-sim.ts
538
- const SOLAR_PEAK_W = 5200;
539
- const SUNRISE_HOUR = 6.5;
540
- const SUNSET_HOUR = 20.5;
541
- const ALWAYS_ON_W = 285;
542
- const FRIDGE_W = 95;
543
- const FRIDGE_CYCLE_MIN = 45;
544
- const FRIDGE_ON_MIN = 18;
545
- const BATTERY_CAPACITY_WH = 1e4;
546
- const BATTERY_MIN_SOC = 10;
547
- const BATTERY_MAX_SOC = 100;
548
- const BATTERY_POWER_CAP_W = 3e3;
549
- const MIDNIGHT_SOC = 55;
550
- const SOC_STEP_MIN = 5;
551
- /** Integer hash -> uint32, used to seed mulberry32 deterministically. */
552
- function hashInt(n) {
553
- let h = n | 0;
554
- h = Math.imul(h ^ h >>> 16, 73244475);
555
- h = Math.imul(h ^ h >>> 16, 73244475);
556
- h = (h ^ h >>> 16) >>> 0;
557
- return h;
558
- }
559
- /** Deterministic [0,1) generator seeded by an integer. */
560
- function mulberry32(seed) {
561
- let a = seed >>> 0;
562
- return () => {
563
- a |= 0;
564
- a = a + 1831565813 | 0;
565
- let t = Math.imul(a ^ a >>> 15, 1 | a);
566
- t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
567
- return ((t ^ t >>> 14) >>> 0) / 4294967296;
568
- };
569
- }
570
- function localTime(timestampMs) {
571
- const d = new Date(timestampMs);
572
- const hours = d.getHours();
573
- const minutes = d.getMinutes();
574
- const seconds = d.getSeconds();
575
- const hourFrac = hours + minutes / 60 + seconds / 3600;
576
- const minuteOfDay = hours * 60 + minutes + seconds / 60;
577
- const offsetMs = d.getTimezoneOffset() * 6e4;
578
- const dayKey = Math.floor((timestampMs - offsetMs) / 864e5);
579
- return {
580
- hourFrac,
581
- minuteOfDay,
582
- dayOfWeek: d.getDay(),
583
- dayKey
584
- };
585
- }
586
- function localMidnightMs(timestampMs, lt) {
587
- return timestampMs - lt.minuteOfDay * 6e4;
588
- }
589
- /** Smooth multiplicative cloud factor in [0.55, 1.0], seeded per day. */
590
- function cloudFactor(lt) {
591
- const rand = mulberry32(hashInt(lt.dayKey * 2654435761));
592
- const phase1 = rand() * Math.PI * 2;
593
- const phase2 = rand() * Math.PI * 2;
594
- const phase3 = rand() * Math.PI * 2;
595
- const m = lt.minuteOfDay;
596
- const wave = .16 * Math.sin(m / 37 * Math.PI * 2 + phase1) + .14 * Math.sin(m / 73 * Math.PI * 2 + phase2) + .05 * Math.sin(m / 11 * Math.PI * 2 + phase3);
597
- const f = .85 + wave;
598
- return Math.max(.55, Math.min(1, f));
599
- }
600
- function solarPower(lt) {
601
- if (lt.hourFrac <= SUNRISE_HOUR || lt.hourFrac >= SUNSET_HOUR) return 0;
602
- const frac = (lt.hourFrac - SUNRISE_HOUR) / (SUNSET_HOUR - SUNRISE_HOUR);
603
- const shape = Math.sin(frac * Math.PI) ** 1.3;
604
- return SOLAR_PEAK_W * shape * cloudFactor(lt);
605
- }
606
- /** Coarse solar elevation in degrees for sun.sun attributes. */
607
- function sunElevation(lt) {
608
- if (lt.hourFrac <= SUNRISE_HOUR || lt.hourFrac >= SUNSET_HOUR) return -12;
609
- const frac = (lt.hourFrac - SUNRISE_HOUR) / (SUNSET_HOUR - SUNRISE_HOUR);
610
- return 60 * Math.sin(frac * Math.PI);
611
- }
612
- function fridgePower(lt) {
613
- const rand = mulberry32(hashInt(lt.dayKey * 40503 + 7));
614
- const phase = rand() * FRIDGE_CYCLE_MIN;
615
- const pos = (lt.minuteOfDay + phase) % FRIDGE_CYCLE_MIN;
616
- return pos < FRIDGE_ON_MIN ? FRIDGE_W : 0;
617
- }
618
- /** A square wave: `power` while inside [start, start+durationMin). */
619
- function windowPower(minuteOfDay, startMin, durationMin, power) {
620
- return minuteOfDay >= startMin && minuteOfDay < startMin + durationMin ? power : 0;
621
- }
622
- function kettlePower(lt) {
623
- return windowPower(lt.minuteOfDay, 430, 4, 2e3) + windowPower(lt.minuteOfDay, 1e3, 4, 2e3);
624
- }
625
- function ovenPower(lt) {
626
- const start = 1125;
627
- if (lt.minuteOfDay < start || lt.minuteOfDay >= start + 40) return 0;
628
- const into = lt.minuteOfDay - start;
629
- const cyclePos = into % 6.5;
630
- return cyclePos < 4 ? 2200 : 0;
631
- }
632
- function dishwasherPower(lt) {
633
- const start = 1270;
634
- if (lt.minuteOfDay < start || lt.minuteOfDay >= start + 65) return 0;
635
- const into = lt.minuteOfDay - start;
636
- if (into < 12) return 1900;
637
- if (into >= 57) return 1900;
638
- return 120;
639
- }
640
- function washerPower(lt) {
641
- if (![
642
- 1,
643
- 4,
644
- 6
645
- ].includes(lt.dayOfWeek)) return 0;
646
- const start = 630;
647
- if (lt.minuteOfDay < start || lt.minuteOfDay >= start + 50) return 0;
648
- const into = lt.minuteOfDay - start;
649
- return into < 14 ? 2100 : 250;
650
- }
651
- function evChargerPower(lt) {
652
- if (![
653
- 2,
654
- 4,
655
- 0
656
- ].includes(lt.dayOfWeek)) return 0;
657
- const start = 1100;
658
- const stop = 1300;
659
- if (lt.minuteOfDay < start || lt.minuteOfDay >= stop) return 0;
660
- const into = lt.minuteOfDay - start;
661
- const ramp = into < 2 ? into / 2 : 1;
662
- return 7200 * ramp;
663
- }
664
- const HEAT_PUMP_PEAK_W = 3200;
665
- /**
666
- * A smooth heat-pump load: a slow morning/evening envelope carrying a
667
- * few-minute shimmer. Its peaks push the home deficit past the battery's power
668
- * cap, so grid import (and the running cost) visibly rise and fall on live
669
- * ticks instead of sitting at zero. Gated out of the deep-night window so the
670
- * quiet 02:00-03:00 baseline is unchanged.
671
- */
672
- function heatPumpPower(lt) {
673
- const h = lt.hourFrac;
674
- let envelope = 0;
675
- if (h >= 6 && h <= 9) envelope = Math.sin((h - 6) / 3 * Math.PI);
676
- else if (h >= 16 && h <= 23) envelope = Math.sin((h - 16) / 7 * Math.PI);
677
- else return 0;
678
- const shimmer = .7 + .3 * Math.sin(lt.minuteOfDay * Math.PI * 2);
679
- return HEAT_PUMP_PEAK_W * envelope * shimmer;
680
- }
681
- /** Smooth occupancy envelope: extra load mornings and evenings. */
682
- function occupancyEnvelope(lt) {
683
- const h = lt.hourFrac;
684
- let e = 0;
685
- if (h >= 7 && h <= 9) e += 80 + 270 * Math.sin((h - 7) / 2 * Math.PI);
686
- if (h >= 17.5 && h <= 23) e += 80 + 270 * Math.sin((h - 17.5) / 5.5 * Math.PI);
687
- return e;
688
- }
689
- /** Low-amplitude smooth noise multiplier, ±4%, seeded per day. */
690
- function homeNoise(lt) {
691
- const rand = mulberry32(hashInt(lt.dayKey * 22699 + 13));
692
- const phase = rand() * Math.PI * 2;
693
- return 1 + .04 * Math.sin(lt.minuteOfDay / 23 * Math.PI * 2 + phase);
694
- }
695
- const socCache = /* @__PURE__ */ new Map();
696
- /** Net surplus (solar - home) at a given local-time sample, in watts. */
697
- function netSurplusAt(timestampMs) {
698
- const lt = localTime(timestampMs);
699
- const solar = solarPower(lt);
700
- const home = homePower(lt);
701
- return solar - home;
702
- }
703
- /**
704
- * Battery SOC (%) at every {@link SOC_STEP_MIN}-minute step from local
705
- * midnight. Returns step `i` = SOC after integrating `i` steps. Index 0 is
706
- * the midnight SOC. Memoized per local day (pure computation).
707
- */
708
- function socPrefix(dayKey, midnightMs) {
709
- const cached = socCache.get(dayKey);
710
- if (cached) return cached;
711
- const steps = Math.ceil(24 * 60 / SOC_STEP_MIN);
712
- const arr = new Float64Array(steps + 1);
713
- arr[0] = MIDNIGHT_SOC;
714
- const stepMs = SOC_STEP_MIN * 6e4;
715
- const stepHours = SOC_STEP_MIN / 60;
716
- for (let i = 0; i < steps; i++) {
717
- const soc = arr[i] ?? MIDNIGHT_SOC;
718
- const sampleMs = midnightMs + i * stepMs;
719
- const surplus = netSurplusAt(sampleMs);
720
- let chargeW = 0;
721
- let dischargeW = 0;
722
- if (surplus > 0) {
723
- const headroomWh = (BATTERY_MAX_SOC - soc) / 100 * BATTERY_CAPACITY_WH;
724
- const headroomW = headroomWh / stepHours;
725
- chargeW = Math.min(surplus, BATTERY_POWER_CAP_W, Math.max(0, headroomW));
726
- } else if (surplus < 0) {
727
- const availWh = (soc - BATTERY_MIN_SOC) / 100 * BATTERY_CAPACITY_WH;
728
- const availW = availWh / stepHours;
729
- dischargeW = Math.min(-surplus, BATTERY_POWER_CAP_W, Math.max(0, availW));
730
- }
731
- const deltaWh = (chargeW - dischargeW) * stepHours;
732
- const nextSoc = soc + deltaWh / BATTERY_CAPACITY_WH * 100;
733
- arr[i + 1] = Math.max(BATTERY_MIN_SOC, Math.min(BATTERY_MAX_SOC, nextSoc));
734
- }
735
- socCache.set(dayKey, arr);
736
- return arr;
737
- }
738
- /** Battery SOC (%) at an exact timestamp, linearly interpolated between steps. */
739
- function batterySoc(lt, midnightMs) {
740
- const prefix = socPrefix(lt.dayKey, midnightMs);
741
- const stepFloat = lt.minuteOfDay / SOC_STEP_MIN;
742
- const i = Math.floor(stepFloat);
743
- const frac = stepFloat - i;
744
- const a = prefix[i] ?? MIDNIGHT_SOC;
745
- const b = prefix[i + 1] ?? a;
746
- return a + (b - a) * frac;
747
- }
748
- function homePower(lt) {
749
- const base = ALWAYS_ON_W + fridgePower(lt) + kettlePower(lt) + ovenPower(lt) + dishwasherPower(lt) + washerPower(lt) + evChargerPower(lt) + heatPumpPower(lt) + occupancyEnvelope(lt);
750
- return base * homeNoise(lt);
751
- }
752
- /**
753
- * Compute every instantaneous energy value at a timestamp.
754
- *
755
- * Deterministic: equal timestamps always yield deep-equal samples. Obeys the
756
- * conservation invariant
757
- * `solar + gridImport + batteryDischarge === home + gridExport + batteryCharge`
758
- * to within rounding.
759
- */
760
- function simulateEnergy(timestampMs) {
761
- const lt = localTime(timestampMs);
762
- const midnightMs = localMidnightMs(timestampMs, lt);
763
- const solarW = solarPower(lt);
764
- const fridgeW = fridgePower(lt);
765
- const ovenW = ovenPower(lt);
766
- const dishwasherW = dishwasherPower(lt);
767
- const washerW = washerPower(lt);
768
- const evChargerW = evChargerPower(lt);
769
- const alwaysOnW = ALWAYS_ON_W;
770
- const homeW = homePower(lt);
771
- const soc = batterySoc(lt, midnightMs);
772
- const surplus = solarW - homeW;
773
- let batteryChargeW = 0;
774
- let batteryDischargeW = 0;
775
- if (surplus > 0) {
776
- const headroom = soc < BATTERY_MAX_SOC ? BATTERY_POWER_CAP_W : 0;
777
- batteryChargeW = Math.min(surplus, headroom);
778
- } else if (surplus < 0) {
779
- const avail = soc > BATTERY_MIN_SOC ? BATTERY_POWER_CAP_W : 0;
780
- batteryDischargeW = Math.min(-surplus, avail);
781
- }
782
- const remainingDeficit = homeW - solarW - batteryDischargeW + batteryChargeW;
783
- const gridImportW = Math.max(0, remainingDeficit);
784
- const gridExportW = Math.max(0, -remainingDeficit);
785
- return {
786
- solarW,
787
- homeW,
788
- batteryChargeW,
789
- batteryDischargeW,
790
- batterySocPct: soc,
791
- gridImportW,
792
- gridExportW,
793
- fridgeW,
794
- dishwasherW,
795
- washerW,
796
- ovenW,
797
- evChargerW,
798
- alwaysOnW
799
- };
800
- }
801
- /** Whether the sun is above the horizon at a timestamp. */
802
- function isSunUp(timestampMs) {
803
- const lt = localTime(timestampMs);
804
- return lt.hourFrac > SUNRISE_HOUR && lt.hourFrac < SUNSET_HOUR;
805
- }
806
- /** Next sunrise/sunset ISO strings relative to a timestamp. */
807
- function sunEvents(timestampMs) {
808
- const lt = localTime(timestampMs);
809
- const midnightMs = localMidnightMs(timestampMs, lt);
810
- const sunriseMs = midnightMs + SUNRISE_HOUR * 36e5;
811
- const sunsetMs = midnightMs + SUNSET_HOUR * 36e5;
812
- const nextRising = timestampMs < sunriseMs ? sunriseMs : sunriseMs + 864e5;
813
- const nextSetting = timestampMs < sunsetMs ? sunsetMs : sunsetMs + 864e5;
814
- return {
815
- nextRising: new Date(nextRising).toISOString(),
816
- nextSetting: new Date(nextSetting).toISOString(),
817
- elevation: Math.round(sunElevation(lt) * 100) / 100
818
- };
819
- }
820
- /** The energy sensor entity ids the simulation drives. */
821
- const ENERGY_ENTITY_IDS = [
822
- "sensor.solar_power",
823
- "sensor.grid_import_power",
824
- "sensor.grid_export_power",
825
- "sensor.battery_charge_power",
826
- "sensor.battery_discharge_power",
827
- "sensor.battery_soc",
828
- "sensor.home_power",
829
- "sensor.fridge_power",
830
- "sensor.dishwasher_power",
831
- "sensor.washing_machine_power",
832
- "sensor.oven_power",
833
- "sensor.ev_charger_power",
834
- "sensor.always_on_power"
835
- ];
836
- /** Map an energy entity id to its numeric value from a sample (W, or % for SOC). */
837
- function energyEntityValue(entityId, sample) {
838
- switch (entityId) {
839
- case "sensor.solar_power": return sample.solarW;
840
- case "sensor.grid_import_power": return sample.gridImportW;
841
- case "sensor.grid_export_power": return sample.gridExportW;
842
- case "sensor.battery_charge_power": return sample.batteryChargeW;
843
- case "sensor.battery_discharge_power": return sample.batteryDischargeW;
844
- case "sensor.battery_soc": return sample.batterySocPct;
845
- case "sensor.home_power": return sample.homeW;
846
- case "sensor.fridge_power": return sample.fridgeW;
847
- case "sensor.dishwasher_power": return sample.dishwasherW;
848
- case "sensor.washing_machine_power": return sample.washerW;
849
- case "sensor.oven_power": return sample.ovenW;
850
- case "sensor.ev_charger_power": return sample.evChargerW;
851
- case "sensor.always_on_power": return sample.alwaysOnW;
852
- default: return void 0;
853
- }
854
- }
855
- /** Whether an entity id is one of the simulated energy sensors. */
856
- function isEnergyEntity(entityId) {
857
- return energyEntityValue(entityId, ZERO_SAMPLE) !== void 0;
858
- }
859
- const ZERO_SAMPLE = {
860
- solarW: 0,
861
- homeW: 0,
862
- batteryChargeW: 0,
863
- batteryDischargeW: 0,
864
- batterySocPct: 0,
865
- gridImportW: 0,
866
- gridExportW: 0,
867
- fridgeW: 0,
868
- dishwasherW: 0,
869
- washerW: 0,
870
- ovenW: 0,
871
- evChargerW: 0,
872
- alwaysOnW: 0
873
- };
874
- /** Round to 1 decimal for display-friendly state strings (whole % for SOC). */
875
- function formatEnergyState(entityId, value) {
876
- if (entityId === "sensor.battery_soc") return String(Math.round(value));
877
- return String(Math.round(value));
878
- }
879
- /**
880
- * Synthesize history points for one energy entity by sampling the model across
881
- * the window at a fixed cadence (default 1-minute). Returns HA-compressed
882
- * `{ lu, s }` points with `lu` in Unix seconds.
883
- */
884
- function synthesizeEnergyHistory(entityId, startMs, endMs, stepMs = 6e4) {
885
- const points = [];
886
- for (let t = startMs; t <= endMs; t += stepMs) {
887
- const value = energyEntityValue(entityId, simulateEnergy(t));
888
- if (value === void 0) continue;
889
- points.push({
890
- lu: Math.round(t / 1e3),
891
- s: formatEnergyState(entityId, value)
892
- });
893
- }
894
- return points;
895
- }
896
- /** Bucket length in ms for a statistics period. */
897
- function bucketMs(period) {
898
- return period === "hour" ? 36e5 : 864e5;
899
- }
900
- /**
901
- * Synthesize statistics buckets for one energy entity across a window.
902
- *
903
- * `mean`/`min`/`max` come from 5-minute power samples in the bucket; `sum` is
904
- * the energy in Wh (mean W times bucket hours). `sum` is cumulative across the
905
- * window, matching HA's monotonically increasing meter semantics.
906
- */
907
- function synthesizeEnergyStatistics(entityId, startMs, endMs, period) {
908
- const span = bucketMs(period);
909
- const sampleMs = 5 * 6e4;
910
- const buckets = [];
911
- let cumulativeWh = 0;
912
- for (let bStart = startMs; bStart < endMs; bStart += span) {
913
- const bEnd = bStart + span;
914
- let sum = 0;
915
- let count = 0;
916
- let min = Number.POSITIVE_INFINITY;
917
- let max = Number.NEGATIVE_INFINITY;
918
- for (let t = bStart; t < bEnd; t += sampleMs) {
919
- const value = energyEntityValue(entityId, simulateEnergy(t));
920
- if (value === void 0) continue;
921
- sum += value;
922
- count++;
923
- if (value < min) min = value;
924
- if (value > max) max = value;
925
- }
926
- if (count === 0) continue;
927
- const mean = sum / count;
928
- const bucketHours = span / 36e5;
929
- const energyWh = entityId === "sensor.battery_soc" ? 0 : mean * bucketHours;
930
- cumulativeWh += energyWh;
931
- buckets.push({
932
- start: bStart,
933
- end: bEnd,
934
- mean,
935
- min: min === Number.POSITIVE_INFINITY ? mean : min,
936
- max: max === Number.NEGATIVE_INFINITY ? mean : max,
937
- sum: cumulativeWh,
938
- change: energyWh / 1e3
939
- });
940
- }
941
- return buckets;
942
- }
943
- /** The demo energy dashboard preferences wiring the simulated sensors. */
944
- function demoEnergyPreferences() {
945
- return {
946
- energy_sources: [
947
- {
948
- type: "solar",
949
- stat_energy_from: "sensor.solar_power"
950
- },
951
- {
952
- type: "grid",
953
- flow_from: [{ stat_energy_from: "sensor.grid_import_power" }],
954
- flow_to: [{ stat_energy_to: "sensor.grid_export_power" }]
955
- },
956
- {
957
- type: "battery",
958
- stat_energy_from: "sensor.battery_discharge_power",
959
- stat_energy_to: "sensor.battery_charge_power"
960
- }
961
- ],
962
- device_consumption: [
963
- {
964
- stat_consumption: "sensor.fridge_power",
965
- name: "Fridge"
966
- },
967
- {
968
- stat_consumption: "sensor.dishwasher_power",
969
- name: "Dishwasher"
970
- },
971
- {
972
- stat_consumption: "sensor.washing_machine_power",
973
- name: "Washing Machine"
974
- },
975
- {
976
- stat_consumption: "sensor.oven_power",
977
- name: "Oven"
978
- },
979
- {
980
- stat_consumption: "sensor.ev_charger_power",
981
- name: "EV Charger"
982
- },
983
- {
984
- stat_consumption: "sensor.always_on_power",
985
- name: "Always On"
986
- }
987
- ]
988
- };
989
- }
990
-
991
- //#endregion
992
- //#region src/demo/demo-data.ts
993
- const now = (/* @__PURE__ */ new Date()).toISOString();
994
- let contextCounter = 0;
995
- function ctx() {
996
- return {
997
- id: `demo-${++contextCounter}`,
998
- parent_id: null,
999
- user_id: null
1000
- };
1001
- }
1002
- function makeEntity(entity_id, state$1, attributes = {}) {
1003
- return {
1004
- entity_id,
1005
- state: state$1,
1006
- attributes: {
1007
- friendly_name: friendlyName(entity_id),
1008
- ...attributes
1009
- },
1010
- last_changed: now,
1011
- last_updated: now,
1012
- context: ctx()
1013
- };
1014
- }
1015
- /**
1016
- * One entity per weather scene the widget can render. Entity IDs use the
1017
- * `weather.demo_<slug>` form so a demo dashboard can reference them directly.
1018
- */
1019
- const WEATHER_FIXTURES = [
1020
- {
1021
- slug: "sunny",
1022
- state: "sunny",
1023
- temp: 28,
1024
- apparent: 31,
1025
- humidity: 35,
1026
- pressure: 1018,
1027
- wind: 8,
1028
- low: 18
1029
- },
1030
- {
1031
- slug: "clear_night",
1032
- state: "clear-night",
1033
- temp: 14,
1034
- apparent: 12,
1035
- humidity: 55,
1036
- pressure: 1016,
1037
- wind: 5,
1038
- low: 9
1039
- },
1040
- {
1041
- slug: "cloudy",
1042
- state: "cloudy",
1043
- temp: 17,
1044
- apparent: 16,
1045
- humidity: 72,
1046
- pressure: 1010,
1047
- wind: 14,
1048
- low: 11
1049
- },
1050
- {
1051
- slug: "partly_cloudy",
1052
- state: "partlycloudy",
1053
- temp: 23,
1054
- apparent: 24,
1055
- humidity: 50,
1056
- pressure: 1014,
1057
- wind: 12,
1058
- low: 15
1059
- },
1060
- {
1061
- slug: "rainy",
1062
- state: "rainy",
1063
- temp: 12,
1064
- apparent: 10,
1065
- humidity: 88,
1066
- pressure: 1004,
1067
- wind: 18,
1068
- low: 8
1069
- },
1070
- {
1071
- slug: "pouring",
1072
- state: "pouring",
1073
- temp: 11,
1074
- apparent: 8,
1075
- humidity: 95,
1076
- pressure: 998,
1077
- wind: 26,
1078
- low: 7
1079
- },
1080
- {
1081
- slug: "snowy",
1082
- state: "snowy",
1083
- temp: -2,
1084
- apparent: -6,
1085
- humidity: 80,
1086
- pressure: 1020,
1087
- wind: 10,
1088
- low: -7
1089
- },
1090
- {
1091
- slug: "snowy_rainy",
1092
- state: "snowy-rainy",
1093
- temp: 1,
1094
- apparent: -2,
1095
- humidity: 92,
1096
- pressure: 1006,
1097
- wind: 16,
1098
- low: -2
1099
- },
1100
- {
1101
- slug: "lightning",
1102
- state: "lightning",
1103
- temp: 22,
1104
- apparent: 24,
1105
- humidity: 78,
1106
- pressure: 1001,
1107
- wind: 22,
1108
- low: 17
1109
- },
1110
- {
1111
- slug: "lightning_rainy",
1112
- state: "lightning-rainy",
1113
- temp: 19,
1114
- apparent: 18,
1115
- humidity: 90,
1116
- pressure: 996,
1117
- wind: 28,
1118
- low: 14
1119
- },
1120
- {
1121
- slug: "fog",
1122
- state: "fog",
1123
- temp: 8,
1124
- apparent: 6,
1125
- humidity: 98,
1126
- pressure: 1015,
1127
- wind: 4,
1128
- low: 6
1129
- },
1130
- {
1131
- slug: "hail",
1132
- state: "hail",
1133
- temp: 6,
1134
- apparent: 3,
1135
- humidity: 84,
1136
- pressure: 1002,
1137
- wind: 20,
1138
- low: 1
1139
- },
1140
- {
1141
- slug: "windy",
1142
- state: "windy",
1143
- temp: 18,
1144
- apparent: 15,
1145
- humidity: 60,
1146
- pressure: 1009,
1147
- wind: 42,
1148
- low: 12
1149
- },
1150
- {
1151
- slug: "exceptional",
1152
- state: "exceptional",
1153
- temp: 38,
1154
- apparent: 44,
1155
- humidity: 22,
1156
- pressure: 1005,
1157
- wind: 30,
1158
- low: 28
1159
- }
1160
- ];
1161
- function makeWeatherEntity(w) {
1162
- const today = /* @__PURE__ */ new Date();
1163
- const day = (n) => new Date(today.getTime() + n * 864e5).toISOString();
1164
- return makeEntity(`weather.demo_${w.slug}`, w.state, {
1165
- temperature: w.temp,
1166
- temperature_unit: "°C",
1167
- apparent_temperature: w.apparent ?? w.temp,
1168
- humidity: w.humidity,
1169
- pressure: w.pressure,
1170
- pressure_unit: "hPa",
1171
- wind_speed: w.wind,
1172
- wind_speed_unit: "km/h",
1173
- wind_bearing: w.bearing ?? 220,
1174
- visibility: 10,
1175
- visibility_unit: "km",
1176
- uv_index: w.state === "sunny" ? 8 : 2,
1177
- forecast: [
1178
- {
1179
- datetime: day(0),
1180
- condition: w.state,
1181
- temperature: w.temp,
1182
- templow: w.low
1183
- },
1184
- {
1185
- datetime: day(1),
1186
- condition: "partlycloudy",
1187
- temperature: w.temp + 2,
1188
- templow: w.low
1189
- },
1190
- {
1191
- datetime: day(2),
1192
- condition: "cloudy",
1193
- temperature: w.temp + 1,
1194
- templow: w.low - 1
1195
- },
1196
- {
1197
- datetime: day(3),
1198
- condition: "sunny",
1199
- temperature: w.temp + 4,
1200
- templow: w.low + 1
1201
- },
1202
- {
1203
- datetime: day(4),
1204
- condition: "rainy",
1205
- temperature: w.temp - 2,
1206
- templow: w.low - 2
1207
- }
1208
- ]
1209
- });
1210
- }
1211
- function friendlyName(entityId) {
1212
- const objectId = entityId.split(".")[1] ?? entityId;
1213
- return objectId.split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
1214
- }
1215
- function makeRegistryEntry(entity_id, overrides = {}) {
1216
- const domain = entity_id.split(".")[0] ?? "";
1217
- return {
1218
- aliases: [],
1219
- area_id: null,
1220
- capabilities: null,
1221
- categories: {},
1222
- config_entry_id: "demo_config",
1223
- config_subentry_id: null,
1224
- created_at: now,
1225
- device_class: null,
1226
- device_id: null,
1227
- disabled_by: null,
1228
- entity_category: null,
1229
- entity_id,
1230
- has_entity_name: false,
1231
- hidden_by: null,
1232
- icon: null,
1233
- id: `reg-${entity_id.replace(".", "-")}`,
1234
- labels: [],
1235
- modified_at: now,
1236
- name: null,
1237
- options: {},
1238
- original_device_class: null,
1239
- original_icon: null,
1240
- original_name: null,
1241
- platform: domain === "weather" ? "met" : "demo",
1242
- previous_unique_id: null,
1243
- suggested_object_id: null,
1244
- supported_features: 0,
1245
- translation_key: null,
1246
- unique_id: `demo_${entity_id.replace(".", "_")}`,
1247
- unit_of_measurement: null,
1248
- ...overrides
1249
- };
1250
- }
1251
- function makeDevice(id, name, areaId, manufacturer = "Demo", model = "Virtual") {
1252
- return {
1253
- id,
1254
- name,
1255
- name_by_user: null,
1256
- manufacturer,
1257
- model,
1258
- model_id: null,
1259
- sw_version: "1.0.0",
1260
- hw_version: null,
1261
- area_id: areaId,
1262
- config_entries: ["demo_config"],
1263
- config_entries_subentries: {},
1264
- configuration_url: null,
1265
- connections: [],
1266
- created_at: now,
1267
- disabled_by: null,
1268
- entry_type: null,
1269
- identifiers: [["demo", id]],
1270
- labels: [],
1271
- modified_at: now,
1272
- primary_config_entry: "demo_config",
1273
- serial_number: null,
1274
- via_device_id: null
1275
- };
1276
- }
1277
- function makeArea(id, name, floorId = "ground_floor") {
1278
- return {
1279
- id,
1280
- name,
1281
- normalized_name: name.toLowerCase().replace(/\s+/g, "_"),
1282
- created_at: now,
1283
- modified_at: now,
1284
- aliases: [],
1285
- floor_id: floorId,
1286
- humidity_entity_id: null,
1287
- icon: null,
1288
- labels: [],
1289
- picture: null,
1290
- temperature_entity_id: null
1291
- };
1292
- }
1293
- /**
1294
- * Create ~30 curated apartment entity fixtures.
1295
- *
1296
- * Entities span lights, sensors, binary sensors, climate, covers,
1297
- * locks, switches, media player, weather, camera, scenes, and buttons
1298
- * across 6 areas: living_room, bedroom, kitchen, entry, utility, garage.
1299
- */
1300
- function createDemoFixtures() {
1301
- contextCounter = 0;
1302
- const areas$1 = {
1303
- living_room: makeArea("living_room", "Living Room"),
1304
- bedroom: makeArea("bedroom", "Bedroom"),
1305
- kitchen: makeArea("kitchen", "Kitchen"),
1306
- entry: makeArea("entry", "Entry"),
1307
- utility: makeArea("utility", "Utility"),
1308
- garage: makeArea("garage", "Garage")
1309
- };
1310
- areas$1.living_room.temperature_entity_id = "sensor.temperature_living";
1311
- areas$1.living_room.humidity_entity_id = "sensor.humidity_living";
1312
- const devices = {
1313
- living_room_lights: makeDevice("living_room_lights", "Living Room Lights", "living_room", "Philips", "Hue"),
1314
- kitchen_lights: makeDevice("kitchen_lights", "Kitchen Lights", "kitchen", "IKEA", "Tradfri"),
1315
- bedroom_lights: makeDevice("bedroom_lights", "Bedroom Lights", "bedroom", "Philips", "Hue"),
1316
- hallway_lights: makeDevice("hallway_lights", "Hallway Lights", "entry", "IKEA", "Tradfri"),
1317
- bathroom_lights: makeDevice("bathroom_lights", "Bathroom Lights", null),
1318
- thermostat_lr: makeDevice("thermostat_lr", "Living Room Thermostat", "living_room", "Nest", "Learning Thermostat"),
1319
- thermostat_br: makeDevice("thermostat_br", "Bedroom AC", "bedroom", "Daikin", "Split AC"),
1320
- blinds_lr: makeDevice("blinds_lr", "Living Room Blinds", "living_room", "Somfy", "Roller Shade"),
1321
- curtains_br: makeDevice("curtains_br", "Bedroom Curtains", "bedroom", "IKEA", "Fyrtur"),
1322
- garage_door_opener: makeDevice("garage_door_opener", "Garage Door Opener", "garage", "Chamberlain", "MyQ"),
1323
- shutters_lr: makeDevice("shutters_lr", "Living Room Shutters", "living_room", "Somfy", "Venetian Shutter"),
1324
- blinds_kitchen: makeDevice("blinds_kitchen", "Kitchen Blinds", "kitchen", "IKEA", "Praktlysing"),
1325
- lock_front: makeDevice("lock_front", "Front Door Lock", "entry", "Yale", "Assure Lock 2"),
1326
- lock_back: makeDevice("lock_back", "Back Door Lock", "entry", "August", "Smart Lock Pro"),
1327
- speaker_lr: makeDevice("speaker_lr", "Living Room Speaker", "living_room", "Sonos", "One"),
1328
- camera_front: makeDevice("camera_front", "Front Door Camera", "entry", "Ring", "Video Doorbell"),
1329
- door_sensor: makeDevice("door_sensor", "Front Door Sensor", "entry", "Aqara", "Door Sensor"),
1330
- motion_sensor: makeDevice("motion_sensor", "Hallway Motion Sensor", "entry", "Aqara", "Motion Sensor")
1331
- };
1332
- const entities$1 = {};
1333
- const entityRegistry = {};
1334
- function add(e, regOverrides = {}) {
1335
- entities$1[e.entity_id] = e;
1336
- entityRegistry[e.entity_id] = makeRegistryEntry(e.entity_id, regOverrides);
1337
- }
1338
- add(makeEntity("light.living_room_main", "on", {
1339
- brightness: 179,
1340
- color_temp_kelvin: 3e3,
1341
- color_mode: "color_temp",
1342
- supported_color_modes: ["color_temp", "xy"]
1343
- }), {
1344
- device_id: "living_room_lights",
1345
- area_id: "living_room",
1346
- supported_features: 44
1347
- });
1348
- add(makeEntity("light.kitchen_counter", "off", { supported_color_modes: ["brightness"] }), {
1349
- device_id: "kitchen_lights",
1350
- area_id: "kitchen",
1351
- supported_features: 1
1352
- });
1353
- add(makeEntity("light.bedroom_ceiling", "on", {
1354
- brightness: 77,
1355
- color_temp_kelvin: 2700,
1356
- color_mode: "color_temp",
1357
- supported_color_modes: ["color_temp"]
1358
- }), {
1359
- device_id: "bedroom_lights",
1360
- area_id: "bedroom",
1361
- supported_features: 44
1362
- });
1363
- add(makeEntity("light.bathroom", "off", { supported_color_modes: ["brightness"] }), { device_id: "bathroom_lights" });
1364
- add(makeEntity("light.hallway", "on", {
1365
- brightness: 128,
1366
- color_mode: "brightness",
1367
- supported_color_modes: ["brightness"]
1368
- }), {
1369
- device_id: "hallway_lights",
1370
- area_id: "entry",
1371
- supported_features: 1
1372
- });
1373
- add(makeEntity("light.studio_rgb", "on", {
1374
- brightness: 205,
1375
- hs_color: [265, 66],
1376
- color_mode: "hs",
1377
- supported_color_modes: ["hs", "color_temp"]
1378
- }), {
1379
- device_id: "studio_lights",
1380
- area_id: "living_room",
1381
- supported_features: 44
1382
- });
1383
- add(makeEntity("light.desk_rgb", "on", {
1384
- brightness: 200,
1385
- hs_color: [172, 79],
1386
- color_mode: "hs",
1387
- supported_color_modes: ["hs", "color_temp"]
1388
- }), {
1389
- device_id: "desk_lights",
1390
- area_id: "bedroom",
1391
- supported_features: 44
1392
- });
1393
- add(makeEntity("sensor.temperature_living", "22.5", {
1394
- unit_of_measurement: "°C",
1395
- device_class: "temperature",
1396
- state_class: "measurement"
1397
- }), {
1398
- area_id: "living_room",
1399
- device_class: "temperature",
1400
- unit_of_measurement: "°C"
1401
- });
1402
- add(makeEntity("sensor.humidity_living", "45", {
1403
- unit_of_measurement: "%",
1404
- device_class: "humidity",
1405
- state_class: "measurement"
1406
- }), {
1407
- area_id: "living_room",
1408
- device_class: "humidity",
1409
- unit_of_measurement: "%"
1410
- });
1411
- add(makeEntity("sensor.temperature_outdoor", "-2", {
1412
- unit_of_measurement: "°C",
1413
- device_class: "temperature",
1414
- state_class: "measurement"
1415
- }), {
1416
- device_class: "temperature",
1417
- unit_of_measurement: "°C"
1418
- });
1419
- add(makeEntity("sensor.power_consumption", "1.2", {
1420
- unit_of_measurement: "kW",
1421
- device_class: "power",
1422
- state_class: "measurement"
1423
- }), {
1424
- device_class: "power",
1425
- unit_of_measurement: "kW"
1426
- });
1427
- add(makeEntity("sensor.battery_door_sensor", "87", {
1428
- unit_of_measurement: "%",
1429
- device_class: "battery",
1430
- state_class: "measurement"
1431
- }), {
1432
- device_id: "door_sensor",
1433
- area_id: "entry",
1434
- device_class: "battery",
1435
- unit_of_measurement: "%"
1436
- });
1437
- add(makeEntity("binary_sensor.front_door", "off", { device_class: "door" }), {
1438
- device_id: "door_sensor",
1439
- area_id: "entry",
1440
- device_class: "door"
1441
- });
1442
- add(makeEntity("binary_sensor.motion_hallway", "off", { device_class: "motion" }), {
1443
- device_id: "motion_sensor",
1444
- area_id: "entry",
1445
- device_class: "motion"
1446
- });
1447
- add(makeEntity("binary_sensor.window_bedroom", "off", { device_class: "window" }), {
1448
- area_id: "bedroom",
1449
- device_class: "window"
1450
- });
1451
- add(makeEntity("climate.living_room_thermostat", "heat", {
1452
- temperature: 22,
1453
- current_temperature: 21.8,
1454
- hvac_modes: [
1455
- "off",
1456
- "heat",
1457
- "cool",
1458
- "auto"
1459
- ],
1460
- hvac_action: "heating",
1461
- min_temp: 7,
1462
- max_temp: 35
1463
- }), {
1464
- device_id: "thermostat_lr",
1465
- area_id: "living_room",
1466
- supported_features: 385
1467
- });
1468
- add(makeEntity("climate.bedroom_ac", "off", {
1469
- temperature: 20,
1470
- current_temperature: 22.1,
1471
- hvac_modes: [
1472
- "off",
1473
- "cool",
1474
- "heat",
1475
- "dry",
1476
- "fan_only"
1477
- ],
1478
- hvac_action: "idle",
1479
- min_temp: 16,
1480
- max_temp: 30
1481
- }), {
1482
- device_id: "thermostat_br",
1483
- area_id: "bedroom",
1484
- supported_features: 385
1485
- });
1486
- add(makeEntity("water_heater.boiler", "eco", {
1487
- temperature: 50,
1488
- current_temperature: 47.5,
1489
- operation_list: [
1490
- "off",
1491
- "eco",
1492
- "performance"
1493
- ],
1494
- min_temp: 43,
1495
- max_temp: 60,
1496
- away_mode: "off"
1497
- }), {
1498
- device_id: "boiler",
1499
- area_id: "utility",
1500
- supported_features: 7
1501
- });
1502
- add(makeEntity("water_heater.heat_pump_tank", "heat_pump", {
1503
- temperature: 55,
1504
- current_temperature: 52.3,
1505
- operation_list: [
1506
- "off",
1507
- "eco",
1508
- "heat_pump",
1509
- "high_demand"
1510
- ],
1511
- min_temp: 43,
1512
- max_temp: 60
1513
- }), {
1514
- device_id: "hp_tank",
1515
- area_id: "garage",
1516
- supported_features: 3
1517
- });
1518
- add(makeEntity("fan.bedroom_ceiling", "on", {
1519
- percentage: 200 / 3,
1520
- percentage_step: 100 / 3,
1521
- current_direction: "forward"
1522
- }), {
1523
- device_id: "ceiling_fan",
1524
- area_id: "bedroom",
1525
- supported_features: 5
1526
- });
1527
- add(makeEntity("fan.air_purifier", "on", {
1528
- percentage: 40,
1529
- preset_modes: [
1530
- "auto",
1531
- "sleep",
1532
- "turbo"
1533
- ],
1534
- preset_mode: "auto",
1535
- oscillating: false
1536
- }), {
1537
- device_id: "purifier",
1538
- area_id: "living_room",
1539
- supported_features: 11
1540
- });
1541
- add(makeEntity("cover.living_room_blinds", "open", {
1542
- current_position: 80,
1543
- device_class: "shade"
1544
- }), {
1545
- device_id: "blinds_lr",
1546
- area_id: "living_room",
1547
- device_class: "shade",
1548
- supported_features: 15
1549
- });
1550
- add(makeEntity("cover.bedroom_curtains", "closed", {
1551
- current_position: 0,
1552
- device_class: "curtain"
1553
- }), {
1554
- device_id: "curtains_br",
1555
- area_id: "bedroom",
1556
- device_class: "curtain",
1557
- supported_features: 15
1558
- });
1559
- add(makeEntity("cover.garage_door", "closed", { device_class: "garage" }), {
1560
- device_id: "garage_door_opener",
1561
- area_id: "garage",
1562
- device_class: "garage",
1563
- supported_features: 11
1564
- });
1565
- add(makeEntity("cover.driveway_gate", "open", {
1566
- device_class: "gate",
1567
- assumed_state: true
1568
- }), {
1569
- area_id: "garage",
1570
- device_class: "gate",
1571
- supported_features: 3
1572
- });
1573
- add(makeEntity("cover.living_room_shutters", "open", {
1574
- current_position: 45,
1575
- current_tilt_position: 30,
1576
- device_class: "shutter"
1577
- }), {
1578
- device_id: "shutters_lr",
1579
- area_id: "living_room",
1580
- device_class: "shutter",
1581
- supported_features: 255
1582
- });
1583
- add(makeEntity("cover.kitchen_blinds", "open", {
1584
- current_position: 100,
1585
- device_class: "blind"
1586
- }), {
1587
- device_id: "blinds_kitchen",
1588
- area_id: "kitchen",
1589
- device_class: "blind",
1590
- supported_features: 127
1591
- });
1592
- add(makeEntity("lock.front_door_lock", "locked", {}), {
1593
- device_id: "lock_front",
1594
- area_id: "entry",
1595
- supported_features: 1
1596
- });
1597
- add(makeEntity("lock.back_door_lock", "locked", {}), {
1598
- device_id: "lock_back",
1599
- area_id: "entry",
1600
- supported_features: 1
1601
- });
1602
- add(makeEntity("switch.coffee_machine", "off", { device_class: "outlet" }), {
1603
- area_id: "kitchen",
1604
- device_class: "outlet"
1605
- });
1606
- add(makeEntity("switch.fan_living_room", "on", { device_class: "switch" }), {
1607
- area_id: "living_room",
1608
- device_class: "switch"
1609
- });
1610
- add(makeEntity("media_player.living_room_speaker", "playing", {
1611
- media_title: "Lo-fi Beats",
1612
- media_artist: "Chill Station",
1613
- media_content_type: "music",
1614
- volume_level: .4,
1615
- is_volume_muted: false,
1616
- supported_features: 152461
1617
- }), {
1618
- device_id: "speaker_lr",
1619
- area_id: "living_room",
1620
- supported_features: 152461
1621
- });
1622
- for (const w of WEATHER_FIXTURES) add(makeWeatherEntity(w), { platform: "met" });
1623
- add(makeEntity("camera.front_door_camera", "streaming", {
1624
- entity_picture: "/api/camera_proxy/camera.front_door_camera",
1625
- frontend_stream_type: "hls",
1626
- access_token: "demo-token"
1627
- }), {
1628
- device_id: "camera_front",
1629
- area_id: "entry",
1630
- supported_features: 2
1631
- });
1632
- add(makeEntity("scene.movie_night", "scening", { entity_id: [
1633
- "light.living_room_main",
1634
- "cover.living_room_blinds",
1635
- "media_player.living_room_speaker"
1636
- ] }), { area_id: "living_room" });
1637
- add(makeEntity("scene.good_morning", "scening", { entity_id: [
1638
- "light.bedroom_ceiling",
1639
- "cover.bedroom_curtains",
1640
- "switch.coffee_machine"
1641
- ] }), {});
1642
- add(makeEntity("button.restart_home_assistant", "unknown", { device_class: "restart" }), {
1643
- entity_category: "config",
1644
- device_class: "restart"
1645
- });
1646
- add(makeEntity("button.update_firmware", "unknown", { device_class: "update" }), {
1647
- entity_category: "config",
1648
- device_class: "update"
1649
- });
1650
- add(makeEntity("sensor.door_lock_battery", "92", {
1651
- unit_of_measurement: "%",
1652
- device_class: "battery",
1653
- state_class: "measurement"
1654
- }), {
1655
- device_id: "lock_front",
1656
- area_id: "entry",
1657
- device_class: "battery",
1658
- unit_of_measurement: "%"
1659
- });
1660
- add(makeEntity("sensor.motion_sensor_battery", "65", {
1661
- unit_of_measurement: "%",
1662
- device_class: "battery",
1663
- state_class: "measurement"
1664
- }), {
1665
- device_id: "motion_sensor",
1666
- area_id: "entry",
1667
- device_class: "battery",
1668
- unit_of_measurement: "%"
1669
- });
1670
- const nowMs = Date.now();
1671
- const sample = simulateEnergy(nowMs);
1672
- const sun = sunEvents(nowMs);
1673
- add(makeEntity("sun.sun", isSunUp(nowMs) ? "above_horizon" : "below_horizon", {
1674
- friendly_name: "Sun",
1675
- elevation: sun.elevation,
1676
- next_rising: sun.nextRising,
1677
- next_setting: sun.nextSetting
1678
- }), {});
1679
- const powerSensors = [
1680
- {
1681
- id: "sensor.solar_power",
1682
- name: "Solar Production",
1683
- deviceClass: "power",
1684
- unit: "W"
1685
- },
1686
- {
1687
- id: "sensor.grid_import_power",
1688
- name: "Grid Import",
1689
- deviceClass: "power",
1690
- unit: "W"
1691
- },
1692
- {
1693
- id: "sensor.grid_export_power",
1694
- name: "Grid Export",
1695
- deviceClass: "power",
1696
- unit: "W"
1697
- },
1698
- {
1699
- id: "sensor.battery_charge_power",
1700
- name: "Battery Charge",
1701
- deviceClass: "power",
1702
- unit: "W"
1703
- },
1704
- {
1705
- id: "sensor.battery_discharge_power",
1706
- name: "Battery Discharge",
1707
- deviceClass: "power",
1708
- unit: "W"
1709
- },
1710
- {
1711
- id: "sensor.battery_soc",
1712
- name: "Battery",
1713
- deviceClass: "battery",
1714
- unit: "%"
1715
- },
1716
- {
1717
- id: "sensor.home_power",
1718
- name: "Home Consumption",
1719
- deviceClass: "power",
1720
- unit: "W"
1721
- },
1722
- {
1723
- id: "sensor.fridge_power",
1724
- name: "Fridge",
1725
- deviceClass: "power",
1726
- unit: "W"
1727
- },
1728
- {
1729
- id: "sensor.dishwasher_power",
1730
- name: "Dishwasher",
1731
- deviceClass: "power",
1732
- unit: "W"
1733
- },
1734
- {
1735
- id: "sensor.washing_machine_power",
1736
- name: "Washing Machine",
1737
- deviceClass: "power",
1738
- unit: "W"
1739
- },
1740
- {
1741
- id: "sensor.oven_power",
1742
- name: "Oven",
1743
- deviceClass: "power",
1744
- unit: "W"
1745
- },
1746
- {
1747
- id: "sensor.ev_charger_power",
1748
- name: "EV Charger",
1749
- deviceClass: "power",
1750
- unit: "W"
1751
- },
1752
- {
1753
- id: "sensor.always_on_power",
1754
- name: "Always On",
1755
- deviceClass: "power",
1756
- unit: "W"
1757
- }
1758
- ];
1759
- for (const s of powerSensors) {
1760
- const value = energyEntityValue(s.id, sample) ?? 0;
1761
- add(makeEntity(s.id, formatEnergyState(s.id, value), {
1762
- friendly_name: s.name,
1763
- unit_of_measurement: s.unit,
1764
- device_class: s.deviceClass,
1765
- state_class: "measurement"
1766
- }), {
1767
- area_id: "utility",
1768
- device_class: s.deviceClass,
1769
- unit_of_measurement: s.unit
1770
- });
1771
- }
1772
- return {
1773
- entities: entities$1,
1774
- entityRegistry,
1775
- areas: areas$1,
1776
- devices
1777
- };
1778
- }
1779
-
1780
- //#endregion
1781
- //#region src/demo/demo-provider.ts
1782
- let _isDemoMode = false;
1783
- /**
1784
- * Whether demo mode is currently active.
1785
- */
1786
- function isDemoMode() {
1787
- return _isDemoMode;
1788
- }
1789
- /**
1790
- * Load demo fixtures into the SolidJS store.
1791
- *
1792
- * Populates entities, entity registry, areas, and devices.
1793
- * Sets connection state to "connected" (pretend connected).
1794
- */
1795
- async function loadDemoData() {
1796
- const fixtures = createDemoFixtures();
1797
- setState("entities", reconcile(fixtures.entities));
1798
- setState("entityRegistry", reconcile(fixtures.entityRegistry));
1799
- setState("areas", reconcile(fixtures.areas));
1800
- setState("devices", reconcile(fixtures.devices));
1801
- setState("hassUrl", "https://demo.home-assistant.local");
1802
- setState("connectionState", "connected");
1803
- _isDemoMode = true;
1804
- startDemoEnergyTicker();
1805
- }
1806
- /**
1807
- * Unload demo data and reset to disconnected state.
1808
- */
1809
- function unloadDemoData() {
1810
- stopDemoEnergyTicker();
1811
- setState("entities", reconcile({}));
1812
- setState("entityRegistry", reconcile({}));
1813
- setState("areas", reconcile({}));
1814
- setState("devices", reconcile({}));
1815
- setState("hassUrl", null);
1816
- setState("connectionState", "disconnected");
1817
- _isDemoMode = false;
1818
- }
1819
- const ENERGY_TICK_MS = 5e3;
1820
- let _energyTicker = null;
1821
- /** Write the current energy sample into the store as string states (like real HA). */
1822
- function tickEnergy() {
1823
- const nowMs = Date.now();
1824
- const sample = simulateEnergy(nowMs);
1825
- const sun = sunEvents(nowMs);
1826
- const nowIso = new Date(nowMs).toISOString();
1827
- setState(produce((s) => {
1828
- for (const entityId of ENERGY_ENTITY_IDS) {
1829
- const e = s.entities[entityId];
1830
- if (!e) continue;
1831
- const value = energyEntityValue(entityId, sample);
1832
- if (value === void 0) continue;
1833
- const next = formatEnergyState(entityId, value);
1834
- if (e.state !== next) e.last_changed = nowIso;
1835
- e.state = next;
1836
- e.last_updated = nowIso;
1837
- }
1838
- const sunEntity = s.entities["sun.sun"];
1839
- if (sunEntity) {
1840
- const nextState = isSunUp(nowMs) ? "above_horizon" : "below_horizon";
1841
- if (sunEntity.state !== nextState) sunEntity.last_changed = nowIso;
1842
- sunEntity.state = nextState;
1843
- sunEntity.last_updated = nowIso;
1844
- sunEntity.attributes.elevation = sun.elevation;
1845
- sunEntity.attributes.next_rising = sun.nextRising;
1846
- sunEntity.attributes.next_setting = sun.nextSetting;
1847
- }
1848
- }));
1849
- }
1850
- /**
1851
- * Start the demo energy ticker. Updates the energy sensors and `sun.sun`
1852
- * every 5 seconds. Idempotent: clears any prior interval first. SSR-safe.
1853
- */
1854
- function startDemoEnergyTicker() {
1855
- stopDemoEnergyTicker();
1856
- if (typeof setInterval !== "function") return;
1857
- tickEnergy();
1858
- _energyTicker = setInterval(tickEnergy, ENERGY_TICK_MS);
1859
- }
1860
- /** Stop the demo energy ticker if running. */
1861
- function stopDemoEnergyTicker() {
1862
- if (_energyTicker !== null) {
1863
- clearInterval(_energyTicker);
1864
- _energyTicker = null;
1865
- }
1866
- }
1867
- /**
1868
- * Apply an optimistic state update for demo mode.
1869
- *
1870
- * When a service call is made in demo mode, this updates the entity
1871
- * state locally (no WebSocket message sent). State resets on page refresh.
1872
- */
1873
- function applyDemoServiceCall(_domain, service, _serviceData = {}, target = {}) {
1874
- if (!_isDemoMode) return;
1875
- const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
1876
- for (const entityId of entityIds) {
1877
- const entity$1 = state.entities[entityId];
1878
- if (!entity$1) continue;
1879
- const entityDomain = extractDomain(entityId);
1880
- setState(produce((s) => {
1881
- const e = s.entities[entityId];
1882
- if (!e) return;
1883
- const now$1 = (/* @__PURE__ */ new Date()).toISOString();
1884
- e.last_changed = now$1;
1885
- e.last_updated = now$1;
1886
- if (service === "turn_on") applyTurnOn(e, entityDomain, _serviceData);
1887
- else if (service === "press" && entityDomain === "button") e.state = now$1;
1888
- else if (service === "turn_off") applyTurnOff(e, entityDomain);
1889
- else if (service === "toggle") if (e.state === "on" || e.state === "playing" || e.state === "open") applyTurnOff(e, entityDomain);
1890
- else applyTurnOn(e, entityDomain, _serviceData);
1891
- else if (service === "lock") e.state = "locked";
1892
- else if (service === "unlock") e.state = "unlocked";
1893
- else if (service === "open_cover") {
1894
- e.state = "open";
1895
- if ("current_position" in e.attributes) e.attributes.current_position = 100;
1896
- } else if (service === "close_cover") {
1897
- e.state = "closed";
1898
- if ("current_position" in e.attributes) e.attributes.current_position = 0;
1899
- } else if (service === "set_cover_position" && _serviceData.position != null) {
1900
- e.attributes.current_position = _serviceData.position;
1901
- e.state = _serviceData.position > 0 ? "open" : "closed";
1902
- } else if (service === "open_cover_tilt") e.attributes.current_tilt_position = 100;
1903
- else if (service === "close_cover_tilt") e.attributes.current_tilt_position = 0;
1904
- else if (service === "set_cover_tilt_position" && _serviceData.tilt_position != null) e.attributes.current_tilt_position = _serviceData.tilt_position;
1905
- else if (service === "set_temperature") {
1906
- if (_serviceData.temperature != null) e.attributes.temperature = _serviceData.temperature;
1907
- if (_serviceData.target_temp_low != null) e.attributes.target_temp_low = _serviceData.target_temp_low;
1908
- if (_serviceData.target_temp_high != null) e.attributes.target_temp_high = _serviceData.target_temp_high;
1909
- } else if (service === "set_hvac_mode" && _serviceData.hvac_mode != null) e.state = _serviceData.hvac_mode;
1910
- else if (service === "set_fan_mode" && _serviceData.fan_mode != null) e.attributes.fan_mode = _serviceData.fan_mode;
1911
- else if (service === "set_preset_mode" && _serviceData.preset_mode != null) {
1912
- e.attributes.preset_mode = _serviceData.preset_mode;
1913
- if (entityDomain === "fan") e.state = "on";
1914
- } else if (service === "set_percentage" && _serviceData.percentage != null) {
1915
- e.attributes.percentage = _serviceData.percentage;
1916
- e.state = _serviceData.percentage > 0 ? "on" : "off";
1917
- } else if (service === "oscillate" && _serviceData.oscillating != null) e.attributes.oscillating = _serviceData.oscillating;
1918
- else if (service === "set_direction" && _serviceData.direction != null) e.attributes.current_direction = _serviceData.direction;
1919
- else if (service === "set_operation_mode" && _serviceData.operation_mode != null) e.state = _serviceData.operation_mode;
1920
- else if (service === "set_away_mode" && _serviceData.away_mode != null) e.attributes.away_mode = _serviceData.away_mode ? "on" : "off";
1921
- else if (service === "volume_set" && _serviceData.volume_level != null) e.attributes.volume_level = _serviceData.volume_level;
1922
- else if (service === "media_play_pause") e.state = e.state === "playing" ? "paused" : "playing";
1923
- else if (service === "media_next_track" || service === "media_previous_track") applyTrackSkip(e, service === "media_next_track" ? 1 : -1);
1924
- else if (service === "select_source" && _serviceData.source != null) e.attributes.source = _serviceData.source;
1925
- }));
1926
- }
1927
- }
1928
- function applyTurnOn(e, domain, serviceData = {}) {
1929
- if (domain === "light") {
1930
- e.state = "on";
1931
- if (serviceData.brightness_pct !== void 0) {
1932
- const pct = Math.max(0, Math.min(100, serviceData.brightness_pct));
1933
- e.attributes.brightness = Math.round(pct / 100 * 255);
1934
- } else e.attributes.brightness = serviceData.brightness ?? e.attributes.brightness ?? 255;
1935
- if (serviceData.color_temp_kelvin) e.attributes.color_temp_kelvin = serviceData.color_temp_kelvin;
1936
- if (serviceData.hs_color) e.attributes.hs_color = serviceData.hs_color;
1937
- } else if (domain === "scene") e.state = (/* @__PURE__ */ new Date()).toISOString();
1938
- else if (domain === "media_player") e.state = "playing";
1939
- else if (domain === "cover") {
1940
- e.state = "open";
1941
- if ("current_position" in e.attributes) e.attributes.current_position = 100;
1942
- } else e.state = "on";
1943
- }
1944
- const DEMO_TRACKS = [
1945
- {
1946
- title: "Lo-fi Beats",
1947
- artist: "Chill Station"
1948
- },
1949
- {
1950
- title: "Golden Hour",
1951
- artist: "Analog Sunset"
1952
- },
1953
- {
1954
- title: "Night Drive",
1955
- artist: "Neon Coast"
1956
- }
1957
- ];
1958
- function applyTrackSkip(e, delta) {
1959
- const current = DEMO_TRACKS.findIndex((t) => t.title === e.attributes.media_title);
1960
- const next = DEMO_TRACKS[(current + delta + DEMO_TRACKS.length) % DEMO_TRACKS.length];
1961
- if (!next) return;
1962
- e.attributes.media_title = next.title;
1963
- e.attributes.media_artist = next.artist;
1964
- }
1965
- function applyTurnOff(e, domain) {
1966
- if (domain === "light") {
1967
- e.state = "off";
1968
- delete e.attributes.brightness;
1969
- } else if (domain === "media_player") e.state = "paused";
1970
- else if (domain === "cover") {
1971
- e.state = "closed";
1972
- if ("current_position" in e.attributes) e.attributes.current_position = 0;
1973
- } else e.state = "off";
1974
- }
1975
-
1976
- //#endregion
1977
- //#region src/commands/service.ts
1978
- /**
1979
- * Call a Home Assistant service
1980
- */
1981
- async function callService(domain, service, serviceData = {}, target = {}) {
1982
- const connection = state.conn;
1983
- if (!connection) {
1984
- applyDemoServiceCall(domain, service, serviceData, target);
1985
- return;
1986
- }
1987
- try {
1988
- const mockConnection = connection;
1989
- if (mockConnection && typeof mockConnection.callService === "function") await mockConnection.callService(domain, service, serviceData, target);
1990
- else await connection.sendMessagePromise({
1991
- type: "call_service",
1992
- domain,
1993
- service,
1994
- service_data: serviceData,
1995
- target
1996
- });
1997
- } catch (error) {
1998
- throw new Error(`Service call failed: ${error.message || String(error)}`);
1999
- }
2000
- }
2001
- /**
2002
- * Turn on entity or entities
2003
- */
2004
- async function turnOn(entityId, serviceData = {}) {
2005
- const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
2006
- if (!firstEntityId) throw new Error("Entity ID is required");
2007
- const domain = firstEntityId.split(".")[0];
2008
- if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
2009
- await callService(domain, "turn_on", serviceData, { entity_id: entityId });
2010
- }
2011
- /**
2012
- * Turn off entity or entities
2013
- */
2014
- async function turnOff(entityId, serviceData = {}) {
2015
- const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
2016
- if (!firstEntityId) throw new Error("Entity ID is required");
2017
- const domain = firstEntityId.split(".")[0];
2018
- if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
2019
- await callService(domain, "turn_off", serviceData, { entity_id: entityId });
2020
- }
2021
- /**
2022
- * Toggle entity or entities
2023
- */
2024
- async function toggle(entityId, serviceData = {}) {
2025
- const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
2026
- if (!firstEntityId) throw new Error("Entity ID is required");
2027
- const domain = firstEntityId.split(".")[0];
2028
- if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
2029
- await callService(domain, "toggle", serviceData, { entity_id: entityId });
2030
- }
2031
- /**
2032
- * Update entity registry entry
2033
- */
2034
- async function updateEntity(entityId, updates) {
2035
- const connection = state.conn;
2036
- if (!connection) throw new Error("Not connected to Home Assistant. Call initConnection() first.");
2037
- try {
2038
- const result = await connection.sendMessagePromise({
2039
- type: "config/entity_registry/update",
2040
- entity_id: entityId,
2041
- ...updates
214
+ }
215
+ }
2042
216
  });
2043
- if (result && typeof result === "object" && "_mockError" in result) throw new Error(`Entity registry entry not found: ${entityId}`);
2044
- return result;
2045
- } catch (error) {
2046
- if (error?.message && error.message.includes("Entity registry entry not found")) throw error;
2047
- throw new Error(`Entity update failed: ${error.message || String(error)}`);
2048
- }
2049
- }
2050
- /**
2051
- * Send a generic WebSocket command
2052
- */
2053
- async function sendCommand(command) {
2054
- const connection = state.conn;
2055
- if (!connection) throw new Error("Not connected");
2056
- try {
2057
- return await connection.sendMessagePromise(command);
2058
- } catch (error) {
2059
- throw new Error(`Command failed: ${error.message || String(error)}`);
2060
- }
2061
- }
2062
- /**
2063
- * Execute multiple service calls in parallel
2064
- */
2065
- async function batchServiceCalls(calls) {
2066
- await Promise.all(calls.map((call) => callService(call.domain, call.service, call.serviceData, call.target)));
2067
- }
2068
- /**
2069
- * Execute multiple entity updates in parallel
2070
- */
2071
- async function batchEntityUpdates(updates) {
2072
- return Promise.all(updates.map((u) => updateEntity(u.entityId, u.updates)));
217
+ return auth$1;
218
+ } catch {}
219
+ let finalAuthCode = authCode;
220
+ if (!finalAuthCode && getAuthCode) finalAuthCode = await getAuthCode();
221
+ const auth = await getAuth({
222
+ hassUrl: url,
223
+ clientId: clientId ?? null,
224
+ redirectUrl: redirectUri,
225
+ authCode: finalAuthCode,
226
+ limitHassInstance,
227
+ async loadTokens() {
228
+ if (loadAuth) {
229
+ const saved = await loadAuth();
230
+ if (saved?.data) return saved.data;
231
+ }
232
+ return null;
233
+ },
234
+ saveTokens(tokens) {
235
+ if (saveAuth && tokens) {
236
+ const authObj = {
237
+ hassUrl: url,
238
+ clientId,
239
+ redirectUrl: redirectUri,
240
+ data: tokens,
241
+ async refreshAccessToken() {
242
+ return Promise.resolve();
243
+ }
244
+ };
245
+ saveAuth(authObj);
246
+ }
247
+ }
248
+ });
249
+ return auth;
2073
250
  }
2074
251
 
2075
252
  //#endregion
@@ -2334,35 +511,35 @@ async function subscribeEntities(connection, entityIds) {
2334
511
  /**
2335
512
  * Subscribe to all relevant events from Home Assistant
2336
513
  */
2337
- async function subscribeToUpdates(conn$1) {
514
+ async function subscribeToUpdates(conn) {
2338
515
  await cleanupSubscriptions();
2339
- setManagerConnection(conn$1);
516
+ setManagerConnection(conn);
2340
517
  setResubscribeHandler(subscribeEntities);
2341
- const registryUpdatedSub = await conn$1.subscribeEvents((event) => {
518
+ const registryUpdatedSub = await conn.subscribeEvents((event) => {
2342
519
  handleRegistryUpdate(event);
2343
520
  }, "entity_registry_updated");
2344
521
  registrySubscriptions.push(() => registryUpdatedSub());
2345
- const deviceRegistrySub = await conn$1.subscribeEvents((event) => {
522
+ const deviceRegistrySub = await conn.subscribeEvents((event) => {
2346
523
  handleDeviceRegistryUpdate(event);
2347
524
  }, "device_registry_updated");
2348
525
  registrySubscriptions.push(() => deviceRegistrySub());
2349
- const areaRegistrySub = await conn$1.subscribeEvents((event) => {
526
+ const areaRegistrySub = await conn.subscribeEvents((event) => {
2350
527
  handleAreaRegistryUpdate(event);
2351
528
  }, "area_registry_updated");
2352
529
  registrySubscriptions.push(() => areaRegistrySub());
2353
- const floorRegistrySub = await conn$1.subscribeEvents((event) => {
530
+ const floorRegistrySub = await conn.subscribeEvents((event) => {
2354
531
  handleFloorRegistryUpdate(event);
2355
532
  }, "floor_registry_updated");
2356
533
  registrySubscriptions.push(() => floorRegistrySub());
2357
- const labelRegistrySub = await conn$1.subscribeEvents((event) => {
534
+ const labelRegistrySub = await conn.subscribeEvents((event) => {
2358
535
  handleLabelRegistryUpdate(event);
2359
536
  }, "label_registry_updated");
2360
537
  registrySubscriptions.push(() => labelRegistrySub());
2361
- const statisticsSub = await conn$1.subscribeEvents((event) => {
538
+ const statisticsSub = await conn.subscribeEvents((event) => {
2362
539
  handleStatisticsUpdate(event);
2363
540
  }, "recorder_5min_statistics_generated");
2364
541
  registrySubscriptions.push(() => statisticsSub());
2365
- const coreConfigSub = await conn$1.subscribeEvents(() => {
542
+ const coreConfigSub = await conn.subscribeEvents(() => {
2366
543
  handleCoreConfigUpdate();
2367
544
  }, "core_config_updated");
2368
545
  registrySubscriptions.push(() => coreConfigSub());
@@ -2376,10 +553,10 @@ async function cleanupSubscriptions() {
2376
553
  registrySubscriptions = [];
2377
554
  }
2378
555
  async function handleCoreConfigUpdate() {
2379
- const conn$1 = state.conn;
2380
- if (!conn$1) return;
556
+ const conn = privilegedConn();
557
+ if (!conn) return;
2381
558
  try {
2382
- const config = await conn$1.sendMessagePromise({ type: "get_config" });
559
+ const config = await conn.sendMessagePromise({ type: "get_config" });
2383
560
  if (config) setState("config", config);
2384
561
  } catch (error) {
2385
562
  console.error("Error refreshing core config:", error);
@@ -2388,10 +565,10 @@ async function handleCoreConfigUpdate() {
2388
565
  async function handleRegistryUpdate(event) {
2389
566
  const { action, entity_id } = event.data;
2390
567
  if (action === "create" || action === "update") {
2391
- const conn$1 = state.conn;
2392
- if (!conn$1) return;
568
+ const conn = privilegedConn();
569
+ if (!conn) return;
2393
570
  try {
2394
- const entry = await conn$1.sendMessagePromise({
571
+ const entry = await conn.sendMessagePromise({
2395
572
  type: "config/entity_registry/get",
2396
573
  entity_id
2397
574
  });
@@ -2404,10 +581,10 @@ async function handleRegistryUpdate(event) {
2404
581
  async function handleDeviceRegistryUpdate(event) {
2405
582
  const { action, device_id } = event.data;
2406
583
  if (action === "create" || action === "update") {
2407
- const conn$1 = state.conn;
2408
- if (!conn$1) return;
584
+ const conn = privilegedConn();
585
+ if (!conn) return;
2409
586
  try {
2410
- const devices = await conn$1.sendMessagePromise({ type: "config/device_registry/list" });
587
+ const devices = await conn.sendMessagePromise({ type: "config/device_registry/list" });
2411
588
  const device = devices.find((d) => d.id === device_id);
2412
589
  if (device) updateDevice(device);
2413
590
  } catch (error) {
@@ -2418,10 +595,10 @@ async function handleDeviceRegistryUpdate(event) {
2418
595
  async function handleAreaRegistryUpdate(event) {
2419
596
  const { action, area_id } = event.data;
2420
597
  if (action === "create" || action === "update") {
2421
- const conn$1 = state.conn;
2422
- if (!conn$1) return;
598
+ const conn = privilegedConn();
599
+ if (!conn) return;
2423
600
  try {
2424
- const areas$1 = await conn$1.sendMessagePromise({ type: "config/area_registry/list" });
601
+ const areas$1 = await conn.sendMessagePromise({ type: "config/area_registry/list" });
2425
602
  const area$1 = areas$1.find((a) => a.id === area_id);
2426
603
  if (area$1) updateArea(area$1);
2427
604
  } catch (error) {
@@ -2432,10 +609,10 @@ async function handleAreaRegistryUpdate(event) {
2432
609
  async function handleFloorRegistryUpdate(event) {
2433
610
  const { action, floor_id } = event.data;
2434
611
  if (action === "create" || action === "update") {
2435
- const conn$1 = state.conn;
2436
- if (!conn$1) return;
612
+ const conn = privilegedConn();
613
+ if (!conn) return;
2437
614
  try {
2438
- const floors = await conn$1.sendMessagePromise({ type: "config/floor_registry/list" });
615
+ const floors = await conn.sendMessagePromise({ type: "config/floor_registry/list" });
2439
616
  const floor = floors.find((f) => f.floor_id === floor_id);
2440
617
  if (floor) updateFloor(floor);
2441
618
  } catch (error) {
@@ -2446,10 +623,10 @@ async function handleFloorRegistryUpdate(event) {
2446
623
  async function handleLabelRegistryUpdate(event) {
2447
624
  const { action, label_id } = event.data;
2448
625
  if (action === "create" || action === "update") {
2449
- const conn$1 = state.conn;
2450
- if (!conn$1) return;
626
+ const conn = privilegedConn();
627
+ if (!conn) return;
2451
628
  try {
2452
- const labels = await conn$1.sendMessagePromise({ type: "config/label_registry/list" });
629
+ const labels = await conn.sendMessagePromise({ type: "config/label_registry/list" });
2453
630
  const label = labels.find((l) => l.label_id === label_id);
2454
631
  if (label) updateLabel(label);
2455
632
  } catch (error) {
@@ -2460,10 +637,10 @@ async function handleLabelRegistryUpdate(event) {
2460
637
  async function handleStatisticsUpdate(event) {
2461
638
  const { statistic_ids } = event.data;
2462
639
  if (!statistic_ids || statistic_ids.length === 0) return;
2463
- const conn$1 = state.conn;
2464
- if (!conn$1) return;
640
+ const conn = privilegedConn();
641
+ if (!conn) return;
2465
642
  try {
2466
- const metadata = await conn$1.sendMessagePromise({
643
+ const metadata = await conn.sendMessagePromise({
2467
644
  type: "recorder/get_statistics_metadata",
2468
645
  statistic_ids
2469
646
  });
@@ -2571,8 +748,6 @@ function applyStateDiff(s, entityId, diff) {
2571
748
 
2572
749
  //#endregion
2573
750
  //#region src/connection/manager.ts
2574
- const HEARTBEAT_INTERVAL_MS = 3e4;
2575
- const HEARTBEAT_TIMEOUT_MS = 15e3;
2576
751
  const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
2577
752
  let activeConn = null;
2578
753
  let heartbeatInterval = null;
@@ -2580,18 +755,6 @@ let suspendTimeout = null;
2580
755
  let visiblePromiseResolve = null;
2581
756
  let visibilityHandler = null;
2582
757
  let pageshowHandler = null;
2583
- function promiseTimeout(ms, promise) {
2584
- return new Promise((resolve, reject) => {
2585
- const timer = setTimeout(() => reject(new Error("Promise timeout")), ms);
2586
- promise.then((value) => {
2587
- clearTimeout(timer);
2588
- resolve(value);
2589
- }, (err) => {
2590
- clearTimeout(timer);
2591
- reject(err);
2592
- });
2593
- });
2594
- }
2595
758
  function clearSuspendTimeout() {
2596
759
  if (suspendTimeout !== null) {
2597
760
  clearTimeout(suspendTimeout);
@@ -2604,45 +767,12 @@ function resolveVisiblePromise() {
2604
767
  visiblePromiseResolve = null;
2605
768
  }
2606
769
  }
2607
- function startHeartbeat(conn$1) {
2608
- stopHeartbeat();
2609
- heartbeatInterval = setInterval(() => {
2610
- promiseTimeout(HEARTBEAT_TIMEOUT_MS, conn$1.ping()).catch(() => {
2611
- conn$1.reconnect();
2612
- });
2613
- }, HEARTBEAT_INTERVAL_MS);
2614
- }
2615
770
  function stopHeartbeat() {
2616
771
  if (heartbeatInterval !== null) {
2617
772
  clearInterval(heartbeatInterval);
2618
773
  heartbeatInterval = null;
2619
774
  }
2620
775
  }
2621
- function setupLifecycleListeners(conn$1) {
2622
- if (typeof document === "undefined" || typeof window === "undefined") return;
2623
- teardownLifecycleListeners();
2624
- visibilityHandler = () => {
2625
- if (document.visibilityState === "hidden") {
2626
- const pending = new Promise((resolve) => {
2627
- visiblePromiseResolve = resolve;
2628
- });
2629
- conn$1.suspendReconnectUntil(pending);
2630
- clearSuspendTimeout();
2631
- suspendTimeout = setTimeout(() => {
2632
- conn$1.suspend();
2633
- suspendTimeout = null;
2634
- }, SUSPEND_DELAY_MS);
2635
- } else {
2636
- clearSuspendTimeout();
2637
- resolveVisiblePromise();
2638
- }
2639
- };
2640
- pageshowHandler = (event) => {
2641
- if (event.persisted) conn$1.reconnect();
2642
- };
2643
- document.addEventListener("visibilitychange", visibilityHandler);
2644
- window.addEventListener("pageshow", pageshowHandler);
2645
- }
2646
776
  function teardownLifecycleListeners() {
2647
777
  if (typeof document !== "undefined" && visibilityHandler) document.removeEventListener("visibilitychange", visibilityHandler);
2648
778
  if (typeof window !== "undefined" && pageshowHandler) window.removeEventListener("pageshow", pageshowHandler);
@@ -2652,47 +782,6 @@ function teardownLifecycleListeners() {
2652
782
  resolveVisiblePromise();
2653
783
  }
2654
784
  /**
2655
- * Initialize connection to Home Assistant
2656
- */
2657
- async function initConnection(options) {
2658
- try {
2659
- setState(produce((s) => {
2660
- s.connectionState = "connecting";
2661
- s.connectionError = null;
2662
- }));
2663
- let auth;
2664
- if (typeof options.auth === "string") auth = authenticateWithToken(options.url, options.auth);
2665
- else if (typeof options.auth === "object" && "clientId" in options.auth) auth = await authenticateWithOAuth(options.auth);
2666
- else auth = options.auth;
2667
- const conn$1 = await createConnection({
2668
- auth,
2669
- ...options.createSocket ? { createSocket: options.createSocket } : {}
2670
- });
2671
- const wrappedConn = wrapHAConnection(conn$1);
2672
- activeConn = conn$1;
2673
- setState(produce((s) => {
2674
- s.conn = conn$1;
2675
- s.hassUrl = options.url.replace(/\/$/, "");
2676
- s.connectionState = "connected";
2677
- }));
2678
- setupEventHandlers(conn$1, options);
2679
- await loadInitialData(conn$1);
2680
- await subscribeToUpdates(wrappedConn);
2681
- setupLifecycleListeners(conn$1);
2682
- startHeartbeat(conn$1);
2683
- options.onConnect?.();
2684
- return conn$1;
2685
- } catch (error) {
2686
- const err = error instanceof Error ? error : new Error(String(error));
2687
- setState(produce((s) => {
2688
- s.connectionState = "error";
2689
- s.connectionError = err;
2690
- }));
2691
- options.onError?.(err);
2692
- throw err;
2693
- }
2694
- }
2695
- /**
2696
785
  * Disconnect from Home Assistant
2697
786
  */
2698
787
  function disconnect() {
@@ -2701,17 +790,11 @@ function disconnect() {
2701
790
  activeConn?.close();
2702
791
  activeConn = null;
2703
792
  setState(produce((s) => {
2704
- s.conn = null;
793
+ setPrivilegedConn(null);
2705
794
  s.connectionState = "disconnected";
2706
795
  }));
2707
796
  }
2708
797
  /**
2709
- * Get current connection link
2710
- */
2711
- function getConnection() {
2712
- return state.conn;
2713
- }
2714
- /**
2715
798
  * Check if currently connected
2716
799
  */
2717
800
  function isConnected() {
@@ -2726,41 +809,16 @@ function isConnected() {
2726
809
  function getConnectionState() {
2727
810
  return activeConn ? activeConn.connected : state.connectionState === "connected";
2728
811
  }
2729
- function setupEventHandlers(conn$1, options) {
2730
- conn$1.addEventListener("disconnected", () => {
2731
- setState(produce((s) => {
2732
- s.connectionState = "disconnected";
2733
- }));
2734
- options.onDisconnect?.();
2735
- });
2736
- conn$1.addEventListener("reconnect-error", () => {
2737
- setState(produce((s) => {
2738
- s.connectionState = "reconnecting";
2739
- }));
2740
- });
2741
- conn$1.addEventListener("ready", async () => {
2742
- const wasReconnecting = state.connectionState === "reconnecting";
2743
- setState(produce((s) => {
2744
- s.connectionState = "connected";
2745
- s.connectionError = null;
2746
- }));
2747
- if (wasReconnecting) {
2748
- await loadInitialData(conn$1);
2749
- await forceResubscribe();
2750
- options.onReconnect?.();
2751
- }
2752
- });
2753
- }
2754
- async function loadInitialData(conn$1) {
812
+ async function loadInitialData(conn) {
2755
813
  try {
2756
814
  const [states, entityRegistry, deviceRegistry, areaRegistry, floorRegistry, labelRegistry, config] = await Promise.all([
2757
- conn$1.sendMessagePromise({ type: "get_states" }),
2758
- conn$1.sendMessagePromise({ type: "config/entity_registry/list" }),
2759
- conn$1.sendMessagePromise({ type: "config/device_registry/list" }),
2760
- conn$1.sendMessagePromise({ type: "config/area_registry/list" }),
2761
- conn$1.sendMessagePromise({ type: "config/floor_registry/list" }),
2762
- conn$1.sendMessagePromise({ type: "config/label_registry/list" }),
2763
- conn$1.sendMessagePromise({ type: "get_config" })
815
+ conn.sendMessagePromise({ type: "get_states" }),
816
+ conn.sendMessagePromise({ type: "config/entity_registry/list" }),
817
+ conn.sendMessagePromise({ type: "config/device_registry/list" }),
818
+ conn.sendMessagePromise({ type: "config/area_registry/list" }),
819
+ conn.sendMessagePromise({ type: "config/floor_registry/list" }),
820
+ conn.sendMessagePromise({ type: "config/label_registry/list" }),
821
+ conn.sendMessagePromise({ type: "get_config" })
2764
822
  ]);
2765
823
  bulkUpdateEntities(states);
2766
824
  bulkUpdateEntityRegistry(entityRegistry);
@@ -2793,229 +851,12 @@ async function loadInitialData(conn$1) {
2793
851
  }
2794
852
  }
2795
853
 
2796
- //#endregion
2797
- //#region src/entities/views.ts
2798
- /**
2799
- * Get icon for an entity from registry or attributes
2800
- */
2801
- function resolveIcon(entity$1, registry) {
2802
- if (registry?.icon) return {
2803
- icon: registry.icon,
2804
- source: "registry"
2805
- };
2806
- if (entity$1.attributes?.icon) return {
2807
- icon: entity$1.attributes.icon,
2808
- source: "attribute"
2809
- };
2810
- return null;
2811
- }
2812
- /**
2813
- * Build an EntityView from runtime state and registry metadata
2814
- */
2815
- function buildEntityView(entity$1, registry) {
2816
- const domain = extractDomain(entity$1.entity_id);
2817
- const attributes = entity$1.attributes ?? {};
2818
- const friendlyName$1 = attributes.friendly_name ?? registry?.name ?? entity$1.entity_id;
2819
- const name = registry?.name ?? friendlyName$1;
2820
- const iconResolution = resolveIcon(entity$1, registry);
2821
- const { device_class: _dc, unit_of_measurement: _uom, friendly_name: _fn, icon: _icon,...restAttributes } = attributes;
2822
- let areaId = registry?.area_id ?? null;
2823
- if (areaId === null && registry?.device_id) {
2824
- const device = state.devices[registry.device_id];
2825
- if (device) areaId = device.area_id ?? null;
2826
- }
2827
- return {
2828
- id: entity$1.entity_id,
2829
- domain,
2830
- state: entity$1.state,
2831
- attributes: restAttributes,
2832
- lastChanged: new Date(entity$1.last_changed),
2833
- lastUpdated: new Date(entity$1.last_updated),
2834
- context: {
2835
- id: entity$1.context?.id ?? "",
2836
- parentId: entity$1.context?.parent_id ?? null,
2837
- userId: entity$1.context?.user_id ?? null
2838
- },
2839
- name,
2840
- friendlyName: friendlyName$1,
2841
- areaId,
2842
- deviceId: registry?.device_id ?? null,
2843
- platform: registry?.platform ?? "unknown",
2844
- uniqueId: registry?.unique_id ?? null,
2845
- isDisabled: registry?.disabled_by !== null && registry?.disabled_by !== void 0,
2846
- isHidden: registry?.hidden_by !== null && registry?.hidden_by !== void 0,
2847
- icon: iconResolution?.icon ?? null,
2848
- iconSource: iconResolution?.source ?? "default",
2849
- entityCategory: registry?.entity_category ?? null,
2850
- labels: registry?.labels ?? [],
2851
- aliases: registry?.aliases ?? [],
2852
- deviceClass: registry?.device_class ?? attributes.device_class ?? null,
2853
- unitOfMeasurement: registry?.unit_of_measurement ?? attributes.unit_of_measurement ?? null,
2854
- supportedFeatures: registry?.supported_features ?? attributes.supported_features
2855
- };
2856
- }
2857
- /**
2858
- * Get or build an EntityView
2859
- */
2860
- function getEntityView(entityId) {
2861
- const entity$1 = state.entities[entityId];
2862
- if (!entity$1) return void 0;
2863
- const registry = state.entityRegistry[entityId];
2864
- return buildEntityView(entity$1, registry);
2865
- }
2866
- /**
2867
- * Get multiple entity views
2868
- */
2869
- function getEntityViews(entityIds) {
2870
- const views = [];
2871
- for (const id of entityIds) {
2872
- const view = getEntityView(id);
2873
- if (view) views.push(view);
2874
- }
2875
- return views;
2876
- }
2877
- /**
2878
- * Get all entity views
2879
- */
2880
- function getAllEntityViews() {
2881
- return getEntityViews(Object.keys(state.entities));
2882
- }
2883
- /**
2884
- * Shallow equality check for EntityView.
2885
- * Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
2886
- */
2887
- function entityViewEquals(a, b) {
2888
- if (a === b) return true;
2889
- if (!a || !b) return false;
2890
- 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;
2891
- }
2892
-
2893
- //#endregion
2894
- //#region src/entities/area-views.ts
2895
- /** The area an entity belongs to: its own `area_id` wins; otherwise it inherits
2896
- * the area of its device. Null when neither resolves. */
2897
- function entityAreaId(entry) {
2898
- if (entry.area_id) return entry.area_id;
2899
- if (entry.device_id) return state.devices[entry.device_id]?.area_id ?? null;
2900
- return null;
2901
- }
2902
- /** One pass over the device + entity registries → membership per area.
2903
- * O(devices + entities) for the whole home, vs the O(areas × (devices +
2904
- * entities)) of resolving each area independently. */
2905
- function computeAreaMembership() {
2906
- const byArea = /* @__PURE__ */ new Map();
2907
- const ensure = (areaId) => {
2908
- let m = byArea.get(areaId);
2909
- if (!m) {
2910
- m = {
2911
- devices: [],
2912
- entityIds: []
2913
- };
2914
- byArea.set(areaId, m);
2915
- }
2916
- return m;
2917
- };
2918
- for (const device of Object.values(state.devices)) if (device.area_id) ensure(device.area_id).devices.push(device);
2919
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) {
2920
- const areaId = entityAreaId(entry);
2921
- if (areaId) ensure(areaId).entityIds.push(entityId);
2922
- }
2923
- return byArea;
2924
- }
2925
- /** Membership for a single area, without indexing every other area. */
2926
- function membershipFor(areaId) {
2927
- const devices = [];
2928
- for (const device of Object.values(state.devices)) if (device.area_id === areaId) devices.push(device);
2929
- const entityIds = [];
2930
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entityAreaId(entry) === areaId) entityIds.push(entityId);
2931
- return {
2932
- devices,
2933
- entityIds
2934
- };
2935
- }
2936
- /**
2937
- * Build a DeviceView from registry data
2938
- */
2939
- function buildDeviceView(device) {
2940
- return {
2941
- id: device.id,
2942
- name: device.name ?? "",
2943
- nameByUser: device.name_by_user ?? null,
2944
- manufacturer: device.manufacturer ?? null,
2945
- model: device.model ?? null,
2946
- swVersion: device.sw_version ?? null,
2947
- areaId: device.area_id ?? null,
2948
- configEntries: device.config_entries ?? [],
2949
- connections: device.connections ?? [],
2950
- identifiers: device.identifiers ?? [],
2951
- disabledBy: device.disabled_by ?? null
2952
- };
2953
- }
2954
- /** Assemble an AreaView from pre-resolved membership. `entities` is a lazy
2955
- * getter: materializing live EntityViews reads entity state, so deferring it
2956
- * keeps callers that only need names/ids/counts off the per-state-tick
2957
- * recompute path. The getter stays reactive — read inside a tracking scope it
2958
- * tracks those entities' state and updates like any other store read. */
2959
- function assembleAreaView(areaId, membership) {
2960
- const area$1 = state.areas[areaId];
2961
- if (!area$1) throw new Error(`Area ${areaId} not found`);
2962
- const devices = membership.devices.map((device) => buildDeviceView(device));
2963
- const entityIds = membership.entityIds;
2964
- return {
2965
- id: areaId,
2966
- name: area$1.name,
2967
- normalizedName: area$1.normalized_name ?? area$1.name.toLowerCase(),
2968
- aliases: Array.isArray(area$1.aliases) ? area$1.aliases : [],
2969
- floorId: area$1.floor_id ?? null,
2970
- icon: area$1.icon ?? null,
2971
- picture: area$1.picture ?? null,
2972
- labels: Array.isArray(area$1.labels) ? area$1.labels : [],
2973
- temperatureEntityId: area$1.temperature_entity_id ?? null,
2974
- humidityEntityId: area$1.humidity_entity_id ?? null,
2975
- createdAt: area$1.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
2976
- modifiedAt: area$1.modified_at ?? (/* @__PURE__ */ new Date()).toISOString(),
2977
- devices,
2978
- entityIds,
2979
- deviceIds: devices.map((d) => d.id),
2980
- get entities() {
2981
- return entityIds.length > 0 ? getEntityViews(entityIds) : [];
2982
- }
2983
- };
2984
- }
2985
- /**
2986
- * Build an AreaView for a single area from registry data.
2987
- */
2988
- function buildAreaView(areaId) {
2989
- return assembleAreaView(areaId, membershipFor(areaId));
2990
- }
2991
- /**
2992
- * Get all area views. Membership is resolved in a single registry pass; entity
2993
- * views stay lazy per area (see `assembleAreaView`).
2994
- */
2995
- function getAreaViews() {
2996
- const membership = computeAreaMembership();
2997
- return Object.keys(state.areas).map((areaId) => assembleAreaView(areaId, membership.get(areaId) ?? {
2998
- devices: [],
2999
- entityIds: []
3000
- }));
3001
- }
3002
- /**
3003
- * Get area view
3004
- */
3005
- function getAreaView(areaId) {
3006
- try {
3007
- return buildAreaView(areaId);
3008
- } catch {
3009
- return void 0;
3010
- }
3011
- }
3012
-
3013
854
  //#endregion
3014
855
  //#region src/query/area-builder.ts
3015
856
  var AreaQueryBuilderImpl = class AreaQueryBuilderImpl {
3016
857
  queryState;
3017
- constructor(initialState$1) {
3018
- this.queryState = initialState$1 ?? {
858
+ constructor(initialState) {
859
+ this.queryState = initialState ?? {
3019
860
  filters: [],
3020
861
  sorts: []
3021
862
  };
@@ -3269,8 +1110,8 @@ function getNestedValue(obj, path) {
3269
1110
  //#region src/query/builder.ts
3270
1111
  var EntitiesQueryBuilderImpl = class EntitiesQueryBuilderImpl {
3271
1112
  queryState;
3272
- constructor(initialState$1) {
3273
- this.queryState = initialState$1 ?? {
1113
+ constructor(initialState) {
1114
+ this.queryState = initialState ?? {
3274
1115
  indexFilters: [],
3275
1116
  predicateFilters: [],
3276
1117
  sorts: [],
@@ -3515,7 +1356,7 @@ const DEMO_HLS_STREAM = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
3515
1356
  */
3516
1357
  async function fetchStream(entityId, format = "hls") {
3517
1358
  if (isDemoMode()) return format === "hls" ? DEMO_HLS_STREAM : null;
3518
- if (!state.conn) return null;
1359
+ if (!privilegedConn()) return null;
3519
1360
  const response = await sendCommand({
3520
1361
  type: "camera/stream",
3521
1362
  entity_id: entityId,
@@ -3695,9 +1536,9 @@ function hassMediaUrl(path) {
3695
1536
  * Get WebRTC client configuration (STUN/TURN servers) from HA
3696
1537
  */
3697
1538
  async function getWebRtcClientConfig(entityId) {
3698
- const conn$1 = state.conn;
3699
- if (!conn$1) throw new Error("No connection available");
3700
- return conn$1.sendMessagePromise({
1539
+ const conn = privilegedConn();
1540
+ if (!conn) throw new Error("No connection available");
1541
+ return conn.sendMessagePromise({
3701
1542
  type: "camera/webrtc/get_client_config",
3702
1543
  entity_id: entityId
3703
1544
  });
@@ -3709,8 +1550,8 @@ async function getWebRtcClientConfig(entityId) {
3709
1550
  * Local ICE candidates should be sent via sendWebRtcCandidate().
3710
1551
  */
3711
1552
  function startWebRtcSession(entityId, sdpOffer, onCandidate) {
3712
- const conn$1 = state.conn;
3713
- if (!conn$1) return Promise.reject(new Error("Not connected"));
1553
+ const conn = privilegedConn();
1554
+ if (!conn) return Promise.reject(new Error("Not connected"));
3714
1555
  return new Promise((resolve, reject) => {
3715
1556
  const session = {
3716
1557
  sessionId: null,
@@ -3723,7 +1564,7 @@ function startWebRtcSession(entityId, sdpOffer, onCandidate) {
3723
1564
  reject(new Error("WebRTC offer timed out"));
3724
1565
  }
3725
1566
  }, 15e3);
3726
- conn$1.subscribeMessage((event) => {
1567
+ conn.subscribeMessage((event) => {
3727
1568
  switch (event.type) {
3728
1569
  case "session":
3729
1570
  session.sessionId = event.session_id ?? null;
@@ -3763,9 +1604,9 @@ function startWebRtcSession(entityId, sdpOffer, onCandidate) {
3763
1604
  * Send a local ICE candidate to HA
3764
1605
  */
3765
1606
  async function sendWebRtcCandidate(entityId, sessionId, candidate) {
3766
- const conn$1 = state.conn;
3767
- if (!conn$1) throw new Error("Not connected");
3768
- await conn$1.sendMessagePromise({
1607
+ const conn = privilegedConn();
1608
+ if (!conn) throw new Error("Not connected");
1609
+ await conn.sendMessagePromise({
3769
1610
  type: "camera/webrtc/candidate",
3770
1611
  entity_id: entityId,
3771
1612
  session_id: sessionId,
@@ -3773,77 +1614,6 @@ async function sendWebRtcCandidate(entityId, sessionId, candidate) {
3773
1614
  });
3774
1615
  }
3775
1616
 
3776
- //#endregion
3777
- //#region src/history/statistics.ts
3778
- const STATISTIC_TYPES = [
3779
- "mean",
3780
- "sum",
3781
- "min",
3782
- "max",
3783
- "state",
3784
- "change"
3785
- ];
3786
- /**
3787
- * HA returns start/end as ISO strings or ms numbers depending on version.
3788
- * Normalize both shapes to ms epoch numbers; unparseable values become NaN.
3789
- */
3790
- function normalizeStatisticTime(value) {
3791
- if (typeof value === "number") return value;
3792
- if (typeof value === "string") return new Date(value).getTime();
3793
- return NaN;
3794
- }
3795
- function normalizeNumber(value) {
3796
- return typeof value === "number" ? value : void 0;
3797
- }
3798
- function normalizeStatisticValue(raw) {
3799
- const value = {
3800
- start: normalizeStatisticTime(raw.start),
3801
- end: normalizeStatisticTime(raw.end)
3802
- };
3803
- const mean = normalizeNumber(raw.mean);
3804
- if (mean !== void 0) value.mean = mean;
3805
- const sum = normalizeNumber(raw.sum);
3806
- if (sum !== void 0) value.sum = sum;
3807
- const min = normalizeNumber(raw.min);
3808
- if (min !== void 0) value.min = min;
3809
- const max = normalizeNumber(raw.max);
3810
- if (max !== void 0) value.max = max;
3811
- const stateVal = normalizeNumber(raw.state);
3812
- if (stateVal !== void 0) value.state = stateVal;
3813
- const change = normalizeNumber(raw.change);
3814
- if (change !== void 0) value.change = change;
3815
- return value;
3816
- }
3817
- /**
3818
- * Fetch long-term statistics for one or more statistic ids during a period.
3819
- *
3820
- * Statistic ids with no data are returned as empty arrays so callers never
3821
- * hit `undefined`.
3822
- */
3823
- async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
3824
- const { startTime, endTime, period } = options;
3825
- if (isDemoMode()) {
3826
- const result$1 = {};
3827
- const statPeriod = period === "hour" || period === "day" ? period : "day";
3828
- const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
3829
- for (const id of statisticIds) result$1[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : [];
3830
- return result$1;
3831
- }
3832
- const params = {
3833
- type: "recorder/statistics_during_period",
3834
- start_time: startTime.toISOString(),
3835
- statistic_ids: statisticIds,
3836
- period,
3837
- types: STATISTIC_TYPES
3838
- };
3839
- if (endTime) params.end_time = endTime.toISOString();
3840
- const response = await connection.sendMessagePromise(params);
3841
- const result = {};
3842
- for (const id of statisticIds) result[id] = [];
3843
- if (response && typeof response === "object") for (const [id, buckets] of Object.entries(response)) result[id] = Array.isArray(buckets) ? buckets.map(normalizeStatisticValue) : [];
3844
- return result;
3845
- }
3846
-
3847
1617
  //#endregion
3848
1618
  //#region src/weather/fetch.ts
3849
1619
  /**
@@ -3891,8 +1661,8 @@ function buildDemoForecast(entityId, type) {
3891
1661
  */
3892
1662
  async function fetchForecast(entityId, type) {
3893
1663
  if (isDemoMode()) return buildDemoForecast(entityId, type);
3894
- const conn$1 = state.conn;
3895
- if (!conn$1) throw new Error("Not connected");
1664
+ const conn = privilegedConn();
1665
+ if (!conn) throw new Error("Not connected");
3896
1666
  return new Promise((resolve, reject) => {
3897
1667
  let resolved = false;
3898
1668
  let unsubscribe = null;
@@ -3902,7 +1672,7 @@ async function fetchForecast(entityId, type) {
3902
1672
  unsubscribe().then(() => reject(new Error("Forecast subscription timeout"))).catch(() => reject(new Error("Forecast subscription timeout")));
3903
1673
  }
3904
1674
  }, 1e4);
3905
- conn$1.subscribeMessage((event) => {
1675
+ conn.subscribeMessage((event) => {
3906
1676
  if (resolved) return;
3907
1677
  if (event && typeof event === "object" && "forecast" in event) {
3908
1678
  resolved = true;
@@ -4101,47 +1871,6 @@ async function refreshForecasts(entityId, types) {
4101
1871
  return getForecasts(entityId, types);
4102
1872
  }
4103
1873
 
4104
- //#endregion
4105
- //#region src/connection/bridged.ts
4106
- /**
4107
- * Wire an HA bridge (worker-backed connection) into the store. The bridge's
4108
- * facade rides the same load/subscribe pipeline as a direct socket; only the
4109
- * lifecycle signals differ — they arrive as worker messages instead of
4110
- * Connection events.
4111
- *
4112
- * Call after bridge.connect() resolved. Returns when initial data is loaded.
4113
- */
4114
- async function attachBridgeToStore(bridge, hassUrl, mediaProxyBase) {
4115
- setState(produce((s) => {
4116
- s.conn = bridge.conn;
4117
- s.hassUrl = hassUrl.replace(/\/$/, "");
4118
- s.mediaProxyBase = mediaProxyBase ?? null;
4119
- s.connectionState = "connected";
4120
- s.connectionError = null;
4121
- }));
4122
- await loadInitialData(bridge.conn);
4123
- await subscribeToUpdates(bridge.conn);
4124
- }
4125
- /** Map worker connection-state messages onto the store. */
4126
- function applyBridgeConnState(state$1) {
4127
- setState(produce((s) => {
4128
- s.connectionState = state$1;
4129
- if (state$1 === "connected") s.connectionError = null;
4130
- }));
4131
- }
4132
- /** Re-sync after the worker reports a completed reconnect. */
4133
- async function reloadAfterBridgeReconnect(bridge) {
4134
- await loadInitialData(bridge.conn);
4135
- await forceResubscribe();
4136
- }
4137
- /** Detach on shutdown; the caller terminates the worker itself. */
4138
- function detachBridgeFromStore() {
4139
- setState(produce((s) => {
4140
- s.conn = null;
4141
- s.connectionState = "disconnected";
4142
- }));
4143
- }
4144
-
4145
1874
  //#endregion
4146
1875
  //#region src/worker/bridge-client.ts
4147
1876
  var BridgeNeedsAuthError = class extends Error {
@@ -4233,7 +1962,7 @@ function createHaBridge(worker, events = {}) {
4233
1962
  });
4234
1963
  };
4235
1964
  }
4236
- const conn$1 = {
1965
+ const conn = {
4237
1966
  connect: () => Promise.resolve(),
4238
1967
  disconnect: () => post({ k: "disconnect" }),
4239
1968
  get connected$() {
@@ -4270,7 +1999,7 @@ function createHaBridge(worker, events = {}) {
4270
1999
  disconnect: () => post({ k: "disconnect" }),
4271
2000
  reconnect: () => post({ k: "reconnect" }),
4272
2001
  clearTokens: () => post({ k: "clear_tokens" }),
4273
- conn: conn$1,
2002
+ conn,
4274
2003
  registerWidget(widgetId, caps) {
4275
2004
  const channel = new MessageChannel();
4276
2005
  post({
@@ -4306,9 +2035,77 @@ function createHaBridge(worker, events = {}) {
4306
2035
  };
4307
2036
  }
4308
2037
 
2038
+ //#endregion
2039
+ //#region src/connection/bridged.ts
2040
+ /**
2041
+ * Wire an HA bridge (worker-backed connection) into the store. The bridge's
2042
+ * facade rides the same load/subscribe pipeline as a direct socket; only the
2043
+ * lifecycle signals differ — they arrive as worker messages instead of
2044
+ * Connection events.
2045
+ *
2046
+ * Call after bridge.connect() resolved. Returns when initial data is loaded.
2047
+ */
2048
+ async function attachBridgeToStore(bridge, hassUrl, mediaProxyBase) {
2049
+ setState(produce((s) => {
2050
+ setPrivilegedConn(bridge.conn);
2051
+ s.hassUrl = hassUrl.replace(/\/$/, "");
2052
+ s.mediaProxyBase = mediaProxyBase ?? null;
2053
+ s.connectionState = "connected";
2054
+ s.connectionError = null;
2055
+ }));
2056
+ await loadInitialData(bridge.conn);
2057
+ await subscribeToUpdates(bridge.conn);
2058
+ }
2059
+ /** Map worker connection-state messages onto the store. */
2060
+ function applyBridgeConnState(state$1) {
2061
+ setState(produce((s) => {
2062
+ s.connectionState = state$1;
2063
+ if (state$1 === "connected") s.connectionError = null;
2064
+ }));
2065
+ }
2066
+ /** Re-sync after the worker reports a completed reconnect. */
2067
+ async function reloadAfterBridgeReconnect(bridge) {
2068
+ await loadInitialData(bridge.conn);
2069
+ await forceResubscribe();
2070
+ }
2071
+ /** Detach on shutdown; the caller terminates the worker itself. */
2072
+ function detachBridgeFromStore() {
2073
+ setState(produce((s) => {
2074
+ setPrivilegedConn(null);
2075
+ s.connectionState = "disconnected";
2076
+ }));
2077
+ }
2078
+
2079
+ //#endregion
2080
+ //#region src/host-api.ts
2081
+ let unclaimed = {
2082
+ attachBridgeToStore,
2083
+ detachBridgeFromStore,
2084
+ applyBridgeConnState,
2085
+ reloadAfterBridgeReconnect,
2086
+ createHaBridge,
2087
+ authenticateWithOAuth,
2088
+ disconnect,
2089
+ loadDemoData,
2090
+ unloadDemoData
2091
+ };
2092
+ /**
2093
+ * Take the host-only surface. Returns it once; null on every later call.
2094
+ *
2095
+ * Call it exactly once, during host boot, and keep the result in a module the
2096
+ * import map does not serve. Calling it from anywhere else is what this guards
2097
+ * against, so a null return means something already claimed it — treat that as
2098
+ * a fatal misconfiguration, not a retry.
2099
+ */
2100
+ function claimHostApi() {
2101
+ const api = unclaimed;
2102
+ unclaimed = null;
2103
+ return api;
2104
+ }
2105
+
4309
2106
  //#endregion
4310
2107
  //#region src/index.ts
4311
2108
  const VERSION = "0.2.1";
4312
2109
 
4313
2110
  //#endregion
4314
- export { BridgeNeedsAuthError, DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyBridgeConnState, applyDemoServiceCall, area, areas, attachBridgeToStore, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, createHaBridge, detachBridgeFromStore, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, hassMediaUrl, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, normalizeStatisticTime, refreshAuth, refreshForecast, refreshForecasts, refreshStream, registerEntity, reloadAfterBridgeReconnect, resetStore, runHaBridgeWorker, searchEntities, sendCommand, sendWebRtcCandidate, setState, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
2111
+ export { BridgeNeedsAuthError, DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, MAX_HISTORY_POINTS, NEED_ATTRIBUTE_DOMAINS, VERSION, appendHistoryPoint, applyDemoServiceCall, area, areas, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, claimHostApi, createDemoFixtures, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEnergyPreferences, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStatisticsDuringPeriod, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnectionState, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, hassMediaUrl, historyStateToTimeline, isConnected, isDemoMode, isHistoryTracked, normalizeStatisticTime, refreshForecast, refreshForecasts, refreshStream, registerEntity, runHaBridgeWorker, searchEntities, sendCommand, sendWebRtcCandidate, startDemoEnergyTicker, startWebRtcSession, state, stopDemoEnergyTicker, toggle, trackEntityHistory, turnOff, turnOn, untrackEntityHistory, updateEntity };