Custom device_classes for alert icons in Area Card (#19131)

* Custom device_classes for alerts in Area Card

* small refactor

* drop more-info feature

* Update src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* localization and css updates

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
karwosts 2024-01-15 06:51:19 -08:00 committed by GitHub
parent 2053cf23c2
commit 9f26bedf51
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 114 additions and 19 deletions

View File

@ -26,6 +26,7 @@ import memoizeOne from "memoize-one";
import { STATES_OFF } from "../../../common/const"; import { STATES_OFF } from "../../../common/const";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { binarySensorIcon } from "../../../common/entity/binary_sensor_icon";
import { domainIcon } from "../../../common/entity/domain_icon"; import { domainIcon } from "../../../common/entity/domain_icon";
import { navigate } from "../../../common/navigate"; import { navigate } from "../../../common/navigate";
import { formatNumber } from "../../../common/number/format_number"; import { formatNumber } from "../../../common/number/format_number";
@ -67,7 +68,7 @@ const TOGGLE_DOMAINS = ["light", "switch", "fan"];
const OTHER_DOMAINS = ["camera"]; const OTHER_DOMAINS = ["camera"];
const DEVICE_CLASSES = { export const DEVICE_CLASSES = {
sensor: ["temperature", "humidity"], sensor: ["temperature", "humidity"],
binary_sensor: ["motion", "moisture"], binary_sensor: ["motion", "moisture"],
}; };
@ -113,6 +114,8 @@ export class HuiAreaCard
@state() private _areas?: AreaRegistryEntry[]; @state() private _areas?: AreaRegistryEntry[];
private _deviceClasses: { [key: string]: string[] } = DEVICE_CLASSES;
private _ratio: { private _ratio: {
w: number; w: number;
h: number; h: number;
@ -123,6 +126,7 @@ export class HuiAreaCard
areaId: string, areaId: string,
devicesInArea: Set<string>, devicesInArea: Set<string>,
registryEntities: EntityRegistryEntry[], registryEntities: EntityRegistryEntry[],
deviceClasses: { [key: string]: string[] },
states: HomeAssistant["states"] states: HomeAssistant["states"]
) => { ) => {
const entitiesInArea = registryEntities const entitiesInArea = registryEntities
@ -156,7 +160,7 @@ export class HuiAreaCard
if ( if (
(SENSOR_DOMAINS.includes(domain) || ALERT_DOMAINS.includes(domain)) && (SENSOR_DOMAINS.includes(domain) || ALERT_DOMAINS.includes(domain)) &&
!DEVICE_CLASSES[domain].includes( !deviceClasses[domain].includes(
stateObj.attributes.device_class || "" stateObj.attributes.device_class || ""
) )
) { ) {
@ -173,11 +177,12 @@ export class HuiAreaCard
} }
); );
private _isOn(domain: string, deviceClass?: string): boolean | undefined { private _isOn(domain: string, deviceClass?: string): HassEntity | undefined {
const entities = this._entitiesByDomain( const entities = this._entitiesByDomain(
this._config!.area, this._config!.area,
this._devicesInArea(this._config!.area, this._devices!), this._devicesInArea(this._config!.area, this._devices!),
this._entities!, this._entities!,
this._deviceClasses,
this.hass.states this.hass.states
)[domain]; )[domain];
if (!entities) { if (!entities) {
@ -189,7 +194,7 @@ export class HuiAreaCard
(entity) => entity.attributes.device_class === deviceClass (entity) => entity.attributes.device_class === deviceClass
) )
: entities : entities
).some( ).find(
(entity) => (entity) =>
!isUnavailableState(entity.state) && !STATES_OFF.includes(entity.state) !isUnavailableState(entity.state) && !STATES_OFF.includes(entity.state)
); );
@ -200,6 +205,7 @@ export class HuiAreaCard
this._config!.area, this._config!.area,
this._devicesInArea(this._config!.area, this._devices!), this._devicesInArea(this._config!.area, this._devices!),
this._entities!, this._entities!,
this._deviceClasses,
this.hass.states this.hass.states
)[domain].filter((entity) => )[domain].filter((entity) =>
deviceClass ? entity.attributes.device_class === deviceClass : true deviceClass ? entity.attributes.device_class === deviceClass : true
@ -273,6 +279,11 @@ export class HuiAreaCard
} }
this._config = config; this._config = config;
this._deviceClasses = { ...DEVICE_CLASSES };
if (config.alert_classes) {
this._deviceClasses.binary_sensor = config.alert_classes;
}
} }
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
@ -314,6 +325,7 @@ export class HuiAreaCard
this._config.area, this._config.area,
this._devicesInArea(this._config.area, this._devices), this._devicesInArea(this._config.area, this._devices),
this._entities, this._entities,
this._deviceClasses,
this.hass.states this.hass.states
); );
@ -355,6 +367,7 @@ export class HuiAreaCard
this._config.area, this._config.area,
this._devicesInArea(this._config.area, this._devices), this._devicesInArea(this._config.area, this._devices),
this._entities, this._entities,
this._deviceClasses,
this.hass.states this.hass.states
); );
const area = this._area(this._config.area, this._areas); const area = this._area(this._config.area, this._areas);
@ -427,17 +440,16 @@ export class HuiAreaCard
if (!(domain in entitiesByDomain)) { if (!(domain in entitiesByDomain)) {
return ""; return "";
} }
return DEVICE_CLASSES[domain].map((deviceClass) => return this._deviceClasses[domain].map((deviceClass) => {
this._isOn(domain, deviceClass) const entity = this._isOn(domain, deviceClass);
? html` return entity
${DOMAIN_ICONS[domain][deviceClass] ? html`<ha-svg-icon
? html`<ha-svg-icon class="alert"
.path=${DOMAIN_ICONS[domain][deviceClass]} .path=${DOMAIN_ICONS[domain][deviceClass] ||
></ha-svg-icon>` binarySensorIcon(entity.state, entity)}
: ""} ></ha-svg-icon>`
` : nothing;
: "" });
);
})} })}
</div> </div>
<div class="bottom"> <div class="bottom">
@ -562,6 +574,7 @@ export class HuiAreaCard
background: var(--accent-color); background: var(--accent-color);
color: var(--text-accent-color, var(--text-primary-color)); color: var(--text-accent-color, var(--text-primary-color));
padding: 8px; padding: 8px;
margin-right: 8px;
border-radius: 50%; border-radius: 50%;
} }

