@glasshome/sync-layer 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
  */
@@ -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: {
@@ -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;
@@ -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,12 +28,14 @@ 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";
38
39
  export type { EntityForecastQueryOptions, ForecastQueryOptions, ForecastResult, ForecastsResult, ForecastType, WeatherForecast, WeatherForecastData, WeatherForecasts, WeatherForecastsData, } from "./weather/types";
40
+ export { applyDemoServiceCall, createDemoFixtures, isDemoMode, loadDemoData, unloadDemoData, } from "./demo/demo-provider";
39
41
  export declare const VERSION = "0.1.0";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
- import { resetStore, setState, state } from "./demo-data-gELwUOS_.js";
2
- import { applyDemoServiceCall, extractDomain } from "./demo-provider-I4BC9Woh.js";
1
+ import { createDemoFixtures, resetStore, setState, state } from "./demo-data-gELwUOS_.js";
2
+ import { forceResubscribe, setManagerConnection, setResubscribeHandler } from "./subscription-manager-gR0qpDYZ.js";
3
3
  import { callService as callService$1, createConnection, createLongLivedTokenAuth, getAuth } from "home-assistant-js-websocket";
4
- import { produce } from "solid-js/store";
4
+ import { produce, reconcile } from "solid-js/store";
5
5
 
6
6
  //#region src/connection/adapter.ts
7
7
  /**
@@ -395,6 +395,114 @@ async function refreshAuth(auth) {
395
395
  return auth;
396
396
  }
397
397
 
398
+ //#endregion
399
+ //#region src/core/types.ts
400
+ /**
401
+ * Extract domain from entity ID
402
+ */
403
+ function extractDomain(entityId) {
404
+ return entityId.split(".")[0] ?? "";
405
+ }
406
+
407
+ //#endregion
408
+ //#region src/demo/demo-provider.ts
409
+ let _isDemoMode = false;
410
+ /**
411
+ * Whether demo mode is currently active.
412
+ */
413
+ function isDemoMode() {
414
+ return _isDemoMode;
415
+ }
416
+ /**
417
+ * Load demo fixtures into the SolidJS store.
418
+ *
419
+ * Populates entities, entity registry, areas, and devices.
420
+ * Sets connection state to "connected" (pretend connected).
421
+ */
422
+ async function loadDemoData() {
423
+ const fixtures = createDemoFixtures();
424
+ setState("entities", reconcile(fixtures.entities));
425
+ setState("entityRegistry", reconcile(fixtures.entityRegistry));
426
+ setState("areas", reconcile(fixtures.areas));
427
+ setState("devices", reconcile(fixtures.devices));
428
+ setState("hassUrl", "https://demo.home-assistant.local");
429
+ setState("connectionState", "connected");
430
+ _isDemoMode = true;
431
+ }
432
+ /**
433
+ * Unload demo data and reset to disconnected state.
434
+ */
435
+ function unloadDemoData() {
436
+ setState("entities", reconcile({}));
437
+ setState("entityRegistry", reconcile({}));
438
+ setState("areas", reconcile({}));
439
+ setState("devices", reconcile({}));
440
+ setState("hassUrl", null);
441
+ setState("connectionState", "disconnected");
442
+ _isDemoMode = false;
443
+ }
444
+ /**
445
+ * Apply an optimistic state update for demo mode.
446
+ *
447
+ * When a service call is made in demo mode, this updates the entity
448
+ * state locally (no WebSocket message sent). State resets on page refresh.
449
+ */
450
+ function applyDemoServiceCall(domain, service, _serviceData = {}, target = {}) {
451
+ if (!_isDemoMode) return;
452
+ const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
453
+ for (const entityId of entityIds) {
454
+ const entity$1 = state.entities[entityId];
455
+ if (!entity$1) continue;
456
+ const entityDomain = extractDomain(entityId);
457
+ setState(produce((s) => {
458
+ const e = s.entities[entityId];
459
+ if (!e) return;
460
+ const now = (/* @__PURE__ */ new Date()).toISOString();
461
+ e.last_changed = now;
462
+ e.last_updated = now;
463
+ if (service === "turn_on") applyTurnOn(e, entityDomain, _serviceData);
464
+ else if (service === "turn_off") applyTurnOff(e, entityDomain);
465
+ else if (service === "toggle") if (e.state === "on" || e.state === "playing" || e.state === "open") applyTurnOff(e, entityDomain);
466
+ else applyTurnOn(e, entityDomain, _serviceData);
467
+ else if (service === "lock") e.state = "locked";
468
+ else if (service === "unlock") e.state = "unlocked";
469
+ else if (service === "open_cover") {
470
+ e.state = "open";
471
+ e.attributes.current_position = 100;
472
+ } else if (service === "close_cover") {
473
+ e.state = "closed";
474
+ e.attributes.current_position = 0;
475
+ } else if (service === "set_cover_position" && _serviceData.position != null) {
476
+ e.attributes.current_position = _serviceData.position;
477
+ e.state = _serviceData.position > 0 ? "open" : "closed";
478
+ } else if (service === "set_temperature" && _serviceData.temperature != null) e.attributes.temperature = _serviceData.temperature;
479
+ else if (service === "set_hvac_mode" && _serviceData.hvac_mode != null) e.state = _serviceData.hvac_mode;
480
+ else if (service === "volume_set" && _serviceData.volume_level != null) e.attributes.volume_level = _serviceData.volume_level;
481
+ }));
482
+ }
483
+ }
484
+ function applyTurnOn(e, domain, serviceData = {}) {
485
+ if (domain === "light") {
486
+ e.state = "on";
487
+ e.attributes.brightness = serviceData.brightness ?? e.attributes.brightness ?? 255;
488
+ if (serviceData.color_temp_kelvin) e.attributes.color_temp_kelvin = serviceData.color_temp_kelvin;
489
+ } else if (domain === "media_player") e.state = "playing";
490
+ else if (domain === "cover") {
491
+ e.state = "open";
492
+ e.attributes.current_position = 100;
493
+ } else e.state = "on";
494
+ }
495
+ function applyTurnOff(e, domain) {
496
+ if (domain === "light") {
497
+ e.state = "off";
498
+ delete e.attributes.brightness;
499
+ } else if (domain === "media_player") e.state = "paused";
500
+ else if (domain === "cover") {
501
+ e.state = "closed";
502
+ e.attributes.current_position = 0;
503
+ } else e.state = "off";
504
+ }
505
+
398
506
  //#endregion
399
507
  //#region src/commands/service.ts
400
508
  /**
@@ -649,60 +757,120 @@ function appendHistoryPoint(entityId, stateValue, attributes, lastUpdated, lastC
649
757
  });
650
758
  }));
651
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
+ }
652
786
 
653
787
  //#endregion
654
788
  //#region src/connection/subscriptions.ts
655
- /** Active subscription cleanup functions (managed externally from store) */
656
- let activeSubscriptions = [];
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
+ }
657
835
  /**
658
836
  * Subscribe to all relevant events from Home Assistant
659
837
  */
