mirror of
https://github.com/home-assistant/frontend.git
synced 2025-04-24 21:37:21 +00:00
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:
parent
60ce805b3b
commit
48d12ceafe
@ -1,4 +1,12 @@
|
||||
import "@material/mwc-ripple";
|
||||
import {
|
||||
mdiLightbulbMultiple,
|
||||
mdiLightbulbMultipleOff,
|
||||
mdiRun,
|
||||
mdiToggleSwitch,
|
||||
mdiToggleSwitchOff,
|
||||
mdiWaterPercent,
|
||||
} from "@mdi/js";
|
||||
import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import {
|
||||
css,
|
||||
@ -10,13 +18,13 @@ import {
|
||||
} from "lit";
|
||||
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 { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
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 { formatNumber } from "../../../common/number/format_number";
|
||||
import "../../../components/entity/state-badge";
|
||||
import "../../../components/ha-card";
|
||||
import "../../../components/ha-icon-button";
|
||||
@ -30,31 +38,40 @@ import {
|
||||
DeviceRegistryEntry,
|
||||
subscribeDeviceRegistry,
|
||||
} from "../../../data/device_registry";
|
||||
import { UNAVAILABLE_STATES } from "../../../data/entity";
|
||||
import {
|
||||
EntityRegistryEntry,
|
||||
subscribeEntityRegistry,
|
||||
} from "../../../data/entity_registry";
|
||||
import { forwardHaptic } from "../../../data/haptics";
|
||||
import { ActionHandlerEvent } from "../../../data/lovelace";
|
||||
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
|
||||
import { HomeAssistant } from "../../../types";
|
||||
import { actionHandler } from "../common/directives/action-handler-directive";
|
||||
import { toggleEntity } from "../common/entity/toggle-entity";
|
||||
import "../components/hui-warning";
|
||||
import { LovelaceCard, LovelaceCardEditor } from "../types";
|
||||
import { AreaCardConfig } from "./types";
|
||||
|
||||
const SENSOR_DOMAINS = new Set(["sensor", "binary_sensor"]);
|
||||
const SENSOR_DOMAINS = ["sensor"];
|
||||
|
||||
const SENSOR_DEVICE_CLASSES = new Set([
|
||||
"temperature",
|
||||
"humidity",
|
||||
"motion",
|
||||
"door",
|
||||
"aqi",
|
||||
]);
|
||||
const ALERT_DOMAINS = ["binary_sensor"];
|
||||
|
||||
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")
|
||||
export class HuiAreaCard
|
||||
@ -80,7 +97,7 @@ export class HuiAreaCard
|
||||
|
||||
@state() private _areas?: AreaRegistryEntry[];
|
||||
|
||||
private _memberships = memoizeOne(
|
||||
private _entitiesByDomain = memoizeOne(
|
||||
(
|
||||
areaId: string,
|
||||
devicesInArea: Set<string>,
|
||||
@ -97,44 +114,98 @@ export class HuiAreaCard
|
||||
)
|
||||
.map((entry) => entry.entity_id);
|
||||
|
||||
const sensorEntities: HassEntity[] = [];
|
||||
const entitiesToggle: HassEntity[] = [];
|
||||
const entitiesByDomain: { [domain: string]: HassEntity[] } = {};
|
||||
|
||||
for (const entity of entitiesInArea) {
|
||||
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;
|
||||
}
|
||||
|
||||
const stateObj: HassEntity | undefined = states[entity];
|
||||
|
||||
if (!stateObj) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entitiesToggle.length < 3 && TOGGLE_DOMAINS.has(domain)) {
|
||||
entitiesToggle.push(stateObj);
|
||||
if (
|
||||
(SENSOR_DOMAINS.includes(domain) || ALERT_DOMAINS.includes(domain)) &&
|
||||
!DEVICE_CLASSES[domain].includes(
|
||||
stateObj.attributes.device_class || ""
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
sensorEntities.length < 3 &&
|
||||
SENSOR_DOMAINS.has(domain) &&
|
||||
stateObj.attributes.device_class &&
|
||||
SENSOR_DEVICE_CLASSES.has(stateObj.attributes.device_class)
|
||||
) {
|
||||
sensorEntities.push(stateObj);
|
||||
}
|
||||
|
||||
if (sensorEntities.length === 3 && entitiesToggle.length === 3) {
|
||||
break;
|
||||
if (!(domain in entitiesByDomain)) {
|
||||
entitiesByDomain[domain] = [];
|
||||
}
|
||||
entitiesByDomain[domain].push(stateObj);
|
||||
}
|
||||
|
||||
return { sensorEntities, entitiesToggle };
|
||||
return entitiesByDomain;
|
||||
}
|
||||
);
|
||||
|
||||
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(
|
||||
(areaId: string | undefined, areas: AreaRegistryEntry[]) =>
|
||||
areas.find((area) => area.area_id === areaId) || null
|
||||
@ -212,22 +283,18 @@ export class HuiAreaCard
|
||||
return false;
|
||||
}
|
||||
|
||||
const { sensorEntities, entitiesToggle } = this._memberships(
|
||||
const entities = this._entitiesByDomain(
|
||||
this._config.area,
|
||||
this._devicesInArea(this._config.area, this._devices),
|
||||
this._entities,
|
||||
this.hass.states
|
||||
);
|
||||
|
||||
for (const stateObj of sensorEntities) {
|
||||
if (oldHass!.states[stateObj.entity_id] !== stateObj) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for (const stateObj of entitiesToggle) {
|
||||
if (oldHass!.states[stateObj.entity_id] !== stateObj) {
|
||||
return true;
|
||||
for (const domainEntities of Object.values(entities)) {
|
||||
for (const stateObj of domainEntities) {
|
||||
if (oldHass!.states[stateObj.entity_id] !== stateObj) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,13 +312,12 @@ export class HuiAreaCard
|
||||
return html``;
|
||||
}
|
||||
|
||||
const { sensorEntities, entitiesToggle } = this._memberships(
|
||||
const entitiesByDomain = this._entitiesByDomain(
|
||||
this._config.area,
|
||||
this._devicesInArea(this._config.area, this._devices),
|
||||
this._entities,
|
||||
this.hass.states
|
||||
);
|
||||
|
||||
const area = this._area(this._config.area, this._areas);
|
||||
|
||||
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`
|
||||
<ha-card
|
||||
style=${styleMap({
|
||||
"background-image": `url(${this.hass.hassUrl(area.picture)})`,
|
||||
})}
|
||||
>
|
||||
<div class="container">
|
||||
<div class="sensors">
|
||||
${sensorEntities.map(
|
||||
(stateObj) => html`
|
||||
<span
|
||||
.entity=${stateObj.entity_id}
|
||||
@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>
|
||||
`
|
||||
)}
|
||||
<ha-card class=${area.picture ? "image" : ""}>
|
||||
${area.picture || cameraEntityId
|
||||
? html`<hui-image
|
||||
.config=${this._config}
|
||||
.hass=${this.hass}
|
||||
.image=${area.picture
|
||||
? this.hass.hassUrl(area.picture)
|
||||
: undefined}
|
||||
.cameraImage=${cameraEntityId}
|
||||
aspectRatio="16:9"
|
||||
></hui-image>`
|
||||
: ""}
|
||||
|
||||
<div
|
||||
class="container ${classMap({
|
||||
navigate: this._config.navigation_path !== undefined,
|
||||
})}"
|
||||
@click=${this._handleNavigation}
|
||||
>
|
||||
<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
|
||||
class="name ${this._config.navigation_path ? "navigate" : ""}"
|
||||
@click=${this._handleNavigation}
|
||||
>
|
||||
${area.name}
|
||||
<div>
|
||||
<div class="name">${area.name}</div>
|
||||
${sensors.length
|
||||
? html`<div class="sensors">${sensors}</div>`
|
||||
: ""}
|
||||
</div>
|
||||
<div class="buttons">
|
||||
${entitiesToggle.map(
|
||||
(stateObj) => html`
|
||||
<ha-icon-button
|
||||
class=${classMap({
|
||||
off: stateObj.state === "off",
|
||||
})}
|
||||
.entity=${stateObj.entity_id}
|
||||
.actionHandler=${actionHandler({
|
||||
hasHold: true,
|
||||
})}
|
||||
@action=${this._handleAction}
|
||||
>
|
||||
<state-badge
|
||||
.hass=${this.hass}
|
||||
.stateObj=${stateObj}
|
||||
stateColor
|
||||
></state-badge>
|
||||
</ha-icon-button>
|
||||
`
|
||||
)}
|
||||
${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>
|
||||
</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() {
|
||||
if (this._config!.navigation_path) {
|
||||
navigate(this._config!.navigation_path);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleAction(ev: ActionHandlerEvent) {
|
||||
const entity = (ev.currentTarget as any).entity as string;
|
||||
if (ev.detail.action === "hold") {
|
||||
fireEvent(this, "hass-more-info", { entityId: entity });
|
||||
} else if (ev.detail.action === "tap") {
|
||||
toggleEntity(this.hass, entity);
|
||||
forwardHaptic("light");
|
||||
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");
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
@ -373,24 +476,52 @@ export class HuiAreaCard
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
ha-card.image {
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 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 {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
flex: 1;
|
||||
color: #e3e3e3;
|
||||
font-size: 16px;
|
||||
--mdc-icon-size: 24px;
|
||||
opacity: 0.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.alerts {
|
||||
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 {
|
||||
@ -402,24 +533,23 @@ export class HuiAreaCard
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 8px 8px 16px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.name.navigate {
|
||||
.navigate {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
state-badge {
|
||||
--ha-icon-display: inline;
|
||||
}
|
||||
|
||||
ha-icon-button {
|
||||
color: white;
|
||||
background-color: var(--area-button-color, rgb(175, 175, 175, 0.5));
|
||||
background-color: var(--area-button-color, #727272b2);
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
--mdc-icon-button-size: 44px;
|
||||
}
|
||||
.on {
|
||||
color: var(--paper-item-icon-active-color, #fdd835);
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user