@glasshome/sync-layer 0.4.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/connection/types.d.ts +0 -10
- package/dist/core/privileged-conn.d.ts +29 -0
- package/dist/core/store.d.ts +0 -2
- package/dist/host-api.d.ts +50 -0
- package/dist/index.d.ts +5 -7
- package/dist/index.js +126 -1006
- package/dist/solid/hooks.d.ts +1 -1
- package/dist/solid/index.js +2 -2
- package/dist/{demo-data-CcG2efdQ.js → statistics-C6JQBDVf.js} +755 -6
- package/package.json +2 -7
- package/dist/testing/fixtures.d.ts +0 -161
- package/dist/testing/helpers.d.ts +0 -160
- package/dist/testing/index.d.ts +0 -13
- package/dist/testing/index.js +0 -2067
- package/dist/testing/mock-behaviors/base.d.ts +0 -73
- package/dist/testing/mock-behaviors/climate.d.ts +0 -22
- package/dist/testing/mock-behaviors/cover.d.ts +0 -22
- package/dist/testing/mock-behaviors/index.d.ts +0 -11
- package/dist/testing/mock-behaviors/light.d.ts +0 -20
- package/dist/testing/mock-behaviors/lock.d.ts +0 -20
- package/dist/testing/mock-behaviors/media_player.d.ts +0 -42
- package/dist/testing/mock-behaviors/registry.d.ts +0 -55
- package/dist/testing/mock-behaviors/switch.d.ts +0 -17
- package/dist/testing/mock.d.ts +0 -196
- package/dist/testing/types.d.ts +0 -87
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { createStore, reconcile } from "solid-js/store";
|
|
1
|
+
import { createStore, produce, reconcile } from "solid-js/store";
|
|
2
2
|
|
|
3
3
|
//#region src/core/store.ts
|
|
4
4
|
const initialState = {
|
|
5
|
-
conn: null,
|
|
6
5
|
connectionState: "disconnected",
|
|
7
6
|
connectionError: null,
|
|
8
7
|
hassUrl: null,
|
|
@@ -25,11 +24,111 @@ globalThis.__GH_SYNC_LAYER_STORE__ = true;
|
|
|
25
24
|
const storeTuple = createStore(initialState);
|
|
26
25
|
const state = storeTuple[0];
|
|
27
26
|
const setState = storeTuple[1];
|
|
27
|
+
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/connection/subscription-manager.ts
|
|
30
|
+
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.");
|
|
31
|
+
globalThis.__GH_SYNC_LAYER_SUB_MGR__ = true;
|
|
32
|
+
/** Ref counts — how many consumers need each entity */
|
|
33
|
+
const refCounts = /* @__PURE__ */ new Map();
|
|
34
|
+
/** Current HA subscription unsub function */
|
|
35
|
+
let currentEntityUnsub = null;
|
|
36
|
+
/** Snapshot of entity IDs in the current HA subscription */
|
|
37
|
+
let subscribedIds = /* @__PURE__ */ new Set();
|
|
38
|
+
/** Connection reference for resubscribing */
|
|
39
|
+
let conn = null;
|
|
40
|
+
/** Pending flush timer (microtask for register, timeout for unregister) */
|
|
41
|
+
let flushScheduled = false;
|
|
42
|
+
/** Delayed unregister timeout */
|
|
43
|
+
let unregisterTimeout = null;
|
|
44
|
+
/** Callback invoked on resubscribe — set by subscriptions.ts */
|
|
45
|
+
let onResubscribe = null;
|
|
46
|
+
/** Delay before unregistered entities are actually removed from subscription */
|
|
47
|
+
const UNREGISTER_DELAY_MS = 5e3;
|
|
48
|
+
/** Guard against concurrent flush() calls */
|
|
49
|
+
let flushInFlight = false;
|
|
50
|
+
/**
|
|
51
|
+
* Register an entity for live updates. Returns an unregister function.
|
|
52
|
+
* Call from useEntity/useEntities hooks via onCleanup.
|
|
53
|
+
*/
|
|
54
|
+
function registerEntity(entityId) {
|
|
55
|
+
refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
|
|
56
|
+
scheduleFlush();
|
|
57
|
+
return () => unregisterEntity(entityId);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Set the connection reference used for resubscribing.
|
|
61
|
+
*/
|
|
62
|
+
function setManagerConnection(connection) {
|
|
63
|
+
conn = connection;
|
|
64
|
+
if (refCounts.size > 0) scheduleFlush();
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
68
|
+
* the manager into the actual WS subscription lifecycle.
|
|
69
|
+
*/
|
|
70
|
+
function setResubscribeHandler(handler) {
|
|
71
|
+
onResubscribe = handler;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Force a resubscribe with the current active set.
|
|
75
|
+
* Used on reconnect.
|
|
76
|
+
*/
|
|
77
|
+
async function forceResubscribe() {
|
|
78
|
+
await flush();
|
|
79
|
+
}
|
|
80
|
+
function unregisterEntity(entityId) {
|
|
81
|
+
const count = (refCounts.get(entityId) ?? 1) - 1;
|
|
82
|
+
if (count <= 0) refCounts.delete(entityId);
|
|
83
|
+
else refCounts.set(entityId, count);
|
|
84
|
+
scheduleDelayedFlush();
|
|
85
|
+
}
|
|
86
|
+
function scheduleFlush() {
|
|
87
|
+
if (flushScheduled) return;
|
|
88
|
+
flushScheduled = true;
|
|
89
|
+
queueMicrotask(() => {
|
|
90
|
+
flushScheduled = false;
|
|
91
|
+
flush();
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function scheduleDelayedFlush() {
|
|
95
|
+
if (unregisterTimeout) return;
|
|
96
|
+
unregisterTimeout = setTimeout(() => {
|
|
97
|
+
unregisterTimeout = null;
|
|
98
|
+
flush();
|
|
99
|
+
}, UNREGISTER_DELAY_MS);
|
|
100
|
+
}
|
|
101
|
+
async function flush() {
|
|
102
|
+
if (!conn || !onResubscribe) return;
|
|
103
|
+
if (flushInFlight) return;
|
|
104
|
+
flushInFlight = true;
|
|
105
|
+
try {
|
|
106
|
+
const newIds = new Set(refCounts.keys());
|
|
107
|
+
if (setsEqual(newIds, subscribedIds)) return;
|
|
108
|
+
const oldUnsub = currentEntityUnsub;
|
|
109
|
+
const entityIds = [...newIds];
|
|
110
|
+
currentEntityUnsub = await onResubscribe(conn, entityIds);
|
|
111
|
+
subscribedIds = newIds;
|
|
112
|
+
if (oldUnsub) try {
|
|
113
|
+
await oldUnsub();
|
|
114
|
+
} catch {}
|
|
115
|
+
} finally {
|
|
116
|
+
flushInFlight = false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function setsEqual(a, b) {
|
|
120
|
+
if (a.size !== b.size) return false;
|
|
121
|
+
for (const id of a) if (!b.has(id)) return false;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/core/types.ts
|
|
28
127
|
/**
|
|
29
|
-
*
|
|
128
|
+
* Extract domain from entity ID
|
|
30
129
|
*/
|
|
31
|
-
function
|
|
32
|
-
|
|
130
|
+
function extractDomain(entityId) {
|
|
131
|
+
return entityId.split(".")[0] ?? "";
|
|
33
132
|
}
|
|
34
133
|
|
|
35
134
|
//#endregion
|
|
@@ -982,6 +1081,61 @@ function createDemoFixtures() {
|
|
|
982
1081
|
area_id: "bedroom",
|
|
983
1082
|
supported_features: 385
|
|
984
1083
|
});
|
|
1084
|
+
add(makeEntity("water_heater.boiler", "eco", {
|
|
1085
|
+
temperature: 50,
|
|
1086
|
+
current_temperature: 47.5,
|
|
1087
|
+
operation_list: [
|
|
1088
|
+
"off",
|
|
1089
|
+
"eco",
|
|
1090
|
+
"performance"
|
|
1091
|
+
],
|
|
1092
|
+
min_temp: 43,
|
|
1093
|
+
max_temp: 60,
|
|
1094
|
+
away_mode: "off"
|
|
1095
|
+
}), {
|
|
1096
|
+
device_id: "boiler",
|
|
1097
|
+
area_id: "utility",
|
|
1098
|
+
supported_features: 7
|
|
1099
|
+
});
|
|
1100
|
+
add(makeEntity("water_heater.heat_pump_tank", "heat_pump", {
|
|
1101
|
+
temperature: 55,
|
|
1102
|
+
current_temperature: 52.3,
|
|
1103
|
+
operation_list: [
|
|
1104
|
+
"off",
|
|
1105
|
+
"eco",
|
|
1106
|
+
"heat_pump",
|
|
1107
|
+
"high_demand"
|
|
1108
|
+
],
|
|
1109
|
+
min_temp: 43,
|
|
1110
|
+
max_temp: 60
|
|
1111
|
+
}), {
|
|
1112
|
+
device_id: "hp_tank",
|
|
1113
|
+
area_id: "garage",
|
|
1114
|
+
supported_features: 3
|
|
1115
|
+
});
|
|
1116
|
+
add(makeEntity("fan.bedroom_ceiling", "on", {
|
|
1117
|
+
percentage: 200 / 3,
|
|
1118
|
+
percentage_step: 100 / 3,
|
|
1119
|
+
current_direction: "forward"
|
|
1120
|
+
}), {
|
|
1121
|
+
device_id: "ceiling_fan",
|
|
1122
|
+
area_id: "bedroom",
|
|
1123
|
+
supported_features: 5
|
|
1124
|
+
});
|
|
1125
|
+
add(makeEntity("fan.air_purifier", "on", {
|
|
1126
|
+
percentage: 40,
|
|
1127
|
+
preset_modes: [
|
|
1128
|
+
"auto",
|
|
1129
|
+
"sleep",
|
|
1130
|
+
"turbo"
|
|
1131
|
+
],
|
|
1132
|
+
preset_mode: "auto",
|
|
1133
|
+
oscillating: false
|
|
1134
|
+
}), {
|
|
1135
|
+
device_id: "purifier",
|
|
1136
|
+
area_id: "living_room",
|
|
1137
|
+
supported_features: 11
|
|
1138
|
+
});
|
|
985
1139
|
add(makeEntity("cover.living_room_blinds", "open", {
|
|
986
1140
|
current_position: 80,
|
|
987
1141
|
device_class: "shade"
|
|
@@ -1222,4 +1376,599 @@ function createDemoFixtures() {
|
|
|
1222
1376
|
}
|
|
1223
1377
|
|
|
1224
1378
|
//#endregion
|
|
1225
|
-
|
|
1379
|
+
//#region src/demo/demo-provider.ts
|
|
1380
|
+
let _isDemoMode = false;
|
|
1381
|
+
/**
|
|
1382
|
+
* Whether demo mode is currently active.
|
|
1383
|
+
*/
|
|
1384
|
+
function isDemoMode() {
|
|
1385
|
+
return _isDemoMode;
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Load demo fixtures into the SolidJS store.
|
|
1389
|
+
*
|
|
1390
|
+
* Populates entities, entity registry, areas, and devices.
|
|
1391
|
+
* Sets connection state to "connected" (pretend connected).
|
|
1392
|
+
*/
|
|
1393
|
+
async function loadDemoData() {
|
|
1394
|
+
const fixtures = createDemoFixtures();
|
|
1395
|
+
setState("entities", reconcile(fixtures.entities));
|
|
1396
|
+
setState("entityRegistry", reconcile(fixtures.entityRegistry));
|
|
1397
|
+
setState("areas", reconcile(fixtures.areas));
|
|
1398
|
+
setState("devices", reconcile(fixtures.devices));
|
|
1399
|
+
setState("hassUrl", "https://demo.home-assistant.local");
|
|
1400
|
+
setState("connectionState", "connected");
|
|
1401
|
+
_isDemoMode = true;
|
|
1402
|
+
startDemoEnergyTicker();
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Unload demo data and reset to disconnected state.
|
|
1406
|
+
*/
|
|
1407
|
+
function unloadDemoData() {
|
|
1408
|
+
stopDemoEnergyTicker();
|
|
1409
|
+
setState("entities", reconcile({}));
|
|
1410
|
+
setState("entityRegistry", reconcile({}));
|
|
1411
|
+
setState("areas", reconcile({}));
|
|
1412
|
+
setState("devices", reconcile({}));
|
|
1413
|
+
setState("hassUrl", null);
|
|
1414
|
+
setState("connectionState", "disconnected");
|
|
1415
|
+
_isDemoMode = false;
|
|
1416
|
+
}
|
|
1417
|
+
const ENERGY_TICK_MS = 5e3;
|
|
1418
|
+
let _energyTicker = null;
|
|
1419
|
+
/** Write the current energy sample into the store as string states (like real HA). */
|
|
1420
|
+
function tickEnergy() {
|
|
1421
|
+
const nowMs = Date.now();
|
|
1422
|
+
const sample = simulateEnergy(nowMs);
|
|
1423
|
+
const sun = sunEvents(nowMs);
|
|
1424
|
+
const nowIso = new Date(nowMs).toISOString();
|
|
1425
|
+
setState(produce((s) => {
|
|
1426
|
+
for (const entityId of ENERGY_ENTITY_IDS) {
|
|
1427
|
+
const e = s.entities[entityId];
|
|
1428
|
+
if (!e) continue;
|
|
1429
|
+
const value = energyEntityValue(entityId, sample);
|
|
1430
|
+
if (value === void 0) continue;
|
|
1431
|
+
const next = formatEnergyState(entityId, value);
|
|
1432
|
+
if (e.state !== next) e.last_changed = nowIso;
|
|
1433
|
+
e.state = next;
|
|
1434
|
+
e.last_updated = nowIso;
|
|
1435
|
+
}
|
|
1436
|
+
const sunEntity = s.entities["sun.sun"];
|
|
1437
|
+
if (sunEntity) {
|
|
1438
|
+
const nextState = isSunUp(nowMs) ? "above_horizon" : "below_horizon";
|
|
1439
|
+
if (sunEntity.state !== nextState) sunEntity.last_changed = nowIso;
|
|
1440
|
+
sunEntity.state = nextState;
|
|
1441
|
+
sunEntity.last_updated = nowIso;
|
|
1442
|
+
sunEntity.attributes.elevation = sun.elevation;
|
|
1443
|
+
sunEntity.attributes.next_rising = sun.nextRising;
|
|
1444
|
+
sunEntity.attributes.next_setting = sun.nextSetting;
|
|
1445
|
+
}
|
|
1446
|
+
}));
|
|
1447
|
+
}
|
|
1448
|
+
/**
|
|
1449
|
+
* Start the demo energy ticker. Updates the energy sensors and `sun.sun`
|
|
1450
|
+
* every 5 seconds. Idempotent: clears any prior interval first. SSR-safe.
|
|
1451
|
+
*/
|
|
1452
|
+
function startDemoEnergyTicker() {
|
|
1453
|
+
stopDemoEnergyTicker();
|
|
1454
|
+
if (typeof setInterval !== "function") return;
|
|
1455
|
+
tickEnergy();
|
|
1456
|
+
_energyTicker = setInterval(tickEnergy, ENERGY_TICK_MS);
|
|
1457
|
+
}
|
|
1458
|
+
/** Stop the demo energy ticker if running. */
|
|
1459
|
+
function stopDemoEnergyTicker() {
|
|
1460
|
+
if (_energyTicker !== null) {
|
|
1461
|
+
clearInterval(_energyTicker);
|
|
1462
|
+
_energyTicker = null;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
/**
|
|
1466
|
+
* Apply an optimistic state update for demo mode.
|
|
1467
|
+
*
|
|
1468
|
+
* When a service call is made in demo mode, this updates the entity
|
|
1469
|
+
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
1470
|
+
*/
|
|
1471
|
+
function applyDemoServiceCall(_domain, service, _serviceData = {}, target = {}) {
|
|
1472
|
+
if (!_isDemoMode) return;
|
|
1473
|
+
const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
|
|
1474
|
+
for (const entityId of entityIds) {
|
|
1475
|
+
const entity = state.entities[entityId];
|
|
1476
|
+
if (!entity) continue;
|
|
1477
|
+
const entityDomain = extractDomain(entityId);
|
|
1478
|
+
setState(produce((s) => {
|
|
1479
|
+
const e = s.entities[entityId];
|
|
1480
|
+
if (!e) return;
|
|
1481
|
+
const now$1 = (/* @__PURE__ */ new Date()).toISOString();
|
|
1482
|
+
e.last_changed = now$1;
|
|
1483
|
+
e.last_updated = now$1;
|
|
1484
|
+
if (service === "turn_on") applyTurnOn(e, entityDomain, _serviceData);
|
|
1485
|
+
else if (service === "press" && entityDomain === "button") e.state = now$1;
|
|
1486
|
+
else if (service === "turn_off") applyTurnOff(e, entityDomain);
|
|
1487
|
+
else if (service === "toggle") if (e.state === "on" || e.state === "playing" || e.state === "open") applyTurnOff(e, entityDomain);
|
|
1488
|
+
else applyTurnOn(e, entityDomain, _serviceData);
|
|
1489
|
+
else if (service === "lock") e.state = "locked";
|
|
1490
|
+
else if (service === "unlock") e.state = "unlocked";
|
|
1491
|
+
else if (service === "open_cover") {
|
|
1492
|
+
e.state = "open";
|
|
1493
|
+
if ("current_position" in e.attributes) e.attributes.current_position = 100;
|
|
1494
|
+
} else if (service === "close_cover") {
|
|
1495
|
+
e.state = "closed";
|
|
1496
|
+
if ("current_position" in e.attributes) e.attributes.current_position = 0;
|
|
1497
|
+
} else if (service === "set_cover_position" && _serviceData.position != null) {
|
|
1498
|
+
e.attributes.current_position = _serviceData.position;
|
|
1499
|
+
e.state = _serviceData.position > 0 ? "open" : "closed";
|
|
1500
|
+
} else if (service === "open_cover_tilt") e.attributes.current_tilt_position = 100;
|
|
1501
|
+
else if (service === "close_cover_tilt") e.attributes.current_tilt_position = 0;
|
|
1502
|
+
else if (service === "set_cover_tilt_position" && _serviceData.tilt_position != null) e.attributes.current_tilt_position = _serviceData.tilt_position;
|
|
1503
|
+
else if (service === "set_temperature") {
|
|
1504
|
+
if (_serviceData.temperature != null) e.attributes.temperature = _serviceData.temperature;
|
|
1505
|
+
if (_serviceData.target_temp_low != null) e.attributes.target_temp_low = _serviceData.target_temp_low;
|
|
1506
|
+
if (_serviceData.target_temp_high != null) e.attributes.target_temp_high = _serviceData.target_temp_high;
|
|
1507
|
+
} else if (service === "set_hvac_mode" && _serviceData.hvac_mode != null) e.state = _serviceData.hvac_mode;
|
|
1508
|
+
else if (service === "set_fan_mode" && _serviceData.fan_mode != null) e.attributes.fan_mode = _serviceData.fan_mode;
|
|
1509
|
+
else if (service === "set_preset_mode" && _serviceData.preset_mode != null) {
|
|
1510
|
+
e.attributes.preset_mode = _serviceData.preset_mode;
|
|
1511
|
+
if (entityDomain === "fan") e.state = "on";
|
|
1512
|
+
} else if (service === "set_percentage" && _serviceData.percentage != null) {
|
|
1513
|
+
e.attributes.percentage = _serviceData.percentage;
|
|
1514
|
+
e.state = _serviceData.percentage > 0 ? "on" : "off";
|
|
1515
|
+
} else if (service === "oscillate" && _serviceData.oscillating != null) e.attributes.oscillating = _serviceData.oscillating;
|
|
1516
|
+
else if (service === "set_direction" && _serviceData.direction != null) e.attributes.current_direction = _serviceData.direction;
|
|
1517
|
+
else if (service === "set_operation_mode" && _serviceData.operation_mode != null) e.state = _serviceData.operation_mode;
|
|
1518
|
+
else if (service === "set_away_mode" && _serviceData.away_mode != null) e.attributes.away_mode = _serviceData.away_mode ? "on" : "off";
|
|
1519
|
+
else if (service === "volume_set" && _serviceData.volume_level != null) e.attributes.volume_level = _serviceData.volume_level;
|
|
1520
|
+
else if (service === "media_play_pause") e.state = e.state === "playing" ? "paused" : "playing";
|
|
1521
|
+
else if (service === "media_next_track" || service === "media_previous_track") applyTrackSkip(e, service === "media_next_track" ? 1 : -1);
|
|
1522
|
+
else if (service === "select_source" && _serviceData.source != null) e.attributes.source = _serviceData.source;
|
|
1523
|
+
}));
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
function applyTurnOn(e, domain, serviceData = {}) {
|
|
1527
|
+
if (domain === "light") {
|
|
1528
|
+
e.state = "on";
|
|
1529
|
+
if (serviceData.brightness_pct !== void 0) {
|
|
1530
|
+
const pct = Math.max(0, Math.min(100, serviceData.brightness_pct));
|
|
1531
|
+
e.attributes.brightness = Math.round(pct / 100 * 255);
|
|
1532
|
+
} else e.attributes.brightness = serviceData.brightness ?? e.attributes.brightness ?? 255;
|
|
1533
|
+
if (serviceData.color_temp_kelvin) e.attributes.color_temp_kelvin = serviceData.color_temp_kelvin;
|
|
1534
|
+
if (serviceData.hs_color) e.attributes.hs_color = serviceData.hs_color;
|
|
1535
|
+
} else if (domain === "scene") e.state = (/* @__PURE__ */ new Date()).toISOString();
|
|
1536
|
+
else if (domain === "media_player") e.state = "playing";
|
|
1537
|
+
else if (domain === "cover") {
|
|
1538
|
+
e.state = "open";
|
|
1539
|
+
if ("current_position" in e.attributes) e.attributes.current_position = 100;
|
|
1540
|
+
} else e.state = "on";
|
|
1541
|
+
}
|
|
1542
|
+
const DEMO_TRACKS = [
|
|
1543
|
+
{
|
|
1544
|
+
title: "Lo-fi Beats",
|
|
1545
|
+
artist: "Chill Station"
|
|
1546
|
+
},
|
|
1547
|
+
{
|
|
1548
|
+
title: "Golden Hour",
|
|
1549
|
+
artist: "Analog Sunset"
|
|
1550
|
+
},
|
|
1551
|
+
{
|
|
1552
|
+
title: "Night Drive",
|
|
1553
|
+
artist: "Neon Coast"
|
|
1554
|
+
}
|
|
1555
|
+
];
|
|
1556
|
+
function applyTrackSkip(e, delta) {
|
|
1557
|
+
const current = DEMO_TRACKS.findIndex((t) => t.title === e.attributes.media_title);
|
|
1558
|
+
const next = DEMO_TRACKS[(current + delta + DEMO_TRACKS.length) % DEMO_TRACKS.length];
|
|
1559
|
+
if (!next) return;
|
|
1560
|
+
e.attributes.media_title = next.title;
|
|
1561
|
+
e.attributes.media_artist = next.artist;
|
|
1562
|
+
}
|
|
1563
|
+
function applyTurnOff(e, domain) {
|
|
1564
|
+
if (domain === "light") {
|
|
1565
|
+
e.state = "off";
|
|
1566
|
+
delete e.attributes.brightness;
|
|
1567
|
+
} else if (domain === "media_player") e.state = "paused";
|
|
1568
|
+
else if (domain === "cover") {
|
|
1569
|
+
e.state = "closed";
|
|
1570
|
+
if ("current_position" in e.attributes) e.attributes.current_position = 0;
|
|
1571
|
+
} else e.state = "off";
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
//#endregion
|
|
1575
|
+
//#region src/core/privileged-conn.ts
|
|
1576
|
+
let privileged = null;
|
|
1577
|
+
/** Read the live connection. Null before connect and after disconnect. */
|
|
1578
|
+
function privilegedConn() {
|
|
1579
|
+
return privileged;
|
|
1580
|
+
}
|
|
1581
|
+
/** Set by the connection layer on connect/disconnect. Host-side callers only. */
|
|
1582
|
+
function setPrivilegedConn(conn$1) {
|
|
1583
|
+
privileged = conn$1;
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
//#endregion
|
|
1587
|
+
//#region src/commands/service.ts
|
|
1588
|
+
/**
|
|
1589
|
+
* Call a Home Assistant service
|
|
1590
|
+
*/
|
|
1591
|
+
async function callService(domain, service, serviceData = {}, target = {}) {
|
|
1592
|
+
const connection = privilegedConn();
|
|
1593
|
+
if (!connection) {
|
|
1594
|
+
applyDemoServiceCall(domain, service, serviceData, target);
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
try {
|
|
1598
|
+
const mockConnection = connection;
|
|
1599
|
+
if (mockConnection && typeof mockConnection.callService === "function") await mockConnection.callService(domain, service, serviceData, target);
|
|
1600
|
+
else await connection.sendMessagePromise({
|
|
1601
|
+
type: "call_service",
|
|
1602
|
+
domain,
|
|
1603
|
+
service,
|
|
1604
|
+
service_data: serviceData,
|
|
1605
|
+
target
|
|
1606
|
+
});
|
|
1607
|
+
} catch (error) {
|
|
1608
|
+
throw new Error(`Service call failed: ${error.message || String(error)}`);
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Turn on entity or entities
|
|
1613
|
+
*/
|
|
1614
|
+
async function turnOn(entityId, serviceData = {}) {
|
|
1615
|
+
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
1616
|
+
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
1617
|
+
const domain = firstEntityId.split(".")[0];
|
|
1618
|
+
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
1619
|
+
await callService(domain, "turn_on", serviceData, { entity_id: entityId });
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Turn off entity or entities
|
|
1623
|
+
*/
|
|
1624
|
+
async function turnOff(entityId, serviceData = {}) {
|
|
1625
|
+
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
1626
|
+
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
1627
|
+
const domain = firstEntityId.split(".")[0];
|
|
1628
|
+
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
1629
|
+
await callService(domain, "turn_off", serviceData, { entity_id: entityId });
|
|
1630
|
+
}
|
|
1631
|
+
/**
|
|
1632
|
+
* Toggle entity or entities
|
|
1633
|
+
*/
|
|
1634
|
+
async function toggle(entityId, serviceData = {}) {
|
|
1635
|
+
const firstEntityId = Array.isArray(entityId) ? entityId[0] : entityId;
|
|
1636
|
+
if (!firstEntityId) throw new Error("Entity ID is required");
|
|
1637
|
+
const domain = firstEntityId.split(".")[0];
|
|
1638
|
+
if (!domain) throw new Error(`Invalid entity ID: ${firstEntityId}`);
|
|
1639
|
+
await callService(domain, "toggle", serviceData, { entity_id: entityId });
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Update entity registry entry
|
|
1643
|
+
*/
|
|
1644
|
+
async function updateEntity(entityId, updates) {
|
|
1645
|
+
const connection = privilegedConn();
|
|
1646
|
+
if (!connection) throw new Error("Not connected to Home Assistant. Call initConnection() first.");
|
|
1647
|
+
try {
|
|
1648
|
+
const result = await connection.sendMessagePromise({
|
|
1649
|
+
type: "config/entity_registry/update",
|
|
1650
|
+
entity_id: entityId,
|
|
1651
|
+
...updates
|
|
1652
|
+
});
|
|
1653
|
+
if (result && typeof result === "object" && "_mockError" in result) throw new Error(`Entity registry entry not found: ${entityId}`);
|
|
1654
|
+
return result;
|
|
1655
|
+
} catch (error) {
|
|
1656
|
+
if (error?.message && error.message.includes("Entity registry entry not found")) throw error;
|
|
1657
|
+
throw new Error(`Entity update failed: ${error.message || String(error)}`);
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
* Send a generic WebSocket command
|
|
1662
|
+
*/
|
|
1663
|
+
async function sendCommand(command) {
|
|
1664
|
+
const connection = privilegedConn();
|
|
1665
|
+
if (!connection) throw new Error("Not connected");
|
|
1666
|
+
try {
|
|
1667
|
+
return await connection.sendMessagePromise(command);
|
|
1668
|
+
} catch (error) {
|
|
1669
|
+
throw new Error(`Command failed: ${error.message || String(error)}`);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Execute multiple service calls in parallel
|
|
1674
|
+
*/
|
|
1675
|
+
async function batchServiceCalls(calls) {
|
|
1676
|
+
await Promise.all(calls.map((call) => callService(call.domain, call.service, call.serviceData, call.target)));
|
|
1677
|
+
}
|
|
1678
|
+
/**
|
|
1679
|
+
* Execute multiple entity updates in parallel
|
|
1680
|
+
*/
|
|
1681
|
+
async function batchEntityUpdates(updates) {
|
|
1682
|
+
return Promise.all(updates.map((u) => updateEntity(u.entityId, u.updates)));
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
//#endregion
|
|
1686
|
+
//#region src/entities/views.ts
|
|
1687
|
+
/**
|
|
1688
|
+
* Get icon for an entity from registry or attributes
|
|
1689
|
+
*/
|
|
1690
|
+
function resolveIcon(entity, registry) {
|
|
1691
|
+
if (registry?.icon) return {
|
|
1692
|
+
icon: registry.icon,
|
|
1693
|
+
source: "registry"
|
|
1694
|
+
};
|
|
1695
|
+
if (entity.attributes?.icon) return {
|
|
1696
|
+
icon: entity.attributes.icon,
|
|
1697
|
+
source: "attribute"
|
|
1698
|
+
};
|
|
1699
|
+
return null;
|
|
1700
|
+
}
|
|
1701
|
+
/**
|
|
1702
|
+
* Build an EntityView from runtime state and registry metadata
|
|
1703
|
+
*/
|
|
1704
|
+
function buildEntityView(entity, registry) {
|
|
1705
|
+
const domain = extractDomain(entity.entity_id);
|
|
1706
|
+
const attributes = entity.attributes ?? {};
|
|
1707
|
+
const friendlyName$1 = attributes.friendly_name ?? registry?.name ?? entity.entity_id;
|
|
1708
|
+
const name = registry?.name ?? friendlyName$1;
|
|
1709
|
+
const iconResolution = resolveIcon(entity, registry);
|
|
1710
|
+
const { device_class: _dc, unit_of_measurement: _uom, friendly_name: _fn, icon: _icon,...restAttributes } = attributes;
|
|
1711
|
+
let areaId = registry?.area_id ?? null;
|
|
1712
|
+
if (areaId === null && registry?.device_id) {
|
|
1713
|
+
const device = state.devices[registry.device_id];
|
|
1714
|
+
if (device) areaId = device.area_id ?? null;
|
|
1715
|
+
}
|
|
1716
|
+
return {
|
|
1717
|
+
id: entity.entity_id,
|
|
1718
|
+
domain,
|
|
1719
|
+
state: entity.state,
|
|
1720
|
+
attributes: restAttributes,
|
|
1721
|
+
lastChanged: new Date(entity.last_changed),
|
|
1722
|
+
lastUpdated: new Date(entity.last_updated),
|
|
1723
|
+
context: {
|
|
1724
|
+
id: entity.context?.id ?? "",
|
|
1725
|
+
parentId: entity.context?.parent_id ?? null,
|
|
1726
|
+
userId: entity.context?.user_id ?? null
|
|
1727
|
+
},
|
|
1728
|
+
name,
|
|
1729
|
+
friendlyName: friendlyName$1,
|
|
1730
|
+
areaId,
|
|
1731
|
+
deviceId: registry?.device_id ?? null,
|
|
1732
|
+
platform: registry?.platform ?? "unknown",
|
|
1733
|
+
uniqueId: registry?.unique_id ?? null,
|
|
1734
|
+
isDisabled: registry?.disabled_by !== null && registry?.disabled_by !== void 0,
|
|
1735
|
+
isHidden: registry?.hidden_by !== null && registry?.hidden_by !== void 0,
|
|
1736
|
+
icon: iconResolution?.icon ?? null,
|
|
1737
|
+
iconSource: iconResolution?.source ?? "default",
|
|
1738
|
+
entityCategory: registry?.entity_category ?? null,
|
|
1739
|
+
labels: registry?.labels ?? [],
|
|
1740
|
+
aliases: registry?.aliases ?? [],
|
|
1741
|
+
deviceClass: registry?.device_class ?? attributes.device_class ?? null,
|
|
1742
|
+
unitOfMeasurement: registry?.unit_of_measurement ?? attributes.unit_of_measurement ?? null,
|
|
1743
|
+
supportedFeatures: registry?.supported_features ?? attributes.supported_features
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
/**
|
|
1747
|
+
* Get or build an EntityView
|
|
1748
|
+
*/
|
|
1749
|
+
function getEntityView(entityId) {
|
|
1750
|
+
const entity = state.entities[entityId];
|
|
1751
|
+
if (!entity) return void 0;
|
|
1752
|
+
const registry = state.entityRegistry[entityId];
|
|
1753
|
+
return buildEntityView(entity, registry);
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* Get multiple entity views
|
|
1757
|
+
*/
|
|
1758
|
+
function getEntityViews(entityIds) {
|
|
1759
|
+
const views = [];
|
|
1760
|
+
for (const id of entityIds) {
|
|
1761
|
+
const view = getEntityView(id);
|
|
1762
|
+
if (view) views.push(view);
|
|
1763
|
+
}
|
|
1764
|
+
return views;
|
|
1765
|
+
}
|
|
1766
|
+
/**
|
|
1767
|
+
* Get all entity views
|
|
1768
|
+
*/
|
|
1769
|
+
function getAllEntityViews() {
|
|
1770
|
+
return getEntityViews(Object.keys(state.entities));
|
|
1771
|
+
}
|
|
1772
|
+
/**
|
|
1773
|
+
* Shallow equality check for EntityView.
|
|
1774
|
+
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
1775
|
+
*/
|
|
1776
|
+
function entityViewEquals(a, b) {
|
|
1777
|
+
if (a === b) return true;
|
|
1778
|
+
if (!a || !b) return false;
|
|
1779
|
+
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;
|
|
1780
|
+
}
|
|
1781
|
+
|
|
1782
|
+
//#endregion
|
|
1783
|
+
//#region src/entities/area-views.ts
|
|
1784
|
+
/** The area an entity belongs to: its own `area_id` wins; otherwise it inherits
|
|
1785
|
+
* the area of its device. Null when neither resolves. */
|
|
1786
|
+
function entityAreaId(entry) {
|
|
1787
|
+
if (entry.area_id) return entry.area_id;
|
|
1788
|
+
if (entry.device_id) return state.devices[entry.device_id]?.area_id ?? null;
|
|
1789
|
+
return null;
|
|
1790
|
+
}
|
|
1791
|
+
/** One pass over the device + entity registries → membership per area.
|
|
1792
|
+
* O(devices + entities) for the whole home, vs the O(areas × (devices +
|
|
1793
|
+
* entities)) of resolving each area independently. */
|
|
1794
|
+
function computeAreaMembership() {
|
|
1795
|
+
const byArea = /* @__PURE__ */ new Map();
|
|
1796
|
+
const ensure = (areaId) => {
|
|
1797
|
+
let m = byArea.get(areaId);
|
|
1798
|
+
if (!m) {
|
|
1799
|
+
m = {
|
|
1800
|
+
devices: [],
|
|
1801
|
+
entityIds: []
|
|
1802
|
+
};
|
|
1803
|
+
byArea.set(areaId, m);
|
|
1804
|
+
}
|
|
1805
|
+
return m;
|
|
1806
|
+
};
|
|
1807
|
+
for (const device of Object.values(state.devices)) if (device.area_id) ensure(device.area_id).devices.push(device);
|
|
1808
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) {
|
|
1809
|
+
const areaId = entityAreaId(entry);
|
|
1810
|
+
if (areaId) ensure(areaId).entityIds.push(entityId);
|
|
1811
|
+
}
|
|
1812
|
+
return byArea;
|
|
1813
|
+
}
|
|
1814
|
+
/** Membership for a single area, without indexing every other area. */
|
|
1815
|
+
function membershipFor(areaId) {
|
|
1816
|
+
const devices = [];
|
|
1817
|
+
for (const device of Object.values(state.devices)) if (device.area_id === areaId) devices.push(device);
|
|
1818
|
+
const entityIds = [];
|
|
1819
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entityAreaId(entry) === areaId) entityIds.push(entityId);
|
|
1820
|
+
return {
|
|
1821
|
+
devices,
|
|
1822
|
+
entityIds
|
|
1823
|
+
};
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* Build a DeviceView from registry data
|
|
1827
|
+
*/
|
|
1828
|
+
function buildDeviceView(device) {
|
|
1829
|
+
return {
|
|
1830
|
+
id: device.id,
|
|
1831
|
+
name: device.name ?? "",
|
|
1832
|
+
nameByUser: device.name_by_user ?? null,
|
|
1833
|
+
manufacturer: device.manufacturer ?? null,
|
|
1834
|
+
model: device.model ?? null,
|
|
1835
|
+
swVersion: device.sw_version ?? null,
|
|
1836
|
+
areaId: device.area_id ?? null,
|
|
1837
|
+
configEntries: device.config_entries ?? [],
|
|
1838
|
+
connections: device.connections ?? [],
|
|
1839
|
+
identifiers: device.identifiers ?? [],
|
|
1840
|
+
disabledBy: device.disabled_by ?? null
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
/** Assemble an AreaView from pre-resolved membership. `entities` is a lazy
|
|
1844
|
+
* getter: materializing live EntityViews reads entity state, so deferring it
|
|
1845
|
+
* keeps callers that only need names/ids/counts off the per-state-tick
|
|
1846
|
+
* recompute path. The getter stays reactive — read inside a tracking scope it
|
|
1847
|
+
* tracks those entities' state and updates like any other store read. */
|
|
1848
|
+
function assembleAreaView(areaId, membership) {
|
|
1849
|
+
const area = state.areas[areaId];
|
|
1850
|
+
if (!area) throw new Error(`Area ${areaId} not found`);
|
|
1851
|
+
const devices = membership.devices.map((device) => buildDeviceView(device));
|
|
1852
|
+
const entityIds = membership.entityIds;
|
|
1853
|
+
return {
|
|
1854
|
+
id: areaId,
|
|
1855
|
+
name: area.name,
|
|
1856
|
+
normalizedName: area.normalized_name ?? area.name.toLowerCase(),
|
|
1857
|
+
aliases: Array.isArray(area.aliases) ? area.aliases : [],
|
|
1858
|
+
floorId: area.floor_id ?? null,
|
|
1859
|
+
icon: area.icon ?? null,
|
|
1860
|
+
picture: area.picture ?? null,
|
|
1861
|
+
labels: Array.isArray(area.labels) ? area.labels : [],
|
|
1862
|
+
temperatureEntityId: area.temperature_entity_id ?? null,
|
|
1863
|
+
humidityEntityId: area.humidity_entity_id ?? null,
|
|
1864
|
+
createdAt: area.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1865
|
+
modifiedAt: area.modified_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1866
|
+
devices,
|
|
1867
|
+
entityIds,
|
|
1868
|
+
deviceIds: devices.map((d) => d.id),
|
|
1869
|
+
get entities() {
|
|
1870
|
+
return entityIds.length > 0 ? getEntityViews(entityIds) : [];
|
|
1871
|
+
}
|
|
1872
|
+
};
|
|
1873
|
+
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Build an AreaView for a single area from registry data.
|
|
1876
|
+
*/
|
|
1877
|
+
function buildAreaView(areaId) {
|
|
1878
|
+
return assembleAreaView(areaId, membershipFor(areaId));
|
|
1879
|
+
}
|
|
1880
|
+
/**
|
|
1881
|
+
* Get all area views. Membership is resolved in a single registry pass; entity
|
|
1882
|
+
* views stay lazy per area (see `assembleAreaView`).
|
|
1883
|
+
*/
|
|
1884
|
+
function getAreaViews() {
|
|
1885
|
+
const membership = computeAreaMembership();
|
|
1886
|
+
return Object.keys(state.areas).map((areaId) => assembleAreaView(areaId, membership.get(areaId) ?? {
|
|
1887
|
+
devices: [],
|
|
1888
|
+
entityIds: []
|
|
1889
|
+
}));
|
|
1890
|
+
}
|
|
1891
|
+
/**
|
|
1892
|
+
* Get area view
|
|
1893
|
+
*/
|
|
1894
|
+
function getAreaView(areaId) {
|
|
1895
|
+
try {
|
|
1896
|
+
return buildAreaView(areaId);
|
|
1897
|
+
} catch {
|
|
1898
|
+
return void 0;
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
//#endregion
|
|
1903
|
+
//#region src/history/statistics.ts
|
|
1904
|
+
const STATISTIC_TYPES = [
|
|
1905
|
+
"mean",
|
|
1906
|
+
"sum",
|
|
1907
|
+
"min",
|
|
1908
|
+
"max",
|
|
1909
|
+
"state",
|
|
1910
|
+
"change"
|
|
1911
|
+
];
|
|
1912
|
+
/**
|
|
1913
|
+
* HA returns start/end as ISO strings or ms numbers depending on version.
|
|
1914
|
+
* Normalize both shapes to ms epoch numbers; unparseable values become NaN.
|
|
1915
|
+
*/
|
|
1916
|
+
function normalizeStatisticTime(value) {
|
|
1917
|
+
if (typeof value === "number") return value;
|
|
1918
|
+
if (typeof value === "string") return new Date(value).getTime();
|
|
1919
|
+
return NaN;
|
|
1920
|
+
}
|
|
1921
|
+
function normalizeNumber(value) {
|
|
1922
|
+
return typeof value === "number" ? value : void 0;
|
|
1923
|
+
}
|
|
1924
|
+
function normalizeStatisticValue(raw) {
|
|
1925
|
+
const value = {
|
|
1926
|
+
start: normalizeStatisticTime(raw.start),
|
|
1927
|
+
end: normalizeStatisticTime(raw.end)
|
|
1928
|
+
};
|
|
1929
|
+
const mean = normalizeNumber(raw.mean);
|
|
1930
|
+
if (mean !== void 0) value.mean = mean;
|
|
1931
|
+
const sum = normalizeNumber(raw.sum);
|
|
1932
|
+
if (sum !== void 0) value.sum = sum;
|
|
1933
|
+
const min = normalizeNumber(raw.min);
|
|
1934
|
+
if (min !== void 0) value.min = min;
|
|
1935
|
+
const max = normalizeNumber(raw.max);
|
|
1936
|
+
if (max !== void 0) value.max = max;
|
|
1937
|
+
const stateVal = normalizeNumber(raw.state);
|
|
1938
|
+
if (stateVal !== void 0) value.state = stateVal;
|
|
1939
|
+
const change = normalizeNumber(raw.change);
|
|
1940
|
+
if (change !== void 0) value.change = change;
|
|
1941
|
+
return value;
|
|
1942
|
+
}
|
|
1943
|
+
/**
|
|
1944
|
+
* Fetch long-term statistics for one or more statistic ids during a period.
|
|
1945
|
+
*
|
|
1946
|
+
* Statistic ids with no data are returned as empty arrays so callers never
|
|
1947
|
+
* hit `undefined`.
|
|
1948
|
+
*/
|
|
1949
|
+
async function fetchStatisticsDuringPeriod(connection, statisticIds, options) {
|
|
1950
|
+
const { startTime, endTime, period } = options;
|
|
1951
|
+
if (isDemoMode()) {
|
|
1952
|
+
const result$1 = {};
|
|
1953
|
+
const statPeriod = period === "hour" || period === "day" ? period : "day";
|
|
1954
|
+
const endMs = (endTime ?? /* @__PURE__ */ new Date()).getTime();
|
|
1955
|
+
for (const id of statisticIds) result$1[id] = isEnergyEntity(id) ? synthesizeEnergyStatistics(id, startTime.getTime(), endMs, statPeriod) : [];
|
|
1956
|
+
return result$1;
|
|
1957
|
+
}
|
|
1958
|
+
const params = {
|
|
1959
|
+
type: "recorder/statistics_during_period",
|
|
1960
|
+
start_time: startTime.toISOString(),
|
|
1961
|
+
statistic_ids: statisticIds,
|
|
1962
|
+
period,
|
|
1963
|
+
types: STATISTIC_TYPES
|
|
1964
|
+
};
|
|
1965
|
+
if (endTime) params.end_time = endTime.toISOString();
|
|
1966
|
+
const response = await connection.sendMessagePromise(params);
|
|
1967
|
+
const result = {};
|
|
1968
|
+
for (const id of statisticIds) result[id] = [];
|
|
1969
|
+
if (response && typeof response === "object") for (const [id, buckets] of Object.entries(response)) result[id] = Array.isArray(buckets) ? buckets.map(normalizeStatisticValue) : [];
|
|
1970
|
+
return result;
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
//#endregion
|
|
1974
|
+
export { 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 };
|