660
838
  async function subscribeToUpdates(conn) {
661
839
  await cleanupSubscriptions();
840
+ setManagerConnection(conn);
841
+ setResubscribeHandler(subscribeEntities);
662
842
  const registryUpdatedSub = await conn.subscribeEvents((event) => {
663
843
  handleRegistryUpdate(event);
664
844
  }, "entity_registry_updated");
665
- activeSubscriptions.push(() => registryUpdatedSub());
845
+ registrySubscriptions.push(() => registryUpdatedSub());
666
846
  const deviceRegistrySub = await conn.subscribeEvents((event) => {
667
847
  handleDeviceRegistryUpdate(event);
668
848
  }, "device_registry_updated");
669
- activeSubscriptions.push(() => deviceRegistrySub());
849
+ registrySubscriptions.push(() => deviceRegistrySub());
670
850
  const areaRegistrySub = await conn.subscribeEvents((event) => {
671
851
  handleAreaRegistryUpdate(event);
672
852
  }, "area_registry_updated");
673
- activeSubscriptions.push(() => areaRegistrySub());
853
+ registrySubscriptions.push(() => areaRegistrySub());
674
854
  const floorRegistrySub = await conn.subscribeEvents((event) => {
675
855
  handleFloorRegistryUpdate(event);
676
856
  }, "floor_registry_updated");
677
- activeSubscriptions.push(() => floorRegistrySub());
857
+ registrySubscriptions.push(() => floorRegistrySub());
678
858
  const labelRegistrySub = await conn.subscribeEvents((event) => {
679
859
  handleLabelRegistryUpdate(event);
680
860
  }, "label_registry_updated");
681
- activeSubscriptions.push(() => labelRegistrySub());
861
+ registrySubscriptions.push(() => labelRegistrySub());
682
862
  const statisticsSub = await conn.subscribeEvents((event) => {
683
863
  handleStatisticsUpdate(event);
684
864
  }, "recorder_5min_statistics_generated");
685
- activeSubscriptions.push(() => statisticsSub());
686
- const entityUpdatesSub = await conn.subscribeMessage((message) => {
687
- setState(produce((s) => {
688
- if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) applyCompressedState(s, entityId, compressedState);
689
- if (message.c) for (const [entityId, diff] of Object.entries(message.c)) applyStateDiff(s, entityId, diff);
690
- if (message.r) for (const entityId of message.r) delete s.entities[entityId];
691
- }));
692
- if (message.a) for (const [entityId, compressedState] of Object.entries(message.a)) maybeAppendHistory(entityId, compressedState);
693
- if (message.c) {
694
- for (const [entityId, diff] of Object.entries(message.c)) if (diff["+"]) maybeAppendHistoryFromDiff(entityId, diff["+"]);
695
- }
696
- }, { type: "subscribe_entities" });
697
- activeSubscriptions.push(() => entityUpdatesSub());
865
+ registrySubscriptions.push(() => statisticsSub());
698
866
  }
