diff --git a/gallery/public/images/office.jpg b/gallery/public/images/office.jpg new file mode 100644 index 0000000000..128a77368e Binary files /dev/null and b/gallery/public/images/office.jpg differ diff --git a/gallery/src/demos/demo-hui-area-card.ts b/gallery/src/demos/demo-hui-area-card.ts new file mode 100644 index 0000000000..bbb6dedfd2 --- /dev/null +++ b/gallery/src/demos/demo-hui-area-card.ts @@ -0,0 +1,156 @@ +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, query } from "lit/decorators"; +import { getEntity } from "../../../src/fake_data/entity"; +import { provideHass } from "../../../src/fake_data/provide_hass"; +import "../components/demo-cards"; + +const ENTITIES = [ + getEntity("light", "bed_light", "on", { + friendly_name: "Bed Light", + }), + getEntity("switch", "bed_ac", "on", { + friendly_name: "Ecobee", + }), + getEntity("sensor", "bed_temp", "72", { + friendly_name: "Bedroom Temp", + device_class: "temperature", + unit_of_measurement: "°F", + }), + getEntity("light", "living_room_light", "off", { + friendly_name: "Living Room Light", + }), + getEntity("fan", "living_room", "on", { + friendly_name: "Living Room Fan", + }), + getEntity("sensor", "office_humidity", "73", { + friendly_name: "Office Humidity", + device_class: "humidity", + unit_of_measurement: "%", + }), + getEntity("light", "office", "on", { + friendly_name: "Office Light", + }), + getEntity("fan", "kitchen", "on", { + friendly_name: "Second Office Fan", + }), + getEntity("binary_sensor", "kitchen_door", "on", { + friendly_name: "Office Door", + device_class: "door", + }), +]; + +// TODO: Update image here +const CONFIGS = [ + { + heading: "Bedroom", + config: ` +- type: area + area: bedroom + image: "/images/bed.png" + `, + }, + { + heading: "Living Room", + config: ` +- type: area + area: living_room + image: "/images/living_room.png" + `, + }, + { + heading: "Office", + config: ` +- type: area + area: office + image: "/images/office.jpg" + `, + }, + { + heading: "Kitchen", + config: ` +- type: area + area: kitchen + image: "/images/kitchen.png" + `, + }, +]; + +@customElement("demo-hui-area-card") +class DemoArea extends LitElement { + @query("#demos") private _demoRoot!: HTMLElement; + + protected render(): TemplateResult { + return html``; + } + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + const hass = provideHass(this._demoRoot); + hass.updateTranslations(null, "en"); + hass.updateTranslations("lovelace", "en"); + hass.addEntities(ENTITIES); + hass.mockWS("config/area_registry/list", () => [ + { + name: "Bedroom", + area_id: "bedroom", + }, + { + name: "Living Room", + area_id: "living_room", + }, + { + name: "Office", + area_id: "office", + }, + { + name: "Second Office", + area_id: "kitchen", + }, + ]); + hass.mockWS("config/device_registry/list", () => []); + hass.mockWS("config/entity_registry/list", () => [ + { + area_id: "bedroom", + entity_id: "light.bed_light", + }, + { + area_id: "bedroom", + entity_id: "switch.bed_ac", + }, + { + area_id: "bedroom", + entity_id: "sensor.bed_temp", + }, + { + area_id: "living_room", + entity_id: "light.living_room_light", + }, + { + area_id: "living_room", + entity_id: "fan.living_room", + }, + { + area_id: "office", + entity_id: "light.office", + }, + { + area_id: "office", + entity_id: "sensor.office_humidity", + }, + { + area_id: "kitchen", + entity_id: "fan.kitchen", + }, + { + area_id: "kitchen", + entity_id: "binary_sensor.kitchen_door", + }, + ]); + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-hui-area-card": DemoArea; + } +} diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 4a22d3775e..ed658188df 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -172,6 +172,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { { area_id: "", name: this.hass.localize("ui.components.area-picker.no_areas"), + picture: null, }, ]; } @@ -295,6 +296,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { { area_id: "", name: this.hass.localize("ui.components.area-picker.no_match"), + picture: null, }, ]; } @@ -306,6 +308,7 @@ export class HaAreaPicker extends SubscribeMixin(LitElement) { { area_id: "add_new", name: this.hass.localize("ui.components.area-picker.add_new"), + picture: null, }, ]; } diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts index 3e432ce626..ffb3d4c153 100644 --- a/src/data/area_registry.ts +++ b/src/data/area_registry.ts @@ -7,7 +7,7 @@ import { HomeAssistant } from "../types"; export interface AreaRegistryEntry { area_id: string; name: string; - picture?: string; + picture: string | null; } export interface AreaRegistryEntryMutableParams { diff --git a/src/panels/lovelace/cards/hui-area-card.ts b/src/panels/lovelace/cards/hui-area-card.ts new file mode 100644 index 0000000000..e9a72c7d16 --- /dev/null +++ b/src/panels/lovelace/cards/hui-area-card.ts @@ -0,0 +1,431 @@ +import "@material/mwc-ripple"; +import type { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} 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 { 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 { navigate } from "../../../common/navigate"; +import "../../../components/entity/state-badge"; +import "../../../components/ha-card"; +import "../../../components/ha-icon-button"; +import "../../../components/ha-state-icon"; +import "../../../components/ha-svg-icon"; +import { + AreaRegistryEntry, + subscribeAreaRegistry, +} from "../../../data/area_registry"; +import { + DeviceRegistryEntry, + subscribeDeviceRegistry, +} from "../../../data/device_registry"; +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_DEVICE_CLASSES = new Set([ + "temperature", + "humidity", + "motion", + "door", + "aqi", +]); + +const TOGGLE_DOMAINS = new Set(["light", "fan", "switch"]); + +@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 getStubConfig(): AreaCardConfig { + return { type: "area", area: "" }; + } + + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _config?: AreaCardConfig; + + @state() private _entities?: EntityRegistryEntry[]; + + @state() private _devices?: DeviceRegistryEntry[]; + + @state() private _areas?: AreaRegistryEntry[]; + + private _memberships = memoizeOne( + ( + areaId: string, + devicesInArea: Set, + registryEntities: EntityRegistryEntry[], + states: HomeAssistant["states"] + ) => { + const entitiesInArea = registryEntities + .filter( + (entry) => + !entry.entity_category && + (entry.area_id + ? entry.area_id === areaId + : entry.device_id && devicesInArea.has(entry.device_id)) + ) + .map((entry) => entry.entity_id); + + const sensorEntities: HassEntity[] = []; + const entitiesToggle: HassEntity[] = []; + + for (const entity of entitiesInArea) { + const domain = computeDomain(entity); + if (!TOGGLE_DOMAINS.has(domain) && !SENSOR_DOMAINS.has(domain)) { + continue; + } + + const stateObj: HassEntity | undefined = states[entity]; + + if (!stateObj) { + continue; + } + + if (entitiesToggle.length < 3 && TOGGLE_DOMAINS.has(domain)) { + entitiesToggle.push(stateObj); + 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; + } + } + + return { sensorEntities, entitiesToggle }; + } + ); + + 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 setConfig(config: AreaCardConfig): void { + if (!config.area) { + throw new Error("Area Required"); + } + + this._config = config; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + if (changedProps.has("_config") || !this._config) { + return true; + } + + if ( + changedProps.has("_devicesInArea") || + changedProps.has("_area") || + 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 { sensorEntities, entitiesToggle } = this._memberships( + 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; + } + } + + return false; + } + + protected render(): TemplateResult { + if ( + !this._config || + !this.hass || + !this._areas || + !this._devices || + !this._entities + ) { + return html``; + } + + const { sensorEntities, entitiesToggle } = this._memberships( + 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) { + return html` + + ${this.hass.localize("ui.card.area.area_not_found")} + + `; + } + + return html` + +
+
+ ${sensorEntities.map( + (stateObj) => html` + + + ${computeDomain(stateObj.entity_id) === "binary_sensor" + ? "" + : html` + ${computeStateDisplay( + this.hass!.localize, + stateObj, + this.hass!.locale + )} + `} + + ` + )} +
+
+
+ ${area.name} +
+
+ ${entitiesToggle.map( + (stateObj) => html` + + + + ` + )} +
+
+
+
+ `; + } + + 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 _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"); + } + } + + static get styles(): CSSResultGroup { + return css` + ha-card { + overflow: hidden; + position: relative; + padding-bottom: 56.25%; + background-size: cover; + } + + .container { + display: flex; + flex-direction: column; + position: absolute; + top: 0; + bottom: 0; + left: 0; + right: 0; + background-color: rgba(0, 0, 0, 0.4); + } + + .sensors { + color: white; + font-size: 18px; + flex: 1; + padding: 16px; + --mdc-icon-size: 28px; + cursor: pointer; + } + + .name { + color: white; + font-size: 24px; + } + + .bottom { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 8px 8px 16px; + } + + .name.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)); + border-radius: 50%; + margin-left: 8px; + --mdc-icon-button-size: 44px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-area-card": HuiAreaCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 80a70b4378..cf5ecb5198 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -76,6 +76,11 @@ export interface EntitiesCardConfig extends LovelaceCardConfig { state_color?: boolean; } +export interface AreaCardConfig extends LovelaceCardConfig { + area: string; + navigation_path?: string; +} + export interface ButtonCardConfig extends LovelaceCardConfig { entity?: string; name?: string; diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index 6c8c470e35..3d081cadaf 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -33,6 +33,7 @@ const ALWAYS_LOADED_TYPES = new Set([ const LAZY_LOAD_TYPES = { "alarm-panel": () => import("../cards/hui-alarm-panel-card"), + area: () => import("../cards/hui-area-card"), error: () => import("../cards/hui-error-card"), "empty-state": () => import("../cards/hui-empty-state-card"), "energy-usage-graph": () => 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 new file mode 100644 index 0000000000..e23c463746 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-area-card-editor.ts @@ -0,0 +1,119 @@ +import "@polymer/paper-input/paper-input"; +import { CSSResultGroup, html, LitElement, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { assert, assign, object, optional, string } from "superstruct"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-area-picker"; +import { HomeAssistant } from "../../../../types"; +import { AreaCardConfig } from "../../cards/types"; +import "../../components/hui-theme-select-editor"; +import { LovelaceCardEditor } from "../../types"; +import { baseLovelaceCardConfig } from "../structs/base-card-struct"; +import { EditorTarget } from "../types"; +import { configElementStyle } from "./config-elements-style"; + +const cardConfigStruct = assign( + baseLovelaceCardConfig, + object({ + area: optional(string()), + navigation_path: optional(string()), + theme: optional(string()), + }) +); + +@customElement("hui-area-card-editor") +export class HuiAreaCardEditor + extends LitElement + implements LovelaceCardEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @state() private _config?: AreaCardConfig; + + public setConfig(config: AreaCardConfig): void { + assert(config, cardConfigStruct); + this._config = config; + } + + get _area(): string { + return this._config!.area || ""; + } + + get _navigation_path(): string { + return this._config!.navigation_path || ""; + } + + get _theme(): string { + return this._config!.theme || ""; + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + return html` +
+ + + + +
+ `; + } + + private _valueChanged(ev: CustomEvent): void { + if (!this._config || !this.hass) { + return; + } + const target = ev.target! as EditorTarget; + const value = ev.detail.value; + + if (this[`_${target.configValue}`] === value) { + return; + } + + let newConfig; + if (target.configValue) { + if (!value) { + newConfig = { ...this._config }; + delete newConfig[target.configValue!]; + } else { + newConfig = { + ...this._config, + [target.configValue!]: value, + }; + } + } + fireEvent(this, "config-changed", { config: newConfig }); + } + + static get styles(): CSSResultGroup { + return configElementStyle; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-area-card-editor": HuiAreaCardEditor; + } +} diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts index 2a9beb0371..424cf8a4eb 100644 --- a/src/panels/lovelace/editor/lovelace-cards.ts +++ b/src/panels/lovelace/editor/lovelace-cards.ts @@ -89,6 +89,9 @@ export const coreCards: Card[] = [ type: "weather-forecast", showElement: true, }, + { + type: "area", + }, { type: "conditional", }, diff --git a/src/translations/en.json b/src/translations/en.json index bf077bdc02..744ef87c5b 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -116,6 +116,9 @@ "arm_vacation": "Arm vacation", "arm_custom_bypass": "Custom bypass" }, + "area": { + "area_not_found": "Area not found." + }, "automation": { "last_triggered": "Last triggered", "trigger": "Run Actions" @@ -3212,6 +3215,10 @@ "available_states": "Available States", "description": "The Alarm Panel card allows you to Arm and Disarm your alarm control panel integrations." }, + "area": { + "name": "Area", + "description": "The Area card automatically displays entities of a specific area." + }, "calendar": { "name": "Calendar", "description": "The Calendar card displays a calendar including day, week and list views",