diff --git a/gallery/src/pages/more-info/humidifier.markdown b/gallery/src/pages/more-info/humidifier.markdown new file mode 100644 index 0000000000..68d1c9588c --- /dev/null +++ b/gallery/src/pages/more-info/humidifier.markdown @@ -0,0 +1,3 @@ +--- +title: Humidifier +--- diff --git a/gallery/src/pages/more-info/humidifier.ts b/gallery/src/pages/more-info/humidifier.ts new file mode 100644 index 0000000000..024b4dae7a --- /dev/null +++ b/gallery/src/pages/more-info/humidifier.ts @@ -0,0 +1,57 @@ +import { html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import "../../../../src/components/ha-card"; +import "../../../../src/dialogs/more-info/more-info-content"; +import { getEntity } from "../../../../src/fake_data/entity"; +import { + MockHomeAssistant, + provideHass, +} from "../../../../src/fake_data/provide_hass"; +import "../../components/demo-more-infos"; + +const ENTITIES = [ + getEntity("humidifier", "humidifier", "on", { + friendly_name: "Humidifier", + device_class: "humidifier", + current_humidity: 50, + humidity: 30, + }), + getEntity("humidifier", "dehumidifier", "on", { + friendly_name: "Dehumidifier", + device_class: "dehumidifier", + current_humidity: 50, + humidity: 30, + }), + getEntity("humidifier", "unavailable", "unavailable", { + friendly_name: "Unavailable humidifier", + }), +]; + +@customElement("demo-more-info-humidifier") +class DemoMoreInfoHumidifier extends LitElement { + @property() public hass!: MockHomeAssistant; + + @query("demo-more-infos") private _demoRoot!: HTMLElement; + + protected render(): TemplateResult { + return html` + ent.entityId)} + > + `; + } + + protected firstUpdated(changedProperties: PropertyValues) { + super.firstUpdated(changedProperties); + const hass = provideHass(this._demoRoot); + hass.updateTranslations(null, "en"); + hass.addEntities(ENTITIES); + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-more-info-humidifier": DemoMoreInfoHumidifier; + } +} diff --git a/src/data/humidifier.ts b/src/data/humidifier.ts index 90d70f68d4..4fa99ac248 100644 --- a/src/data/humidifier.ts +++ b/src/data/humidifier.ts @@ -1,9 +1,25 @@ +import { + mdiAccountArrowRight, + mdiAirHumidifier, + mdiArrowDownBold, + mdiArrowUpBold, + mdiBabyCarriage, + mdiClockOutline, + mdiHome, + mdiLeaf, + mdiPower, + mdiPowerSleep, + mdiRefreshAuto, + mdiRocketLaunch, + mdiSofa, + mdiWaterPercent, +} from "@mdi/js"; import { HassEntityAttributeBase, HassEntityBase, } from "home-assistant-js-websocket"; -export type HumidifierState = "on" | "off"; +export type HumidifierState = "off" | "on"; export type HumidifierAction = "off" | "idle" | "humidifying" | "drying"; @@ -19,7 +35,52 @@ export type HumidifierEntity = HassEntityBase & { }; }; -export const HUMIDIFIER_SUPPORT_MODES = 1; +export const enum HumidifierEntityFeature { + MODES = 1, +} -export const HUMIDIFIER_DEVICE_CLASS_HUMIDIFIER = "humidifier"; -export const HUMIDIFIER_DEVICE_CLASS_DEHUMIDIFIER = "dehumidifier"; +export const enum HumidifierEntityDeviceClass { + HUMIDIFIER = "humidifier", + DEHUMIDIFIER = "dehumidifier", +} + +type HumidifierBuiltInMode = + | "normal" + | "eco" + | "away" + | "boost" + | "comfort" + | "home" + | "sleep" + | "auto" + | "baby"; + +export const HUMIDIFIER_MODE_ICONS: Record = { + auto: mdiRefreshAuto, + away: mdiAccountArrowRight, + baby: mdiBabyCarriage, + boost: mdiRocketLaunch, + comfort: mdiSofa, + eco: mdiLeaf, + home: mdiHome, + normal: mdiWaterPercent, + sleep: mdiPowerSleep, +}; + +export const computeHumidiferModeIcon = (mode?: string) => + HUMIDIFIER_MODE_ICONS[mode as HumidifierBuiltInMode] ?? mdiAirHumidifier; + +export const HUMIDIFIER_ACTION_ICONS: Record = { + drying: mdiArrowDownBold, + humidifying: mdiArrowUpBold, + idle: mdiClockOutline, + off: mdiPower, +}; + +export const HUMIDIFIER_ACTION_MODE: Record = + { + drying: "on", + humidifying: "on", + idle: "off", + off: "off", + }; diff --git a/src/dialogs/more-info/components/humidifier/ha-more-info-humidifier-humidity.ts b/src/dialogs/more-info/components/humidifier/ha-more-info-humidifier-humidity.ts new file mode 100644 index 0000000000..3f9bfaefb7 --- /dev/null +++ b/src/dialogs/more-info/components/humidifier/ha-more-info-humidifier-humidity.ts @@ -0,0 +1,334 @@ +import { mdiMinus, mdiPlus } from "@mdi/js"; +import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { computeAttributeValueDisplay } from "../../../../common/entity/compute_attribute_display"; +import { stateActive } from "../../../../common/entity/state_active"; +import { stateColorCss } from "../../../../common/entity/state_color"; +import { clamp } from "../../../../common/number/clamp"; +import { formatNumber } from "../../../../common/number/format_number"; +import { blankBeforePercent } from "../../../../common/translations/blank_before_percent"; +import { debounce } from "../../../../common/util/debounce"; +import "../../../../components/ha-control-circular-slider"; +import "../../../../components/ha-outlined-icon-button"; +import "../../../../components/ha-svg-icon"; +import { UNAVAILABLE } from "../../../../data/entity"; +import { + HUMIDIFIER_ACTION_MODE, + HumidifierEntity, + HumidifierEntityDeviceClass, +} from "../../../../data/humidifier"; +import { HomeAssistant } from "../../../../types"; + +@customElement("ha-more-info-humidifier-humidity") +export class HaMoreInfoHumidifierHumidity extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public stateObj!: HumidifierEntity; + + @state() private _targetHumidity?: number; + + protected willUpdate(changedProp: PropertyValues): void { + super.willUpdate(changedProp); + if (changedProp.has("stateObj")) { + this._targetHumidity = this.stateObj.attributes.humidity; + } + } + + private get _step() { + return 1; + } + + private get _min() { + return this.stateObj.attributes.min_humidity ?? 0; + } + + private get _max() { + return this.stateObj.attributes.max_humidity ?? 100; + } + + private _valueChanged(ev: CustomEvent) { + const value = (ev.detail as any).value; + if (isNaN(value)) return; + this._targetHumidity = value; + this._callService(); + } + + private _valueChanging(ev: CustomEvent) { + const value = (ev.detail as any).value; + if (isNaN(value)) return; + this._targetHumidity = value; + } + + private _debouncedCallService = debounce(() => this._callService(), 1000); + + private _callService() { + this.hass.callService("humidifier", "set_humidity", { + entity_id: this.stateObj!.entity_id, + humidity: this._targetHumidity, + }); + } + + private _handleButton(ev) { + const step = ev.currentTarget.step as number; + + let humidity = this._targetHumidity ?? this._min; + humidity += step; + humidity = clamp(humidity, this._min, this._max); + + this._targetHumidity = humidity; + this._debouncedCallService(); + } + + private _renderAction() { + const action = this.stateObj.attributes.action; + + const actionLabel = computeAttributeValueDisplay( + this.hass.localize, + this.stateObj, + this.hass.locale, + this.hass.config, + this.hass.entities, + "action" + ) as string; + + return html` +

+ ${action && ["drying", "humidifying"].includes(action) + ? this.hass.localize( + "ui.dialogs.more_info_control.humidifier.target_label", + { action: actionLabel } + ) + : action && action !== "off" && action !== "idle" + ? actionLabel + : this.hass.localize( + "ui.dialogs.more_info_control.humidifier.target" + )} +

+ `; + } + + private _renderButtons() { + return html` +
+ + + + + + +
+ `; + } + + private _renderTarget(humidity: number) { + const formatted = formatNumber(humidity, this.hass.locale, { + maximumFractionDigits: 0, + }); + + return html` +
+ +

+ ${formatted}${blankBeforePercent(this.hass.locale)}% +

+
+ `; + } + + protected render() { + const mainColor = stateColorCss(this.stateObj); + const active = stateActive(this.stateObj); + + const action = this.stateObj.attributes.action; + + let actionColor: string | undefined; + if (action && action !== "idle" && action !== "off" && active) { + actionColor = stateColorCss( + this.stateObj, + HUMIDIFIER_ACTION_MODE[action] + ); + } + + const targetHumidity = this._targetHumidity; + const currentHumidity = this.stateObj.attributes.current_humidity; + + if (targetHumidity != null) { + const inverted = + this.stateObj.attributes.device_class === + HumidifierEntityDeviceClass.DEHUMIDIFIER; + + return html` +
+ + +
+
${this._renderAction()}
+
+ ${this._renderTarget(targetHumidity)} +
+
+ ${this._renderButtons()} +
+ `; + } + + return html` +
+ + +
+ `; + } + + static get styles(): CSSResultGroup { + return css` + /* Layout */ + .container { + position: relative; + } + .info { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + pointer-events: none; + font-size: 16px; + line-height: 24px; + letter-spacing: 0.1px; + } + .info * { + margin: 0; + pointer-events: auto; + } + /* Elements */ + .target-container { + margin-bottom: 30px; + } + .target .value { + font-size: 56px; + line-height: 1; + letter-spacing: -0.25px; + } + .target .value .unit { + font-size: 0.4em; + line-height: 1; + margin-left: 2px; + } + .action-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + width: 200px; + height: 48px; + margin-bottom: 6px; + } + .action { + font-weight: 500; + text-align: center; + color: var(--action-color, inherit); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .buttons { + position: absolute; + bottom: 10px; + left: 0; + right: 0; + margin: 0 auto; + width: 120px; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + } + .buttons ha-outlined-icon-button { + --md-outlined-icon-button-container-size: 48px; + --md-outlined-icon-button-icon-size: 24px; + } + /* Accessibility */ + .visually-hidden { + position: absolute; + overflow: hidden; + clip: rect(0 0 0 0); + height: 1px; + width: 1px; + margin: -1px; + padding: 0; + border: 0; + } + /* Slider */ + ha-control-circular-slider { + --control-circular-slider-color: var( + --main-color, + var(--disabled-color) + ); + } + ha-control-circular-slider::after { + display: block; + content: ""; + position: absolute; + top: -10%; + left: -10%; + right: -10%; + bottom: -10%; + background: radial-gradient( + 50% 50% at 50% 50%, + var(--action-color, transparent) 0%, + transparent 100% + ); + opacity: 0.15; + pointer-events: none; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-more-info-humidifier-humidity": HaMoreInfoHumidifierHumidity; + } +} diff --git a/src/dialogs/more-info/const.ts b/src/dialogs/more-info/const.ts index 37295e1767..580faa2e40 100644 --- a/src/dialogs/more-info/const.ts +++ b/src/dialogs/more-info/const.ts @@ -21,6 +21,7 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [ "cover", "climate", "fan", + "humidifier", "input_boolean", "light", "lock", diff --git a/src/dialogs/more-info/controls/more-info-humidifier.ts b/src/dialogs/more-info/controls/more-info-humidifier.ts index 75dba4faf8..d3b8a14e54 100644 --- a/src/dialogs/more-info/controls/more-info-humidifier.ts +++ b/src/dialogs/more-info/controls/more-info-humidifier.ts @@ -1,37 +1,45 @@ -import "@material/mwc-list/mwc-list-item"; import { - css, CSSResultGroup, - html, LitElement, PropertyValues, + css, + html, nothing, } from "lit"; -import { property } from "lit/decorators"; +import { property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { fireEvent } from "../../../common/dom/fire_event"; +import { stopPropagation } from "../../../common/dom/stop_propagation"; import { computeAttributeNameDisplay, computeAttributeValueDisplay, } from "../../../common/entity/compute_attribute_display"; -import { stopPropagation } from "../../../common/dom/stop_propagation"; +import { computeStateDisplay } from "../../../common/entity/compute_state_display"; import { supportsFeature } from "../../../common/entity/supports-feature"; -import { computeRTLDirection } from "../../../common/util/compute_rtl"; -import "../../../components/ha-select"; -import "../../../components/ha-slider"; -import "../../../components/ha-switch"; +import { formatNumber } from "../../../common/number/format_number"; +import { blankBeforePercent } from "../../../common/translations/blank_before_percent"; import { HumidifierEntity, - HUMIDIFIER_SUPPORT_MODES, + HumidifierEntityFeature, } from "../../../data/humidifier"; import { HomeAssistant } from "../../../types"; -import { computeStateDisplay } from "../../../common/entity/compute_state_display"; +import { moreInfoControlStyle } from "../components/ha-more-info-control-style"; +import "../components/humidifier/ha-more-info-humidifier-humidity"; class MoreInfoHumidifier extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @property() public stateObj?: HumidifierEntity; + @state() public _mode?: string; + + protected willUpdate(changedProps: PropertyValues): void { + super.willUpdate(changedProps); + if (changedProps.has("stateObj")) { + this._mode = this.stateObj?.attributes.mode; + } + } + private _resizeDebounce?: number; protected render() { @@ -42,43 +50,52 @@ class MoreInfoHumidifier extends LitElement { const hass = this.hass; const stateObj = this.stateObj; - const supportModes = supportsFeature(stateObj, HUMIDIFIER_SUPPORT_MODES); + const supportModes = supportsFeature( + stateObj, + HumidifierEntityFeature.MODES + ); - const rtlDirection = computeRTLDirection(hass); + const currentHumidity = this.stateObj.attributes.current_humidity; return html` + ${currentHumidity + ? html`
+ ${currentHumidity != null + ? html` +
+

+ ${computeAttributeNameDisplay( + this.hass.localize, + this.stateObj, + this.hass.entities, + "current_humidity" + )} +

+

+ ${formatNumber( + currentHumidity, + this.hass.locale + )}${blankBeforePercent(this.hass.locale)}% +

+
+ ` + : nothing} +
` + : nothing} +
+ +
-
-
- ${computeAttributeNameDisplay( - hass.localize, - stateObj, - hass.entities, - "humidity" - )} -
-
-
${stateObj.attributes.humidity} %
- - -
-
${computeStateDisplay( - hass.localize, - stateObj, - hass.locale, + this.hass.localize, + this.stateObj, + this.hass.locale, this.hass.config, - hass.entities, + this.hass.entities, "off" )} ${computeStateDisplay( - hass.localize, - stateObj, - hass.locale, + this.hass.localize, + this.stateObj, + this.hass.locale, this.hass.config, - hass.entities, + this.hass.entities, "on" )} @@ -114,6 +131,7 @@ class MoreInfoHumidifier extends LitElement { fixedMenuPosition naturalMenuWidth @selected=${this._handleModeChanged} + @action=${this._handleModeChanged} @closed=${stopPropagation} > ${stateObj.attributes.available_modes!.map( @@ -121,9 +139,9 @@ class MoreInfoHumidifier extends LitElement { ${computeAttributeValueDisplay( hass.localize, - stateObj, + stateObj!, hass.locale, - this.hass.config, + hass.config, hass.entities, "mode", mode @@ -133,7 +151,7 @@ class MoreInfoHumidifier extends LitElement { )} ` - : ""} + : nothing}
`; } @@ -153,16 +171,6 @@ class MoreInfoHumidifier extends LitElement { }, 500); } - private _targetHumiditySliderChanged(ev) { - const newVal = ev.target.value; - this._callServiceHelper( - this.stateObj!.attributes.humidity, - newVal, - "set_humidity", - { humidity: newVal } - ); - } - private _handleStateChanged(ev) { const newVal = ev.target.value || null; this._callServiceHelper( @@ -173,16 +181,6 @@ class MoreInfoHumidifier extends LitElement { ); } - private _handleModeChanged(ev) { - const newVal = ev.target.value || null; - this._callServiceHelper( - this.stateObj!.attributes.mode, - newVal, - "set_mode", - { mode: newVal } - ); - } - private async _callServiceHelper( oldVal: unknown, newVal: unknown, @@ -221,37 +219,71 @@ class MoreInfoHumidifier extends LitElement { } } + private _handleModeChanged(ev) { + ev.stopPropagation(); + ev.preventDefault(); + + const index = ev.detail.index; + const newVal = this.stateObj!.attributes.available_modes![index]; + const oldVal = this._mode; + + if (!newVal || oldVal === newVal) return; + + this._mode = newVal; + this.hass.callService("humidifier", "set_mode", { + entity_id: this.stateObj!.entity_id, + mode: newVal, + }); + } + static get styles(): CSSResultGroup { - return css` - :host { - color: var(--primary-text-color); - } + return [ + moreInfoControlStyle, + css` + :host { + color: var(--primary-text-color); + } - ha-select { - width: 100%; - margin-top: 8px; - } + .current { + display: flex; + flex-direction: row; + align-items: center; + justify-content: center; + text-align: center; + margin-bottom: 40px; + } + .current div { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + flex: 1; + } + .current p { + margin: 0; + text-align: center; + color: var(--primary-text-color); + } + .current .label { + opacity: 0.8; + font-size: 14px; + line-height: 16px; + letter-spacing: 0.4px; + margin-bottom: 4px; + } + .current .value { + font-size: 22px; + font-weight: 500; + line-height: 28px; + } - ha-slider { - width: 100%; - } - - .container-humidity .single-row { - display: flex; - height: 50px; - } - - .target-humidity { - width: 90px; - font-size: 200%; - margin: auto; - direction: ltr; - } - - .single-row { - padding: 8px 0; - } - `; + ha-select { + width: 100%; + margin-top: 8px; + } + `, + ]; } } diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index f9b10c65a9..bcde89cfa6 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -225,6 +225,15 @@ export class HuiTileCard extends LitElement implements LovelaceCard { } } + if (domain === "humidifier" && stateActive(stateObj)) { + const humidity = (stateObj as HumidifierEntity).attributes.humidity; + if (humidity) { + return `${Math.round(humidity)}${blankBeforePercent( + this.hass!.locale + )}%`; + } + } + const stateDisplay = computeStateDisplay( this.hass!.localize, stateObj, diff --git a/src/panels/lovelace/cards/tile/badges/tile-badge-humidifier.ts b/src/panels/lovelace/cards/tile/badges/tile-badge-humidifier.ts index c4901e0b2d..709f4e7c68 100644 --- a/src/panels/lovelace/cards/tile/badges/tile-badge-humidifier.ts +++ b/src/panels/lovelace/cards/tile/badges/tile-badge-humidifier.ts @@ -1,32 +1,11 @@ -import { - mdiArrowDownBold, - mdiArrowUpBold, - mdiClockOutline, - mdiPower, -} from "@mdi/js"; import { stateColorCss } from "../../../../../common/entity/state_color"; import { - HumidifierAction, + HUMIDIFIER_ACTION_ICONS, + HUMIDIFIER_ACTION_MODE, HumidifierEntity, - HumidifierState, } from "../../../../../data/humidifier"; import { ComputeBadgeFunction } from "./tile-badge"; -export const HUMIDIFIER_ACTION_ICONS: Record = { - drying: mdiArrowDownBold, - humidifying: mdiArrowUpBold, - idle: mdiClockOutline, - off: mdiPower, -}; - -export const HUMIDIFIER_ACTION_MODE: Record = - { - drying: "on", - humidifying: "on", - idle: "off", - off: "off", - }; - export const computeHumidifierBadge: ComputeBadgeFunction = (stateObj) => { const hvacAction = (stateObj as HumidifierEntity).attributes.action; diff --git a/src/translations/en.json b/src/translations/en.json index 7eb6025147..d8d67cb1ee 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -1000,6 +1000,10 @@ "climate": { "target_label": "{action} to target", "target": "Target" + }, + "humidifier": { + "target_label": "[%key:ui::dialogs::more_info_control::climate::target_label%]", + "target": "[%key:ui::dialogs::more_info_control::climate::target%]" } }, "entity_registry": {