@hashsome/integration.home-assistant 0.0.0-stage → 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/dist/index.js ADDED
@@ -0,0 +1,183 @@
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 { mapEntity } from './mappers/index.js';
7
+ export async function createHaClient(options) {
8
+ const auth = createLongLivedTokenAuth(options.url, options.token);
9
+ const connection = await createConnection({ auth });
10
+ return {
11
+ subscribeEntities: (listener) => subscribeEntities(connection, listener),
12
+ callService: (domain, service, data, target) => haCallService(connection, domain, service, data, target),
13
+ sendCommand: (message) => connection.sendMessagePromise(message),
14
+ on: (event, listener) => connection.addEventListener(event, listener),
15
+ close: () => connection.close(),
16
+ };
17
+ }
18
+ export class HomeAssistantIntegration extends BaseIntegration {
19
+ id;
20
+ #options;
21
+ #client;
22
+ #stopEntities;
23
+ #cache = new Map();
24
+ constructor(options) {
25
+ super();
26
+ this.id = options.id ?? 'ha';
27
+ this.#options = options;
28
+ }
29
+ async connect() {
30
+ if (this.#client) {
31
+ return;
32
+ }
33
+ this.setStatus('connecting');
34
+ try {
35
+ const client = await (this.#options.createClient ?? (() => createHaClient(this.#options)))();
36
+ this.#client = client;
37
+ client.on('ready', () => this.setStatus('connected'));
38
+ client.on('disconnected', () => this.setStatus('disconnected'));
39
+ client.on('reconnect-error', () => this.setStatus('error'));
40
+ // `connect()` resolves only once the first full set of entities has arrived, so
41
+ // `listEntities()` is complete — and "unknown entity" can be decided — from then on.
42
+ const firstLoad = new Promise((resolve) => {
43
+ this.#stopEntities = client.subscribeEntities((entities) => {
44
+ this.#applyEntities(entities);
45
+ resolve();
46
+ });
47
+ });
48
+ await firstLoad;
49
+ this.setStatus('connected');
50
+ }
51
+ catch (error) {
52
+ this.setStatus('error');
53
+ throw error;
54
+ }
55
+ }
56
+ disconnect() {
57
+ this.#stopEntities?.();
58
+ this.#stopEntities = undefined;
59
+ this.#client?.close();
60
+ this.#client = undefined;
61
+ this.setStatus('disconnected');
62
+ }
63
+ async command(entityId, name, args) {
64
+ const client = this.#requireClient();
65
+ const entity = this.getEntity(entityId);
66
+ if (!entity) {
67
+ throw new UnknownEntityError(this.id, entityId);
68
+ }
69
+ const { domain, service, data } = toServiceRequest(entityId, entity, name, args);
70
+ await client.callService(domain, service, data, { entity_id: [entityId] });
71
+ }
72
+ #assetUrl = (path) => assetUrl(this.id, path);
73
+ /**
74
+ * A sensor's past values from Home Assistant's long-term statistics: the average per bucket for a
75
+ * measurement (temperature, power), or how much the counter grew per bucket for a running total
76
+ * (energy). An entity that has no statistics (not a sensor with a `state_class`) has no points.
77
+ */
78
+ async history(entityId, query) {
79
+ const client = this.#requireClient();
80
+ if (!this.getEntity(entityId)) {
81
+ throw new UnknownEntityError(this.id, entityId);
82
+ }
83
+ const [meta] = await client.sendCommand({
84
+ type: 'recorder/get_statistics_metadata',
85
+ statistic_ids: [entityId],
86
+ });
87
+ if (!meta || (!meta.has_sum && !meta.has_mean)) {
88
+ return { points: [], kind: 'measurement' };
89
+ }
90
+ const total = meta.has_sum;
91
+ const field = total ? 'change' : 'mean';
92
+ const bucket = query.bucket ?? DEFAULT_BUCKET[query.range];
93
+ const found = await client.sendCommand({
94
+ type: 'recorder/statistics_during_period',
95
+ start_time: new Date(Date.now() - RANGE_MS[query.range]).toISOString(),
96
+ statistic_ids: [entityId],
97
+ period: PERIODS[bucket],
98
+ types: [field],
99
+ });
100
+ const points = (found[entityId] ?? []).flatMap((row) => {
101
+ const value = row[field];
102
+ return typeof value === 'number'
103
+ ? [{ timestamp: new Date(row.start).toISOString(), value }]
104
+ : [];
105
+ });
106
+ const unit = meta.statistics_unit_of_measurement;
107
+ return { points, kind: total ? 'total' : 'measurement', ...(unit ? { unit } : {}) };
108
+ }
109
+ /** A file Home Assistant serves (artwork, a person's picture), fetched with the token. Only a
110
+ * path on Home Assistant itself is accepted, never another address. */
111
+ async fetchAsset(path) {
112
+ const base = new URL(this.#options.url);
113
+ const target = new URL(path, base);
114
+ if (!path.startsWith('/') || path.startsWith('//') || target.origin !== base.origin) {
115
+ throw new Error('Not a path on Home Assistant');
116
+ }
117
+ return fetch(target, { headers: { Authorization: `Bearer ${this.#options.token}` } });
118
+ }
119
+ /** One level of the player's own media library, from `media_player/browse_media`. */
120
+ async browse(entityId, query) {
121
+ const client = this.#requireClient();
122
+ const entity = this.getEntity(entityId);
123
+ if (entity?.kind !== 'mediaPlayer') {
124
+ throw new UnknownEntityError(this.id, entityId);
125
+ }
126
+ if (query.search !== undefined) {
127
+ throw new Error('This library cannot be searched');
128
+ }
129
+ const at = query.path === undefined ? undefined : decodeItemId(query.path);
130
+ const level = await client.sendCommand({
131
+ type: 'media_player/browse_media',
132
+ entity_id: entityId,
133
+ ...(at ? { media_content_type: at.contentType, media_content_id: at.contentId } : {}),
134
+ });
135
+ return {
136
+ ...(at && level.title ? { title: level.title } : {}),
137
+ items: (level.children ?? []).map((media) => toBrowseItem(media, this.#assetUrl)),
138
+ };
139
+ }
140
+ /** Calls any Home Assistant service: `{ domain, service, entityIds?, data? }`. */
141
+ async callRaw(request) {
142
+ const { domain, service, entityIds, data } = request;
143
+ if (typeof domain !== 'string' || typeof service !== 'string') {
144
+ throw new Error('A raw Home Assistant request needs a "domain" and a "service"');
145
+ }
146
+ const ids = Array.isArray(entityIds) ? entityIds.filter((i) => typeof i === 'string') : [];
147
+ return this.#requireClient().callService(domain, service, data && typeof data === 'object' ? data : undefined, ids.length > 0 ? { entity_id: ids } : undefined);
148
+ }
149
+ async getAreas() {
150
+ return this.#requireClient().sendCommand({ type: 'config/area_registry/list' });
151
+ }
152
+ async getEntityRegistry() {
153
+ return this.#requireClient().sendCommand({
154
+ type: 'config/entity_registry/list',
155
+ });
156
+ }
157
+ #requireClient() {
158
+ if (!this.#client) {
159
+ throw new Error('Home Assistant integration is not connected');
160
+ }
161
+ return this.#client;
162
+ }
163
+ #applyEntities(entities) {
164
+ const next = new Map();
165
+ const nextCache = new Map();
166
+ const options = {
167
+ temperatureUnit: this.#options.temperatureUnit ?? '°C',
168
+ assetUrl: this.#assetUrl,
169
+ };
170
+ for (const [id, entity] of Object.entries(entities)) {
171
+ // The HA lib keeps object identity for unchanged entities; reuse our mapped input so
172
+ // replaceEntities only notifies subscribers of real changes.
173
+ const cached = this.#cache.get(id);
174
+ const entry = cached && cached.source === entity
175
+ ? cached
176
+ : { source: entity, input: mapEntity(entity, options) };
177
+ nextCache.set(id, entry);
178
+ next.set(id, entry.input);
179
+ }
180
+ this.#cache = nextCache;
181
+ this.replaceEntities(next);
182
+ }
183
+ }
@@ -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,21 @@
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
+ export declare const str: (value: unknown) => string | undefined;
21
+ export declare const strings: (value: unknown) => string[];
@@ -0,0 +1,37 @@
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
+ export const str = (value) => typeof value === 'string' && value ? value : undefined;
37
+ 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
+ }
@@ -0,0 +1,3 @@
1
+ import type { EntityInput } from '@hashsome/core';
2
+ import type { HassEntity } from 'home-assistant-js-websocket';
3
+ export declare function mapLight(entity: HassEntity): EntityInput;
@@ -0,0 +1,49 @@
1
+ import { baseOf, num, strings } from './common.js';
2
+ const COLOR_MODES = ['hs', 'xy', 'rgb', 'rgbw', 'rgbww'];
3
+ function colorOf(a) {
4
+ const kelvin = num(a.color_temp_kelvin);
5
+ if (a.color_mode === 'color_temp' && kelvin !== undefined) {
6
+ return { mode: 'temperature', kelvin };
7
+ }
8
+ const hs = a.hs_color;
9
+ if (Array.isArray(hs) && typeof hs[0] === 'number' && typeof hs[1] === 'number') {
10
+ const rgb = a.rgb_color;
11
+ return {
12
+ mode: 'color',
13
+ hue: hs[0],
14
+ saturation: hs[1],
15
+ ...(Array.isArray(rgb) && rgb.length >= 3 && rgb.every((n) => typeof n === 'number')
16
+ ? { rgb: [rgb[0], rgb[1], rgb[2]] }
17
+ : {}),
18
+ };
19
+ }
20
+ return kelvin !== undefined ? { mode: 'temperature', kelvin } : undefined;
21
+ }
22
+ export function mapLight(entity) {
23
+ const a = entity.attributes;
24
+ const modes = strings(a.supported_color_modes);
25
+ const brightness = num(a.brightness);
26
+ const dimmable = modes.some((mode) => mode !== 'onoff' && mode !== 'unknown');
27
+ const colorTemperature = modes.includes('color_temp');
28
+ const color = colorOf(a);
29
+ return {
30
+ kind: 'light',
31
+ ...baseOf(entity),
32
+ on: entity.state === 'on',
33
+ ...(brightness !== undefined ? { brightness: brightness / 255 } : {}),
34
+ ...(color ? { color } : {}),
35
+ capabilities: {
36
+ brightness: dimmable,
37
+ colorTemperature,
38
+ color: modes.some((mode) => COLOR_MODES.includes(mode)),
39
+ ...(colorTemperature
40
+ ? {
41
+ kelvinRange: {
42
+ min: num(a.min_color_temp_kelvin) ?? 2000,
43
+ max: num(a.max_color_temp_kelvin) ?? 6500,
44
+ },
45
+ }
46
+ : {}),
47
+ },
48
+ };
49
+ }
@@ -0,0 +1,3 @@
1
+ import type { EntityInput } from '@hashsome/core';
2
+ import type { HassEntity } from 'home-assistant-js-websocket';
3
+ export declare function mapMediaPlayer(entity: HassEntity, toAssetUrl?: (path: string) => string): EntityInput;
@@ -0,0 +1,69 @@
1
+ import { baseOf, fileUrl, num, str } from './common.js';
2
+ /** `MediaPlayerEntityFeature` bit flags from Home Assistant. */
3
+ const FEATURE = {
4
+ seek: 2,
5
+ volumeSet: 4,
6
+ volumeMute: 8,
7
+ previous: 16,
8
+ next: 32,
9
+ shuffle: 32768,
10
+ browse: 131072,
11
+ };
12
+ const PLAYBACK = {
13
+ playing: 'playing',
14
+ paused: 'paused',
15
+ idle: 'idle',
16
+ standby: 'idle',
17
+ on: 'idle',
18
+ off: 'off',
19
+ buffering: 'buffering',
20
+ };
21
+ export function mapMediaPlayer(entity, toAssetUrl = (path) => path) {
22
+ const a = entity.attributes;
23
+ const features = num(a.supported_features);
24
+ const has = (flag, fallback) => features === undefined ? fallback : (features & flag) !== 0;
25
+ const volume = num(a.volume_level);
26
+ const title = str(a.media_title);
27
+ const artist = str(a.media_artist);
28
+ const album = str(a.media_album_name);
29
+ const picture = str(a.entity_picture_local) ?? str(a.entity_picture);
30
+ const artworkUrl = picture ? fileUrl(picture, toAssetUrl) : undefined;
31
+ const position = num(a.media_position);
32
+ const duration = num(a.media_duration);
33
+ const positionUpdatedAt = str(a.media_position_updated_at);
34
+ const shuffle = typeof a.shuffle === 'boolean' ? a.shuffle : undefined;
35
+ return {
36
+ kind: 'mediaPlayer',
37
+ ...baseOf(entity),
38
+ playback: PLAYBACK[entity.state] ?? 'idle',
39
+ muted: a.is_volume_muted === true,
40
+ ...(volume !== undefined ? { volume } : {}),
41
+ ...(title || artist || album || artworkUrl
42
+ ? {
43
+ media: {
44
+ ...(title ? { title } : {}),
45
+ ...(artist ? { artist } : {}),
46
+ ...(album ? { album } : {}),
47
+ ...(artworkUrl ? { artworkUrl } : {}),
48
+ },
49
+ }
50
+ : {}),
51
+ ...(shuffle !== undefined ? { shuffle } : {}),
52
+ ...(position !== undefined ? { position } : {}),
53
+ ...(duration !== undefined ? { duration } : {}),
54
+ ...(position !== undefined && positionUpdatedAt ? { positionUpdatedAt } : {}),
55
+ capabilities: {
56
+ volume: volume !== undefined || has(FEATURE.volumeSet, false),
57
+ mute: a.is_volume_muted !== undefined || has(FEATURE.volumeMute, false),
58
+ next: has(FEATURE.next, true),
59
+ previous: has(FEATURE.previous, true),
60
+ browse: has(FEATURE.browse, false),
61
+ // Home Assistant's browse contract has no search.
62
+ search: false,
63
+ seek: has(FEATURE.seek, false),
64
+ shuffle: has(FEATURE.shuffle, false),
65
+ transfer: false,
66
+ group: false,
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,8 @@
1
+ import type { EntityInput } from '@hashsome/core';
2
+ import type { HassEntity } from 'home-assistant-js-websocket';
3
+ export declare function mapSensor(entity: HassEntity): EntityInput;
4
+ /** `on` for an on/off thing; for a lock, `locked`. */
5
+ export declare function mapSwitch(entity: HassEntity): EntityInput;
6
+ export declare function mapAction(entity: HassEntity): EntityInput;
7
+ export declare function mapPerson(entity: HassEntity, toAssetUrl?: (path: string) => string): EntityInput;
8
+ export declare function mapGeneric(entity: HassEntity): EntityInput;
@@ -0,0 +1,38 @@
1
+ import { baseOf, fileUrl, num, str } from './common.js';
2
+ export function mapSensor(entity) {
3
+ const unit = str(entity.attributes.unit_of_measurement);
4
+ const measurement = str(entity.attributes.device_class);
5
+ const numeric = entity.state.trim() === '' ? undefined : num(Number(entity.state));
6
+ return {
7
+ kind: 'sensor',
8
+ ...baseOf(entity),
9
+ value: entity.state,
10
+ ...(numeric !== undefined ? { numeric } : {}),
11
+ ...(unit ? { unit } : {}),
12
+ ...(measurement ? { measurement } : {}),
13
+ };
14
+ }
15
+ /** `on` for an on/off thing; for a lock, `locked`. */
16
+ export function mapSwitch(entity) {
17
+ const on = entity.state === 'on' || entity.state === 'locked';
18
+ return { kind: 'switch', ...baseOf(entity), on };
19
+ }
20
+ export function mapAction(entity) {
21
+ const last = str(entity.attributes.last_triggered);
22
+ return { kind: 'action', ...baseOf(entity), ...(last ? { lastTriggered: last } : {}) };
23
+ }
24
+ export function mapPerson(entity, toAssetUrl = (path) => path) {
25
+ const raw = str(entity.attributes.entity_picture);
26
+ const picture = raw ? fileUrl(raw, toAssetUrl) : undefined;
27
+ return {
28
+ kind: 'person',
29
+ ...baseOf(entity),
30
+ location: entity.state,
31
+ home: entity.state === 'home',
32
+ ...(picture ? { pictureUrl: picture } : {}),
33
+ };
34
+ }
35
+ export function mapGeneric(entity) {
36
+ const unit = str(entity.attributes.unit_of_measurement);
37
+ return { kind: 'generic', ...baseOf(entity), value: entity.state, ...(unit ? { unit } : {}) };
38
+ }
@@ -0,0 +1,5 @@
1
+ import { type EntityInput } from '@hashsome/core';
2
+ import type { HassEntity } from 'home-assistant-js-websocket';
3
+ /** A `weather.*` entity: its state is the current condition, its attributes the current readings
4
+ * (the forecast is separate, not part of the entity's state). */
5
+ export declare function mapWeather(entity: HassEntity): EntityInput;
@@ -0,0 +1,19 @@
1
+ import { WEATHER_CONDITIONS } from '@hashsome/core';
2
+ import { baseOf, num, str } from './common.js';
3
+ const isCondition = (state) => WEATHER_CONDITIONS.includes(state);
4
+ /** A `weather.*` entity: its state is the current condition, its attributes the current readings
5
+ * (the forecast is separate, not part of the entity's state). */
6
+ export function mapWeather(entity) {
7
+ const a = entity.attributes;
8
+ const temperature = num(a.temperature);
9
+ const unit = str(a.temperature_unit);
10
+ const humidity = num(a.humidity);
11
+ return {
12
+ kind: 'weather',
13
+ ...baseOf(entity),
14
+ condition: isCondition(entity.state) ? entity.state : 'unknown',
15
+ ...(temperature !== undefined ? { temperature } : {}),
16
+ ...(unit ? { unit } : {}),
17
+ ...(humidity !== undefined ? { humidity } : {}),
18
+ };
19
+ }
package/dist/mock.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { MockIntegration, type EntityInput } from '@hashsome/core';
2
+ export interface HomeAssistantMockOptions {
3
+ /** Integration id. Defaults to `ha`. */
4
+ id?: string;
5
+ /** Extra entities, by local id; they replace a default with the same id. */
6
+ entities?: Record<string, EntityInput>;
7
+ }
8
+ /** A Home Assistant integration that needs no Home Assistant: representative devices, mapped the
9
+ * same way real ones are, and commands that change their state. For tests, the gallery and
10
+ * dashboards under development. */
11
+ export declare function createMock(options?: HomeAssistantMockOptions): MockIntegration;