Files
frontend/src/data/selector.ts
2025-10-08 08:13:54 +00:00

986 lines
22 KiB
TypeScript

import type {
HassEntity,
HassServiceTarget,
} from "home-assistant-js-websocket";
import { ensureArray } from "../common/array/ensure-array";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import { supportsFeature } from "../common/entity/supports-feature";
import type { CropOptions } from "../dialogs/image-cropper-dialog/show-image-cropper-dialog";
import { isHelperDomain } from "../panels/config/helpers/const";
import type { UiAction } from "../panels/lovelace/components/hui-action-editor";
import type { HomeAssistant } from "../types";
import {
type DeviceRegistryEntry,
getDeviceIntegrationLookup,
} from "./device_registry";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "./entity_registry";
import type { EntitySources } from "./entity_sources";
import type { EntityNameItem } from "../common/entity/compute_entity_name_display";
export type Selector =
| ActionSelector
| AddonSelector
| AreaSelector
| AreasDisplaySelector
| AttributeSelector
| BooleanSelector
| ButtonToggleSelector
| ColorRGBSelector
| ColorTempSelector
| ConditionSelector
| ConversationAgentSelector
| ConfigEntrySelector
| ConstantSelector
| CountrySelector
| DateSelector
| DateTimeSelector
| DeviceSelector
| FloorSelector
| LegacyDeviceSelector
| DurationSelector
| EntitySelector
| EntityNameSelector
| LegacyEntitySelector
| FileSelector
| IconSelector
| LabelSelector
| ImageSelector
| BackgroundSelector
| LanguageSelector
| LocationSelector
| MediaSelector
| NavigationSelector
| NumberSelector
| ObjectSelector
| AssistPipelineSelector
| QRCodeSelector
| SelectSelector
| SelectorSelector
| StateSelector
| StatisticSelector
| StringSelector
| STTSelector
| TargetSelector
| TemplateSelector
| ThemeSelector
| TimeSelector
| TriggerSelector
| TTSSelector
| TTSVoiceSelector
| UiActionSelector
| UiColorSelector
| UiStateContentSelector
| BackupLocationSelector;
export interface ActionSelector {
action: {
optionsInSidebar?: boolean;
} | null;
}
export interface AddonSelector {
addon: {
name?: string;
slug?: string;
} | null;
}
export interface AreaSelector {
area: {
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
multiple?: boolean;
} | null;
}
export interface AreasDisplaySelector {
areas_display: {} | null;
}
export interface AttributeSelector {
attribute: {
entity_id?: string | string[];
hide_attributes?: readonly string[];
} | null;
}
export interface BooleanSelector {
boolean: {} | null;
}
export interface ButtonToggleSelector {
button_toggle: {
options: readonly string[] | readonly SelectOption[];
translation_key?: string;
sort?: boolean;
} | null;
}
export interface ColorRGBSelector {
color_rgb: {} | null;
}
export interface ColorTempSelector {
color_temp: {
unit?: "kelvin" | "mired";
min?: number;
max?: number;
min_mireds?: number;
max_mireds?: number;
} | null;
}
export interface ConditionSelector {
condition: {
optionsInSidebar?: boolean;
} | null;
}
export interface ConversationAgentSelector {
conversation_agent: { language?: string } | null;
}
export interface ConfigEntrySelector {
config_entry: {
integration?: string;
} | null;
}
export interface ConstantSelector {
constant: {
value: string | number | boolean;
label?: string;
translation_key?: string;
} | null;
}
export interface CountrySelector {
country: {
countries: string[];
no_sort?: boolean;
} | null;
}
export interface DateSelector {
date: {} | null;
}
export interface DateTimeSelector {
datetime: {} | null;
}
interface DeviceSelectorFilter {
integration?: string;
manufacturer?: string;
model?: string;
model_id?: string;
}
export interface DeviceSelector {
device: {
filter?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
multiple?: boolean;
} | null;
}
export interface FloorSelector {
floor: {
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
multiple?: boolean;
} | null;
}
export interface LegacyDeviceSelector {
device: DeviceSelector["device"] & {
/**
* @deprecated Use filter instead
*/
integration?: DeviceSelectorFilter["integration"];
/**
* @deprecated Use filter instead
*/
manufacturer?: DeviceSelectorFilter["manufacturer"];
/**
* @deprecated Use filter instead
*/
model?: DeviceSelectorFilter["model"];
};
}
export interface DurationSelector {
duration: {
enable_day?: boolean;
enable_millisecond?: boolean;
} | null;
}
interface EntitySelectorFilter {
integration?: string;
domain?: string | readonly string[];
device_class?: string | readonly string[];
supported_features?: number | [number];
}
export interface EntitySelector {
entity: {
multiple?: boolean;
include_entities?: string[];
exclude_entities?: string[];
filter?: EntitySelectorFilter | readonly EntitySelectorFilter[];
reorder?: boolean;
} | null;
}
export interface LegacyEntitySelector {
entity: EntitySelector["entity"] & {
/**
* @deprecated Use filter instead
*/
integration?: EntitySelectorFilter["integration"];
/**
* @deprecated Use filter instead
*/
domain?: EntitySelectorFilter["domain"];
/**
* @deprecated Use filter instead
*/
device_class?: EntitySelectorFilter["device_class"];
};
}
export interface StatisticSelector {
statistic: {
device_class?: string;
multiple?: boolean;
};
}
export interface FileSelector {
file: {
accept: string;
} | null;
}
export interface IconSelector {
icon: {
placeholder?: string;
fallbackPath?: string;
} | null;
}
export interface ImageSelector {
image: { original?: boolean; crop?: CropOptions } | null;
}
export interface BackgroundSelector {
background: { original?: boolean; crop?: CropOptions } | null;
}
export interface LabelSelector {
label: {
multiple?: boolean;
};
}
export interface LanguageSelector {
language: {
languages?: string[];
native_name?: boolean;
no_sort?: boolean;
} | null;
}
export interface LocationSelector {
location: {
radius?: boolean;
radius_readonly?: boolean;
icon?: string;
} | null;
}
export interface LocationSelectorValue {
latitude: number;
longitude: number;
radius?: number;
}
export interface MediaSelector {
media: {
accept?: string[];
} | null;
}
export interface MediaSelectorValue {
entity_id?: string;
media_content_id?: string;
media_content_type?: string;
metadata?: {
title?: string;
thumbnail?: string | null;
media_class?: string;
children_media_class?: string | null;
navigateIds?: { media_content_type: string; media_content_id: string }[];
browse_entity_id?: string;
};
}
export interface NavigationSelector {
navigation: {} | null;
}
export interface NumberSelector {
number: {
min?: number;
max?: number;
step?: number | "any";
mode?: "box" | "slider";
unit_of_measurement?: string;
slider_ticks?: boolean;
translation_key?: string;
} | null;
}
interface ObjectSelectorField {
selector: Selector;
label?: string;
required?: boolean;
}
export interface ObjectSelector {
object?: {
label_field?: string;
description_field?: string;
translation_key?: string;
fields?: Record<string, ObjectSelectorField>;
multiple?: boolean;
} | null;
}
export interface AssistPipelineSelector {
assist_pipeline: {
include_last_used?: boolean;
} | null;
}
interface SelectBoxOptionImage {
src: string;
src_dark?: string;
flip_rtl?: boolean;
}
export interface SelectOption {
value: any;
label: string;
description?: string;
image?: string | SelectBoxOptionImage;
disabled?: boolean;
}
export interface SelectSelector {
select: {
multiple?: boolean;
custom_value?: boolean;
mode?: "list" | "dropdown" | "box";
options: readonly string[] | readonly SelectOption[];
translation_key?: string;
sort?: boolean;
reorder?: boolean;
box_max_columns?: number;
} | null;
}
export interface SelectorSelector {
selector: {} | null;
}
export interface StateSelector {
state: {
extra_options?: { label: string; value: any }[];
entity_id?: string | string[];
attribute?: string;
hide_states?: string[];
multiple?: boolean;
} | null;
}
export interface BackupLocationSelector {
backup_location: {} | null;
}
export interface QRCodeSelector {
qr_code: {
data: string;
scale?: number;
error_correction_level?: "low" | "medium" | "quartile" | "high";
center_image?: string;
} | null;
}
export interface StringSelector {
text: {
multiline?: boolean;
type?:
| "number"
| "text"
| "search"
| "tel"
| "url"
| "email"
| "password"
| "date"
| "month"
| "week"
| "time"
| "datetime-local"
| "color";
prefix?: string;
suffix?: string;
autocomplete?: string;
multiple?: true;
} | null;
}
export interface STTSelector {
stt: { language?: string } | null;
}
export interface TargetSelector {
target: {
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
} | null;
}
export interface TemplateSelector {
template: {} | null;
}
export interface ThemeSelector {
theme: { include_default?: boolean } | null;
}
export interface TimeSelector {
time: { no_second?: boolean } | null;
}
export interface TriggerSelector {
trigger: {} | null;
}
export interface TTSSelector {
tts: { language?: string } | null;
}
export interface TTSVoiceSelector {
tts_voice: { engineId?: string; language?: string } | null;
}
export interface UiActionSelector {
ui_action: {
actions?: UiAction[];
default_action?: UiAction;
} | null;
}
export interface UiColorSelector {
ui_color: {
default_color?: string;
include_none?: boolean;
include_state?: boolean;
} | null;
}
export interface UiStateContentSelector {
ui_state_content: {
entity_id?: string;
allow_name?: boolean;
} | null;
}
export interface EntityNameSelector {
entity_name: {
entity_id?: string;
default_name?: EntityNameItem | EntityNameItem[] | string;
} | null;
}
export const expandLabelTarget = (
hass: HomeAssistant,
labelId: string,
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"],
targetSelector: TargetSelector,
entitySources?: EntitySources
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
const newAreas: string[] = [];
Object.values(areas).forEach((area) => {
if (
area.labels.includes(labelId) &&
areaMeetsTargetSelector(
hass,
entities,
devices,
area.area_id,
targetSelector,
entitySources
)
) {
newAreas.push(area.area_id);
}
});
Object.values(devices).forEach((device) => {
if (
device.labels.includes(labelId) &&
deviceMeetsTargetSelector(
hass,
Object.values(entities),
device,
targetSelector,
entitySources
)
) {
newDevices.push(device.id);
}
});
Object.values(entities).forEach((entity) => {
if (
entity.labels.includes(labelId) &&
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
)
) {
newEntities.push(entity.entity_id);
}
});
return { areas: newAreas, devices: newDevices, entities: newEntities };
};
export const expandFloorTarget = (
hass: HomeAssistant,
floorId: string,
areas: HomeAssistant["areas"],
targetSelector: TargetSelector,
entitySources?: EntitySources
) => {
const newAreas: string[] = [];
Object.values(areas).forEach((area) => {
if (
area.floor_id === floorId &&
areaMeetsTargetSelector(
hass,
hass.entities,
hass.devices,
area.area_id,
targetSelector,
entitySources
)
) {
newAreas.push(area.area_id);
}
});
return { areas: newAreas };
};
export const expandAreaTarget = (
hass: HomeAssistant,
areaId: string,
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"],
targetSelector: TargetSelector,
entitySources?: EntitySources
) => {
const newEntities: string[] = [];
const newDevices: string[] = [];
Object.values(devices).forEach((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass,
Object.values(entities),
device,
targetSelector,
entitySources
)
) {
newDevices.push(device.id);
}
});
Object.values(entities).forEach((entity) => {
if (
entity.area_id === areaId &&
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
)
) {
newEntities.push(entity.entity_id);
}
});
return { devices: newDevices, entities: newEntities };
};
export const expandDeviceTarget = (
hass: HomeAssistant,
deviceId: string,
entities: HomeAssistant["entities"],
targetSelector: TargetSelector,
entitySources?: EntitySources
) => {
const newEntities: string[] = [];
Object.values(entities).forEach((entity) => {
if (
entity.device_id === deviceId &&
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
)
) {
newEntities.push(entity.entity_id);
}
});
return { entities: newEntities };
};
export const areaMeetsTargetSelector = (
hass: HomeAssistant,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areaId: string,
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const hasMatchingdevice = Object.values(devices).some((device) => {
if (
device.area_id === areaId &&
deviceMeetsTargetSelector(
hass,
Object.values(entities),
device,
targetSelector,
entitySources
)
) {
return true;
}
return false;
});
if (hasMatchingdevice) {
return true;
}
return Object.values(entities).some((entity) => {
if (
entity.area_id === areaId &&
entityMeetsTargetSelector(
hass.states[entity.entity_id],
targetSelector,
entitySources
)
) {
return true;
}
return false;
});
};
export const deviceMeetsTargetSelector = (
hass: HomeAssistant,
entityRegistry: EntityRegistryDisplayEntry[] | EntityRegistryEntry[],
device: DeviceRegistryEntry,
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
const deviceIntegrationLookup = entitySources
? getDeviceIntegrationLookup(entitySources, entityRegistry)
: undefined;
if (targetSelector.target?.device) {
if (
!ensureArray(targetSelector.target.device).some((filterDevice) =>
filterSelectorDevices(filterDevice, device, deviceIntegrationLookup)
)
) {
return false;
}
}
if (targetSelector.target?.entity) {
const entities = entityRegistry.filter(
(reg) => reg.device_id === device.id
);
return entities.some((entity) => {
const entityState = hass.states[entity.entity_id];
return entityMeetsTargetSelector(
entityState,
targetSelector,
entitySources
);
});
}
return true;
};
export const entityMeetsTargetSelector = (
entity: HassEntity | undefined,
targetSelector: TargetSelector,
entitySources?: EntitySources
): boolean => {
if (!entity) {
return false;
}
if (targetSelector.target?.entity) {
return ensureArray(targetSelector.target!.entity).some((filterEntity) =>
filterSelectorEntities(filterEntity, entity, entitySources)
);
}
return true;
};
export const filterSelectorDevices = (
filterDevice: DeviceSelectorFilter,
device: DeviceRegistryEntry,
deviceIntegrationLookup?: Record<string, Set<string>> | undefined
): boolean => {
const {
manufacturer: filterManufacturer,
model: filterModel,
model_id: filterModelId,
integration: filterIntegration,
} = filterDevice;
if (filterManufacturer && device.manufacturer !== filterManufacturer) {
return false;
}
if (filterModel && device.model !== filterModel) {
return false;
}
if (filterModelId && device.model_id !== filterModelId) {
return false;
}
if (filterIntegration && deviceIntegrationLookup) {
if (!deviceIntegrationLookup?.[device.id]?.has(filterIntegration)) {
return false;
}
}
return true;
};
export const filterSelectorEntities = (
filterEntity: EntitySelectorFilter,
entity: HassEntity,
entitySources?: EntitySources
): boolean => {
const {
domain: filterDomain,
device_class: filterDeviceClass,
supported_features: filterSupportedFeature,
integration: filterIntegration,
} = filterEntity;
if (filterDomain) {
const entityDomain = computeStateDomain(entity);
if (
Array.isArray(filterDomain)
? !filterDomain.includes(entityDomain)
: entityDomain !== filterDomain
) {
return false;
}
}
if (filterDeviceClass) {
const entityDeviceClass = entity.attributes.device_class;
if (
entityDeviceClass && Array.isArray(filterDeviceClass)
? !filterDeviceClass.includes(entityDeviceClass)
: entityDeviceClass !== filterDeviceClass
) {
return false;
}
}
if (filterSupportedFeature) {
if (
!ensureArray(filterSupportedFeature).some((feature) =>
supportsFeature(entity, feature)
)
) {
return false;
}
}
if (
filterIntegration &&
entitySources?.[entity.entity_id]?.domain !== filterIntegration
) {
return false;
}
return true;
};
export const handleLegacyEntitySelector = (
selector: LegacyEntitySelector | EntitySelector
): EntitySelector => {
if (!selector.entity) return { entity: null };
if ("filter" in selector.entity) return selector;
const { domain, integration, device_class, ...rest } = (
selector as LegacyEntitySelector
).entity!;
if (domain || integration || device_class) {
return {
entity: {
...rest,
filter: {
domain,
integration,
device_class,
},
},
};
}
return {
entity: rest,
};
};
export const handleLegacyDeviceSelector = (
selector: LegacyDeviceSelector | DeviceSelector
): DeviceSelector => {
if (!selector.device) return { device: null };
if ("filter" in selector.device) return selector;
const { integration, manufacturer, model, ...rest } = (
selector as LegacyDeviceSelector
).device!;
if (integration || manufacturer || model) {
return {
device: {
...rest,
filter: {
integration,
manufacturer,
model,
},
},
};
}
return {
device: rest,
};
};
export const computeCreateDomains = (
selector: EntitySelector | TargetSelector
): undefined | string[] => {
let entityFilters: EntitySelectorFilter[] | undefined;
if ("target" in selector) {
entityFilters = ensureArray(selector.target?.entity);
} else if ("entity" in selector) {
if (selector.entity?.include_entities) {
return undefined;
}
entityFilters = ensureArray(selector.entity?.filter);
}
if (!entityFilters) {
return undefined;
}
const createDomains = entityFilters.flatMap((entityFilter) =>
!entityFilter.integration &&
!entityFilter.device_class &&
!entityFilter.supported_features &&
entityFilter.domain
? ensureArray(entityFilter.domain).filter((domain) =>
isHelperDomain(domain)
)
: []
);
return [...new Set(createDomains)];
};
export const resolveEntityIDs = (
hass: HomeAssistant,
targetPickerValue: HassServiceTarget,
entities: HomeAssistant["entities"],
devices: HomeAssistant["devices"],
areas: HomeAssistant["areas"]
): string[] => {
if (!targetPickerValue) {
return [];
}
const targetSelector = { target: {} };
const targetEntities = new Set(ensureArray(targetPickerValue.entity_id));
const targetDevices = new Set(ensureArray(targetPickerValue.device_id));
const targetAreas = new Set(ensureArray(targetPickerValue.area_id));
const targetFloors = new Set(ensureArray(targetPickerValue.floor_id));
const targetLabels = new Set(ensureArray(targetPickerValue.label_id));
targetLabels.forEach((labelId) => {
const expanded = expandLabelTarget(
hass,
labelId,
areas,
devices,
entities,
targetSelector
);
expanded.devices.forEach((id) => targetDevices.add(id));
expanded.entities.forEach((id) => targetEntities.add(id));
expanded.areas.forEach((id) => targetAreas.add(id));
});
targetFloors.forEach((floorId) => {
const expanded = expandFloorTarget(hass, floorId, areas, targetSelector);
expanded.areas.forEach((id) => targetAreas.add(id));
});
targetAreas.forEach((areaId) => {
const expanded = expandAreaTarget(
hass,
areaId,
devices,
entities,
targetSelector
);
expanded.devices.forEach((id) => targetDevices.add(id));
expanded.entities.forEach((id) => targetEntities.add(id));
});
targetDevices.forEach((deviceId) => {
const expanded = expandDeviceTarget(
hass,
deviceId,
entities,
targetSelector
);
expanded.entities.forEach((id) => targetEntities.add(id));
});
return Array.from(targetEntities);
};