`conditional` & `entity-filter`: add ability to filter through `entity_id` & add `entity-filter` `conditional`'s conditions (#19182)

* entity-filter: add ability to filter through entity_id value

* review: test filter value against undefined

Co-authored-by: karwosts <32912880+karwosts@users.noreply.github.com>

* review: better handle state values that could be mixed with an entity_id

* Add multiple filter/condition types

* Fix automation's NumericStateCondition above/below types

* Replace operator condition by state for string or number

* Move to condition: type & attr

* Remove enable attr

* fix condition state array

* Remove necessary undefined check

* Move to condition: use same codebase as conditionnal card

* Fix entities error 'read properties of undefined' + conditions first

* Fix lint

* Merge condition to set the entity to filter on

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* review: make numeric_state below & above working together again, with entity_id support

* shorthand getValueFromEntityId

* review: states are string

* Split legacy state filter and condition logic

* Fix types

* Fix type

* Update gallery doc

* Fix operator in while numaric array

* Rename condition card header in gallery

* Don't use real gas station name

* Update gallery

* Update card is entity in condition change

* Don't check for entity id in state condition

* Improve check

* Update condition card demo

* Revert "Don't check for entity id in state condition"

This reverts commit f5e6a65a37.

* Use set instead of list

* Update demo

---------

Co-authored-by: karwosts <32912880+karwosts@users.noreply.github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Quentame 2024-03-19 14:29:34 +01:00 committed by GitHub
parent cbc150bad2
commit 552eeeddf6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 461 additions and 104 deletions

View File

