preact-homeassistant 0.1.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -102,19 +102,42 @@ Returns a strict type based on the domain prefix:
102
102
  - `'calendar.*'` → `CalendarEntity`
103
103
  - `'weather.*'` → `WeatherEntity`
104
104
  - `'sun.sun'` → `SunEntity`
105
+ - `'fan.*'` → `FanEntity`
105
106
  - Other domains → `HassEntity` (the loose type from `home-assistant-js-websocket`)
106
107
 
107
108
  The mapping comes from the `DomainEntityMap` interface. To add a new domain,
108
109
  see the *Contributing types* section below.
109
110
 
111
+ ### `useService(entityId)`
112
+
113
+ Returns a stable function that calls services on a specific entity. The
114
+ service domain is parsed from the entity ID prefix and `entity_id` is
115
+ auto-injected into every call. Service names and data shapes are
116
+ strongly typed via `DomainServiceMap` when the domain is registered.
117
+
118
+ ```tsx
119
+ const fanService = useService(config.entity); // config.entity: `fan.${string}`
120
+ await fanService('toggle'); // entity_id auto-injected
121
+ await fanService('set_percentage', { percentage: 67 });
122
+ ```
123
+
124
+ For registered domains (currently `fan`), TypeScript autocompletes service
125
+ names and validates the data shape. For other domains the hook still works,
126
+ just without per-service autocomplete — useful for ad-hoc calls until the
127
+ domain is added to `DomainServiceMap`.
128
+
129
+ The returned function is a no-op if the entity ID is empty (common while the
130
+ card config is being set up) or if `hass` isn't connected yet.
131
+
110
132
  ### `useHass()`
111
133
 
112
- Access the full `hass` object for calling services or reading config. Does not
113
- re-render on entity changes.
134
+ Access the full `hass` object for reading config or making service calls that
135
+ `useService` doesn't cover (different entity per call, no entity, custom
136
+ `return_response`, etc.). Does not re-render on entity changes.
114
137
 
115
138
  ```tsx
116
139
  const { getHass } = useHass();
117
- await getHass()?.callService('light', 'turn_on', { entity_id: 'light.bedroom' });
140
+ await getHass()?.callService('script', 'morning_routine');
118
141
  ```
119
142
 
120
143
  ### `useCalendarEvents(entityId, { start, end })`
