@glasshome/sync-layer 0.1.7 → 0.1.9
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/subscription-manager.d.ts +45 -0
- package/dist/connection/subscriptions.d.ts +6 -0
- package/dist/core/types.d.ts +0 -2
- package/dist/demo/demo-provider.d.ts +1 -1
- package/dist/{demo-data-gELwUOS_.js → demo-data-zwOczByr.js} +197 -42
- package/dist/entities/views.d.ts +5 -0
- package/dist/history/query.d.ts +12 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +279 -133
- package/dist/query/memoization.d.ts +1 -1
- package/dist/solid/hooks.d.ts +7 -1
- package/dist/solid/index.js +55 -7
- package/dist/subscription-manager-gR0qpDYZ.js +96 -0
- package/dist/testing/index.js +1 -1
- package/package.json +4 -2
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Entity Subscription Manager
|
|
3
|
+
*
|
|
4
|
+
* Ref-counted registry that tracks which entity IDs need live updates
|
|
5
|
+
* from Home Assistant. When the active set changes, resubscribes with
|
|
6
|
+
* the new entity_ids filter so HA only sends diffs for displayed entities.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
import type { EntityId } from "../core/types";
|
|
11
|
+
import type { SyncLayerConnection } from "./types";
|
|
12
|
+
type UnsubscribeFn = () => Promise<void> | void;
|
|
13
|
+
/**
|
|
14
|
+
* Register an entity for live updates. Returns an unregister function.
|
|
15
|
+
* Call from useEntity/useEntities hooks via onCleanup.
|
|
16
|
+
*/
|
|
17
|
+
export declare function registerEntity(entityId: EntityId): () => void;
|
|
18
|
+
/**
|
|
19
|
+
* Get the current set of actively subscribed entity IDs.
|
|
20
|
+
*/
|
|
21
|
+
export declare function getActiveEntityIds(): Set<EntityId>;
|
|
22
|
+
/**
|
|
23
|
+
* Set the connection reference used for resubscribing.
|
|
24
|
+
*/
|
|
25
|
+
export declare function setManagerConnection(connection: SyncLayerConnection): void;
|
|
26
|
+
/**
|
|
27
|
+
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
28
|
+
* the manager into the actual WS subscription lifecycle.
|
|
29
|
+
*/
|
|
30
|
+
export declare function setResubscribeHandler(handler: (conn: SyncLayerConnection, entityIds: EntityId[]) => Promise<UnsubscribeFn>): void;
|
|
31
|
+
/**
|
|
32
|
+
* Store the current entity subscription's unsub function.
|
|
33
|
+
* Called after initial subscribe_entities.
|
|
34
|
+
*/
|
|
35
|
+
export declare function setCurrentEntityUnsub(unsub: UnsubscribeFn): void;
|
|
36
|
+
/**
|
|
37
|
+
* Force a resubscribe with the current active set.
|
|
38
|
+
* Used on reconnect.
|
|
39
|
+
*/
|
|
40
|
+
export declare function forceResubscribe(): Promise<void>;
|
|
41
|
+
/**
|
|
42
|
+
* Reset manager state. Used on disconnect or for testing.
|
|
43
|
+
*/
|
|
44
|
+
export declare function resetManager(): void;
|
|
45
|
+
export {};
|
|
@@ -6,7 +6,13 @@
|
|
|
6
6
|
*
|
|
7
7
|
* @packageDocumentation
|
|
8
8
|
*/
|
|
9
|
+
import type { EntityId } from "../core/types";
|
|
9
10
|
import type { SyncLayerConnection } from "./types";
|
|
11
|
+
/**
|
|
12
|
+
* Subscribe to entity state updates with an entity_ids filter.
|
|
13
|
+
* Called by the subscription manager on flush.
|
|
14
|
+
*/
|
|
15
|
+
export declare function subscribeEntities(connection: SyncLayerConnection, entityIds: EntityId[]): Promise<() => Promise<void>>;
|
|
10
16
|
/**
|
|
11
17
|
* Subscribe to all relevant events from Home Assistant
|
|
12
18
|
*/
|
package/dist/core/types.d.ts
CHANGED
|
@@ -177,9 +177,7 @@ export interface EntityView {
|
|
|
177
177
|
state: string;
|
|
178
178
|
/** Entity attributes */
|
|
179
179
|
attributes: Record<string, any>;
|
|
180
|
-
/** Last state change timestamp */
|
|
181
180
|
lastChanged: Date;
|
|
182
|
-
/** Last update timestamp */
|
|
183
181
|
lastUpdated: Date;
|
|
184
182
|
/** Event context */
|
|
185
183
|
context: {
|
|
@@ -27,7 +27,7 @@ export declare function unloadDemoData(): void;
|
|
|
27
27
|
* When a service call is made in demo mode, this updates the entity
|
|
28
28
|
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
29
29
|
*/
|
|
30
|
-
export declare function applyDemoServiceCall(
|
|
30
|
+
export declare function applyDemoServiceCall(_domain: string, service: string, _serviceData?: Record<string, any>, target?: {
|
|
31
31
|
entity_id?: string | string[];
|
|
32
32
|
}): void;
|
|
33
33
|
export { createDemoFixtures } from "./demo-data";
|
|
@@ -74,6 +74,202 @@ function makeEntity(entity_id, state$1, attributes = {}) {
|
|
|
74
74
|
context: ctx()
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* One entity per weather scene the widget can render. Entity IDs use the
|
|
79
|
+
* `weather.demo_<slug>` form so a demo dashboard can reference them directly.
|
|
80
|
+
*/
|
|
81
|
+
const WEATHER_FIXTURES = [
|
|
82
|
+
{
|
|
83
|
+
slug: "sunny",
|
|
84
|
+
state: "sunny",
|
|
85
|
+
temp: 28,
|
|
86
|
+
apparent: 31,
|
|
87
|
+
humidity: 35,
|
|
88
|
+
pressure: 1018,
|
|
89
|
+
wind: 8,
|
|
90
|
+
low: 18
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
slug: "clear_night",
|
|
94
|
+
state: "clear-night",
|
|
95
|
+
temp: 14,
|
|
96
|
+
apparent: 12,
|
|
97
|
+
humidity: 55,
|
|
98
|
+
pressure: 1016,
|
|
99
|
+
wind: 5,
|
|
100
|
+
low: 9
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
slug: "cloudy",
|
|
104
|
+
state: "cloudy",
|
|
105
|
+
temp: 17,
|
|
106
|
+
apparent: 16,
|
|
107
|
+
humidity: 72,
|
|
108
|
+
pressure: 1010,
|
|
109
|
+
wind: 14,
|
|
110
|
+
low: 11
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
slug: "partly_cloudy",
|
|
114
|
+
state: "partlycloudy",
|
|
115
|
+
temp: 23,
|
|
116
|
+
apparent: 24,
|
|
117
|
+
humidity: 50,
|
|
118
|
+
pressure: 1014,
|
|
119
|
+
wind: 12,
|
|
120
|
+
low: 15
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
slug: "rainy",
|
|
124
|
+
state: "rainy",
|
|
125
|
+
temp: 12,
|
|
126
|
+
apparent: 10,
|
|
127
|
+
humidity: 88,
|
|
128
|
+
pressure: 1004,
|
|
129
|
+
wind: 18,
|
|
130
|
+
low: 8
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
slug: "pouring",
|
|
134
|
+
state: "pouring",
|
|
135
|
+
temp: 11,
|
|
136
|
+
apparent: 8,
|
|
137
|
+
humidity: 95,
|
|
138
|
+
pressure: 998,
|
|
139
|
+
wind: 26,
|
|
140
|
+
low: 7
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
slug: "snowy",
|
|
144
|
+
state: "snowy",
|
|
145
|
+
temp: -2,
|
|
146
|
+
apparent: -6,
|
|
147
|
+
humidity: 80,
|
|
148
|
+
pressure: 1020,
|
|
149
|
+
wind: 10,
|
|
150
|
+
low: -7
|
|
151
|
+
},
|
|
152
|
+
{
|
|
153
|
+
slug: "snowy_rainy",
|
|
154
|
+
state: "snowy-rainy",
|
|
155
|
+
temp: 1,
|
|
156
|
+
apparent: -2,
|
|
157
|
+
humidity: 92,
|
|
158
|
+
pressure: 1006,
|
|
159
|
+
wind: 16,
|
|
160
|
+
low: -2
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
slug: "lightning",
|
|
164
|
+
state: "lightning",
|
|
165
|
+
temp: 22,
|
|
166
|
+
apparent: 24,
|
|
167
|
+
humidity: 78,
|
|
168
|
+
pressure: 1001,
|
|
169
|
+
wind: 22,
|
|
170
|
+
low: 17
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
slug: "lightning_rainy",
|
|
174
|
+
state: "lightning-rainy",
|
|
175
|
+
temp: 19,
|
|
176
|
+
apparent: 18,
|
|
177
|
+
humidity: 90,
|
|
178
|
+
pressure: 996,
|
|
179
|
+
wind: 28,
|
|
180
|
+
low: 14
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
slug: "fog",
|
|
184
|
+
state: "fog",
|
|
185
|
+
temp: 8,
|
|
186
|
+
apparent: 6,
|
|
187
|
+
humidity: 98,
|
|
188
|
+
pressure: 1015,
|
|
189
|
+
wind: 4,
|
|
190
|
+
low: 6
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
slug: "hail",
|
|
194
|
+
state: "hail",
|
|
195
|
+
temp: 6,
|
|
196
|
+
apparent: 3,
|
|
197
|
+
humidity: 84,
|
|
198
|
+
pressure: 1002,
|
|
199
|
+
wind: 20,
|
|
200
|
+
low: 1
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
slug: "windy",
|
|
204
|
+
state: "windy",
|
|
205
|
+
temp: 18,
|
|
206
|
+
apparent: 15,
|
|
207
|
+
humidity: 60,
|
|
208
|
+
pressure: 1009,
|
|
209
|
+
wind: 42,
|
|
210
|
+
low: 12
|
|
211
|
+
},
|
|
212
|
+
{
|
|
213
|
+
slug: "exceptional",
|
|
214
|
+
state: "exceptional",
|
|
215
|
+
temp: 38,
|
|
216
|
+
apparent: 44,
|
|
217
|
+
humidity: 22,
|
|
218
|
+
pressure: 1005,
|
|
219
|
+
wind: 30,
|
|
220
|
+
low: 28
|
|
221
|
+
}
|
|
222
|
+
];
|
|
223
|
+
function makeWeatherEntity(w) {
|
|
224
|
+
const today = /* @__PURE__ */ new Date();
|
|
225
|
+
const day = (n) => new Date(today.getTime() + n * 864e5).toISOString();
|
|
226
|
+
return makeEntity(`weather.demo_${w.slug}`, w.state, {
|
|
227
|
+
temperature: w.temp,
|
|
228
|
+
temperature_unit: "°C",
|
|
229
|
+
apparent_temperature: w.apparent ?? w.temp,
|
|
230
|
+
humidity: w.humidity,
|
|
231
|
+
pressure: w.pressure,
|
|
232
|
+
pressure_unit: "hPa",
|
|
233
|
+
wind_speed: w.wind,
|
|
234
|
+
wind_speed_unit: "km/h",
|
|
235
|
+
wind_bearing: w.bearing ?? 220,
|
|
236
|
+
visibility: 10,
|
|
237
|
+
visibility_unit: "km",
|
|
238
|
+
uv_index: w.state === "sunny" ? 8 : 2,
|
|
239
|
+
forecast: [
|
|
240
|
+
{
|
|
241
|
+
datetime: day(0),
|
|
242
|
+
condition: w.state,
|
|
243
|
+
temperature: w.temp,
|
|
244
|
+
templow: w.low
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
datetime: day(1),
|
|
248
|
+
condition: "partlycloudy",
|
|
249
|
+
temperature: w.temp + 2,
|
|
250
|
+
templow: w.low
|
|
251
|
+
},
|
|
252
|
+
{
|
|
253
|
+
datetime: day(2),
|
|
254
|
+
condition: "cloudy",
|
|
255
|
+
temperature: w.temp + 1,
|
|
256
|
+
templow: w.low - 1
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
datetime: day(3),
|
|
260
|
+
condition: "sunny",
|
|
261
|
+
temperature: w.temp + 4,
|
|
262
|
+
templow: w.low + 1
|
|
263
|
+
},
|
|
264
|
+
{
|
|
265
|
+
datetime: day(4),
|
|
266
|
+
condition: "rainy",
|
|
267
|
+
temperature: w.temp - 2,
|
|
268
|
+
templow: w.low - 2
|
|
269
|
+
}
|
|
270
|
+
]
|
|
271
|
+
});
|
|
272
|
+
}
|
|
77
273
|
function friendlyName(entityId) {
|
|
78
274
|
const objectId = entityId.split(".")[1] ?? entityId;
|
|
79
275
|
return objectId.split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
@@ -372,48 +568,7 @@ function createDemoFixtures() {
|
|
|
372
568
|
area_id: "living_room",
|
|
373
569
|
supported_features: 152461
|
|
374
570
|
});
|
|
375
|
-
add(
|
|
376
|
-
temperature: 3,
|
|
377
|
-
temperature_unit: "°C",
|
|
378
|
-
humidity: 72,
|
|
379
|
-
pressure: 1013,
|
|
380
|
-
pressure_unit: "hPa",
|
|
381
|
-
wind_speed: 12,
|
|
382
|
-
wind_speed_unit: "km/h",
|
|
383
|
-
wind_bearing: 220,
|
|
384
|
-
forecast: [
|
|
385
|
-
{
|
|
386
|
-
datetime: "2026-03-07T00:00:00Z",
|
|
387
|
-
condition: "cloudy",
|
|
388
|
-
temperature: 4,
|
|
389
|
-
templow: -1
|
|
390
|
-
},
|
|
391
|
-
{
|
|
392
|
-
datetime: "2026-03-08T00:00:00Z",
|
|
393
|
-
condition: "sunny",
|
|
394
|
-
temperature: 7,
|
|
395
|
-
templow: 1
|
|
396
|
-
},
|
|
397
|
-
{
|
|
398
|
-
datetime: "2026-03-09T00:00:00Z",
|
|
399
|
-
condition: "rainy",
|
|
400
|
-
temperature: 5,
|
|
401
|
-
templow: 2
|
|
402
|
-
},
|
|
403
|
-
{
|
|
404
|
-
datetime: "2026-03-10T00:00:00Z",
|
|
405
|
-
condition: "partlycloudy",
|
|
406
|
-
temperature: 6,
|
|
407
|
-
templow: 0
|
|
408
|
-
},
|
|
409
|
-
{
|
|
410
|
-
datetime: "2026-03-11T00:00:00Z",
|
|
411
|
-
condition: "snowy",
|
|
412
|
-
temperature: 1,
|
|
413
|
-
templow: -3
|
|
414
|
-
}
|
|
415
|
-
]
|
|
416
|
-
}), { platform: "met" });
|
|
571
|
+
for (const w of WEATHER_FIXTURES) add(makeWeatherEntity(w), { platform: "met" });
|
|
417
572
|
add(makeEntity("camera.front_door_camera", "streaming", {
|
|
418
573
|
entity_picture: "/api/camera_proxy/camera.front_door_camera",
|
|
419
574
|
frontend_stream_type: "hls",
|
package/dist/entities/views.d.ts
CHANGED
|
@@ -28,3 +28,8 @@ export declare function getAllEntityViews(): EntityView[];
|
|
|
28
28
|
* Rebuild entity view
|
|
29
29
|
*/
|
|
30
30
|
export declare function rebuildEntityView(entityId: EntityId): EntityView | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Shallow equality check for EntityView.
|
|
33
|
+
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
34
|
+
*/
|
|
35
|
+
export declare function entityViewEquals(a: EntityView | undefined, b: EntityView | undefined): boolean;
|
package/dist/history/query.d.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { EntityId } from "../core/types";
|
|
2
2
|
import type { EntityHistoryData, EntityHistoryQueryOptions } from "./types";
|
|
3
|
+
export interface HistoryPoint {
|
|
4
|
+
entityId: EntityId;
|
|
5
|
+
stateValue: string;
|
|
6
|
+
attributes: Record<string, unknown>;
|
|
7
|
+
lastUpdated: number;
|
|
8
|
+
lastChanged?: number;
|
|
9
|
+
}
|
|
3
10
|
/** Check if an entity has active history tracking. */
|
|
4
11
|
export declare function isHistoryTracked(entityId: EntityId): boolean;
|
|
5
12
|
/** Start tracking history for an entity. Fetches initial backfill. */
|
|
@@ -11,3 +18,8 @@ export declare function untrackEntityHistory(entityId: EntityId): void;
|
|
|
11
18
|
* Called by the entity subscription handler when a tracked entity changes.
|
|
12
19
|
*/
|
|
13
20
|
export declare function appendHistoryPoint(entityId: EntityId, stateValue: string, attributes: Record<string, unknown>, lastUpdated: number, lastChanged?: number): void;
|
|
21
|
+
/**
|
|
22
|
+
* Append multiple history points in a single setState(produce(...)).
|
|
23
|
+
* Called by the rAF-batched subscription flush.
|
|
24
|
+
*/
|
|
25
|
+
export declare function bulkAppendHistoryPoints(points: HistoryPoint[]): void;
|
package/dist/index.d.ts
CHANGED
|
@@ -28,10 +28,11 @@ export { getStream, refreshStream } from "./camera/query";
|
|
|
28
28
|
export type { CameraStream, CameraStreamData, CameraStreams, EntityStreamQueryOptions, StreamFormat, StreamQueryOptions, StreamResult, } from "./camera/types";
|
|
29
29
|
export { getWebRtcClientConfig, sendWebRtcCandidate, startWebRtcSession, } from "./camera/webrtc";
|
|
30
30
|
export { buildAreaView, buildDeviceView, getAreaView, getAreaViews, } from "./entities/area-views";
|
|
31
|
-
export { buildEntityView, getAllEntityViews, getEntityView, getEntityViews, } from "./entities/views";
|
|
31
|
+
export { buildEntityView, entityViewEquals, getAllEntityViews, getEntityView, getEntityViews, } from "./entities/views";
|
|
32
32
|
export { DOMAINS_USE_LAST_UPDATED, entityIdHistoryNeedsAttributes, LINE_ATTRIBUTES_TO_KEEP, NEED_ATTRIBUTE_DOMAINS, } from "./history/constants";
|
|
33
33
|
export { fetchEntityHistory, fetchHistory, historyStateToTimeline } from "./history/fetch";
|
|
34
|
-
export { appendHistoryPoint, isHistoryTracked, trackEntityHistory, untrackEntityHistory, } from "./history/query";
|
|
34
|
+
export { appendHistoryPoint, bulkAppendHistoryPoints, isHistoryTracked, trackEntityHistory, untrackEntityHistory, } from "./history/query";
|
|
35
|
+
export type { HistoryPoint } from "./history/query";
|
|
35
36
|
export type { EntityHistoryData, EntityHistoryQueryOptions, EntityHistoryResult, EntityHistoryState, HistoryData, HistoryQueryOptions, HistoryResult, HistoryStates, TimelineState, } from "./history/types";
|
|
36
37
|
export { fetchForecast, fetchForecastData, fetchForecasts } from "./weather/fetch";
|
|
37
38
|
export { getForecast, getForecasts, refreshForecast, refreshForecasts } from "./weather/query";
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { createDemoFixtures, resetStore, setState, state } from "./demo-data-
|
|
1
|
+
import { createDemoFixtures, resetStore, setState, state } from "./demo-data-zwOczByr.js";
|
|
2
|
+
import { forceResubscribe, setManagerConnection, setResubscribeHandler } from "./subscription-manager-gR0qpDYZ.js";
|
|
2
3
|
import { callService as callService$1, createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
|
|
3
4
|
import { produce, reconcile } from "solid-js/store";
|
|
4
5
|
|
|
@@ -446,7 +447,7 @@ function unloadDemoData() {
|
|
|
446
447
|
* When a service call is made in demo mode, this updates the entity
|
|
447
448
|
* state locally (no WebSocket message sent). State resets on page refresh.
|
|
448
449
|
*/
|
|
449
|
-
function applyDemoServiceCall(
|
|
450
|
+
function applyDemoServiceCall(_domain, service, _serviceData = {}, target = {}) {
|
|
450
451
|
if (!_isDemoMode) return;
|
|
451
452
|
const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
|
|
452
453
|
for (const entityId of entityIds) {
|
|
@@ -756,60 +757,120 @@ function appendHistoryPoint(entityId, stateValue, attributes, lastUpdated, lastC
|
|
|
756
757
|
});
|
|
757
758
|
}));
|
|
758
759
|
}
|
|
760
|
+
/**
|
|
761
|
+
* Append multiple history points in a single setState(produce(...)).
|
|
762
|
+
* Called by the rAF-batched subscription flush.
|
|
763
|
+
*/
|
|
764
|
+
function bulkAppendHistoryPoints(points) {
|
|
765
|
+
if (points.length === 0) return;
|
|
766
|
+
setState(produce((s) => {
|
|
767
|
+
for (const { entityId, stateValue, attributes, lastUpdated, lastChanged } of points) {
|
|
768
|
+
const history = s.history[entityId];
|
|
769
|
+
if (!history) continue;
|
|
770
|
+
history.entityHistory.push({
|
|
771
|
+
s: stateValue,
|
|
772
|
+
a: attributes,
|
|
773
|
+
lu: lastUpdated,
|
|
774
|
+
lc: lastChanged
|
|
775
|
+
});
|
|
776
|
+
history.timeline.push({
|
|
777
|
+
timestamp: lastUpdated,
|
|
778
|
+
state: stateValue,
|
|
779
|
+
attributes,
|
|
780
|
+
lastChanged,
|
|
781
|
+
lastUpdated
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}));
|
|
785
|
+
}
|
|
759
786
|
|
|
760
787
|
//#endregion
|
|
761
788
|
//#region src/connection/subscriptions.ts
|
|
762
|
-
/**
|
|
763
|
-
let
|
|
789
|
+
/** Registry subscription cleanup functions (entity sub managed by subscription-manager) */
|
|
790
|
+
let registrySubscriptions = [];
|
|
791
|
+
/** Buffered messages waiting for next animation frame */
|
|
792
|
+
let messageBuffer = [];
|
|
793
|
+
let rafScheduled = false;
|
|
794
|
+
function bufferMessage(message) {
|
|
795
|
+
messageBuffer.push(message);
|
|
796
|
+
if (!rafScheduled) {
|
|
797
|
+
rafScheduled = true;
|
|
798
|
+
const schedule = typeof requestAnimationFrame === "function" ? requestAnimationFrame : queueMicrotask;
|
|
799
|
+
schedule(flushMessageBuffer);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
function flushMessageBuffer() {
|
|
803
|
+
rafScheduled = false;
|
|
804
|
+
const messages = messageBuffer;
|
|
805
|
+
messageBuffer = [];
|
|
806
|
+
if (messages.length === 0) return;
|
|
807
|
+
setState(produce((s) => {
|
|
808
|
+
for (const message of messages) {
|
|
809
|
+
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
|
|
810
|
+
if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
|
|
811
|
+
if (message.r) for (const entityId of message.r) delete s.entities[entityId];
|
|
812
|
+
}
|
|
813
|
+
}));
|
|
814
|
+
const historyPoints = [];
|
|
815
|
+
for (const message of messages) {
|
|
816
|
+
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) collectHistoryFromCompressed(historyPoints, entityId, compressedState);
|
|
817
|
+
if (message.c) {
|
|
818
|
+
for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) collectHistoryFromDiff(historyPoints, entityId, diff["+"]);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
if (historyPoints.length > 0) bulkAppendHistoryPoints(historyPoints);
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* Subscribe to entity state updates with an entity_ids filter.
|
|
825
|
+
* Called by the subscription manager on flush.
|
|
826
|
+
*/
|
|
827
|
+
async function subscribeEntities(connection, entityIds) {
|
|
828
|
+
const subscribeMsg = { type: "subscribe_entities" };
|
|
829
|
+
if (entityIds.length > 0) subscribeMsg.entity_ids = entityIds;
|
|
830
|
+
const unsub = await connection.subscribeMessage((message) => {
|
|
831
|
+
bufferMessage(message);
|
|
832
|
+
}, subscribeMsg);
|
|
833
|
+
return unsub;
|
|
834
|
+
}
|
|
764
835
|
/**
|
|
765
836
|
* Subscribe to all relevant events from Home Assistant
|
|
766
837
|
*/
|
|
767
838
|
async function subscribeToUpdates(conn) {
|
|
768
839
|
await cleanupSubscriptions();
|
|
840
|
+
setManagerConnection(conn);
|
|
841
|
+
setResubscribeHandler(subscribeEntities);
|
|
769
842
|
const registryUpdatedSub = await conn.subscribeEvents((event) => {
|
|
770
843
|
handleRegistryUpdate(event);
|
|
771
844
|
}, "entity_registry_updated");
|
|
772
|
-
|
|
845
|
+
registrySubscriptions.push(() => registryUpdatedSub());
|
|
773
846
|
const deviceRegistrySub = await conn.subscribeEvents((event) => {
|
|
774
847
|
handleDeviceRegistryUpdate(event);
|
|
775
848
|
}, "device_registry_updated");
|
|
776
|
-
|
|
849
|
+
registrySubscriptions.push(() => deviceRegistrySub());
|
|
777
850
|
const areaRegistrySub = await conn.subscribeEvents((event) => {
|
|
778
851
|
handleAreaRegistryUpdate(event);
|
|
779
852
|
}, "area_registry_updated");
|
|
780
|
-
|
|
853
|
+
registrySubscriptions.push(() => areaRegistrySub());
|
|
781
854
|
const floorRegistrySub = await conn.subscribeEvents((event) => {
|
|
782
855
|
handleFloorRegistryUpdate(event);
|
|
783
856
|
}, "floor_registry_updated");
|
|
784
|
-
|
|
857
|
+
registrySubscriptions.push(() => floorRegistrySub());
|
|
785
858
|
const labelRegistrySub = await conn.subscribeEvents((event) => {
|
|
786
859
|
handleLabelRegistryUpdate(event);
|
|
787
860
|
}, "label_registry_updated");
|
|
788
|
-
|
|
861
|
+
registrySubscriptions.push(() => labelRegistrySub());
|
|
789
862
|
const statisticsSub = await conn.subscribeEvents((event) => {
|
|
790
863
|
handleStatisticsUpdate(event);
|
|
791
864
|
}, "recorder_5min_statistics_generated");
|
|
792
|
-
|
|
793
|
-
const entityUpdatesSub = await conn.subscribeMessage((message) => {
|
|
794
|
-
setState(produce((s) => {
|
|
795
|
-
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
|
|
796
|
-
if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
|
|
797
|
-
if (message.r) for (const entityId of message.r) delete s.entities[entityId];
|
|
798
|
-
}));
|
|
799
|
-
if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) maybeAppendHistory(entityId, compressedState);
|
|
800
|
-
if (message.c) {
|
|
801
|
-
for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) maybeAppendHistoryFromDiff(entityId, diff["+"]);
|
|
802
|
-
}
|
|
803
|
-
}, { type: "subscribe_entities" });
|
|
804
|
-
activeSubscriptions.push(() => entityUpdatesSub());
|
|
865
|
+
registrySubscriptions.push(() => statisticsSub());
|
|
805
866
|
}
|
|
806
867
|
/**
|
|
807
868
|
* Clean up all active subscriptions
|
|
808
869
|
*/
|
|
809
870
|
async function cleanupSubscriptions() {
|
|
810
|
-
const subscriptions = [...
|
|
871
|
+
const subscriptions = [...registrySubscriptions];
|
|
811
872
|
for (const unsub of subscriptions) await unsub();
|
|
812
|
-
|
|
873
|
+
registrySubscriptions = [];
|
|
813
874
|
}
|
|
814
875
|
async function handleRegistryUpdate(event) {
|
|
815
876
|
const { action, entity_id } = event.data;
|
|
@@ -848,24 +909,8 @@ async function handleAreaRegistryUpdate(event) {
|
|
|
848
909
|
if (!conn) return;
|
|
849
910
|
try {
|
|
850
911
|
const areas$1 = await conn.sendMessagePromise({ type: "config/area_registry/list" });
|
|
851
|
-
const
|
|
852
|
-
if (
|
|
853
|
-
const area$1 = {
|
|
854
|
-
id: areaApi.area_id || areaApi.id,
|
|
855
|
-
name: areaApi.name,
|
|
856
|
-
normalized_name: areaApi.normalized_name || areaApi.name.toLowerCase().replace(/\s+/g, "_"),
|
|
857
|
-
aliases: Array.isArray(areaApi.aliases) ? areaApi.aliases : [],
|
|
858
|
-
floor_id: areaApi.floor_id ?? null,
|
|
859
|
-
humidity_entity_id: areaApi.humidity_entity_id ?? null,
|
|
860
|
-
icon: areaApi.icon ?? null,
|
|
861
|
-
labels: Array.isArray(areaApi.labels) ? areaApi.labels : [],
|
|
862
|
-
picture: areaApi.picture ?? null,
|
|
863
|
-
temperature_entity_id: areaApi.temperature_entity_id ?? null,
|
|
864
|
-
created_at: typeof areaApi.created_at === "number" ? new Date(areaApi.created_at * 1e3).toISOString() : areaApi.created_at,
|
|
865
|
-
modified_at: typeof areaApi.modified_at === "number" ? new Date(areaApi.modified_at * 1e3).toISOString() : areaApi.modified_at
|
|
866
|
-
};
|
|
867
|
-
updateArea(area$1);
|
|
868
|
-
}
|
|
912
|
+
const area$1 = areas$1.find((a) => a.id === area_id);
|
|
913
|
+
if (area$1) updateArea(area$1);
|
|
869
914
|
} catch (error) {
|
|
870
915
|
console.error("Error fetching area:", error);
|
|
871
916
|
}
|
|
@@ -919,21 +964,39 @@ function timestampToIso(timestamp) {
|
|
|
919
964
|
if (typeof timestamp === "string") return timestamp;
|
|
920
965
|
return new Date(timestamp * 1e3).toISOString();
|
|
921
966
|
}
|
|
922
|
-
function
|
|
967
|
+
function collectHistoryFromCompressed(points, entityId, compressed) {
|
|
923
968
|
if (!isHistoryTracked(entityId)) return;
|
|
924
969
|
const stateVal = compressed.s ?? compressed.state ?? "unknown";
|
|
925
970
|
const attributes = compressed.a ?? compressed.attributes ?? {};
|
|
926
971
|
const lu = compressed.lu ?? (compressed.last_updated ? new Date(compressed.last_updated).getTime() / 1e3 : Date.now() / 1e3);
|
|
927
972
|
const lc = compressed.lc ?? (compressed.last_changed ? new Date(compressed.last_changed).getTime() / 1e3 : void 0);
|
|
928
|
-
|
|
973
|
+
points.push({
|
|
974
|
+
entityId,
|
|
975
|
+
stateValue: stateVal,
|
|
976
|
+
attributes,
|
|
977
|
+
lastUpdated: lu,
|
|
978
|
+
lastChanged: lc
|
|
979
|
+
});
|
|
929
980
|
}
|
|
930
|
-
function
|
|
981
|
+
function collectHistoryFromDiff(points, entityId, additions) {
|
|
931
982
|
if (!isHistoryTracked(entityId)) return;
|
|
932
|
-
const
|
|
933
|
-
if (
|
|
983
|
+
const stateVal = additions.s ?? additions.state;
|
|
984
|
+
if (stateVal === void 0) return;
|
|
934
985
|
const lu = additions.lu ?? Date.now() / 1e3;
|
|
935
986
|
const lc = additions.lc;
|
|
936
|
-
|
|
987
|
+
const attributes = additions.a ?? additions.attributes ?? {};
|
|
988
|
+
points.push({
|
|
989
|
+
entityId,
|
|
990
|
+
stateValue: stateVal,
|
|
991
|
+
attributes,
|
|
992
|
+
lastUpdated: lu,
|
|
993
|
+
lastChanged: lc
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
function shallowEqualAttributes(existing, incoming) {
|
|
997
|
+
const incomingKeys = Object.keys(incoming);
|
|
998
|
+
for (const key of incomingKeys) if (existing[key] !== incoming[key]) return false;
|
|
999
|
+
return true;
|
|
937
1000
|
}
|
|
938
1001
|
function applyCompressedState(s, entityId, compressed) {
|
|
939
1002
|
const stateVal = compressed.s ?? compressed.state ?? "unknown";
|
|
@@ -945,7 +1008,16 @@ function applyCompressedState(s, entityId, compressed) {
|
|
|
945
1008
|
};
|
|
946
1009
|
const lastChanged = timestampToIso(compressed.lc ?? compressed.last_changed);
|
|
947
1010
|
const lastUpdated = timestampToIso(compressed.lu ?? compressed.last_updated);
|
|
948
|
-
const
|
|
1011
|
+
const existing = s.entities[entityId];
|
|
1012
|
+
if (existing) {
|
|
1013
|
+
const stateChanged = existing.state !== stateVal;
|
|
1014
|
+
const changedAt = existing.last_changed !== lastChanged;
|
|
1015
|
+
if (stateChanged) existing.state = stateVal;
|
|
1016
|
+
if (changedAt) existing.last_changed = lastChanged;
|
|
1017
|
+
if (stateChanged || changedAt) existing.last_updated = lastUpdated;
|
|
1018
|
+
if (!shallowEqualAttributes(existing.attributes, attributes)) Object.assign(existing.attributes, attributes);
|
|
1019
|
+
if (existing.context.id !== context.id) existing.context = context;
|
|
1020
|
+
} else s.entities[entityId] = {
|
|
949
1021
|
entity_id: entityId,
|
|
950
1022
|
state: stateVal,
|
|
951
1023
|
attributes,
|
|
@@ -953,18 +1025,32 @@ function applyCompressedState(s, entityId, compressed) {
|
|
|
953
1025
|
last_updated: lastUpdated,
|
|
954
1026
|
context
|
|
955
1027
|
};
|
|
956
|
-
s.entities[entityId] = entity$1;
|
|
957
1028
|
}
|
|
958
1029
|
function applyStateDiff(s, entityId, diff) {
|
|
959
1030
|
const existing = s.entities[entityId];
|
|
960
1031
|
if (!existing) return;
|
|
961
1032
|
const additions = diff["+"];
|
|
962
1033
|
if (additions) {
|
|
963
|
-
|
|
964
|
-
if (additions.
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1034
|
+
let stateChanged = false;
|
|
1035
|
+
if (additions.s !== void 0 && existing.state !== additions.s) {
|
|
1036
|
+
existing.state = additions.s;
|
|
1037
|
+
stateChanged = true;
|
|
1038
|
+
}
|
|
1039
|
+
if (additions.a) {
|
|
1040
|
+
for (const [key, value] of Object.entries(additions.a)) if (existing.attributes[key] !== value) {
|
|
1041
|
+
existing.attributes[key] = value;
|
|
1042
|
+
stateChanged = true;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (additions.lc !== void 0) {
|
|
1046
|
+
const lcIso = timestampToIso(additions.lc);
|
|
1047
|
+
if (existing.last_changed !== lcIso) {
|
|
1048
|
+
existing.last_changed = lcIso;
|
|
1049
|
+
stateChanged = true;
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
if (additions.lu !== void 0 && stateChanged) existing.last_updated = timestampToIso(additions.lu);
|
|
1053
|
+
if (additions.c && existing.context.id !== additions.c.id) existing.context = additions.c;
|
|
968
1054
|
}
|
|
969
1055
|
const removals = diff["-"];
|
|
970
1056
|
if (removals?.a) for (const key of removals.a) delete existing.attributes[key];
|
|
@@ -1053,6 +1139,7 @@ function setupEventHandlers(conn, options) {
|
|
|
1053
1139
|
}));
|
|
1054
1140
|
if (wasReconnecting) {
|
|
1055
1141
|
await loadInitialData(conn);
|
|
1142
|
+
await forceResubscribe();
|
|
1056
1143
|
options.onReconnect?.();
|
|
1057
1144
|
}
|
|
1058
1145
|
});
|
|
@@ -1097,77 +1184,6 @@ async function loadInitialData(conn) {
|
|
|
1097
1184
|
}
|
|
1098
1185
|
}
|
|
1099
1186
|
|
|
1100
|
-
//#endregion
|
|
1101
|
-
//#region src/entities/index.ts
|
|
1102
|
-
/**
|
|
1103
|
-
* Get all entity IDs
|
|
1104
|
-
*/
|
|
1105
|
-
function getAllEntityIds() {
|
|
1106
|
-
return Object.keys(state.entities);
|
|
1107
|
-
}
|
|
1108
|
-
/**
|
|
1109
|
-
* Get entities by domain
|
|
1110
|
-
*/
|
|
1111
|
-
function getEntitiesByDomain(domain) {
|
|
1112
|
-
return Object.keys(state.entities).filter((id) => extractDomain(id) === domain);
|
|
1113
|
-
}
|
|
1114
|
-
/**
|
|
1115
|
-
* Get entities via devices in area
|
|
1116
|
-
*
|
|
1117
|
-
* Only includes entities that inherit area from device (entity.area_id is null).
|
|
1118
|
-
*/
|
|
1119
|
-
function getEntitiesViaDevices(areaId) {
|
|
1120
|
-
const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
|
|
1121
|
-
const entityIds = [];
|
|
1122
|
-
for (const device of devicesInArea) {
|
|
1123
|
-
const deviceEntities = getEntitiesByDevice(device.id);
|
|
1124
|
-
for (const entityId of deviceEntities) {
|
|
1125
|
-
const registry = state.entityRegistry[entityId];
|
|
1126
|
-
if (!registry?.area_id) entityIds.push(entityId);
|
|
1127
|
-
}
|
|
1128
|
-
}
|
|
1129
|
-
return entityIds;
|
|
1130
|
-
}
|
|
1131
|
-
/**
|
|
1132
|
-
* Get entities by area
|
|
1133
|
-
*
|
|
1134
|
-
* Returns entities with direct area_id assignments and entities that inherit their area from devices.
|
|
1135
|
-
*/
|
|
1136
|
-
function getEntitiesByArea(areaId) {
|
|
1137
|
-
const directEntityIds = [];
|
|
1138
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
|
|
1139
|
-
const deviceEntityIds = getEntitiesViaDevices(areaId);
|
|
1140
|
-
return Array.from(new Set([...directEntityIds, ...deviceEntityIds]));
|
|
1141
|
-
}
|
|
1142
|
-
/**
|
|
1143
|
-
* Get entities by device
|
|
1144
|
-
*/
|
|
1145
|
-
function getEntitiesByDevice(deviceId) {
|
|
1146
|
-
const entityIds = [];
|
|
1147
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.device_id === deviceId) entityIds.push(entityId);
|
|
1148
|
-
return entityIds;
|
|
1149
|
-
}
|
|
1150
|
-
/**
|
|
1151
|
-
* Get entities by floor
|
|
1152
|
-
*/
|
|
1153
|
-
function getEntitiesByFloor(floorId) {
|
|
1154
|
-
const entityIds = [];
|
|
1155
|
-
const areasOnFloor = Object.values(state.areas).filter((area$1) => area$1.floor_id === floorId);
|
|
1156
|
-
for (const area$1 of areasOnFloor) {
|
|
1157
|
-
const areaEntities = getEntitiesByArea(area$1.id);
|
|
1158
|
-
entityIds.push(...areaEntities);
|
|
1159
|
-
}
|
|
1160
|
-
return entityIds;
|
|
1161
|
-
}
|
|
1162
|
-
/**
|
|
1163
|
-
* Get entities by label
|
|
1164
|
-
*/
|
|
1165
|
-
function getEntitiesByLabel(labelId) {
|
|
1166
|
-
const entityIds = [];
|
|
1167
|
-
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.labels?.includes(labelId)) entityIds.push(entityId);
|
|
1168
|
-
return entityIds;
|
|
1169
|
-
}
|
|
1170
|
-
|
|
1171
1187
|
//#endregion
|
|
1172
1188
|
//#region src/entities/views.ts
|
|
1173
1189
|
/**
|
|
@@ -1253,9 +1269,29 @@ function getEntityViews(entityIds) {
|
|
|
1253
1269
|
function getAllEntityViews() {
|
|
1254
1270
|
return getEntityViews(Object.keys(state.entities));
|
|
1255
1271
|
}
|
|
1272
|
+
/**
|
|
1273
|
+
* Shallow equality check for EntityView.
|
|
1274
|
+
* Compares fields that affect rendering; skips lastUpdated (heartbeat noise).
|
|
1275
|
+
*/
|
|
1276
|
+
function entityViewEquals(a, b) {
|
|
1277
|
+
if (a === b) return true;
|
|
1278
|
+
if (!a || !b) return false;
|
|
1279
|
+
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;
|
|
1280
|
+
}
|
|
1256
1281
|
|
|
1257
1282
|
//#endregion
|
|
1258
1283
|
//#region src/entities/area-views.ts
|
|
1284
|
+
function getDevicesForArea(areaId) {
|
|
1285
|
+
const result = [];
|
|
1286
|
+
for (const device of Object.values(state.devices)) if (device.area_id === areaId) result.push(device);
|
|
1287
|
+
return result;
|
|
1288
|
+
}
|
|
1289
|
+
function getEntityIdsForArea(areaId, deviceIdsInArea) {
|
|
1290
|
+
const entityIds = [];
|
|
1291
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) entityIds.push(entityId);
|
|
1292
|
+
else if (!entry.area_id && entry.device_id && deviceIdsInArea.has(entry.device_id)) entityIds.push(entityId);
|
|
1293
|
+
return entityIds;
|
|
1294
|
+
}
|
|
1259
1295
|
/**
|
|
1260
1296
|
* Build a DeviceView from registry data
|
|
1261
1297
|
*/
|
|
@@ -1280,12 +1316,10 @@ function buildDeviceView(device) {
|
|
|
1280
1316
|
function buildAreaView(areaId) {
|
|
1281
1317
|
const area$1 = state.areas[areaId];
|
|
1282
1318
|
if (!area$1) throw new Error(`Area ${areaId} not found`);
|
|
1283
|
-
const
|
|
1284
|
-
|
|
1285
|
-
const
|
|
1286
|
-
const allEntityIds = Array.from(new Set([...directEntityIds, ...deviceEntityIds]));
|
|
1319
|
+
const devicesInArea = getDevicesForArea(areaId);
|
|
1320
|
+
const deviceIdsInArea = new Set(devicesInArea.map((d) => d.id));
|
|
1321
|
+
const allEntityIds = getEntityIdsForArea(areaId, deviceIdsInArea);
|
|
1287
1322
|
const areaEntities = allEntityIds.length > 0 ? getEntityViews(allEntityIds) : [];
|
|
1288
|
-
const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
|
|
1289
1323
|
const devices = devicesInArea.map((device) => buildDeviceView(device));
|
|
1290
1324
|
return {
|
|
1291
1325
|
id: areaId,
|
|
@@ -1416,6 +1450,77 @@ function area(areaId) {
|
|
|
1416
1450
|
return getAreaView(areaId);
|
|
1417
1451
|
}
|
|
1418
1452
|
|
|
1453
|
+
//#endregion
|
|
1454
|
+
//#region src/entities/index.ts
|
|
1455
|
+
/**
|
|
1456
|
+
* Get all entity IDs
|
|
1457
|
+
*/
|
|
1458
|
+
function getAllEntityIds() {
|
|
1459
|
+
return Object.keys(state.entities);
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Get entities by domain
|
|
1463
|
+
*/
|
|
1464
|
+
function getEntitiesByDomain(domain) {
|
|
1465
|
+
return Object.keys(state.entities).filter((id) => extractDomain(id) === domain);
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Get entities via devices in area
|
|
1469
|
+
*
|
|
1470
|
+
* Only includes entities that inherit area from device (entity.area_id is null).
|
|
1471
|
+
*/
|
|
1472
|
+
function getEntitiesViaDevices(areaId) {
|
|
1473
|
+
const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
|
|
1474
|
+
const entityIds = [];
|
|
1475
|
+
for (const device of devicesInArea) {
|
|
1476
|
+
const deviceEntities = getEntitiesByDevice(device.id);
|
|
1477
|
+
for (const entityId of deviceEntities) {
|
|
1478
|
+
const registry = state.entityRegistry[entityId];
|
|
1479
|
+
if (!registry?.area_id) entityIds.push(entityId);
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
return entityIds;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Get entities by area
|
|
1486
|
+
*
|
|
1487
|
+
* Returns entities with direct area_id assignments and entities that inherit their area from devices.
|
|
1488
|
+
*/
|
|
1489
|
+
function getEntitiesByArea(areaId) {
|
|
1490
|
+
const directEntityIds = [];
|
|
1491
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
|
|
1492
|
+
const deviceEntityIds = getEntitiesViaDevices(areaId);
|
|
1493
|
+
return Array.from(new Set([...directEntityIds, ...deviceEntityIds]));
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Get entities by device
|
|
1497
|
+
*/
|
|
1498
|
+
function getEntitiesByDevice(deviceId) {
|
|
1499
|
+
const entityIds = [];
|
|
1500
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.device_id === deviceId) entityIds.push(entityId);
|
|
1501
|
+
return entityIds;
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* Get entities by floor
|
|
1505
|
+
*/
|
|
1506
|
+
function getEntitiesByFloor(floorId) {
|
|
1507
|
+
const entityIds = [];
|
|
1508
|
+
const areasOnFloor = Object.values(state.areas).filter((area$1) => area$1.floor_id === floorId);
|
|
1509
|
+
for (const area$1 of areasOnFloor) {
|
|
1510
|
+
const areaEntities = getEntitiesByArea(area$1.id);
|
|
1511
|
+
entityIds.push(...areaEntities);
|
|
1512
|
+
}
|
|
1513
|
+
return entityIds;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Get entities by label
|
|
1517
|
+
*/
|
|
1518
|
+
function getEntitiesByLabel(labelId) {
|
|
1519
|
+
const entityIds = [];
|
|
1520
|
+
for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.labels?.includes(labelId)) entityIds.push(entityId);
|
|
1521
|
+
return entityIds;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1419
1524
|
//#endregion
|
|
1420
1525
|
//#region src/query/memoization.ts
|
|
1421
1526
|
/**
|
|
@@ -1433,7 +1538,7 @@ function buildQuerySignature(query) {
|
|
|
1433
1538
|
/**
|
|
1434
1539
|
* Execute query (no caching -- SolidJS createMemo handles this in Plan 03)
|
|
1435
1540
|
*/
|
|
1436
|
-
function memoizedExecute(
|
|
1541
|
+
function memoizedExecute(_signature, compute) {
|
|
1437
1542
|
return compute();
|
|
1438
1543
|
}
|
|
1439
1544
|
|
|
@@ -1956,9 +2061,50 @@ async function sendWebRtcCandidate(entityId, sessionId, candidate) {
|
|
|
1956
2061
|
//#endregion
|
|
1957
2062
|
//#region src/weather/fetch.ts
|
|
1958
2063
|
/**
|
|
2064
|
+
* In demo mode, derive forecasts from the entity's `forecast` attribute.
|
|
2065
|
+
* Hourly and daily are synthesised from the same source (daily entries
|
|
2066
|
+
* expanded into 12 hourly steps for the chart).
|
|
2067
|
+
*/
|
|
2068
|
+
function buildDemoForecast(entityId, type) {
|
|
2069
|
+
const entity$1 = state.entities[entityId];
|
|
2070
|
+
const raw = entity$1?.attributes?.forecast ?? [];
|
|
2071
|
+
if (raw.length === 0) return [];
|
|
2072
|
+
if (type === "daily" || type === "twice_daily") return raw.map((d) => {
|
|
2073
|
+
const templow = d.templow;
|
|
2074
|
+
return {
|
|
2075
|
+
...d,
|
|
2076
|
+
temp_high: d.temp_high ?? d.temperature,
|
|
2077
|
+
temp_low: d.temp_low ?? templow
|
|
2078
|
+
};
|
|
2079
|
+
});
|
|
2080
|
+
const start = /* @__PURE__ */ new Date();
|
|
2081
|
+
const today = raw[0];
|
|
2082
|
+
const tomorrow = raw[1] ?? today;
|
|
2083
|
+
const tHi = today?.temperature ?? 18;
|
|
2084
|
+
const tLo = today?.templow ?? tHi - 5;
|
|
2085
|
+
const tNext = tomorrow?.temperature ?? tHi;
|
|
2086
|
+
const out = [];
|
|
2087
|
+
for (let i = 0; i < 24; i++) {
|
|
2088
|
+
const dt = new Date(start.getTime() + i * 60 * 60 * 1e3);
|
|
2089
|
+
const hour = dt.getHours();
|
|
2090
|
+
const dayMix = i < 12 ? 0 : (i - 12) / 12;
|
|
2091
|
+
const baseHi = tHi * (1 - dayMix) + tNext * dayMix;
|
|
2092
|
+
const phase = (hour - 6) / 24 * Math.PI * 2;
|
|
2093
|
+
const swing = (baseHi - tLo) / 2;
|
|
2094
|
+
const mid = (baseHi + tLo) / 2;
|
|
2095
|
+
out.push({
|
|
2096
|
+
datetime: dt.toISOString(),
|
|
2097
|
+
temperature: Math.round((mid + Math.sin(phase) * swing) * 10) / 10,
|
|
2098
|
+
condition: today?.condition ?? entity$1?.state ?? "cloudy"
|
|
2099
|
+
});
|
|
2100
|
+
}
|
|
2101
|
+
return out;
|
|
2102
|
+
}
|
|
2103
|
+
/**
|
|
1959
2104
|
* Fetch forecast for a single entity and type
|
|
1960
2105
|
*/
|
|
1961
2106
|
async function fetchForecast(entityId, type) {
|
|
2107
|
+
if (isDemoMode()) return buildDemoForecast(entityId, type);
|
|
1962
2108
|
const conn = state.conn;
|
|
1963
2109
|
if (!conn) throw new Error("Not connected");
|
|
1964
2110
|
return new Promise((resolve, reject) => {
|
|
@@ -2174,4 +2320,4 @@ async function refreshForecasts(entityId, types) {
|
|
|
2174
2320
|
const VERSION = "0.1.0";
|
|
2175
2321
|
|
|
2176
2322
|
//#endregion
|
|
2177
|
-
export { DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyDemoServiceCall, area, areas, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, callService, createDemoFixtures, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, extractDomain, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, refreshAuth, refreshForecast, refreshForecasts, refreshStream, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startWebRtcSession, state, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
|
|
2323
|
+
export { DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, applyDemoServiceCall, area, areas, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, bulkAppendHistoryPoints, callService, createDemoFixtures, disconnect, entities, entitiesByAreaAndDomain, entitiesWhere, entity, entityIdHistoryNeedsAttributes, entityViewEquals, extractDomain, fetchEntityHistory, fetchForecast, fetchForecastData, fetchForecasts, fetchHistory, fetchStream, fetchStreamData, getAllEntityViews, getAreaView, getAreaViews, getConnection, getEntityView, getEntityViews, getForecast, getForecasts, getStream, getWebRtcClientConfig, historyStateToTimeline, initConnection, isAuthValid, isConnected, isDemoMode, isHistoryTracked, loadDemoData, refreshAuth, refreshForecast, refreshForecasts, refreshStream, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startWebRtcSession, state, toggle, trackEntityHistory, turnOff, turnOn, unloadDemoData, untrackEntityHistory, updateEntity };
|
|
@@ -11,7 +11,7 @@ export declare function buildQuerySignature(query: QueryState): string;
|
|
|
11
11
|
/**
|
|
12
12
|
* Execute query (no caching -- SolidJS createMemo handles this in Plan 03)
|
|
13
13
|
*/
|
|
14
|
-
export declare function memoizedExecute<T>(
|
|
14
|
+
export declare function memoizedExecute<T>(_signature: string, compute: () => T): T;
|
|
15
15
|
/**
|
|
16
16
|
* Check if cache should be invalidated
|
|
17
17
|
*/
|
package/dist/solid/hooks.d.ts
CHANGED
|
@@ -16,16 +16,20 @@ import { callService, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
|
|
|
16
16
|
/**
|
|
17
17
|
* Get a reactive EntityView for an entity ID.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
19
|
+
* Registers the entity for live updates via the subscription manager.
|
|
20
20
|
* Only re-runs when the specific entity's data changes.
|
|
21
21
|
*/
|
|
22
22
|
export declare function useEntity(entityId: Accessor<string> | string): Accessor<EntityView | undefined>;
|
|
23
23
|
/**
|
|
24
24
|
* Get reactive EntityViews for multiple entity IDs.
|
|
25
|
+
*
|
|
26
|
+
* Registers all entity IDs for live updates via the subscription manager.
|
|
25
27
|
*/
|
|
26
28
|
export declare function useEntities(entityIds: Accessor<string[]>): Accessor<EntityView[]>;
|
|
27
29
|
/**
|
|
28
30
|
* Get raw HassEntity state (without EntityView transformation).
|
|
31
|
+
*
|
|
32
|
+
* Registers the entity for live updates via the subscription manager.
|
|
29
33
|
*/
|
|
30
34
|
export declare function useEntityState(entityId: Accessor<string> | string): Accessor<HassEntity | undefined>;
|
|
31
35
|
/**
|
|
@@ -56,6 +60,8 @@ export declare function useToggle(): typeof toggle;
|
|
|
56
60
|
export declare function useAreas(): Accessor<AreaView[]>;
|
|
57
61
|
/**
|
|
58
62
|
* Get a reactive AreaView for a specific area.
|
|
63
|
+
*
|
|
64
|
+
* Registers all entities in the area for live updates.
|
|
59
65
|
*/
|
|
60
66
|
export declare function useArea(areaId: Accessor<string> | string): Accessor<AreaView | undefined>;
|
|
61
67
|
/**
|
package/dist/solid/index.js
CHANGED
|
@@ -1,34 +1,62 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { registerEntity } from "../subscription-manager-gR0qpDYZ.js";
|
|
2
|
+
import { createEffect, createMemo, onCleanup } from "solid-js";
|
|
3
|
+
import { buildAreaView, callService, entityViewEquals, extractDomain, getEntityView, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
|
|
3
4
|
|
|
4
5
|
//#region src/solid/hooks.ts
|
|
5
6
|
/**
|
|
6
7
|
* Get a reactive EntityView for an entity ID.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
+
* Registers the entity for live updates via the subscription manager.
|
|
9
10
|
* Only re-runs when the specific entity's data changes.
|
|
10
11
|
*/
|
|
11
12
|
function useEntity(entityId) {
|
|
12
13
|
const getId = typeof entityId === "function" ? entityId : () => entityId;
|
|
14
|
+
createEffect(() => {
|
|
15
|
+
const id = getId();
|
|
16
|
+
if (!id) return;
|
|
17
|
+
const unregister = registerEntity(id);
|
|
18
|
+
onCleanup(unregister);
|
|
19
|
+
});
|
|
13
20
|
return createMemo(() => {
|
|
14
21
|
const id = getId();
|
|
15
22
|
if (!id) return void 0;
|
|
16
23
|
return getEntityView(id);
|
|
17
|
-
});
|
|
24
|
+
}, void 0, { equals: entityViewEquals });
|
|
18
25
|
}
|
|
19
26
|
/**
|
|
20
27
|
* Get reactive EntityViews for multiple entity IDs.
|
|
28
|
+
*
|
|
29
|
+
* Registers all entity IDs for live updates via the subscription manager.
|
|
21
30
|
*/
|
|
22
31
|
function useEntities(entityIds) {
|
|
32
|
+
createEffect(() => {
|
|
33
|
+
const ids = entityIds();
|
|
34
|
+
const unregisters = ids.map((id) => registerEntity(id));
|
|
35
|
+
onCleanup(() => {
|
|
36
|
+
for (const unreg of unregisters) unreg();
|
|
37
|
+
});
|
|
38
|
+
});
|
|
23
39
|
return createMemo(() => {
|
|
24
40
|
return entityIds().map((id) => getEntityView(id)).filter((v) => v !== void 0);
|
|
25
|
-
})
|
|
41
|
+
}, void 0, { equals: (a, b) => {
|
|
42
|
+
if (a.length !== b.length) return false;
|
|
43
|
+
for (let i = 0; i < a.length; i++) if (!entityViewEquals(a[i], b[i])) return false;
|
|
44
|
+
return true;
|
|
45
|
+
} });
|
|
26
46
|
}
|
|
27
47
|
/**
|
|
28
48
|
* Get raw HassEntity state (without EntityView transformation).
|
|
49
|
+
*
|
|
50
|
+
* Registers the entity for live updates via the subscription manager.
|
|
29
51
|
*/
|
|
30
52
|
function useEntityState(entityId) {
|
|
31
53
|
const getId = typeof entityId === "function" ? entityId : () => entityId;
|
|
54
|
+
createEffect(() => {
|
|
55
|
+
const id = getId();
|
|
56
|
+
if (!id) return;
|
|
57
|
+
const unregister = registerEntity(id);
|
|
58
|
+
onCleanup(unregister);
|
|
59
|
+
});
|
|
32
60
|
return createMemo(() => state.entities[getId()]);
|
|
33
61
|
}
|
|
34
62
|
/**
|
|
@@ -77,14 +105,25 @@ function useAreas() {
|
|
|
77
105
|
return void 0;
|
|
78
106
|
}
|
|
79
107
|
}).filter((v) => v !== void 0);
|
|
80
|
-
})
|
|
108
|
+
}, void 0, { equals: (a, b) => {
|
|
109
|
+
if (a.length !== b.length) return false;
|
|
110
|
+
for (let i = 0; i < a.length; i++) {
|
|
111
|
+
const aItem = a[i];
|
|
112
|
+
const bItem = b[i];
|
|
113
|
+
if (!aItem || !bItem) return false;
|
|
114
|
+
if (aItem.id !== bItem.id || aItem.modifiedAt !== bItem.modifiedAt || aItem.entities.length !== bItem.entities.length) return false;
|
|
115
|
+
}
|
|
116
|
+
return true;
|
|
117
|
+
} });
|
|
81
118
|
}
|
|
82
119
|
/**
|
|
83
120
|
* Get a reactive AreaView for a specific area.
|
|
121
|
+
*
|
|
122
|
+
* Registers all entities in the area for live updates.
|
|
84
123
|
*/
|
|
85
124
|
function useArea(areaId) {
|
|
86
125
|
const getId = typeof areaId === "function" ? areaId : () => areaId;
|
|
87
|
-
|
|
126
|
+
const view = createMemo(() => {
|
|
88
127
|
const id = getId();
|
|
89
128
|
if (!id || !state.areas[id]) return void 0;
|
|
90
129
|
try {
|
|
@@ -93,6 +132,15 @@ function useArea(areaId) {
|
|
|
93
132
|
return void 0;
|
|
94
133
|
}
|
|
95
134
|
});
|
|
135
|
+
createEffect(() => {
|
|
136
|
+
const v = view();
|
|
137
|
+
if (!v) return;
|
|
138
|
+
const unregisters = v.entityIds.map((id) => registerEntity(id));
|
|
139
|
+
onCleanup(() => {
|
|
140
|
+
for (const unreg of unregisters) unreg();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
return view;
|
|
96
144
|
}
|
|
97
145
|
/**
|
|
98
146
|
* Get reactive entity history data.
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
//#region src/connection/subscription-manager.ts
|
|
2
|
+
/** Ref counts — how many consumers need each entity */
|
|
3
|
+
const refCounts = /* @__PURE__ */ new Map();
|
|
4
|
+
/** Current HA subscription unsub function */
|
|
5
|
+
let currentEntityUnsub = null;
|
|
6
|
+
/** Snapshot of entity IDs in the current HA subscription */
|
|
7
|
+
let subscribedIds = /* @__PURE__ */ new Set();
|
|
8
|
+
/** Connection reference for resubscribing */
|
|
9
|
+
let conn = null;
|
|
10
|
+
/** Pending flush timer (microtask for register, timeout for unregister) */
|
|
11
|
+
let flushScheduled = false;
|
|
12
|
+
/** Delayed unregister timeout */
|
|
13
|
+
let unregisterTimeout = null;
|
|
14
|
+
/** Callback invoked on resubscribe — set by subscriptions.ts */
|
|
15
|
+
let onResubscribe = null;
|
|
16
|
+
/** Delay before unregistered entities are actually removed from subscription */
|
|
17
|
+
const UNREGISTER_DELAY_MS = 5e3;
|
|
18
|
+
/** Guard against concurrent flush() calls */
|
|
19
|
+
let flushInFlight = false;
|
|
20
|
+
/**
|
|
21
|
+
* Register an entity for live updates. Returns an unregister function.
|
|
22
|
+
* Call from useEntity/useEntities hooks via onCleanup.
|
|
23
|
+
*/
|
|
24
|
+
function registerEntity(entityId) {
|
|
25
|
+
refCounts.set(entityId, (refCounts.get(entityId) ?? 0) + 1);
|
|
26
|
+
scheduleFlush();
|
|
27
|
+
return () => unregisterEntity(entityId);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Set the connection reference used for resubscribing.
|
|
31
|
+
*/
|
|
32
|
+
function setManagerConnection(connection) {
|
|
33
|
+
conn = connection;
|
|
34
|
+
if (refCounts.size > 0) scheduleFlush();
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Set the resubscribe callback. Called by subscriptions.ts to wire
|
|
38
|
+
* the manager into the actual WS subscription lifecycle.
|
|
39
|
+
*/
|
|
40
|
+
function setResubscribeHandler(handler) {
|
|
41
|
+
onResubscribe = handler;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Force a resubscribe with the current active set.
|
|
45
|
+
* Used on reconnect.
|
|
46
|
+
*/
|
|
47
|
+
async function forceResubscribe() {
|
|
48
|
+
await flush();
|
|
49
|
+
}
|
|
50
|
+
function unregisterEntity(entityId) {
|
|
51
|
+
const count = (refCounts.get(entityId) ?? 1) - 1;
|
|
52
|
+
if (count <= 0) refCounts.delete(entityId);
|
|
53
|
+
else refCounts.set(entityId, count);
|
|
54
|
+
scheduleDelayedFlush();
|
|
55
|
+
}
|
|
56
|
+
function scheduleFlush() {
|
|
57
|
+
if (flushScheduled) return;
|
|
58
|
+
flushScheduled = true;
|
|
59
|
+
queueMicrotask(() => {
|
|
60
|
+
flushScheduled = false;
|
|
61
|
+
flush();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
function scheduleDelayedFlush() {
|
|
65
|
+
if (unregisterTimeout) return;
|
|
66
|
+
unregisterTimeout = setTimeout(() => {
|
|
67
|
+
unregisterTimeout = null;
|
|
68
|
+
flush();
|
|
69
|
+
}, UNREGISTER_DELAY_MS);
|
|
70
|
+
}
|
|
71
|
+
async function flush() {
|
|
72
|
+
if (!conn || !onResubscribe) return;
|
|
73
|
+
if (flushInFlight) return;
|
|
74
|
+
flushInFlight = true;
|
|
75
|
+
try {
|
|
76
|
+
const newIds = new Set(refCounts.keys());
|
|
77
|
+
if (setsEqual(newIds, subscribedIds)) return;
|
|
78
|
+
const oldUnsub = currentEntityUnsub;
|
|
79
|
+
const entityIds = [...newIds];
|
|
80
|
+
currentEntityUnsub = await onResubscribe(conn, entityIds);
|
|
81
|
+
subscribedIds = newIds;
|
|
82
|
+
if (oldUnsub) try {
|
|
83
|
+
await oldUnsub();
|
|
84
|
+
} catch {}
|
|
85
|
+
} finally {
|
|
86
|
+
flushInFlight = false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function setsEqual(a, b) {
|
|
90
|
+
if (a.size !== b.size) return false;
|
|
91
|
+
for (const id of a) if (!b.has(id)) return false;
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
//#endregion
|
|
96
|
+
export { forceResubscribe, registerEntity, setManagerConnection, setResubscribeHandler };
|
package/dist/testing/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glasshome/sync-layer",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Type-safe, reactive state synchronization layer for Home Assistant",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"dist"
|
|
45
45
|
],
|
|
46
46
|
"scripts": {
|
|
47
|
-
"build": "tsdown",
|
|
47
|
+
"build": "tsdown && tsc --noEmit",
|
|
48
48
|
"dev": "tsdown --watch",
|
|
49
49
|
"test": "bun test",
|
|
50
50
|
"lint": "tsc --noEmit"
|
|
@@ -53,7 +53,9 @@
|
|
|
53
53
|
"home-assistant-js-websocket": "^9.6.0"
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
|
+
"@glasshome/ha-types": "0.1.0",
|
|
56
57
|
"@types/bun": "latest",
|
|
58
|
+
"solid-js": "^1.9.0",
|
|
57
59
|
"tsdown": "^0.2.0",
|
|
58
60
|
"typescript": "^5"
|
|
59
61
|
},
|