preact-homeassistant 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,29 +1,11 @@
1
1
  import { jsx, jsxs } from "preact/jsx-runtime";
2
2
  import { createContext, render } from "preact";
3
3
  import { useRef, useMemo, useState, useEffect, useContext } from "preact/hooks";
4
- const CACHE_PREFIX = "preact-ha:";
5
- const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1e3;
6
- function loadFromCache(key) {
7
- try {
8
- const raw = localStorage.getItem(`${CACHE_PREFIX}${key}`);
9
- if (!raw) return void 0;
10
- const entry = JSON.parse(raw);
11
- if (Date.now() - entry.timestamp > CACHE_EXPIRY_MS) {
12
- localStorage.removeItem(`${CACHE_PREFIX}${key}`);
13
- return void 0;
14
- }
15
- return entry.data;
16
- } catch {
17
- return void 0;
18
- }
4
+ function readCache(cache, key) {
5
+ return cache.get(key)?.data;
19
6
  }
20
- function saveToCache(key, data) {
21
- try {
22
- const entry = { data, timestamp: Date.now() };
23
- localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(entry));
24
- } catch (e) {
25
- console.warn("[preact-homeassistant cache] Failed to save:", e);
26
- }
7
+ function writeCache(cache, key, data) {
8
+ cache.set(key, { data });
27
9
  }
28
10
  function useCallbackStable(callback) {
29
11
  const callbackRef = useRef(callback);
@@ -36,18 +18,30 @@ function useCallbackStable(callback) {
36
18
  }
37
19
  return stableRef.current;
38
20
  }
21
+ const noopSubscribeToHass = () => () => {
22
+ };
39
23
  const HAContext = createContext(null);
40
- function HAProvider({ hass, subscribeToEntity, children }) {
24
+ function HAProvider({
25
+ hass,
26
+ subscribeToEntity,
27
+ subscribeToHass,
28
+ cache,
29
+ children
30
+ }) {
41
31
  const hassRef = useRef(hass);
42
32
  hassRef.current = hass;
43
33
  const getHass = useCallbackStable(() => hassRef.current);
34
+ const cacheRef = useRef();
35
+ if (!cacheRef.current) cacheRef.current = cache ?? /* @__PURE__ */ new Map();
36
+ const resolvedSubscribeToHass = subscribeToHass ?? noopSubscribeToHass;
44
37
  const store = useMemo(
45
38
  () => ({
46
- hass: hassRef.current,
47
39
  getHass,
48
- subscribeToEntity
40
+ subscribeToEntity,
41
+ subscribeToHass: resolvedSubscribeToHass,
42
+ cache: cacheRef.current
49
43
  }),
50
- [getHass, subscribeToEntity]
44
+ [getHass, subscribeToEntity, resolvedSubscribeToHass]
51
45
  );
52
46
  return /* @__PURE__ */ jsx(HAContext.Provider, { value: store, children });
53
47
  }
@@ -62,23 +56,45 @@ function useEntity(entityId) {
62
56
  const store = useHAStore();
63
57
  const cacheKey = `entity:${entityId}`;
64
58
  const [entity, setEntity] = useState(() => {
65
- const current = store.hass?.states[entityId];
59
+ const current = store.getHass()?.states[entityId];
66
60
  if (current) return current;
67
- return loadFromCache(cacheKey);
61
+ return readCache(store.cache, cacheKey);
68
62
  });
69
63
  useEffect(() => {
70
64
  const unsubscribe = store.subscribeToEntity(entityId, (newEntity) => {
71
65
  setEntity(newEntity);
72
- saveToCache(cacheKey, newEntity);
66
+ writeCache(store.cache, cacheKey, newEntity);
73
67
  });
74
68
  return unsubscribe;
75
- }, [entityId, store.subscribeToEntity, cacheKey]);
69
+ }, [entityId, store.subscribeToEntity, store.cache, cacheKey]);
76
70
  return entity;
77
71
  }
78
72
  function useHass() {
79
73
  const store = useHAStore();
80
74
  return { getHass: store.getHass };
81
75
  }
