From 7040c6d469ec8531fd8014102782eb6b25696a41 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Tue, 29 Aug 2023 16:36:07 +0200 Subject: [PATCH] Add target temperature tile feature for climate and water heater (#17697) --- .../ha-control-number-buttons.markdown | 3 + .../components/ha-control-number-buttons.ts | 100 +++++++ src/common/number/clamp.ts | 4 +- src/components/ha-control-number-buttons.ts | 258 ++++++++++++++++++ .../ha-more-info-climate-temperature.ts | 3 +- .../ha-more-info-water_heater-temperature.ts | 3 +- .../create-tile-feature-element.ts | 14 +- .../hui-tile-card-features-editor.ts | 9 +- .../hui-target-temperature-tile-feature.ts | 254 +++++++++++++++++ src/panels/lovelace/tile-features/types.ts | 5 + src/translations/en.json | 3 + 11 files changed, 644 insertions(+), 12 deletions(-) create mode 100644 gallery/src/pages/components/ha-control-number-buttons.markdown create mode 100644 gallery/src/pages/components/ha-control-number-buttons.ts create mode 100644 src/components/ha-control-number-buttons.ts create mode 100644 src/panels/lovelace/tile-features/hui-target-temperature-tile-feature.ts diff --git a/gallery/src/pages/components/ha-control-number-buttons.markdown b/gallery/src/pages/components/ha-control-number-buttons.markdown new file mode 100644 index 000000000000..1c06f0ade3d5 --- /dev/null +++ b/gallery/src/pages/components/ha-control-number-buttons.markdown @@ -0,0 +1,3 @@ +--- +title: Control Number Buttons +--- diff --git a/gallery/src/pages/components/ha-control-number-buttons.ts b/gallery/src/pages/components/ha-control-number-buttons.ts new file mode 100644 index 000000000000..8afa9ee487a0 --- /dev/null +++ b/gallery/src/pages/components/ha-control-number-buttons.ts @@ -0,0 +1,100 @@ +import { LitElement, TemplateResult, css, html } from "lit"; +import { customElement, state } from "lit/decorators"; +import "../../../../src/components/ha-card"; +import "../../../../src/components/ha-control-number-buttons"; +import { repeat } from "lit/directives/repeat"; +import { ifDefined } from "lit/directives/if-defined"; + +const buttons: { + id: string; + label: string; + min?: number; + max?: number; + step?: number; + class?: string; +}[] = [ + { + id: "basic", + label: "Basic", + }, + { + id: "min_max_step", + label: "With min/max and step", + min: 5, + max: 25, + step: 0.5, + }, + { + id: "custom", + label: "Custom", + class: "custom", + }, +]; + +@customElement("demo-components-ha-control-number-buttons") +export class DemoHarControlNumberButtons extends LitElement { + @state() value = 5; + + private _valueChanged(ev) { + this.value = ev.detail.value; + } + + protected render(): TemplateResult { + return html` + ${repeat(buttons, (button) => { + const { id, label, ...config } = button; + return html` + +
+ +
Config: ${JSON.stringify(config)}
+ + +
+
+ `; + })} + `; + } + + static get styles() { + return css` + ha-card { + max-width: 600px; + margin: 24px auto; + } + pre { + margin-top: 0; + margin-bottom: 8px; + } + p { + margin: 0; + } + label { + font-weight: 600; + } + .custom { + color: #2196f3; + --control-number-buttons-color: #2196f3; + --control-number-buttons-background-color: #2196f3; + --control-number-buttons-background-opacity: 0.1; + --control-number-buttons-thickness: 100px; + --control-number-buttons-border-radius: 24px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "demo-components-ha-control-number-buttons": DemoHarControlNumberButtons; + } +} diff --git a/src/common/number/clamp.ts b/src/common/number/clamp.ts index 5591885f2ebb..e5d6bbcb6391 100644 --- a/src/common/number/clamp.ts +++ b/src/common/number/clamp.ts @@ -4,7 +4,7 @@ export const clamp = (value: number, min: number, max: number) => // Variant that only applies the clamping to a border if the border is defined export const conditionalClamp = (value: number, min?: number, max?: number) => { let result: number; - result = min ? Math.max(value, min) : value; - result = max ? Math.min(result, max) : result; + result = min != null ? Math.max(value, min) : value; + result = max != null ? Math.min(result, max) : result; return result; }; diff --git a/src/components/ha-control-number-buttons.ts b/src/components/ha-control-number-buttons.ts new file mode 100644 index 000000000000..95c16f5fa22e --- /dev/null +++ b/src/components/ha-control-number-buttons.ts @@ -0,0 +1,258 @@ +import { mdiMinus, mdiPlus } from "@mdi/js"; +import { CSSResultGroup, LitElement, TemplateResult, css, html } from "lit"; +import { customElement, property, query } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; +import { conditionalClamp } from "../common/number/clamp"; +import { formatNumber } from "../common/number/format_number"; +import { FrontendLocaleData } from "../data/translation"; +import { fireEvent } from "../common/dom/fire_event"; + +const A11Y_KEY_CODES = new Set([ + "ArrowRight", + "ArrowUp", + "ArrowLeft", + "ArrowDown", + "PageUp", + "PageDown", + "Home", + "End", +]); + +@customElement("ha-control-number-buttons") +export class HaControlNumberButton extends LitElement { + @property({ attribute: false }) public locale?: FrontendLocaleData; + + @property({ type: Boolean, reflect: true }) disabled = false; + + @property() public label?: string; + + @property({ type: Number }) public step?: number; + + @property({ type: Number }) public value?: number; + + @property({ type: Number }) public min?: number; + + @property({ type: Number }) public max?: number; + + @property({ attribute: "false" }) + public formatOptions: Intl.NumberFormatOptions = {}; + + @query("#input") _input!: HTMLDivElement; + + private boundedValue(value: number) { + const clamped = conditionalClamp(value, this.min, this.max); + return Math.round(clamped / this._step) * this._step; + } + + private get _step() { + return this.step ?? 1; + } + + private get _value() { + return this.value ?? 0; + } + + private get _tenPercentStep() { + if (this.max == null || this.min == null) return this._step; + const range = this.max - this.min / 10; + + if (range <= this._step) return this._step; + return Math.max(range / 10); + } + + private _handlePlusButton() { + this._increment(); + fireEvent(this, "value-changed", { value: this.value }); + this._input.focus(); + } + + private _handleMinusButton() { + this._decrement(); + fireEvent(this, "value-changed", { value: this.value }); + this._input.focus(); + } + + private _increment() { + this.value = this.boundedValue(this._value + this._step); + } + + private _decrement() { + this.value = this.boundedValue(this._value - this._step); + } + + _handleKeyDown(e: KeyboardEvent) { + if (!A11Y_KEY_CODES.has(e.code)) return; + e.preventDefault(); + switch (e.code) { + case "ArrowRight": + case "ArrowUp": + this._increment(); + break; + case "ArrowLeft": + case "ArrowDown": + this._decrement(); + break; + case "PageUp": + this.value = this.boundedValue(this._value + this._tenPercentStep); + break; + case "PageDown": + this.value = this.boundedValue(this._value - this._tenPercentStep); + break; + case "Home": + if (this.min != null) { + this.value = this.min; + } + break; + case "End": + if (this.max != null) { + this.value = this.max; + } + break; + } + fireEvent(this, "value-changed", { value: this.value }); + } + + protected render(): TemplateResult { + const displayedValue = + this.value != null + ? formatNumber(this.value, this.locale, this.formatOptions) + : "-"; + + return html` +
+
+ ${displayedValue} +
+ + +
+ `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + --control-number-buttons-focus-color: var(--primary-color); + --control-number-buttons-background-color: var(--disabled-color); + --control-number-buttons-background-opacity: 0.2; + --control-number-buttons-border-radius: 10px; + --mdc-icon-size: 16px; + height: 40px; + width: 200px; + color: var(--primary-text-color); + -webkit-tap-highlight-color: transparent; + font-style: normal; + font-weight: 500; + transition: color 180ms ease-in-out; + } + :host([disabled]) { + color: var(--disabled-color); + } + .container { + position: relative; + width: 100%; + height: 100%; + } + .value { + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + position: relative; + width: 100%; + height: 100%; + padding: 0 44px; + border-radius: var(--control-number-buttons-border-radius); + padding: 0; + margin: 0; + box-sizing: border-box; + line-height: 0; + overflow: hidden; + /* For safari border-radius overflow */ + z-index: 0; + font-size: inherit; + color: inherit; + user-select: none; + -webkit-tap-highlight-color: transparent; + outline: none; + } + .value::before { + content: ""; + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + background-color: var(--control-number-buttons-background-color); + transition: + background-color 180ms ease-in-out, + opacity 180ms ease-in-out; + opacity: var(--control-number-buttons-background-opacity); + } + .value:focus-visible { + box-shadow: 0 0 0 2px var(--control-number-buttons-focus-color); + } + .button { + color: inherit; + position: absolute; + top: 0; + bottom: 0; + padding: 0; + width: 35px; + height: 40px; + border: none; + background: none; + cursor: pointer; + outline: none; + } + .button[disabled] { + opacity: 0.4; + pointer-events: none; + } + .button.minus { + left: 0; + } + .button.plus { + right: 0; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-control-number-buttons": HaControlNumberButton; + } +} diff --git a/src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts b/src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts index 1262bb168df9..9207bda5e9d2 100644 --- a/src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts +++ b/src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts @@ -10,6 +10,7 @@ import { import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { styleMap } from "lit/directives/style-map"; +import { UNIT_F } from "../../../../common/const"; import { computeAttributeValueDisplay } from "../../../../common/entity/compute_attribute_display"; import { stateActive } from "../../../../common/entity/state_active"; import { stateColorCss } from "../../../../common/entity/state_color"; @@ -67,7 +68,7 @@ export class HaMoreInfoClimateTemperature extends LitElement { private get _step() { return ( this.stateObj.attributes.target_temp_step || - (this.hass.config.unit_system.temperature.indexOf("F") === -1 ? 0.5 : 1) + (this.hass.config.unit_system.temperature === UNIT_F ? 1 : 0.5) ); } diff --git a/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts b/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts index 8977e6e00029..f23177a59601 100644 --- a/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts +++ b/src/dialogs/more-info/components/water_heater/ha-more-info-water_heater-temperature.ts @@ -9,6 +9,7 @@ import { } from "lit"; import { customElement, property, state } from "lit/decorators"; import { styleMap } from "lit/directives/style-map"; +import { UNIT_F } from "../../../../common/const"; import { stateActive } from "../../../../common/entity/state_active"; import { stateColorCss } from "../../../../common/entity/state_color"; import { supportsFeature } from "../../../../common/entity/supports-feature"; @@ -44,7 +45,7 @@ export class HaMoreInfoWaterHeaterTemperature extends LitElement { private get _step() { return ( this.stateObj.attributes.target_temp_step || - (this.hass.config.unit_system.temperature.indexOf("F") === -1 ? 0.5 : 1) + (this.hass.config.unit_system.temperature === UNIT_F ? 1 : 0.5) ); } diff --git a/src/panels/lovelace/create-element/create-tile-feature-element.ts b/src/panels/lovelace/create-element/create-tile-feature-element.ts index f85e66037803..41f6e2b699bc 100644 --- a/src/panels/lovelace/create-element/create-tile-feature-element.ts +++ b/src/panels/lovelace/create-element/create-tile-feature-element.ts @@ -1,14 +1,15 @@ import "../tile-features/hui-alarm-modes-tile-feature"; import "../tile-features/hui-climate-hvac-modes-tile-feature"; +import "../tile-features/hui-target-temperature-tile-feature"; import "../tile-features/hui-cover-open-close-tile-feature"; import "../tile-features/hui-cover-position-tile-feature"; import "../tile-features/hui-cover-tilt-position-tile-feature"; import "../tile-features/hui-cover-tilt-tile-feature"; import "../tile-features/hui-fan-speed-tile-feature"; +import "../tile-features/hui-lawn-mower-commands-tile-feature"; import "../tile-features/hui-light-brightness-tile-feature"; import "../tile-features/hui-light-color-temp-tile-feature"; import "../tile-features/hui-vacuum-commands-tile-feature"; -import "../tile-features/hui-lawn-mower-commands-tile-feature"; import "../tile-features/hui-water-heater-operation-modes-tile-feature"; import { LovelaceTileFeatureConfig } from "../tile-features/types"; import { @@ -17,17 +18,18 @@ import { } from "./create-element-base"; const TYPES: Set = new Set([ + "alarm-modes", + "climate-hvac-modes", "cover-open-close", "cover-position", - "cover-tilt", "cover-tilt-position", + "cover-tilt", + "fan-speed", + "lawn-mower-commands", "light-brightness", "light-color-temp", + "target-temperature", "vacuum-commands", - "lawn-mower-commands", - "fan-speed", - "alarm-modes", - "climate-hvac-modes", "water-heater-operation-modes", ]); diff --git a/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts index 6a38b0a67214..8f20d3afa081 100644 --- a/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-tile-card-features-editor.ts @@ -37,6 +37,7 @@ import { supportsLightColorTempTileFeature } from "../../tile-features/hui-light import { supportsVacuumCommandTileFeature } from "../../tile-features/hui-vacuum-commands-tile-feature"; import { supportsWaterHeaterOperationModesTileFeature } from "../../tile-features/hui-water-heater-operation-modes-tile-feature"; import { LovelaceTileFeatureConfig } from "../../tile-features/types"; +import { supportsTargetTemperatureTileFeature } from "../../tile-features/hui-target-temperature-tile-feature"; type FeatureType = LovelaceTileFeatureConfig["type"]; type SupportsFeature = (stateObj: HassEntity) => boolean; @@ -44,6 +45,7 @@ type SupportsFeature = (stateObj: HassEntity) => boolean; const FEATURE_TYPES: FeatureType[] = [ "alarm-modes", "climate-hvac-modes", + "target-temperature", "cover-open-close", "cover-position", "cover-tilt-position", @@ -76,6 +78,7 @@ const SUPPORTS_FEATURE_TYPES: Record = "lawn-mower-commands": supportsLawnMowerCommandTileFeature, "light-brightness": supportsLightBrightnessTileFeature, "light-color-temp": supportsLightColorTempTileFeature, + "target-temperature": supportsTargetTemperatureTileFeature, "vacuum-commands": supportsVacuumCommandTileFeature, "water-heater-operation-modes": supportsWaterHeaterOperationModesTileFeature, @@ -151,8 +154,10 @@ export class HuiTileCardFeaturesEditor extends LitElement { const customFeatureEntry = CUSTOM_FEATURE_ENTRIES[customType]; return customFeatureEntry?.name || type; } - return this.hass!.localize( - `ui.panel.lovelace.editor.card.tile.features.types.${type}.label` + return ( + this.hass!.localize( + `ui.panel.lovelace.editor.card.tile.features.types.${type}.label` + ) || type ); } diff --git a/src/panels/lovelace/tile-features/hui-target-temperature-tile-feature.ts b/src/panels/lovelace/tile-features/hui-target-temperature-tile-feature.ts new file mode 100644 index 000000000000..34fbf1ac8384 --- /dev/null +++ b/src/panels/lovelace/tile-features/hui-target-temperature-tile-feature.ts @@ -0,0 +1,254 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, nothing, PropertyValues } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { styleMap } from "lit/directives/style-map"; +import { UNIT_F } from "../../../common/const"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { computeStateDomain } from "../../../common/entity/compute_state_domain"; +import { stateColorCss } from "../../../common/entity/state_color"; +import { supportsFeature } from "../../../common/entity/supports-feature"; +import { debounce } from "../../../common/util/debounce"; +import "../../../components/ha-control-button-group"; +import "../../../components/ha-control-number-buttons"; +import { ClimateEntity, ClimateEntityFeature } from "../../../data/climate"; +import { UNAVAILABLE } from "../../../data/entity"; +import { + WaterHeaterEntity, + WaterHeaterEntityFeature, +} from "../../../data/water_heater"; +import { HomeAssistant } from "../../../types"; +import { LovelaceTileFeature } from "../types"; +import { TargetTemperatureTileFeatureConfig } from "./types"; + +type Target = "value" | "low" | "high"; + +export const supportsTargetTemperatureTileFeature = (stateObj: HassEntity) => { + const domain = computeDomain(stateObj.entity_id); + return ( + (domain === "climate" && + (supportsFeature(stateObj, ClimateEntityFeature.TARGET_TEMPERATURE) || + supportsFeature( + stateObj, + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ))) || + (domain === "water_heater" && + supportsFeature(stateObj, WaterHeaterEntityFeature.TARGET_TEMPERATURE)) + ); +}; + +@customElement("hui-target-temperature-tile-feature") +class HuiTargetTemperatureTileFeature + extends LitElement + implements LovelaceTileFeature +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public stateObj?: + | ClimateEntity + | WaterHeaterEntity; + + @state() private _config?: TargetTemperatureTileFeatureConfig; + + @state() private _targetTemperature: Partial> = {}; + + static getStubConfig(): TargetTemperatureTileFeatureConfig { + return { + type: "target-temperature", + }; + } + + public setConfig(config: TargetTemperatureTileFeatureConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + protected willUpdate(changedProp: PropertyValues): void { + super.willUpdate(changedProp); + if (changedProp.has("stateObj")) { + this._targetTemperature = { + value: this.stateObj!.attributes.temperature, + low: + "target_temp_low" in this.stateObj!.attributes + ? this.stateObj!.attributes.target_temp_low + : undefined, + high: + "target_temp_high" in this.stateObj!.attributes + ? this.stateObj!.attributes.target_temp_high + : undefined, + }; + } + } + + private get _step() { + return ( + this.stateObj!.attributes.target_temp_step || + (this.hass!.config.unit_system.temperature === UNIT_F ? 1 : 0.5) + ); + } + + private get _min() { + return this.stateObj!.attributes.min_temp; + } + + private get _max() { + return this.stateObj!.attributes.max_temp; + } + + private async _valueChanged(ev: CustomEvent) { + const value = (ev.detail as any).value; + if (isNaN(value)) return; + const target = (ev.currentTarget as any).target ?? "value"; + + this._targetTemperature = { + ...this._targetTemperature, + [target]: value, + }; + this._debouncedCallService(target); + } + + private _debouncedCallService = debounce( + (target: Target) => this._callService(target), + 1000 + ); + + private _callService(type: string) { + const domain = computeStateDomain(this.stateObj!); + if (type === "high" || type === "low") { + this.hass!.callService(domain, "set_temperature", { + entity_id: this.stateObj!.entity_id, + target_temp_low: this._targetTemperature.low, + target_temp_high: this._targetTemperature.high, + }); + return; + } + this.hass!.callService(domain, "set_temperature", { + entity_id: this.stateObj!.entity_id, + temperature: this._targetTemperature.value, + }); + } + + protected render() { + if ( + !this._config || + !this.hass || + !this.stateObj || + !supportsTargetTemperatureTileFeature(this.stateObj) + ) { + return nothing; + } + + const stateColor = stateColorCss(this.stateObj); + const digits = this._step.toString().split(".")?.[1]?.length ?? 0; + + const options = { + maximumFractionDigits: digits, + minimumFractionDigits: digits, + }; + + const domain = computeStateDomain(this.stateObj!); + + if ( + (domain === "climate" && + supportsFeature( + this.stateObj, + ClimateEntityFeature.TARGET_TEMPERATURE + )) || + (domain === "water_heater" && + supportsFeature( + this.stateObj, + WaterHeaterEntityFeature.TARGET_TEMPERATURE + )) + ) { + return html` + + + + + `; + } + + if ( + domain === "climate" && + supportsFeature( + this.stateObj, + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + ) { + return html` + + + + + + + `; + } + + return nothing; + } + + static get styles() { + return css` + ha-control-button-group { + margin: 0 12px 12px 12px; + --control-button-group-spacing: 12px; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-target-temperature-tile-feature": HuiTargetTemperatureTileFeature; + } +} diff --git a/src/panels/lovelace/tile-features/types.ts b/src/panels/lovelace/tile-features/types.ts index 71e3ad711ab1..6401c28ec5a8 100644 --- a/src/panels/lovelace/tile-features/types.ts +++ b/src/panels/lovelace/tile-features/types.ts @@ -40,6 +40,10 @@ export interface ClimateHvacModesTileFeatureConfig { hvac_modes?: HvacMode[]; } +export interface TargetTemperatureTileFeatureConfig { + type: "target-temperature"; +} + export interface WaterHeaterOperationModesTileFeatureConfig { type: "water-heater-operation-modes"; operation_modes?: OperationMode[]; @@ -81,6 +85,7 @@ export type LovelaceTileFeatureConfig = | LightBrightnessTileFeatureConfig | LightColorTempTileFeatureConfig | VacuumCommandsTileFeatureConfig + | TargetTemperatureTileFeatureConfig | WaterHeaterOperationModesTileFeatureConfig; export type LovelaceTileFeatureContext = { diff --git a/src/translations/en.json b/src/translations/en.json index f05f25ca9a90..71961fca7ad1 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5031,6 +5031,9 @@ "label": "Climate HVAC modes", "hvac_modes": "HVAC modes" }, + "target-temperature": { + "label": "Target temperature" + }, "water-heater-operation-modes": { "label": "Water heater operation modes", "operation_modes": "Operation modes"