699
867
  /**
700
868
  * Clean up all active subscriptions
701
869
  */
702
870
  async function cleanupSubscriptions() {
703
- const subscriptions = [...activeSubscriptions];
871
+ const subscriptions = [...registrySubscriptions];
704
872
  for (const unsub of subscriptions) await unsub();
705
- activeSubscriptions = [];
873
+ registrySubscriptions = [];
706
874
  }
707
875
  async function handleRegistryUpdate(event) {
708
876
  const { action, entity_id } = event.data;
@@ -741,24 +909,8 @@ async function handleAreaRegistryUpdate(event) {
741
909
  if (!conn) return;
742
910
  try {
743
911
  const areas$1 = await conn.sendMessagePromise({ type: "config/area_registry/list" });
744
- const areaApi = areas$1.find((a) => a.area_id === area_id || a.id === area_id);
745
- if (areaApi) {
746
- const area$1 = {
747
- id: areaApi.area_id || areaApi.id,
748
- name: areaApi.name,
749
- normalized_name: areaApi.normalized_name || areaApi.name.toLowerCase().replace(/\s+/g, "_"),
750
- aliases: Array.isArray(areaApi.aliases) ? areaApi.aliases : [],
751
- floor_id: areaApi.floor_id ?? null,
752
- humidity_entity_id: areaApi.humidity_entity_id ?? null,
753
- icon: areaApi.icon ?? null,
754
- labels: Array.isArray(areaApi.labels) ? areaApi.labels : [],
755
- picture: areaApi.picture ?? null,
756
- temperature_entity_id: areaApi.temperature_entity_id ?? null,
757
- created_at: typeof areaApi.created_at === "number" ? new Date(areaApi.created_at * 1e3).toISOString() : areaApi.created_at,
758
- modified_at: typeof areaApi.modified_at === "number" ? new Date(areaApi.modified_at * 1e3).toISOString() : areaApi.modified_at
759
- };
760
- updateArea(area$1);
761
- }
912
+ const area$1 = areas$1.find((a) => a.id === area_id);
913
+ if (area$1) updateArea(area$1);
762
914
  } catch (error) {
763
915
  console.error("Error fetching area:", error);
764
916
  }
@@ -812,21 +964,39 @@ function timestampToIso(timestamp) {
812
964
  if (typeof timestamp === "string") return timestamp;
813
965
  return new Date(timestamp * 1e3).toISOString();
814
966
  }
815
- function maybeAppendHistory(entityId, compressed) {
967
+ function collectHistoryFromCompressed(points, entityId, compressed) {
816
968
  if (!isHistoryTracked(entityId)) return;
817
969
  const stateVal = compressed.s ?? compressed.state ?? "unknown";
818
970
  const attributes = compressed.a ?? compressed.attributes ?? {};
819
971
  const lu = compressed.lu ?? (compressed.last_updated ? new Date(compressed.last_updated).getTime() / 1e3 : Date.now() / 1e3);
820
972
  const lc = compressed.lc ?? (compressed.last_changed ? new Date(compressed.last_changed).getTime() / 1e3 : void 0);
821
- appendHistoryPoint(entityId, stateVal, attributes, lu, lc);
973
+ points.push({
974
+ entityId,
975
+ stateValue: stateVal,
976
+ attributes,
977
+ lastUpdated: lu,
978
+ lastChanged: lc
979
+ });
822
980
  }
823
- function maybeAppendHistoryFromDiff(entityId, additions) {
981
+ function collectHistoryFromDiff(points, entityId, additions) {
824
982
  if (!isHistoryTracked(entityId)) return;
825
- const entity$1 = state.entities[entityId];
826
- if (!entity$1) return;
983
+ const stateVal = additions.s ?? additions.state;
984
+ if (stateVal === void 0) return;
827
985
  const lu = additions.lu ?? Date.now() / 1e3;
828
986
  const lc = additions.lc;
829
- appendHistoryPoint(entityId, entity$1.state, entity$1.attributes, lu, lc);
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;
830
1000
  }
831
1001
  function applyCompressedState(s, entityId, compressed) {
832
1002
  const stateVal = compressed.s ?? compressed.state ?? "unknown";
@@ -838,7 +1008,16 @@ function applyCompressedState(s, entityId, compressed) {
838
1008
  };
839
1009
  const lastChanged = timestampToIso(compressed.lc ?? compressed.last_changed);
840
1010
  const lastUpdated = timestampToIso(compressed.lu ?? compressed.last_updated);
841
- const entity$1 = {
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] = {
842
1021
  entity_id: entityId,
843
1022
  state: stateVal,
844
1023
  attributes,
@@ -846,18 +1025,32 @@ function applyCompressedState(s, entityId, compressed) {
846
1025
  last_updated: lastUpdated,
847
1026
  context
848
1027
  };
849
- s.entities[entityId] = entity$1;
850
1028
  }
851
1029
  function applyStateDiff(s, entityId, diff) {
852
1030
  const existing = s.entities[entityId];
853
1031
  if (!existing) return;
854
1032
  const additions = diff["+"];
855
1033
  if (additions) {
856
- if (additions.s !== void 0) existing.state = additions.s;
857
- if (additions.a) Object.assign(existing.attributes, additions.a);
858
- if (additions.lc !== void 0) existing.last_changed = timestampToIso(additions.lc);
859
- if (additions.lu !== void 0) existing.last_updated = timestampToIso(additions.lu);
860
- if (additions.c) existing.context = additions.c;
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;
861
1054
  }
862
1055
  const removals = diff["-"];
863
1056
  if (removals?.a) for (const key of removals.a) delete existing.attributes[key];
@@ -946,6 +1139,7 @@ function setupEventHandlers(conn, options) {
946
1139
  }));
947
1140
  if (wasReconnecting) {
948
1141
  await loadInitialData(conn);
1142
+ await forceResubscribe();
949
1143
  options.onReconnect?.();
950
1144
  }
951
1145
  });
@@ -990,77 +1184,6 @@ async function loadInitialData(conn) {
990
1184
  }
991
1185
  }
992
1186
 
993
- //#endregion
994
- //#region src/entities/index.ts
995
- /**
996
- * Get all entity IDs
997
- */
998
- function getAllEntityIds() {
999
- return Object.keys(state.entities);
1000
- }
1001
- /**
1002
- * Get entities by domain
1003
- */
1004
- function getEntitiesByDomain(domain) {
1005
- return Object.keys(state.entities).filter((id) => extractDomain(id) === domain);
1006
- }
1007
- /**
1008
- * Get entities via devices in area
1009
- *
1010
- * Only includes entities that inherit area from device (entity.area_id is null).
1011
- */
1012
- function getEntitiesViaDevices(areaId) {
1013
- const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
1014
- const entityIds = [];
1015
- for (const device of devicesInArea) {
1016
- const deviceEntities = getEntitiesByDevice(device.id);
1017
- for (const entityId of deviceEntities) {
1018
- const registry = state.entityRegistry[entityId];
1019
- if (!registry?.area_id) entityIds.push(entityId);
1020
- }
1021
- }
1022
- return entityIds;
1023
- }
1024
- /**
1025
- * Get entities by area
1026
- *
1027
- * Returns entities with direct area_id assignments and entities that inherit their area from devices.
1028
- */
1029
- function getEntitiesByArea(areaId) {
1030
- const directEntityIds = [];
1031
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
1032
- const deviceEntityIds = getEntitiesViaDevices(areaId);
1033
- return Array.from(new Set([...directEntityIds, ...deviceEntityIds]));
1034
- }
1035
- /**
1036
- * Get entities by device
1037
- */
1038
- function getEntitiesByDevice(deviceId) {
1039
- const entityIds = [];
1040
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.device_id === deviceId) entityIds.push(entityId);
1041
- return entityIds;
1042
- }
1043
- /**
1044
- * Get entities by floor
1045
- */
1046
- function getEntitiesByFloor(floorId) {
1047
- const entityIds = [];
1048
- const areasOnFloor = Object.values(state.areas).filter((area$1) => area$1.floor_id === floorId);
1049
- for (const area$1 of areasOnFloor) {
1050
- const areaEntities = getEntitiesByArea(area$1.id);
1051
- entityIds.push(...areaEntities);
1052
- }
1053
- return entityIds;
1054
- }
1055
- /**
1056
- * Get entities by label
1057
- */
1058
- function getEntitiesByLabel(labelId) {
1059
- const entityIds = [];
1060
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.labels?.includes(labelId)) entityIds.push(entityId);
1061
- return entityIds;
1062
- }
1063
-
1064
1187
  //#endregion
