preact-homeassistant 0.1.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/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/index.d.ts +276 -0
- package/dist/index.js +459 -0
- package/dist/index.js.map +1 -0
- package/package.json +63 -0
- package/src/HAContext.tsx +407 -0
- package/src/__tests__/cacheUtils.test.ts +61 -0
- package/src/__tests__/registerPreactCard.test.tsx +213 -0
- package/src/__tests__/setup.ts +27 -0
- package/src/__tests__/testHelpers.tsx +73 -0
- package/src/__tests__/useCachedFetch.test.tsx +147 -0
- package/src/__tests__/useEntity.test.tsx +140 -0
- package/src/__tests__/useMultiCalendarEvents.test.tsx +217 -0
- package/src/__tests__/useWeatherForecast.test.tsx +197 -0
- package/src/cacheUtils.ts +32 -0
- package/src/index.ts +27 -0
- package/src/registerPreactCard.tsx +191 -0
- package/src/styleRegistry.ts +31 -0
- package/src/types/calendar.ts +40 -0
- package/src/types/common.ts +23 -0
- package/src/types/index.ts +38 -0
- package/src/types/sun.ts +20 -0
- package/src/types/weather.ts +42 -0
- package/src/useCallbackStable.ts +24 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { act, screen, waitFor } from '@testing-library/preact';
|
|
2
|
+
import { render } from '@testing-library/preact';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { useWeatherForecast } from '../HAContext';
|
|
5
|
+
import { HAProvider } from '../HAContext';
|
|
6
|
+
import type { FetchStatus } from '../types';
|
|
7
|
+
import { createMockSubscribe, makeHass } from './testHelpers';
|
|
8
|
+
|
|
9
|
+
function ForecastDisplay({
|
|
10
|
+
entityId,
|
|
11
|
+
type,
|
|
12
|
+
}: {
|
|
13
|
+
entityId: `weather.${string}`;
|
|
14
|
+
type: 'daily' | 'hourly' | 'twice_daily';
|
|
15
|
+
}) {
|
|
16
|
+
const { forecast, status, error } = useWeatherForecast(entityId, type);
|
|
17
|
+
return (
|
|
18
|
+
<div>
|
|
19
|
+
<span data-testid="count">{forecast?.length ?? 'loading'}</span>
|
|
20
|
+
<span data-testid="status">{status}</span>
|
|
21
|
+
<span data-testid="error">{error?.message ?? 'none'}</span>
|
|
22
|
+
<span data-testid="conditions">{forecast?.map((f) => f.condition).join(',') ?? ''}</span>
|
|
23
|
+
</div>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe('useWeatherForecast', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
localStorage.clear();
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('fetches forecast data', async () => {
|
|
34
|
+
const sendMessagePromise = vi.fn().mockResolvedValue({
|
|
35
|
+
response: {
|
|
36
|
+
'weather.home': {
|
|
37
|
+
forecast: [
|
|
38
|
+
{ datetime: '2025-01-01T00:00:00Z', condition: 'sunny', temperature: 72 },
|
|
39
|
+
{ datetime: '2025-01-02T00:00:00Z', condition: 'cloudy', temperature: 65 },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const hass = makeHass(
|
|
46
|
+
{},
|
|
47
|
+
{
|
|
48
|
+
connection: { sendMessagePromise } as any,
|
|
49
|
+
},
|
|
50
|
+
);
|
|
51
|
+
const { subscribe } = createMockSubscribe();
|
|
52
|
+
|
|
53
|
+
render(
|
|
54
|
+
<HAProvider hass={hass} subscribeToEntity={subscribe}>
|
|
55
|
+
<ForecastDisplay entityId="weather.home" type="daily" />
|
|
56
|
+
</HAProvider>,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
await waitFor(() => {
|
|
60
|
+
expect(screen.getByTestId('status').textContent).toBe('ready');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
expect(screen.getByTestId('count').textContent).toBe('2');
|
|
64
|
+
expect(screen.getByTestId('conditions').textContent).toBe('sunny,cloudy');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('sends the correct service call', async () => {
|
|
68
|
+
const sendMessagePromise = vi.fn().mockResolvedValue({
|
|
69
|
+
response: { 'weather.home': { forecast: [] } },
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const hass = makeHass(
|
|
73
|
+
{},
|
|
74
|
+
{
|
|
75
|
+
connection: { sendMessagePromise } as any,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
const { subscribe } = createMockSubscribe();
|
|
79
|
+
|
|
80
|
+
render(
|
|
81
|
+
<HAProvider hass={hass} subscribeToEntity={subscribe}>
|
|
82
|
+
<ForecastDisplay entityId="weather.home" type="hourly" />
|
|
83
|
+
</HAProvider>,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
await waitFor(() => {
|
|
87
|
+
expect(sendMessagePromise).toHaveBeenCalled();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(sendMessagePromise).toHaveBeenCalledWith(
|
|
91
|
+
expect.objectContaining({
|
|
92
|
+
type: 'call_service',
|
|
93
|
+
domain: 'weather',
|
|
94
|
+
service: 'get_forecasts',
|
|
95
|
+
service_data: { type: 'hourly' },
|
|
96
|
+
target: { entity_id: 'weather.home' },
|
|
97
|
+
return_response: true,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('refetches when entity changes (debounced)', async () => {
|
|
103
|
+
vi.useFakeTimers();
|
|
104
|
+
|
|
105
|
+
const sendMessagePromise = vi.fn().mockResolvedValue({
|
|
106
|
+
response: { 'weather.home': { forecast: [] } },
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const hass = makeHass(
|
|
110
|
+
{},
|
|
111
|
+
{
|
|
112
|
+
connection: { sendMessagePromise } as any,
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
const { subscribe, notify } = createMockSubscribe();
|
|
116
|
+
|
|
117
|
+
render(
|
|
118
|
+
<HAProvider hass={hass} subscribeToEntity={subscribe}>
|
|
119
|
+
<ForecastDisplay entityId="weather.home" type="daily" />
|
|
120
|
+
</HAProvider>,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Let initial fetch + timer scheduling settle
|
|
124
|
+
await act(async () => {
|
|
125
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const initialCallCount = sendMessagePromise.mock.calls.length;
|
|
129
|
+
|
|
130
|
+
// Simulate entity change
|
|
131
|
+
act(() => {
|
|
132
|
+
notify('weather.home', { state: 'cloudy' });
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
// Advance past debounce (500ms)
|
|
136
|
+
await act(async () => {
|
|
137
|
+
await vi.advanceTimersByTimeAsync(600);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
expect(sendMessagePromise.mock.calls.length).toBeGreaterThan(initialCallCount);
|
|
141
|
+
|
|
142
|
+
vi.useRealTimers();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('handles missing connection', async () => {
|
|
146
|
+
const hass = makeHass({}, { connection: undefined as any });
|
|
147
|
+
const { subscribe } = createMockSubscribe();
|
|
148
|
+
|
|
149
|
+
render(
|
|
150
|
+
<HAProvider hass={hass} subscribeToEntity={subscribe}>
|
|
151
|
+
<ForecastDisplay entityId="weather.home" type="daily" />
|
|
152
|
+
</HAProvider>,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
expect(screen.getByTestId('error').textContent).toContain('connection');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('schedules hourly refetch', async () => {
|
|
161
|
+
vi.useFakeTimers();
|
|
162
|
+
|
|
163
|
+
const sendMessagePromise = vi.fn().mockResolvedValue({
|
|
164
|
+
response: { 'weather.home': { forecast: [] } },
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
const hass = makeHass(
|
|
168
|
+
{},
|
|
169
|
+
{
|
|
170
|
+
connection: { sendMessagePromise } as any,
|
|
171
|
+
},
|
|
172
|
+
);
|
|
173
|
+
const { subscribe } = createMockSubscribe();
|
|
174
|
+
|
|
175
|
+
render(
|
|
176
|
+
<HAProvider hass={hass} subscribeToEntity={subscribe}>
|
|
177
|
+
<ForecastDisplay entityId="weather.home" type="hourly" />
|
|
178
|
+
</HAProvider>,
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
// Let initial fetch settle
|
|
182
|
+
await act(async () => {
|
|
183
|
+
await vi.advanceTimersByTimeAsync(100);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const callsAfterInit = sendMessagePromise.mock.calls.length;
|
|
187
|
+
|
|
188
|
+
// Advance to next hour
|
|
189
|
+
await act(async () => {
|
|
190
|
+
await vi.advanceTimersByTimeAsync(61 * 60 * 1000);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(sendMessagePromise.mock.calls.length).toBeGreaterThan(callsAfterInit);
|
|
194
|
+
|
|
195
|
+
vi.useRealTimers();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const CACHE_PREFIX = 'preact-ha:';
|
|
2
|
+
const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000;
|
|
3
|
+
|
|
4
|
+
interface CacheEntry<T> {
|
|
5
|
+
data: T;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function loadFromCache<T>(key: string): T | undefined {
|
|
10
|
+
try {
|
|
11
|
+
const raw = localStorage.getItem(`${CACHE_PREFIX}${key}`);
|
|
12
|
+
if (!raw) return undefined;
|
|
13
|
+
|
|
14
|
+
const entry: CacheEntry<T> = JSON.parse(raw);
|
|
15
|
+
if (Date.now() - entry.timestamp > CACHE_EXPIRY_MS) {
|
|
16
|
+
localStorage.removeItem(`${CACHE_PREFIX}${key}`);
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
return entry.data;
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function saveToCache<T>(key: string, data: T): void {
|
|
26
|
+
try {
|
|
27
|
+
const entry: CacheEntry<T> = { data, timestamp: Date.now() };
|
|
28
|
+
localStorage.setItem(`${CACHE_PREFIX}${key}`, JSON.stringify(entry));
|
|
29
|
+
} catch (e) {
|
|
30
|
+
console.warn('[preact-homeassistant cache] Failed to save:', e);
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { registerPreactCard } from './registerPreactCard';
|
|
2
|
+
export {
|
|
3
|
+
HAProvider,
|
|
4
|
+
useEntity,
|
|
5
|
+
useHass,
|
|
6
|
+
useCachedFetch,
|
|
7
|
+
useCalendarEvents,
|
|
8
|
+
useMultiCalendarEvents,
|
|
9
|
+
useWeatherForecast,
|
|
10
|
+
} from './HAContext';
|
|
11
|
+
export { useCallbackStable } from './useCallbackStable';
|
|
12
|
+
export { css, registerRawStyles, getAllStyles } from './styleRegistry';
|
|
13
|
+
export { loadFromCache, saveToCache } from './cacheUtils';
|
|
14
|
+
|
|
15
|
+
export type {
|
|
16
|
+
HomeAssistant,
|
|
17
|
+
FetchStatus,
|
|
18
|
+
CalendarEntity,
|
|
19
|
+
CalendarEvent,
|
|
20
|
+
CalendarEventWithSource,
|
|
21
|
+
WeatherEntity,
|
|
22
|
+
WeatherForecast,
|
|
23
|
+
ForecastType,
|
|
24
|
+
SunEntity,
|
|
25
|
+
EntityForId,
|
|
26
|
+
DomainEntityMap,
|
|
27
|
+
} from './types';
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { type ComponentType, render } from 'preact';
|
|
2
|
+
import { HAProvider } from './HAContext';
|
|
3
|
+
import { getAllStyles } from './styleRegistry';
|
|
4
|
+
import type { HomeAssistant } from './types';
|
|
5
|
+
|
|
6
|
+
interface RegisterPreactCardOptions<TConfig> {
|
|
7
|
+
type: string;
|
|
8
|
+
name: string;
|
|
9
|
+
description: string;
|
|
10
|
+
Component: ComponentType<{ config: TConfig }>;
|
|
11
|
+
ConfigComponent?: ComponentType<{
|
|
12
|
+
hass: HomeAssistant;
|
|
13
|
+
config: TConfig;
|
|
14
|
+
onConfigChanged: (config: TConfig) => void;
|
|
15
|
+
}>;
|
|
16
|
+
UnconfiguredComponent?: ComponentType<{}>;
|
|
17
|
+
getStubConfig?: () => Partial<TConfig>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
declare global {
|
|
21
|
+
interface Window {
|
|
22
|
+
customCards?: Array<{ type: string; name: string; description: string }>;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function registerPreactCard<TConfig>(options: RegisterPreactCardOptions<TConfig>) {
|
|
27
|
+
const {
|
|
28
|
+
type,
|
|
29
|
+
name,
|
|
30
|
+
description,
|
|
31
|
+
Component,
|
|
32
|
+
ConfigComponent,
|
|
33
|
+
UnconfiguredComponent,
|
|
34
|
+
getStubConfig,
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
class HACard extends HTMLElement {
|
|
38
|
+
private _hass?: HomeAssistant;
|
|
39
|
+
private _config?: TConfig;
|
|
40
|
+
private _shadowRoot: ShadowRoot;
|
|
41
|
+
private _hasRendered = false;
|
|
42
|
+
private _entityChangeListeners = new Map<string, Set<(entity: any) => void>>();
|
|
43
|
+
|
|
44
|
+
constructor() {
|
|
45
|
+
super();
|
|
46
|
+
this._shadowRoot = this.attachShadow({ mode: 'open' });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
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) {
|
|
54
|
+
this._render();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
disconnectedCallback() {
|
|
59
|
+
this._entityChangeListeners.clear();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
set hass(hass: HomeAssistant) {
|
|
63
|
+
const prevStates = this._hass?.states;
|
|
64
|
+
this._hass = hass;
|
|
65
|
+
|
|
66
|
+
for (const [entityId, listeners] of this._entityChangeListeners) {
|
|
67
|
+
const newState = hass.states[entityId];
|
|
68
|
+
const oldState = prevStates?.[entityId];
|
|
69
|
+
if (newState !== oldState) {
|
|
70
|
+
listeners.forEach((listener) => listener(newState));
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
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
|
+
if (!prevStates && this._config && this.isConnected) {
|
|
78
|
+
this._render();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
setConfig(config: TConfig) {
|
|
83
|
+
this._config = config;
|
|
84
|
+
if (this._hass && this.isConnected) {
|
|
85
|
+
this._render();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private _subscribeToEntity = (entityId: string, callback: (entity: any) => void) => {
|
|
90
|
+
if (!this._entityChangeListeners.has(entityId)) {
|
|
91
|
+
this._entityChangeListeners.set(entityId, new Set());
|
|
92
|
+
}
|
|
93
|
+
this._entityChangeListeners.get(entityId)!.add(callback);
|
|
94
|
+
|
|
95
|
+
return () => {
|
|
96
|
+
const listeners = this._entityChangeListeners.get(entityId);
|
|
97
|
+
if (listeners) {
|
|
98
|
+
listeners.delete(callback);
|
|
99
|
+
if (listeners.size === 0) {
|
|
100
|
+
this._entityChangeListeners.delete(entityId);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
private _render() {
|
|
107
|
+
if (!this._config || !this._hass) {
|
|
108
|
+
if (UnconfiguredComponent) {
|
|
109
|
+
render(<UnconfiguredComponent />, this._shadowRoot);
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
render(
|
|
115
|
+
<HAProvider hass={this._hass} subscribeToEntity={this._subscribeToEntity}>
|
|
116
|
+
<style>{getAllStyles()}</style>
|
|
117
|
+
<Component config={this._config} />
|
|
118
|
+
</HAProvider>,
|
|
119
|
+
this._shadowRoot,
|
|
120
|
+
);
|
|
121
|
+
this._hasRendered = true;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
static getConfigElement() {
|
|
125
|
+
if (ConfigComponent) {
|
|
126
|
+
return document.createElement(`${type}-editor`);
|
|
127
|
+
}
|
|
128
|
+
return undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static getStubConfig() {
|
|
132
|
+
return getStubConfig?.() ?? {};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
customElements.define(type, HACard);
|
|
137
|
+
|
|
138
|
+
if (ConfigComponent) {
|
|
139
|
+
const EditorComponent = ConfigComponent;
|
|
140
|
+
|
|
141
|
+
class HACardEditor extends HTMLElement {
|
|
142
|
+
private _hass?: HomeAssistant;
|
|
143
|
+
private _config?: TConfig;
|
|
144
|
+
|
|
145
|
+
set hass(hass: HomeAssistant) {
|
|
146
|
+
this._hass = hass;
|
|
147
|
+
this._render();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
setConfig(config: TConfig) {
|
|
151
|
+
this._config = config;
|
|
152
|
+
this._render();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private _fireConfigChanged = (config: TConfig) => {
|
|
156
|
+
this.dispatchEvent(
|
|
157
|
+
new CustomEvent('config-changed', {
|
|
158
|
+
detail: { config },
|
|
159
|
+
bubbles: true,
|
|
160
|
+
composed: true,
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
private _render() {
|
|
166
|
+
if (!this._hass || !this._config) return;
|
|
167
|
+
|
|
168
|
+
// Render to light DOM so HA's custom elements (ha-select etc.) work.
|
|
169
|
+
render(
|
|
170
|
+
<EditorComponent
|
|
171
|
+
hass={this._hass}
|
|
172
|
+
config={this._config}
|
|
173
|
+
onConfigChanged={this._fireConfigChanged}
|
|
174
|
+
/>,
|
|
175
|
+
this,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
customElements.define(`${type}-editor`, HACardEditor);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
window.customCards = window.customCards || [];
|
|
184
|
+
window.customCards.push({ type, name, description });
|
|
185
|
+
|
|
186
|
+
console.info(
|
|
187
|
+
`%c ${name.toUpperCase()} %c loaded `,
|
|
188
|
+
'background: #3b82f6; color: white; font-weight: bold',
|
|
189
|
+
'',
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Style registry for Shadow DOM injection
|
|
2
|
+
// Each .styles.ts file uses css`` which auto-registers
|
|
3
|
+
|
|
4
|
+
const styleRegistry: string[] = [];
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* CSS tagged template literal for syntax highlighting.
|
|
8
|
+
* Automatically registers the styles with the global registry.
|
|
9
|
+
*/
|
|
10
|
+
export const css = (strings: TemplateStringsArray, ...values: unknown[]): string => {
|
|
11
|
+
const result = strings.reduce((acc, str, i) => acc + str + (values[i] ?? ''), '');
|
|
12
|
+
styleRegistry.push(result);
|
|
13
|
+
return result;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Register raw CSS string (e.g., from ?inline imports).
|
|
18
|
+
* Only registers if not already present.
|
|
19
|
+
*/
|
|
20
|
+
export function registerRawStyles(styles: string): void {
|
|
21
|
+
if (!styleRegistry.includes(styles)) {
|
|
22
|
+
styleRegistry.push(styles);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Get all registered styles for Shadow DOM injection.
|
|
28
|
+
*/
|
|
29
|
+
export function getAllStyles(): string {
|
|
30
|
+
return styleRegistry.join('\n');
|
|
31
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { HassEntityAttributeBase, HassEntityBase } from 'home-assistant-js-websocket';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Calendar entity - only exposes the current/next event.
|
|
5
|
+
* Use useCalendarEvents() to fetch a list of events.
|
|
6
|
+
*/
|
|
7
|
+
export interface CalendarEntity extends HassEntityBase {
|
|
8
|
+
entity_id: `calendar.${string}`;
|
|
9
|
+
state: 'on' | 'off' | 'unavailable' | 'unknown';
|
|
10
|
+
attributes: HassEntityAttributeBase & {
|
|
11
|
+
message?: string;
|
|
12
|
+
description?: string;
|
|
13
|
+
start_time?: string;
|
|
14
|
+
end_time?: string;
|
|
15
|
+
location?: string;
|
|
16
|
+
all_day?: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Calendar event returned by the calendar/get_events websocket call.
|
|
22
|
+
*/
|
|
23
|
+
export interface CalendarEvent {
|
|
24
|
+
start: string;
|
|
25
|
+
end: string;
|
|
26
|
+
summary: string;
|
|
27
|
+
description?: string;
|
|
28
|
+
location?: string;
|
|
29
|
+
uid?: string;
|
|
30
|
+
recurrence_id?: string;
|
|
31
|
+
rrule?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Calendar event with its source calendar entity ID attached. Used by
|
|
36
|
+
* useMultiCalendarEvents() to disambiguate events from multiple calendars.
|
|
37
|
+
*/
|
|
38
|
+
export interface CalendarEventWithSource extends CalendarEvent {
|
|
39
|
+
calendarId: string;
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Connection,
|
|
3
|
+
HassConfig,
|
|
4
|
+
HassEntities,
|
|
5
|
+
HassServices,
|
|
6
|
+
} from 'home-assistant-js-websocket';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Subset of the Home Assistant `hass` object passed to custom cards.
|
|
10
|
+
*/
|
|
11
|
+
export interface HomeAssistant {
|
|
12
|
+
states: HassEntities;
|
|
13
|
+
config: HassConfig;
|
|
14
|
+
services: HassServices;
|
|
15
|
+
connection: Connection;
|
|
16
|
+
callService: (domain: string, service: string, data?: object) => Promise<void>;
|
|
17
|
+
themes?: {
|
|
18
|
+
darkMode?: boolean;
|
|
19
|
+
theme?: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type FetchStatus = 'loading' | 'cached' | 'ready' | 'refreshing';
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { HassEntity } from 'home-assistant-js-websocket';
|
|
2
|
+
import type { CalendarEntity } from './calendar';
|
|
3
|
+
import type { SunEntity } from './sun';
|
|
4
|
+
import type { WeatherEntity } from './weather';
|
|
5
|
+
|
|
6
|
+
export type { HomeAssistant, FetchStatus } from './common';
|
|
7
|
+
export type {
|
|
8
|
+
CalendarEntity,
|
|
9
|
+
CalendarEvent,
|
|
10
|
+
CalendarEventWithSource,
|
|
11
|
+
} from './calendar';
|
|
12
|
+
export type { WeatherEntity, WeatherForecast, ForecastType } from './weather';
|
|
13
|
+
export type { SunEntity } from './sun';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Map of known HA domains to their strict entity types. Contributors adding
|
|
17
|
+
* new domain types should add a new file under `src/types/` and extend this map.
|
|
18
|
+
*/
|
|
19
|
+
export interface DomainEntityMap {
|
|
20
|
+
calendar: CalendarEntity;
|
|
21
|
+
weather: WeatherEntity;
|
|
22
|
+
sun: SunEntity;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type KnownDomain = keyof DomainEntityMap;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Infers the strict entity type from an entity ID literal type.
|
|
29
|
+
*
|
|
30
|
+
* EntityForId<'calendar.family'> -> CalendarEntity
|
|
31
|
+
* EntityForId<'weather.home'> -> WeatherEntity
|
|
32
|
+
* EntityForId<'sensor.foo'> -> HassEntity (fallback)
|
|
33
|
+
*/
|
|
34
|
+
export type EntityForId<T extends string> = T extends `${infer D}.${string}`
|
|
35
|
+
? D extends KnownDomain
|
|
36
|
+
? DomainEntityMap[D]
|
|
37
|
+
: HassEntity
|
|
38
|
+
: HassEntity;
|
package/src/types/sun.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { HassEntityAttributeBase, HassEntityBase } from 'home-assistant-js-websocket';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sun entity - provides sunrise/sunset and elevation information.
|
|
5
|
+
*/
|
|
6
|
+
export interface SunEntity extends HassEntityBase {
|
|
7
|
+
entity_id: 'sun.sun';
|
|
8
|
+
state: 'above_horizon' | 'below_horizon';
|
|
9
|
+
attributes: HassEntityAttributeBase & {
|
|
10
|
+
next_dawn?: string;
|
|
11
|
+
next_dusk?: string;
|
|
12
|
+
next_midnight?: string;
|
|
13
|
+
next_noon?: string;
|
|
14
|
+
next_rising?: string;
|
|
15
|
+
next_setting?: string;
|
|
16
|
+
elevation?: number;
|
|
17
|
+
azimuth?: number;
|
|
18
|
+
rising?: boolean;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { HassEntityAttributeBase, HassEntityBase } from 'home-assistant-js-websocket';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Weather entity - current conditions only.
|
|
5
|
+
* Use useWeatherForecast() to fetch forecast data.
|
|
6
|
+
*/
|
|
7
|
+
export interface WeatherEntity extends HassEntityBase {
|
|
8
|
+
entity_id: `weather.${string}`;
|
|
9
|
+
state: string;
|
|
10
|
+
attributes: HassEntityAttributeBase & {
|
|
11
|
+
temperature?: number;
|
|
12
|
+
apparent_temperature?: number;
|
|
13
|
+
dew_point?: number;
|
|
14
|
+
humidity?: number;
|
|
15
|
+
pressure?: number;
|
|
16
|
+
wind_speed?: number;
|
|
17
|
+
wind_gust_speed?: number;
|
|
18
|
+
wind_bearing?: number;
|
|
19
|
+
visibility?: number;
|
|
20
|
+
supported_features?: number;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Weather forecast item returned by the weather/get_forecasts websocket call.
|
|
26
|
+
*/
|
|
27
|
+
export interface WeatherForecast {
|
|
28
|
+
datetime: string;
|
|
29
|
+
condition?: string;
|
|
30
|
+
temperature?: number;
|
|
31
|
+
templow?: number;
|
|
32
|
+
precipitation_probability?: number;
|
|
33
|
+
precipitation?: number;
|
|
34
|
+
humidity?: number;
|
|
35
|
+
wind_speed?: number;
|
|
36
|
+
wind_bearing?: number;
|
|
37
|
+
cloud_coverage?: number;
|
|
38
|
+
uv_index?: number;
|
|
39
|
+
is_daytime?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type ForecastType = 'daily' | 'hourly' | 'twice_daily';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { useRef } from 'preact/hooks';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Creates a stable callback reference that always calls the latest version of the callback.
|
|
5
|
+
* Unlike useCallback, this never changes identity, so it won't cause re-renders in children.
|
|
6
|
+
*
|
|
7
|
+
* @param callback The callback function to stabilize
|
|
8
|
+
* @returns A stable function reference that always calls the latest callback
|
|
9
|
+
*/
|
|
10
|
+
export function useCallbackStable<T extends (...args: never[]) => unknown>(callback: T): T {
|
|
11
|
+
const callbackRef = useRef<T>(callback);
|
|
12
|
+
|
|
13
|
+
callbackRef.current = callback;
|
|
14
|
+
|
|
15
|
+
// Create a stable function reference once
|
|
16
|
+
const stableRef = useRef<T | null>(null);
|
|
17
|
+
if (stableRef.current === null) {
|
|
18
|
+
stableRef.current = ((...args: Parameters<T>) => {
|
|
19
|
+
return callbackRef.current(...args);
|
|
20
|
+
}) as T;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return stableRef.current;
|
|
24
|
+
}
|