diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts index 0b390386b0..e20aca2209 100644 --- a/src/panels/lovelace/cards/hui-area-card.ts +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -1,367 +1,185 @@ -import { - mdiFan, - mdiFanOff, - mdiLightbulbMultiple, - mdiLightbulbMultipleOff, - mdiRun, - mdiToggleSwitch, - mdiToggleSwitchOff, - mdiWaterAlert, -} from "@mdi/js"; -import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, css, html, nothing } from "lit"; +import { mdiTextureBox } from "@mdi/js"; +import { css, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { classMap } from "lit/directives/class-map"; -import { styleMap } from "lit/directives/style-map"; +import { ifDefined } from "lit/directives/if-defined"; import memoizeOne from "memoize-one"; -import { STATES_OFF } from "../../../common/const"; -import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; -import { computeDomain } from "../../../common/entity/compute_domain"; +import { computeAreaName } from "../../../common/entity/compute_area_name"; +import { generateEntityFilter } from "../../../common/entity/entity_filter"; import { navigate } from "../../../common/navigate"; import { formatNumber, isNumericState, } from "../../../common/number/format_number"; import { blankBeforeUnit } from "../../../common/translations/blank_before_unit"; -import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; -import { subscribeOne } from "../../../common/util/subscribe-one"; import "../../../components/ha-card"; -import "../../../components/ha-domain-icon"; -import "../../../components/ha-icon-button"; -import "../../../components/ha-state-icon"; -import type { AreaRegistryEntry } from "../../../data/area_registry"; -import { subscribeAreaRegistry } from "../../../data/area_registry"; -import type { DeviceRegistryEntry } from "../../../data/device_registry"; -import { subscribeDeviceRegistry } from "../../../data/device_registry"; +import "../../../components/ha-control-button"; +import "../../../components/ha-control-button-group"; +import "../../../components/ha-ripple"; +import "../../../components/tile/ha-tile-icon"; +import "../../../components/tile/ha-tile-info"; import { isUnavailableState } from "../../../data/entity"; -import type { EntityRegistryEntry } from "../../../data/entity_registry"; -import { subscribeEntityRegistry } from "../../../data/entity_registry"; -import { forwardHaptic } from "../../../data/haptics"; -import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import type { HomeAssistant } from "../../../types"; -import "../components/hui-image"; -import "../components/hui-warning"; -import type { - LovelaceCard, - LovelaceCardEditor, - LovelaceGridOptions, -} from "../types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import type { LovelaceCard, LovelaceCardEditor } from "../types"; import type { AreaCardConfig } from "./types"; export const DEFAULT_ASPECT_RATIO = "16:9"; -const SENSOR_DOMAINS = ["sensor"]; - -const ALERT_DOMAINS = ["binary_sensor"]; - -const TOGGLE_DOMAINS = ["light", "switch", "fan"]; - -const OTHER_DOMAINS = ["camera"]; - export const DEVICE_CLASSES = { sensor: ["temperature", "humidity"], binary_sensor: ["motion", "moisture"], }; -const DOMAIN_ICONS = { - light: { on: mdiLightbulbMultiple, off: mdiLightbulbMultipleOff }, - switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff }, - fan: { on: mdiFan, off: mdiFanOff }, - binary_sensor: { - motion: mdiRun, - moisture: mdiWaterAlert, - }, -}; - @customElement("hui-area-card") -export class HuiAreaCard - extends SubscribeMixin(LitElement) - implements LovelaceCard -{ - public static async getConfigElement(): Promise { - await import("../editor/config-elements/hui-area-card-editor"); - return document.createElement("hui-area-card-editor"); - } - - public static async getStubConfig( - hass: HomeAssistant - ): Promise { - const areas = await subscribeOne(hass.connection, subscribeAreaRegistry); - return { type: "area", area: areas[0]?.area_id || "" }; - } - +export class HuiAreaCard extends LitElement implements LovelaceCard { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public layout?: string; @state() private _config?: AreaCardConfig; - @state() private _entities?: EntityRegistryEntry[]; - - @state() private _devices?: DeviceRegistryEntry[]; - - @state() private _areas?: AreaRegistryEntry[]; - - private _deviceClasses: Record = DEVICE_CLASSES; - - private _ratio: { - w: number; - h: number; - } | null = null; - - private _entitiesByDomain = memoizeOne( - ( - areaId: string, - devicesInArea: Set, - registryEntities: EntityRegistryEntry[], - deviceClasses: Record, - states: HomeAssistant["states"] - ) => { - const entitiesInArea = registryEntities - .filter( - (entry) => - !entry.entity_category && - !entry.hidden_by && - (entry.area_id - ? entry.area_id === areaId - : entry.device_id && devicesInArea.has(entry.device_id)) - ) - .map((entry) => entry.entity_id); - - const entitiesByDomain: Record = {}; - - for (const entity of entitiesInArea) { - const domain = computeDomain(entity); - if ( - !TOGGLE_DOMAINS.includes(domain) && - !SENSOR_DOMAINS.includes(domain) && - !ALERT_DOMAINS.includes(domain) && - !OTHER_DOMAINS.includes(domain) - ) { - continue; - } - const stateObj: HassEntity | undefined = states[entity]; - - if (!stateObj) { - continue; - } - - if ( - (SENSOR_DOMAINS.includes(domain) || ALERT_DOMAINS.includes(domain)) && - !deviceClasses[domain].includes( - stateObj.attributes.device_class || "" - ) - ) { - continue; - } - - if (!(domain in entitiesByDomain)) { - entitiesByDomain[domain] = []; - } - entitiesByDomain[domain].push(stateObj); - } - - return entitiesByDomain; - } - ); - - private _isOn(domain: string, deviceClass?: string): HassEntity | undefined { - const entities = this._entitiesByDomain( - this._config!.area, - this._devicesInArea(this._config!.area, this._devices!), - this._entities!, - this._deviceClasses, - this.hass.states - )[domain]; - if (!entities) { - return undefined; - } - return ( - deviceClass - ? entities.filter( - (entity) => entity.attributes.device_class === deviceClass - ) - : entities - ).find( - (entity) => - !isUnavailableState(entity.state) && !STATES_OFF.includes(entity.state) - ); - } - - private _average(domain: string, deviceClass?: string): string | undefined { - const entities = this._entitiesByDomain( - this._config!.area, - this._devicesInArea(this._config!.area, this._devices!), - this._entities!, - this._deviceClasses, - this.hass.states - )[domain].filter((entity) => - deviceClass ? entity.attributes.device_class === deviceClass : true - ); - if (!entities) { - return undefined; - } - let uom; - const values = entities.filter((entity) => { - if (!isNumericState(entity) || isNaN(Number(entity.state))) { - return false; - } - if (!uom) { - uom = entity.attributes.unit_of_measurement; - return true; - } - return entity.attributes.unit_of_measurement === uom; - }); - if (!values.length) { - return undefined; - } - const sum = values.reduce( - (total, entity) => total + Number(entity.state), - 0 - ); - return `${formatNumber(sum / values.length, this.hass!.locale, { - maximumFractionDigits: 1, - })}${uom ? blankBeforeUnit(uom, this.hass!.locale) : ""}${uom || ""}`; - } - - private _area = memoizeOne( - (areaId: string | undefined, areas: AreaRegistryEntry[]) => - areas.find((area) => area.area_id === areaId) || null - ); - - private _devicesInArea = memoizeOne( - (areaId: string | undefined, devices: DeviceRegistryEntry[]) => - new Set( - areaId - ? devices - .filter((device) => device.area_id === areaId) - .map((device) => device.id) - : [] - ) - ); - - public hassSubscribe(): UnsubscribeFunc[] { - return [ - subscribeAreaRegistry(this.hass!.connection, (areas) => { - this._areas = areas; - }), - subscribeDeviceRegistry(this.hass!.connection, (devices) => { - this._devices = devices; - }), - subscribeEntityRegistry(this.hass!.connection, (entries) => { - this._entities = entries; - }), - ]; - } - - public getCardSize(): number { - return 3; + public static async getConfigElement(): Promise { + await import("../editor/config-elements/hui-area-card-editor"); + return document.createElement("hui-area-card-editor"); } public setConfig(config: AreaCardConfig): void { - if (!config.area) { - throw new Error("Area Required"); - } - 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; + public static async getStubConfig( + hass: HomeAssistant + ): Promise { + const areas = Object.values(hass.areas); + return { type: "area-legacy", area: areas[0]?.area_id || "" }; + } + + public getCardSize(): number { + return 1; + } + + private get _hasCardAction() { + return this._config?.navigation_path; + } + + private _handleAction() { + if (this._config?.navigation_path) { + navigate(this._config.navigation_path); } } - protected shouldUpdate(changedProps: PropertyValues): boolean { - if (changedProps.has("_config") || !this._config) { - return true; + private _groupedSensorEntityIds = memoizeOne( + ( + entities: HomeAssistant["entities"], + areaId: string, + sensorClasses: string[] + ): Map => { + const sensorFilter = generateEntityFilter(this.hass, { + area: areaId, + entity_category: "none", + domain: "sensor", + device_class: sensorClasses, + }); + const entityIds = Object.keys(entities).filter(sensorFilter); + + // Group entities by device class + return entityIds.reduce((acc, entityId) => { + const stateObj = this.hass.states[entityId]; + const deviceClass = stateObj.attributes.device_class!; + if (!acc.has(deviceClass)) { + acc.set(deviceClass, []); + } + acc.get(deviceClass)!.push(stateObj.entity_id); + return acc; + }, new Map()); + } + ); + + private _computeSensorsDisplay(): string | undefined { + const areaId = this._config?.area; + const area = areaId ? this.hass.areas[areaId] : undefined; + const sensorClasses = this._config?.sensor_classes; + if (!area || !sensorClasses) { + return undefined; } - if ( - changedProps.has("_devicesInArea") || - changedProps.has("_areas") || - changedProps.has("_entities") - ) { - return true; - } - - if (!changedProps.has("hass")) { - return false; - } - - const oldHass = changedProps.get("hass") as HomeAssistant | undefined; - - if ( - !oldHass || - oldHass.themes !== this.hass!.themes || - oldHass.locale !== this.hass!.locale - ) { - return true; - } - - if ( - !this._devices || - !this._devicesInArea(this._config.area, this._devices) || - !this._entities - ) { - return false; - } - - const entities = this._entitiesByDomain( - this._config.area, - this._devicesInArea(this._config.area, this._devices), - this._entities, - this._deviceClasses, - this.hass.states + const groupedEntities = this._groupedSensorEntityIds( + this.hass.entities, + area.area_id, + sensorClasses ); - for (const domainEntities of Object.values(entities)) { - for (const stateObj of domainEntities) { - if (oldHass!.states[stateObj.entity_id] !== stateObj) { - return true; + const sensorStates = sensorClasses + .map((sensorClass) => { + if (sensorClass === "temperature" && area.temperature_entity_id) { + const stateObj = this.hass.states[area.temperature_entity_id]; + return isUnavailableState(stateObj.state) + ? "" + : this.hass.formatEntityState(stateObj); + } + if (sensorClass === "humidity" && area.humidity_entity_id) { + const stateObj = this.hass.states[area.humidity_entity_id]; + return isUnavailableState(stateObj.state) + ? "" + : this.hass.formatEntityState(stateObj); } - } - } - return false; - } + const entityIds = groupedEntities.get(sensorClass); - public willUpdate(changedProps: PropertyValues) { - if (changedProps.has("_config") || this._ratio === null) { - this._ratio = this._config?.aspect_ratio - ? parseAspectRatio(this._config?.aspect_ratio) - : null; + if (!entityIds) { + return undefined; + } - if (this._ratio === null || this._ratio.w <= 0 || this._ratio.h <= 0) { - this._ratio = parseAspectRatio(DEFAULT_ASPECT_RATIO); - } - } + // Ensure all entities have state + const entities = entityIds + .map((entityId) => this.hass.states[entityId]) + .filter(Boolean); + + if (entities.length === 0) { + return undefined; + } + + // Use the first entity's unit_of_measurement for formatting + const uom = entities.find( + (entity) => entity.attributes.unit_of_measurement + )?.attributes.unit_of_measurement; + + // Ensure all entities have the same unit_of_measurement + const validEntities = entities.filter( + (entity) => + entity.attributes.unit_of_measurement === uom && + isNumericState(entity) && + !isNaN(Number(entity.state)) + ); + + if (validEntities.length === 0) { + return undefined; + } + + const value = + validEntities.reduce((acc, entity) => acc + Number(entity.state), 0) / + validEntities.length; + + const formattedAverage = formatNumber(value, this.hass!.locale, { + maximumFractionDigits: 1, + }); + const formattedUnit = uom + ? `${blankBeforeUnit(uom, this.hass!.locale)}${uom}` + : ""; + + return `${formattedAverage}${formattedUnit}`; + }) + .filter(Boolean) + .join(" · "); + + return sensorStates; } protected render() { - if ( - !this._config || - !this.hass || - !this._areas || - !this._devices || - !this._entities - ) { - return nothing; - } + const areaId = this._config?.area; + const area = areaId ? this.hass.areas[areaId] : undefined; - const entitiesByDomain = this._entitiesByDomain( - this._config.area, - this._devicesInArea(this._config.area, this._devices), - this._entities, - this._deviceClasses, - this.hass.states - ); - const area = this._area(this._config.area, this._areas); - - if (area === null) { + if (!area) { return html` ${this.hass.localize("ui.card.area.area_not_found")} @@ -369,315 +187,149 @@ export class HuiAreaCard `; } - const sensors: TemplateResult[] = []; - SENSOR_DOMAINS.forEach((domain) => { - if (!(domain in entitiesByDomain)) { - return; - } - this._deviceClasses[domain].forEach((deviceClass) => { - let areaSensorEntityId: string | null = null; - switch (deviceClass) { - case "temperature": - areaSensorEntityId = area.temperature_entity_id; - break; - case "humidity": - areaSensorEntityId = area.humidity_entity_id; - break; - } - const areaEntity = - areaSensorEntityId && - this.hass.states[areaSensorEntityId] && - !isUnavailableState(this.hass.states[areaSensorEntityId].state) - ? this.hass.states[areaSensorEntityId] - : undefined; - if ( - areaEntity || - entitiesByDomain[domain].some( - (entity) => entity.attributes.device_class === deviceClass - ) - ) { - let value = areaEntity - ? this.hass.formatEntityState(areaEntity) - : this._average(domain, deviceClass); - if (!value) value = "—"; - sensors.push(html` -
- - ${value} -
- `); - } - }); - }); + const icon = area.icon; - let cameraEntityId: string | undefined; - if (this._config.show_camera && "camera" in entitiesByDomain) { - cameraEntityId = entitiesByDomain.camera[0].entity_id; - } + const name = computeAreaName(area); - const imageClass = area.picture || cameraEntityId; - - const ignoreAspectRatio = this.layout === "grid"; + const primary = name; + const secondary = this._computeSensorsDisplay(); return html` - - ${area.picture || cameraEntityId - ? html` - - ` - : area.icon - ? html` -
- -
- ` - : nothing} - +