@ -21,10 +21,10 @@ const ENTITIES = [
}),
];
const conditions = [
{ condition: "and" },
{ condition: "not" },
{ condition: "or" },
const conditions: Condition[] = [
{ condition: "and", conditions: [] },
{ condition: "not", conditions: [] },
{ condition: "or", conditions: [] },
{ condition: "state", entity_id: "light.kitchen", state: "on" },
{
condition: "numeric_state",
@ -34,11 +34,11 @@ const conditions = [
above: 20,
},
{ condition: "sun", after: "sunset" },
{ condition: "sun", after: "sunrise", offset: "-01:00" },
{ condition: "sun", after: "sunrise", before_offset: 3600 },
{ condition: "zone", entity_id: "device_tracker.person", zone: "zone.home" },
{ condition: "trigger", id: "motion" },
{ condition: "time" },
{ condition: "template" },
{ condition: "template", value_template: "" },
];
const initialCondition: Condition = {

View File

@ -11,7 +11,7 @@ const ENTITIES = [
latitude: 32.877105,
longitude: 117.232185,
gps_accuracy: 91,
battery: 71,
battery: 25,
friendly_name: "Paulus",
}),
getEntity("device_tracker", "demo_anne_therese", "school", {
@ -19,7 +19,7 @@ const ENTITIES = [
latitude: 32.877105,
longitude: 117.232185,
gps_accuracy: 91,
battery: 71,
battery: 50,
friendly_name: "Anne Therese",
}),
getEntity("device_tracker", "demo_home_boy", "home", {
@ -27,7 +27,7 @@ const ENTITIES = [
latitude: 32.877105,
longitude: 117.232185,
gps_accuracy: 91,
battery: 71,
battery: 75,
friendly_name: "Home Boy",
}),
getEntity("light", "bed_light", "on", {
@ -39,21 +39,53 @@ const ENTITIES = [
getEntity("light", "ceiling_lights", "off", {
friendly_name: "Ceiling Lights",
}),
getEntity("sensor", "battery_1", 20, {
device_class: "battery",
friendly_name: "Battery 1",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_2", 35, {
device_class: "battery",
friendly_name: "Battery 2",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_3", 40, {
device_class: "battery",
friendly_name: "Battery 3",
unit_of_measurement: "%",
}),
getEntity("sensor", "battery_4", 80, {
device_class: "battery",
friendly_name: "Battery 4",
unit_of_measurement: "%",
}),
getEntity("input_number", "min_battery_level", 30, {
mode: "slider",
step: 10,
min: 0,
max: 100,
icon: "mdi:battery-alert-variant",
friendly_name: "Minimum Battery Level",
unit_of_measurement: "%",
}),
];
const CONFIGS = [
{
heading: "Unfiltered controller",
heading: "Unfiltered entities",
config: `
- type: entities
entities:
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
`,
},
{
heading: "Filtered entities card",
heading: "On and home entities",
config: `
- type: entity-filter
entities:
@ -63,9 +95,28 @@ const CONFIGS = [
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
state_filter:
- "on"
- home
conditions:
- condition: state
state:
- "on"
- home
`,
},
{
heading: "Same state as Bed Light",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
conditions:
- condition: state
state:
- light.bed_light
`,
},
{
@ -79,9 +130,11 @@ const CONFIGS = [
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
state_filter:
- "on"
- not_home
conditions:
- condition: state
state:
- "on"
- home
card:
type: entities
title: Custom Title
@ -99,15 +152,101 @@ const CONFIGS = [
- light.bed_light
- light.ceiling_lights
- light.kitchen_lights
state_filter:
- "on"
- not_home
conditions:
- condition: state
state:
- "on"
- home
card:
type: glance
show_state: true
title: Custom Title
`,
},
{
heading:
"Filtered entities by battery attribute (< '30') using state filter",
config: `
- type: entity-filter
entities:
- device_tracker.demo_anne_therese
- device_tracker.demo_home_boy
- device_tracker.demo_paulus
state_filter:
- operator: <
attribute: battery
value: "30"
`,
},
{
heading: "Unfiltered number entities",
config: `
- type: entities
entities:
- input_number.min_battery_level
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
`,
},
{
heading: "Battery lower than 50%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: 50
`,
},
{
heading: "Battery lower than min battery level",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
below: input_number.min_battery_level
`,
},
{
heading: "Battery between min battery level and 70%",
config: `
- type: entity-filter
entities:
- sensor.battery_1
- sensor.battery_3
- sensor.battery_2
- sensor.battery_4
conditions:
- condition: numeric_state
above: input_number.min_battery_level
below: 70
`,
},
{
heading: "Error: Entities must be specified",
config: `
- type: entity-filter
`,
},
{
heading: "Error: Incorrect filter config",
config: `
- type: entity-filter
entities:
- sensor.gas_station_lowest_price
`,
},
];
@customElement("demo-lovelace-entity-filter-card")

View File

@ -219,8 +219,8 @@ export interface NumericStateCondition extends BaseCondition {
condition: "numeric_state";
entity_id: string;
attribute?: string;
above?: number;
below?: number;
above?: string | number;
below?: string | number;
value_template?: string;
}

View File

@ -1,8 +1,13 @@
import { PropertyValues, ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { HomeAssistant } from "../../../types";
import { evaluateFilter } from "../common/evaluate-filter";
import { evaluateStateFilter } from "../common/evaluate-filter";
import { processConfigEntities } from "../common/process-config-entities";
import {
addEntityToCondition,
checkConditionsMet,
extractConditionEntityIds,
} from "../common/validate-condition";
import { createBadgeElement } from "../create-element/create-badge-element";
import { EntityFilterEntityConfig } from "../entity-rows/types";
import { LovelaceBadge } from "../types";
@ -29,7 +34,10 @@ export class HuiEntityFilterBadge
}
if (
!(config.state_filter && Array.isArray(config.state_filter)) &&
!(
(config.conditions && Array.isArray(config.conditions)) ||
(config.state_filter && Array.isArray(config.state_filter))
) &&
!config.entities.every(
(entity) =>
typeof entity === "object" &&
@ -81,23 +89,19 @@ export class HuiEntityFilterBadge
const entitiesList = this._configEntities.filter((entityConf) => {
const stateObj = this.hass.states[entityConf.entity];
if (!stateObj) return false;
if (!stateObj) {
return false;
const conditions = entityConf.conditions ?? this._config!.conditions;
if (conditions) {
const conditionWithEntity = conditions.map((condition) =>
addEntityToCondition(condition, entityConf.entity)
);
return checkConditionsMet(conditionWithEntity, this.hass!);
}
if (entityConf.state_filter) {
for (const filter of entityConf.state_filter) {
if (evaluateFilter(stateObj, filter)) {
return true;
}
}
} else {
for (const filter of this._config!.state_filter) {
if (evaluateFilter(stateObj, filter)) {
return true;
}
}
const filters = entityConf.state_filter ?? this._config!.state_filter;
if (filters) {
return filters.some((filter) => evaluateStateFilter(stateObj, filter));
}
return false;
@ -152,8 +156,24 @@ export class HuiEntityFilterBadge
if (this.hass.states[config.entity] !== oldHass.states[config.entity]) {
return true;
}
if (config.conditions) {
const entityIds = extractConditionEntityIds(config.conditions);
for (const entityId of entityIds) {
if (this.hass.states[entityId] !== oldHass.states[entityId]) {
return true;
}
}
}
}
if (this._config?.conditions) {
const entityIds = extractConditionEntityIds(this._config?.conditions);
for (const entityId of entityIds) {
if (this.hass.states[entityId] !== oldHass.states[entityId]) {
return true;
}
}
}
return false;
}
}

View File

@ -1,11 +1,14 @@
import { ActionConfig } from "../../../data/lovelace/config/action";
import { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import { EntityFilterEntityConfig } from "../entity-rows/types";
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { LovelaceBadgeConfig } from "../../../data/lovelace/config/badge";
import type { LegacyStateFilter } from "../common/evaluate-filter";
import type { Condition } from "../common/validate-condition";
import type { EntityFilterEntityConfig } from "../entity-rows/types";
export interface EntityFilterBadgeConfig extends LovelaceBadgeConfig {
type: "entity-filter";
entities: Array<EntityFilterEntityConfig | string>;
state_filter: Array<{ key: string } | string>;
state_filter?: Array<LegacyStateFilter>;
conditions?: Array<Condition>;
}
export interface ErrorBadgeConfig extends LovelaceBadgeConfig {

View File

@ -3,9 +3,14 @@ import { customElement, property, state } from "lit/decorators";
import { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
import { evaluateFilter } from "../common/evaluate-filter";
import { evaluateStateFilter } from "../common/evaluate-filter";
import { findEntities } from "../common/find-entities";
import { processConfigEntities } from "../common/process-config-entities";
import {
addEntityToCondition,
checkConditionsMet,
extractConditionEntityIds,
} from "../common/validate-condition";
import { createCardElement } from "../create-element/create-card-element";
import { EntityFilterEntityConfig } from "../entity-rows/types";
import { LovelaceCard } from "../types";
@ -33,9 +38,14 @@ export class HuiEntityFilterCard
return {
type: "entity-filter",
entities: foundEntities,
state_filter: [
foundEntities[0] ? hass.states[foundEntities[0]].state : "",
],
conditions: foundEntities[0]
? [
{
condition: "state",
state: hass.states[foundEntities[0]].state,
},
]
: [],
card: { type: "entities" },
};
}
@ -61,12 +71,19 @@ export class HuiEntityFilterCard
}
public setConfig(config: EntityFilterCardConfig): void {
if (!config.entities.length || !Array.isArray(config.entities)) {
if (
!config.entities ||
!config.entities.length ||
!Array.isArray(config.entities)
) {
throw new Error("Entities must be specified");
}
if (
!(config.state_filter && Array.isArray(config.state_filter)) &&
!(
(config.conditions && Array.isArray(config.conditions)) ||
(config.state_filter && Array.isArray(config.state_filter))
) &&
!config.entities.every(
(entity) =>
typeof entity === "object" &&
@ -127,23 +144,19 @@ export class HuiEntityFilterCard
const entitiesList = this._configEntities.filter((entityConf) => {
const stateObj = this.hass!.states[entityConf.entity];
if (!stateObj) return false;
if (!stateObj) {
return false;
const conditions = entityConf.conditions ?? this._config!.conditions;
if (conditions) {
const conditionWithEntity = conditions.map((condition) =>
addEntityToCondition(condition, entityConf.entity)
);
return checkConditionsMet(conditionWithEntity, this.hass!);
}
if (entityConf.state_filter) {
for (const filter of entityConf.state_filter) {
if (evaluateFilter(stateObj, filter)) {
return true;
}
}
} else {
for (const filter of this._config!.state_filter) {
if (evaluateFilter(stateObj, filter)) {
return true;
}
}
const filters = entityConf.state_filter ?? this._config!.state_filter;
if (filters) {
return filters.some((filter) => evaluateStateFilter(stateObj, filter));
}
return false;
@ -202,6 +215,23 @@ export class HuiEntityFilterCard
if (this.hass.states[config.entity] !== oldHass.states[config.entity]) {
return true;
}
if (config.conditions) {
const entityIds = extractConditionEntityIds(config.conditions);
for (const entityId of entityIds) {
if (this.hass.states[entityId] !== oldHass.states[entityId]) {
return true;
}
}
}
}
if (this._config?.conditions) {
const entityIds = extractConditionEntityIds(this._config?.conditions);
for (const entityId of entityIds) {
if (this.hass.states[entityId] !== oldHass.states[entityId]) {
return true;
}
}
}
return false;

View File

@ -5,6 +5,7 @@ import { Statistic, StatisticType } from "../../../data/recorder";
import { ForecastType } from "../../../data/weather";
import { FullCalendarView, TranslationDict } from "../../../types";
import { LovelaceCardFeatureConfig } from "../card-features/types";
import { LegacyStateFilter } from "../common/evaluate-filter";
import { Condition, LegacyCondition } from "../common/validate-condition";
import { HuiImage } from "../components/hui-image";
import { TimestampRenderingFormat } from "../components/types";
@ -201,7 +202,8 @@ export interface EnergyCarbonGaugeCardConfig extends LovelaceCardConfig {
export interface EntityFilterCardConfig extends LovelaceCardConfig {
type: "entity-filter";
entities: Array<EntityFilterEntityConfig | string>;
state_filter: Array<{ key: string } | string>;
state_filter?: Array<LegacyStateFilter>;
conditions: Array<Condition>;
card?: Partial<LovelaceCardConfig>;
show_empty?: boolean;
}

View File

@ -1,11 +1,45 @@
import { HassEntity } from "home-assistant-js-websocket";
export const evaluateFilter = (stateObj: HassEntity, filter: any): boolean => {
const operator = filter.operator || "==";
let value = filter.value ?? filter;
let state = filter.attribute
? stateObj.attributes[filter.attribute]
: stateObj.state;
type FilterOperator =
| "=="
| "<="
| "<"
| ">="
| ">"
| "!="
| "in"
| "not in"
| "regex";
// Legacy entity-filter badge & card condition
export type LegacyStateFilter =
| {
operator: FilterOperator;
attribute?: string;
value: string | number | (string | number)[];
}
| number
| string;
export const evaluateStateFilter = (
stateObj: HassEntity,
filter: LegacyStateFilter
): boolean => {
let operator: FilterOperator;
let value: string | number | (string | number)[];
let state: any;
if (typeof filter === "object") {
operator = filter.operator;
value = filter.value;
state = filter.attribute
? stateObj.attributes[filter.attribute]
: stateObj.state;
} else {
operator = "==";
value = filter;
state = stateObj.state;
}
if (operator === "==" || operator === "!=") {
const valueIsNumeric =
@ -35,15 +69,24 @@ export const evaluateFilter = (stateObj: HassEntity, filter: any): boolean => {
return state !== value;
case "in":
if (Array.isArray(value) || typeof value === "string") {
if (Array.isArray(value)) {
value = value.map((val) => `${val}`);
}
return value.includes(state);
}
return false;
case "not in":
if (Array.isArray(value) || typeof value === "string") {
if (Array.isArray(value)) {
value = value.map((val) => `${val}`);
}
return !value.includes(state);
}
return false;
case "regex": {
if (typeof value !== "string") {
return false;
}
if (state !== null && typeof state === "object") {
return RegExp(value).test(JSON.stringify(state));
}

View File

@ -1,54 +1,76 @@
import { ensureArray } from "../../../common/array/ensure-array";
import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { UNAVAILABLE } from "../../../data/entity";
import { HomeAssistant } from "../../../types";
export type Condition =
| NumericStateCondition
| ScreenCondition
| StateCondition
| ScreenCondition
| UserCondition
| OrCondition
| AndCondition;
export type LegacyCondition = {
// Legacy conditional card condition
export interface LegacyCondition {
entity?: string;
state?: string | string[];
state_not?: string | string[];
};
}
export type NumericStateCondition = {
interface BaseCondition {
condition: string;
}
export interface NumericStateCondition extends BaseCondition {
condition: "numeric_state";
entity?: string;
below?: number;
above?: number;
};
below?: string | number;
above?: string | number;
}
export type StateCondition = {
export interface StateCondition extends BaseCondition {
condition: "state";
entity?: string;
state?: string | string[];
state_not?: string | string[];
};
}
export type ScreenCondition = {
export interface ScreenCondition extends BaseCondition {
condition: "screen";
media_query?: string;
};
}
export type UserCondition = {
export interface UserCondition extends BaseCondition {
condition: "user";
users?: string[];
};
}
export type OrCondition = {
export interface OrCondition extends BaseCondition {
condition: "or";
conditions?: Condition[];
};
}
export type AndCondition = {
export interface AndCondition extends BaseCondition {
condition: "and";
conditions?: Condition[];
};
}
function getValueFromEntityId(
hass: HomeAssistant,
value: string | string[]
): string | string[] {
if (
typeof value === "string" &&
isValidEntityId(value) &&
hass.states[value]
) {
value = hass.states[value]?.state;
} else if (Array.isArray(value)) {
value = value.map((v) => getValueFromEntityId(hass, v) as string);
}
return value;
}
function checkStateCondition(
condition: StateCondition | LegacyCondition,
@ -58,32 +80,50 @@ function checkStateCondition(
condition.entity && hass.states[condition.entity]
? hass.states[condition.entity].state
: UNAVAILABLE;
let value = condition.state ?? condition.state_not;
// Handle entity_id, UI should be updated for conditionnal card (filters does not have UI for now)
if (Array.isArray(value) || typeof value === "string") {
value = getValueFromEntityId(hass, value);
}
return condition.state != null
? ensureArray(condition.state).includes(state)
: !ensureArray(condition.state_not).includes(state);
? ensureArray(value).includes(state)
: !ensureArray(value).includes(state);
}
function checkStateNumericCondition(
condition: NumericStateCondition,
hass: HomeAssistant
) {
const entity =
(condition.entity ? hass.states[condition.entity] : undefined) ?? undefined;
const state = (condition.entity ? hass.states[condition.entity] : undefined)
?.state;
let above = condition.above;
let below = condition.below;
if (!entity) {
return false;
// Handle entity_id, UI should be updated for conditionnal card (filters does not have UI for now)
if (typeof above === "string") {
above = getValueFromEntityId(hass, above) as string;
}
if (typeof below === "string") {
below = getValueFromEntityId(hass, below) as string;
}
const numericState = Number(entity.state);
const numericState = Number(state);
const numericAbove = Number(above);
const numericBelow = Number(below);
if (isNaN(numericState)) {
return false;
}
return (
(condition.above == null || condition.above < numericState) &&
(condition.below == null || condition.below > numericState)
(condition.above == null ||
isNaN(numericAbove) ||
numericAbove < numericState) &&
(condition.below == null ||
isNaN(numericBelow) ||
numericBelow > numericState)
);
}
@ -109,6 +149,12 @@ function checkOrCondition(condition: OrCondition, hass: HomeAssistant) {
return condition.conditions.some((c) => checkConditionsMet([c], hass));
}
/**
* Return the result of applying conditions
* @param conditions conditions to apply
* @param hass Home Assistant object
* @returns true if conditions are respected
*/
export function checkConditionsMet(
conditions: (Condition | LegacyCondition)[],
hass: HomeAssistant
@ -134,6 +180,43 @@ export function checkConditionsMet(
});
}
export function extractConditionEntityIds(
conditions: Condition[]
): Set<string> {
const entityIds: Set<string> = new Set();
for (const condition of conditions) {
if (condition.condition === "numeric_state") {
if (
typeof condition.above === "string" &&
isValidEntityId(condition.above)
) {
entityIds.add(condition.above);
}
if (
typeof condition.below === "string" &&
isValidEntityId(condition.below)
) {
entityIds.add(condition.below);
}
} else if (condition.condition === "state") {
[
...(ensureArray(condition.state) ?? []),
...(ensureArray(condition.state_not) ?? []),
].forEach((state) => {
if (!!state && isValidEntityId(state)) {
entityIds.add(state);
}
});
} else if ("conditions" in condition && condition.conditions) {
return new Set([
...entityIds,
...extractConditionEntityIds(condition.conditions),
]);
}
}
return entityIds;
}
function validateStateCondition(condition: StateCondition | LegacyCondition) {
return (
condition.entity != null &&
@ -163,7 +246,11 @@ function validateNumericStateCondition(condition: NumericStateCondition) {
(condition.above != null || condition.below != null)
);
}
/**
* Validate the conditions config for the UI
* @param conditions conditions to apply
* @returns true if conditions are validated
*/
export function validateConditionalConfig(
conditions: (Condition | LegacyCondition)[]
): boolean {
@ -187,3 +274,34 @@ export function validateConditionalConfig(
return validateStateCondition(c);
});
}
/**
* Build a condition for filters
* @param condition condition to apply
* @param entityId base the condition on that entity
* @returns a new condition with entity id
*/
export function addEntityToCondition(
condition: Condition,
entityId: string
): Condition {
if ("conditions" in condition && condition.conditions) {
return {
...condition,
conditions: condition.conditions.map((c) =>
addEntityToCondition(c, entityId)
),
};
}
if (
condition.condition === "state" ||
condition.condition === "numeric_state"
) {
return {
...condition,
entity: entityId,
};
}
return condition;
}

View File

@ -1,7 +1,8 @@
import { ActionConfig } from "../../../data/lovelace/config/action";
import { HomeAssistant } from "../../../types";
import { Condition } from "../common/validate-condition";
import { TimestampRenderingFormat } from "../components/types";
import type { ActionConfig } from "../../../data/lovelace/config/action";
import type { HomeAssistant } from "../../../types";
import type { LegacyStateFilter } from "../common/evaluate-filter";
import type { Condition } from "../common/validate-condition";
import type { TimestampRenderingFormat } from "../components/types";
export interface EntityConfig {
entity: string;
@ -14,7 +15,8 @@ export interface ActionRowConfig extends EntityConfig {
action_name?: string;
}
export interface EntityFilterEntityConfig extends EntityConfig {
state_filter?: Array<{ key: string } | string>;
state_filter?: Array<LegacyStateFilter>;
conditions?: Array<Condition>;
}
export interface DividerConfig {
type: "divider";