preact-homeassistant 0.1.0 → 0.2.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/README.md +40 -6
- package/dist/index.d.ts +89 -0
- package/dist/index.js +17 -3
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/HAContext.tsx +31 -0
- package/src/__tests__/registerPreactCard.test.tsx +9 -3
- package/src/__tests__/useService.test.tsx +113 -0
- package/src/index.ts +5 -0
- package/src/registerPreactCard.tsx +6 -3
- package/src/types/fan.ts +35 -0
- package/src/types/index.ts +26 -0
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
|
|
113
|
-
|
|
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('
|
|
140
|
+
await getHass()?.callService('script', 'morning_routine');
|
|
118
141
|
```
|
|
119
142
|
|
|
120
143
|
### `useCalendarEvents(entityId, { start, end })`
|
|
@@ -189,8 +212,9 @@ All HA domain types live in [`src/types/`](src/types/):
|
|
|
189
212
|
- [`calendar.ts`](src/types/calendar.ts) — `CalendarEntity`, `CalendarEvent`, `CalendarEventWithSource`
|
|
190
213
|
- [`weather.ts`](src/types/weather.ts) — `WeatherEntity`, `WeatherForecast`, `ForecastType`
|
|
191
214
|
- [`sun.ts`](src/types/sun.ts) — `SunEntity`
|
|
215
|
+
- [`fan.ts`](src/types/fan.ts) — `FanEntity`, `FanServices`
|
|
192
216
|
- [`common.ts`](src/types/common.ts) — `HomeAssistant`, `FetchStatus`
|
|
193
|
-
- [`index.ts`](src/types/index.ts) — `DomainEntityMap`, `EntityForId<T>`
|
|
217
|
+
- [`index.ts`](src/types/index.ts) — `DomainEntityMap`, `EntityForId<T>`, `DomainServiceMap`, `ServicesForId<T>`
|
|
194
218
|
|
|
195
219
|
Re-exported from the package root:
|
|
196
220
|
|
|
@@ -200,8 +224,13 @@ import type {
|
|
|
200
224
|
CalendarEntity,
|
|
201
225
|
WeatherEntity,
|
|
202
226
|
SunEntity,
|
|
227
|
+
FanEntity,
|
|
228
|
+
FanServices,
|
|
203
229
|
WeatherForecast,
|
|
204
230
|
EntityForId,
|
|
231
|
+
DomainEntityMap,
|
|
232
|
+
DomainServiceMap,
|
|
233
|
+
ServicesForId,
|
|
205
234
|
/* ... */
|
|
206
235
|
} from 'preact-homeassistant';
|
|
207
236
|
```
|
|
@@ -214,11 +243,16 @@ for another domain (light, climate, media_player, cover, etc.), PRs are very
|
|
|
214
243
|
welcome.
|
|
215
244
|
|
|
216
245
|
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
|
|
218
|
-
3.
|
|
246
|
+
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.
|
|
247
|
+
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
248
|
4. Add a quick test under `src/__tests__/` if you're feeling thorough.
|
|
220
249
|
5. PR.
|
|
221
250
|
|
|
251
|
+
Both the entity types and the service types are opt-in: until a domain
|
|
252
|
+
appears in `DomainEntityMap`, `useEntity('light.foo')` falls back to
|
|
253
|
+
`HassEntity`; until it appears in `DomainServiceMap`, `useService('light.foo')`
|
|
254
|
+
still works but without per-service autocomplete.
|
|
255
|
+
|
|
222
256
|
We err toward including only fields that are well-documented; speculative
|
|
223
257
|
attributes can land later.
|
|
224
258
|
|
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);
|
|
@@ -329,9 +338,12 @@ function registerPreactCard(options) {
|
|
|
329
338
|
this._render();
|
|
330
339
|
}
|
|
331
340
|
}
|
|
332
|
-
disconnectedCallback
|
|
333
|
-
|
|
334
|
-
|
|
341
|
+
// Intentionally no disconnectedCallback: HA detaches + reattaches the card
|
|
342
|
+
// on edit-mode toggle, but the shadow root (with Preact's tree + effects)
|
|
343
|
+
// travels with the host. Clearing _entityChangeListeners here would orphan
|
|
344
|
+
// the still-mounted components — their useEffect cleanups never run, so
|
|
345
|
+
// they think they're subscribed while the host's map is empty, and entity
|
|
346
|
+
// updates stop reaching the UI.
|
|
335
347
|
set hass(hass) {
|
|
336
348
|
const prevStates = this._hass?.states;
|
|
337
349
|
this._hass = hass;
|
|
@@ -444,6 +456,7 @@ function registerPreactCard(options) {
|
|
|
444
456
|
export {
|
|
445
457
|
HAProvider,
|
|
446
458
|
css,
|
|
459
|
+
getAllStyles,
|
|
447
460
|
loadFromCache,
|
|
448
461
|
registerPreactCard,
|
|
449
462
|
registerRawStyles,
|
|
@@ -454,6 +467,7 @@ export {
|
|
|
454
467
|
useEntity,
|
|
455
468
|
useHass,
|
|
456
469
|
useMultiCalendarEvents,
|
|
470
|
+
useService,
|
|
457
471
|
useWeatherForecast
|
|
458
472
|
};
|
|
459
473
|
//# 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\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 // Intentionally no disconnectedCallback: HA detaches + reattaches the card\n // on edit-mode toggle, but the shadow root (with Preact's tree + effects)\n // travels with the host. Clearing _entityChangeListeners here would orphan\n // the still-mounted components — their useEffect cleanups never run, so\n // they think they're subscribed while the host's map is empty, and entity\n // updates stop reaching the UI.\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
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('
|
|
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
|
-
|
|
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).
|
|
186
|
+
expect(callback).toHaveBeenCalledWith({ state: '73' });
|
|
181
187
|
});
|
|
182
188
|
|
|
183
189
|
it('registers editor element when ConfigComponent is provided', () => {
|
|
@@ -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
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -3,6 +3,7 @@ export {
|
|
|
3
3
|
HAProvider,
|
|
4
4
|
useEntity,
|
|
5
5
|
useHass,
|
|
6
|
+
useService,
|
|
6
7
|
useCachedFetch,
|
|
7
8
|
useCalendarEvents,
|
|
8
9
|
useMultiCalendarEvents,
|
|
@@ -22,6 +23,10 @@ export type {
|
|
|
22
23
|
WeatherForecast,
|
|
23
24
|
ForecastType,
|
|
24
25
|
SunEntity,
|
|
26
|
+
FanEntity,
|
|
27
|
+
FanServices,
|
|
25
28
|
EntityForId,
|
|
26
29
|
DomainEntityMap,
|
|
30
|
+
DomainServiceMap,
|
|
31
|
+
ServicesForId,
|
|
27
32
|
} from './types';
|
|
@@ -55,9 +55,12 @@ export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<T
|
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
disconnectedCallback
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
// Intentionally no disconnectedCallback: HA detaches + reattaches the card
|
|
59
|
+
// on edit-mode toggle, but the shadow root (with Preact's tree + effects)
|
|
60
|
+
// travels with the host. Clearing _entityChangeListeners here would orphan
|
|
61
|
+
// the still-mounted components — their useEffect cleanups never run, so
|
|
62
|
+
// they think they're subscribed while the host's map is empty, and entity
|
|
63
|
+
// updates stop reaching the UI.
|
|
61
64
|
|
|
62
65
|
set hass(hass: HomeAssistant) {
|
|
63
66
|
const prevStates = this._hass?.states;
|
package/src/types/fan.ts
ADDED
|
@@ -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
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -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>;
|