Add target temperature tile feature for climate and water heater (#17697)

This commit is contained in:
Paul Bottein 2023-08-29 16:36:07 +02:00 committed by GitHub
parent 6f99a39b55
commit 7040c6d469
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 648 additions and 16 deletions

View File

@ -0,0 +1,3 @@
---
title: Control Number Buttons
---

View File

@ -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`
<ha-card>
<div class="card-content">
<label id=${id}>${label}</label>
<pre>Config: ${JSON.stringify(config)}</pre>
<ha-control-number-buttons
.value=${this.value}
.min=${config.min}
.max=${config.max}
.step=${config.step}
class=${ifDefined(config.class)}
@value-changed=${this._valueChanged}
.label=${label}
>
</ha-control-number-buttons>
</div>
</ha-card>
`;
})}
`;
}
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;
}
}

View File

@ -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;
};

View File

@ -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`
<div class="container">
<div
id="input"
class="value"
role="number-button"
tabindex="0"
aria-valuenow=${this.value}
aria-valuemin=${this.min}
aria-valuemax=${this.max}
aria-label=${ifDefined(this.label)}
.disabled=${this.disabled}
@keydown=${this._handleKeyDown}
>
${displayedValue}
</div>
<button
class="button minus"
type="button"
tabindex="-1"
aria-label="decrement"
@click=${this._handleMinusButton}
.disabled=${this.disabled ||
(this.min != null && this._value <= this.min)}
>
<ha-svg-icon aria-hidden .path=${mdiMinus}></ha-svg-icon>
</button>
<button
class="button plus"
type="button"
tabindex="-1"
aria-label="increment"
@click=${this._handlePlusButton}
.disabled=${this.disabled ||
(this.max != null && this._value >= this.max)}
>
<ha-svg-icon aria-hidden .path=${mdiPlus}></ha-svg-icon>
</button>
</div>
`;
}
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;
}
}

View File

@ -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)
);
}

View File

@ -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)
);
}

View File

@ -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<LovelaceTileFeatureConfig["type"]> = new Set([
"cover-open-close",
"cover-position",
"cover-tilt",
"cover-tilt-position",
"light-brightness",
"light-color-temp",
"vacuum-commands",
"lawn-mower-commands",
"fan-speed",
"alarm-modes",
"climate-hvac-modes",
"cover-open-close",
"cover-position",
"cover-tilt-position",
"cover-tilt",
"fan-speed",
"lawn-mower-commands",
"light-brightness",
"light-color-temp",
"target-temperature",
"vacuum-commands",
"water-heater-operation-modes",
]);

View File

@ -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<FeatureType, SupportsFeature | undefined> =
"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
);
}

View File

@ -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<Record<Target, number>> = {};
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`
<ha-control-button-group>
<ha-control-number-buttons
.formatOptions=${options}
.target="value"
.value=${this.stateObj.attributes.temperature}
.min=${this._min}
.max=${this._max}
.step=${this._step}
@value-changed=${this._valueChanged}
.label=${this.hass.formatEntityAttributeName(
this.stateObj,
"temperature"
)}
style=${styleMap({
"--control-number-buttons-focus-color": stateColor,
})}
.disabled=${this.stateObj!.state === UNAVAILABLE}
>
</ha-control-number-buttons>
</ha-control-number-buttons>
`;
}
if (
domain === "climate" &&
supportsFeature(
this.stateObj,
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
) {
return html`
<ha-control-button-group>
<ha-control-number-buttons
.formatOptions=${options}
.target=${"low"}
.value=${(this.stateObj as ClimateEntity).attributes.target_temp_low}
.min=${this._min}
.max=${Math.min(this._max, this._targetTemperature.high ?? this._max)}
.step=${this._step}
@value-changed=${this._valueChanged}
.label=${this.hass.formatEntityAttributeName(
this.stateObj,
"temperature"
)}
style=${styleMap({
"--control-number-buttons-focus-color": stateColor,
})}
.disabled=${this.stateObj!.state === UNAVAILABLE}
>
</ha-control-number-buttons>
<ha-control-number-buttons
.formatOptions=${options}
.target=${"high"}
.value=${(this.stateObj as ClimateEntity).attributes.target_temp_high}
.min=${Math.max(this._min, this._targetTemperature.low ?? this._min)}
.max=${this._max}
.step=${this._step}
@value-changed=${this._valueChanged}
.label=${this.hass.formatEntityAttributeName(
this.stateObj,
"temperature"
)}
style=${styleMap({
"--control-number-buttons-focus-color": stateColor,
})}
.disabled=${this.stateObj!.state === UNAVAILABLE}
>
</ha-control-number-buttons>
</ha-control-number-buttons>
`;
}
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;
}
}

View File

@ -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 = {

View File

@ -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"