Group entities in area card by domain (#10767)

* Group entities in area card by domain

* Update hui-area-card.ts

* Update

* Add background color when no image

* Add camera support

* exclude unavailable states

* Update hui-area-card.ts
This commit is contained in:
Bram Kragten 2021-12-02 23:15:18 +01:00 committed by GitHub
parent 60ce805b3b
commit 48d12ceafe
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

View File

@ -1,4 +1,12 @@
import "@material/mwc-ripple"; import "@material/mwc-ripple";
import {
mdiLightbulbMultiple,
mdiLightbulbMultipleOff,
mdiRun,
mdiToggleSwitch,
mdiToggleSwitchOff,
mdiWaterPercent,
} from "@mdi/js";
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
import { import {
css, css,
@ -10,13 +18,13 @@ import {
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; 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 memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
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 { fireEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain"; import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDisplay } from "../../../common/entity/compute_state_display"; 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 "../../../components/entity/state-badge"; import "../../../components/entity/state-badge";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
@ -30,31 +38,40 @@ import {
DeviceRegistryEntry, DeviceRegistryEntry,
subscribeDeviceRegistry, subscribeDeviceRegistry,
} from "../../../data/device_registry"; } from "../../../data/device_registry";
import { UNAVAILABLE_STATES } from "../../../data/entity";
import { import {
EntityRegistryEntry, EntityRegistryEntry,
subscribeEntityRegistry, subscribeEntityRegistry,
} from "../../../data/entity_registry"; } from "../../../data/entity_registry";
import { forwardHaptic } from "../../../data/haptics"; import { forwardHaptic } from "../../../data/haptics";
import { ActionHandlerEvent } from "../../../data/lovelace";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive";
import { toggleEntity } from "../common/entity/toggle-entity";
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";
const SENSOR_DOMAINS = new Set(["sensor", "binary_sensor"]); const SENSOR_DOMAINS = ["sensor"];
const SENSOR_DEVICE_CLASSES = new Set([ const ALERT_DOMAINS = ["binary_sensor"];
"temperature",
"humidity",
"motion",
"door",
"aqi",
]);
const TOGGLE_DOMAINS = new Set(["light", "fan", "switch"]); const TOGGLE_DOMAINS = ["light", "switch", "fan"];
const OTHER_DOMAINS = ["camera"];
const DEVICE_CLASSES = {
sensor: ["temperature"],
binary_sensor: ["motion"],
};
const DOMAIN_ICONS = {
light: { on: mdiLightbulbMultiple, off: mdiLightbulbMultipleOff },
switch: { on: mdiToggleSwitch, off: mdiToggleSwitchOff },
fan: { on: domainIcon("fan"), off: domainIcon("fan") },
sensor: { humidity: mdiWaterPercent },
binary_sensor: {
motion: mdiRun,
},
};
@customElement("hui-area-card") @customElement("hui-area-card")
export class HuiAreaCard export class HuiAreaCard
@ -80,7 +97,7 @@ export class HuiAreaCard
@state() private _areas?: AreaRegistryEntry[]; @state() private _areas?: AreaRegistryEntry[];
private _memberships = memoizeOne( private _entitiesByDomain = memoizeOne(
( (
areaId: string, areaId: string,
devicesInArea: Set<string>, devicesInArea: Set<string>,
@ -97,44 +114,98 @@ export class HuiAreaCard
) )
.map((entry) => entry.entity_id); .map((entry) => entry.entity_id);
const sensorEntities: HassEntity[] = []; const entitiesByDomain: { [domain: string]: HassEntity[] } = {};
const entitiesToggle: HassEntity[] = [];
for (const entity of entitiesInArea) { for (const entity of entitiesInArea) {
const domain = computeDomain(entity); const domain = computeDomain(entity);
if (!TOGGLE_DOMAINS.has(domain) && !SENSOR_DOMAINS.has(domain)) { if (
!TOGGLE_DOMAINS.includes(domain) &&
!SENSOR_DOMAINS.includes(domain) &&
!ALERT_DOMAINS.includes(domain) &&
!OTHER_DOMAINS.includes(domain)
) {
continue; continue;
} }
const stateObj: HassEntity | undefined = states[entity]; const stateObj: HassEntity | undefined = states[entity];
if (!stateObj) { if (!stateObj) {
continue; continue;
} }
if (entitiesToggle.length < 3 && TOGGLE_DOMAINS.has(domain)) { if (
entitiesToggle.push(stateObj); (SENSOR_DOMAINS.includes(domain) || ALERT_DOMAINS.includes(domain)) &&
!DEVICE_CLASSES[domain].includes(
stateObj.attributes.device_class || ""
)
) {
continue; continue;
} }
if ( if (!(domain in entitiesByDomain)) {
sensorEntities.length < 3 && entitiesByDomain[domain] = [];
SENSOR_DOMAINS.has(domain) && }
stateObj.attributes.device_class && entitiesByDomain[domain].push(stateObj);
SENSOR_DEVICE_CLASSES.has(stateObj.attributes.device_class)
) {
sensorEntities.push(stateObj);
} }
if (sensorEntities.length === 3 && entitiesToggle.length === 3) { return entitiesByDomain;
break;
}
}
return { sensorEntities, entitiesToggle };
} }
); );
private _isOn(domain: string, deviceClass?: string): boolean | undefined {
const entities = this._entitiesByDomain(
this._config!.area,
this._devicesInArea(this._config!.area, this._devices!),
this._entities!,
this.hass.states
)[domain];
if (!entities) {
return undefined;
}
return (
deviceClass
? entities.filter(
(entity) => entity.attributes.device_class === deviceClass
)
: entities
).some(
(entity) =>
!UNAVAILABLE_STATES.includes(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.hass.states
)[domain].filter((entity) =>
deviceClass ? entity.attributes.device_class === deviceClass : true
);
if (!entities) {
return undefined;
}
let uom;
const values = entities.filter((entity) => {
if (!entity.attributes.unit_of_measurement) {
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((a, b) => a + Number(b.state), 0);
return `${formatNumber(sum / values.length, this.hass!.locale, {
maximumFractionDigits: 1,
})} ${uom}`;
}
private _area = memoizeOne( private _area = memoizeOne(
(areaId: string | undefined, areas: AreaRegistryEntry[]) => (areaId: string | undefined, areas: AreaRegistryEntry[]) =>
areas.find((area) => area.area_id === areaId) || null areas.find((area) => area.area_id === areaId) || null
@ -212,23 +283,19 @@ export class HuiAreaCard
return false; return false;
} }
const { sensorEntities, entitiesToggle } = this._memberships( 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.hass.states this.hass.states
); );
for (const stateObj of sensorEntities) { for (const domainEntities of Object.values(entities)) {
for (const stateObj of domainEntities) {
if (oldHass!.states[stateObj.entity_id] !== stateObj) { if (oldHass!.states[stateObj.entity_id] !== stateObj) {
return true; return true;
} }
} }
for (const stateObj of entitiesToggle) {
if (oldHass!.states[stateObj.entity_id] !== stateObj) {
return true;
}
} }
return false; return false;
@ -245,13 +312,12 @@ export class HuiAreaCard
return html``; return html``;
} }
const { sensorEntities, entitiesToggle } = this._memberships( const entitiesByDomain = 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.hass.states this.hass.states
); );
const area = this._area(this._config.area, this._areas); const area = this._area(this._config.area, this._areas);
if (area === null) { if (area === null) {
@ -262,62 +328,98 @@ export class HuiAreaCard
`; `;
} }
const sensors: TemplateResult[] = [];
SENSOR_DOMAINS.forEach((domain) => {
if (!(domain in entitiesByDomain)) {
return;
}
DEVICE_CLASSES[domain].forEach((deviceClass) => {
if (
entitiesByDomain[domain].some(
(entity) => entity.attributes.device_class === deviceClass
)
) {
sensors.push(html`
${DOMAIN_ICONS[domain][deviceClass]
? html`<ha-svg-icon
.path=${DOMAIN_ICONS[domain][deviceClass]}
></ha-svg-icon>`
: ""}
${this._average(domain, deviceClass)}
`);
}
});
});
let cameraEntityId: string | undefined;
if ("camera" in entitiesByDomain) {
cameraEntityId = entitiesByDomain.camera[0].entity_id;
}
return html` return html`
<ha-card <ha-card class=${area.picture ? "image" : ""}>
style=${styleMap({ ${area.picture || cameraEntityId
"background-image": `url(${this.hass.hassUrl(area.picture)})`, ? html`<hui-image
})} .config=${this._config}
> .hass=${this.hass}
<div class="container"> .image=${area.picture
<div class="sensors"> ? this.hass.hassUrl(area.picture)
${sensorEntities.map( : undefined}
(stateObj) => html` .cameraImage=${cameraEntityId}
<span aspectRatio="16:9"
.entity=${stateObj.entity_id} ></hui-image>`
@click=${this._handleMoreInfo} : ""}
>
<ha-state-icon .state=${stateObj}></ha-state-icon>
${computeDomain(stateObj.entity_id) === "binary_sensor"
? ""
: html`
${computeStateDisplay(
this.hass!.localize,
stateObj,
this.hass!.locale
)}
`}
</span>
`
)}
</div>
<div class="bottom">
<div <div
class="name ${this._config.navigation_path ? "navigate" : ""}" class="container ${classMap({
navigate: this._config.navigation_path !== undefined,
})}"
@click=${this._handleNavigation} @click=${this._handleNavigation}
> >
${area.name} <div class="alerts">
${ALERT_DOMAINS.map((domain) => {
if (!(domain in entitiesByDomain)) {
return "";
}
return DEVICE_CLASSES[domain].map((deviceClass) =>
this._isOn(domain, deviceClass)
? html`
${DOMAIN_ICONS[domain][deviceClass]
? html`<ha-svg-icon
.path=${DOMAIN_ICONS[domain][deviceClass]}
></ha-svg-icon>`
: ""}
`
: ""
);
})}
</div>
<div class="bottom">
<div>
<div class="name">${area.name}</div>
${sensors.length
? html`<div class="sensors">${sensors}</div>`
: ""}
</div> </div>
<div class="buttons"> <div class="buttons">
${entitiesToggle.map( ${TOGGLE_DOMAINS.map((domain) => {
(stateObj) => html` if (!(domain in entitiesByDomain)) {
return "";
}
const on = this._isOn(domain)!;
return TOGGLE_DOMAINS.includes(domain)
? html`
<ha-icon-button <ha-icon-button
class=${classMap({ class=${on ? "on" : "off"}
off: stateObj.state === "off", .path=${DOMAIN_ICONS[domain][on ? "on" : "off"]}
})} .domain=${domain}
.entity=${stateObj.entity_id} @click=${this._toggle}
.actionHandler=${actionHandler({
hasHold: true,
})}
@action=${this._handleAction}
> >
<state-badge
.hass=${this.hass}
.stateObj=${stateObj}
stateColor
></state-badge>
</ha-icon-button> </ha-icon-button>
` `
)} : "";
})}
</div> </div>
</div> </div>
</div> </div>
@ -343,25 +445,26 @@ export class HuiAreaCard
} }
} }
private _handleMoreInfo(ev) {
const entity = (ev.currentTarget as any).entity;
fireEvent(this, "hass-more-info", { entityId: entity });
}
private _handleNavigation() { private _handleNavigation() {
if (this._config!.navigation_path) { if (this._config!.navigation_path) {
navigate(this._config!.navigation_path); navigate(this._config!.navigation_path);
} }
} }
private _handleAction(ev: ActionHandlerEvent) { private _toggle(ev: Event) {
const entity = (ev.currentTarget as any).entity as string; ev.stopPropagation();
if (ev.detail.action === "hold") { const domain = (ev.currentTarget as any).domain as string;
fireEvent(this, "hass-more-info", { entityId: entity }); if (TOGGLE_DOMAINS.includes(domain)) {
} else if (ev.detail.action === "tap") { this.hass.callService(
toggleEntity(this.hass, entity); domain,
forwardHaptic("light"); this._isOn(domain) ? "turn_off" : "turn_on",
undefined,
{
area_id: this._config!.area,
} }
);
}
forwardHaptic("light");
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
@ -373,24 +476,52 @@ export class HuiAreaCard
background-size: cover; background-size: cover;
} }
ha-card.image {
padding-bottom: 0;
}
.container { .container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: space-between;
position: absolute; position: absolute;
top: 0; top: 0;
bottom: 0; bottom: 0;
left: 0; left: 0;
right: 0; right: 0;
background-color: rgba(0, 0, 0, 0.4); 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;
} }
.sensors { .sensors {
color: white; color: #e3e3e3;
font-size: 18px; font-size: 16px;
flex: 1; --mdc-icon-size: 24px;
opacity: 0.6;
margin-top: 8px;
}
.alerts {
padding: 16px; padding: 16px;
--mdc-icon-size: 28px; }
cursor: pointer;
.alerts ha-svg-icon {
background: var(--accent-color);
color: var(--text-accent-color, var(--text-primary-color));
padding: 8px;
border-radius: 50%;
} }
.name { .name {
@ -402,24 +533,23 @@ export class HuiAreaCard
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 8px 8px 8px 16px; padding: 16px;
} }
.name.navigate { .navigate {
cursor: pointer; cursor: pointer;
} }
state-badge {
--ha-icon-display: inline;
}
ha-icon-button { ha-icon-button {
color: white; color: white;
background-color: var(--area-button-color, rgb(175, 175, 175, 0.5)); background-color: var(--area-button-color, #727272b2);
border-radius: 50%; border-radius: 50%;
margin-left: 8px; margin-left: 8px;
--mdc-icon-button-size: 44px; --mdc-icon-button-size: 44px;
} }
.on {
color: var(--paper-item-icon-active-color, #fdd835);
}
`; `;
} }
} }