@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.
@@ -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: see `forecast()`). */
5
+ export declare function mapWeather(entity: HassEntity): EntityInput;
@@ -0,0 +1,40 @@
1
+ import { WEATHER_CONDITIONS } from '@hashsome/core';
2
+ import { forecastsOf } from '../forecast.js';
3
+ import { baseOf, bearing, num, str } from './common.js';
4
+ const isCondition = (state) => WEATHER_CONDITIONS.includes(state);
5
+ /** A `weather.*` entity: its state is the current condition, its attributes the current readings
6
+ * (the forecast is separate, not part of the entity's state: see `forecast()`). */
7
+ export function mapWeather(entity) {
8
+ const a = entity.attributes;
9
+ const temperature = num(a.temperature);
10
+ const unit = str(a.temperature_unit);
11
+ const humidity = num(a.humidity);
12
+ const windSpeed = num(a.wind_speed);
13
+ const windBearing = bearing(a.wind_bearing);
14
+ const windUnit = str(a.wind_speed_unit);
15
+ const uvIndex = num(a.uv_index);
16
+ const cloudCoverage = num(a.cloud_coverage);
17
+ const apparentTemperature = num(a.apparent_temperature);
18
+ const pressure = num(a.pressure);
19
+ const pressureUnit = str(a.pressure_unit);
20
+ const precipitationUnit = str(a.precipitation_unit);
21
+ const forecasts = forecastsOf(num(a.supported_features));
22
+ return {
23
+ kind: 'weather',
24
+ ...baseOf(entity),
25
+ condition: isCondition(entity.state) ? entity.state : 'unknown',
26
+ ...(temperature !== undefined ? { temperature } : {}),
27
+ ...(unit ? { unit } : {}),
28
+ ...(humidity !== undefined ? { humidity } : {}),
29
+ ...(windSpeed !== undefined ? { windSpeed } : {}),
30
+ ...(windBearing !== undefined ? { windBearing } : {}),
31
+ ...(windUnit ? { windUnit } : {}),
32
+ ...(uvIndex !== undefined ? { uvIndex } : {}),
33
+ ...(cloudCoverage !== undefined ? { cloudCoverage } : {}),
34
+ ...(apparentTemperature !== undefined ? { apparentTemperature } : {}),
35
+ ...(pressure !== undefined ? { pressure } : {}),
36
+ ...(pressureUnit ? { pressureUnit } : {}),
37
+ ...(precipitationUnit ? { precipitationUnit } : {}),
38
+ ...(forecasts.length > 0 ? { forecasts } : {}),
39
+ };
40
+ }
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;
package/dist/mock.js ADDED
@@ -0,0 +1,114 @@
1
+ import { MockIntegration, mockLibrary } from '@hashsome/core';
2
+ import { mapEntity } from './mappers/index.js';
3
+ const state = (id, value, attributes = {}) => ({
4
+ entity_id: id,
5
+ state: value,
6
+ attributes,
7
+ last_changed: '2026-01-01T00:00:00.000Z',
8
+ last_updated: '2026-01-01T00:00:00.000Z',
9
+ context: { id: 'mock', parent_id: null, user_id: null },
10
+ });
11
+ /** What Home Assistant reports for a representative home: every domain the mappers understand,
12
+ * and the states a dashboard has to handle (off, unavailable, unknown). Raw states, so the mock
13
+ * is built by the same mapping the real integration uses. */
14
+ const states = () => [
15
+ state('light.plain', 'on', { friendly_name: 'Lamp', supported_color_modes: ['onoff'] }),
16
+ state('light.dimmable', 'on', {
17
+ friendly_name: 'Dimmable light',
18
+ supported_color_modes: ['brightness'],
19
+ brightness: 153,
20
+ }),
21
+ state('light.tunable', 'on', {
22
+ friendly_name: 'Tunable white',
23
+ supported_color_modes: ['color_temp'],
24
+ color_mode: 'color_temp',
25
+ brightness: 255,
26
+ color_temp_kelvin: 2700,
27
+ min_color_temp_kelvin: 2000,
28
+ max_color_temp_kelvin: 6500,
29
+ }),
30
+ state('light.color', 'off', {
31
+ friendly_name: 'Color light',
32
+ supported_color_modes: ['hs'],
33
+ brightness: 255,
34
+ hs_color: [280, 80],
35
+ }),
36
+ state('light.unavailable', 'unavailable', { friendly_name: 'Unplugged light' }),
37
+ state('climate.heater', 'heat', {
38
+ friendly_name: 'Heater',
39
+ hvac_modes: ['off', 'heat'],
40
+ temperature: 21,
41
+ current_temperature: 19.4,
42
+ min_temp: 5,
43
+ max_temp: 30,
44
+ target_temp_step: 0.5,
45
+ }),
46
+ state('climate.thermostat', 'off', {
47
+ friendly_name: 'Thermostat',
48
+ hvac_modes: ['off', 'heat', 'cool', 'auto'],
49
+ temperature: 21.5,
50
+ current_temperature: 18.8,
51
+ preset_modes: ['home', 'away'],
52
+ preset_mode: 'home',
53
+ }),
54
+ state('sensor.temperature', '18.4', {
55
+ friendly_name: 'Temperature',
56
+ device_class: 'temperature',
57
+ unit_of_measurement: '°C',
58
+ }),
59
+ state('sensor.humidity', '52', {
60
+ friendly_name: 'Humidity',
61
+ device_class: 'humidity',
62
+ unit_of_measurement: '%',
63
+ }),
64
+ state('sensor.power', '9', {
65
+ friendly_name: 'Power',
66
+ device_class: 'power',
67
+ unit_of_measurement: 'W',
68
+ }),
69
+ state('sensor.energy', '84.3', {
70
+ friendly_name: 'Energy',
71
+ device_class: 'energy',
72
+ unit_of_measurement: 'kWh',
73
+ }),
74
+ state('sensor.not_reporting', 'unknown', {
75
+ friendly_name: 'Silent sensor',
76
+ device_class: 'temperature',
77
+ }),
78
+ state('binary_sensor.door', 'off', { friendly_name: 'Door', device_class: 'door' }),
79
+ state('person.dan', 'home', { friendly_name: 'Dan' }),
80
+ state('person.alex', 'not_home', { friendly_name: 'Alex' }),
81
+ state('scene.movie_night', 'scening', { friendly_name: 'Movie night' }),
82
+ state('script.shower', 'off', { friendly_name: 'Shower' }),
83
+ state('vacuum.robot', 'docked', { friendly_name: 'Robot vacuum' }),
84
+ state('switch.coffee_maker', 'off', { friendly_name: 'Coffee maker' }),
85
+ state('fan.exhaust', 'off', { friendly_name: 'Exhaust fan' }),
86
+ state('lock.front_door', 'locked', { friendly_name: 'Front door' }),
87
+ state('media_player.living_room', 'playing', {
88
+ friendly_name: 'Living room',
89
+ media_title: 'Blank Space',
90
+ media_artist: 'More More',
91
+ volume_level: 0.4,
92
+ media_position: 64,
93
+ media_duration: 231,
94
+ media_position_updated_at: new Date().toISOString(),
95
+ shuffle: false,
96
+ // play, pause, seek, volume, mute, previous, next, shuffle and browse.
97
+ supported_features: 163967,
98
+ }),
99
+ state('input_boolean.guest_mode', 'off', { friendly_name: 'Guest mode' }),
100
+ ];
101
+ /** A Home Assistant integration that needs no Home Assistant: representative devices, mapped the
102
+ * same way real ones are, and commands that change their state. For tests, the gallery and
103
+ * dashboards under development. */
104
+ export function createMock(options = {}) {
105
+ const entities = {};
106
+ for (const hass of states()) {
107
+ entities[hass.entity_id] = mapEntity(hass, { temperatureUnit: '°C' });
108
+ }
109
+ return new MockIntegration({
110
+ id: options.id ?? 'ha',
111
+ library: mockLibrary(),
112
+ entities: { ...entities, ...options.entities },
113
+ });
114
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@hashsome/integration.home-assistant",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.2.0",
4
+ "description": "Home Assistant integration: entities, services, area/entity registry",
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@hashsome/core": "0.2.0",
8
+ "home-assistant-js-websocket": "9.7.0"
9
+ },
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./mock": {
16
+ "types": "./dist/mock.d.ts",
17
+ "default": "./dist/mock.js"
18
+ },
19
+ "./package.json": "./package.json"
20
+ },
21
+ "license": "GPL-3.0-only",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/dstoyanoff/hashsome.git",
25
+ "directory": "packages/integrations/home-assistant"
26
+ },
27
+ "homepage": "https://github.com/dstoyanoff/hashsome#readme",
28
+ "bugs": "https://github.com/dstoyanoff/hashsome/issues",
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "publishConfig": {
33
+ "access": "public",
34
+ "provenance": true
35
+ },
36
+ "scripts": {
37
+ "typecheck": "tsc --noEmit",
38
+ "states": "node scripts/print-states.ts",
39
+ "build": "tsc -b tsconfig.build.json"
40
+ }
6
41
  }
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.