View File

@ -1,15 +1,29 @@
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { assert, assign, boolean, object, optional, string } from "superstruct"; import {
assert,
array,
assign,
boolean,
object,
optional,
string,
} from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form"; import "../../../../components/ha-form/ha-form";
import { DEFAULT_ASPECT_RATIO } from "../../cards/hui-area-card"; import {
DEFAULT_ASPECT_RATIO,
DEVICE_CLASSES,
} from "../../cards/hui-area-card";
import type { SchemaUnion } from "../../../../components/ha-form/types"; import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import type { AreaCardConfig } from "../../cards/types"; import type { AreaCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types"; import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import { SelectOption } from "../../../../data/selector";
const cardConfigStruct = assign( const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
@ -20,8 +34,10 @@ const cardConfigStruct = assign(
show_camera: optional(boolean()), show_camera: optional(boolean()),
camera_view: optional(string()), camera_view: optional(string()),
aspect_ratio: optional(string()), aspect_ratio: optional(string()),
alert_classes: optional(array(string())),
}) })
); );
@customElement("hui-area-card-editor") @customElement("hui-area-card-editor")
export class HuiAreaCardEditor export class HuiAreaCardEditor
extends LitElement extends LitElement
@ -32,7 +48,7 @@ export class HuiAreaCardEditor
@state() private _config?: AreaCardConfig; @state() private _config?: AreaCardConfig;
private _schema = memoizeOne( private _schema = memoizeOne(
(showCamera: boolean) => (showCamera: boolean, binaryClasses: SelectOption[]) =>
[ [
{ name: "area", selector: { area: {} } }, { name: "area", selector: { area: {} } },
{ name: "show_camera", required: false, selector: { boolean: {} } }, { name: "show_camera", required: false, selector: { boolean: {} } },
@ -61,9 +77,60 @@ export class HuiAreaCardEditor
}, },
], ],
}, },
{
name: "alert_classes",
selector: {
select: {
reorder: true,
multiple: true,
custom_value: true,
options: binaryClasses,
},
},
},
] as const ] as const
); );
private _binaryClassesForArea = memoizeOne((area: string): string[] => {
const entities = Object.values(this.hass!.entities).filter(
(e) =>
computeDomain(e.entity_id) === "binary_sensor" &&
!e.entity_category &&
!e.hidden &&
(e.area_id === area ||
(e.device_id && this.hass!.devices[e.device_id].area_id === area))
);
const classes = entities
.map((e) => this.hass!.states[e.entity_id]?.attributes.device_class || "")
.filter((c) => c);
return [...new Set(classes)];
});
private _buildOptions = memoizeOne(
(possibleClasses: string[], currentClasses: string[]): SelectOption[] => {
const options = [...new Set([...possibleClasses, ...currentClasses])].map(
(deviceClass) => ({
value: deviceClass,
label:
this.hass!.localize(
`component.binary_sensor.entity_component.${deviceClass}.name`
) || deviceClass,
})
);
options.sort((a, b) =>
caseInsensitiveStringCompare(
a.label,
b.label,
this.hass!.locale.language
)
);
return options;
}
);
public setConfig(config: AreaCardConfig): void { public setConfig(config: AreaCardConfig): void {
assert(config, cardConfigStruct); assert(config, cardConfigStruct);
this._config = config; this._config = config;
@ -74,10 +141,20 @@ export class HuiAreaCardEditor
return nothing; return nothing;
} }
const schema = this._schema(this._config.show_camera || false); const possibleClasses = this._binaryClassesForArea(this._config.area || "");
const selectOptions = this._buildOptions(
possibleClasses,
this._config.alert_classes || DEVICE_CLASSES.binary_sensor
);
const schema = this._schema(
this._config.show_camera || false,
selectOptions
);
const data = { const data = {
camera_view: "auto", camera_view: "auto",
alert_classes: DEVICE_CLASSES.binary_sensor,
...this._config, ...this._config,
}; };
@ -124,6 +201,10 @@ export class HuiAreaCardEditor
return this.hass!.localize( return this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.camera_view" "ui.panel.lovelace.editor.card.generic.camera_view"
); );
case "alert_classes":
return this.hass!.localize(
"ui.panel.lovelace.editor.card.area.alert_classes"
);
} }
return this.hass!.localize( return this.hass!.localize(
`ui.panel.lovelace.editor.card.area.${schema.name}` `ui.panel.lovelace.editor.card.area.${schema.name}`

View File

@ -5132,6 +5132,7 @@
}, },
"area": { "area": {
"name": "Area", "name": "Area",
"alert_classes": "Alert Classes",
"description": "The Area card automatically displays entities of a specific area.", "description": "The Area card automatically displays entities of a specific area.",
"show_camera": "Show camera feed instead of area picture" "show_camera": "Show camera feed instead of area picture"
}, },