Custom device_classes for Area Card sensors (#19421)

* Custom device_classes for Area Card sensors

* no sensor DOMAIN_ICONS
This commit is contained in:
karwosts 2024-01-17 04:22:08 -08:00 committed by GitHub
parent 58c4bf081b
commit 7d4284d409
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 122 additions and 56 deletions

View File

@ -3,11 +3,9 @@ import {
mdiLightbulbMultiple, mdiLightbulbMultiple,
mdiLightbulbMultipleOff, mdiLightbulbMultipleOff,
mdiRun, mdiRun,
mdiThermometer,
mdiToggleSwitch, mdiToggleSwitch,
mdiToggleSwitchOff, mdiToggleSwitchOff,
mdiWaterAlert, mdiWaterAlert,
mdiWaterPercent,
} from "@mdi/js"; } from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
@ -23,13 +21,16 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { STATES_OFF } from "../../../common/const"; import { STATES_OFF, FIXED_DEVICE_CLASS_ICONS } 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 { 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,
isNumericState,
} from "../../../common/number/format_number";
import { subscribeOne } from "../../../common/util/subscribe-one"; import { subscribeOne } from "../../../common/util/subscribe-one";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
import "../../../components/entity/state-badge"; import "../../../components/entity/state-badge";
@ -57,6 +58,7 @@ import "../components/hui-image";
import "../components/hui-warning"; import "../components/hui-warning";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { AreaCardConfig } from "./types"; import { AreaCardConfig } from "./types";
import { blankBeforeUnit } from "../../../common/translations/blank_before_unit";
export const DEFAULT_ASPECT_RATIO = "16:9"; export const DEFAULT_ASPECT_RATIO = "16:9";
@ -77,10 +79,6 @@ const DOMAIN_ICONS = {
light: { on: mdiLightbulbMultiple, off: mdiLightbulbMultipleOff }, light: { on: mdiLightbulbMultiple, off: mdiLightbulbMultipleOff },
switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff }, switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff },
fan: { on: domainIcon("fan"), off: domainIcon("fan") }, fan: { on: domainIcon("fan"), off: domainIcon("fan") },
sensor: {
temperature: mdiThermometer,
humidity: mdiWaterPercent,
},
binary_sensor: { binary_sensor: {
motion: mdiRun, motion: mdiRun,
moisture: mdiWaterAlert, moisture: mdiWaterAlert,
@ -215,10 +213,7 @@ export class HuiAreaCard
} }
let uom; let uom;
const values = entities.filter((entity) => { const values = entities.filter((entity) => {
if ( if (!isNumericState(entity) || isNaN(Number(entity.state))) {
!entity.attributes.unit_of_measurement ||
isNaN(Number(entity.state))
) {
return false; return false;
} }
if (!uom) { if (!uom) {
@ -236,7 +231,7 @@ export class HuiAreaCard
); );
return `${formatNumber(sum / values.length, this.hass!.locale, { return `${formatNumber(sum / values.length, this.hass!.locale, {
maximumFractionDigits: 1, maximumFractionDigits: 1,
})} ${uom}`; })}${uom ? blankBeforeUnit(uom, this.hass!.locale) : ""}${uom || ""}`;
} }
private _area = memoizeOne( private _area = memoizeOne(
@ -281,6 +276,9 @@ export class HuiAreaCard
this._config = config; this._config = config;
this._deviceClasses = { ...DEVICE_CLASSES }; this._deviceClasses = { ...DEVICE_CLASSES };
if (config.sensor_classes) {
this._deviceClasses.sensor = config.sensor_classes;
}
if (config.alert_classes) { if (config.alert_classes) {
this._deviceClasses.binary_sensor = config.alert_classes; this._deviceClasses.binary_sensor = config.alert_classes;
} }
@ -385,20 +383,19 @@ export class HuiAreaCard
if (!(domain in entitiesByDomain)) { if (!(domain in entitiesByDomain)) {
return; return;
} }
DEVICE_CLASSES[domain].forEach((deviceClass) => { this._deviceClasses[domain].forEach((deviceClass) => {
if ( if (
entitiesByDomain[domain].some( entitiesByDomain[domain].some(
(entity) => entity.attributes.device_class === deviceClass (entity) => entity.attributes.device_class === deviceClass
) )
) { ) {
sensors.push(html` const icon = FIXED_DEVICE_CLASS_ICONS[deviceClass];
${DOMAIN_ICONS[domain][deviceClass] sensors.push(
? html`<ha-svg-icon html`<div class="sensor">
.path=${DOMAIN_ICONS[domain][deviceClass]} ${icon ? html`<ha-svg-icon .path=${icon}></ha-svg-icon>` : ""}
></ha-svg-icon>` ${this._average(domain, deviceClass)}
: ""} </div> `
${this._average(domain, deviceClass)} );
`);
} }
}); });
}); });
@ -566,6 +563,12 @@ export class HuiAreaCard
margin-top: 8px; margin-top: 8px;
} }
.sensor {
white-space: nowrap;
float: left;
margin-right: 4px;
}
.alerts { .alerts {
padding: 16px; padding: 16px;
} }

View File

