@digital-alchemy/hass 25.10.25 → 25.10.26
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/dev/mappings.d.mts +13 -7
- package/dist/dev/registry.d.mts +238 -0
- package/dist/hass.module.d.mts +5 -1
- package/dist/hass.module.mjs +5 -1
- package/dist/hass.module.mjs.map +1 -1
- package/dist/helpers/fetch/service-list.d.mts +15 -4
- package/dist/helpers/index.d.mts +1 -0
- package/dist/helpers/index.mjs +1 -0
- package/dist/helpers/index.mjs.map +1 -1
- package/dist/helpers/supported-features.d.mts +447 -0
- package/dist/helpers/supported-features.mjs +290 -0
- package/dist/helpers/supported-features.mjs.map +1 -0
- package/dist/mock_assistant/mock-assistant.module.d.mts +2 -0
- package/dist/services/feature.service.d.mts +10 -0
- package/dist/services/feature.service.mjs +95 -0
- package/dist/services/feature.service.mjs.map +1 -0
- package/dist/services/index.d.mts +1 -0
- package/dist/services/index.mjs +1 -0
- package/dist/services/index.mjs.map +1 -1
- package/dist/testing/feature.spec.d.mts +1 -0
- package/dist/testing/feature.spec.mjs +203 -0
- package/dist/testing/feature.spec.mjs.map +1 -0
- package/dist/testing/id-by.spec.mjs +3 -3
- package/dist/testing/id-by.spec.mjs.map +1 -1
- package/dist/testing/ref-by.spec.mjs +2 -2
- package/dist/testing/ref-by.spec.mjs.map +1 -1
- package/package.json +9 -9
- package/src/dev/mappings.mts +38 -7
- package/src/dev/registry.mts +232 -0
- package/src/hass.module.mts +6 -0
- package/src/helpers/fetch/service-list.mts +13 -5
- package/src/helpers/index.mts +1 -0
- package/src/helpers/supported-features.mts +328 -0
- package/src/services/feature.service.mts +127 -0
- package/src/services/index.mts +1 -0
- package/src/testing/feature.spec.mts +219 -0
- package/src/testing/id-by.spec.mts +3 -3
- package/src/testing/ref-by.spec.mts +2 -2
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/* eslint-disable sort-keys-fix/sort-keys-fix */
|
|
2
|
+
|
|
3
|
+
import type { ALL_DOMAINS } from "../user.mts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* homeassistant/components/light/const.py
|
|
7
|
+
*/
|
|
8
|
+
export const LIGHT = {
|
|
9
|
+
EFFECT: 4,
|
|
10
|
+
FLASH: 8,
|
|
11
|
+
TRANSITION: 32,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* homeassistant/components/vacuum/__init__.py
|
|
16
|
+
*/
|
|
17
|
+
export const VACUUM = {
|
|
18
|
+
/**
|
|
19
|
+
* Deprecated
|
|
20
|
+
*/
|
|
21
|
+
TURN_ON: 1,
|
|
22
|
+
/**
|
|
23
|
+
* Deprecated
|
|
24
|
+
*/
|
|
25
|
+
TURN_OFF: 2,
|
|
26
|
+
PAUSE: 4,
|
|
27
|
+
STOP: 8,
|
|
28
|
+
RETURN_HOME: 16,
|
|
29
|
+
FAN_SPEED: 32,
|
|
30
|
+
BATTERY: 64,
|
|
31
|
+
/**
|
|
32
|
+
* Deprecated
|
|
33
|
+
*/
|
|
34
|
+
STATUS: 128,
|
|
35
|
+
SEND_COMMAND: 256,
|
|
36
|
+
LOCATE: 512,
|
|
37
|
+
CLEAN_SPOT: 1024,
|
|
38
|
+
MAP: 2048,
|
|
39
|
+
STATE: 4096,
|
|
40
|
+
START: 8192,
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* homeassistant/components/water_heater/__init__.py
|
|
45
|
+
*/
|
|
46
|
+
export const WATER_HEATER = {
|
|
47
|
+
TARGET_TEMPERATURE: 1,
|
|
48
|
+
OPERATION_MODE: 2,
|
|
49
|
+
AWAY_MODE: 4,
|
|
50
|
+
ON_OFF: 8,
|
|
51
|
+
} as const;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* homeassistant/components/valve/__init__.py
|
|
55
|
+
*/
|
|
56
|
+
export const VALVE = {
|
|
57
|
+
OPEN: 1,
|
|
58
|
+
CLOSE: 2,
|
|
59
|
+
SET_POSITION: 4,
|
|
60
|
+
STOP: 8,
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* homeassistant/components/weather/const.py
|
|
65
|
+
*/
|
|
66
|
+
export const WEATHER = {
|
|
67
|
+
FORECAST_DAILY: 1,
|
|
68
|
+
FORECAST_HOURLY: 2,
|
|
69
|
+
FORECAST_TWICE_DAILY: 4,
|
|
70
|
+
} as const;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* homeassistant/components/ai_task/const.py
|
|
74
|
+
*/
|
|
75
|
+
export const AI_TASK = {
|
|
76
|
+
ATTACHMENTS: 2,
|
|
77
|
+
} as const;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* homeassistant/components/switch/__init__.py
|
|
81
|
+
*/
|
|
82
|
+
export const SWITCH = {
|
|
83
|
+
TURN_ON: 1,
|
|
84
|
+
TURN_OFF: 2,
|
|
85
|
+
} as const;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* homeassistant/components/cover/__init__.py
|
|
89
|
+
*/
|
|
90
|
+
export const COVER = {
|
|
91
|
+
OPEN: 1,
|
|
92
|
+
CLOSE: 2,
|
|
93
|
+
SET_POSITION: 4,
|
|
94
|
+
STOP: 8,
|
|
95
|
+
OPEN_TILT: 16,
|
|
96
|
+
CLOSE_TILT: 32,
|
|
97
|
+
STOP_TILT: 64,
|
|
98
|
+
SET_TILT_POSITION: 128,
|
|
99
|
+
} as const;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* homeassistant/components/fan/__init__.py
|
|
103
|
+
*/
|
|
104
|
+
export const FAN = {
|
|
105
|
+
SET_SPEED: 1,
|
|
106
|
+
OSCILLATE: 2,
|
|
107
|
+
DIRECTION: 4,
|
|
108
|
+
PRESET_MODE: 8,
|
|
109
|
+
TURN_OFF: 16,
|
|
110
|
+
TURN_ON: 32,
|
|
111
|
+
} as const;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* homeassistant/components/climate/const.py
|
|
115
|
+
*/
|
|
116
|
+
export const CLIMATE = {
|
|
117
|
+
TARGET_TEMPERATURE: 1,
|
|
118
|
+
TARGET_TEMPERATURE_RANGE: 2,
|
|
119
|
+
TARGET_HUMIDITY: 4,
|
|
120
|
+
FAN_MODE: 8,
|
|
121
|
+
PRESET_MODE: 16,
|
|
122
|
+
SWING_MODE: 32,
|
|
123
|
+
TURN_OFF: 128,
|
|
124
|
+
TURN_ON: 256,
|
|
125
|
+
SWING_HORIZONTAL_MODE: 512,
|
|
126
|
+
} as const;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* homeassistant/components/media_player/const.py
|
|
130
|
+
*/
|
|
131
|
+
export const MEDIA_PLAYER = {
|
|
132
|
+
PAUSE: 1,
|
|
133
|
+
SEEK: 2,
|
|
134
|
+
VOLUME_SET: 4,
|
|
135
|
+
VOLUME_MUTE: 8,
|
|
136
|
+
PREVIOUS_TRACK: 16,
|
|
137
|
+
NEXT_TRACK: 32,
|
|
138
|
+
TURN_ON: 128,
|
|
139
|
+
TURN_OFF: 256,
|
|
140
|
+
PLAY_MEDIA: 512,
|
|
141
|
+
VOLUME_STEP: 1024,
|
|
142
|
+
SELECT_SOURCE: 2048,
|
|
143
|
+
STOP: 4096,
|
|
144
|
+
CLEAR_PLAYLIST: 8192,
|
|
145
|
+
PLAY: 16384,
|
|
146
|
+
SHUFFLE_SET: 32768,
|
|
147
|
+
SELECT_SOUND_MODE: 65536,
|
|
148
|
+
BROWSE_MEDIA: 131072,
|
|
149
|
+
REPEAT_SET: 262144,
|
|
150
|
+
GROUPING: 524288,
|
|
151
|
+
MEDIA_ANNOUNCE: 1048576,
|
|
152
|
+
MEDIA_ENQUEUE: 2097152,
|
|
153
|
+
SEARCH_MEDIA: 4194304,
|
|
154
|
+
} as const;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* homeassistant/components/camera/__init__.py
|
|
158
|
+
*/
|
|
159
|
+
export const CAMERA = {
|
|
160
|
+
ON_OFF: 1,
|
|
161
|
+
STREAM: 2,
|
|
162
|
+
} as const;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* homeassistant/components/lock/__init__.py
|
|
166
|
+
*/
|
|
167
|
+
export const LOCK = {
|
|
168
|
+
OPEN: 1,
|
|
169
|
+
} as const;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* homeassistant/components/alarm_control_panel/__init__.py
|
|
173
|
+
*/
|
|
174
|
+
export const ALARM_CONTROL_PANEL = {
|
|
175
|
+
ARM_HOME: 1,
|
|
176
|
+
ARM_AWAY: 2,
|
|
177
|
+
ARM_NIGHT: 4,
|
|
178
|
+
ARM_VACATION: 8,
|
|
179
|
+
ARM_CUSTOM_BYPASS: 16,
|
|
180
|
+
} as const;
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* homeassistant/components/binary_sensor/__init__.py
|
|
184
|
+
*/
|
|
185
|
+
export const BINARY_SENSOR = {
|
|
186
|
+
TURN_ON: 1,
|
|
187
|
+
TURN_OFF: 2,
|
|
188
|
+
} as const;
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* homeassistant/components/sensor/__init__.py
|
|
192
|
+
*/
|
|
193
|
+
export const SENSOR = {
|
|
194
|
+
TURN_ON: 1,
|
|
195
|
+
TURN_OFF: 2,
|
|
196
|
+
} as const;
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* homeassistant/components/update/const.py
|
|
200
|
+
*/
|
|
201
|
+
export const UPDATE = {
|
|
202
|
+
INSTALL: 1,
|
|
203
|
+
SPECIFIC_VERSION: 2,
|
|
204
|
+
PROGRESS: 4,
|
|
205
|
+
BACKUP: 8,
|
|
206
|
+
RELEASE_NOTES: 16,
|
|
207
|
+
} as const;
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* homeassistant/components/button/__init__.py
|
|
211
|
+
*/
|
|
212
|
+
export const BUTTON = {
|
|
213
|
+
TURN_ON: 1,
|
|
214
|
+
TURN_OFF: 2,
|
|
215
|
+
} as const;
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* homeassistant/components/number/__init__.py
|
|
219
|
+
*/
|
|
220
|
+
export const NUMBER = {
|
|
221
|
+
TURN_ON: 1,
|
|
222
|
+
TURN_OFF: 2,
|
|
223
|
+
} as const;
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* homeassistant/components/select/__init__.py
|
|
227
|
+
*/
|
|
228
|
+
export const SELECT = {
|
|
229
|
+
TURN_ON: 1,
|
|
230
|
+
TURN_OFF: 2,
|
|
231
|
+
} as const;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* homeassistant/components/siren/const.py
|
|
235
|
+
*/
|
|
236
|
+
export const SIREN = {
|
|
237
|
+
TURN_ON: 1,
|
|
238
|
+
TURN_OFF: 2,
|
|
239
|
+
TONES: 4,
|
|
240
|
+
VOLUME_SET: 8,
|
|
241
|
+
DURATION: 16,
|
|
242
|
+
} as const;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* homeassistant/components/text/__init__.py
|
|
246
|
+
*/
|
|
247
|
+
export const TEXT = {
|
|
248
|
+
TURN_ON: 1,
|
|
249
|
+
TURN_OFF: 2,
|
|
250
|
+
} as const;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* homeassistant/components/todo/const.py
|
|
254
|
+
*/
|
|
255
|
+
export const TODO = {
|
|
256
|
+
CREATE_TODO_ITEM: 1,
|
|
257
|
+
DELETE_TODO_ITEM: 2,
|
|
258
|
+
UPDATE_TODO_ITEM: 4,
|
|
259
|
+
MOVE_TODO_ITEM: 8,
|
|
260
|
+
SET_DUE_DATE_ON_ITEM: 16,
|
|
261
|
+
SET_DUE_DATETIME_ON_ITEM: 32,
|
|
262
|
+
SET_DESCRIPTION_ON_ITEM: 64,
|
|
263
|
+
} as const;
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* homeassistant/components/humidifier/const.py
|
|
267
|
+
*/
|
|
268
|
+
export const HUMIDIFIER = {
|
|
269
|
+
MODES: 1,
|
|
270
|
+
} as const;
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* homeassistant/components/remote/__init__.py
|
|
274
|
+
*/
|
|
275
|
+
export const REMOTE = {
|
|
276
|
+
LEARN_COMMAND: 1,
|
|
277
|
+
DELETE_COMMAND: 2,
|
|
278
|
+
ACTIVITY: 4,
|
|
279
|
+
} as const;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* homeassistant/components/notify/__init__.py
|
|
283
|
+
*/
|
|
284
|
+
export const NOTIFY = {
|
|
285
|
+
TITLE: 1,
|
|
286
|
+
} as const;
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Registry for other library logic to take advantage of
|
|
290
|
+
*/
|
|
291
|
+
export const SUPPORTED_FEATURES = {
|
|
292
|
+
AI_TASK,
|
|
293
|
+
ALARM_CONTROL_PANEL,
|
|
294
|
+
BINARY_SENSOR,
|
|
295
|
+
BUTTON,
|
|
296
|
+
CAMERA,
|
|
297
|
+
CLIMATE,
|
|
298
|
+
COVER,
|
|
299
|
+
FAN,
|
|
300
|
+
HUMIDIFIER,
|
|
301
|
+
LIGHT,
|
|
302
|
+
LOCK,
|
|
303
|
+
MEDIA_PLAYER,
|
|
304
|
+
NOTIFY,
|
|
305
|
+
NUMBER,
|
|
306
|
+
REMOTE,
|
|
307
|
+
SELECT,
|
|
308
|
+
SENSOR,
|
|
309
|
+
SIREN,
|
|
310
|
+
SWITCH,
|
|
311
|
+
TEXT,
|
|
312
|
+
TODO,
|
|
313
|
+
UPDATE,
|
|
314
|
+
VACUUM,
|
|
315
|
+
VALVE,
|
|
316
|
+
WATER_HEATER,
|
|
317
|
+
WEATHER,
|
|
318
|
+
} as const;
|
|
319
|
+
|
|
320
|
+
export type SUPPORTED_FEATURES = typeof SUPPORTED_FEATURES;
|
|
321
|
+
export type SupportedFeatureDomains = Extract<keyof SUPPORTED_FEATURES, string>;
|
|
322
|
+
export type UsedSupportedFeatureDomains = Extract<Lowercase<SupportedFeatureDomains>, ALL_DOMAINS>;
|
|
323
|
+
|
|
324
|
+
export type SupportedFeatures<DOMAIN extends SupportedFeatureDomains = SupportedFeatureDomains> =
|
|
325
|
+
`${DOMAIN}.${Extract<keyof SUPPORTED_FEATURES[DOMAIN], string>}`;
|
|
326
|
+
export type SupportedEntityFeatures<
|
|
327
|
+
DOMAIN extends UsedSupportedFeatureDomains = UsedSupportedFeatureDomains,
|
|
328
|
+
> = `${Uppercase<DOMAIN>}.${Extract<keyof SUPPORTED_FEATURES[Uppercase<DOMAIN>], string>}`;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-magic-numbers */
|
|
2
|
+
import { LABEL, type TServiceParams } from "@digital-alchemy/core";
|
|
3
|
+
|
|
4
|
+
import type { ByIdProxy } from "../helpers/entity-state.mts";
|
|
5
|
+
import type {
|
|
6
|
+
SupportedEntityFeatures,
|
|
7
|
+
SupportedFeatureDomains,
|
|
8
|
+
UsedSupportedFeatureDomains,
|
|
9
|
+
} from "../index.mts";
|
|
10
|
+
import { domain, SUPPORTED_FEATURES } from "../index.mts";
|
|
11
|
+
import type { PICK_ENTITY } from "../user.mts";
|
|
12
|
+
|
|
13
|
+
export function HassFeatureService({
|
|
14
|
+
hass,
|
|
15
|
+
logger,
|
|
16
|
+
internal: {
|
|
17
|
+
utils: { is },
|
|
18
|
+
},
|
|
19
|
+
}: TServiceParams) {
|
|
20
|
+
/**
|
|
21
|
+
* Helper function to create supported features from an array of feature numbers
|
|
22
|
+
*/
|
|
23
|
+
function createSupportedFeatures<T extends UsedSupportedFeatureDomains>(
|
|
24
|
+
features: (number | SupportedEntityFeatures<T>)[],
|
|
25
|
+
): number {
|
|
26
|
+
return features.reduce((acc: number, feature) => {
|
|
27
|
+
if (is.string(feature)) {
|
|
28
|
+
const original = feature;
|
|
29
|
+
const [domain, featureName] = feature.split(".") as [SupportedFeatureDomains, string];
|
|
30
|
+
const featureDomain = SUPPORTED_FEATURES[domain] as Record<string, number>;
|
|
31
|
+
feature = featureDomain?.[featureName];
|
|
32
|
+
if (!featureDomain?.[featureName]) {
|
|
33
|
+
feature = 0;
|
|
34
|
+
logger.error({ feature: original }, `invalid feature lookup`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return acc | feature;
|
|
38
|
+
}, 0);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function lookup(input: PICK_ENTITY | ByIdProxy<PICK_ENTITY>) {
|
|
42
|
+
const ref = is.string(input) ? hass.refBy.id(input) : input;
|
|
43
|
+
const attributes = ref.attributes as { supported_features: number };
|
|
44
|
+
return attributes?.supported_features ?? 0;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Helper function to check if an entity supports a specific feature
|
|
49
|
+
*/
|
|
50
|
+
function hasFeature<T extends UsedSupportedFeatureDomains>(
|
|
51
|
+
input: number | PICK_ENTITY<T> | ByIdProxy<PICK_ENTITY<T>>,
|
|
52
|
+
feature: number | SupportedEntityFeatures<T>,
|
|
53
|
+
): boolean {
|
|
54
|
+
const features = is.number(input) ? input : lookup(input);
|
|
55
|
+
if (is.string(feature)) {
|
|
56
|
+
const original = feature;
|
|
57
|
+
const [domain, featureName] = feature.split(".") as [SupportedFeatureDomains, string];
|
|
58
|
+
const featureDomain = SUPPORTED_FEATURES[domain] as Record<string, number>;
|
|
59
|
+
feature = featureDomain?.[featureName];
|
|
60
|
+
if (!feature) {
|
|
61
|
+
feature = 0;
|
|
62
|
+
logger.error({ feature: original }, `invalid feature lookup`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return (features & feature) !== 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Helper function to get all supported features as an array
|
|
70
|
+
* Can accept a number (bitmask), entity ID, or entity proxy
|
|
71
|
+
*/
|
|
72
|
+
function getSupportedFeatures(input: number | PICK_ENTITY | ByIdProxy<PICK_ENTITY>): number[] {
|
|
73
|
+
const features = is.number(input) ? input : lookup(input);
|
|
74
|
+
|
|
75
|
+
const supported: number[] = [];
|
|
76
|
+
let bit = 1;
|
|
77
|
+
while (bit <= features) {
|
|
78
|
+
if ((features & bit) !== 0) {
|
|
79
|
+
supported.push(bit);
|
|
80
|
+
}
|
|
81
|
+
bit <<= 1;
|
|
82
|
+
}
|
|
83
|
+
return supported;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function listEntityFeatures<
|
|
87
|
+
DOMAIN extends UsedSupportedFeatureDomains = UsedSupportedFeatureDomains,
|
|
88
|
+
>(
|
|
89
|
+
input: PICK_ENTITY<DOMAIN> | ByIdProxy<PICK_ENTITY<DOMAIN>>,
|
|
90
|
+
): SupportedEntityFeatures<DOMAIN>[] {
|
|
91
|
+
const inputDomain = domain(is.string(input) ? input : input.entity_id);
|
|
92
|
+
const domainFeatures = SUPPORTED_FEATURES[inputDomain.toUpperCase() as SupportedFeatureDomains];
|
|
93
|
+
if (!domainFeatures) {
|
|
94
|
+
return [];
|
|
95
|
+
}
|
|
96
|
+
const features = lookup(input);
|
|
97
|
+
|
|
98
|
+
const supported: number[] = [];
|
|
99
|
+
let bit = 1;
|
|
100
|
+
while (bit <= features) {
|
|
101
|
+
if ((features & bit) !== 0) {
|
|
102
|
+
supported.push(bit);
|
|
103
|
+
}
|
|
104
|
+
bit <<= 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const options = Object.entries(domainFeatures);
|
|
108
|
+
return supported
|
|
109
|
+
.map(i => {
|
|
110
|
+
const found = options.find(([, value]) => value === i);
|
|
111
|
+
if (!found) return null;
|
|
112
|
+
return (inputDomain.toUpperCase() +
|
|
113
|
+
"." +
|
|
114
|
+
found[
|
|
115
|
+
LABEL
|
|
116
|
+
]) as `${Uppercase<DOMAIN>}.${Extract<keyof SUPPORTED_FEATURES[Uppercase<DOMAIN>], string>}`;
|
|
117
|
+
})
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
createSupportedFeatures,
|
|
123
|
+
getSupportedFeatures,
|
|
124
|
+
hasFeature,
|
|
125
|
+
listEntityFeatures,
|
|
126
|
+
};
|
|
127
|
+
}
|
package/src/services/index.mts
CHANGED
|
@@ -6,6 +6,7 @@ export * from "./device.service.mts";
|
|
|
6
6
|
export * from "./diagnostics.service.mts";
|
|
7
7
|
export * from "./entity.service.mts";
|
|
8
8
|
export * from "./events.service.mts";
|
|
9
|
+
export * from "./feature.service.mts";
|
|
9
10
|
export * from "./fetch-api.service.mts";
|
|
10
11
|
export * from "./floor.service.mts";
|
|
11
12
|
export * from "./id-by.service.mts";
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { LIGHT } from "../helpers/supported-features.mts";
|
|
2
|
+
import { hassTestRunner } from "../mock_assistant/index.mts";
|
|
3
|
+
|
|
4
|
+
afterEach(async () => {
|
|
5
|
+
await hassTestRunner.teardown();
|
|
6
|
+
vi.restoreAllMocks();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
describe("Feature Service", () => {
|
|
10
|
+
describe("createSupportedFeatures", () => {
|
|
11
|
+
it("should create bitmask from array of numbers", async () => {
|
|
12
|
+
expect.assertions(1);
|
|
13
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
14
|
+
lifecycle.onReady(async () => {
|
|
15
|
+
const result = hass.feature.createSupportedFeatures([LIGHT.EFFECT, LIGHT.FLASH]);
|
|
16
|
+
expect(result).toBe(LIGHT.EFFECT | LIGHT.FLASH);
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("should create bitmask from array of feature strings", async () => {
|
|
22
|
+
expect.assertions(1);
|
|
23
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
24
|
+
lifecycle.onReady(async () => {
|
|
25
|
+
const result = hass.feature.createSupportedFeatures<"light">([
|
|
26
|
+
"LIGHT.EFFECT",
|
|
27
|
+
"LIGHT.FLASH",
|
|
28
|
+
]);
|
|
29
|
+
expect(result).toBe(LIGHT.EFFECT | LIGHT.FLASH);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("should handle mixed array of numbers and strings", async () => {
|
|
35
|
+
expect.assertions(1);
|
|
36
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
37
|
+
lifecycle.onReady(async () => {
|
|
38
|
+
const result = hass.feature.createSupportedFeatures<"light">([
|
|
39
|
+
LIGHT.EFFECT,
|
|
40
|
+
"LIGHT.FLASH",
|
|
41
|
+
]);
|
|
42
|
+
expect(result).toBe(LIGHT.EFFECT | LIGHT.FLASH);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("should handle invalid feature strings gracefully", async () => {
|
|
48
|
+
expect.assertions(1);
|
|
49
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
50
|
+
lifecycle.onReady(async () => {
|
|
51
|
+
const result = hass.feature.createSupportedFeatures<"light">([
|
|
52
|
+
// @ts-expect-error: part of the test
|
|
53
|
+
"INVALID.DOMAIN",
|
|
54
|
+
"LIGHT.EFFECT",
|
|
55
|
+
]);
|
|
56
|
+
// Should return only the valid feature, ignoring the invalid one
|
|
57
|
+
expect(result).toBe(LIGHT.EFFECT);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe("hasFeature", () => {
|
|
64
|
+
it("should return true when entity has the feature", async () => {
|
|
65
|
+
expect.assertions(1);
|
|
66
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
67
|
+
lifecycle.onReady(async () => {
|
|
68
|
+
// Use numeric features input instead of entity lookup to avoid test environment issues
|
|
69
|
+
const features = LIGHT.EFFECT | LIGHT.FLASH;
|
|
70
|
+
const result = hass.feature.hasFeature(features, LIGHT.EFFECT);
|
|
71
|
+
expect(result).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("should return false when entity does not have the feature", async () => {
|
|
77
|
+
expect.assertions(1);
|
|
78
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
79
|
+
lifecycle.onReady(async () => {
|
|
80
|
+
// Use numeric features input instead of entity lookup
|
|
81
|
+
const features = LIGHT.EFFECT; // Only EFFECT, no FLASH
|
|
82
|
+
const result = hass.feature.hasFeature(features, LIGHT.FLASH);
|
|
83
|
+
expect(result).toBe(false);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("should work with feature string", async () => {
|
|
89
|
+
expect.assertions(1);
|
|
90
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
91
|
+
lifecycle.onReady(async () => {
|
|
92
|
+
const features = LIGHT.EFFECT | LIGHT.FLASH;
|
|
93
|
+
const result = hass.feature.hasFeature<"light">(features, "LIGHT.EFFECT");
|
|
94
|
+
expect(result).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("should work with numeric features input", async () => {
|
|
100
|
+
expect.assertions(1);
|
|
101
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
102
|
+
lifecycle.onReady(async () => {
|
|
103
|
+
const features = LIGHT.EFFECT | LIGHT.FLASH;
|
|
104
|
+
const result = hass.feature.hasFeature(features, LIGHT.EFFECT);
|
|
105
|
+
expect(result).toBe(true);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe("getSupportedFeatures", () => {
|
|
112
|
+
it("should return array of supported feature numbers", async () => {
|
|
113
|
+
expect.assertions(1);
|
|
114
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
115
|
+
lifecycle.onReady(async () => {
|
|
116
|
+
// Use numeric features input instead of entity lookup
|
|
117
|
+
const features = LIGHT.EFFECT | LIGHT.FLASH | LIGHT.TRANSITION;
|
|
118
|
+
const result = hass.feature.getSupportedFeatures(features);
|
|
119
|
+
expect(result).toEqual([LIGHT.EFFECT, LIGHT.FLASH, LIGHT.TRANSITION]);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("should work with numeric features input", async () => {
|
|
125
|
+
expect.assertions(1);
|
|
126
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
127
|
+
lifecycle.onReady(async () => {
|
|
128
|
+
const features = LIGHT.EFFECT | LIGHT.FLASH;
|
|
129
|
+
const result = hass.feature.getSupportedFeatures(features);
|
|
130
|
+
expect(result).toEqual([LIGHT.EFFECT, LIGHT.FLASH]);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("should return empty array when no features are supported", async () => {
|
|
136
|
+
expect.assertions(1);
|
|
137
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
138
|
+
lifecycle.onReady(async () => {
|
|
139
|
+
const features = 0;
|
|
140
|
+
const result = hass.feature.getSupportedFeatures(features);
|
|
141
|
+
expect(result).toEqual([]);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
describe("listEntityFeatures", () => {
|
|
148
|
+
it("should return array of feature strings for light entity", async () => {
|
|
149
|
+
expect.assertions(1);
|
|
150
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
151
|
+
lifecycle.onReady(async () => {
|
|
152
|
+
// Use REAL entity reference from fixtures
|
|
153
|
+
const entity = hass.refBy.id("light.test_light");
|
|
154
|
+
const result = hass.feature.listEntityFeatures(entity);
|
|
155
|
+
// light.test_light has supported_features: 44 (LIGHT.EFFECT | LIGHT.FLASH | LIGHT.TRANSITION)
|
|
156
|
+
expect(result).toEqual(["LIGHT.EFFECT", "LIGHT.FLASH", "LIGHT.TRANSITION"]);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("should return array of feature strings for todo entity", async () => {
|
|
162
|
+
expect.assertions(1);
|
|
163
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
164
|
+
lifecycle.onReady(async () => {
|
|
165
|
+
// Use REAL entity reference from fixtures
|
|
166
|
+
const entity = hass.refBy.id("todo.test_todo");
|
|
167
|
+
const result = hass.feature.listEntityFeatures(entity);
|
|
168
|
+
// todo.test_todo has supported_features: 15 (TODO.CREATE_TODO_ITEM | TODO.DELETE_TODO_ITEM | TODO.UPDATE_TODO_ITEM | TODO.MOVE_TODO_ITEM)
|
|
169
|
+
expect(result).toEqual([
|
|
170
|
+
"TODO.CREATE_TODO_ITEM",
|
|
171
|
+
"TODO.DELETE_TODO_ITEM",
|
|
172
|
+
"TODO.UPDATE_TODO_ITEM",
|
|
173
|
+
"TODO.MOVE_TODO_ITEM",
|
|
174
|
+
]);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("should return array of feature strings for climate entity", async () => {
|
|
180
|
+
expect.assertions(1);
|
|
181
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
182
|
+
lifecycle.onReady(async () => {
|
|
183
|
+
// Use REAL entity reference from fixtures
|
|
184
|
+
const entity = hass.refBy.id("climate.test_climate");
|
|
185
|
+
const result = hass.feature.listEntityFeatures(entity);
|
|
186
|
+
// climate.test_climate has supported_features: 1 (CLIMATE.TARGET_TEMPERATURE)
|
|
187
|
+
expect(result).toEqual(["CLIMATE.TARGET_TEMPERATURE"]);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("should return empty array for unsupported domain", async () => {
|
|
193
|
+
expect.assertions(1);
|
|
194
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
195
|
+
lifecycle.onReady(async () => {
|
|
196
|
+
// Use REAL entity reference from fixtures
|
|
197
|
+
const entity = hass.refBy.id("unsupported.test");
|
|
198
|
+
// @ts-expect-error part of the test
|
|
199
|
+
const result = hass.feature.listEntityFeatures(entity);
|
|
200
|
+
// unsupported.test has supported_features: 1 but domain is unsupported
|
|
201
|
+
expect(result).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("should return empty array when no features are supported", async () => {
|
|
207
|
+
expect.assertions(1);
|
|
208
|
+
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
209
|
+
lifecycle.onReady(async () => {
|
|
210
|
+
// Use REAL entity reference from fixtures that has no features
|
|
211
|
+
const entity = hass.refBy.id("sensor.magic");
|
|
212
|
+
const result = hass.feature.listEntityFeatures(entity);
|
|
213
|
+
// sensor.magic has supported_features: 0
|
|
214
|
+
expect(result).toEqual([]);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -100,7 +100,7 @@ describe("enabled entities", () => {
|
|
|
100
100
|
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
101
101
|
lifecycle.onReady(() => {
|
|
102
102
|
const synapse = hass.idBy.platform("synapse");
|
|
103
|
-
expect(synapse.length).toBe(
|
|
103
|
+
expect(synapse.length).toBe(11);
|
|
104
104
|
});
|
|
105
105
|
});
|
|
106
106
|
});
|
|
@@ -110,7 +110,7 @@ describe("enabled entities", () => {
|
|
|
110
110
|
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
111
111
|
lifecycle.onReady(() => {
|
|
112
112
|
const synapse = hass.idBy.platform("synapse", "light");
|
|
113
|
-
expect(synapse.length).toBe(
|
|
113
|
+
expect(synapse.length).toBe(1);
|
|
114
114
|
});
|
|
115
115
|
});
|
|
116
116
|
});
|
|
@@ -205,7 +205,7 @@ describe("disabled entities", () => {
|
|
|
205
205
|
await hassTestRunner.run(({ lifecycle, hass }) => {
|
|
206
206
|
lifecycle.onReady(() => {
|
|
207
207
|
const synapse = hass.idBy.platform("synapse");
|
|
208
|
-
expect(synapse.length).toBe(
|
|
208
|
+
expect(synapse.length).toBe(12);
|
|
209
209
|
});
|
|
210
210
|
});
|
|
211
211
|
});
|