1065
1188
  //#region src/entities/views.ts
1066
1189
  /**
@@ -1146,9 +1269,29 @@ function getEntityViews(entityIds) {
1146
1269
  function getAllEntityViews() {
1147
1270
  return getEntityViews(Object.keys(state.entities));
1148
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
+ }
1149
1281
 
1150
1282
  //#endregion
1151
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
+ }
1152
1295
  /**
1153
1296
  * Build a DeviceView from registry data
1154
1297
  */
@@ -1173,12 +1316,10 @@ function buildDeviceView(device) {
1173
1316
  function buildAreaView(areaId) {
1174
1317
  const area$1 = state.areas[areaId];
1175
1318
  if (!area$1) throw new Error(`Area ${areaId} not found`);
1176
- const directEntityIds = [];
1177
- for (const [entityId, entry] of Object.entries(state.entityRegistry)) if (entry.area_id === areaId) directEntityIds.push(entityId);
1178
- const deviceEntityIds = getEntitiesViaDevices(areaId);
1179
- 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);
1180
1322
  const areaEntities = allEntityIds.length > 0 ? getEntityViews(allEntityIds) : [];
1181
- const devicesInArea = Object.values(state.devices).filter((device) => device.area_id === areaId);
1182
1323
  const devices = devicesInArea.map((device) => buildDeviceView(device));
1183
1324
  return {
1184
1325
  id: areaId,
@@ -1309,6 +1450,77 @@ function area(areaId) {
1309
1450
  return getAreaView(areaId);
1310
1451
  }
1311
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
+
1312
1524
  //#endregion
1313
1525
  //#region src/query/memoization.ts
1314
1526
  /**
@@ -2067,4 +2279,4 @@ async function refreshForecasts(entityId, types) {
2067
2279
  const VERSION = "0.1.0";
2068
2280
 
2069
2281
  //#endregion
2070
- export { DOMAINS_USE_LAST_UPDATED, LINE_ATTRIBUTES_TO_KEEP, NEED_ATTRIBUTE_DOMAINS, VERSION, addDebugIncomingMessageListener, addDebugOutgoingMessageListener, appendHistoryPoint, area, areas, authenticateWithOAuth, authenticateWithToken, batchEntityUpdates, batchServiceCalls, buildAreaView, buildDeviceView, buildEntityView, callService, 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, isHistoryTracked, refreshAuth, refreshForecast, refreshForecasts, refreshStream, resetStore, searchEntities, sendCommand, sendWebRtcCandidate, setState, startWebRtcSession, state, toggle, trackEntityHistory, turnOff, turnOn, untrackEntityHistory, updateEntity };
2282
+ 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 };
@@ -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
- * Direct createMemo over store -- zero overhead, fine-grained reactivity.
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
  /**
@@ -1,34 +1,62 @@
1
- import { createMemo } from "solid-js";
2
- import { buildAreaView, callService, extractDomain, getEntityView, state, toggle, turnOff, turnOn } from "@glasshome/sync-layer";
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
- * Direct createMemo over store -- zero overhead, fine-grained reactivity.
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,20 @@ 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++) if (a[i].id !== b[i].id || a[i].modifiedAt !== b[i].modifiedAt || a[i].entities.length !== b[i].entities.length) return false;
111
+ return true;
112
+ } });
81
113
  }
82
114
  /**
83
115
  * Get a reactive AreaView for a specific area.
116
+ *
117
+ * Registers all entities in the area for live updates.
84
118
  */
85
119
  function useArea(areaId) {
86
120
  const getId = typeof areaId === "function" ? areaId : () => areaId;
87
- return createMemo(() => {
121
+ const view = createMemo(() => {
88
122
  const id = getId();
89
123
  if (!id || !state.areas[id]) return void 0;
90
124
  try {
@@ -93,6 +127,15 @@ function useArea(areaId) {
93
127
  return void 0;
94
128
  }
95
129
  });
130
+ createEffect(() => {
131
+ const v = view();
132
+ if (!v) return;
133
+ const unregisters = v.entityIds.map((id) => registerEntity(id));
134
+ onCleanup(() => {
135
+ for (const unreg of unregisters) unreg();
136
+ });
137
+ });
138
+ return view;
96
139
  }
97
140
  /**
98
141
  * 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glasshome/sync-layer",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Type-safe, reactive state synchronization layer for Home Assistant",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,11 +26,6 @@
26
26
  "types": "./dist/testing/index.d.ts",
27
27
  "import": "./dist/testing/index.js"
28
28
  },
29
- "./demo": {
30
- "@glasshome/source": "./src/demo/demo-provider.ts",
31
- "types": "./dist/demo/demo-provider.d.ts",
32
- "import": "./dist/demo/demo-provider.js"
33
- },
34
29
  "./package.json": "./package.json"
35
30
  },
36
31
  "peerDependencies": {
@@ -1,4 +0,0 @@
1
- import { createDemoFixtures } from "../demo-data-gELwUOS_.js";
2
- import { applyDemoServiceCall, isDemoMode, loadDemoData, unloadDemoData } from "../demo-provider-I4BC9Woh.js";
3
-
4
- export { applyDemoServiceCall, createDemoFixtures, isDemoMode, loadDemoData, unloadDemoData };
@@ -1,112 +0,0 @@
1
- import { createDemoFixtures, setState, state } from "./demo-data-gELwUOS_.js";
2
- import { produce, reconcile } from "solid-js/store";
3
-
4
- //#region src/core/types.ts
5
- /**
6
- * Extract domain from entity ID
7
- */
8
- function extractDomain(entityId) {
9
- return entityId.split(".")[0] ?? "";
10
- }
11
-
12
- //#endregion
13
- //#region src/demo/demo-provider.ts
14
- let _isDemoMode = false;
15
- /**
16
- * Whether demo mode is currently active.
17
- */
18
- function isDemoMode() {
19
- return _isDemoMode;
20
- }
21
- /**
22
- * Load demo fixtures into the SolidJS store.
23
- *
24
- * Populates entities, entity registry, areas, and devices.
25
- * Sets connection state to "connected" (pretend connected).
26
- */
27
- async function loadDemoData() {
28
- const fixtures = createDemoFixtures();
29
- setState("entities", reconcile(fixtures.entities));
30
- setState("entityRegistry", reconcile(fixtures.entityRegistry));
31
- setState("areas", reconcile(fixtures.areas));
32
- setState("devices", reconcile(fixtures.devices));
33
- setState("hassUrl", "https://demo.home-assistant.local");
34
- setState("connectionState", "connected");
35
- _isDemoMode = true;
36
- }
37
- /**
38
- * Unload demo data and reset to disconnected state.
39
- */
40
- function unloadDemoData() {
41
- setState("entities", reconcile({}));
42
- setState("entityRegistry", reconcile({}));
43
- setState("areas", reconcile({}));
44
- setState("devices", reconcile({}));
45
- setState("hassUrl", null);
46
- setState("connectionState", "disconnected");
47
- _isDemoMode = false;
48
- }
49
- /**
50
- * Apply an optimistic state update for demo mode.
51
- *
52
- * When a service call is made in demo mode, this updates the entity
53
- * state locally (no WebSocket message sent). State resets on page refresh.
54
- */
55
- function applyDemoServiceCall(domain, service, _serviceData = {}, target = {}) {
56
- if (!_isDemoMode) return;
57
- const entityIds = Array.isArray(target.entity_id) ? target.entity_id : target.entity_id ? [target.entity_id] : [];
58
- for (const entityId of entityIds) {
59
- const entity = state.entities[entityId];
60
- if (!entity) continue;
61
- const entityDomain = extractDomain(entityId);
62
- setState(produce((s) => {
63
- const e = s.entities[entityId];
64
- if (!e) return;
65
- const now = (/* @__PURE__ */ new Date()).toISOString();
66
- e.last_changed = now;
67
- e.last_updated = now;
68
- if (service === "turn_on") applyTurnOn(e, entityDomain, _serviceData);
69
- else if (service === "turn_off") applyTurnOff(e, entityDomain);
70
- else if (service === "toggle") if (e.state === "on" || e.state === "playing" || e.state === "open") applyTurnOff(e, entityDomain);
71
- else applyTurnOn(e, entityDomain, _serviceData);
72
- else if (service === "lock") e.state = "locked";
73
- else if (service === "unlock") e.state = "unlocked";
74
- else if (service === "open_cover") {
75
- e.state = "open";
76
- e.attributes.current_position = 100;
77
- } else if (service === "close_cover") {
78
- e.state = "closed";
79
- e.attributes.current_position = 0;
80
- } else if (service === "set_cover_position" && _serviceData.position != null) {
81
- e.attributes.current_position = _serviceData.position;
82
- e.state = _serviceData.position > 0 ? "open" : "closed";
83
- } else if (service === "set_temperature" && _serviceData.temperature != null) e.attributes.temperature = _serviceData.temperature;
84
- else if (service === "set_hvac_mode" && _serviceData.hvac_mode != null) e.state = _serviceData.hvac_mode;
85
- else if (service === "volume_set" && _serviceData.volume_level != null) e.attributes.volume_level = _serviceData.volume_level;
86
- }));
87
- }
88
- }
89
- function applyTurnOn(e, domain, serviceData = {}) {
90
- if (domain === "light") {
91
- e.state = "on";
92
- e.attributes.brightness = serviceData.brightness ?? e.attributes.brightness ?? 255;
93
- if (serviceData.color_temp_kelvin) e.attributes.color_temp_kelvin = serviceData.color_temp_kelvin;
94
- } else if (domain === "media_player") e.state = "playing";
95
- else if (domain === "cover") {
96
- e.state = "open";
97
- e.attributes.current_position = 100;
98
- } else e.state = "on";
99
- }
100
- function applyTurnOff(e, domain) {
101
- if (domain === "light") {
102
- e.state = "off";
103
- delete e.attributes.brightness;
104
- } else if (domain === "media_player") e.state = "paused";
105
- else if (domain === "cover") {
106
- e.state = "closed";
107
- e.attributes.current_position = 0;
108
- } else e.state = "off";
109
- }
110
-
111
- //#endregion
112
- export { applyDemoServiceCall, extractDomain, isDemoMode, loadDemoData, unloadDemoData };