diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index 7fe1749099..eff790b5fe 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -3,11 +3,9 @@ import { mdiLightbulbMultiple, mdiLightbulbMultipleOff, mdiRun, - mdiThermometer, mdiToggleSwitch, mdiToggleSwitchOff, mdiWaterAlert, - mdiWaterPercent, } from "@mdi/js"; import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import { @@ -23,13 +21,16 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { styleMap } from "lit/directives/style-map"; 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 { computeDomain } from "../../../common/entity/compute_domain"; import { binarySensorIcon } from "../../../common/entity/binary_sensor_icon"; import { domainIcon } from "../../../common/entity/domain_icon"; 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 parseAspectRatio from "../../../common/util/parse-aspect-ratio"; import "../../../components/entity/state-badge"; @@ -57,6 +58,7 @@ import "../components/hui-image"; import "../components/hui-warning"; import { LovelaceCard, LovelaceCardEditor } from "../types"; import { AreaCardConfig } from "./types"; +import { blankBeforeUnit } from "../../../common/translations/blank_before_unit"; export const DEFAULT_ASPECT_RATIO = "16:9"; @@ -77,10 +79,6 @@ const DOMAIN_ICONS = { light: { on: mdiLightbulbMultiple, off: mdiLightbulbMultipleOff }, switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff }, fan: { on: domainIcon("fan"), off: domainIcon("fan") }, - sensor: { - temperature: mdiThermometer, - humidity: mdiWaterPercent, - }, binary_sensor: { motion: mdiRun, moisture: mdiWaterAlert, @@ -215,10 +213,7 @@ export class HuiAreaCard } let uom; const values = entities.filter((entity) => { - if ( - !entity.attributes.unit_of_measurement || - isNaN(Number(entity.state)) - ) { + if (!isNumericState(entity) || isNaN(Number(entity.state))) { return false; } if (!uom) { @@ -236,7 +231,7 @@ export class HuiAreaCard ); return `${formatNumber(sum / values.length, this.hass!.locale, { maximumFractionDigits: 1, - })} ${uom}`; + })}${uom ? blankBeforeUnit(uom, this.hass!.locale) : ""}${uom || ""}`; } private _area = memoizeOne( @@ -281,6 +276,9 @@ export class HuiAreaCard this._config = config; this._deviceClasses = { ...DEVICE_CLASSES }; + if (config.sensor_classes) { + this._deviceClasses.sensor = config.sensor_classes; + } if (config.alert_classes) { this._deviceClasses.binary_sensor = config.alert_classes; } @@ -385,20 +383,19 @@ export class HuiAreaCard if (!(domain in entitiesByDomain)) { return; } - DEVICE_CLASSES[domain].forEach((deviceClass) => { + this._deviceClasses[domain].forEach((deviceClass) => { if ( entitiesByDomain[domain].some( (entity) => entity.attributes.device_class === deviceClass ) ) { - sensors.push(html` - ${DOMAIN_ICONS[domain][deviceClass] - ? html`` - : ""} - ${this._average(domain, deviceClass)} - `); + const icon = FIXED_DEVICE_CLASS_ICONS[deviceClass]; + sensors.push( + html`
+ ${icon ? html`` : ""} + ${this._average(domain, deviceClass)} +
` + ); } }); }); @@ -566,6 +563,12 @@ export class HuiAreaCard margin-top: 8px; } + .sensor { + white-space: nowrap; + float: left; + margin-right: 4px; + } + .alerts { padding: 16px; } diff --git a/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts index c49e9bf99e..f46f279b45 100644 --- a/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts @@ -24,6 +24,7 @@ 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"; +import { getSensorNumericDeviceClasses } from "../../../../data/sensor"; const cardConfigStruct = assign( baseLovelaceCardConfig, @@ -35,6 +36,7 @@ const cardConfigStruct = assign( camera_view: optional(string()), aspect_ratio: optional(string()), alert_classes: optional(array(string())), + sensor_classes: optional(array(string())), }) ); @@ -47,8 +49,14 @@ export class HuiAreaCardEditor @state() private _config?: AreaCardConfig; + @state() private _numericDeviceClasses?: string[]; + private _schema = memoizeOne( - (showCamera: boolean, binaryClasses: SelectOption[]) => + ( + showCamera: boolean, + binaryClasses: SelectOption[], + sensorClasses: SelectOption[] + ) => [ { name: "area", selector: { area: {} } }, { 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 ); - 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( (e) => - computeDomain(e.entity_id) === "binary_sensor" && + computeDomain(e.entity_id) === domain && !e.entity_category && !e.hidden && (e.area_id === area || @@ -103,58 +135,92 @@ export class HuiAreaCardEditor const classes = entities .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)]; - }); + } - 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; - } + private _buildBinaryOptions = memoizeOne( + (possibleClasses: string[], currentClasses: string[]): SelectOption[] => + this._buildOptions("binary_sensor", possibleClasses, currentClasses) ); + 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 { assert(config, cardConfigStruct); 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() { if (!this.hass || !this._config) { return nothing; } - const possibleClasses = this._binaryClassesForArea(this._config.area || ""); - const selectOptions = this._buildOptions( - possibleClasses, + const possibleBinaryClasses = this._binaryClassesForArea( + this._config.area || "" + ); + const possibleSensorClasses = this._sensorClassesForArea( + this._config.area || "", + this._numericDeviceClasses + ); + const binarySelectOptions = this._buildBinaryOptions( + possibleBinaryClasses, this._config.alert_classes || DEVICE_CLASSES.binary_sensor ); + const sensorSelectOptions = this._buildSensorOptions( + possibleSensorClasses, + this._config.sensor_classes || DEVICE_CLASSES.sensor + ); const schema = this._schema( this._config.show_camera || false, - selectOptions + binarySelectOptions, + sensorSelectOptions ); const data = { camera_view: "auto", alert_classes: DEVICE_CLASSES.binary_sensor, + sensor_classes: DEVICE_CLASSES.sensor, ...this._config, }; @@ -201,10 +267,6 @@ export class HuiAreaCardEditor return this.hass!.localize( "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( `ui.panel.lovelace.editor.card.area.${schema.name}` diff --git a/src/translations/en.json b/src/translations/en.json index c7254e73c9..df0409ca76 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5133,6 +5133,7 @@ "area": { "name": "Area", "alert_classes": "Alert Classes", + "sensor_classes": "Sensor Classes", "description": "The Area card automatically displays entities of a specific area.", "show_camera": "Show camera feed instead of area picture" },