Add name and sensor states to area card

This commit is contained in:
Paul Bottein 2025-05-22 18:51:17 +02:00
parent ebb98bd196
commit c67024f36f
No known key found for this signature in database
2 changed files with 259 additions and 605 deletions

View File

@ -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<LovelaceCardEditor> {
await import("../editor/config-elements/hui-area-card-editor");
return document.createElement("hui-area-card-editor");
}
public static async getStubConfig(
hass: HomeAssistant
): Promise<AreaCardConfig> {
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<string, string[]> = DEVICE_CLASSES;
private _ratio: {
w: number;
h: number;
} | null = null;
private _entitiesByDomain = memoizeOne(
(
areaId: string,
devicesInArea: Set<string>,
registryEntities: EntityRegistryEntry[],
deviceClasses: Record<string, string[]>,
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<string, HassEntity[]> = {};
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<LovelaceCardEditor> {
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<AreaCardConfig> {
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<string, string[]> => {
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<string, string[]>());
}
);
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`
<hui-warning .hass=${this.hass}>
${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`
<div class="sensor">
<ha-domain-icon
.hass=${this.hass}
.domain=${domain}
.deviceClass=${deviceClass}
></ha-domain-icon>
${value}
</div>
`);
}
});
});
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`
<ha-card
class=${imageClass ? "image" : ""}
style=${styleMap({
paddingBottom:
ignoreAspectRatio || imageClass
? "0"
: `${((100 * this._ratio!.h) / this._ratio!.w).toFixed(2)}%`,
})}
>
${area.picture || cameraEntityId
? html`
<hui-image
.config=${this._config}
.hass=${this.hass}
.image=${area.picture ? area.picture : undefined}
.cameraImage=${cameraEntityId}
.cameraView=${this._config.camera_view}
.aspectRatio=${ignoreAspectRatio
? undefined
: this._config.aspect_ratio || DEFAULT_ASPECT_RATIO}
fitMode="cover"
></hui-image>
`
: area.icon
? html`
<div class="icon-container">
<ha-icon icon=${area.icon}></ha-icon>
</div>
`
: nothing}
<ha-card>
<div
class="container ${classMap({
navigate: this._config.navigation_path !== undefined,
})}"
@click=${this._handleNavigation}
class="background"
@action=${this._handleAction}
.actionHandler=${actionHandler()}
role=${ifDefined(this._hasCardAction ? "button" : undefined)}
tabindex=${ifDefined(this._hasCardAction ? "0" : undefined)}
aria-labelledby="info"
>
<div class="alerts">
${ALERT_DOMAINS.map((domain) => {
if (!(domain in entitiesByDomain)) {
return nothing;
}
return this._deviceClasses[domain].map((deviceClass) => {
const entity = this._isOn(domain, deviceClass);
return entity
? html`
<ha-state-icon
class="alert"
.hass=${this.hass}
.stateObj=${entity}
></ha-state-icon>
`
: nothing;
});
})}
</div>
<div class="bottom">
<div>
<div class="name">${area.name}</div>
${sensors.length
? html`<div class="sensors">${sensors}</div>`
: ""}
</div>
<div class="buttons">
${TOGGLE_DOMAINS.map((domain) => {
if (!(domain in entitiesByDomain)) {
return "";
}
const on = this._isOn(domain)!;
return TOGGLE_DOMAINS.includes(domain)
? html`
<ha-icon-button
class=${on ? "on" : "off"}
.path=${DOMAIN_ICONS[domain][on ? "on" : "off"]}
.domain=${domain}
@click=${this._toggle}
>
</ha-icon-button>
`
: "";
})}
</div>
<ha-ripple .disabled=${!this._hasCardAction}></ha-ripple>
</div>
<div class="container">
<div class="content">
<ha-tile-icon>
${icon
? html`<ha-icon slot="icon" .icon=${icon}></ha-icon>`
: html`
<ha-svg-icon
slot="icon"
.path=${mdiTextureBox}
></ha-svg-icon>
`}
</ha-tile-icon>
<ha-tile-info
id="info"
.primary=${primary}
.secondary=${secondary}
></ha-tile-info>
</div>
</div>
</ha-card>
`;
}
protected updated(changedProps: PropertyValues): void {
super.updated(changedProps);
if (!this._config || !this.hass) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
const oldConfig = changedProps.get("_config") as AreaCardConfig | undefined;
if (
(changedProps.has("hass") &&
(!oldHass || oldHass.themes !== this.hass.themes)) ||
(changedProps.has("_config") &&
(!oldConfig || oldConfig.theme !== this._config.theme))
) {
applyThemesOnElement(this, this.hass.themes, this._config.theme);
}
}
private _handleNavigation() {
if (this._config!.navigation_path) {
navigate(this._config!.navigation_path);
}
}
private _toggle(ev: Event) {
ev.stopPropagation();
const domain = (ev.currentTarget as any).domain as string;
if (TOGGLE_DOMAINS.includes(domain)) {
this.hass.callService(
domain,
this._isOn(domain) ? "turn_off" : "turn_on",
undefined,
{
area_id: this._config!.area,
}
);
}
forwardHaptic("light");
}
getGridOptions(): LovelaceGridOptions {
return {
columns: 12,
rows: 3,
min_columns: 3,
};
}
static styles = css`
ha-card {
overflow: hidden;
position: relative;
background-size: cover;
height: 100%;
:host {
--tile-color: var(--state-icon-color);
-webkit-tap-highlight-color: transparent;
}
.container {
ha-card:has(.background:focus-visible) {
--shadow-default: var(--ha-card-box-shadow, 0 0 0 0 transparent);
--shadow-focus: 0 0 0 1px var(--tile-color);
border-color: var(--tile-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
ha-card {
--ha-ripple-color: var(--tile-color);
--ha-ripple-hover-opacity: 0.04;
--ha-ripple-pressed-opacity: 0.12;
height: 100%;
transition:
box-shadow 180ms ease-in-out,
border-color 180ms ease-in-out;
display: flex;
flex-direction: column;
justify-content: space-between;
position: absolute;
top: 0;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(
0,
rgba(33, 33, 33, 0.9) 0%,
rgba(33, 33, 33, 0) 45%
);
}
ha-card:not(.image) .container::before {
position: absolute;
content: "";
width: 100%;
height: 100%;
background-color: var(--sidebar-selected-icon-color);
opacity: 0.12;
}
.image hui-image {
height: 100%;
}
.icon-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
}
.icon-container ha-icon {
--mdc-icon-size: 60px;
color: var(--sidebar-selected-icon-color);
}
.sensors {
color: #e3e3e3;
font-size: var(--ha-font-size-l);
--mdc-icon-size: 24px;
opacity: 0.6;
margin-top: 8px;
}
.sensor {
white-space: nowrap;
float: left;
margin-right: 4px;
margin-inline-end: 4px;
margin-inline-start: initial;
}
.alerts {
padding: 16px;
}
ha-state-icon {
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
}
.alerts ha-state-icon {
background: var(--accent-color);
color: var(--text-accent-color, var(--text-primary-color));
padding: 8px;
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
border-radius: 50%;
}
.name {
color: white;
font-size: var(--ha-font-size-2xl);
}
.bottom {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
}
.navigate {
[role="button"] {
cursor: pointer;
pointer-events: auto;
}
[role="button"]:focus {
outline: none;
}
.background {
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
border-radius: var(--ha-card-border-radius, 12px);
margin: calc(-1 * var(--ha-card-border-width, 1px));
overflow: hidden;
}
.container {
margin: calc(-1 * var(--ha-card-border-width, 1px));
display: flex;
flex-direction: column;
flex: 1;
}
.container.horizontal {
flex-direction: row;
}
ha-icon-button {
color: white;
background-color: var(--area-button-color, #727272b2);
border-radius: 50%;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: initial;
--mdc-icon-button-size: 44px;
.content {
position: relative;
display: flex;
flex-direction: row;
align-items: center;
padding: 10px;
flex: 1;
min-width: 0;
box-sizing: border-box;
pointer-events: none;
gap: 10px;
}
.on {
color: var(--state-light-active-color);
.vertical {
flex-direction: column;
text-align: center;
justify-content: center;
}
.vertical ha-tile-info {
width: 100%;
flex: none;
}
ha-tile-icon {
--tile-icon-color: var(--tile-color);
position: relative;
padding: 6px;
margin: -6px;
}
ha-tile-badge {
position: absolute;
top: 3px;
right: 3px;
inset-inline-end: 3px;
inset-inline-start: initial;
}
ha-tile-info {
position: relative;
min-width: 0;
transition: background-color 180ms ease-in-out;
box-sizing: border-box;
}
hui-card-features {
--feature-color: var(--tile-color);
padding: 0 12px 12px 12px;
}
.container.horizontal hui-card-features {
width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
flex: none;
--feature-height: 36px;
padding: 0 12px;
padding-inline-start: 0;
}
`;
}

View File

@ -101,11 +101,13 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
}
export interface AreaCardConfig extends LovelaceCardConfig {
area: string;
area?: string;
navigation_path?: string;
show_camera?: boolean;
camera_view?: HuiImage["cameraView"];
aspect_ratio?: string;
sensor_classes?: string[];
alert_classes?: string[];
}
export interface ButtonCardConfig extends LovelaceCardConfig {