@ -24,6 +24,7 @@ import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
import { caseInsensitiveStringCompare } from "../../../../common/string/compare"; import { caseInsensitiveStringCompare } from "../../../../common/string/compare";
import { SelectOption } from "../../../../data/selector"; import { SelectOption } from "../../../../data/selector";
import { getSensorNumericDeviceClasses } from "../../../../data/sensor";
const cardConfigStruct = assign( const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
@ -35,6 +36,7 @@ const cardConfigStruct = assign(
camera_view: optional(string()), camera_view: optional(string()),
aspect_ratio: optional(string()), aspect_ratio: optional(string()),
alert_classes: optional(array(string())), alert_classes: optional(array(string())),
sensor_classes: optional(array(string())),
}) })
); );
@ -47,8 +49,14 @@ export class HuiAreaCardEditor
@state() private _config?: AreaCardConfig; @state() private _config?: AreaCardConfig;
@state() private _numericDeviceClasses?: string[];
private _schema = memoizeOne( private _schema = memoizeOne(
(showCamera: boolean, binaryClasses: SelectOption[]) => (
showCamera: boolean,
binaryClasses: SelectOption[],
sensorClasses: SelectOption[]
) =>
[ [
{ name: "area", selector: { area: {} } }, { name: "area", selector: { area: {} } },
{ name: "show_camera", required: false, selector: { boolean: {} } }, { name: "show_camera", required: false, selector: { boolean: {} } },
@ -88,13 +96,37 @@ export class HuiAreaCardEditor
}, },
}, },
}, },
{
name: "sensor_classes",
selector: {
select: {
reorder: true,
multiple: true,
custom_value: true,
options: sensorClasses,
},
},
},
] as const ] as const
); );
private _binaryClassesForArea = memoizeOne((area: string): string[] => { private _binaryClassesForArea = memoizeOne((area: string): string[] =>
this._classesForArea(area, "binary_sensor")
);
private _sensorClassesForArea = memoizeOne(
(area: string, numericDeviceClasses?: string[]): string[] =>
this._classesForArea(area, "sensor", numericDeviceClasses)
);
private _classesForArea(
area: string,
domain: "sensor" | "binary_sensor",
numericDeviceClasses?: string[] | undefined
): string[] {
const entities = Object.values(this.hass!.entities).filter( const entities = Object.values(this.hass!.entities).filter(
(e) => (e) =>
computeDomain(e.entity_id) === "binary_sensor" && computeDomain(e.entity_id) === domain &&
!e.entity_category && !e.entity_category &&
!e.hidden && !e.hidden &&
(e.area_id === area || (e.area_id === area ||
@ -103,58 +135,92 @@ export class HuiAreaCardEditor
const classes = entities const classes = entities
.map((e) => this.hass!.states[e.entity_id]?.attributes.device_class || "") .map((e) => this.hass!.states[e.entity_id]?.attributes.device_class || "")
.filter((c) => c); .filter(
(c) =>
c &&
(domain !== "sensor" ||
!numericDeviceClasses ||
numericDeviceClasses.includes(c))
);
return [...new Set(classes)]; return [...new Set(classes)];
}); }
private _buildOptions = memoizeOne( private _buildBinaryOptions = memoizeOne(
(possibleClasses: string[], currentClasses: string[]): SelectOption[] => { (possibleClasses: string[], currentClasses: string[]): SelectOption[] =>
const options = [...new Set([...possibleClasses, ...currentClasses])].map( this._buildOptions("binary_sensor", possibleClasses, currentClasses)
(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;
}
); );
private _buildSensorOptions = memoizeOne(
(possibleClasses: string[], currentClasses: string[]): SelectOption[] =>
this._buildOptions("sensor", possibleClasses, currentClasses)
);
private _buildOptions(
domain: "sensor" | "binary_sensor",
possibleClasses: string[],
currentClasses: string[]
): SelectOption[] {
const options = [...new Set([...possibleClasses, ...currentClasses])].map(
(deviceClass) => ({
value: deviceClass,
label:
this.hass!.localize(
`component.${domain}.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;
} }
protected async updated() {
if (this.hass && !this._numericDeviceClasses) {
const { numeric_device_classes: sensorNumericDeviceClasses } =
await getSensorNumericDeviceClasses(this.hass);
this._numericDeviceClasses = sensorNumericDeviceClasses;
}
}
protected render() { protected render() {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
return nothing; return nothing;
} }
const possibleClasses = this._binaryClassesForArea(this._config.area || ""); const possibleBinaryClasses = this._binaryClassesForArea(
const selectOptions = this._buildOptions( this._config.area || ""
possibleClasses, );
const possibleSensorClasses = this._sensorClassesForArea(
this._config.area || "",
this._numericDeviceClasses
);
const binarySelectOptions = this._buildBinaryOptions(
possibleBinaryClasses,
this._config.alert_classes || DEVICE_CLASSES.binary_sensor this._config.alert_classes || DEVICE_CLASSES.binary_sensor
); );
const sensorSelectOptions = this._buildSensorOptions(
possibleSensorClasses,
this._config.sensor_classes || DEVICE_CLASSES.sensor
);
const schema = this._schema( const schema = this._schema(
this._config.show_camera || false, this._config.show_camera || false,
selectOptions binarySelectOptions,
sensorSelectOptions
); );
const data = { const data = {
camera_view: "auto", camera_view: "auto",
alert_classes: DEVICE_CLASSES.binary_sensor, alert_classes: DEVICE_CLASSES.binary_sensor,
sensor_classes: DEVICE_CLASSES.sensor,
...this._config, ...this._config,
}; };
@ -201,10 +267,6 @@ 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

@ -5133,6 +5133,7 @@
"area": { "area": {
"name": "Area", "name": "Area",
"alert_classes": "Alert Classes", "alert_classes": "Alert Classes",
"sensor_classes": "Sensor 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"
}, },