@glasshome/sync-layer 0.3.0 → 0.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/demo/energy-sim.d.ts +6 -0
- package/dist/{demo-data-DiGSP4EH.js → demo-data-3LO-p1RV.js} +20 -2
- package/dist/entities/area-views.d.ts +11 -4
- package/dist/index.js +68 -25
- package/dist/solid/index.js +3 -11
- package/dist/testing/index.js +1 -1
- package/dist/worker/worker-main.js +1 -1
- package/dist/{worker-main-BYGgbA7k.js → worker-main-DTbzEKor.js} +17 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Type-safe, reactive state synchronization layer for Home Assistant.
|
|
4
4
|
|
|
5
|
-
Provides connection management, entity querying, service calls, and reactive state via a SolidJS store — all with full TypeScript types powered by `@glasshome/ha-types`.
|
|
5
|
+
Provides connection management, entity querying, service calls, and reactive state via a SolidJS store — all with full TypeScript types powered by `@glasshome/ha-types`. Part of [GlassHome](https://glasshome.app), the local-first dashboard for Home Assistant.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
@@ -89,6 +89,12 @@ export interface DemoStatisticBucket {
|
|
|
89
89
|
max: number;
|
|
90
90
|
/** Energy in Wh accumulated over the bucket (W-h integration). */
|
|
91
91
|
sum: number;
|
|
92
|
+
/**
|
|
93
|
+
* Energy added during this bucket, in kWh, matching HA's `change` field for
|
|
94
|
+
* an energy (kWh) sensor. This is the per-bucket delta (not cumulative), the
|
|
95
|
+
* value daily-total consumers read.
|
|
96
|
+
*/
|
|
97
|
+
change: number;
|
|
92
98
|
}
|
|
93
99
|
/**
|
|
94
100
|
* Synthesize statistics buckets for one energy entity across a window.
|
|
@@ -160,6 +160,23 @@ function evChargerPower(lt) {
|
|
|
160
160
|
const ramp = into < 2 ? into / 2 : 1;
|
|
161
161
|
return 7200 * ramp;
|
|
162
162
|
}
|
|
163
|
+
const HEAT_PUMP_PEAK_W = 3200;
|
|
164
|
+
/**
|
|
165
|
+
* A smooth heat-pump load: a slow morning/evening envelope carrying a
|
|
166
|
+
* few-minute shimmer. Its peaks push the home deficit past the battery's power
|
|
167
|
+
* cap, so grid import (and the running cost) visibly rise and fall on live
|
|
168
|
+
* ticks instead of sitting at zero. Gated out of the deep-night window so the
|
|
169
|
+
* quiet 02:00-03:00 baseline is unchanged.
|
|
170
|
+
*/
|
|
171
|
+
function heatPumpPower(lt) {
|
|
172
|
+
const h = lt.hourFrac;
|
|
173
|
+
let envelope = 0;
|
|
174
|
+
if (h >= 6 && h <= 9) envelope = Math.sin((h - 6) / 3 * Math.PI);
|
|
175
|
+
else if (h >= 16 && h <= 23) envelope = Math.sin((h - 16) / 7 * Math.PI);
|
|
176
|
+
else return 0;
|
|
177
|
+
const shimmer = .7 + .3 * Math.sin(lt.minuteOfDay * Math.PI * 2);
|
|
178
|
+
return HEAT_PUMP_PEAK_W * envelope * shimmer;
|
|
179
|
+
}
|
|
163
180
|
/** Smooth occupancy envelope: extra load mornings and evenings. */
|
|
164
181
|
function occupancyEnvelope(lt) {
|
|
165
182
|
const h = lt.hourFrac;
|
|
@@ -228,7 +245,7 @@ function batterySoc(lt, midnightMs) {
|
|
|
228
245
|
return a + (b - a) * frac;
|
|
229
246
|
}
|
|
230
247
|
function homePower(lt) {
|
|
231
|
-
const base = ALWAYS_ON_W + fridgePower(lt) + kettlePower(lt) + ovenPower(lt) + dishwasherPower(lt) + washerPower(lt) + evChargerPower(lt) + occupancyEnvelope(lt);
|
|
248
|
+
const base = ALWAYS_ON_W + fridgePower(lt) + kettlePower(lt) + ovenPower(lt) + dishwasherPower(lt) + washerPower(lt) + evChargerPower(lt) + heatPumpPower(lt) + occupancyEnvelope(lt);
|
|
232
249
|
return base * homeNoise(lt);
|
|
233
250
|
}
|
|
234
251
|
/**
|
|
@@ -416,7 +433,8 @@ function synthesizeEnergyStatistics(entityId, startMs, endMs, period) {
|
|
|
416
433
|
mean,
|
|
417
434
|
min: min === Number.POSITIVE_INFINITY ? mean : min,
|
|
418
435
|
max: max === Number.NEGATIVE_INFINITY ? mean : max,
|
|
419
|
-
sum: cumulativeWh
|
|
436
|
+
sum: cumulativeWh,
|
|
437
|
+
change: energyWh / 1e3
|
|
420
438
|
});
|
|
421
439
|
}
|
|
422
440
|
return buckets;
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Area View Builder
|
|
3
3
|
*
|
|
4
|
-
* Constructs unified AreaView objects by combining registry data
|
|
5
|
-
*
|
|
4
|
+
* Constructs unified AreaView objects by combining registry data with computed
|
|
5
|
+
* entities and devices.
|
|
6
|
+
*
|
|
7
|
+
* Membership (which entities/devices belong to an area) is registry-driven and
|
|
8
|
+
* cheap; live entity *views* read entity state and are therefore exposed as a
|
|
9
|
+
* lazy `entities` getter. Callers that only need names, ids, or counts never
|
|
10
|
+
* touch entity state, so building the whole-area list does not recompute on
|
|
11
|
+
* every entity state tick.
|
|
6
12
|
*
|
|
7
13
|
* @packageDocumentation
|
|
8
14
|
*/
|
|
@@ -12,11 +18,12 @@ import type { AreaId, AreaView, DeviceRegistryEntry, DeviceView } from "../core/
|
|
|
12
18
|
*/
|
|
13
19
|
export declare function buildDeviceView(device: DeviceRegistryEntry): DeviceView;
|
|
14
20
|
/**
|
|
15
|
-
* Build an AreaView from registry data
|
|
21
|
+
* Build an AreaView for a single area from registry data.
|
|
16
22
|
*/
|
|
17
23
|
export declare function buildAreaView(areaId: AreaId): AreaView;
|
|
18
24
|
/**
|
|
19
|
-
* Get all area views
|
|
25
|
+
* Get all area views. Membership is resolved in a single registry pass; entity
|
|
26
|
+
* views stay lazy per area (see `assembleAreaView`).
|
|
20
27
|
*/
|
|
21
28
|
export declare function getAreaViews(): AreaView[];
|
|
22
29
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { ENERGY_ENTITY_IDS, createDemoFixtures, demoEnergyPreferences, energyEntityValue, formatEnergyState, isEnergyEntity, isSunUp, resetStore, setState, simulateEnergy, state, sunEvents, synthesizeEnergyHistory, synthesizeEnergyStatistics } from "./demo-data-
|
|
2
|
-
import { runHaBridgeWorker } from "./worker-main-
|
|
1
|
+
import { ENERGY_ENTITY_IDS, createDemoFixtures, demoEnergyPreferences, energyEntityValue, formatEnergyState, isEnergyEntity, isSunUp, resetStore, setState, simulateEnergy, state, sunEvents, synthesizeEnergyHistory, synthesizeEnergyStatistics } from "./demo-data-3LO-p1RV.js";
|
|
2
|
+
import { runHaBridgeWorker } from "./worker-main-DTbzEKor.js";
|
|
3
3
|
import { createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
|
|
4
4
|
import { produce, reconcile } from "solid-js/store";
|
|
5
5
|
|
|
@@ -1568,16 +1568,46 @@ function entityViewEquals(a, b) {
|
|
|
1568
1568
|
|
|
1569
1569
|
//#endregion
|
|
1570
1570
|
//#region src/entities/area-views.ts
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
return
|
|
1571
|
+
/** The area an entity belongs to: its own `area_id` wins; otherwise it inherits
|
|
1572
|
+
* the area of its device. Null when neither resolves. */
|
|
1573
|
+
function entityAreaId(entry) {
|
|
1574
|
+
if (entry.area_id) return entry.area_id;
|
|
1575
|
+
if (entry.device_id) return state.devices[entry.device_id]?.area_id ?? null;
|
|
1576
|
+
return null;
|
|
1577
|
+
}
|
|
1578
|
+
/** One pass over the device + entity registries → membership per area.
|
|
1579
|
+
* O(devices + entities) for the whole home, vs the O(areas × (devices +
|
|
1580
|
+
* entities)) of resolving each area independently. */
|
|
1581
|
+
function computeAreaMembership() {
|
|
1582
|
+
const byArea = /* @__PURE__ */ new Map();
|
|
1583
|
+
const ensure = (areaId) => {
|
|
1584
|
+
let m = byArea.get(areaId);
|
|
1585
|
+
if (!m) {
|
|
1586
|
+
m = {
|
|
1587
|
+
devices: [],
|
|
1588
|
+
entityIds: []
|
|
1589
|
+
};
|
|
1590
|
+
byArea.set(areaId, m);
|
|
1591
|
+
}
|
|
1592
|
+
return m;
|
|
1593
|
+
};
|
|
1594
|
+
for (const device of Object.values(state.devices)) if (device.area_id) ensure(device.area_id).devices.push(device);
|
|
1595
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) {
|
|
1596
|
+
const areaId = entityAreaId(entry);
|
|
1597
|
+
if (areaId) ensure(areaId).entityIds.push(entityId);
|
|
1598
|
+
}
|
|
1599
|
+
return byArea;
|
|
1575
1600
|
}
|
|
1576
|
-
|
|
1601
|
+
/** Membership for a single area, without indexing every other area. */
|
|
1602
|
+
function membershipFor(areaId) {
|
|
1603
|
+
const devices = [];
|
|
1604
|
+
for (const device of Object.values(state.devices)) if (device.area_id === areaId) devices.push(device);
|
|
1577
1605
|
const entityIds = [];
|
|
1578
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry
|
|
1579
|
-
|
|
1580
|
-
|
|
1606
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entityAreaId(entry) === areaId) entityIds.push(entityId);
|
|
1607
|
+
return {
|
|
1608
|
+
devices,
|
|
1609
|
+
entityIds
|
|
1610
|
+
};
|
|
1581
1611
|
}
|
|
1582
1612
|
/**
|
|
1583
1613
|
* Build a DeviceView from registry data
|
|
@@ -1597,17 +1627,16 @@ function buildDeviceView(device) {
|
|
|
1597
1627
|
disabledBy: device.disabled_by ?? null
|
|
1598
1628
|
};
|
|
1599
1629
|
}
|
|
1600
|
-
/**
|
|
1601
|
-
*
|
|
1602
|
-
|
|
1603
|
-
|
|
1630
|
+
/** Assemble an AreaView from pre-resolved membership. `entities` is a lazy
|
|
1631
|
+
* getter: materializing live EntityViews reads entity state, so deferring it
|
|
1632
|
+
* keeps callers that only need names/ids/counts off the per-state-tick
|
|
1633
|
+
* recompute path. The getter stays reactive — read inside a tracking scope it
|
|
1634
|
+
* tracks those entities' state and updates like any other store read. */
|
|
1635
|
+
function assembleAreaView(areaId, membership) {
|
|
1604
1636
|
const area$1 = state.areas[areaId];
|
|
1605
1637
|
if (!area$1) throw new Error(`Area ${areaId} not found`);
|
|
1606
|
-
const
|
|
1607
|
-
const
|
|
1608
|
-
const allEntityIds = getEntityIdsForArea(areaId, deviceIdsInArea);
|
|
1609
|
-
const areaEntities = allEntityIds.length > 0 ? getEntityViews(allEntityIds) : [];
|
|
1610
|
-
const devices = devicesInArea.map((device) => buildDeviceView(device));
|
|
1638
|
+
const devices = membership.devices.map((device) => buildDeviceView(device));
|
|
1639
|
+
const entityIds = membership.entityIds;
|
|
1611
1640
|
return {
|
|
1612
1641
|
id: areaId,
|
|
1613
1642
|
name: area$1.name,
|
|
@@ -1621,17 +1650,30 @@ function buildAreaView(areaId) {
|
|
|
1621
1650
|
humidityEntityId: area$1.humidity_entity_id ?? null,
|
|
1622
1651
|
createdAt: area$1.created_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1623
1652
|
modifiedAt: area$1.modified_at ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
1624
|
-
entities: areaEntities,
|
|
1625
1653
|
devices,
|
|
1626
|
-
entityIds
|
|
1627
|
-
deviceIds: devices.map((d) => d.id)
|
|
1654
|
+
entityIds,
|
|
1655
|
+
deviceIds: devices.map((d) => d.id),
|
|
1656
|
+
get entities() {
|
|
1657
|
+
return entityIds.length > 0 ? getEntityViews(entityIds) : [];
|
|
1658
|
+
}
|
|
1628
1659
|
};
|
|
1629
1660
|
}
|
|
1630
1661
|
/**
|
|
1631
|
-
*
|
|
1662
|
+
* Build an AreaView for a single area from registry data.
|
|
1663
|
+
*/
|
|
1664
|
+
function buildAreaView(areaId) {
|
|
1665
|
+
return assembleAreaView(areaId, membershipFor(areaId));
|
|
1666
|
+
}
|
|
1667
|
+
/**
|
|
1668
|
+
* Get all area views. Membership is resolved in a single registry pass; entity
|
|
1669
|
+
* views stay lazy per area (see `assembleAreaView`).
|
|
1632
1670
|
*/
|
|
1633
1671
|
function getAreaViews() {
|
|
1634
|
-
|
|
1672
|
+
const membership = computeAreaMembership();
|
|
1673
|
+
return Object.keys(state.areas).map((areaId) => assembleAreaView(areaId, membership.get(areaId) ?? {
|
|
1674
|
+
devices: [],
|
|
1675
|
+
entityIds: []
|
|
1676
|
+
}));
|
|
1635
1677
|
}
|
|
1636
1678
|
/**
|
|
1637
1679
|
* Get area view
|
|
@@ -2148,7 +2190,8 @@ const DEMO_HLS_STREAM = "https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8";
|
|
|
2148
2190
|
* Returns a public demo HLS stream when not connected.
|
|
2149
2191
|
*/
|
|
2150
2192
|
async function fetchStream(entityId, format = "hls") {
|
|
2151
|
-
if (
|
|
2193
|
+
if (isDemoMode()) return format === "hls" ? DEMO_HLS_STREAM : null;
|
|
2194
|
+
if (!state.conn) return null;
|
|
2152
2195
|
const response = await sendCommand({
|
|
2153
2196
|
type: "camera/stream",
|
|
2154
2197
|
entity_id: entityId,
|
package/dist/solid/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createEffect, createMemo, createResource, onCleanup } from "solid-js";
|
|
2
|
-
import { buildAreaView, callService, entityViewEquals, extractDomain, fetchStatisticsDuringPeriod, getEntityView, isDemoMode, registerEntity, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
|
|
2
|
+
import { buildAreaView, callService, entityViewEquals, extractDomain, fetchStatisticsDuringPeriod, getAreaViews, getEntityView, isDemoMode, registerEntity, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
|
|
3
3
|
|
|
4
4
|
//#region src/solid/hooks.ts
|
|
5
5
|
/**
|
|
@@ -116,21 +116,13 @@ function useToggle() {
|
|
|
116
116
|
* Get reactive list of all area views.
|
|
117
117
|
*/
|
|
118
118
|
function useAreas() {
|
|
119
|
-
return createMemo(() => {
|
|
120
|
-
return Object.keys(state.areas).map((areaId) => {
|
|
121
|
-
try {
|
|
122
|
-
return buildAreaView(areaId);
|
|
123
|
-
} catch {
|
|
124
|
-
return void 0;
|
|
125
|
-
}
|
|
126
|
-
}).filter((v) => v !== void 0);
|
|
127
|
-
}, void 0, { equals: (a, b) => {
|
|
119
|
+
return createMemo(() => getAreaViews(), void 0, { equals: (a, b) => {
|
|
128
120
|
if (a.length !== b.length) return false;
|
|
129
121
|
for (let i = 0; i < a.length; i++) {
|
|
130
122
|
const aItem = a[i];
|
|
131
123
|
const bItem = b[i];
|
|
132
124
|
if (!aItem || !bItem) return false;
|
|
133
|
-
if (aItem.id !== bItem.id || aItem.modifiedAt !== bItem.modifiedAt || aItem.
|
|
125
|
+
if (aItem.id !== bItem.id || aItem.modifiedAt !== bItem.modifiedAt || aItem.entityIds.length !== bItem.entityIds.length) return false;
|
|
134
126
|
}
|
|
135
127
|
return true;
|
|
136
128
|
} });
|
package/dist/testing/index.js
CHANGED
|
@@ -128,6 +128,18 @@ const SUSPEND_DELAY_MS = 5 * 60 * 1e3;
|
|
|
128
128
|
function isHaWireMessage(value) {
|
|
129
129
|
return typeof value === "object" && value !== null && typeof value.type === "string";
|
|
130
130
|
}
|
|
131
|
+
/** Extract a readable message from a thrown value. HA's websocket client rejects
|
|
132
|
+
* with a plain `{ code, message }` object, not an Error, so `String(err)` would
|
|
133
|
+
* yield "[object Object]" and hide the real reason. */
|
|
134
|
+
function errText(err) {
|
|
135
|
+
if (err instanceof Error) return err.message;
|
|
136
|
+
if (typeof err === "string") return err;
|
|
137
|
+
if (err && typeof err === "object" && "message" in err) {
|
|
138
|
+
const m = err.message;
|
|
139
|
+
if (typeof m === "string") return m;
|
|
140
|
+
}
|
|
141
|
+
return String(err);
|
|
142
|
+
}
|
|
131
143
|
var BrokeredAuth = class extends Auth {
|
|
132
144
|
constructor(mintUrl, data, ticket) {
|
|
133
145
|
super(data);
|
|
@@ -243,7 +255,7 @@ function runHaBridgeWorker(scope) {
|
|
|
243
255
|
id: call.id,
|
|
244
256
|
ok: false,
|
|
245
257
|
code: "ERROR",
|
|
246
|
-
message:
|
|
258
|
+
message: errText(err)
|
|
247
259
|
});
|
|
248
260
|
}
|
|
249
261
|
};
|
|
@@ -325,7 +337,7 @@ function runHaBridgeWorker(scope) {
|
|
|
325
337
|
post({
|
|
326
338
|
k: "connect_result",
|
|
327
339
|
ok: false,
|
|
328
|
-
error:
|
|
340
|
+
error: errText(err)
|
|
329
341
|
});
|
|
330
342
|
return;
|
|
331
343
|
}
|
|
@@ -346,7 +358,7 @@ function runHaBridgeWorker(scope) {
|
|
|
346
358
|
post({
|
|
347
359
|
k: "connect_result",
|
|
348
360
|
ok: false,
|
|
349
|
-
error:
|
|
361
|
+
error: errText(err)
|
|
350
362
|
});
|
|
351
363
|
return;
|
|
352
364
|
}
|
|
@@ -410,7 +422,7 @@ function runHaBridgeWorker(scope) {
|
|
|
410
422
|
k: "result",
|
|
411
423
|
id: msg.id,
|
|
412
424
|
ok: false,
|
|
413
|
-
error:
|
|
425
|
+
error: errText(err)
|
|
414
426
|
});
|
|
415
427
|
}
|
|
416
428
|
break;
|
|
@@ -448,7 +460,7 @@ function runHaBridgeWorker(scope) {
|
|
|
448
460
|
post({
|
|
449
461
|
k: "sub_end",
|
|
450
462
|
id: msg.id,
|
|
451
|
-
error:
|
|
463
|
+
error: errText(err)
|
|
452
464
|
});
|
|
453
465
|
}
|
|
454
466
|
break;
|