@@ -148,6 +171,60 @@ const { forecast, status, error, refetch } = useWeatherForecast('weather.home',
148
171
  Generic hook for fetching data with localStorage caching. The domain-specific
149
172
  hooks above are built on this.
150
173
 
174
+ ### `useResizeObserver(ref, callback, deps?)`
175
+
176
+ Observe an element's size via `ResizeObserver`. The callback fires once after
177
+ mount with the current size, on every subsequent resize, and whenever `deps`
178
+ change. The callback is held in a ref, so passing a fresh closure each render
179
+ is safe — the observer is never re-created.
180
+
181
+ ```tsx
182
+ const containerRef = useRef<HTMLDivElement>(null);
183
+
184
+ useResizeObserver(
185
+ containerRef,
186
+ ({ width, height }) => {
187
+ if (width === 0 || height === 0) return; // optional, consumer's call
188
+ drawChart(canvasRef.current, forecast, width, height);
189
+ },
190
+ [forecast],
191
+ );
192
+ ```
193
+
194
+ The callback is suppressed while the element is detached from the document.
195
+ Zero width/height is passed through — many draw routines need to guard
196
+ against zero dimensions (a 0-sized canvas throws `InvalidStateError` on
197
+ `drawImage`; ratios of measurements like `Math.ceil(width / cellSize)`
198
+ produce `Infinity` when a dimension is zero and infinite-loop the next
199
+ `for` they feed into) — but the guard belongs at the call site so the hook
200
+ stays general-purpose.
201
+
202
+ Sizes are read from `offsetWidth` / `offsetHeight` (CSS pixels, includes
203
+ padding and border).
204
+
205
+ ### `useWidth(ref)`
206
+
207
+ Stateful sibling to `useResizeObserver` for the JSX path: tracks a
208
+ referenced element's width and re-renders the component when it changes.
209
+ Returns `undefined` until the first non-zero measurement, then a positive
210
+ number that never returns to `undefined` or `0` — transient zero-width
211
+ firings during HA layout transitions (dashboard switch, edit-mode toggle)
212
+ and detached states are silently ignored.
213
+
214
+ ```tsx
215
+ const ref = useRef<HTMLDivElement>(null);
216
+ const width = useWidth(ref);
217
+ return (
218
+ <div ref={ref}>
219
+ {width !== undefined && <Chart width={width} />}
220
+ </div>
221
+ );
222
+ ```
223
+
224
+ Use this when the width needs to appear in JSX (responsive layout, prop to
225
+ a sized child). For imperative use inside a draw callback, prefer
226
+ `useResizeObserver` directly — no state, no extra re-renders.
227
+
151
228
  ## Styles
152
229
 
153
230
  Styles are registered globally via the `css\`\`` tagged template and
@@ -189,8 +266,9 @@ All HA domain types live in [`src/types/`](src/types/):
189
266
  - [`calendar.ts`](src/types/calendar.ts) — `CalendarEntity`, `CalendarEvent`, `CalendarEventWithSource`
190
267
  - [`weather.ts`](src/types/weather.ts) — `WeatherEntity`, `WeatherForecast`, `ForecastType`
191
268
  - [`sun.ts`](src/types/sun.ts) — `SunEntity`
269
+ - [`fan.ts`](src/types/fan.ts) — `FanEntity`, `FanServices`
192
270
  - [`common.ts`](src/types/common.ts) — `HomeAssistant`, `FetchStatus`
193
- - [`index.ts`](src/types/index.ts) — `DomainEntityMap`, `EntityForId<T>`
271
+ - [`index.ts`](src/types/index.ts) — `DomainEntityMap`, `EntityForId<T>`, `DomainServiceMap`, `ServicesForId<T>`
194
272
 
195
273
  Re-exported from the package root:
196
274
 
@@ -200,8 +278,13 @@ import type {
200
278
  CalendarEntity,
201
279
  WeatherEntity,
202
280
  SunEntity,
281
+ FanEntity,
282
+ FanServices,
203
283
  WeatherForecast,
204
284
  EntityForId,
285
+ DomainEntityMap,
286
+ DomainServiceMap,
287
+ ServicesForId,
205
288
  /* ... */
206
289
  } from 'preact-homeassistant';
207
290
  ```
@@ -214,11 +297,16 @@ for another domain (light, climate, media_player, cover, etc.), PRs are very
214
297
  welcome.
215
298
 
216
299
  1. Look up the domain in the [Home Assistant frontend repo](https://github.com/home-assistant/frontend/tree/dev/src/data) — most domains have a `data/<domain>.ts` file with TypeScript types.
217
- 2. Add `src/types/<domain>.ts` mirroring the fields your card needs. Extend `HassEntityBase` and `HassEntityAttributeBase` from `home-assistant-js-websocket`.
218
- 3. Add the entity to `DomainEntityMap` in [`src/types/index.ts`](src/types/index.ts) and re-export the types.
300
+ 2. Add `src/types/<domain>.ts`. Include an entity interface that extends `HassEntityBase` / `HassEntityAttributeBase` from `home-assistant-js-websocket`, plus a services interface mapping each service name to its data shape (or `undefined` for services that take no payload beyond `entity_id`). See [`src/types/fan.ts`](src/types/fan.ts) for the shape.
301
+ 3. In [`src/types/index.ts`](src/types/index.ts), add the entity to `DomainEntityMap` and the services to `DomainServiceMap`, and re-export the new types.
219
302
  4. Add a quick test under `src/__tests__/` if you're feeling thorough.
220
303
  5. PR.
221
304
 
305
+ Both the entity types and the service types are opt-in: until a domain
306
+ appears in `DomainEntityMap`, `useEntity('light.foo')` falls back to
307
+ `HassEntity`; until it appears in `DomainServiceMap`, `useService('light.foo')`
308
+ still works but without per-service autocomplete.
309
+
222
310
  We err toward including only fields that are well-documented; speculative
223
311
  attributes can land later.
224
312
 
package/dist/index.d.ts CHANGED
@@ -62,6 +62,16 @@ export declare interface DomainEntityMap {
62
62
  calendar: CalendarEntity;
63
63
  weather: WeatherEntity;
64
64
  sun: SunEntity;
65
+ fan: FanEntity;
66
+ }
67
+
68
+ /**
69
+ * Map of known HA domains to their service signatures. Mirrors DomainEntityMap.
70
+ * Contributors adding a new domain's actions should add a new file under
71
+ * `src/types/` and extend this map.
72
+ */
73
+ export declare interface DomainServiceMap {
74
+ fan: FanServices;
65
75
  }
66
76
 
67
77
  /**
@@ -73,10 +83,64 @@ export declare interface DomainEntityMap {
73
83
  */
74
84
  export declare type EntityForId<T extends string> = T extends `${infer D}.${string}` ? D extends KnownDomain ? DomainEntityMap[D] : HassEntity : HassEntity;
75
85
 
86
+ /**
87
+ * Fan entity - speed is reported as percentage (0-100) with percentage_step
88
+ * giving the smallest discrete increment the fan supports.
89
+ */
90
+ export declare interface FanEntity extends HassEntityBase {
91
+ state: 'on' | 'off' | 'unavailable' | 'unknown';
92
+ attributes: HassEntityAttributeBase & {
93
+ percentage?: number;
94
+ percentage_step?: number;
95
+ preset_modes?: string[];
96
+ preset_mode?: string;
97
+ oscillating?: boolean;
98
+ direction?: 'forward' | 'reverse';
99
+ supported_features?: number;
100
+ };
101
+ }
102
+
103
+ /**
104
+ * Data payload shapes for each service in the `fan` domain.
105
+ * `undefined` means the service accepts no payload beyond `entity_id`.
106
+ * See https://www.home-assistant.io/integrations/fan/#actions
107
+ */
108
+ export declare interface FanServices {
109
+ turn_on: {
110
+ percentage?: number;
111
+ preset_mode?: string;
112
+ };
113
+ turn_off: undefined;
114
+ toggle: undefined;
115
+ set_percentage: {
116
+ percentage: number;
117
+ };
118
+ set_preset_mode: {
119
+ preset_mode: string;
120
+ };
121
+ oscillate: {
122
+ oscillating: boolean;
123
+ };
124
+ set_direction: {
125
+ direction: 'forward' | 'reverse';
126
+ };
127
+ increase_speed: {
128
+ percentage_step?: number;
129
+ };
130
+ decrease_speed: {
131
+ percentage_step?: number;
132
+ };
133
+ }
134
+
76
135
  export declare type FetchStatus = 'loading' | 'cached' | 'ready' | 'refreshing';
77
136
 
78
137
  export declare type ForecastType = 'daily' | 'hourly' | 'twice_daily';
79
138
 
139
+ /**
140
+ * Get all registered styles for Shadow DOM injection.
141
+ */
142
+ export declare function getAllStyles(): string;
143
+
80
144
  export declare function HAProvider({ hass, subscribeToEntity, children }: HAProviderProps): JSX.Element;
81
145
 
82
146
  declare interface HAProviderProps {
@@ -102,6 +166,8 @@ export declare interface HomeAssistant {
102
166
 
103
167
  declare type KnownDomain = keyof DomainEntityMap;
104
168
 
169
+ declare type KnownServiceDomain = keyof DomainServiceMap;
170
+
105
171
  export declare function loadFromCache<T>(key: string): T | undefined;
106
172
 
107
173
  export declare function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<TConfig>): void;
@@ -130,6 +196,16 @@ export declare function registerRawStyles(styles: string): void;
130
196
 
131
197
  export declare function saveToCache<T>(key: string, data: T): void;
132
198
 
199
+ declare type ServiceCaller<T extends string> = <S extends keyof ServicesForId<T> & string>(service: S, ...args: ServicesForId<T>[S] extends undefined ? [] : Record<string, never> extends Exclude<ServicesForId<T>[S], undefined> ? [data?: ServicesForId<T>[S]] : [data: ServicesForId<T>[S]]) => Promise<void>;
200
+
201
+ /**
202
+ * Infers the service registry from an entity ID literal type. Mirrors EntityForId.
203
+ *
204
+ * ServicesForId<'fan.bedroom'> -> FanServices
205
+ * ServicesForId<'sensor.foo'> -> Record<string, Record<string, unknown> | undefined> (fallback)
206
+ */
207
+ export declare type ServicesForId<T extends string> = T extends `${infer D}.${string}` ? D extends KnownServiceDomain ? DomainServiceMap[D] : Record<string, Record<string, unknown> | undefined> : Record<string, Record<string, unknown> | undefined>;
208
+
133
209
  /**
134
210
  * Sun entity - provides sunrise/sunset and elevation information.
135
211
  */
@@ -221,6 +297,19 @@ declare interface UseMultiCalendarEventsResult {
221
297
  refetch: () => void;
222
298
  }
223
299
 
300
+ /**
301
+ * Returns a stable function that calls services on a specific HA entity.
302
+ * The service domain is parsed from the entity ID prefix and `entity_id` is
303
+ * auto-injected into every call. Service names and data shapes are strongly
304
+ * typed via DomainServiceMap when the domain is registered. No-ops if hass
305
+ * is not yet available or the entity ID is empty.
306
+ *
307
+ * const fanService = useService(config.entity); // `fan.${string}`
308
+ * await fanService('turn_off');
309
+ * await fanService('set_percentage', { percentage: 67 });
310
+ */
311
+ export declare function useService<T extends string>(entityId: T): ServiceCaller<T>;
312
+
224
313
  /**
225
314
  * Fetch weather forecast data with localStorage caching. Auto-refetches at the
226
315
  * top of each hour and when the underlying entity changes (debounced).
package/dist/index.js CHANGED
@@ -79,6 +79,15 @@ function useHass() {
79
79
  const store = useHAStore();
80
80
  return { getHass: store.getHass };
81
81
  }
82
+ function useService(entityId) {
83
+ const { getHass } = useHass();
84
+ return useCallbackStable(((service, data) => {
85
+ const hass = getHass();
86
+ if (!hass || !entityId.includes(".")) return Promise.resolve();
87
+ const domain = entityId.split(".", 1)[0];
88
+ return hass.callService(domain, service, { entity_id: entityId, ...data });
89
+ }));
90
+ }
82
91
  function useCachedFetch(cacheKey, fetcher, deps) {
83
92
  const [data, setData] = useState(() => loadFromCache(cacheKey));
84
93
  const [isFresh, setIsFresh] = useState(false);
@@ -320,17 +329,30 @@ function registerPreactCard(options) {
320
329
  _shadowRoot;
321
330
  _hasRendered = false;
322
331
  _entityChangeListeners = /* @__PURE__ */ new Map();
332
+ // Pending tree teardown — see disconnectedCallback.
333
+ _unmountTimer;
323
334
  constructor() {
324
335
  super();
325
336
  this._shadowRoot = this.attachShadow({ mode: "open" });
326
337
  }
327
338
  connectedCallback() {
339
+ if (this._unmountTimer !== void 0) {
340
+ clearTimeout(this._unmountTimer);
341
+ this._unmountTimer = void 0;
342
+ }
328
343
  if (this._hass && this._config && !this._hasRendered) {
329
344
  this._render();
330
345
  }
331
346
  }
332
347
  disconnectedCallback() {
333
- this._entityChangeListeners.clear();
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);
334
356
  }
335
357
  set hass(hass) {
336
358
  const prevStates = this._hass?.states;
@@ -444,6 +466,7 @@ function registerPreactCard(options) {
444
466
  export {
445
467
  HAProvider,
446
468
  css,
469
+ getAllStyles,
447
470
  loadFromCache,
448
471
  registerPreactCard,
449
472
  registerRawStyles,
@@ -454,6 +477,7 @@ export {
454
477
  useEntity,
455
478
  useHass,
456
479
  useMultiCalendarEvents,
480
+ useService,
457
481
  useWeatherForecast
458
482
  };
459
483
  //# sourceMappingURL=index.js.map
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../src/cacheUtils.ts","../src/useCallbackStable.ts","../src/HAContext.tsx","../src/styleRegistry.ts","../src/registerPreactCard.tsx"],"sourcesContent":["const CACHE_PREFIX = 'preact-ha:';\nconst CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000;\n\ninterface CacheEntry<T> {\n data: T;\n timestamp: number;\n}\n\nexport function loadFromCache<T>(key: string): T | undefined {\n try {\n const raw = localStorage.getItem(`${CACHE_PREFIX}${key}`);\n if (!raw) return undefined;\n\n const entry: CacheEntry<T> = JSON.parse(raw);\n if (Date.now() - entry.timestamp > CACHE_EXPIRY_MS) {\n localStorage.removeItem(`${CACHE_PREFIX}${key}`);\n return undefined;\n }\n return entry.data;\n } catch {\n return undefined;\n }\n}\n\nexport function saveToCache<T>(key: string, data: T): void {\n try {\n const entry: CacheEntry<T> = { data, timestamp: Date.now() };\n localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(entry));\n } catch (e) {\n console.warn('[preact-homeassistant cache] Failed to save:', e);\n }\n}\n","import { useRef } from 'preact/hooks';\n\n/**\n * Creates a stable callback reference that always calls the latest version of the callback.\n * Unlike useCallback, this never changes identity, so it won't cause re-renders in children.\n *\n * @param callback The callback function to stabilize\n * @returns A stable function reference that always calls the latest callback\n */\nexport function useCallbackStable<T extends (...args: never[]) => unknown>(callback: T): T {\n const callbackRef = useRef<T>(callback);\n\n callbackRef.current = callback;\n\n // Create a stable function reference once\n const stableRef = useRef<T | null>(null);\n if (stableRef.current === null) {\n stableRef.current = ((...args: Parameters<T>) => {\n return callbackRef.current(...args);\n }) as T;\n }\n\n return stableRef.current;\n}\n","import { createContext } from 'preact';\nimport type { ComponentChildren } from 'preact';\nimport { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';\n\nimport { loadFromCache, saveToCache } from './cacheUtils';\nimport type {\n CalendarEvent,\n CalendarEventWithSource,\n EntityForId,\n FetchStatus,\n ForecastType,\n HomeAssistant,\n WeatherForecast,\n} from './types';\nimport { useCallbackStable } from './useCallbackStable';\n\ninterface HAStore {\n hass: HomeAssistant | undefined;\n getHass: () => HomeAssistant | undefined;\n subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;\n}\n\nconst HAContext = createContext<HAStore | null>(null);\n\ninterface HAProviderProps {\n hass: HomeAssistant | undefined;\n subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;\n children: ComponentChildren;\n}\n\nexport function HAProvider({ hass, subscribeToEntity, children }: HAProviderProps) {\n const hassRef = useRef(hass);\n hassRef.current = hass;\n\n const getHass = useCallbackStable(() => hassRef.current);\n\n const store = useMemo<HAStore>(\n () => ({\n hass: hassRef.current,\n getHass,\n subscribeToEntity,\n }),\n [getHass, subscribeToEntity],\n );\n\n return <HAContext.Provider value={store}>{children}</HAContext.Provider>;\n}\n\nfunction useHAStore(): HAStore {\n const store = useContext(HAContext);\n if (!store) {\n throw new Error('useEntity/useHass must be used within an HAProvider');\n }\n return store;\n}\n\n/**\n * Subscribe to a specific entity by ID. Re-renders only when that entity changes.\n *\n * Returns a typed entity based on the domain prefix:\n * - 'calendar.xyz' -> CalendarEntity\n * - 'weather.xyz' -> WeatherEntity\n * - 'sun.sun' -> SunEntity\n * - other domains -> HassEntity (fallback)\n */\nexport function useEntity<T extends string>(entityId: T): EntityForId<T> | undefined {\n const store = useHAStore();\n const cacheKey = `entity:${entityId}`;\n\n const [entity, setEntity] = useState<EntityForId<T> | undefined>(() => {\n const current = store.hass?.states[entityId] as EntityForId<T> | undefined;\n if (current) return current;\n return loadFromCache<EntityForId<T>>(cacheKey);\n });\n\n useEffect(() => {\n const unsubscribe = store.subscribeToEntity(entityId, (newEntity) => {\n setEntity(newEntity as EntityForId<T>);\n saveToCache(cacheKey, newEntity);\n });\n return unsubscribe;\n }, [entityId, store.subscribeToEntity, cacheKey]);\n\n return entity;\n}\n\n/**\n * Get access to the full hass object for calling services / accessing config.\n * Does NOT re-render on entity changes. Use useEntity for that.\n */\nexport function useHass(): { getHass: () => HomeAssistant | undefined } {\n const store = useHAStore();\n return { getHass: store.getHass };\n}\n\ninterface UseCachedFetchResult<T> {\n data: T | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Generic hook for fetching data with localStorage caching. Returns a cache-aware\n * status string to distinguish cached vs fresh data.\n */\nexport function useCachedFetch<T>(\n cacheKey: string,\n fetcher: () => Promise<T>,\n deps: unknown[],\n): UseCachedFetchResult<T> {\n const [data, setData] = useState<T | undefined>(() => loadFromCache<T>(cacheKey));\n const [isFresh, setIsFresh] = useState(false);\n const [isFetching, setIsFetching] = useState(false);\n const [error, setError] = useState<Error | undefined>(undefined);\n\n const fetchIdRef = useRef(0);\n\n const doFetch = useCallbackStable(async () => {\n const fetchId = ++fetchIdRef.current;\n setIsFetching(true);\n setError(undefined);\n\n try {\n const result = await fetcher();\n if (fetchId === fetchIdRef.current) {\n setData(result);\n setIsFresh(true);\n saveToCache(cacheKey, result);\n }\n } catch (err) {\n if (fetchId === fetchIdRef.current) {\n setError(err instanceof Error ? err : new Error(String(err)));\n }\n } finally {\n if (fetchId === fetchIdRef.current) {\n setIsFetching(false);\n }\n }\n });\n\n useEffect(() => {\n doFetch();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps);\n\n const status: FetchStatus = useMemo(() => {\n if (!data && isFetching) return 'loading';\n if (data && !isFresh && isFetching) return 'cached';\n if (data && isFresh && isFetching) return 'refreshing';\n return 'ready';\n }, [data, isFresh, isFetching]);\n\n return { data, status, error, refetch: doFetch };\n}\n\ninterface UseCalendarEventsResult {\n events: CalendarEvent[] | undefined;\n loading: boolean;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Fetch calendar events for a date range from a single calendar.\n */\nexport function useCalendarEvents(\n entityId: `calendar.${string}`,\n options: { start: Date; end: Date },\n): UseCalendarEventsResult {\n const { getHass } = useHass();\n const [events, setEvents] = useState<CalendarEvent[] | undefined>(undefined);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<Error | undefined>(undefined);\n\n const fetchIdRef = useRef(0);\n\n const fetchEvents = useCallbackStable(async () => {\n const hass = getHass();\n if (!hass?.connection) {\n setError(new Error('Home Assistant connection not available'));\n return;\n }\n\n const fetchId = ++fetchIdRef.current;\n setLoading(true);\n setError(undefined);\n\n try {\n const result = await hass.connection.sendMessagePromise<{\n response: { [entityId: string]: { events: CalendarEvent[] } };\n }>({\n type: 'call_service',\n domain: 'calendar',\n service: 'get_events',\n service_data: {\n start_date_time: options.start.toISOString(),\n end_date_time: options.end.toISOString(),\n },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n if (fetchId === fetchIdRef.current) {\n const entityEvents = result.response?.[entityId]?.events ?? [];\n setEvents(entityEvents);\n setLoading(false);\n }\n } catch (err) {\n if (fetchId === fetchIdRef.current) {\n setError(err instanceof Error ? err : new Error(String(err)));\n setLoading(false);\n }\n }\n });\n\n useEffect(() => {\n fetchEvents();\n }, [entityId, options.start.getTime(), options.end.getTime(), fetchEvents]);\n\n return { events, loading, error, refetch: fetchEvents };\n}\n\ninterface UseMultiCalendarEventsResult {\n events: CalendarEventWithSource[] | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Fetch events from multiple calendars for a date range, with localStorage\n * caching. Events are tagged with their source calendar ID.\n */\nexport function useMultiCalendarEvents(\n entityIds: `calendar.${string}`[],\n options: { start: Date; end: Date },\n): UseMultiCalendarEventsResult {\n const store = useHAStore();\n const { getHass } = useHass();\n\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const entityIdsKey = entityIds.join(',');\n const dateRangeKey = `${options.start.getTime()}-${options.end.getTime()}`;\n const cacheKey = `events:${entityIdsKey}:${dateRangeKey}`;\n\n const fetcher = useCallbackStable(async () => {\n const hass = getHass();\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n\n if (entityIds.length === 0) {\n return [];\n }\n\n const results = await Promise.all(\n entityIds.map(async (entityId) => {\n try {\n const result = await hass.connection.sendMessagePromise<{\n response: { [key: string]: { events: CalendarEvent[] } };\n }>({\n type: 'call_service',\n domain: 'calendar',\n service: 'get_events',\n service_data: {\n start_date_time: options.start.toISOString(),\n end_date_time: options.end.toISOString(),\n },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n const calendarEvents = result.response?.[entityId]?.events ?? [];\n return calendarEvents.map(\n (event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),\n );\n } catch (err) {\n console.error(`Failed to fetch events for ${entityId}:`, err);\n return [];\n }\n }),\n );\n\n return results.flat();\n });\n\n const {\n data: events,\n status,\n error,\n refetch,\n } = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]);\n\n const debouncedRefetch = useCallbackStable(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => refetch(), 500);\n });\n\n useEffect(() => {\n const unsubscribes = entityIds.map((entityId) =>\n store.subscribeToEntity(entityId, debouncedRefetch),\n );\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n };\n }, [entityIdsKey, store.subscribeToEntity, debouncedRefetch]);\n\n return { events, status, error, refetch };\n}\n\ninterface UseWeatherForecastResult {\n forecast: WeatherForecast[] | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Fetch weather forecast data with localStorage caching. Auto-refetches at the\n * top of each hour and when the underlying entity changes (debounced).\n */\nexport function useWeatherForecast(\n entityId: `weather.${string}`,\n type: ForecastType,\n): UseWeatherForecastResult {\n const store = useHAStore();\n const { getHass } = useHass();\n const cacheKey = `forecast:${entityId}:${type}`;\n\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const hourlyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const fetcher = useCallbackStable(async () => {\n const hass = getHass();\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n\n const result = await hass.connection.sendMessagePromise<{\n response: { [entityId: string]: { forecast: WeatherForecast[] } };\n }>({\n type: 'call_service',\n domain: 'weather',\n service: 'get_forecasts',\n service_data: { type },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n return result.response?.[entityId]?.forecast ?? [];\n });\n\n const {\n data: forecast,\n status,\n error,\n refetch,\n } = useCachedFetch(cacheKey, fetcher, [entityId, type]);\n\n const debouncedRefetch = useCallbackStable(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => refetch(), 500);\n });\n\n const scheduleHourlyRefetch = useCallbackStable(() => {\n if (hourlyTimerRef.current) {\n clearTimeout(hourlyTimerRef.current);\n }\n const now = new Date();\n const nextHour = new Date(now);\n nextHour.setHours(now.getHours() + 1, 0, 0, 0);\n const msUntilNextHour = nextHour.getTime() - now.getTime();\n\n hourlyTimerRef.current = setTimeout(() => {\n refetch();\n scheduleHourlyRefetch();\n }, msUntilNextHour);\n });\n\n useEffect(() => {\n scheduleHourlyRefetch();\n }, [entityId, type, scheduleHourlyRefetch]);\n\n useEffect(() => {\n const unsubscribe = store.subscribeToEntity(entityId, debouncedRefetch);\n return () => {\n unsubscribe();\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n if (hourlyTimerRef.current) {\n clearTimeout(hourlyTimerRef.current);\n }\n };\n }, [entityId, store.subscribeToEntity, debouncedRefetch]);\n\n return { forecast, status, error, refetch };\n}\n","// Style registry for Shadow DOM injection\n// Each .styles.ts file uses css`` which auto-registers\n\nconst styleRegistry: string[] = [];\n\n/**\n * CSS tagged template literal for syntax highlighting.\n * Automatically registers the styles with the global registry.\n */\nexport const css = (strings: TemplateStringsArray, ...values: unknown[]): string => {\n const result = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');\n styleRegistry.push(result);\n return result;\n};\n\n/**\n * Register raw CSS string (e.g., from ?inline imports).\n * Only registers if not already present.\n */\nexport function registerRawStyles(styles: string): void {\n if (!styleRegistry.includes(styles)) {\n styleRegistry.push(styles);\n }\n}\n\n/**\n * Get all registered styles for Shadow DOM injection.\n */\nexport function getAllStyles(): string {\n return styleRegistry.join('\\n');\n}\n","import { type ComponentType, render } from 'preact';\nimport { HAProvider } from './HAContext';\nimport { getAllStyles } from './styleRegistry';\nimport type { HomeAssistant } from './types';\n\ninterface RegisterPreactCardOptions<TConfig> {\n type: string;\n name: string;\n description: string;\n Component: ComponentType<{ config: TConfig }>;\n ConfigComponent?: ComponentType<{\n hass: HomeAssistant;\n config: TConfig;\n onConfigChanged: (config: TConfig) => void;\n }>;\n UnconfiguredComponent?: ComponentType<{}>;\n getStubConfig?: () => Partial<TConfig>;\n}\n\ndeclare global {\n interface Window {\n customCards?: Array<{ type: string; name: string; description: string }>;\n }\n}\n\nexport function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<TConfig>) {\n const {\n type,\n name,\n description,\n Component,\n ConfigComponent,\n UnconfiguredComponent,\n getStubConfig,\n } = options;\n\n class HACard extends HTMLElement {\n private _hass?: HomeAssistant;\n private _config?: TConfig;\n private _shadowRoot: ShadowRoot;\n private _hasRendered = false;\n private _entityChangeListeners = new Map<string, Set<(entity: any) => void>>();\n\n constructor() {\n super();\n this._shadowRoot = this.attachShadow({ mode: 'open' });\n }\n\n connectedCallback() {\n // HA disconnects + reconnects the element during edit-mode toggling.\n // Re-rendering on each connect causes Preact to lose its diff anchor and\n // append a duplicate tree, so only render once per attachment cycle.\n if (this._hass && this._config && !this._hasRendered) {\n this._render();\n }\n }\n\n disconnectedCallback() {\n this._entityChangeListeners.clear();\n }\n\n set hass(hass: HomeAssistant) {\n const prevStates = this._hass?.states;\n this._hass = hass;\n\n for (const [entityId, listeners] of this._entityChangeListeners) {\n const newState = hass.states[entityId];\n const oldState = prevStates?.[entityId];\n if (newState !== oldState) {\n listeners.forEach((listener) => listener(newState));\n }\n }\n\n // Render only when attached. HA may set hass/config before insertion;\n // rendering into a detached shadow root then again on connect duplicates\n // the tree. connectedCallback handles the detached-first-render case.\n if (!prevStates && this._config && this.isConnected) {\n this._render();\n }\n }\n\n setConfig(config: TConfig) {\n this._config = config;\n if (this._hass && this.isConnected) {\n this._render();\n }\n }\n\n private _subscribeToEntity = (entityId: string, callback: (entity: any) => void) => {\n if (!this._entityChangeListeners.has(entityId)) {\n this._entityChangeListeners.set(entityId, new Set());\n }\n this._entityChangeListeners.get(entityId)!.add(callback);\n\n return () => {\n const listeners = this._entityChangeListeners.get(entityId);\n if (listeners) {\n listeners.delete(callback);\n if (listeners.size === 0) {\n this._entityChangeListeners.delete(entityId);\n }\n }\n };\n };\n\n private _render() {\n if (!this._config || !this._hass) {\n if (UnconfiguredComponent) {\n render(<UnconfiguredComponent />, this._shadowRoot);\n }\n return;\n }\n\n render(\n <HAProvider hass={this._hass} subscribeToEntity={this._subscribeToEntity}>\n <style>{getAllStyles()}</style>\n <Component config={this._config} />\n </HAProvider>,\n this._shadowRoot,\n );\n this._hasRendered = true;\n }\n\n static getConfigElement() {\n if (ConfigComponent) {\n return document.createElement(`${type}-editor`);\n }\n return undefined;\n }\n\n static getStubConfig() {\n return getStubConfig?.() ?? {};\n }\n }\n\n customElements.define(type, HACard);\n\n if (ConfigComponent) {\n const EditorComponent = ConfigComponent;\n\n class HACardEditor extends HTMLElement {\n private _hass?: HomeAssistant;\n private _config?: TConfig;\n\n set hass(hass: HomeAssistant) {\n this._hass = hass;\n this._render();\n }\n\n setConfig(config: TConfig) {\n this._config = config;\n this._render();\n }\n\n private _fireConfigChanged = (config: TConfig) => {\n this.dispatchEvent(\n new CustomEvent('config-changed', {\n detail: { config },\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n private _render() {\n if (!this._hass || !this._config) return;\n\n // Render to light DOM so HA's custom elements (ha-select etc.) work.\n render(\n <EditorComponent\n hass={this._hass}\n config={this._config}\n onConfigChanged={this._fireConfigChanged}\n />,\n this,\n );\n }\n }\n\n customElements.define(`${type}-editor`, HACardEditor);\n }\n\n window.customCards = window.customCards || [];\n window.customCards.push({ type, name, description });\n\n console.info(\n `%c ${name.toUpperCase()} %c loaded `,\n 'background: #3b82f6; color: white; font-weight: bold',\n '',\n );\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sources":["../src/cacheUtils.ts","../src/useCallbackStable.ts","../src/HAContext.tsx","../src/styleRegistry.ts","../src/registerPreactCard.tsx"],"sourcesContent":["const CACHE_PREFIX = 'preact-ha:';\nconst CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000;\n\ninterface CacheEntry<T> {\n data: T;\n timestamp: number;\n}\n\nexport function loadFromCache<T>(key: string): T | undefined {\n try {\n const raw = localStorage.getItem(`${CACHE_PREFIX}${key}`);\n if (!raw) return undefined;\n\n const entry: CacheEntry<T> = JSON.parse(raw);\n if (Date.now() - entry.timestamp > CACHE_EXPIRY_MS) {\n localStorage.removeItem(`${CACHE_PREFIX}${key}`);\n return undefined;\n }\n return entry.data;\n } catch {\n return undefined;\n }\n}\n\nexport function saveToCache<T>(key: string, data: T): void {\n try {\n const entry: CacheEntry<T> = { data, timestamp: Date.now() };\n localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(entry));\n } catch (e) {\n console.warn('[preact-homeassistant cache] Failed to save:', e);\n }\n}\n","import { useRef } from 'preact/hooks';\n\n/**\n * Creates a stable callback reference that always calls the latest version of the callback.\n * Unlike useCallback, this never changes identity, so it won't cause re-renders in children.\n *\n * @param callback The callback function to stabilize\n * @returns A stable function reference that always calls the latest callback\n */\nexport function useCallbackStable<T extends (...args: never[]) => unknown>(callback: T): T {\n const callbackRef = useRef<T>(callback);\n\n callbackRef.current = callback;\n\n // Create a stable function reference once\n const stableRef = useRef<T | null>(null);\n if (stableRef.current === null) {\n stableRef.current = ((...args: Parameters<T>) => {\n return callbackRef.current(...args);\n }) as T;\n }\n\n return stableRef.current;\n}\n","import { createContext } from 'preact';\nimport type { ComponentChildren } from 'preact';\nimport { useContext, useEffect, useMemo, useRef, useState } from 'preact/hooks';\n\nimport { loadFromCache, saveToCache } from './cacheUtils';\nimport type {\n CalendarEvent,\n CalendarEventWithSource,\n EntityForId,\n FetchStatus,\n ForecastType,\n HomeAssistant,\n ServicesForId,\n WeatherForecast,\n} from './types';\nimport { useCallbackStable } from './useCallbackStable';\n\ninterface HAStore {\n hass: HomeAssistant | undefined;\n getHass: () => HomeAssistant | undefined;\n subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;\n}\n\nconst HAContext = createContext<HAStore | null>(null);\n\ninterface HAProviderProps {\n hass: HomeAssistant | undefined;\n subscribeToEntity: (entityId: string, callback: (entity: any) => void) => () => void;\n children: ComponentChildren;\n}\n\nexport function HAProvider({ hass, subscribeToEntity, children }: HAProviderProps) {\n const hassRef = useRef(hass);\n hassRef.current = hass;\n\n const getHass = useCallbackStable(() => hassRef.current);\n\n const store = useMemo<HAStore>(\n () => ({\n hass: hassRef.current,\n getHass,\n subscribeToEntity,\n }),\n [getHass, subscribeToEntity],\n );\n\n return <HAContext.Provider value={store}>{children}</HAContext.Provider>;\n}\n\nfunction useHAStore(): HAStore {\n const store = useContext(HAContext);\n if (!store) {\n throw new Error('useEntity/useHass must be used within an HAProvider');\n }\n return store;\n}\n\n/**\n * Subscribe to a specific entity by ID. Re-renders only when that entity changes.\n *\n * Returns a typed entity based on the domain prefix:\n * - 'calendar.xyz' -> CalendarEntity\n * - 'weather.xyz' -> WeatherEntity\n * - 'sun.sun' -> SunEntity\n * - other domains -> HassEntity (fallback)\n */\nexport function useEntity<T extends string>(entityId: T): EntityForId<T> | undefined {\n const store = useHAStore();\n const cacheKey = `entity:${entityId}`;\n\n const [entity, setEntity] = useState<EntityForId<T> | undefined>(() => {\n const current = store.hass?.states[entityId] as EntityForId<T> | undefined;\n if (current) return current;\n return loadFromCache<EntityForId<T>>(cacheKey);\n });\n\n useEffect(() => {\n const unsubscribe = store.subscribeToEntity(entityId, (newEntity) => {\n setEntity(newEntity as EntityForId<T>);\n saveToCache(cacheKey, newEntity);\n });\n return unsubscribe;\n }, [entityId, store.subscribeToEntity, cacheKey]);\n\n return entity;\n}\n\n/**\n * Get access to the full hass object for calling services / accessing config.\n * Does NOT re-render on entity changes. Use useEntity for that.\n */\nexport function useHass(): { getHass: () => HomeAssistant | undefined } {\n const store = useHAStore();\n return { getHass: store.getHass };\n}\n\ntype ServiceCaller<T extends string> = <S extends keyof ServicesForId<T> & string>(\n service: S,\n ...args: ServicesForId<T>[S] extends undefined\n ? []\n : Record<string, never> extends Exclude<ServicesForId<T>[S], undefined>\n ? [data?: ServicesForId<T>[S]]\n : [data: ServicesForId<T>[S]]\n) => Promise<void>;\n\n/**\n * Returns a stable function that calls services on a specific HA entity.\n * The service domain is parsed from the entity ID prefix and `entity_id` is\n * auto-injected into every call. Service names and data shapes are strongly\n * typed via DomainServiceMap when the domain is registered. No-ops if hass\n * is not yet available or the entity ID is empty.\n *\n * const fanService = useService(config.entity); // `fan.${string}`\n * await fanService('turn_off');\n * await fanService('set_percentage', { percentage: 67 });\n */\nexport function useService<T extends string>(entityId: T): ServiceCaller<T> {\n const { getHass } = useHass();\n return useCallbackStable(((service: string, data?: object) => {\n const hass = getHass();\n if (!hass || !entityId.includes('.')) return Promise.resolve();\n const domain = entityId.split('.', 1)[0];\n return hass.callService(domain, service, { entity_id: entityId, ...data });\n }) as ServiceCaller<T>);\n}\n\ninterface UseCachedFetchResult<T> {\n data: T | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Generic hook for fetching data with localStorage caching. Returns a cache-aware\n * status string to distinguish cached vs fresh data.\n */\nexport function useCachedFetch<T>(\n cacheKey: string,\n fetcher: () => Promise<T>,\n deps: unknown[],\n): UseCachedFetchResult<T> {\n const [data, setData] = useState<T | undefined>(() => loadFromCache<T>(cacheKey));\n const [isFresh, setIsFresh] = useState(false);\n const [isFetching, setIsFetching] = useState(false);\n const [error, setError] = useState<Error | undefined>(undefined);\n\n const fetchIdRef = useRef(0);\n\n const doFetch = useCallbackStable(async () => {\n const fetchId = ++fetchIdRef.current;\n setIsFetching(true);\n setError(undefined);\n\n try {\n const result = await fetcher();\n if (fetchId === fetchIdRef.current) {\n setData(result);\n setIsFresh(true);\n saveToCache(cacheKey, result);\n }\n } catch (err) {\n if (fetchId === fetchIdRef.current) {\n setError(err instanceof Error ? err : new Error(String(err)));\n }\n } finally {\n if (fetchId === fetchIdRef.current) {\n setIsFetching(false);\n }\n }\n });\n\n useEffect(() => {\n doFetch();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, deps);\n\n const status: FetchStatus = useMemo(() => {\n if (!data && isFetching) return 'loading';\n if (data && !isFresh && isFetching) return 'cached';\n if (data && isFresh && isFetching) return 'refreshing';\n return 'ready';\n }, [data, isFresh, isFetching]);\n\n return { data, status, error, refetch: doFetch };\n}\n\ninterface UseCalendarEventsResult {\n events: CalendarEvent[] | undefined;\n loading: boolean;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Fetch calendar events for a date range from a single calendar.\n */\nexport function useCalendarEvents(\n entityId: `calendar.${string}`,\n options: { start: Date; end: Date },\n): UseCalendarEventsResult {\n const { getHass } = useHass();\n const [events, setEvents] = useState<CalendarEvent[] | undefined>(undefined);\n const [loading, setLoading] = useState(false);\n const [error, setError] = useState<Error | undefined>(undefined);\n\n const fetchIdRef = useRef(0);\n\n const fetchEvents = useCallbackStable(async () => {\n const hass = getHass();\n if (!hass?.connection) {\n setError(new Error('Home Assistant connection not available'));\n return;\n }\n\n const fetchId = ++fetchIdRef.current;\n setLoading(true);\n setError(undefined);\n\n try {\n const result = await hass.connection.sendMessagePromise<{\n response: { [entityId: string]: { events: CalendarEvent[] } };\n }>({\n type: 'call_service',\n domain: 'calendar',\n service: 'get_events',\n service_data: {\n start_date_time: options.start.toISOString(),\n end_date_time: options.end.toISOString(),\n },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n if (fetchId === fetchIdRef.current) {\n const entityEvents = result.response?.[entityId]?.events ?? [];\n setEvents(entityEvents);\n setLoading(false);\n }\n } catch (err) {\n if (fetchId === fetchIdRef.current) {\n setError(err instanceof Error ? err : new Error(String(err)));\n setLoading(false);\n }\n }\n });\n\n useEffect(() => {\n fetchEvents();\n }, [entityId, options.start.getTime(), options.end.getTime(), fetchEvents]);\n\n return { events, loading, error, refetch: fetchEvents };\n}\n\ninterface UseMultiCalendarEventsResult {\n events: CalendarEventWithSource[] | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Fetch events from multiple calendars for a date range, with localStorage\n * caching. Events are tagged with their source calendar ID.\n */\nexport function useMultiCalendarEvents(\n entityIds: `calendar.${string}`[],\n options: { start: Date; end: Date },\n): UseMultiCalendarEventsResult {\n const store = useHAStore();\n const { getHass } = useHass();\n\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const entityIdsKey = entityIds.join(',');\n const dateRangeKey = `${options.start.getTime()}-${options.end.getTime()}`;\n const cacheKey = `events:${entityIdsKey}:${dateRangeKey}`;\n\n const fetcher = useCallbackStable(async () => {\n const hass = getHass();\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n\n if (entityIds.length === 0) {\n return [];\n }\n\n const results = await Promise.all(\n entityIds.map(async (entityId) => {\n try {\n const result = await hass.connection.sendMessagePromise<{\n response: { [key: string]: { events: CalendarEvent[] } };\n }>({\n type: 'call_service',\n domain: 'calendar',\n service: 'get_events',\n service_data: {\n start_date_time: options.start.toISOString(),\n end_date_time: options.end.toISOString(),\n },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n const calendarEvents = result.response?.[entityId]?.events ?? [];\n return calendarEvents.map(\n (event): CalendarEventWithSource => ({ ...event, calendarId: entityId }),\n );\n } catch (err) {\n console.error(`Failed to fetch events for ${entityId}:`, err);\n return [];\n }\n }),\n );\n\n return results.flat();\n });\n\n const {\n data: events,\n status,\n error,\n refetch,\n } = useCachedFetch(cacheKey, fetcher, [entityIdsKey, dateRangeKey]);\n\n const debouncedRefetch = useCallbackStable(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => refetch(), 500);\n });\n\n useEffect(() => {\n const unsubscribes = entityIds.map((entityId) =>\n store.subscribeToEntity(entityId, debouncedRefetch),\n );\n return () => {\n unsubscribes.forEach((unsub) => unsub());\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n };\n }, [entityIdsKey, store.subscribeToEntity, debouncedRefetch]);\n\n return { events, status, error, refetch };\n}\n\ninterface UseWeatherForecastResult {\n forecast: WeatherForecast[] | undefined;\n status: FetchStatus;\n error: Error | undefined;\n refetch: () => void;\n}\n\n/**\n * Fetch weather forecast data with localStorage caching. Auto-refetches at the\n * top of each hour and when the underlying entity changes (debounced).\n */\nexport function useWeatherForecast(\n entityId: `weather.${string}`,\n type: ForecastType,\n): UseWeatherForecastResult {\n const store = useHAStore();\n const { getHass } = useHass();\n const cacheKey = `forecast:${entityId}:${type}`;\n\n const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n const hourlyTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);\n\n const fetcher = useCallbackStable(async () => {\n const hass = getHass();\n if (!hass?.connection) {\n throw new Error('Home Assistant connection not available');\n }\n\n const result = await hass.connection.sendMessagePromise<{\n response: { [entityId: string]: { forecast: WeatherForecast[] } };\n }>({\n type: 'call_service',\n domain: 'weather',\n service: 'get_forecasts',\n service_data: { type },\n target: { entity_id: entityId },\n return_response: true,\n });\n\n return result.response?.[entityId]?.forecast ?? [];\n });\n\n const {\n data: forecast,\n status,\n error,\n refetch,\n } = useCachedFetch(cacheKey, fetcher, [entityId, type]);\n\n const debouncedRefetch = useCallbackStable(() => {\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n debounceTimerRef.current = setTimeout(() => refetch(), 500);\n });\n\n const scheduleHourlyRefetch = useCallbackStable(() => {\n if (hourlyTimerRef.current) {\n clearTimeout(hourlyTimerRef.current);\n }\n const now = new Date();\n const nextHour = new Date(now);\n nextHour.setHours(now.getHours() + 1, 0, 0, 0);\n const msUntilNextHour = nextHour.getTime() - now.getTime();\n\n hourlyTimerRef.current = setTimeout(() => {\n refetch();\n scheduleHourlyRefetch();\n }, msUntilNextHour);\n });\n\n useEffect(() => {\n scheduleHourlyRefetch();\n }, [entityId, type, scheduleHourlyRefetch]);\n\n useEffect(() => {\n const unsubscribe = store.subscribeToEntity(entityId, debouncedRefetch);\n return () => {\n unsubscribe();\n if (debounceTimerRef.current) {\n clearTimeout(debounceTimerRef.current);\n }\n if (hourlyTimerRef.current) {\n clearTimeout(hourlyTimerRef.current);\n }\n };\n }, [entityId, store.subscribeToEntity, debouncedRefetch]);\n\n return { forecast, status, error, refetch };\n}\n","// Style registry for Shadow DOM injection\n// Each .styles.ts file uses css`` which auto-registers\n\nconst styleRegistry: string[] = [];\n\n/**\n * CSS tagged template literal for syntax highlighting.\n * Automatically registers the styles with the global registry.\n */\nexport const css = (strings: TemplateStringsArray, ...values: unknown[]): string => {\n const result = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');\n styleRegistry.push(result);\n return result;\n};\n\n/**\n * Register raw CSS string (e.g., from ?inline imports).\n * Only registers if not already present.\n */\nexport function registerRawStyles(styles: string): void {\n if (!styleRegistry.includes(styles)) {\n styleRegistry.push(styles);\n }\n}\n\n/**\n * Get all registered styles for Shadow DOM injection.\n */\nexport function getAllStyles(): string {\n return styleRegistry.join('\\n');\n}\n","import { type ComponentType, render } from 'preact';\nimport { HAProvider } from './HAContext';\nimport { getAllStyles } from './styleRegistry';\nimport type { HomeAssistant } from './types';\n\ninterface RegisterPreactCardOptions<TConfig> {\n type: string;\n name: string;\n description: string;\n Component: ComponentType<{ config: TConfig }>;\n ConfigComponent?: ComponentType<{\n hass: HomeAssistant;\n config: TConfig;\n onConfigChanged: (config: TConfig) => void;\n }>;\n UnconfiguredComponent?: ComponentType<{}>;\n getStubConfig?: () => Partial<TConfig>;\n}\n\ndeclare global {\n interface Window {\n customCards?: Array<{ type: string; name: string; description: string }>;\n }\n}\n\nexport function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<TConfig>) {\n const {\n type,\n name,\n description,\n Component,\n ConfigComponent,\n UnconfiguredComponent,\n getStubConfig,\n } = options;\n\n class HACard extends HTMLElement {\n private _hass?: HomeAssistant;\n private _config?: TConfig;\n private _shadowRoot: ShadowRoot;\n private _hasRendered = false;\n private _entityChangeListeners = new Map<string, Set<(entity: any) => void>>();\n // Pending tree teardown — see disconnectedCallback.\n private _unmountTimer: ReturnType<typeof setTimeout> | undefined;\n\n constructor() {\n super();\n this._shadowRoot = this.attachShadow({ mode: 'open' });\n }\n\n connectedCallback() {\n // If a teardown was scheduled by a recent disconnect, cancel it: HA is\n // doing a transient detach/reattach (edit-mode toggle, drag/drop) and\n // the tree is still valid.\n if (this._unmountTimer !== undefined) {\n clearTimeout(this._unmountTimer);\n this._unmountTimer = undefined;\n }\n if (this._hass && this._config && !this._hasRendered) {\n this._render();\n }\n }\n\n disconnectedCallback() {\n // HA disconnects + reconnects the element during edit-mode toggling and\n // drag-and-drop. A reconnect lands within a tick. If we're still\n // detached after a short delay, tear the Preact tree down properly so\n // useEffect cleanups run (e.g. Leaflet's map.remove(), which removes\n // its non-passive touch/wheel listeners from window/document).\n // Leaving the tree mounted on a detached shadow root leaks listeners on\n // every reconnect cycle and eventually locks up the UI.\n if (this._unmountTimer !== undefined) return;\n this._unmountTimer = setTimeout(() => {\n this._unmountTimer = undefined;\n if (this.isConnected) return;\n render(null, this._shadowRoot);\n this._hasRendered = false;\n this._entityChangeListeners.clear();\n }, 100);\n }\n\n set hass(hass: HomeAssistant) {\n const prevStates = this._hass?.states;\n this._hass = hass;\n\n for (const [entityId, listeners] of this._entityChangeListeners) {\n const newState = hass.states[entityId];\n const oldState = prevStates?.[entityId];\n if (newState !== oldState) {\n listeners.forEach((listener) => listener(newState));\n }\n }\n\n // Render only when attached. HA may set hass/config before insertion;\n // rendering into a detached shadow root then again on connect duplicates\n // the tree. connectedCallback handles the detached-first-render case.\n if (!prevStates && this._config && this.isConnected) {\n this._render();\n }\n }\n\n setConfig(config: TConfig) {\n this._config = config;\n if (this._hass && this.isConnected) {\n this._render();\n }\n }\n\n private _subscribeToEntity = (entityId: string, callback: (entity: any) => void) => {\n if (!this._entityChangeListeners.has(entityId)) {\n this._entityChangeListeners.set(entityId, new Set());\n }\n this._entityChangeListeners.get(entityId)!.add(callback);\n\n return () => {\n const listeners = this._entityChangeListeners.get(entityId);\n if (listeners) {\n listeners.delete(callback);\n if (listeners.size === 0) {\n this._entityChangeListeners.delete(entityId);\n }\n }\n };\n };\n\n private _render() {\n if (!this._config || !this._hass) {\n if (UnconfiguredComponent) {\n render(<UnconfiguredComponent />, this._shadowRoot);\n }\n return;\n }\n\n render(\n <HAProvider hass={this._hass} subscribeToEntity={this._subscribeToEntity}>\n <style>{getAllStyles()}</style>\n <Component config={this._config} />\n </HAProvider>,\n this._shadowRoot,\n );\n this._hasRendered = true;\n }\n\n static getConfigElement() {\n if (ConfigComponent) {\n return document.createElement(`${type}-editor`);\n }\n return undefined;\n }\n\n static getStubConfig() {\n return getStubConfig?.() ?? {};\n }\n }\n\n customElements.define(type, HACard);\n\n if (ConfigComponent) {\n const EditorComponent = ConfigComponent;\n\n class HACardEditor extends HTMLElement {\n private _hass?: HomeAssistant;\n private _config?: TConfig;\n\n set hass(hass: HomeAssistant) {\n this._hass = hass;\n this._render();\n }\n\n setConfig(config: TConfig) {\n this._config = config;\n this._render();\n }\n\n private _fireConfigChanged = (config: TConfig) => {\n this.dispatchEvent(\n new CustomEvent('config-changed', {\n detail: { config },\n bubbles: true,\n composed: true,\n }),\n );\n };\n\n private _render() {\n if (!this._hass || !this._config) return;\n\n // Render to light DOM so HA's custom elements (ha-select etc.) work.\n render(\n <EditorComponent\n hass={this._hass}\n config={this._config}\n onConfigChanged={this._fireConfigChanged}\n />,\n this,\n );\n }\n }\n\n customElements.define(`${type}-editor`, HACardEditor);\n }\n\n window.customCards = window.customCards || [];\n window.customCards.push({ type, name, description });\n\n console.info(\n `%c ${name.toUpperCase()} %c loaded `,\n 'background: #3b82f6; color: white; font-weight: bold',\n '',\n );\n}\n"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "preact-homeassistant",
3
- "version": "0.1.0",
3
+ "version": "0.2.2",
4
4
  "description": "Preact hooks and helpers for building Home Assistant custom cards",
5
5
  "author": {
6
6
  "name": "Stu Kabakoff",
package/src/HAContext.tsx CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  FetchStatus,
11
11
  ForecastType,
12
12
  HomeAssistant,
13
+ ServicesForId,
13
14
  WeatherForecast,
14
15
  } from './types';
15
16
  import { useCallbackStable } from './useCallbackStable';
@@ -93,6 +94,36 @@ export function useHass(): { getHass: () => HomeAssistant | undefined } {
93
94
  return { getHass: store.getHass };
94
95
  }
95
96
 
97
+ type ServiceCaller<T extends string> = <S extends keyof ServicesForId<T> & string>(
98
+ service: S,
99
+ ...args: ServicesForId<T>[S] extends undefined
100
+ ? []
101
+ : Record<string, never> extends Exclude<ServicesForId<T>[S], undefined>
102
+ ? [data?: ServicesForId<T>[S]]
103
+ : [data: ServicesForId<T>[S]]
104
+ ) => Promise<void>;
105
+
106
+ /**
107
+ * Returns a stable function that calls services on a specific HA entity.
108
+ * The service domain is parsed from the entity ID prefix and `entity_id` is
109
+ * auto-injected into every call. Service names and data shapes are strongly
110
+ * typed via DomainServiceMap when the domain is registered. No-ops if hass
111
+ * is not yet available or the entity ID is empty.
112
+ *
113
+ * const fanService = useService(config.entity); // `fan.${string}`
114
+ * await fanService('turn_off');
115
+ * await fanService('set_percentage', { percentage: 67 });
116
+ */
117
+ export function useService<T extends string>(entityId: T): ServiceCaller<T> {
118
+ const { getHass } = useHass();
119
+ return useCallbackStable(((service: string, data?: object) => {
120
+ const hass = getHass();
121
+ if (!hass || !entityId.includes('.')) return Promise.resolve();
122
+ const domain = entityId.split('.', 1)[0];
123
+ return hass.callService(domain, service, { entity_id: entityId, ...data });
124
+ }) as ServiceCaller<T>);
125
+ }
126
+
96
127
  interface UseCachedFetchResult<T> {
97
128
  data: T | undefined;
98
129
  status: FetchStatus;
@@ -154,7 +154,12 @@ describe('registerPreactCard', () => {
154
154
  expect(callback).toHaveBeenCalledWith({ state: 'on' });
155
155
  });
156
156
 
157
- it('clears all listeners on disconnect', () => {
157
+ it('preserves entity subscriptions across disconnect + reconnect', () => {
158
+ // HA detaches + reattaches cards during edit-mode toggling. The shadow
159
+ // root and its Preact tree travel with the host, so subscriptions
160
+ // registered by useEffect must survive — otherwise the host's listener
161
+ // map empties while components still believe they're subscribed, and
162
+ // entity updates stop reaching the UI.
158
163
  const type = uniqueType();
159
164
  registerPreactCard({
160
165
  type,
@@ -173,11 +178,12 @@ describe('registerPreactCard', () => {
173
178
  card.hass = makeHass({ 'sensor.temp': { state: '72' } });
174
179
  expect(callback).toHaveBeenCalledTimes(1);
175
180
 
176
- card.disconnectedCallback();
181
+ document.body.removeChild(card);
182
+ document.body.appendChild(card);
177
183
  callback.mockClear();
178
184
 
179
185
  card.hass = makeHass({ 'sensor.temp': { state: '73' } });
180
- expect(callback).not.toHaveBeenCalled();
186
+ expect(callback).toHaveBeenCalledWith({ state: '73' });
181
187
  });
182
188
 
183
189
  it('registers editor element when ConfigComponent is provided', () => {
@@ -0,0 +1,151 @@
1
+ import { act, render } from '@testing-library/preact';
2
+ import { useRef } from 'preact/hooks';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { type ElementSize, useResizeObserver } from '../useResizeObserver';
5
+
6
+ // jsdom doesn't implement ResizeObserver. Provide a controllable stub so we
7
+ // can drive size changes deterministically.
8
+ type StubObserver = {
9
+ observe: ReturnType<typeof vi.fn>;
10
+ disconnect: ReturnType<typeof vi.fn>;
11
+ trigger: () => void;
12
+ };
13
+ const liveObservers: StubObserver[] = [];
14
+
15
+ beforeEach(() => {
16
+ liveObservers.length = 0;
17
+ (globalThis as any).ResizeObserver = class {
18
+ private cb: () => void;
19
+ public observe: any;
20
+ public disconnect: any;
21
+ constructor(cb: () => void) {
22
+ this.cb = cb;
23
+ this.observe = vi.fn();
24
+ this.disconnect = vi.fn(() => {
25
+ const idx = liveObservers.findIndex((o) => o.observe === this.observe);
26
+ if (idx !== -1) liveObservers.splice(idx, 1);
27
+ });
28
+ liveObservers.push({
29
+ observe: this.observe,
30
+ disconnect: this.disconnect,
31
+ trigger: () => this.cb(),
32
+ });
33
+ }
34
+ };
35
+ });
36
+
37
+ afterEach(() => {
38
+ (globalThis as any).ResizeObserver = undefined;
39
+ document.body.innerHTML = '';
40
+ });
41
+
42
+ function setSize(el: HTMLElement, width: number, height: number) {
43
+ Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width });
44
+ Object.defineProperty(el, 'offsetHeight', { configurable: true, value: height });
45
+ }
46
+
47
+ function Probe({
48
+ callback,
49
+ deps,
50
+ initialWidth = 400,
51
+ initialHeight = 80,
52
+ }: {
53
+ callback: (size: ElementSize) => void;
54
+ deps?: unknown[];
55
+ initialWidth?: number;
56
+ initialHeight?: number;
57
+ }) {
58
+ const ref = useRef<HTMLDivElement>(null);
59
+ // Apply size to the element synchronously before useResizeObserver's effect
60
+ // runs by relying on the callback ref pattern: the ref is populated before
61
+ // useEffect fires.
62
+ useResizeObserver(
63
+ ref,
64
+ (size) => {
65
+ callback(size);
66
+ },
67
+ deps,
68
+ );
69
+ return (
70
+ <div
71
+ ref={(el) => {
72
+ if (el) {
73
+ ref.current = el;
74
+ setSize(el, initialWidth, initialHeight);
75
+ }
76
+ }}
77
+ />
78
+ );
79
+ }
80
+
81
+ describe('useResizeObserver', () => {
82
+ it('fires once with the current size after mount', () => {
83
+ const cb = vi.fn();
84
+ render(<Probe callback={cb} initialWidth={300} initialHeight={150} />);
85
+ act(() => liveObservers[0].trigger());
86
+ expect(cb).toHaveBeenCalledTimes(1);
87
+ expect(cb).toHaveBeenCalledWith({ width: 300, height: 150 });
88
+ });
89
+
90
+ it('fires again when the element resizes', () => {
91
+ const cb = vi.fn();
92
+ const { container } = render(<Probe callback={cb} />);
93
+ act(() => liveObservers[0].trigger());
94
+ cb.mockClear();
95
+
96
+ const el = container.firstChild as HTMLElement;
97
+ setSize(el, 500, 100);
98
+ act(() => liveObservers[0].trigger());
99
+
100
+ expect(cb).toHaveBeenCalledWith({ width: 500, height: 100 });
101
+ });
102
+
103
+ it('delivers zero dimensions to the callback (consumer decides)', () => {
104
+ const cb = vi.fn();
105
+ render(<Probe callback={cb} initialWidth={0} initialHeight={80} />);
106
+ act(() => liveObservers[0].trigger());
107
+ expect(cb).toHaveBeenCalledWith({ width: 0, height: 80 });
108
+ });
109
+
110
+ it('does not fire when the element is detached', () => {
111
+ const cb = vi.fn();
112
+ const { container, unmount } = render(<Probe callback={cb} />);
113
+ const el = container.firstChild as HTMLElement;
114
+ setSize(el, 400, 80);
115
+
116
+ // Detach without unmounting Preact (simulates HA moving the host).
117
+ el.remove();
118
+ act(() => liveObservers[0]?.trigger());
119
+
120
+ expect(cb).not.toHaveBeenCalled();
121
+ unmount();
122
+ });
123
+
124
+ it('re-fires when deps change', () => {
125
+ const cb = vi.fn();
126
+ const { rerender } = render(<Probe callback={cb} deps={[1]} />);
127
+ act(() => liveObservers[0].trigger());
128
+ cb.mockClear();
129
+
130
+ rerender(<Probe callback={cb} deps={[2]} />);
131
+ expect(cb).toHaveBeenCalledWith({ width: 400, height: 80 });
132
+ });
133
+
134
+ it('does not re-fire on the mount run of the deps effect', () => {
135
+ // The observer fires once on .observe(). The deps effect's first-mount run
136
+ // must not double-fire with the same dimensions.
137
+ const cb = vi.fn();
138
+ render(<Probe callback={cb} deps={[1]} />);
139
+ act(() => liveObservers[0].trigger());
140
+ expect(cb).toHaveBeenCalledTimes(1);
141
+ });
142
+
143
+ it('disconnects the observer on unmount', () => {
144
+ const cb = vi.fn();
145
+ const { unmount } = render(<Probe callback={cb} />);
146
+ const observer = liveObservers[0];
147
+ expect(observer.disconnect).not.toHaveBeenCalled();
148
+ unmount();
149
+ expect(observer.disconnect).toHaveBeenCalled();
150
+ });
151
+ });
@@ -0,0 +1,113 @@
1
+ import { act } from '@testing-library/preact';
2
+ import { useEffect, useRef } from 'preact/hooks';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+ import { useService } from '../HAContext';
5
+ import { makeHass, renderWithHA } from './testHelpers';
6
+
7
+ function ServiceCallProbe({
8
+ entityId,
9
+ onReady,
10
+ }: {
11
+ entityId: string;
12
+ onReady: (call: ReturnType<typeof useService>) => void;
13
+ }) {
14
+ const call = useService(entityId);
15
+ useEffect(() => {
16
+ onReady(call);
17
+ }, [call, onReady]);
18
+ return null;
19
+ }
20
+
21
+ describe('useService', () => {
22
+ it('calls hass.callService with the parsed domain, service, and injected entity_id', async () => {
23
+ const callService = vi.fn().mockResolvedValue(undefined);
24
+ const hass = makeHass({}, { callService });
25
+
26
+ let invoke: ReturnType<typeof useService> | undefined;
27
+ renderWithHA(
28
+ <ServiceCallProbe
29
+ entityId="fan.bedroom"
30
+ onReady={(call) => {
31
+ invoke = call;
32
+ }}
33
+ />,
34
+ { hass },
35
+ );
36
+
37
+ await act(async () => {
38
+ await invoke!('set_percentage', { percentage: 67 });
39
+ });
40
+
41
+ expect(callService).toHaveBeenCalledWith('fan', 'set_percentage', {
42
+ entity_id: 'fan.bedroom',
43
+ percentage: 67,
44
+ });
45
+ });
46
+
47
+ it('calls services that take no data with just the injected entity_id', async () => {
48
+ const callService = vi.fn().mockResolvedValue(undefined);
49
+ const hass = makeHass({}, { callService });
50
+
51
+ let invoke: ReturnType<typeof useService> | undefined;
52
+ renderWithHA(
53
+ <ServiceCallProbe
54
+ entityId="fan.bedroom"
55
+ onReady={(call) => {
56
+ invoke = call;
57
+ }}
58
+ />,
59
+ { hass },
60
+ );
61
+
62
+ await act(async () => {
63
+ await invoke!('turn_off');
64
+ });
65
+
66
+ expect(callService).toHaveBeenCalledWith('fan', 'turn_off', {
67
+ entity_id: 'fan.bedroom',
68
+ });
69
+ });
70
+
71
+ it('returns a stable function reference across renders', () => {
72
+ const hass = makeHass({}, { callService: vi.fn() });
73
+
74
+ const refs: Array<ReturnType<typeof useService>> = [];
75
+
76
+ function Probe() {
77
+ const call = useService('fan.bedroom');
78
+ const renderCount = useRef(0);
79
+ renderCount.current++;
80
+ refs.push(call);
81
+ return <div data-testid="renders">{renderCount.current}</div>;
82
+ }
83
+
84
+ const { rerender } = renderWithHA(<Probe />, { hass });
85
+ rerender(<Probe />);
86
+ rerender(<Probe />);
87
+
88
+ // All captured refs should be the same function.
89
+ expect(new Set(refs).size).toBe(1);
90
+ });
91
+
92
+ it('is a no-op when the entity ID is empty', async () => {
93
+ const callService = vi.fn();
94
+ const hass = makeHass({}, { callService });
95
+
96
+ let invoke: ReturnType<typeof useService> | undefined;
97
+ renderWithHA(
98
+ <ServiceCallProbe
99
+ entityId=""
100
+ onReady={(call) => {
101
+ invoke = call;
102
+ }}
103
+ />,
104
+ { hass },
105
+ );
106
+
107
+ await act(async () => {
108
+ await invoke!('turn_off' as never);
109
+ });
110
+
111
+ expect(callService).not.toHaveBeenCalled();
112
+ });
113
+ });
@@ -0,0 +1,110 @@
1
+ import { act, render } from '@testing-library/preact';
2
+ import { useRef } from 'preact/hooks';
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { useWidth } from '../useWidth';
5
+
6
+ // Controllable ResizeObserver stub (same shape as useResizeObserver.test).
7
+ type StubObserver = {
8
+ observe: ReturnType<typeof vi.fn>;
9
+ disconnect: ReturnType<typeof vi.fn>;
10
+ trigger: () => void;
11
+ };
12
+ const liveObservers: StubObserver[] = [];
13
+
14
+ beforeEach(() => {
15
+ liveObservers.length = 0;
16
+ (globalThis as any).ResizeObserver = class {
17
+ private cb: () => void;
18
+ public observe: any;
19
+ public disconnect: any;
20
+ constructor(cb: () => void) {
21
+ this.cb = cb;
22
+ this.observe = vi.fn();
23
+ this.disconnect = vi.fn(() => {
24
+ const idx = liveObservers.findIndex((o) => o.observe === this.observe);
25
+ if (idx !== -1) liveObservers.splice(idx, 1);
26
+ });
27
+ liveObservers.push({
28
+ observe: this.observe,
29
+ disconnect: this.disconnect,
30
+ trigger: () => this.cb(),
31
+ });
32
+ }
33
+ };
34
+ });
35
+
36
+ afterEach(() => {
37
+ (globalThis as any).ResizeObserver = undefined;
38
+ document.body.innerHTML = '';
39
+ });
40
+
41
+ function setSize(el: HTMLElement, width: number, height: number) {
42
+ Object.defineProperty(el, 'offsetWidth', { configurable: true, value: width });
43
+ Object.defineProperty(el, 'offsetHeight', { configurable: true, value: height });
44
+ }
45
+
46
+ let lastWidth: number | undefined;
47
+
48
+ function Probe({ initialWidth = 400 }: { initialWidth?: number }) {
49
+ const ref = useRef<HTMLDivElement>(null);
50
+ lastWidth = useWidth(ref);
51
+ return (
52
+ <div
53
+ ref={(el) => {
54
+ if (el) {
55
+ ref.current = el;
56
+ setSize(el, initialWidth, 80);
57
+ }
58
+ }}
59
+ />
60
+ );
61
+ }
62
+
63
+ describe('useWidth', () => {
64
+ beforeEach(() => {
65
+ lastWidth = undefined;
66
+ });
67
+
68
+ it('returns undefined until the first measurement', () => {
69
+ render(<Probe />);
70
+ expect(lastWidth).toBeUndefined();
71
+ });
72
+
73
+ it('returns the observed width after the first non-zero measurement', () => {
74
+ const { container } = render(<Probe initialWidth={400} />);
75
+ act(() => liveObservers[0].trigger());
76
+ expect(lastWidth).toBe(400);
77
+
78
+ const el = container.firstChild as HTMLElement;
79
+ setSize(el, 500, 80);
80
+ act(() => liveObservers[0].trigger());
81
+ expect(lastWidth).toBe(500);
82
+ });
83
+
84
+ it('ignores zero-width measurements (keeps last good value)', () => {
85
+ const { container } = render(<Probe initialWidth={400} />);
86
+ act(() => liveObservers[0].trigger());
87
+ expect(lastWidth).toBe(400);
88
+
89
+ const el = container.firstChild as HTMLElement;
90
+ setSize(el, 0, 80);
91
+ act(() => liveObservers[0].trigger());
92
+ expect(lastWidth).toBe(400);
93
+
94
+ setSize(el, 450, 80);
95
+ act(() => liveObservers[0].trigger());
96
+ expect(lastWidth).toBe(450);
97
+ });
98
+
99
+ it('does not update while the element is detached', () => {
100
+ const { container } = render(<Probe initialWidth={400} />);
101
+ act(() => liveObservers[0].trigger());
102
+ expect(lastWidth).toBe(400);
103
+
104
+ const el = container.firstChild as HTMLElement;
105
+ el.remove();
106
+ setSize(el, 999, 80);
107
+ act(() => liveObservers[0]?.trigger());
108
+ expect(lastWidth).toBe(400);
109
+ });
110
+ });
package/src/index.ts CHANGED
@@ -3,12 +3,19 @@ export {
3
3
  HAProvider,
4
4
  useEntity,
5
5
  useHass,
6
+ useService,
6
7
  useCachedFetch,
7
8
  useCalendarEvents,
8
9
  useMultiCalendarEvents,
9
10
  useWeatherForecast,
10
11
  } from './HAContext';
11
12
  export { useCallbackStable } from './useCallbackStable';
13
+ export {
14
+ useResizeObserver,
15
+ type ElementSize,
16
+ type ResizeCallback,
17
+ } from './useResizeObserver';
18
+ export { useWidth } from './useWidth';
12
19
  export { css, registerRawStyles, getAllStyles } from './styleRegistry';
13
20
  export { loadFromCache, saveToCache } from './cacheUtils';
14
21
 
@@ -22,6 +29,10 @@ export type {
22
29
  WeatherForecast,
23
30
  ForecastType,
24
31
  SunEntity,
32
+ FanEntity,
33
+ FanServices,
25
34
  EntityForId,
26
35
  DomainEntityMap,
36
+ DomainServiceMap,
37
+ ServicesForId,
27
38
  } from './types';
@@ -38,7 +38,6 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
38
38
  private _hass?: HomeAssistant;
39
39
  private _config?: TConfig;
40
40
  private _shadowRoot: ShadowRoot;
41
- private _hasRendered = false;
42
41
  private _entityChangeListeners = new Map<string, Set<(entity: any) => void>>();
43
42
 
44
43
  constructor() {
@@ -47,18 +46,11 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
47
46
  }
48
47
 
49
48
  connectedCallback() {
50
- // HA disconnects + reconnects the element during edit-mode toggling.
51
- // Re-rendering on each connect causes Preact to lose its diff anchor and
52
- // append a duplicate tree, so only render once per attachment cycle.
53
- if (this._hass && this._config && !this._hasRendered) {
49
+ if (this._hass && this._config) {
54
50
  this._render();
55
51
  }
56
52
  }
57
53
 
58
- disconnectedCallback() {
59
- this._entityChangeListeners.clear();
60
- }
61
-
62
54
  set hass(hass: HomeAssistant) {
63
55
  const prevStates = this._hass?.states;
64
56
  this._hass = hass;
@@ -71,9 +63,6 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
71
63
  }
72
64
  }
73
65
 
74
- // Render only when attached. HA may set hass/config before insertion;
75
- // rendering into a detached shadow root then again on connect duplicates
76
- // the tree. connectedCallback handles the detached-first-render case.
77
66
  if (!prevStates && this._config && this.isConnected) {
78
67
  this._render();
79
68
  }
@@ -118,7 +107,6 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
118
107
  </HAProvider>,
119
108
  this._shadowRoot,
120
109
  );
121
- this._hasRendered = true;
122
110
  }
123
111
 
124
112
  static getConfigElement() {
@@ -0,0 +1,35 @@
1
+ import type { HassEntityAttributeBase, HassEntityBase } from 'home-assistant-js-websocket';
2
+
3
+ /**
4
+ * Fan entity - speed is reported as percentage (0-100) with percentage_step
5
+ * giving the smallest discrete increment the fan supports.
6
+ */
7
+ export interface FanEntity extends HassEntityBase {
8
+ state: 'on' | 'off' | 'unavailable' | 'unknown';
9
+ attributes: HassEntityAttributeBase & {
10
+ percentage?: number;
11
+ percentage_step?: number;
12
+ preset_modes?: string[];
13
+ preset_mode?: string;
14
+ oscillating?: boolean;
15
+ direction?: 'forward' | 'reverse';
16
+ supported_features?: number;
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Data payload shapes for each service in the `fan` domain.
22
+ * `undefined` means the service accepts no payload beyond `entity_id`.
23
+ * See https://www.home-assistant.io/integrations/fan/#actions
24
+ */
25
+ export interface FanServices {
26
+ turn_on: { percentage?: number; preset_mode?: string };
27
+ turn_off: undefined;
28
+ toggle: undefined;
29
+ set_percentage: { percentage: number };
30
+ set_preset_mode: { preset_mode: string };
31
+ oscillate: { oscillating: boolean };
32
+ set_direction: { direction: 'forward' | 'reverse' };
33
+ increase_speed: { percentage_step?: number };
34
+ decrease_speed: { percentage_step?: number };
35
+ }
@@ -1,5 +1,6 @@
1
1
  import type { HassEntity } from 'home-assistant-js-websocket';
2
2
  import type { CalendarEntity } from './calendar';
3
+ import type { FanEntity, FanServices } from './fan';
3
4
  import type { SunEntity } from './sun';
4
5
  import type { WeatherEntity } from './weather';
5
6
 
@@ -11,6 +12,7 @@ export type {
11
12
  } from './calendar';
12
13
  export type { WeatherEntity, WeatherForecast, ForecastType } from './weather';
13
14
  export type { SunEntity } from './sun';
15
+ export type { FanEntity, FanServices } from './fan';
14
16
 
15
17
  /**
16
18
  * Map of known HA domains to their strict entity types. Contributors adding
@@ -20,6 +22,7 @@ export interface DomainEntityMap {
20
22
  calendar: CalendarEntity;
21
23
  weather: WeatherEntity;
22
24
  sun: SunEntity;
25
+ fan: FanEntity;
23
26
  }
24
27
 
25
28
  type KnownDomain = keyof DomainEntityMap;
@@ -36,3 +39,26 @@ export type EntityForId<T extends string> = T extends `${infer D}.${string}`
36
39
  ? DomainEntityMap[D]
37
40
  : HassEntity
38
41
  : HassEntity;
42
+
43
+ /**
44
+ * Map of known HA domains to their service signatures. Mirrors DomainEntityMap.
45
+ * Contributors adding a new domain's actions should add a new file under
46
+ * `src/types/` and extend this map.
47
+ */
48
+ export interface DomainServiceMap {
49
+ fan: FanServices;
50
+ }
51
+
52
+ type KnownServiceDomain = keyof DomainServiceMap;
53
+
54
+ /**
55
+ * Infers the service registry from an entity ID literal type. Mirrors EntityForId.
56
+ *
57
+ * ServicesForId<'fan.bedroom'> -> FanServices
58
+ * ServicesForId<'sensor.foo'> -> Record<string, Record<string, unknown> | undefined> (fallback)
59
+ */
60
+ export type ServicesForId<T extends string> = T extends `${infer D}.${string}`
61
+ ? D extends KnownServiceDomain
62
+ ? DomainServiceMap[D]
63
+ : Record<string, Record<string, unknown> | undefined>
64
+ : Record<string, Record<string, unknown> | undefined>;
@@ -0,0 +1,88 @@
1
+ import type { RefObject } from 'preact';
2
+ import { useEffect, useRef } from 'preact/hooks';
3
+
4
+ export interface ElementSize {
5
+ width: number;
6
+ height: number;
7
+ }
8
+
9
+ export type ResizeCallback = (size: ElementSize) => void;
10
+
11
+ /**
12
+ * Observe an element's size via ResizeObserver. The callback fires:
13
+ *
14
+ * 1. Once after mount, with the element's current size.
15
+ * 2. Whenever the element's size changes.
16
+ * 3. Whenever `deps` change, re-firing with the current size — so callers
17
+ * can re-run draws when their inputs change without re-creating the
18
+ * observer.
19
+ *
20
+ * The callback is suppressed only while the element is detached from the
21
+ * document. Zero width/height is delivered to the callback as-is — consumers
22
+ * that need to skip degenerate sizes (e.g. canvas painters where a 0-sized
23
+ * drawImage throws InvalidStateError) should add their own early return.
24
+ *
25
+ * Sizes are read from `offsetWidth` / `offsetHeight` (CSS pixels, includes
26
+ * padding + border). The callback is held in a ref, so passing a fresh
27
+ * function each render is safe — it never re-creates the observer.
28
+ *
29
+ * @example
30
+ * const containerRef = useRef<HTMLDivElement>(null);
31
+ * const canvasRef = useRef<HTMLCanvasElement>(null);
32
+ *
33
+ * useResizeObserver(
34
+ * containerRef,
35
+ * ({ width, height }) => {
36
+ * if (width === 0 || height === 0) return; // optional, consumer's call
37
+ * drawChart(canvasRef.current, forecast, width, height);
38
+ * },
39
+ * [forecast],
40
+ * );
41
+ */
42
+ export function useResizeObserver<T extends HTMLElement>(
43
+ ref: RefObject<T>,
44
+ callback: ResizeCallback,
45
+ deps: unknown[] = [],
46
+ ): void {
47
+ const callbackRef = useRef(callback);
48
+ callbackRef.current = callback;
49
+
50
+ // Set up the observer once per element. ResizeObserver fires once
51
+ // synchronously-ish after `.observe()` with the current size, which
52
+ // covers the initial draw.
53
+ useEffect(() => {
54
+ const element = ref.current;
55
+ if (!element) return;
56
+
57
+ const fire = () => {
58
+ if (!element.isConnected) return;
59
+ callbackRef.current({
60
+ width: element.offsetWidth,
61
+ height: element.offsetHeight,
62
+ });
63
+ };
64
+
65
+ const observer = new ResizeObserver(fire);
66
+ observer.observe(element);
67
+ return () => observer.disconnect();
68
+ // ref identity is stable across renders; observer setup runs once.
69
+ // eslint-disable-next-line react-hooks/exhaustive-deps
70
+ }, []);
71
+
72
+ // Re-fire on dependency change. Skip the first render — the observer's
73
+ // initial `.observe()` fire already delivers the mount-time size.
74
+ const isFirstRun = useRef(true);
75
+ useEffect(() => {
76
+ if (isFirstRun.current) {
77
+ isFirstRun.current = false;
78
+ return;
79
+ }
80
+ const element = ref.current;
81
+ if (!element || !element.isConnected) return;
82
+ callbackRef.current({
83
+ width: element.offsetWidth,
84
+ height: element.offsetHeight,
85
+ });
86
+ // eslint-disable-next-line react-hooks/exhaustive-deps
87
+ }, deps);
88
+ }
@@ -0,0 +1,37 @@
1
+ import type { RefObject } from 'preact';
2
+ import { useState } from 'preact/hooks';
3
+ import { useResizeObserver } from './useResizeObserver';
4
+
5
+ /**
6
+ * Track the current width of a referenced element in CSS pixels.
7
+ *
8
+ * Returns `undefined` until the first non-zero measurement is observed, then
9
+ * a positive number that updates as the element resizes. Once a real width
10
+ * is captured the hook will never report `undefined` or `0` again, even
11
+ * during HA layout transitions (dashboard switch, edit-mode toggle) — the
12
+ * underlying ResizeObserver firings are silently dropped while the element
13
+ * is detached or transiently zero-width, so the component renders with the
14
+ * last good value instead of flashing through a degenerate state.
15
+ *
16
+ * Use this when you need a width value in JSX (responsive layout, prop to a
17
+ * sized child). If you only need the value imperatively inside a draw
18
+ * callback, prefer `useResizeObserver` directly — it doesn't allocate
19
+ * component state or cause re-renders.
20
+ *
21
+ * @example
22
+ * const ref = useRef<HTMLDivElement>(null);
23
+ * const width = useWidth(ref);
24
+ * return (
25
+ * <div ref={ref}>
26
+ * {width !== undefined && <Chart width={width} />}
27
+ * </div>
28
+ * );
29
+ */
30
+ export function useWidth<T extends HTMLElement>(ref: RefObject<T>): number | undefined {
31
+ const [width, setWidth] = useState<number | undefined>(undefined);
32
+ useResizeObserver(ref, (size) => {
33
+ if (size.width === 0) return;
34
+ setWidth((prev) => (prev === size.width ? prev : size.width));
35
+ });
36
+ return width;
37
+ }