76
+ function useHassValue(selector, isEqual = Object.is) {
77
+ const store = useHAStore();
78
+ const selectorRef = useRef(selector);
79
+ selectorRef.current = selector;
80
+ const isEqualRef = useRef(isEqual);
81
+ isEqualRef.current = isEqual;
82
+ const [value, setValue] = useState(() => selectorRef.current(store.getHass()));
83
+ useEffect(() => {
84
+ const unsubscribe = store.subscribeToHass(() => {
85
+ const next = selectorRef.current(store.getHass());
86
+ setValue((prev) => isEqualRef.current(prev, next) ? prev : next);
87
+ });
88
+ return unsubscribe;
89
+ }, [store.subscribeToHass, store.getHass]);
90
+ return value;
91
+ }
92
+ function useHassConfig() {
93
+ return useHassValue((hass) => hass?.config);
94
+ }
95
+ function useDarkMode() {
96
+ return useHassValue((hass) => hass?.themes?.darkMode ?? false);
97
+ }
82
98
  function useService(entityId) {
83
99
  const { getHass } = useHass();
84
100
  return useCallbackStable(((service, data) => {
@@ -89,10 +105,19 @@ function useService(entityId) {
89
105
  }));
90
106
  }
91
107
  function useCachedFetch(cacheKey, fetcher, deps) {
92
- const [data, setData] = useState(() => loadFromCache(cacheKey));
108
+ const store = useHAStore();
109
+ const [data, setData] = useState(() => readCache(store.cache, cacheKey));
93
110
  const [isFresh, setIsFresh] = useState(false);
94
111
  const [isFetching, setIsFetching] = useState(false);
95
112
  const [error, setError] = useState(void 0);
113
+ const dataKeyRef = useRef(cacheKey);
114
+ if (cacheKey !== dataKeyRef.current) {
115
+ dataKeyRef.current = cacheKey;
116
+ setIsFresh(false);
117
+ setError(void 0);
118
+ const cached = readCache(store.cache, cacheKey);
119
+ if (cached !== void 0) setData(cached);
120
+ }
96
121
  const fetchIdRef = useRef(0);
97
122
  const doFetch = useCallbackStable(async () => {
98
123
  const fetchId = ++fetchIdRef.current;
@@ -103,7 +128,7 @@ function useCachedFetch(cacheKey, fetcher, deps) {
103
128
  if (fetchId === fetchIdRef.current) {
104
129
  setData(result);
105
130
  setIsFresh(true);
106
- saveToCache(cacheKey, result);
131
+ writeCache(store.cache, cacheKey, result);
107
132
  }
108
133
  } catch (err) {
109
134
  if (fetchId === fetchIdRef.current) {
@@ -126,97 +151,62 @@ function useCachedFetch(cacheKey, fetcher, deps) {
126
151
  }, [data, isFresh, isFetching]);
127
152
  return { data, status, error, refetch: doFetch };
128
153
  }
129
- function useCalendarEvents(entityId, options) {
130
- const { getHass } = useHass();
131
- const [events, setEvents] = useState(void 0);
132
- const [loading, setLoading] = useState(false);
133
- const [error, setError] = useState(void 0);
134
- const fetchIdRef = useRef(0);
135
- const fetchEvents = useCallbackStable(async () => {
136
- const hass = getHass();
137
- if (!hass?.connection) {
138
- setError(new Error("Home Assistant connection not available"));
139
- return;
140
- }
141
- const fetchId = ++fetchIdRef.current;
142
- setLoading(true);
143
- setError(void 0);
144
- try {
145
- const result = await hass.connection.sendMessagePromise({
146
- type: "call_service",
147
- domain: "calendar",
148
- service: "get_events",
149
- service_data: {
150
- start_date_time: options.start.toISOString(),
151
- end_date_time: options.end.toISOString()
152
- },
153
- target: { entity_id: entityId },
154
- return_response: true
155
- });
156
- if (fetchId === fetchIdRef.current) {
157
- const entityEvents = result.response?.[entityId]?.events ?? [];
158
- setEvents(entityEvents);
159
- setLoading(false);
160
- }
161
- } catch (err) {
162
- if (fetchId === fetchIdRef.current) {
163
- setError(err instanceof Error ? err : new Error(String(err)));
164
- setLoading(false);
154
+ function calendarEventsCacheKey(entityIds, range) {
155
+ return `events:${entityIds.join(",")}:${range.start.getTime()}-${range.end.getTime()}`;
156
+ }
157
+ async function fetchCalendarRange(hass, entityIds, range) {
158
+ if (!hass?.connection) {
159
+ throw new Error("Home Assistant connection not available");
160
+ }
161
+ if (entityIds.length === 0) {
162
+ return [];
163
+ }
164
+ const results = await Promise.all(
165
+ entityIds.map(async (entityId) => {
166
+ try {
167
+ const result = await hass.connection.sendMessagePromise({
168
+ type: "call_service",
169
+ domain: "calendar",
170
+ service: "get_events",
171
+ service_data: {
172
+ start_date_time: range.start.toISOString(),
173
+ end_date_time: range.end.toISOString()
174
+ },
175
+ target: { entity_id: entityId },
176
+ return_response: true
177
+ });
178
+ const calendarEvents = result.response?.[entityId]?.events ?? [];
179
+ return calendarEvents.map(
180
+ (event) => ({ ...event, calendarId: entityId })
181
+ );
182
+ } catch (err) {
183
+ console.error(`Failed to fetch events for ${entityId}:`, err);
184
+ return [];
165
185
  }
166
- }
167
- });
168
- useEffect(() => {
169
- fetchEvents();
170
- }, [entityId, options.start.getTime(), options.end.getTime(), fetchEvents]);
171
- return { events, loading, error, refetch: fetchEvents };
186
+ })
187
+ );
188
+ return results.flat();
172
189
  }
173
- function useMultiCalendarEvents(entityIds, options) {
190
+ function useCalendarEvents(entityIds, options) {
174
191
  const store = useHAStore();
175
192
  const { getHass } = useHass();
176
193
  const debounceTimerRef = useRef(null);
177
194
  const entityIdsKey = entityIds.join(",");
178
195
  const dateRangeKey = `${options.start.getTime()}-${options.end.getTime()}`;
179
196
  const cacheKey = `events:${entityIdsKey}:${dateRangeKey}`;
180
- const fetcher = useCallbackStable(async () => {
181
- const hass = getHass();
182
- if (!hass?.connection) {
183
- throw new Error("Home Assistant connection not available");
184
- }
185
- if (entityIds.length === 0) {
186
- return [];
187
- }
188
- const results = await Promise.all(
189
- entityIds.map(async (entityId) => {
190
- try {
191
- const result = await hass.connection.sendMessagePromise({
192
- type: "call_service",
193
- domain: "calendar",
194
- service: "get_events",
195
- service_data: {
196
- start_date_time: options.start.toISOString(),
197
- end_date_time: options.end.toISOString()
198
- },
199
- target: { entity_id: entityId },
200
- return_response: true
201
- });
202
- const calendarEvents = result.response?.[entityId]?.events ?? [];
203
- return calendarEvents.map(
204
- (event) => ({ ...event, calendarId: entityId })
205
- );
206
- } catch (err) {
207
- console.error(`Failed to fetch events for ${entityId}:`, err);
208
- return [];
209
- }
210
- })
211
- );
212
- return results.flat();
213
- });
197
+ const fetcher = useCallbackStable(() => fetchCalendarRange(getHass(), entityIds, options));
214
198
  const {
215
199
  data: events,
216
200
  status,
217
201
  error,
218
202
  refetch
219
203
  } = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]);
204
+ const prefetch = useCallbackStable((range) => {
205
+ const key = calendarEventsCacheKey(entityIds, range);
206
+ if (store.cache.has(key)) return;
207
+ fetchCalendarRange(getHass(), entityIds, range).then((result) => writeCache(store.cache, key, result)).catch(() => {
208
+ });
209
+ });
220
210
  const debouncedRefetch = useCallbackStable(() => {
221
211
  if (debounceTimerRef.current) {
222
212
  clearTimeout(debounceTimerRef.current);
@@ -234,7 +224,7 @@ function useMultiCalendarEvents(entityIds, options) {
234
224
  }
235
225
  };
236
226
  }, [entityIdsKey, store.subscribeToEntity, debouncedRefetch]);
237
- return { events, status, error, refetch };
227
+ return { events, status, error, refetch, prefetch };
238
228
  }
239
229
  function useWeatherForecast(entityId, type) {
240
230
  const store = useHAStore();
@@ -313,6 +303,7 @@ function registerRawStyles(styles) {
313
303
  function getAllStyles() {
314
304
  return styleRegistry.join("\n");
315
305
  }
306
+ const TEARDOWN_GRACE_MS = 5e3;
316
307
  function registerPreactCard(options) {
317
308
  const {
318
309
  type,
@@ -323,36 +314,55 @@ function registerPreactCard(options) {
323
314
  UnconfiguredComponent,
324
315
  getStubConfig
325
316
  } = options;
326
- class HACard extends HTMLElement {
317
+ class BaseHACard extends HTMLElement {
327
318
  _hass;
328
319
  _config;
329
- _shadowRoot;
330
- _hasRendered = false;
331
- _entityChangeListeners = /* @__PURE__ */ new Map();
332
- // Pending tree teardown — see disconnectedCallback.
333
- _unmountTimer;
334
- constructor() {
335
- super();
336
- this._shadowRoot = this.attachShadow({ mode: "open" });
320
+ _teardownTimer;
321
+ _renderUnconfigured() {
337
322
  }
338
323
  connectedCallback() {
339
- if (this._unmountTimer !== void 0) {
340
- clearTimeout(this._unmountTimer);
341
- this._unmountTimer = void 0;
324
+ if (this._teardownTimer !== void 0) {
325
+ clearTimeout(this._teardownTimer);
326
+ this._teardownTimer = void 0;
342
327
  }
343
- if (this._hass && this._config && !this._hasRendered) {
328
+ this._maybeRenderOnConnect();
329
+ }
330
+ _maybeRenderOnConnect() {
331
+ if (this._hass && this._config) {
344
332
  this._render();
345
333
  }
346
334
  }
347
335
  disconnectedCallback() {
348
- if (this._unmountTimer !== void 0) return;
349
- this._unmountTimer = setTimeout(() => {
350
- this._unmountTimer = void 0;
351
- if (this.isConnected) return;
352
- render(null, this._shadowRoot);
353
- this._hasRendered = false;
354
- this._entityChangeListeners.clear();
355
- }, 100);
336
+ if (this._teardownTimer !== void 0) clearTimeout(this._teardownTimer);
337
+ this._teardownTimer = setTimeout(() => {
338
+ render(null, this._getRenderRoot());
339
+ this._teardownTimer = void 0;
340
+ }, TEARDOWN_GRACE_MS);
341
+ }
342
+ setConfig(config) {
343
+ this._config = config;
344
+ if (this._hass && this.isConnected) {
345
+ this._render();
346
+ }
347
+ }
348
+ _render() {
349
+ if (!this._config || !this._hass) {
350
+ this._renderUnconfigured();
351
+ return;
352
+ }
353
+ this._renderTree();
354
+ }
355
+ }
356
+ class HACard extends BaseHACard {
357
+ _shadowRoot;
358
+ _entityChangeListeners = /* @__PURE__ */ new Map();
359
+ _hassChangeListeners = /* @__PURE__ */ new Set();
360
+ constructor() {
361
+ super();
362
+ this._shadowRoot = this.attachShadow({ mode: "open" });
363
+ }
364
+ _getRenderRoot() {
365
+ return this._shadowRoot;
356
366
  }
357
367
  set hass(hass) {
358
368
  const prevStates = this._hass?.states;
@@ -364,16 +374,11 @@ function registerPreactCard(options) {
364
374
  listeners.forEach((listener) => listener(newState));
365
375
  }
366
376
  }
377
+ this._hassChangeListeners.forEach((listener) => listener());
367
378
  if (!prevStates && this._config && this.isConnected) {
368
379
  this._render();
369
380
  }
370
381
  }
371
- setConfig(config) {
372
- this._config = config;
373
- if (this._hass && this.isConnected) {
374
- this._render();
375
- }
376
- }
377
382
  _subscribeToEntity = (entityId, callback) => {
378
383
  if (!this._entityChangeListeners.has(entityId)) {
379
384
  this._entityChangeListeners.set(entityId, /* @__PURE__ */ new Set());
@@ -389,21 +394,33 @@ function registerPreactCard(options) {
389
394
  }
390
395
  };
391
396
  };
392
- _render() {
393
- if (!this._config || !this._hass) {
394
- if (UnconfiguredComponent) {
395
- render(/* @__PURE__ */ jsx(UnconfiguredComponent, {}), this._shadowRoot);
396
- }
397
- return;
398
- }
397
+ _subscribeToHass = (callback) => {
398
+ this._hassChangeListeners.add(callback);
399
+ return () => {
400
+ this._hassChangeListeners.delete(callback);
401
+ };
402
+ };
403
+ _renderTree() {
399
404
  render(
400
- /* @__PURE__ */ jsxs(HAProvider, { hass: this._hass, subscribeToEntity: this._subscribeToEntity, children: [
401
- /* @__PURE__ */ jsx("style", { children: getAllStyles() }),
402
- /* @__PURE__ */ jsx(Component, { config: this._config })
403
- ] }),
405
+ /* @__PURE__ */ jsxs(
406
+ HAProvider,
407
+ {
408
+ hass: this._hass,
409
+ subscribeToEntity: this._subscribeToEntity,
410
+ subscribeToHass: this._subscribeToHass,
411
+ children: [
412
+ /* @__PURE__ */ jsx("style", { children: getAllStyles() }),
413
+ /* @__PURE__ */ jsx(Component, { config: this._config })
414
+ ]
415
+ }
416
+ ),
404
417
  this._shadowRoot
405
418
  );
406
- this._hasRendered = true;
419
+ }
420
+ _renderUnconfigured() {
421
+ if (UnconfiguredComponent) {
422
+ render(/* @__PURE__ */ jsx(UnconfiguredComponent, {}), this._shadowRoot);
423
+ }
407
424
  }
408
425
  static getConfigElement() {
409
426
  if (ConfigComponent) {
@@ -418,13 +435,19 @@ function registerPreactCard(options) {
418
435
  customElements.define(type, HACard);
419
436
  if (ConfigComponent) {
420
437
  const EditorComponent = ConfigComponent;
421
- class HACardEditor extends HTMLElement {
422
- _hass;
423
- _config;
438
+ class HACardEditor extends BaseHACard {
439
+ _getRenderRoot() {
440
+ return this;
441
+ }
442
+ // The editor re-renders on every hass update: it passes `hass` straight to
443
+ // HA's <ha-form>/<ha-selector>, whose entity pickers need a fresh hass to
444
+ // stay current. (Unlike the card, which renders once then subscribes.)
424
445
  set hass(hass) {
425
446
  this._hass = hass;
426
447
  this._render();
427
448
  }
449
+ // Render whenever config arrives, regardless of connection — HA may set
450
+ // config/hass before connecting the editor element.
428
451
  setConfig(config) {
429
452
  this._config = config;
430
453
  this._render();
@@ -438,8 +461,8 @@ function registerPreactCard(options) {
438
461
  })
439
462
  );
440
463
  };
441
- _render() {
442
- if (!this._hass || !this._config) return;
464
+ // Render to light DOM so HA's custom elements (ha-form etc.) work.
465
+ _renderTree() {
443
466
  render(
444
467
  /* @__PURE__ */ jsx(
445
468
  EditorComponent,
@@ -463,21 +486,62 @@ function registerPreactCard(options) {
463
486
  ""
464
487
  );
465
488
  }
489
+ function useResizeObserver(ref, callback, deps = []) {
490
+ const callbackRef = useRef(callback);
491
+ callbackRef.current = callback;
492
+ useEffect(() => {
493
+ const element = ref.current;
494
+ if (!element) return;
495
+ const fire = () => {
496
+ if (!element.isConnected) return;
497
+ callbackRef.current({
498
+ width: element.offsetWidth,
499
+ height: element.offsetHeight
500
+ });
501
+ };
502
+ const observer = new ResizeObserver(fire);
503
+ observer.observe(element);
504
+ return () => observer.disconnect();
505
+ }, []);
506
+ const isFirstRun = useRef(true);
507
+ useEffect(() => {
508
+ if (isFirstRun.current) {
509
+ isFirstRun.current = false;
510
+ return;
511
+ }
512
+ const element = ref.current;
513
+ if (!element || !element.isConnected) return;
514
+ callbackRef.current({
515
+ width: element.offsetWidth,
516
+ height: element.offsetHeight
517
+ });
518
+ }, deps);
519
+ }
520
+ function useWidth(ref) {
521
+ const [width, setWidth] = useState(void 0);
522
+ useResizeObserver(ref, (size) => {
523
+ if (size.width === 0) return;
524
+ setWidth((prev) => prev === size.width ? prev : size.width);
525
+ });
526
+ return width;
527
+ }
466
528
  export {
467
529
  HAProvider,
468
530
  css,
469
531
  getAllStyles,
470
- loadFromCache,
471
532
  registerPreactCard,
472
533
  registerRawStyles,
473
- saveToCache,
474
534
  useCachedFetch,
475
535
  useCalendarEvents,
476
536
  useCallbackStable,
537
+ useDarkMode,
477
538
  useEntity,
478
539
  useHass,
479
- useMultiCalendarEvents,
540
+ useHassConfig,
541
+ useHassValue,
542
+ useResizeObserver,
480
543
  useService,
481
- useWeatherForecast
544
+ useWeatherForecast,
545
+ useWidth
482
546
  };
483
547
  //# sourceMappingURL=index.js.map