Add new design to thermostat card (#18709)

* Add new design for thermostat card

* Add feature to thermostat card

* Fix margin

* Add current

* use big number component

* Add current

* Fix translations

* Add theme and name options

* Reduce margin on small card

* Fix types

* Add hvac mode to default dashboard

* Don't put feature full size

* Full width for features

* Improve design on small screen
This commit is contained in:
Paul Bottein 2023-11-22 14:42:42 +01:00 committed by GitHub
parent 1526209f82
commit c787c920fc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 583 additions and 801 deletions

View File

@ -0,0 +1,114 @@
import { CSSResultGroup, LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { formatNumber } from "../common/number/format_number";
import { blankBeforeUnit } from "../common/translations/blank_before_unit";
import { HomeAssistant } from "../types";
@customElement("ha-big-number")
export class HaBigNumber extends LitElement {
@property() public value!: number;
@property() public unit?: string;
@property({ attribute: "unit-position" })
public unitPosition: "top" | "bottom" = "top";
@property({ attribute: false })
public hass?: HomeAssistant;
@property({ attribute: false })
public formatOptions: Intl.NumberFormatOptions = {};
protected render() {
const formatted = formatNumber(
this.value,
this.hass?.locale,
this.formatOptions
);
const [integer] = formatted.includes(".")
? formatted.split(".")
: formatted.split(",");
const temperatureDecimal = formatted.replace(integer, "");
const formattedValue = `${this.value}${
this.unit ? `${blankBeforeUnit(this.unit)}${this.unit}` : ""
}`;
const unitBottom = this.unitPosition === "bottom";
return html`
<p class="value">
<span aria-hidden="true" class="displayed-value">
<span>${integer}</span>
<span class="addon ${classMap({ bottom: unitBottom })}">
<span class="decimal">${temperatureDecimal}</span>
<span class="unit">${this.unit}</span>
</span>
</span>
<span class="visually-hidden">${formattedValue}</span>
</p>
`;
}
static get styles(): CSSResultGroup {
return [
css`
:host {
font-size: 57px;
line-height: 1.12;
letter-spacing: -0.25px;
}
.value {
display: flex;
margin: 0;
direction: ltr;
}
.displayed-value {
display: inline-flex;
flex-direction: row;
align-items: flex-end;
}
.addon {
display: flex;
flex-direction: column-reverse;
padding: 4px 0;
}
.addon.bottom {
flex-direction: row;
align-items: baseline;
}
.addon.bottom .unit {
margin-bottom: 4px;
margin-left: 2px;
}
.value .decimal {
font-size: 0.42em;
line-height: 1.33;
}
.value .unit {
font-size: 0.33em;
line-height: 1.26;
}
/* Accessibility */
.visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-big-number": HaBigNumber;
}
}

View File

@ -618,9 +618,11 @@ export class HaControlCircularSlider extends LitElement {
--control-circular-slider-high-color: var( --control-circular-slider-high-color: var(
--control-circular-slider-color --control-circular-slider-color
); );
width: 320px;
display: block;
} }
svg { svg {
width: 320px; width: 100%;
display: block; display: block;
} }
#slider { #slider {

View File

@ -44,7 +44,7 @@ export class HaControlNumberButton extends LitElement {
@property() public unit?: string; @property() public unit?: string;
@property({ attribute: "false" }) @property({ attribute: false })
public formatOptions: Intl.NumberFormatOptions = {}; public formatOptions: Intl.NumberFormatOptions = {};
@query("#input") _input!: HTMLDivElement; @query("#input") _input!: HTMLDivElement;

View File

@ -262,7 +262,7 @@ export class HaControlSelect extends LitElement {
position: relative; position: relative;
flex: 1; flex: 1;
height: 100%; height: 100%;
width: 100%; width: 40px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;

View File

@ -1,5 +1,6 @@
import { mdiMinus, mdiPlus } from "@mdi/js"; import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; import { mdiMinus, mdiPlus, mdiWaterPercent } from "@mdi/js";
import { CSSResultGroup, LitElement, PropertyValues, html } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { stateActive } from "../../../../common/entity/state_active"; import { stateActive } from "../../../../common/entity/state_active";
@ -7,6 +8,7 @@ import { domainStateColorProperties } from "../../../../common/entity/state_colo
import { supportsFeature } from "../../../../common/entity/supports-feature"; import { supportsFeature } from "../../../../common/entity/supports-feature";
import { clamp } from "../../../../common/number/clamp"; import { clamp } from "../../../../common/number/clamp";
import { debounce } from "../../../../common/util/debounce"; import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-big-number";
import "../../../../components/ha-control-circular-slider"; import "../../../../components/ha-control-circular-slider";
import "../../../../components/ha-outlined-icon-button"; import "../../../../components/ha-outlined-icon-button";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
@ -22,6 +24,9 @@ export class HaMoreInfoClimateHumidity extends LitElement {
@property({ attribute: false }) public stateObj!: ClimateEntity; @property({ attribute: false }) public stateObj!: ClimateEntity;
@property({ attribute: "show-current", type: Boolean })
public showCurrent?: boolean;
@state() private _targetHumidity?: number; @state() private _targetHumidity?: number;
protected willUpdate(changedProp: PropertyValues): void { protected willUpdate(changedProp: PropertyValues): void {
@ -87,9 +92,7 @@ export class HaMoreInfoClimateHumidity extends LitElement {
return html` return html`
<p class="label"> <p class="label">
${this.hass.localize( ${this.hass.localize("ui.card.climate.humidity_target")}
"ui.dialogs.more_info_control.climate.humidity_target"
)}
</p> </p>
`; `;
} }
@ -114,20 +117,37 @@ export class HaMoreInfoClimateHumidity extends LitElement {
} }
private _renderTarget(humidity: number) { private _renderTarget(humidity: number) {
const rounded = Math.round(humidity); const formatOptions = {
const formatted = this.hass.formatEntityAttributeValue( maximumFractionDigits: 0,
this.stateObj, };
"humidity",
rounded
);
return html` return html`
<div class="target"> <ha-big-number
<p class="value" aria-hidden="true"> .value=${humidity}
${rounded}<span class="unit">%</span> unit="%"
unit-position="bottom"
.hass=${this.hass}
.formatOptions=${formatOptions}
></ha-big-number>
`;
}
private _renderCurrentHumidity(humidity?: number) {
if (!this.showCurrent || humidity == null) {
return html`<p class="label">&nbsp;</p>`;
}
return html`
<p class="label">
<ha-svg-icon .path=${mdiWaterPercent}></ha-svg-icon>
<span>
${this.hass.formatEntityAttributeValue(
this.stateObj,
"current_humidity",
humidity
)}
</span>
</p> </p>
<p class="visually-hidden">${formatted}</p>
</div>
`; `;
} }
@ -174,10 +194,10 @@ export class HaMoreInfoClimateHumidity extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()} ${this._renderTarget(targetHumidity)}
<div class="target-container"> ${this._renderCurrentHumidity(
${this._renderTarget(targetHumidity)} this.stateObj.attributes.current_humidity
</div> )}
</div> </div>
${this._renderButtons()} ${this._renderButtons()}
</div> </div>
@ -195,32 +215,17 @@ export class HaMoreInfoClimateHumidity extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()}
${this._renderCurrentHumidity(
this.stateObj.attributes.current_humidity
)}
</div> </div>
</div> </div>
`; `;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return moreInfoControlCircularSliderStyle;
moreInfoControlCircularSliderStyle,
css`
/* Elements */
.target-container {
margin-bottom: 30px;
}
.target .value {
font-size: 58px;
line-height: 1;
letter-spacing: -0.25px;
}
.target .value .unit {
font-size: 0.4em;
line-height: 1;
margin-left: 2px;
}
`,
];
} }
} }

View File

@ -1,12 +1,5 @@
import { mdiMinus, mdiPlus } from "@mdi/js"; import { mdiMinus, mdiPlus, mdiThermometer } from "@mdi/js";
import { import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit";
CSSResultGroup,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
@ -15,8 +8,8 @@ import { stateActive } from "../../../../common/entity/state_active";
import { stateColorCss } from "../../../../common/entity/state_color"; import { stateColorCss } from "../../../../common/entity/state_color";
import { supportsFeature } from "../../../../common/entity/supports-feature"; import { supportsFeature } from "../../../../common/entity/supports-feature";
import { clamp } from "../../../../common/number/clamp"; import { clamp } from "../../../../common/number/clamp";
import { formatNumber } from "../../../../common/number/format_number";
import { debounce } from "../../../../common/util/debounce"; import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-big-number";
import "../../../../components/ha-control-circular-slider"; import "../../../../components/ha-control-circular-slider";
import type { ControlCircularSliderMode } from "../../../../components/ha-control-circular-slider"; import type { ControlCircularSliderMode } from "../../../../components/ha-control-circular-slider";
import "../../../../components/ha-outlined-icon-button"; import "../../../../components/ha-outlined-icon-button";
@ -49,6 +42,9 @@ export class HaMoreInfoClimateTemperature extends LitElement {
@property({ attribute: false }) public stateObj!: ClimateEntity; @property({ attribute: false }) public stateObj!: ClimateEntity;
@property({ attribute: "show-current", type: Boolean })
public showCurrent?: boolean;
@state() private _targetTemperature: Partial<Record<Target, number>> = {}; @state() private _targetTemperature: Partial<Record<Target, number>> = {};
@state() private _selectTargetTemperature: Target = "low"; @state() private _selectTargetTemperature: Target = "low";
@ -183,14 +179,9 @@ export class HaMoreInfoClimateTemperature extends LitElement {
return html` return html`
<p class="label"> <p class="label">
${action && ["preheating", "heating", "cooling"].includes(action) ${action && action !== "off" && action !== "idle"
? this.hass.localize(
"ui.dialogs.more_info_control.climate.target_label",
{ action: actionLabel }
)
: action && action !== "off" && action !== "idle"
? actionLabel ? actionLabel
: this.hass.localize("ui.dialogs.more_info_control.climate.target")} : this.hass.localize("ui.card.climate.target")}
</p> </p>
`; `;
} }
@ -234,30 +225,34 @@ export class HaMoreInfoClimateTemperature extends LitElement {
private _renderTargetTemperature(temperature: number) { private _renderTargetTemperature(temperature: number) {
const digits = this._step.toString().split(".")?.[1]?.length ?? 0; const digits = this._step.toString().split(".")?.[1]?.length ?? 0;
const formatted = formatNumber(temperature, this.hass.locale, { const formatOptions: Intl.NumberFormatOptions = {
maximumFractionDigits: digits, maximumFractionDigits: digits,
minimumFractionDigits: digits, minimumFractionDigits: digits,
}); };
const [temperatureInteger] = formatted.includes(".") return html`
? formatted.split(".") <ha-big-number
: formatted.split(","); .value=${temperature}
.unit=${this.hass.config.unit_system.temperature}
.hass=${this.hass}
.formatOptions=${formatOptions}
></ha-big-number>
`;
}
const temperatureDecimal = formatted.replace(temperatureInteger, ""); private _renderCurrentTemperature(temperature?: number) {
if (!this.showCurrent || temperature == null) {
return html`<p class="label">&nbsp;</p>`;
}
return html` return html`
<p class="temperature"> <p class="label current">
<span aria-hidden="true"> <ha-svg-icon .path=${mdiThermometer}></ha-svg-icon>
${temperatureInteger} <span>
${digits !== 0 ${this.hass.formatEntityAttributeValue(
? html`<span class="decimal">${temperatureDecimal}</span>` this.stateObj,
: nothing} "current_temperature",
<span class="unit"> temperature
${this.hass.config.unit_system.temperature} )}
</span>
</span>
<span class="visually-hidden">
${this.stateObj.attributes.temperature}
${this.hass.config.unit_system.temperature}
</span> </span>
</p> </p>
`; `;
@ -326,10 +321,11 @@ export class HaMoreInfoClimateTemperature extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()}
<div class="temperature-container">
${this._renderTargetTemperature(this._targetTemperature.value)} ${this._renderTargetTemperature(this._targetTemperature.value)}
</div> ${this._renderCurrentTemperature(
this.stateObj.attributes.current_temperature
)}
</div> </div>
${this._renderTemperatureButtons("value")} ${this._renderTemperatureButtons("value")}
</div> </div>
@ -367,8 +363,8 @@ export class HaMoreInfoClimateTemperature extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()}
<div class="temperature-container dual"> <div class="dual">
<button <button
@click=${this._handleSelectTemp} @click=${this._handleSelectTemp}
.target=${"low"} .target=${"low"}
@ -388,6 +384,9 @@ export class HaMoreInfoClimateTemperature extends LitElement {
${this._renderTargetTemperature(this._targetTemperature.high)} ${this._renderTargetTemperature(this._targetTemperature.high)}
</button> </button>
</div> </div>
${this._renderCurrentTemperature(
this.stateObj.attributes.current_temperature
)}
</div> </div>
${this._renderTemperatureButtons(this._selectTargetTemperature, true)} ${this._renderTemperatureButtons(this._selectTargetTemperature, true)}
</div> </div>
@ -412,7 +411,10 @@ export class HaMoreInfoClimateTemperature extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()}
${this._renderCurrentTemperature(
this.stateObj.attributes.current_temperature
)}
</div> </div>
</div> </div>
`; `;
@ -422,45 +424,12 @@ export class HaMoreInfoClimateTemperature extends LitElement {
return [ return [
moreInfoControlCircularSliderStyle, moreInfoControlCircularSliderStyle,
css` css`
/* Elements */ /* Dual target */
.temperature-container {
margin-bottom: 30px;
}
.temperature {
display: inline-flex;
font-size: 58px;
line-height: 64px;
letter-spacing: -0.25px;
margin: 0;
direction: ltr;
}
.temperature span {
display: inline-flex;
}
.temperature .decimal {
font-size: 24px;
line-height: 32px;
align-self: flex-end;
width: 20px;
margin-bottom: 4px;
}
.temperature .unit {
font-size: 20px;
line-height: 24px;
align-self: flex-start;
width: 20px;
margin-top: 4px;
}
.decimal + .unit {
margin-left: -20px;
}
.dual { .dual {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 24px; gap: 24px;
margin-bottom: 40px;
} }
.dual button { .dual button {
outline: none; outline: none;
background: none; background: none;
@ -481,7 +450,16 @@ export class HaMoreInfoClimateTemperature extends LitElement {
.dual button.selected { .dual button.selected {
opacity: 1; opacity: 1;
} }
/* Slider */ @container container (max-width: 250px) {
.dual {
gap: 16px;
}
}
@container container (max-width: 190px) {
.dual {
gap: 8px;
}
}
ha-control-circular-slider { ha-control-circular-slider {
--control-circular-slider-low-color: var( --control-circular-slider-low-color: var(
--low-color, --low-color,

View File

@ -2,8 +2,13 @@ import { css } from "lit";
export const moreInfoControlCircularSliderStyle = css` export const moreInfoControlCircularSliderStyle = css`
/* Layout elements */ /* Layout elements */
:host {
width: 320px;
}
.container { .container {
position: relative; position: relative;
container-type: inline-size;
container-name: container;
} }
.info { .info {
position: absolute; position: absolute;
@ -17,24 +22,17 @@ export const moreInfoControlCircularSliderStyle = css`
justify-content: center; justify-content: center;
pointer-events: none; pointer-events: none;
font-size: 16px; font-size: 16px;
line-height: 24px; line-height: 1.5;
letter-spacing: 0.1px; letter-spacing: 0.1px;
gap: 8px;
--mdc-icon-size: 16px;
} }
.info * { .info * {
margin: 0; margin: 0;
pointer-events: auto; pointer-events: auto;
} }
/* Info elements */
.label-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 200px;
height: 48px;
margin-bottom: 6px;
}
.label { .label {
width: 60%;
font-weight: 500; font-weight: 500;
text-align: center; text-align: center;
color: var(--action-color, inherit); color: var(--action-color, inherit);
@ -43,6 +41,12 @@ export const moreInfoControlCircularSliderStyle = css`
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
} }
.label span {
white-space: nowrap;
}
.label ha-svg-icon {
bottom: 5%;
}
.label.disabled { .label.disabled {
color: var(--secondary-text-color); color: var(--secondary-text-color);
} }
@ -53,30 +57,52 @@ export const moreInfoControlCircularSliderStyle = css`
left: 0; left: 0;
right: 0; right: 0;
margin: 0 auto; margin: 0 auto;
width: 120px; gap: 24px;
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
justify-content: space-between; justify-content: center;
} }
.buttons ha-outlined-icon-button { .buttons ha-outlined-icon-button {
--md-outlined-icon-button-container-width: 48px; --md-outlined-icon-button-container-width: 48px;
--md-outlined-icon-button-container-height: 48px; --md-outlined-icon-button-container-height: 48px;
--md-outlined-icon-button-icon-size: 24px; --md-outlined-icon-button-icon-size: 24px;
} }
/* Accessibility */
.visually-hidden { @container container (max-width: 250px) {
position: absolute; ha-big-number {
overflow: hidden; font-size: 44px;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
} }
.buttons {
gap: 16px;
}
.info {
margin-top: 12px;
gap: 6px;
}
.buttons {
display: none;
}
ha-control-circular-slider {
margin-bottom: -16px;
}
}
@container container (max-width: 190px) {
ha-big-number {
font-size: 32px;
}
.info {
margin-top: 12px;
font-size: 14px;
gap: 2px;
--mdc-icon-size: 14px;
}
}
/* Slider */ /* Slider */
ha-control-circular-slider { ha-control-circular-slider {
width: 100%;
--control-circular-slider-color: var(--state-color, var(--disabled-color)); --control-circular-slider-color: var(--state-color, var(--disabled-color));
} }
ha-control-circular-slider::after { ha-control-circular-slider::after {

View File

@ -1,13 +1,12 @@
import { mdiMinus, mdiPlus } from "@mdi/js"; import { mdiMinus, mdiPlus } from "@mdi/js";
import { CSSResultGroup, LitElement, PropertyValues, css, html } from "lit"; import { CSSResultGroup, LitElement, PropertyValues, html } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { stateActive } from "../../../../common/entity/state_active"; import { stateActive } from "../../../../common/entity/state_active";
import { stateColorCss } from "../../../../common/entity/state_color"; import { stateColorCss } from "../../../../common/entity/state_color";
import { clamp } from "../../../../common/number/clamp"; 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 { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-big-number";
import "../../../../components/ha-control-circular-slider"; import "../../../../components/ha-control-circular-slider";
import "../../../../components/ha-outlined-icon-button"; import "../../../../components/ha-outlined-icon-button";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
@ -26,6 +25,9 @@ export class HaMoreInfoHumidifierHumidity extends LitElement {
@property({ attribute: false }) public stateObj!: HumidifierEntity; @property({ attribute: false }) public stateObj!: HumidifierEntity;
@property({ attribute: "show-current", type: Boolean })
public showCurrent?: boolean = false;
@state() private _targetHumidity?: number; @state() private _targetHumidity?: number;
protected willUpdate(changedProp: PropertyValues): void { protected willUpdate(changedProp: PropertyValues): void {
@ -99,15 +101,31 @@ export class HaMoreInfoHumidifierHumidity extends LitElement {
return html` return html`
<p class="label"> <p class="label">
${action && ["drying", "humidifying"].includes(action) ${action && ["drying", "humidifying"].includes(action)
? this.hass.localize( ? this.hass.localize("ui.card.humidifier.target_label", {
"ui.dialogs.more_info_control.humidifier.target_label", action: actionLabel,
{ action: actionLabel } })
)
: action && action !== "off" && action !== "idle" : action && action !== "off" && action !== "idle"
? actionLabel ? actionLabel
: this.hass.localize( : this.hass.localize("ui.card.humidifier.target")}
"ui.dialogs.more_info_control.humidifier.target" </p>
`;
}
private _renderCurrentHumidity(humidity?: number) {
if (!this.showCurrent || humidity == null) {
return html`<p class="label">&nbsp;</p>`;
}
return html`
<p class="label">
${this.hass.localize("ui.card.humidifier.currently")}
<span>
${this.hass.formatEntityAttributeValue(
this.stateObj,
"current_humidity",
humidity
)} )}
</span>
</p> </p>
`; `;
} }
@ -132,19 +150,18 @@ export class HaMoreInfoHumidifierHumidity extends LitElement {
} }
private _renderTarget(humidity: number) { private _renderTarget(humidity: number) {
const formatted = formatNumber(humidity, this.hass.locale, { const formatOptions = {
maximumFractionDigits: 0, maximumFractionDigits: 0,
}); };
return html` return html`
<div class="target"> <ha-big-number
<p class="value" aria-hidden="true"> .value=${humidity}
${formatted}<span class="unit">%</span> unit="%"
</p> unit-position="bottom"
<p class="visually-hidden"> .hass=${this.hass}
${formatted}${blankBeforePercent(this.hass.locale)}% .formatOptions=${formatOptions}
</p> ></ha-big-number>
</div>
`; `;
} }
@ -191,10 +208,10 @@ export class HaMoreInfoHumidifierHumidity extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()} ${this._renderTarget(targetHumidity)}
<div class="target-container"> ${this._renderCurrentHumidity(
${this._renderTarget(targetHumidity)} this.stateObj.attributes.current_humidity
</div> )}
</div> </div>
${this._renderButtons()} ${this._renderButtons()}
</div> </div>
@ -217,32 +234,17 @@ export class HaMoreInfoHumidifierHumidity extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()}
${this._renderCurrentHumidity(
this.stateObj.attributes.current_humidity
)}
</div> </div>
</div> </div>
`; `;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return moreInfoControlCircularSliderStyle;
moreInfoControlCircularSliderStyle,
css`
/* Elements */
.target-container {
margin-bottom: 30px;
}
.target .value {
font-size: 58px;
line-height: 1;
letter-spacing: -0.25px;
}
.target .value .unit {
font-size: 0.4em;
line-height: 1;
margin-left: 2px;
}
`,
];
} }
} }

