From ec5d7508c999944f461f2140358bab1405bf4934 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Sun, 31 May 2020 22:35:58 +0300 Subject: [PATCH] Add humidifier entity integration --- src/common/const.ts | 1 + src/common/entity/domain_icon.ts | 1 + src/common/style/icon_color_css.ts | 1 + src/components/ha-humidifier-control.js | 142 +++++++ src/components/ha-humidifier-state.js | 83 ++++ src/components/state-history-chart-line.js | 22 + src/data/history.ts | 5 +- src/data/humidifier.ts | 19 + .../more-info/controls/more-info-content.ts | 1 + .../controls/more-info-humidifier.ts | 219 ++++++++++ .../lovelace/cards/hui-humidifier-card.ts | 397 ++++++++++++++++++ src/panels/lovelace/cards/types.ts | 6 + .../common/generate-lovelace-config.ts | 7 + .../create-element/create-card-element.ts | 2 + .../create-element/create-row-element.ts | 2 + .../hui-humidifier-card-editor.ts | 117 ++++++ src/panels/lovelace/editor/lovelace-cards.ts | 4 + .../entity-rows/hui-humidifier-entity-row.ts | 74 ++++ src/translations/en.json | 22 + src/util/hass-attributes-util.js | 3 +- 20 files changed, 1126 insertions(+), 2 deletions(-) create mode 100644 src/components/ha-humidifier-control.js create mode 100644 src/components/ha-humidifier-state.js create mode 100644 src/data/humidifier.ts create mode 100644 src/dialogs/more-info/controls/more-info-humidifier.ts create mode 100644 src/panels/lovelace/cards/hui-humidifier-card.ts create mode 100644 src/panels/lovelace/editor/config-elements/hui-humidifier-card-editor.ts create mode 100644 src/panels/lovelace/entity-rows/hui-humidifier-entity-row.ts diff --git a/src/common/const.ts b/src/common/const.ts index 503e866c4e..c1398fce5f 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -37,6 +37,7 @@ export const DOMAINS_WITH_MORE_INFO = [ "fan", "group", "history_graph", + "humidifier", "input_datetime", "light", "lock", diff --git a/src/common/entity/domain_icon.ts b/src/common/entity/domain_icon.ts index cf0617923b..632473bafd 100644 --- a/src/common/entity/domain_icon.ts +++ b/src/common/entity/domain_icon.ts @@ -22,6 +22,7 @@ const fixedIcons = { history_graph: "hass:chart-line", homeassistant: "hass:home-assistant", homekit: "hass:home-automation", + humidifier: "hass:air-humidifier", image_processing: "hass:image-filter-frames", input_boolean: "hass:toggle-switch-outline", input_datetime: "hass:calendar-clock", diff --git a/src/common/style/icon_color_css.ts b/src/common/style/icon_color_css.ts index 37af74d500..382745c298 100644 --- a/src/common/style/icon_color_css.ts +++ b/src/common/style/icon_color_css.ts @@ -8,6 +8,7 @@ export const iconColorCSS = css` ha-icon[data-domain="camera"][data-state="streaming"], ha-icon[data-domain="cover"][data-state="open"], ha-icon[data-domain="fan"][data-state="on"], + ha-icon[data-domain="humidifier"][data-state="on"], ha-icon[data-domain="light"][data-state="on"], ha-icon[data-domain="input_boolean"][data-state="on"], ha-icon[data-domain="lock"][data-state="unlocked"], diff --git a/src/components/ha-humidifier-control.js b/src/components/ha-humidifier-control.js new file mode 100644 index 0000000000..6746e61843 --- /dev/null +++ b/src/components/ha-humidifier-control.js @@ -0,0 +1,142 @@ +import "@polymer/iron-flex-layout/iron-flex-layout-classes"; +import "./ha-icon-button"; +import { html } from "@polymer/polymer/lib/utils/html-tag"; +/* eslint-plugin-disable lit */ +import { PolymerElement } from "@polymer/polymer/polymer-element"; +import { EventsMixin } from "../mixins/events-mixin"; + +/* + * @appliesMixin EventsMixin + */ +class HaHumidifierControl extends EventsMixin(PolymerElement) { + static get template() { + return html` + + + + +
[[value]] [[units]]
+
+
+ +
+
+ +
+
+ `; + } + + static get properties() { + return { + value: { + type: Number, + observer: "valueChanged", + }, + units: { + type: String, + }, + min: { + type: Number, + }, + max: { + type: Number, + }, + step: { + type: Number, + value: 1, + }, + }; + } + + humidityStateInFlux(inFlux) { + this.$.humidity.classList.toggle("in-flux", inFlux); + } + + _round(val) { + // round value to precision derived from step + // insired by https://github.com/soundar24/roundSlider/blob/master/src/roundslider.js + const s = this.step.toString().split("."); + return s[1] ? parseFloat(val.toFixed(s[1].length)) : Math.round(val); + } + + incrementValue() { + const newval = this._round(this.value + this.step); + if (this.value < this.max) { + this.last_changed = Date.now(); + this.humidityStateInFlux(true); + } + if (newval <= this.max) { + // If no initial target_temp + // this forces control to start + // from the min configured instead of 0 + if (newval <= this.min) { + this.value = this.min; + } else { + this.value = newval; + } + } else { + this.value = this.max; + } + } + + decrementValue() { + const newval = this._round(this.value - this.step); + if (this.value > this.min) { + this.last_changed = Date.now(); + this.humidityStateInFlux(true); + } + if (newval >= this.min) { + this.value = newval; + } else { + this.value = this.min; + } + } + + valueChanged() { + // when the last_changed timestamp is changed, + // trigger a potential event fire in + // the future, as long as last changed is far enough in the + // past. + if (this.last_changed) { + window.setTimeout(() => { + const now = Date.now(); + if (now - this.last_changed >= 2000) { + this.fire("change"); + this.humidityStateInFlux(false); + this.last_changed = null; + } + }, 2010); + } + } +} + +customElements.define("ha-humidifier-control", HaHumidifierControl); diff --git a/src/components/ha-humidifier-state.js b/src/components/ha-humidifier-state.js new file mode 100644 index 0000000000..2731b6451e --- /dev/null +++ b/src/components/ha-humidifier-state.js @@ -0,0 +1,83 @@ +import { html } from "@polymer/polymer/lib/utils/html-tag"; +/* eslint-plugin-disable lit */ +import { PolymerElement } from "@polymer/polymer/polymer-element"; +import LocalizeMixin from "../mixins/localize-mixin"; + +/* + * @appliesMixin LocalizeMixin + */ +class HaHumidifierState extends LocalizeMixin(PolymerElement) { + static get template() { + return html` + + +
+ +
[[computeTarget(stateObj.attributes.humidity)]]
+
+ `; + } + + static get properties() { + return { + stateObj: Object, + }; + } + + computeTarget(humidity) { + if (humidity != null) { + return `${humidity} %`; + } + + return ""; + } + + _hasKnownState(state) { + return state !== "unknown"; + } + + _localizeState(localize, state) { + return localize(`state.default.${state}`) || state; + } + + _localizeMode(localize, mode) { + return localize(`state_attributes.humidifier.mode.${mode}`) || mode; + } + + _renderMode(attributes) { + return attributes.mode; + } +} +customElements.define("ha-humidifier-state", HaHumidifierState); diff --git a/src/components/state-history-chart-line.js b/src/components/state-history-chart-line.js index 4cc1321475..3a857b8663 100644 --- a/src/components/state-history-chart-line.js +++ b/src/components/state-history-chart-line.js @@ -262,6 +262,28 @@ class StateHistoryChartLine extends LocalizeMixin(PolymerElement) { pushData(new Date(state.last_changed), series); } }); + } else if (domain === "humidifier") { + addColumn( + `${this.hass.localize( + "ui.card.humidifier.target_humidity_entity", + "name", + name + )}`, + true + ); + addColumn( + `${this.hass.localize("ui.card.humidifier.on_entity", "name", name)}`, + true, + true + ); + + states.states.forEach((state) => { + if (!state.attributes) return; + const target = safeParseFloat(state.attributes.humidity); + const series = [target]; + series.push(state.state === "on" ? target : null); + pushData(new Date(state.last_changed), series); + }); } else { // Only disable interpolation for sensors const isStep = domain === "sensor"; diff --git a/src/data/history.ts b/src/data/history.ts index ee244a2e7b..0856e9eff3 100644 --- a/src/data/history.ts +++ b/src/data/history.ts @@ -5,13 +5,14 @@ import { computeStateName } from "../common/entity/compute_state_name"; import { LocalizeFunc } from "../common/translations/localize"; import { HomeAssistant } from "../types"; -const DOMAINS_USE_LAST_UPDATED = ["climate", "water_heater"]; +const DOMAINS_USE_LAST_UPDATED = ["climate", "humidifier", "water_heater"]; const LINE_ATTRIBUTES_TO_KEEP = [ "temperature", "current_temperature", "target_temp_low", "target_temp_high", "hvac_action", + "humidity", ]; export interface LineChartState { @@ -221,6 +222,8 @@ export const computeHistory = ( unit = hass.config.unit_system.temperature; } else if (computeStateDomain(stateInfo[0]) === "water_heater") { unit = hass.config.unit_system.temperature; + } else if (computeStateDomain(stateInfo[0]) === "humidifier") { + unit = "%"; } if (!unit) { diff --git a/src/data/humidifier.ts b/src/data/humidifier.ts new file mode 100644 index 0000000000..968aad1dcd --- /dev/null +++ b/src/data/humidifier.ts @@ -0,0 +1,19 @@ +import { + HassEntityAttributeBase, + HassEntityBase, +} from "home-assistant-js-websocket"; + +export type HumidifierEntity = HassEntityBase & { + attributes: HassEntityAttributeBase & { + humidity?: number; + min_humidity?: number; + max_humidity?: number; + mode?: string; + available_modes?: string[]; + }; +}; + +export const HUMIDIFIER_SUPPORT_MODES = 1; + +export const HUMIDIFIER_DEVICE_CLASS_HUMIDIFIER = "humidifier"; +export const HUMIDIFIER_DEVICE_CLASS_DEHUMIDIFIER = "dehumidifier"; diff --git a/src/dialogs/more-info/controls/more-info-content.ts b/src/dialogs/more-info/controls/more-info-content.ts index 896d1c576b..2f7ed3dbf7 100644 --- a/src/dialogs/more-info/controls/more-info-content.ts +++ b/src/dialogs/more-info/controls/more-info-content.ts @@ -14,6 +14,7 @@ import "./more-info-default"; import "./more-info-fan"; import "./more-info-group"; import "./more-info-history_graph"; +import "./more-info-humidifier"; import "./more-info-input_datetime"; import "./more-info-light"; import "./more-info-lock"; diff --git a/src/dialogs/more-info/controls/more-info-humidifier.ts b/src/dialogs/more-info/controls/more-info-humidifier.ts new file mode 100644 index 0000000000..e9fe98991b --- /dev/null +++ b/src/dialogs/more-info/controls/more-info-humidifier.ts @@ -0,0 +1,219 @@ +import "@polymer/iron-flex-layout/iron-flex-layout-classes"; +import "@polymer/paper-item/paper-item"; +import "@polymer/paper-listbox/paper-listbox"; +import { + css, + CSSResult, + html, + LitElement, + property, + PropertyValues, + TemplateResult, +} from "lit-element"; +import { classMap } from "lit-html/directives/class-map"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { supportsFeature } from "../../../common/entity/supports-feature"; +import { computeRTLDirection } from "../../../common/util/compute_rtl"; +import "../../../components/ha-humidifier-control"; +import "../../../components/ha-paper-dropdown-menu"; +import "../../../components/ha-paper-slider"; +import "../../../components/ha-switch"; +import { + HumidifierEntity, + HUMIDIFIER_SUPPORT_MODES, +} from "../../../data/humidifier"; +import { HomeAssistant } from "../../../types"; + +class MoreInfoHumidifier extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public stateObj?: HumidifierEntity; + + private _resizeDebounce?: number; + + protected render(): TemplateResult { + if (!this.stateObj) { + return html``; + } + + const hass = this.hass; + const stateObj = this.stateObj; + + const supportModes = supportsFeature(stateObj, HUMIDIFIER_SUPPORT_MODES); + + const rtlDirection = computeRTLDirection(hass); + + return html` +
+
+
${hass.localize("ui.card.humidifier.humidity")}
+
+
+ ${stateObj.attributes.humidity} % +
+ + +
+
+ + ${supportModes + ? html` +
+ + + ${stateObj.attributes.available_modes!.map( + (mode) => html` + + ${hass.localize( + `state_attributes.humidifier.mode.${mode}` + ) || mode} + + ` + )} + + +
+ ` + : ""} +
+ `; + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (!changedProps.has("stateObj") || !this.stateObj) { + return; + } + + if (this._resizeDebounce) { + clearTimeout(this._resizeDebounce); + } + this._resizeDebounce = window.setTimeout(() => { + fireEvent(this, "iron-resize"); + this._resizeDebounce = undefined; + }, 500); + } + + private _targetHumiditySliderChanged(ev) { + const newVal = ev.target.value; + this._callServiceHelper( + this.stateObj!.attributes.humidity, + newVal, + "set_humidity", + { humidity: newVal } + ); + } + + private _handleModeChanged(ev) { + const newVal = ev.detail.value || null; + this._callServiceHelper( + this.stateObj!.attributes.mode, + newVal, + "set_mode", + { mode: newVal } + ); + } + + private async _callServiceHelper( + oldVal: unknown, + newVal: unknown, + service: string, + data: { + entity_id?: string; + [key: string]: unknown; + } + ) { + if (oldVal === newVal) { + return; + } + + data.entity_id = this.stateObj!.entity_id; + const curState = this.stateObj; + + await this.hass.callService("humidifier", service, data); + + // We reset stateObj to re-sync the inputs with the state. It will be out + // of sync if our service call did not result in the entity to be turned + // on. Since the state is not changing, the resync is not called automatic. + await new Promise((resolve) => setTimeout(resolve, 2000)); + + // No need to resync if we received a new state. + if (this.stateObj !== curState) { + return; + } + + this.stateObj = undefined; + await this.updateComplete; + // Only restore if not set yet by a state change + if (this.stateObj === undefined) { + this.stateObj = curState; + } + } + + static get styles(): CSSResult { + return css` + :host { + color: var(--primary-text-color); + } + + ha-paper-dropdown-menu { + width: 100%; + } + + paper-item { + cursor: pointer; + } + + ha-paper-slider { + width: 100%; + } + + .container-humidity .single-row { + display: flex; + height: 50px; + } + + .target-humidity { + width: 90px; + font-size: 200%; + margin: auto; + direction: ltr; + } + + .humidity { + --paper-slider-active-color: var(--paper-blue-400); + --paper-slider-secondary-color: var(--paper-blue-400); + } + + .single-row { + padding: 8px 0; + } + `; + } +} + +customElements.define("more-info-humidifier", MoreInfoHumidifier); diff --git a/src/panels/lovelace/cards/hui-humidifier-card.ts b/src/panels/lovelace/cards/hui-humidifier-card.ts new file mode 100644 index 0000000000..db3000adcc --- /dev/null +++ b/src/panels/lovelace/cards/hui-humidifier-card.ts @@ -0,0 +1,397 @@ +import "../../../components/ha-icon-button"; +import "@thomasloven/round-slider"; +import { HassEntity } from "home-assistant-js-websocket"; +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + PropertyValues, + svg, + TemplateResult, +} from "lit-element"; +import { classMap } from "lit-html/directives/class-map"; +import { UNIT_F } from "../../../common/const"; +import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; +import { fireEvent } from "../../../common/dom/fire_event"; +import { computeStateName } from "../../../common/entity/compute_state_name"; +import { computeRTLDirection } from "../../../common/util/compute_rtl"; +import "../../../components/ha-card"; +import { HumidifierEntity } from "../../../data/humidifier"; +import { UNAVAILABLE } from "../../../data/entity"; +import { HomeAssistant } from "../../../types"; +import { actionHandler } from "../common/directives/action-handler-directive"; +import { findEntities } from "../common/find-entites"; +import { hasConfigOrEntityChanged } from "../common/has-changed"; +import { createEntityNotFoundWarning } from "../components/hui-warning"; +import { LovelaceCard, LovelaceCardEditor } from "../types"; +import { HumidifierCardConfig } from "./types"; + +@customElement("hui-humidifier-card") +export class HuiHumidifierCard extends LitElement implements LovelaceCard { + public static async getConfigElement(): Promise { + await import( + /* webpackChunkName: "hui-humidifier-card-editor" */ "../editor/config-elements/hui-humidifier-card-editor" + ); + return document.createElement("hui-humidifier-card-editor"); + } + + public static getStubConfig( + hass: HomeAssistant, + entities: string[], + entitiesFallback: string[] + ): HumidifierCardConfig { + const includeDomains = ["humidifier"]; + const maxEntities = 1; + const foundEntities = findEntities( + hass, + maxEntities, + entities, + entitiesFallback, + includeDomains + ); + + return { type: "humidifier", entity: foundEntities[0] || "" }; + } + + @property() public hass?: HomeAssistant; + + @property() private _config?: HumidifierCardConfig; + + @property() private _setHum?: number | number[]; + + public getCardSize(): number { + return 5; + } + + public setConfig(config: HumidifierCardConfig): void { + if (!config.entity || config.entity.split(".")[0] !== "humidifier") { + throw new Error("Specify an entity from within the humidifier domain."); + } + + this._config = config; + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + const stateObj = this.hass.states[this._config.entity] as HumidifierEntity; + + if (!stateObj) { + return html` + + ${createEntityNotFoundWarning(this.hass, this._config.entity)} + + `; + } + + const name = + this._config!.name || + computeStateName(this.hass!.states[this._config!.entity]); + const targetHumidity = + stateObj.attributes.humidity !== null && + Number.isFinite(Number(stateObj.attributes.humidity)) + ? stateObj.attributes.humidity + : stateObj.attributes.min_humidity; + + const rtlDirection = computeRTLDirection(this.hass); + + const slider = + stateObj.state === UNAVAILABLE + ? html` ` + : html` + + `; + + const setValues = svg` + + + ${ + stateObj.state === UNAVAILABLE + ? this.hass.localize("state.default.unavailable") + : this._setHum === undefined || this._setHum === null + ? "" + : svg` + ${this._setHum.toFixed()} + ` + } + + % + + + + + + + ${this.hass!.localize(`state.default.${stateObj.state}`)} + ${ + stateObj.attributes.mode + ? html` + - + ${this.hass!.localize( + `state_attributes.humidifier.mode.${stateObj.attributes.mode}` + ) || stateObj.attributes.mode} + ` + : "" + } + + + + `; + + return html` + + + +
+
+
+ ${slider} +
+
+ ${setValues} +
+
+
+
+
+ ${name} +
+
+
+ `; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + return hasConfigOrEntityChanged(this, changedProps); + } + + protected updated(changedProps: PropertyValues): void { + super.updated(changedProps); + + if ( + !this._config || + !this.hass || + (!changedProps.has("hass") && !changedProps.has("_config")) + ) { + return; + } + + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + const oldConfig = changedProps.get("_config") as + | HumidifierCardConfig + | undefined; + + if ( + !oldHass || + !oldConfig || + oldHass.themes !== this.hass.themes || + oldConfig.theme !== this._config.theme + ) { + applyThemesOnElement(this, this.hass.themes, this._config.theme); + } + + const stateObj = this.hass.states[this._config.entity]; + if (!stateObj) { + return; + } + + if (!oldHass || oldHass.states[this._config.entity] !== stateObj) { + this._setHum = this._getSetHum(stateObj); + this._rescale_svg(); + } + } + + private _rescale_svg() { + // Set the viewbox of the SVG containing the set humidity to perfectly + // fit the text + // That way it will auto-scale correctly + // This is not done to the SVG containing the current humidity, because + // it should not be centered on the text, but only on the value + if (this.shadowRoot && this.shadowRoot.querySelector("ha-card")) { + (this.shadowRoot.querySelector( + "ha-card" + ) as LitElement).updateComplete.then(() => { + const svgRoot = this.shadowRoot!.querySelector("#set-values"); + const box = svgRoot!.querySelector("g")!.getBBox(); + svgRoot!.setAttribute( + "viewBox", + `${box!.x} ${box!.y} ${box!.width} ${box!.height}` + ); + svgRoot!.setAttribute("width", `${box!.width}`); + svgRoot!.setAttribute("height", `${box!.height}`); + }); + } + } + + private _getSetHum( + stateObj: HassEntity + ): undefined | number | [number, number] { + if (stateObj.state === UNAVAILABLE) { + return undefined; + } + + return stateObj.attributes.humidity; + } + + private _dragEvent(e): void { + this._setHum = e.detail.value; + } + + private _setHumidity(e): void { + this.hass!.callService("humidifier", "set_humidity", { + entity_id: this._config!.entity, + humidity: e.detail.value, + }); + } + + private _handleMoreInfo() { + fireEvent(this, "hass-more-info", { + entityId: this._config!.entity, + }); + } + + static get styles(): CSSResult { + return css` + :host { + display: block; + } + + ha-card { + height: 100%; + position: relative; + overflow: hidden; + --name-font-size: 1.2rem; + --brightness-font-size: 1.2rem; + --rail-border-color: transparent; + } + + .more-info { + position: absolute; + cursor: pointer; + top: 0; + right: 0; + border-radius: 100%; + color: var(--secondary-text-color); + z-index: 25; + } + + .content { + height: 100%; + display: flex; + flex-direction: column; + justify-content: center; + } + + #controls { + display: flex; + justify-content: center; + padding: 16px; + position: relative; + } + + #slider { + height: 100%; + width: 100%; + position: relative; + max-width: 250px; + min-width: 100px; + } + + round-slider { + --round-slider-path-color: var(--disabled-text-color); + --round-slider-bar-color: var(--mode-color); + padding-bottom: 10%; + } + + #slider-center { + position: absolute; + width: calc(100% - 40px); + height: calc(100% - 40px); + box-sizing: border-box; + border-radius: 100%; + left: 20px; + top: 20px; + text-align: center; + overflow-wrap: break-word; + pointer-events: none; + } + + #humidity { + position: absolute; + transform: translate(-50%, -50%); + width: 100%; + height: 50%; + top: 45%; + left: 50%; + } + + #set-values { + max-width: 80%; + transform: translate(0, -50%); + font-size: 20px; + } + + #set-mode { + fill: var(--secondary-text-color); + font-size: 16px; + } + + #info { + display: flex-vertical; + justify-content: center; + text-align: center; + padding: 16px; + margin-top: -60px; + font-size: var(--name-font-size); + } + + #modes > * { + color: var(--disabled-text-color); + cursor: pointer; + display: inline-block; + } + + #modes .selected-icon { + color: var(--mode-color); + } + + text { + fill: var(--primary-text-color); + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-humidifier-card": HuiHumidifierCard; + } +} diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index 3ce3342309..c003fb32ab 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -133,6 +133,12 @@ export interface GlanceCardConfig extends LovelaceCardConfig { state_color?: boolean; } +export interface HumidifierCardConfig extends LovelaceCardConfig { + entity: string; + theme?: string; + name?: string; +} + export interface IframeCardConfig extends LovelaceCardConfig { aspect_ratio?: string; title?: string; diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 8941ed45d2..9b3f1c830b 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -37,6 +37,7 @@ import { GroupEntity, HomeAssistant } from "../../../types"; import { AlarmPanelCardConfig, EntitiesCardConfig, + HumidifierCardConfig, LightCardConfig, PictureEntityCardConfig, ThermostatCardConfig, @@ -150,6 +151,12 @@ export const computeCards = ( refresh_interval: stateObj.attributes.refresh, }; cards.push(cardConfig); + } else if (domain === "humidifier") { + const cardConfig: HumidifierCardConfig = { + type: "humidifier", + entity: entityId, + }; + cards.push(cardConfig); } else if (domain === "light" && single) { const cardConfig: LightCardConfig = { type: "light", diff --git a/src/panels/lovelace/create-element/create-card-element.ts b/src/panels/lovelace/create-element/create-card-element.ts index cb5748eecf..e06db4c345 100644 --- a/src/panels/lovelace/create-element/create-card-element.ts +++ b/src/panels/lovelace/create-element/create-card-element.ts @@ -6,6 +6,7 @@ import "../cards/hui-entity-card"; import "../cards/hui-glance-card"; import "../cards/hui-history-graph-card"; import "../cards/hui-horizontal-stack-card"; +import "../cards/hui-humidifier-card"; import "../cards/hui-light-card"; import "../cards/hui-sensor-card"; import "../cards/hui-thermostat-card"; @@ -24,6 +25,7 @@ const ALWAYS_LOADED_TYPES = new Set([ "glance", "history-graph", "horizontal-stack", + "humidifier", "light", "sensor", "thermostat", diff --git a/src/panels/lovelace/create-element/create-row-element.ts b/src/panels/lovelace/create-element/create-row-element.ts index 80533bf0e5..433b50e0a5 100644 --- a/src/panels/lovelace/create-element/create-row-element.ts +++ b/src/panels/lovelace/create-element/create-row-element.ts @@ -24,6 +24,7 @@ const LAZY_LOAD_TYPES = { "climate-entity": () => import("../entity-rows/hui-climate-entity-row"), "cover-entity": () => import("../entity-rows/hui-cover-entity-row"), "group-entity": () => import("../entity-rows/hui-group-entity-row"), + "humidifier-entity": () => import("../entity-rows/hui-humidifier-entity-row"), "input-datetime-entity": () => import("../entity-rows/hui-input-datetime-entity-row"), "input-number-entity": () => @@ -51,6 +52,7 @@ const DOMAIN_TO_ELEMENT_TYPE = { cover: "cover", fan: "toggle", group: "group", + humidifier: "humidifier", input_boolean: "toggle", input_number: "input-number", input_select: "input-select", diff --git a/src/panels/lovelace/editor/config-elements/hui-humidifier-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-humidifier-card-editor.ts new file mode 100644 index 0000000000..b293953407 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-humidifier-card-editor.ts @@ -0,0 +1,117 @@ +import "@polymer/paper-input/paper-input"; +import { + customElement, + html, + LitElement, + property, + TemplateResult, +} from "lit-element"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/entity/ha-entity-picker"; +import { HomeAssistant } from "../../../../types"; +import { HumidifierCardConfig } from "../../cards/types"; +import { struct } from "../../common/structs/struct"; +import "../../components/hui-theme-select-editor"; +import { LovelaceCardEditor } from "../../types"; +import { EditorTarget, EntitiesEditorEvent } from "../types"; +import { configElementStyle } from "./config-elements-style"; + +const cardConfigStruct = struct({ + type: "string", + entity: "string", + name: "string?", + theme: "string?", +}); + +const includeDomains = ["humidifier"]; + +@customElement("hui-humidifier-card-editor") +export class HuiHumidifierCardEditor extends LitElement + implements LovelaceCardEditor { + @property() public hass?: HomeAssistant; + + @property() private _config?: HumidifierCardConfig; + + public setConfig(config: HumidifierCardConfig): void { + config = cardConfigStruct(config); + this._config = config; + } + + get _entity(): string { + return this._config!.entity || ""; + } + + get _name(): string { + return this._config!.name || ""; + } + + get _theme(): string { + return this._config!.theme || ""; + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + return html` + ${configElementStyle} +
+ + + +
+ `; + } + + private _valueChanged(ev: EntitiesEditorEvent): void { + if (!this._config || !this.hass) { + return; + } + const target = ev.target! as EditorTarget; + + if (this[`_${target.configValue}`] === target.value) { + return; + } + if (target.configValue) { + if (target.value === "") { + delete this._config[target.configValue!]; + } else { + this._config = { ...this._config, [target.configValue!]: target.value }; + } + } + fireEvent(this, "config-changed", { config: this._config }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-humidifier-card-editor": HuiHumidifierCardEditor; + } +} diff --git a/src/panels/lovelace/editor/lovelace-cards.ts b/src/panels/lovelace/editor/lovelace-cards.ts index 210f3f834d..9d777fa942 100644 --- a/src/panels/lovelace/editor/lovelace-cards.ts +++ b/src/panels/lovelace/editor/lovelace-cards.ts @@ -29,6 +29,10 @@ export const coreCards: Card[] = [ type: "history-graph", showElement: true, }, + { + type: "humidifier", + showElement: true, + }, { type: "light", showElement: true, diff --git a/src/panels/lovelace/entity-rows/hui-humidifier-entity-row.ts b/src/panels/lovelace/entity-rows/hui-humidifier-entity-row.ts new file mode 100644 index 0000000000..9a0e97522a --- /dev/null +++ b/src/panels/lovelace/entity-rows/hui-humidifier-entity-row.ts @@ -0,0 +1,74 @@ +import { + css, + CSSResult, + customElement, + html, + LitElement, + property, + PropertyValues, + TemplateResult, +} from "lit-element"; +import "../../../components/ha-humidifier-state"; +import { HomeAssistant } from "../../../types"; +import { hasConfigOrEntityChanged } from "../common/has-changed"; +import "../components/hui-generic-entity-row"; +import { createEntityNotFoundWarning } from "../components/hui-warning"; +import { EntityConfig, LovelaceRow } from "./types"; + +@customElement("hui-humidifier-entity-row") +class HuiHumidifierEntityRow extends LitElement implements LovelaceRow { + @property() public hass?: HomeAssistant; + + @property() private _config?: EntityConfig; + + public setConfig(config: EntityConfig): void { + if (!config || !config.entity) { + throw new Error("Invalid Configuration: 'entity' required"); + } + + this._config = config; + } + + protected shouldUpdate(changedProps: PropertyValues): boolean { + return hasConfigOrEntityChanged(this, changedProps); + } + + protected render(): TemplateResult { + if (!this.hass || !this._config) { + return html``; + } + + const stateObj = this.hass.states[this._config.entity]; + + if (!stateObj) { + return html` + + ${createEntityNotFoundWarning(this.hass, this._config.entity)} + + `; + } + + return html` + + + + `; + } + + static get styles(): CSSResult { + return css` + ha-humidifier-state { + text-align: right; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-humidifier-entity-row": HuiHumidifierEntityRow; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 66b9e0441e..ab158f34c7 100755 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -44,6 +44,19 @@ "idle": "Idle", "fan": "Fan" } + }, + "humidifier": { + "mode": { + "normal": "Normal", + "eco": "Eco", + "away": "Away", + "boost": "Boost", + "comfort": "Comfort", + "home": "Home", + "sleep": "Sleep", + "auto": "Auto", + "baby": "Baby" + } } }, "state_badge": { @@ -146,6 +159,11 @@ "forward": "Forward", "reverse": "Reverse" }, + "humidifier": { + "mode": "Mode", + "target_humidity_entity": "{name} target humidity", + "on_entity": "{name} on" + }, "light": { "brightness": "Brightness", "color_temperature": "Color temperature", @@ -1856,6 +1874,10 @@ "name": "Horizontal Stack", "description": "The Horizontal Stack card allows you to stack together multiple cards, so they always sit next to each other in the space of one column." }, + "humidifier": { + "name": "Humidifier", + "description": "The Humidifier card gives control of your humidifier entity. Allowing you to change the humidity and mode of the entity." + }, "iframe": { "name": "Webpage", "description": "The Webpage card allows you to embed your favorite webpage right into Home Assistant." diff --git a/src/util/hass-attributes-util.js b/src/util/hass-attributes-util.js index 05fa3ebb63..04f8ab2701 100644 --- a/src/util/hass-attributes-util.js +++ b/src/util/hass-attributes-util.js @@ -89,7 +89,7 @@ hassAttributeUtil.LOGIC_STATE_ATTRIBUTES = hassAttributeUtil.LOGIC_STATE_ATTRIBU type: "array", options: hassAttributeUtil.DOMAIN_DEVICE_CLASS, description: "Device class", - domains: ["binary_sensor", "cover", "sensor", "switch"], + domains: ["binary_sensor", "cover", "humidifier", "sensor", "switch"], }, hidden: { type: "boolean", description: "Hide from UI" }, assumed_state: { @@ -100,6 +100,7 @@ hassAttributeUtil.LOGIC_STATE_ATTRIBUTES = hassAttributeUtil.LOGIC_STATE_ATTRIBU "cover", "climate", "fan", + "humidifier", "group", "water_heater", ],