@hashsome/integration.home-assistant 0.0.0-stage → 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/LICENSE +674 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/browse.d.ts +19 -0
- package/dist/browse.js +45 -0
- package/dist/commands.d.ts +8 -0
- package/dist/commands.js +177 -0
- package/dist/forecast.d.ts +27 -0
- package/dist/forecast.js +50 -0
- package/dist/history.d.ts +20 -0
- package/dist/history.js +23 -0
- package/dist/index.d.ts +79 -0
- package/dist/index.js +246 -0
- package/dist/logbook.d.ts +28 -0
- package/dist/logbook.js +78 -0
- package/dist/mappers/climate.d.ts +6 -0
- package/dist/mappers/climate.js +55 -0
- package/dist/mappers/common.d.ts +24 -0
- package/dist/mappers/common.js +72 -0
- package/dist/mappers/index.d.ts +14 -0
- package/dist/mappers/index.js +37 -0
- package/dist/mappers/light.d.ts +3 -0
- package/dist/mappers/light.js +49 -0
- package/dist/mappers/media-player.d.ts +3 -0
- package/dist/mappers/media-player.js +69 -0
- package/dist/mappers/simple.d.ts +8 -0
- package/dist/mappers/simple.js +38 -0
- package/dist/mappers/weather.d.ts +5 -0
- package/dist/mappers/weather.js +40 -0
- package/dist/mock.d.ts +11 -0
- package/dist/mock.js +114 -0
- package/package.json +38 -3
- package/README.md +0 -3
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { type HassEntities } from 'home-assistant-js-websocket';
|
|
2
|
+
import { BaseIntegration, type BrowseQuery, type BrowseResult, type ForecastQuery, type ForecastResult, type HistoryQuery, type HistoryResult, type LogbookQuery, type LogbookResult, type Unsubscribe } from '@hashsome/core';
|
|
3
|
+
export interface HaArea {
|
|
4
|
+
area_id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
icon?: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface HaEntityRegistryEntry {
|
|
9
|
+
entity_id: string;
|
|
10
|
+
name?: string | null;
|
|
11
|
+
original_name?: string | null;
|
|
12
|
+
area_id?: string | null;
|
|
13
|
+
device_id?: string | null;
|
|
14
|
+
icon?: string | null;
|
|
15
|
+
disabled_by?: string | null;
|
|
16
|
+
hidden_by?: string | null;
|
|
17
|
+
}
|
|
18
|
+
/** Thin seam over `home-assistant-js-websocket` so the integration can be tested without a server. */
|
|
19
|
+
export interface HaClient {
|
|
20
|
+
subscribeEntities(listener: (entities: HassEntities) => void): Unsubscribe;
|
|
21
|
+
callService(domain: string, service: string, data?: Record<string, unknown>, target?: {
|
|
22
|
+
entity_id?: string[];
|
|
23
|
+
}): Promise<unknown>;
|
|
24
|
+
sendCommand<T>(message: {
|
|
25
|
+
type: string;
|
|
26
|
+
} & Record<string, unknown>): Promise<T>;
|
|
27
|
+
on(event: 'ready' | 'disconnected' | 'reconnect-error', listener: () => void): void;
|
|
28
|
+
close(): void;
|
|
29
|
+
}
|
|
30
|
+
export interface HomeAssistantOptions {
|
|
31
|
+
/** Base URL, e.g. `http://homeassistant.local:8123`. */
|
|
32
|
+
url: string;
|
|
33
|
+
/** Long-lived access token. */
|
|
34
|
+
token: string;
|
|
35
|
+
/** Integration id used in entity refs. Defaults to `ha`. */
|
|
36
|
+
id?: string;
|
|
37
|
+
/** Home Assistant reports its temperature unit in its own config, not per entity. Default `°C`. */
|
|
38
|
+
temperatureUnit?: '°C' | '°F';
|
|
39
|
+
/** Override for tests. */
|
|
40
|
+
createClient?: () => Promise<HaClient>;
|
|
41
|
+
}
|
|
42
|
+
export declare function createHaClient(options: {
|
|
43
|
+
url: string;
|
|
44
|
+
token: string;
|
|
45
|
+
}): Promise<HaClient>;
|
|
46
|
+
export declare class HomeAssistantIntegration extends BaseIntegration {
|
|
47
|
+
#private;
|
|
48
|
+
readonly id: string;
|
|
49
|
+
constructor(options: HomeAssistantOptions);
|
|
50
|
+
connect(): Promise<void>;
|
|
51
|
+
disconnect(): void;
|
|
52
|
+
command(entityId: string, name: string, args?: Record<string, unknown>): Promise<void>;
|
|
53
|
+
/**
|
|
54
|
+
* A sensor's past values from Home Assistant's long-term statistics: the average per bucket for a
|
|
55
|
+
* measurement (temperature, power), or how much the counter grew per bucket for a running total
|
|
56
|
+
* (energy). An entity that has no statistics (not a sensor with a `state_class`) has no points.
|
|
57
|
+
*/
|
|
58
|
+
history(entityId: string, query: HistoryQuery): Promise<HistoryResult>;
|
|
59
|
+
/**
|
|
60
|
+
* A weather entity's forecast, from the `weather.get_forecasts` service (the forecast is no longer
|
|
61
|
+
* part of the entity's state). An entity that does not give that kind of forecast has no points.
|
|
62
|
+
*/
|
|
63
|
+
forecast(entityId: string, query: ForecastQuery): Promise<ForecastResult>;
|
|
64
|
+
/**
|
|
65
|
+
* What happened to an entity in the last week and who or what caused it, from the logbook
|
|
66
|
+
* (`logbook/get_events`), newest first. A change by a person is named from the `person` entities
|
|
67
|
+
* (each carries its Home Assistant user id), so no admin rights are needed to resolve it.
|
|
68
|
+
*/
|
|
69
|
+
logbook(entityId: string, query: LogbookQuery): Promise<LogbookResult>;
|
|
70
|
+
/** A file Home Assistant serves (artwork, a person's picture), fetched with the token. Only a
|
|
71
|
+
* path on Home Assistant itself is accepted, never another address. */
|
|
72
|
+
fetchAsset(path: string): Promise<Response>;
|
|
73
|
+
/** One level of the player's own media library, from `media_player/browse_media`. */
|
|
74
|
+
browse(entityId: string, query: BrowseQuery): Promise<BrowseResult>;
|
|
75
|
+
/** Calls any Home Assistant service: `{ domain, service, entityIds?, data? }`. */
|
|
76
|
+
callRaw(request: Record<string, unknown>): Promise<unknown>;
|
|
77
|
+
getAreas(): Promise<HaArea[]>;
|
|
78
|
+
getEntityRegistry(): Promise<HaEntityRegistryEntry[]>;
|
|
79
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { callService as haCallService, createConnection, createLongLivedTokenAuth, subscribeEntities, } from 'home-assistant-js-websocket';
|
|
2
|
+
import { assetUrl, BaseIntegration, UnknownEntityError, } from '@hashsome/core';
|
|
3
|
+
import { decodeItemId, toBrowseItem } from './browse.js';
|
|
4
|
+
import { DEFAULT_BUCKET, PERIODS, RANGE_MS, } from './history.js';
|
|
5
|
+
import { toServiceRequest } from './commands.js';
|
|
6
|
+
import { toForecastPoints } from './forecast.js';
|
|
7
|
+
import { toLogbookEntries } from './logbook.js';
|
|
8
|
+
import { mapEntity } from './mappers/index.js';
|
|
9
|
+
export async function createHaClient(options) {
|
|
10
|
+
const auth = createLongLivedTokenAuth(options.url, options.token);
|
|
11
|
+
const connection = await createConnection({ auth });
|
|
12
|
+
return {
|
|
13
|
+
subscribeEntities: (listener) => subscribeEntities(connection, listener),
|
|
14
|
+
callService: (domain, service, data, target) => haCallService(connection, domain, service, data, target),
|
|
15
|
+
sendCommand: (message) => connection.sendMessagePromise(message),
|
|
16
|
+
on: (event, listener) => connection.addEventListener(event, listener),
|
|
17
|
+
close: () => connection.close(),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
/** How far back the logbook is read: enough for the latest handful of entries of most devices. */
|
|
21
|
+
const LOGBOOK_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
|
|
22
|
+
export class HomeAssistantIntegration extends BaseIntegration {
|
|
23
|
+
id;
|
|
24
|
+
#options;
|
|
25
|
+
#client;
|
|
26
|
+
#stopEntities;
|
|
27
|
+
#cache = new Map();
|
|
28
|
+
constructor(options) {
|
|
29
|
+
super();
|
|
30
|
+
this.id = options.id ?? 'ha';
|
|
31
|
+
this.#options = options;
|
|
32
|
+
}
|
|
33
|
+
async connect() {
|
|
34
|
+
if (this.#client) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
this.setStatus('connecting');
|
|
38
|
+
try {
|
|
39
|
+
const client = await (this.#options.createClient ?? (() => createHaClient(this.#options)))();
|
|
40
|
+
this.#client = client;
|
|
41
|
+
client.on('ready', () => this.setStatus('connected'));
|
|
42
|
+
client.on('disconnected', () => this.setStatus('disconnected'));
|
|
43
|
+
client.on('reconnect-error', () => this.setStatus('error'));
|
|
44
|
+
// `connect()` resolves only once the first full set of entities has arrived, so
|
|
45
|
+
// `listEntities()` is complete — and "unknown entity" can be decided — from then on.
|
|
46
|
+
const firstLoad = new Promise((resolve) => {
|
|
47
|
+
this.#stopEntities = client.subscribeEntities((entities) => {
|
|
48
|
+
this.#applyEntities(entities);
|
|
49
|
+
resolve();
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
await firstLoad;
|
|
53
|
+
this.setStatus('connected');
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
this.setStatus('error');
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
disconnect() {
|
|
61
|
+
this.#stopEntities?.();
|
|
62
|
+
this.#stopEntities = undefined;
|
|
63
|
+
this.#client?.close();
|
|
64
|
+
this.#client = undefined;
|
|
65
|
+
this.setStatus('disconnected');
|
|
66
|
+
}
|
|
67
|
+
async command(entityId, name, args) {
|
|
68
|
+
const client = this.#requireClient();
|
|
69
|
+
const entity = this.getEntity(entityId);
|
|
70
|
+
if (!entity) {
|
|
71
|
+
throw new UnknownEntityError(this.id, entityId);
|
|
72
|
+
}
|
|
73
|
+
const { domain, service, data } = toServiceRequest(entityId, entity, name, args);
|
|
74
|
+
await client.callService(domain, service, data, { entity_id: [entityId] });
|
|
75
|
+
}
|
|
76
|
+
#assetUrl = (path) => assetUrl(this.id, path);
|
|
77
|
+
/**
|
|
78
|
+
* A sensor's past values from Home Assistant's long-term statistics: the average per bucket for a
|
|
79
|
+
* measurement (temperature, power), or how much the counter grew per bucket for a running total
|
|
80
|
+
* (energy). An entity that has no statistics (not a sensor with a `state_class`) has no points.
|
|
81
|
+
*/
|
|
82
|
+
async history(entityId, query) {
|
|
83
|
+
const client = this.#requireClient();
|
|
84
|
+
if (!this.getEntity(entityId)) {
|
|
85
|
+
throw new UnknownEntityError(this.id, entityId);
|
|
86
|
+
}
|
|
87
|
+
const [meta] = await client.sendCommand({
|
|
88
|
+
type: 'recorder/get_statistics_metadata',
|
|
89
|
+
statistic_ids: [entityId],
|
|
90
|
+
});
|
|
91
|
+
if (!meta || (!meta.has_sum && !meta.has_mean)) {
|
|
92
|
+
return { points: [], kind: 'measurement' };
|
|
93
|
+
}
|
|
94
|
+
const total = meta.has_sum;
|
|
95
|
+
const field = total ? 'change' : 'mean';
|
|
96
|
+
const bucket = query.bucket ?? DEFAULT_BUCKET[query.range];
|
|
97
|
+
const found = await client.sendCommand({
|
|
98
|
+
type: 'recorder/statistics_during_period',
|
|
99
|
+
start_time: new Date(Date.now() - RANGE_MS[query.range]).toISOString(),
|
|
100
|
+
statistic_ids: [entityId],
|
|
101
|
+
period: PERIODS[bucket],
|
|
102
|
+
types: [field],
|
|
103
|
+
});
|
|
104
|
+
const points = (found[entityId] ?? []).flatMap((row) => {
|
|
105
|
+
const value = row[field];
|
|
106
|
+
return typeof value === 'number'
|
|
107
|
+
? [{ timestamp: new Date(row.start).toISOString(), value }]
|
|
108
|
+
: [];
|
|
109
|
+
});
|
|
110
|
+
const unit = meta.statistics_unit_of_measurement;
|
|
111
|
+
return { points, kind: total ? 'total' : 'measurement', ...(unit ? { unit } : {}) };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* A weather entity's forecast, from the `weather.get_forecasts` service (the forecast is no longer
|
|
115
|
+
* part of the entity's state). An entity that does not give that kind of forecast has no points.
|
|
116
|
+
*/
|
|
117
|
+
async forecast(entityId, query) {
|
|
118
|
+
const client = this.#requireClient();
|
|
119
|
+
const entity = this.getEntity(entityId);
|
|
120
|
+
if (entity?.kind !== 'weather') {
|
|
121
|
+
throw new UnknownEntityError(this.id, entityId);
|
|
122
|
+
}
|
|
123
|
+
if (!entity.forecasts?.includes(query.type)) {
|
|
124
|
+
return { type: query.type, points: [] };
|
|
125
|
+
}
|
|
126
|
+
const answer = await client.sendCommand({
|
|
127
|
+
type: 'call_service',
|
|
128
|
+
domain: 'weather',
|
|
129
|
+
service: 'get_forecasts',
|
|
130
|
+
service_data: { type: query.type },
|
|
131
|
+
target: { entity_id: entityId },
|
|
132
|
+
return_response: true,
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
type: query.type,
|
|
136
|
+
points: toForecastPoints(answer.response?.[entityId]?.forecast),
|
|
137
|
+
...(entity.unit ? { unit: entity.unit } : {}),
|
|
138
|
+
...(entity.windUnit ? { windUnit: entity.windUnit } : {}),
|
|
139
|
+
...(entity.precipitationUnit ? { precipitationUnit: entity.precipitationUnit } : {}),
|
|
140
|
+
...(entity.pressureUnit ? { pressureUnit: entity.pressureUnit } : {}),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* What happened to an entity in the last week and who or what caused it, from the logbook
|
|
145
|
+
* (`logbook/get_events`), newest first. A change by a person is named from the `person` entities
|
|
146
|
+
* (each carries its Home Assistant user id), so no admin rights are needed to resolve it.
|
|
147
|
+
*/
|
|
148
|
+
async logbook(entityId, query) {
|
|
149
|
+
const client = this.#requireClient();
|
|
150
|
+
if (!this.getEntity(entityId)) {
|
|
151
|
+
throw new UnknownEntityError(this.id, entityId);
|
|
152
|
+
}
|
|
153
|
+
const end = Date.now();
|
|
154
|
+
const events = await client.sendCommand({
|
|
155
|
+
type: 'logbook/get_events',
|
|
156
|
+
start_time: new Date(end - LOGBOOK_WINDOW_MS).toISOString(),
|
|
157
|
+
end_time: new Date(end).toISOString(),
|
|
158
|
+
entity_ids: [entityId],
|
|
159
|
+
});
|
|
160
|
+
const personByUserId = new Map();
|
|
161
|
+
for (const entity of this.listEntities()) {
|
|
162
|
+
const userId = entity.kind === 'person' ? entity.raw?.attributes : undefined;
|
|
163
|
+
const id = userId?.user_id;
|
|
164
|
+
if (typeof id === 'string') {
|
|
165
|
+
personByUserId.set(id, entity.name);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
entries: toLogbookEntries(events, { personByUserId, nameOf: (id) => this.getEntity(id)?.name }, query.limit ?? 20),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
/** A file Home Assistant serves (artwork, a person's picture), fetched with the token. Only a
|
|
173
|
+
* path on Home Assistant itself is accepted, never another address. */
|
|
174
|
+
async fetchAsset(path) {
|
|
175
|
+
const base = new URL(this.#options.url);
|
|
176
|
+
const target = new URL(path, base);
|
|
177
|
+
if (!path.startsWith('/') || path.startsWith('//') || target.origin !== base.origin) {
|
|
178
|
+
throw new Error('Not a path on Home Assistant');
|
|
179
|
+
}
|
|
180
|
+
return fetch(target, { headers: { Authorization: `Bearer ${this.#options.token}` } });
|
|
181
|
+
}
|
|
182
|
+
/** One level of the player's own media library, from `media_player/browse_media`. */
|
|
183
|
+
async browse(entityId, query) {
|
|
184
|
+
const client = this.#requireClient();
|
|
185
|
+
const entity = this.getEntity(entityId);
|
|
186
|
+
if (entity?.kind !== 'mediaPlayer') {
|
|
187
|
+
throw new UnknownEntityError(this.id, entityId);
|
|
188
|
+
}
|
|
189
|
+
if (query.search !== undefined) {
|
|
190
|
+
throw new Error('This library cannot be searched');
|
|
191
|
+
}
|
|
192
|
+
const at = query.path === undefined ? undefined : decodeItemId(query.path);
|
|
193
|
+
const level = await client.sendCommand({
|
|
194
|
+
type: 'media_player/browse_media',
|
|
195
|
+
entity_id: entityId,
|
|
196
|
+
...(at ? { media_content_type: at.contentType, media_content_id: at.contentId } : {}),
|
|
197
|
+
});
|
|
198
|
+
return {
|
|
199
|
+
...(at && level.title ? { title: level.title } : {}),
|
|
200
|
+
items: (level.children ?? []).map((media) => toBrowseItem(media, this.#assetUrl)),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
/** Calls any Home Assistant service: `{ domain, service, entityIds?, data? }`. */
|
|
204
|
+
async callRaw(request) {
|
|
205
|
+
const { domain, service, entityIds, data } = request;
|
|
206
|
+
if (typeof domain !== 'string' || typeof service !== 'string') {
|
|
207
|
+
throw new Error('A raw Home Assistant request needs a "domain" and a "service"');
|
|
208
|
+
}
|
|
209
|
+
const ids = Array.isArray(entityIds) ? entityIds.filter((i) => typeof i === 'string') : [];
|
|
210
|
+
return this.#requireClient().callService(domain, service, data && typeof data === 'object' ? data : undefined, ids.length > 0 ? { entity_id: ids } : undefined);
|
|
211
|
+
}
|
|
212
|
+
async getAreas() {
|
|
213
|
+
return this.#requireClient().sendCommand({ type: 'config/area_registry/list' });
|
|
214
|
+
}
|
|
215
|
+
async getEntityRegistry() {
|
|
216
|
+
return this.#requireClient().sendCommand({
|
|
217
|
+
type: 'config/entity_registry/list',
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
#requireClient() {
|
|
221
|
+
if (!this.#client) {
|
|
222
|
+
throw new Error('Home Assistant integration is not connected');
|
|
223
|
+
}
|
|
224
|
+
return this.#client;
|
|
225
|
+
}
|
|
226
|
+
#applyEntities(entities) {
|
|
227
|
+
const next = new Map();
|
|
228
|
+
const nextCache = new Map();
|
|
229
|
+
const options = {
|
|
230
|
+
temperatureUnit: this.#options.temperatureUnit ?? '°C',
|
|
231
|
+
assetUrl: this.#assetUrl,
|
|
232
|
+
};
|
|
233
|
+
for (const [id, entity] of Object.entries(entities)) {
|
|
234
|
+
// The HA lib keeps object identity for unchanged entities; reuse our mapped input so
|
|
235
|
+
// replaceEntities only notifies subscribers of real changes.
|
|
236
|
+
const cached = this.#cache.get(id);
|
|
237
|
+
const entry = cached && cached.source === entity
|
|
238
|
+
? cached
|
|
239
|
+
: { source: entity, input: mapEntity(entity, options) };
|
|
240
|
+
nextCache.set(id, entry);
|
|
241
|
+
next.set(id, entry.input);
|
|
242
|
+
}
|
|
243
|
+
this.#cache = nextCache;
|
|
244
|
+
this.replaceEntities(next);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { LogbookEntry } from '@hashsome/core';
|
|
2
|
+
/** One event of `logbook/get_events`: a change to an entity and, when known, what caused it. Every
|
|
3
|
+
* field but the time is optional. */
|
|
4
|
+
export interface HaLogbookEvent {
|
|
5
|
+
/** Seconds since the epoch, with a fraction. */
|
|
6
|
+
when?: number;
|
|
7
|
+
entity_id?: string;
|
|
8
|
+
/** The state it changed to. */
|
|
9
|
+
state?: string;
|
|
10
|
+
context_user_id?: string | null;
|
|
11
|
+
context_event_type?: string | null;
|
|
12
|
+
context_domain?: string | null;
|
|
13
|
+
context_name?: string | null;
|
|
14
|
+
context_entity_id?: string | null;
|
|
15
|
+
}
|
|
16
|
+
/** What the integration knows that an event refers to by id. */
|
|
17
|
+
export interface LogbookLookup {
|
|
18
|
+
/** The name of the person for a Home Assistant user id (a `person` entity carries it). */
|
|
19
|
+
personByUserId: ReadonlyMap<string, string>;
|
|
20
|
+
/** The name of an entity: an automation, a light group. */
|
|
21
|
+
nameOf: (entityId: string) => string | undefined;
|
|
22
|
+
}
|
|
23
|
+
/** What happened, from the state it changed to. Lowercase, with no actor in front. */
|
|
24
|
+
export declare function messageFor(state: string | undefined): string;
|
|
25
|
+
/** The events as entries, newest first and at most `limit` of them. An event without a usable time is
|
|
26
|
+
* dropped. A change of state with no cause that follows the device being unreachable is worded as it
|
|
27
|
+
* coming back ("came back online, on"), not as someone turning it on. */
|
|
28
|
+
export declare function toLogbookEntries(events: readonly HaLogbookEvent[], lookup: LogbookLookup, limit: number): LogbookEntry[];
|
package/dist/logbook.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** What happened, from the state it changed to. Lowercase, with no actor in front. */
|
|
2
|
+
export function messageFor(state) {
|
|
3
|
+
switch (state) {
|
|
4
|
+
case undefined:
|
|
5
|
+
case '':
|
|
6
|
+
return 'changed';
|
|
7
|
+
case 'on':
|
|
8
|
+
return 'turned on';
|
|
9
|
+
case 'off':
|
|
10
|
+
return 'turned off';
|
|
11
|
+
case 'unavailable':
|
|
12
|
+
return 'became unavailable';
|
|
13
|
+
case 'unknown':
|
|
14
|
+
return 'became unknown';
|
|
15
|
+
default:
|
|
16
|
+
return `changed to ${state}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
/** Who or what caused an event. An automation comes first (it is the more specific cause, even when
|
|
20
|
+
* someone ran it), then a person by their user id, then another entity that drove it (a light group).
|
|
21
|
+
* Nobody when nothing says: a device going offline. */
|
|
22
|
+
function actorOf(event, lookup) {
|
|
23
|
+
const triggeredBy = event.context_entity_id ?? undefined;
|
|
24
|
+
if (event.context_event_type === 'automation_triggered' ||
|
|
25
|
+
event.context_domain === 'automation') {
|
|
26
|
+
const name = event.context_name ?? (triggeredBy ? lookup.nameOf(triggeredBy) : undefined);
|
|
27
|
+
return name ? { actor: name, actorKind: 'automation' } : {};
|
|
28
|
+
}
|
|
29
|
+
const person = event.context_user_id
|
|
30
|
+
? lookup.personByUserId.get(event.context_user_id)
|
|
31
|
+
: undefined;
|
|
32
|
+
if (person) {
|
|
33
|
+
return { actor: person, actorKind: 'person' };
|
|
34
|
+
}
|
|
35
|
+
if (triggeredBy && triggeredBy !== event.entity_id) {
|
|
36
|
+
return { actor: lookup.nameOf(triggeredBy) ?? triggeredBy, actorKind: 'automation' };
|
|
37
|
+
}
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
const UNREACHABLE = new Set(['unavailable', 'unknown']);
|
|
41
|
+
/** What kind of change a state is: on, off, the device being (un)reachable, or any other mode. */
|
|
42
|
+
function changeOf(state, back) {
|
|
43
|
+
if (back || (state !== undefined && UNREACHABLE.has(state))) {
|
|
44
|
+
return 'availability';
|
|
45
|
+
}
|
|
46
|
+
return state === 'on' || state === 'off' ? state : 'state';
|
|
47
|
+
}
|
|
48
|
+
/** The events as entries, newest first and at most `limit` of them. An event without a usable time is
|
|
49
|
+
* dropped. A change of state with no cause that follows the device being unreachable is worded as it
|
|
50
|
+
* coming back ("came back online, on"), not as someone turning it on. */
|
|
51
|
+
export function toLogbookEntries(events, lookup, limit) {
|
|
52
|
+
const lastState = new Map();
|
|
53
|
+
return events
|
|
54
|
+
.flatMap((event) => typeof event.when === 'number' && Number.isFinite(event.when)
|
|
55
|
+
? [{ event, when: event.when }]
|
|
56
|
+
: [])
|
|
57
|
+
.toSorted((a, b) => a.when - b.when)
|
|
58
|
+
.map(({ event, when }) => {
|
|
59
|
+
const entity = event.entity_id ?? '';
|
|
60
|
+
const before = lastState.get(entity);
|
|
61
|
+
lastState.set(entity, event.state);
|
|
62
|
+
const cause = actorOf(event, lookup);
|
|
63
|
+
const back = cause.actor === undefined &&
|
|
64
|
+
before !== undefined &&
|
|
65
|
+
UNREACHABLE.has(before) &&
|
|
66
|
+
event.state !== undefined &&
|
|
67
|
+
!UNREACHABLE.has(event.state);
|
|
68
|
+
return {
|
|
69
|
+
id: `${entity}@${when}`,
|
|
70
|
+
message: back ? `came back online, ${event.state}` : messageFor(event.state),
|
|
71
|
+
timestamp: new Date(when * 1000).toISOString(),
|
|
72
|
+
change: changeOf(event.state, back),
|
|
73
|
+
...cause,
|
|
74
|
+
};
|
|
75
|
+
})
|
|
76
|
+
.toReversed()
|
|
77
|
+
.slice(0, limit);
|
|
78
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { ClimateMode, EntityInput } from '@hashsome/core';
|
|
2
|
+
import type { HassEntity } from 'home-assistant-js-websocket';
|
|
3
|
+
/** Home Assistant's HVAC mode names and the model's (camelCase) ones. */
|
|
4
|
+
export declare const HVAC_TO_MODE: Record<string, ClimateMode>;
|
|
5
|
+
export declare const MODE_TO_HVAC: Record<ClimateMode, string>;
|
|
6
|
+
export declare function mapClimate(entity: HassEntity, unit: '°C' | '°F'): EntityInput;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { baseOf, num, str, strings } from './common.js';
|
|
2
|
+
/** Home Assistant's HVAC mode names and the model's (camelCase) ones. */
|
|
3
|
+
export const HVAC_TO_MODE = {
|
|
4
|
+
off: 'off',
|
|
5
|
+
heat: 'heat',
|
|
6
|
+
cool: 'cool',
|
|
7
|
+
auto: 'auto',
|
|
8
|
+
heat_cool: 'heatCool',
|
|
9
|
+
dry: 'dry',
|
|
10
|
+
fan_only: 'fanOnly',
|
|
11
|
+
};
|
|
12
|
+
export const MODE_TO_HVAC = {
|
|
13
|
+
off: 'off',
|
|
14
|
+
heat: 'heat',
|
|
15
|
+
cool: 'cool',
|
|
16
|
+
auto: 'auto',
|
|
17
|
+
heatCool: 'heat_cool',
|
|
18
|
+
dry: 'dry',
|
|
19
|
+
fanOnly: 'fan_only',
|
|
20
|
+
};
|
|
21
|
+
const ACTION = {
|
|
22
|
+
off: 'off',
|
|
23
|
+
idle: 'idle',
|
|
24
|
+
heating: 'heating',
|
|
25
|
+
cooling: 'cooling',
|
|
26
|
+
drying: 'drying',
|
|
27
|
+
fan: 'fan',
|
|
28
|
+
};
|
|
29
|
+
export function mapClimate(entity, unit) {
|
|
30
|
+
const a = entity.attributes;
|
|
31
|
+
const modes = strings(a.hvac_modes).flatMap((m) => (HVAC_TO_MODE[m] ? [HVAC_TO_MODE[m]] : []));
|
|
32
|
+
const target = num(a.temperature);
|
|
33
|
+
const current = num(a.current_temperature);
|
|
34
|
+
const humidity = num(a.current_humidity);
|
|
35
|
+
const action = ACTION[str(a.hvac_action) ?? ''];
|
|
36
|
+
const preset = str(a.preset_mode);
|
|
37
|
+
return {
|
|
38
|
+
kind: 'climate',
|
|
39
|
+
...baseOf(entity),
|
|
40
|
+
mode: HVAC_TO_MODE[entity.state] ?? 'off',
|
|
41
|
+
...(action ? { action } : {}),
|
|
42
|
+
...(target !== undefined ? { targetTemperature: target } : {}),
|
|
43
|
+
...(current !== undefined ? { currentTemperature: current } : {}),
|
|
44
|
+
...(humidity !== undefined ? { humidity } : {}),
|
|
45
|
+
unit,
|
|
46
|
+
...(preset ? { preset } : {}),
|
|
47
|
+
capabilities: {
|
|
48
|
+
modes: modes.length > 0 ? modes : ['off', 'heat'],
|
|
49
|
+
presets: strings(a.preset_modes),
|
|
50
|
+
targetTemperature: target !== undefined,
|
|
51
|
+
step: num(a.target_temp_step) ?? 0.5,
|
|
52
|
+
range: { min: num(a.min_temp) ?? 5, max: num(a.max_temp) ?? 35 },
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Availability } from '@hashsome/core';
|
|
2
|
+
import type { HassEntity } from 'home-assistant-js-websocket';
|
|
3
|
+
/** The domain of a Home Assistant entity id: `light` for `light.kitchen`. */
|
|
4
|
+
export declare function domainOf(entityId: string): string;
|
|
5
|
+
export declare function availabilityOf(entity: HassEntity): Availability;
|
|
6
|
+
/** A file address in an entity attribute. Home Assistant serves its own files from a path like
|
|
7
|
+
* `/api/media_player_proxy/…` that only makes sense on Home Assistant, so those go through
|
|
8
|
+
* `toAssetUrl`; a full address is kept as it is. */
|
|
9
|
+
export declare function fileUrl(value: string, toAssetUrl: (path: string) => string): string;
|
|
10
|
+
export declare function nameOf(entity: HassEntity): string;
|
|
11
|
+
/** What every mapped entity shares: name, availability, timestamps and the untouched payload. */
|
|
12
|
+
export declare function baseOf(entity: HassEntity): {
|
|
13
|
+
name: string;
|
|
14
|
+
availability: Availability;
|
|
15
|
+
lastChanged?: string;
|
|
16
|
+
lastUpdated?: string;
|
|
17
|
+
raw: Record<string, unknown>;
|
|
18
|
+
};
|
|
19
|
+
export declare const num: (value: unknown) => number | undefined;
|
|
20
|
+
/** Where the wind comes from in degrees, from what Home Assistant gives: degrees as a number or as
|
|
21
|
+
* text, or a compass name such as `NNW`. Anything else is nothing. */
|
|
22
|
+
export declare function bearing(value: unknown): number | undefined;
|
|
23
|
+
export declare const str: (value: unknown) => string | undefined;
|
|
24
|
+
export declare const strings: (value: unknown) => string[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/** The domain of a Home Assistant entity id: `light` for `light.kitchen`. */
|
|
2
|
+
export function domainOf(entityId) {
|
|
3
|
+
const index = entityId.indexOf('.');
|
|
4
|
+
return index === -1 ? entityId : entityId.slice(0, index);
|
|
5
|
+
}
|
|
6
|
+
export function availabilityOf(entity) {
|
|
7
|
+
if (entity.state === 'unavailable') {
|
|
8
|
+
return 'unavailable';
|
|
9
|
+
}
|
|
10
|
+
if (entity.state === 'unknown') {
|
|
11
|
+
return 'unknown';
|
|
12
|
+
}
|
|
13
|
+
return 'ready';
|
|
14
|
+
}
|
|
15
|
+
/** A file address in an entity attribute. Home Assistant serves its own files from a path like
|
|
16
|
+
* `/api/media_player_proxy/…` that only makes sense on Home Assistant, so those go through
|
|
17
|
+
* `toAssetUrl`; a full address is kept as it is. */
|
|
18
|
+
export function fileUrl(value, toAssetUrl) {
|
|
19
|
+
return value.startsWith('/') && !value.startsWith('//') ? toAssetUrl(value) : value;
|
|
20
|
+
}
|
|
21
|
+
export function nameOf(entity) {
|
|
22
|
+
const name = entity.attributes.friendly_name;
|
|
23
|
+
return typeof name === 'string' && name ? name : entity.entity_id;
|
|
24
|
+
}
|
|
25
|
+
/** What every mapped entity shares: name, availability, timestamps and the untouched payload. */
|
|
26
|
+
export function baseOf(entity) {
|
|
27
|
+
return {
|
|
28
|
+
name: nameOf(entity),
|
|
29
|
+
availability: availabilityOf(entity),
|
|
30
|
+
...(entity.last_changed ? { lastChanged: entity.last_changed } : {}),
|
|
31
|
+
...(entity.last_updated ? { lastUpdated: entity.last_updated } : {}),
|
|
32
|
+
raw: { state: entity.state, attributes: entity.attributes },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
export const num = (value) => typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
|
36
|
+
/** 16-point compass names, by the degrees they start at: a wind from `NNE` comes from 22.5 degrees. */
|
|
37
|
+
const COMPASS = [
|
|
38
|
+
'N',
|
|
39
|
+
'NNE',
|
|
40
|
+
'NE',
|
|
41
|
+
'ENE',
|
|
42
|
+
'E',
|
|
43
|
+
'ESE',
|
|
44
|
+
'SE',
|
|
45
|
+
'SSE',
|
|
46
|
+
'S',
|
|
47
|
+
'SSW',
|
|
48
|
+
'SW',
|
|
49
|
+
'WSW',
|
|
50
|
+
'W',
|
|
51
|
+
'WNW',
|
|
52
|
+
'NW',
|
|
53
|
+
'NNW',
|
|
54
|
+
];
|
|
55
|
+
/** Where the wind comes from in degrees, from what Home Assistant gives: degrees as a number or as
|
|
56
|
+
* text, or a compass name such as `NNW`. Anything else is nothing. */
|
|
57
|
+
export function bearing(value) {
|
|
58
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
59
|
+
return ((value % 360) + 360) % 360;
|
|
60
|
+
}
|
|
61
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const degrees = Number(value);
|
|
65
|
+
if (Number.isFinite(degrees)) {
|
|
66
|
+
return ((degrees % 360) + 360) % 360;
|
|
67
|
+
}
|
|
68
|
+
const index = COMPASS.indexOf(value.trim().toUpperCase());
|
|
69
|
+
return index === -1 ? undefined : index * 22.5;
|
|
70
|
+
}
|
|
71
|
+
export const str = (value) => typeof value === 'string' && value ? value : undefined;
|
|
72
|
+
export const strings = (value) => Array.isArray(value) ? value.filter((v) => typeof v === 'string') : [];
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { EntityInput } from '@hashsome/core';
|
|
2
|
+
import type { HassEntity } from 'home-assistant-js-websocket';
|
|
3
|
+
export interface MapOptions {
|
|
4
|
+
/** Home Assistant reports the temperature unit in its own config, not per entity. */
|
|
5
|
+
temperatureUnit: '°C' | '°F';
|
|
6
|
+
/** Turns a path on Home Assistant into an address the browser can load. Defaults to leaving it. */
|
|
7
|
+
assetUrl?: (path: string) => string;
|
|
8
|
+
}
|
|
9
|
+
/** Domains that are something to trigger, not something with a state to read. */
|
|
10
|
+
export declare const ACTION_DOMAINS: string[];
|
|
11
|
+
/** Domains with two states that can be flipped (a lock is "on" while locked). */
|
|
12
|
+
export declare const SWITCH_DOMAINS: string[];
|
|
13
|
+
/** Maps a Home Assistant entity to the generic model (everything but `ref`). Pure. */
|
|
14
|
+
export declare function mapEntity(entity: HassEntity, options: MapOptions): EntityInput;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { domainOf } from './common.js';
|
|
2
|
+
import { mapClimate } from './climate.js';
|
|
3
|
+
import { mapLight } from './light.js';
|
|
4
|
+
import { mapMediaPlayer } from './media-player.js';
|
|
5
|
+
import { mapWeather } from './weather.js';
|
|
6
|
+
import { mapAction, mapGeneric, mapPerson, mapSensor, mapSwitch } from './simple.js';
|
|
7
|
+
/** Domains that are something to trigger, not something with a state to read. */
|
|
8
|
+
export const ACTION_DOMAINS = ['scene', 'script', 'button', 'input_button', 'automation', 'vacuum'];
|
|
9
|
+
/** Domains with two states that can be flipped (a lock is "on" while locked). */
|
|
10
|
+
export const SWITCH_DOMAINS = ['switch', 'fan', 'input_boolean', 'lock'];
|
|
11
|
+
/** Maps a Home Assistant entity to the generic model (everything but `ref`). Pure. */
|
|
12
|
+
export function mapEntity(entity, options) {
|
|
13
|
+
const domain = domainOf(entity.entity_id);
|
|
14
|
+
switch (domain) {
|
|
15
|
+
case 'media_player':
|
|
16
|
+
return mapMediaPlayer(entity, options.assetUrl);
|
|
17
|
+
case 'light':
|
|
18
|
+
return mapLight(entity);
|
|
19
|
+
case 'climate':
|
|
20
|
+
return mapClimate(entity, options.temperatureUnit);
|
|
21
|
+
case 'sensor':
|
|
22
|
+
case 'binary_sensor':
|
|
23
|
+
return mapSensor(entity);
|
|
24
|
+
case 'person':
|
|
25
|
+
return mapPerson(entity, options.assetUrl);
|
|
26
|
+
case 'weather':
|
|
27
|
+
return mapWeather(entity);
|
|
28
|
+
default:
|
|
29
|
+
if (SWITCH_DOMAINS.includes(domain)) {
|
|
30
|
+
return mapSwitch(entity);
|
|
31
|
+
}
|
|
32
|
+
if (ACTION_DOMAINS.includes(domain)) {
|
|
33
|
+
return mapAction(entity);
|
|
34
|
+
}
|
|
35
|
+
return mapGeneric(entity);
|
|
36
|
+
}
|
|
37
|
+
}
|