View File

@ -1,12 +1,5 @@
import { mdiMinus, mdiPlus } from "@mdi/js"; import { mdiMinus, mdiPlus } from "@mdi/js";
import { import { CSSResultGroup, LitElement, PropertyValues, html } from "lit";
CSSResultGroup,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { UNIT_F } from "../../../../common/const"; import { UNIT_F } from "../../../../common/const";
@ -14,8 +7,8 @@ import { stateActive } from "../../../../common/entity/state_active";
import { stateColorCss } from "../../../../common/entity/state_color"; import { stateColorCss } from "../../../../common/entity/state_color";
import { supportsFeature } from "../../../../common/entity/supports-feature"; import { supportsFeature } from "../../../../common/entity/supports-feature";
import { clamp } from "../../../../common/number/clamp"; import { clamp } from "../../../../common/number/clamp";
import { formatNumber } from "../../../../common/number/format_number";
import { debounce } from "../../../../common/util/debounce"; import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-big-number";
import "../../../../components/ha-control-circular-slider"; import "../../../../components/ha-control-circular-slider";
import "../../../../components/ha-outlined-icon-button"; import "../../../../components/ha-outlined-icon-button";
import "../../../../components/ha-svg-icon"; import "../../../../components/ha-svg-icon";
@ -33,6 +26,9 @@ export class HaMoreInfoWaterHeaterTemperature extends LitElement {
@property({ attribute: false }) public stateObj!: WaterHeaterEntity; @property({ attribute: false }) public stateObj!: WaterHeaterEntity;
@property({ attribute: "show-current", type: Boolean })
public showCurrent?: boolean;
@state() private _targetTemperature?: number; @state() private _targetTemperature?: number;
protected willUpdate(changedProp: PropertyValues): void { protected willUpdate(changedProp: PropertyValues): void {
@ -111,11 +107,7 @@ export class HaMoreInfoWaterHeaterTemperature extends LitElement {
} }
return html` return html`
<p class="label"> <p class="label">${this.hass.localize("ui.card.water_heater.target")}</p>
${this.hass.localize(
"ui.dialogs.more_info_control.water_heater.target"
)}
</p>
`; `;
} }
@ -140,30 +132,34 @@ export class HaMoreInfoWaterHeaterTemperature extends LitElement {
private _renderTargetTemperature(temperature: number) { private _renderTargetTemperature(temperature: number) {
const digits = this._step.toString().split(".")?.[1]?.length ?? 0; const digits = this._step.toString().split(".")?.[1]?.length ?? 0;
const formatted = formatNumber(temperature, this.hass.locale, { const formatOptions: Intl.NumberFormatOptions = {
maximumFractionDigits: digits, maximumFractionDigits: digits,
minimumFractionDigits: digits, minimumFractionDigits: digits,
}); };
const [temperatureInteger] = formatted.includes(".") return html`
? formatted.split(".") <ha-big-number
: formatted.split(","); .value=${temperature}
.unit=${this.hass.config.unit_system.temperature}
.hass=${this.hass}
.formatOptions=${formatOptions}
></ha-big-number>
`;
}
const temperatureDecimal = formatted.replace(temperatureInteger, ""); private _renderCurrentTemperature(temperature?: number) {
if (!this.showCurrent || temperature == null) {
return html`<p class="label">&nbsp;</p>`;
}
return html` return html`
<p class="temperature"> <p class="label">
<span aria-hidden="true"> ${this.hass.localize("ui.card.water_heater.currently")}
${temperatureInteger} <span>
${digits !== 0 ${this.hass.formatEntityAttributeValue(
? html`<span class="decimal">${temperatureDecimal}</span>` this.stateObj,
: nothing} "current_temperature",
<span class="unit"> temperature
${this.hass.config.unit_system.temperature} )}
</span>
</span>
<span class="visually-hidden">
${this.stateObj.attributes.temperature}
${this.hass.config.unit_system.temperature}
</span> </span>
</p> </p>
`; `;
@ -202,10 +198,11 @@ export class HaMoreInfoWaterHeaterTemperature extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()}
<div class="temperature-container">
${this._renderTargetTemperature(this._targetTemperature)} ${this._renderTargetTemperature(this._targetTemperature)}
</div> ${this._renderCurrentTemperature(
this.stateObj.attributes.current_temperature
)}
</div> </div>
${this._renderButtons()} ${this._renderButtons()}
</div> </div>
@ -230,49 +227,17 @@ export class HaMoreInfoWaterHeaterTemperature extends LitElement {
> >
</ha-control-circular-slider> </ha-control-circular-slider>
<div class="info"> <div class="info">
<div class="label-container">${this._renderLabel()}</div> ${this._renderLabel()}
${this._renderCurrentTemperature(
this.stateObj.attributes.current_temperature
)}
</div> </div>
</div> </div>
`; `;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return moreInfoControlCircularSliderStyle;
moreInfoControlCircularSliderStyle,
css`
/* Elements */
.temperature-container {
margin-bottom: 30px;
}
.temperature {
display: inline-flex;
font-size: 58px;
line-height: 64px;
letter-spacing: -0.25px;
margin: 0;
}
.temperature span {
display: inline-flex;
}
.temperature .decimal {
font-size: 24px;
line-height: 32px;
align-self: flex-end;
width: 20px;
margin-bottom: 4px;
}
.temperature .unit {
font-size: 20px;
line-height: 24px;
align-self: flex-start;
width: 20px;
margin-top: 4px;
}
.decimal + .unit {
margin-left: -20px;
}
`,
];
} }
} }

View File

@ -1,15 +1,5 @@
import { import { mdiDotsVertical } from "@mdi/js";
mdiAutorenew,
mdiCalendarSync,
mdiDotsVertical,
mdiFan,
mdiFire,
mdiPower,
mdiSnowflake,
mdiWaterPercent,
} from "@mdi/js";
import "@thomasloven/round-slider"; import "@thomasloven/round-slider";
import { HassEntity } from "home-assistant-js-websocket";
import { import {
CSSResultGroup, CSSResultGroup,
LitElement, LitElement,
@ -17,45 +7,24 @@ import {
css, css,
html, html,
nothing, nothing,
svg,
} from "lit"; } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { UNIT_F } from "../../../common/const";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import { fireEvent } from "../../../common/dom/fire_event"; import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import { stateColorCss } from "../../../common/entity/state_color"; import { stateColorCss } from "../../../common/entity/state_color";
import { formatNumber } from "../../../common/number/format_number";
import "../../../components/ha-card"; import "../../../components/ha-card";
import type { HaCard } from "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import { import { ClimateEntity } from "../../../data/climate";
CLIMATE_PRESET_NONE, import "../../../dialogs/more-info/components/climate/ha-more-info-climate-temperature";
ClimateEntity,
HvacMode,
compareClimateHvacModes,
} from "../../../data/climate";
import { UNAVAILABLE } from "../../../data/entity";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities"; import { findEntities } from "../common/find-entities";
import { hasConfigOrEntityChanged } from "../common/has-changed";
import { createEntityNotFoundWarning } from "../components/hui-warning"; import { createEntityNotFoundWarning } from "../components/hui-warning";
import "../tile-features/hui-tile-features";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { ThermostatCardConfig } from "./types"; import { ThermostatCardConfig } from "./types";
// TODO: Need to align these icon to more info icons
const modeIcons: { [mode in HvacMode]: string } = {
auto: mdiCalendarSync,
heat_cool: mdiAutorenew,
heat: mdiFire,
cool: mdiSnowflake,
off: mdiPower,
fan_only: mdiFan,
dry: mdiWaterPercent,
};
@customElement("hui-thermostat-card") @customElement("hui-thermostat-card")
export class HuiThermostatCard extends LitElement implements LovelaceCard { export class HuiThermostatCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> { public static async getConfigElement(): Promise<LovelaceCardEditor> {
@ -85,12 +54,6 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
@state() private _config?: ThermostatCardConfig; @state() private _config?: ThermostatCardConfig;
@state() private _setTemp?: number | number[];
@query("ha-card") private _card?: HaCard;
@state() private resyncSetpoint = false;
public getCardSize(): number { public getCardSize(): number {
return 7; return 7;
} }
@ -103,173 +66,10 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
this._config = config; this._config = config;
} }
protected render() { private _handleMoreInfo() {
if (!this.hass || !this._config) { fireEvent(this, "hass-more-info", {
return nothing; entityId: this._config!.entity,
} });
const stateObj = this.hass.states[this._config.entity] as ClimateEntity;
if (!stateObj) {
return html`
<hui-warning>
${createEntityNotFoundWarning(this.hass, this._config.entity)}
</hui-warning>
`;
}
const mode = stateObj.state in modeIcons ? stateObj.state : "unknown-mode";
const name =
this._config!.name ||
computeStateName(this.hass!.states[this._config!.entity]);
const targetTemp = this.resyncSetpoint
? // If the user set position in the slider is out of sync with the entity
// value, then rerendering the slider with same $value a second time
// does not move the slider. Need to set it to a different dummy value
// for one update cycle to force it to rerender to the desired value.
stateObj.attributes.min_temp - 1
: stateObj.attributes.temperature !== null &&
Number.isFinite(Number(stateObj.attributes.temperature))
? stateObj.attributes.temperature
: stateObj.attributes.min_temp;
const targetLow = this.resyncSetpoint
? stateObj.attributes.min_temp - 1
: stateObj.attributes.target_temp_low;
const targetHigh = this.resyncSetpoint
? stateObj.attributes.min_temp - 1
: stateObj.attributes.target_temp_high;
const slider =
stateObj.state === UNAVAILABLE
? html` <round-slider disabled="true"></round-slider> `
: html`
<round-slider
.value=${targetTemp}
.low=${targetLow}
.high=${targetHigh}
.min=${stateObj.attributes.min_temp}
.max=${stateObj.attributes.max_temp}
.step=${this._stepSize}
@value-changing=${this._dragEvent}
@value-changed=${this._setTemperature}
></round-slider>
`;
const currentTemperature = svg`
<svg viewBox="0 0 40 20">
<text
x="50%"
dx="1"
y="60%"
text-anchor="middle"
style="font-size: 13px;"
>
${
stateObj.state !== UNAVAILABLE &&
stateObj.attributes.current_temperature != null &&
!isNaN(stateObj.attributes.current_temperature)
? svg`
${formatNumber(
stateObj.attributes.current_temperature,
this.hass.locale
)}
<tspan dx="-3" dy="-6.5" style="font-size: 4px;">
${this.hass.config.unit_system.temperature}
</tspan>
`
: nothing
}
</text>
</svg>
`;
const setValues = svg`
<svg id="set-values">
<g>
<text text-anchor="middle" class="set-value">
${
stateObj.state !== UNAVAILABLE && this._setTemp != null
? Array.isArray(this._setTemp)
? svg`
${this._formatSetTemp(this._setTemp[0])} -
${this._formatSetTemp(this._setTemp[1])}
`
: this._formatSetTemp(this._setTemp)
: nothing
}
</text>
<text
dy="22"
text-anchor="middle"
id="set-mode"
>
${
stateObj.state !== UNAVAILABLE && stateObj.attributes.hvac_action
? this.hass.formatEntityAttributeValue(stateObj, "hvac_action")
: this.hass.formatEntityState(stateObj)
}
${
stateObj.state !== UNAVAILABLE &&
stateObj.attributes.preset_mode &&
stateObj.attributes.preset_mode !== CLIMATE_PRESET_NONE
? html`
-
${this.hass.formatEntityAttributeValue(
stateObj,
"preset_mode"
)}
`
: nothing
}
</text>
</g>
</svg>
`;
return html`
<ha-card
style=${styleMap({
"--mode-color": stateColorCss(stateObj),
})}
>
<ha-icon-button
class="more-info"
.label=${this.hass!.localize(
"ui.panel.lovelace.cards.show_more_info"
)}
.path=${mdiDotsVertical}
@click=${this._handleMoreInfo}
tabindex="0"
></ha-icon-button>
<div class="content">
<div id="controls">
<div id="slider">
${slider}
<div id="slider-center">
<div id="temperature">${currentTemperature} ${setValues}</div>
</div>
</div>
</div>
<div id="info" .title=${name}>
<div id="modes">
${(stateObj.attributes.hvac_modes || [])
.concat()
.sort(compareClimateHvacModes)
.map((modeItem) => this._renderIcon(modeItem, mode))}
</div>
${name}
</div>
</div>
</ha-card>
`;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
return (
hasConfigOrEntityChanged(this, changedProps) ||
changedProps.has("resyncSetpoint")
);
} }
protected updated(changedProps: PropertyValues): void { protected updated(changedProps: PropertyValues): void {
@ -296,221 +96,54 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
) { ) {
applyThemesOnElement(this, this.hass.themes, 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) { protected render() {
this._rescale_svg(); if (!this.hass || !this._config) {
}
}
public willUpdate(changedProps: PropertyValues) {
if (
!this.hass ||
!this._config ||
!(changedProps.has("hass") || changedProps.has("resyncSetpoint"))
) {
return;
}
const stateObj = this.hass.states[this._config.entity];
if (!stateObj) {
return;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if (
!oldHass ||
oldHass.states[this._config.entity] !== stateObj ||
(changedProps.has("resyncSetpoint") && this.resyncSetpoint)
) {
this._setTemp = this._getSetTemp(stateObj);
}
}
private _formatSetTemp(temp: number) {
return this._stepSize === 1
? formatNumber(temp, this.hass!.locale, {
maximumFractionDigits: 0,
})
: formatNumber(temp, this.hass!.locale, {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
});
}
private _rescale_svg() {
// Set the viewbox of the SVG containing the set temperature to perfectly
// fit the text
// That way it will auto-scale correctly
// This is not done to the SVG containing the current temperature, because
// it should not be centered on the text, but only on the value
const card = this._card;
if (card) {
card.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 get _stepSize(): number {
const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity;
if (stateObj.attributes.target_temp_step) {
return stateObj.attributes.target_temp_step;
}
return this.hass!.config.unit_system.temperature === UNIT_F ? 1 : 0.5;
}
private _getSetTemp(
stateObj: HassEntity
): undefined | number | [number, number] {
if (stateObj.state === UNAVAILABLE) {
return undefined;
}
if (
stateObj.attributes.target_temp_low &&
stateObj.attributes.target_temp_high
) {
return [
stateObj.attributes.target_temp_low,
stateObj.attributes.target_temp_high,
];
}
return stateObj.attributes.temperature;
}
private _dragEvent(e): void {
const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity;
if (e.detail.low) {
this._setTemp = [e.detail.low, stateObj.attributes.target_temp_high];
} else if (e.detail.high) {
this._setTemp = [stateObj.attributes.target_temp_low, e.detail.high];
} else {
this._setTemp = e.detail.value;
}
}
private _setTemperature(e): void {
const stateObj = this.hass!.states[this._config!.entity] as ClimateEntity;
if (e.detail.low) {
const newVal = e.detail.low;
this._callServiceHelper(
stateObj.attributes.target_temp_low,
newVal,
"set_temperature",
{
target_temp_low: newVal,
target_temp_high: stateObj.attributes.target_temp_high,
}
);
} else if (e.detail.high) {
const newVal = e.detail.high;
this._callServiceHelper(
stateObj.attributes.target_temp_high,
newVal,
"set_temperature",
{
target_temp_low: stateObj.attributes.target_temp_low,
target_temp_high: newVal,
}
);
} else {
const newVal = e.detail.value;
this._callServiceHelper(
stateObj!.attributes.temperature,
newVal,
"set_temperature",
{ temperature: newVal }
);
}
}
private _renderIcon(mode: string, currentMode: string) {
if (!modeIcons[mode]) {
return nothing; return nothing;
} }
const stateObj = this.hass.states[this._config.entity] as ClimateEntity;
if (!stateObj) {
return html` return html`
<ha-icon-button <hui-warning>
class=${classMap({ "selected-icon": currentMode === mode })} ${createEntityNotFoundWarning(this.hass, this._config.entity)}
.mode=${mode} </hui-warning>
@click=${this._handleAction}
tabindex="0"
.path=${modeIcons[mode]}
.label=${this.hass!.localize(
`component.climate.entity_component._.state.${mode}`
) || mode}
>
</ha-icon-button>
`; `;
} }
private _handleMoreInfo() { const name = this._config!.name || computeStateName(stateObj);
fireEvent(this, "hass-more-info", {
entityId: this._config!.entity,
});
}
private _handleAction(e: MouseEvent): void { const color = stateColorCss(stateObj);
this.hass!.callService("climate", "set_hvac_mode", {
entity_id: this._config!.entity,
hvac_mode: (e.currentTarget as any).mode,
});
}
private async _callServiceHelper( return html`
oldVal: unknown, <ha-card>
newVal: unknown, <p class="title">${name}</p>
service: string, <ha-more-info-climate-temperature
data: { show-current
entity_id?: string; .hass=${this.hass}
[key: string]: unknown; .stateObj=${stateObj}
} ></ha-more-info-climate-temperature>
) { <ha-icon-button
if (oldVal === newVal) { class="more-info"
return; .label=${this.hass!.localize(
} "ui.panel.lovelace.cards.show_more_info"
)}
data.entity_id = this._config!.entity; .path=${mdiDotsVertical}
@click=${this._handleMoreInfo}
await this.hass!.callService("climate", service, data); tabindex="0"
></ha-icon-button>
// After updating temperature, wait 2s and check if the values <hui-tile-features
// from call service are reflected in the entity. If not, resync style=${styleMap({
// the slider to the entity values. "--tile-color": color,
await new Promise((resolve) => { })}
setTimeout(resolve, 2000); .hass=${this.hass}
}); .stateObj=${stateObj}
.color=${this._config.color}
const newState = this.hass!.states[this._config!.entity] as ClimateEntity; .features=${this._config.features}
delete data.entity_id; ></hui-tile-features>
</ha-card>
if ( `;
Object.entries(data).every(
([key, value]) => newState.attributes[key] === value
)
) {
return;
}
this.resyncSetpoint = true;
await this.updateComplete;
this.resyncSetpoint = false;
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
@ -523,10 +156,27 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
height: 100%; height: 100%;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
--name-font-size: 1.2rem; padding: 0;
--brightness-font-size: 1.2rem; display: flex;
--rail-border-color: transparent; flex-direction: column;
--mode-color: var(--state-inactive-color); align-items: center;
}
.title {
width: 100%;
font-size: 18px;
line-height: 24px;
padding: 12px 36px 16px 36px;
margin: 0;
text-align: center;
box-sizing: border-box;
}
ha-more-info-climate-temperature {
width: 100%;
max-width: 344px; /* 12px + 12px + 320px */
padding: 0 12px 12px 12px;
box-sizing: border-box;
} }
.more-info { .more-info {
@ -538,93 +188,11 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
inset-inline-start: initial; inset-inline-start: initial;
border-radius: 100%; border-radius: 100%;
color: var(--secondary-text-color); color: var(--secondary-text-color);
z-index: 1;
direction: var(--direction); direction: var(--direction);
} }
.content { hui-tile-features {
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%; width: 100%;
position: relative;
max-width: 250px;
min-width: 100px;
}
round-slider {
--round-slider-path-color: var(--slider-track-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;
}
#temperature {
position: absolute;
transform: translate(-50%, -50%);
width: 100%;
height: 50%;
top: 45%;
left: 50%;
direction: ltr;
}
#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);
} }
`; `;
} }

View File

@ -452,6 +452,7 @@ export interface ThermostatCardConfig extends LovelaceCardConfig {
entity: string; entity: string;
theme?: string; theme?: string;
name?: string; name?: string;
features?: LovelaceTileFeatureConfig[];
} }
export interface WeatherForecastCardConfig extends LovelaceCardConfig { export interface WeatherForecastCardConfig extends LovelaceCardConfig {

View File

@ -141,6 +141,12 @@ export const computeCards = (
const cardConfig: ThermostatCardConfig = { const cardConfig: ThermostatCardConfig = {
type: "thermostat", type: "thermostat",
entity: entityId, entity: entityId,
features: [
{
type: "climate-hvac-modes",
hvac_modes: states[entityId]?.attributes?.hvac_modes,
},
],
}; };
cards.push(cardConfig); cards.push(cardConfig);
} else if (domain === "humidifier") { } else if (domain === "humidifier") {

View File

@ -1,13 +1,35 @@
import { html, LitElement, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { assert, assign, object, optional, string } from "superstruct"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event"; import {
any,
array,
assert,
assign,
object,
optional,
string,
} from "superstruct";
import { HASSDomEvent, fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form"; import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types"; import type { SchemaUnion } from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import type { ThermostatCardConfig } from "../../cards/types"; import type { ThermostatCardConfig } from "../../cards/types";
import {
LovelaceTileFeatureConfig,
LovelaceTileFeatureContext,
} from "../../tile-features/types";
import type { LovelaceCardEditor } from "../../types"; import type { LovelaceCardEditor } from "../../types";
import "../hui-sub-element-editor";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { EditSubElementEvent, SubElementEditorConfig } from "../types";
import "./hui-tile-card-features-editor";
import type { FeatureType } from "./hui-tile-card-features-editor";
const COMPATIBLE_FEATURES_TYPES: FeatureType[] = [
"climate-hvac-modes",
"climate-preset-modes",
];
const cardConfigStruct = assign( const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
@ -15,6 +37,7 @@ const cardConfigStruct = assign(
entity: optional(string()), entity: optional(string()),
name: optional(string()), name: optional(string()),
theme: optional(string()), theme: optional(string()),
features: optional(array(any())),
}) })
); );
@ -24,8 +47,8 @@ const SCHEMA = [
type: "grid", type: "grid",
name: "", name: "",
schema: [ schema: [
{ name: "name", required: false, selector: { text: {} } }, { name: "name", selector: { text: {} } },
{ name: "theme", required: false, selector: { theme: {} } }, { name: "theme", selector: { theme: {} } },
], ],
}, },
] as const; ] as const;
@ -39,16 +62,39 @@ export class HuiThermostatCardEditor
@state() private _config?: ThermostatCardConfig; @state() private _config?: ThermostatCardConfig;
@state() private _subElementEditorConfig?: SubElementEditorConfig;
public setConfig(config: ThermostatCardConfig): void { public setConfig(config: ThermostatCardConfig): void {
assert(config, cardConfigStruct); assert(config, cardConfigStruct);
this._config = config; this._config = config;
} }
private _context = memoizeOne(
(entity_id?: string): LovelaceTileFeatureContext => ({ entity_id })
);
protected render() { protected render() {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
return nothing; return nothing;
} }
const stateObj = this._config.entity
? this.hass.states[this._config.entity]
: undefined;
if (this._subElementEditorConfig) {
return html`
<hui-sub-element-editor
.hass=${this.hass}
.config=${this._subElementEditorConfig}
.context=${this._context(this._config.entity)}
@go-back=${this._goBack}
@config-changed=${this.subElementChanged}
>
</hui-sub-element-editor>
`;
}
return html` return html`
<ha-form <ha-form
.hass=${this.hass} .hass=${this.hass}
@ -57,6 +103,14 @@ export class HuiThermostatCardEditor
.computeLabel=${this._computeLabelCallback} .computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form> ></ha-form>
<hui-tile-card-features-editor
.hass=${this.hass}
.stateObj=${stateObj}
.featuresTypes=${COMPATIBLE_FEATURES_TYPES}
.features=${this._config!.features ?? []}
@features-changed=${this._featuresChanged}
@edit-detail-element=${this._editDetailElement}
></hui-tile-card-features-editor>
`; `;
} }
@ -64,6 +118,62 @@ export class HuiThermostatCardEditor
fireEvent(this, "config-changed", { config: ev.detail.value }); fireEvent(this, "config-changed", { config: ev.detail.value });
} }
private _featuresChanged(ev: CustomEvent) {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const features = ev.detail.features as LovelaceTileFeatureConfig[];
const config: ThermostatCardConfig = {
...this._config,
features,
};
if (features.length === 0) {
delete config.features;
}
fireEvent(this, "config-changed", { config });
}
private subElementChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const value = ev.detail.config;
const newConfigFeatures = this._config!.features
? [...this._config!.features]
: [];
if (!value) {
newConfigFeatures.splice(this._subElementEditorConfig!.index!, 1);
this._goBack();
} else {
newConfigFeatures[this._subElementEditorConfig!.index!] = value;
}
this._config = { ...this._config!, features: newConfigFeatures };
this._subElementEditorConfig = {
...this._subElementEditorConfig!,
elementConfig: value,
};
fireEvent(this, "config-changed", { config: this._config });
}
private _editDetailElement(ev: HASSDomEvent<EditSubElementEvent>): void {
this._subElementEditorConfig = ev.detail.subElementConfig;
}
private _goBack(): void {
this._subElementEditorConfig = undefined;
}
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => { private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
if (schema.name === "entity") { if (schema.name === "entity") {
return this.hass!.localize( return this.hass!.localize(
@ -71,18 +181,19 @@ export class HuiThermostatCardEditor
); );
} }
if (schema.name === "theme") {
return `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
}
return this.hass!.localize( return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}` `ui.panel.lovelace.editor.card.generic.${schema.name}`
); );
}; };
static get styles() {
return css`
ha-form {
display: block;
margin-bottom: 24px;
}
`;
}
} }
declare global { declare global {

View File

@ -40,7 +40,7 @@ import { LovelaceTileFeatureConfig } from "../../tile-features/types";
import { supportsClimatePresetModesTileFeature } from "../../tile-features/hui-climate-preset-modes-tile-feature"; import { supportsClimatePresetModesTileFeature } from "../../tile-features/hui-climate-preset-modes-tile-feature";
import { supportsNumberTileFeature } from "../../tile-features/hui-number-tile-feature"; import { supportsNumberTileFeature } from "../../tile-features/hui-number-tile-feature";
type FeatureType = LovelaceTileFeatureConfig["type"]; export type FeatureType = LovelaceTileFeatureConfig["type"];
type SupportsFeature = (stateObj: HassEntity) => boolean; type SupportsFeature = (stateObj: HassEntity) => boolean;
const UI_FEATURE_TYPES = [ const UI_FEATURE_TYPES = [
@ -121,7 +121,11 @@ export class HuiTileCardFeaturesEditor extends LitElement {
@property({ attribute: false }) @property({ attribute: false })
public features?: LovelaceTileFeatureConfig[]; public features?: LovelaceTileFeatureConfig[];
@property() public label?: string; @property({ attribute: false })
public featuresTypes?: FeatureType[];
@property()
public label?: string;
private _featuresKeys = new WeakMap<LovelaceTileFeatureConfig, string>(); private _featuresKeys = new WeakMap<LovelaceTileFeatureConfig, string>();
@ -186,7 +190,9 @@ export class HuiTileCardFeaturesEditor extends LitElement {
} }
private _getSupportedFeaturesType() { private _getSupportedFeaturesType() {
const featuresTypes = UI_FEATURE_TYPES as readonly string[]; const featuresTypes = UI_FEATURE_TYPES.filter(
(type) => !this.featuresTypes || this.featuresTypes.includes(type)
) as readonly string[];
const customFeaturesTypes = customTileFeatures.map( const customFeaturesTypes = customTileFeatures.map(
(feature) => `${CUSTOM_TYPE_PREFIX}${feature.type}` (feature) => `${CUSTOM_TYPE_PREFIX}${feature.type}`
); );

View File

@ -23,7 +23,7 @@ export const supportsClimateHvacModesTileFeature = (stateObj: HassEntity) => {
}; };
@customElement("hui-climate-hvac-modes-tile-feature") @customElement("hui-climate-hvac-modes-tile-feature")
class HuiClimateHvacModeTileFeature class HuiClimateHvacModesTileFeature
extends LitElement extends LitElement
implements LovelaceTileFeature implements LovelaceTileFeature
{ {
@ -148,6 +148,6 @@ class HuiClimateHvacModeTileFeature
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"hui-climate-modes-hvac-modes-feature": HuiClimateHvacModeTileFeature; "hui-climate-modes-hvac-modes-feature": HuiClimateHvacModesTileFeature;
} }
} }

View File

@ -106,7 +106,10 @@
"high": "high", "high": "high",
"low": "low", "low": "low",
"mode": "Mode", "mode": "Mode",
"preset": "Preset" "preset": "Preset",
"target_label": "{action} to target",
"target": "Target",
"humidity_target": "Humidity target"
}, },
"counter": { "counter": {
"actions": { "actions": {
@ -128,6 +131,7 @@
"reverse": "Reverse" "reverse": "Reverse"
}, },
"humidifier": { "humidifier": {
"currently": "[%key:ui::card::climate::currently%]",
"humidity": "Target humidity", "humidity": "Target humidity",
"state": "State", "state": "State",
"mode": "Mode", "mode": "Mode",
@ -135,7 +139,9 @@
"current_humidity_entity": "{name} current humidity", "current_humidity_entity": "{name} current humidity",
"humidifying": "{name} humidifying", "humidifying": "{name} humidifying",
"drying": "{name} drying", "drying": "{name} drying",
"on_entity": "{name} on" "on_entity": "{name} on",
"target_label": "[%key:ui::card::climate::target_label%]",
"target": "[%key:ui::card::climate::target%]"
}, },
"lawn_mower": { "lawn_mower": {
"actions": { "actions": {
@ -226,11 +232,13 @@
} }
}, },
"water_heater": { "water_heater": {
"currently": "Currently", "currently": "[%key:ui::card::climate::currently%]",
"on_off": "On / off", "on_off": "On / off",
"target_temperature": "Target temperature", "target_temperature": "Target temperature",
"away_mode": "Away mode", "away_mode": "Away mode",
"mode": "Mode" "mode": "Mode",
"target_label": "[%key:ui::card::climate::target_label%]",
"target": "[%key:ui::card::climate::target%]"
}, },
"weather": { "weather": {
"attributes": { "attributes": {
@ -1039,25 +1047,15 @@
"unlock": "Unlock" "unlock": "Unlock"
}, },
"climate": { "climate": {
"target_label": "{action} to target",
"target": "Target",
"humidity_target": "Humidity target",
"temperature": "Temperature", "temperature": "Temperature",
"humidity": "Humidity" "humidity": "Humidity"
}, },
"humidifier": {
"target_label": "[%key:ui::dialogs::more_info_control::climate::target_label%]",
"target": "[%key:ui::dialogs::more_info_control::climate::target%]"
},
"lawn_mower": { "lawn_mower": {
"activity": "Activity", "activity": "Activity",
"commands": "Lawn mower commands:", "commands": "Lawn mower commands:",
"start_mowing": "Start mowing", "start_mowing": "Start mowing",
"pause": "Pause", "pause": "Pause",
"dock": "Return to dock" "dock": "Return to dock"
},
"water_heater": {
"target": "[%key:ui::dialogs::more_info_control::climate::target%]"
} }
}, },
"entity_registry": { "entity_registry": {