Add new temperature control in climate more info (#17002)

* Add circular slider as temperature control

* Move climate icons and mode mapping

* Update icon

* Add mode icon

* Improve colors

* Add temperature control buttons

* Call service

* Remove climate control

* Some fixes

* Add current temp and humidity

* Fix default mode

* Swap state and current

* Some adjustments

* prettier

* Simplify color rules

* refactor cool mode

* Color button when dual climate

* Add current temp and humidity

* Fix opacity

* Hide current temp is below min or above max

* Adjust button size

* Add action label

* Better off and unavailable state

* Improve current color

* Add gallery

* Fix dark mode

* Add overflow

* Update src/dialogs/more-info/controls/more-info-climate.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Update src/panels/lovelace/cards/hui-thermostat-card.ts

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Update src/dialogs/more-info/components/climate/ha-more-info-climate-temperature.ts

---------

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Paul Bottein 2023-08-08 12:50:21 +02:00 committed by GitHub
parent 85733655c2
commit 89e96e4681
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 877 additions and 355 deletions

View File

@ -0,0 +1,3 @@
---
title: Climate
---

View File

@ -0,0 +1,83 @@
import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/dialogs/more-info/more-info-content";
import { getEntity } from "../../../../src/fake_data/entity";
import {
MockHomeAssistant,
provideHass,
} from "../../../../src/fake_data/provide_hass";
import "../../components/demo-more-infos";
import { ClimateEntityFeature } from "../../../../src/data/climate";
const ENTITIES = [
getEntity("climate", "thermostat", "heat", {
friendly_name: "Basic heater",
hvac_modes: ["heat", "off"],
hvac_mode: "heat",
current_temperature: 18,
temperature: 20,
min_temp: 10,
max_temp: 30,
supported_features: ClimateEntityFeature.TARGET_TEMPERATURE,
}),
getEntity("climate", "ac", "cool", {
friendly_name: "Basic air conditioning",
hvac_modes: ["cool", "off"],
hvac_mode: "cool",
current_temperature: 18,
temperature: 20,
min_temp: 10,
max_temp: 30,
supported_features: ClimateEntityFeature.TARGET_TEMPERATURE,
}),
getEntity("climate", "hvac", "auto", {
friendly_name: "Basic hvac",
hvac_modes: ["auto", "off"],
hvac_mode: "auto",
current_temperature: 18,
min_temp: 10,
max_temp: 30,
target_temp_step: 1,
supported_features: ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
target_temp_low: 20,
target_temp_high: 25,
}),
getEntity("climate", "unavailable", "unavailable", {
friendly_name: "Unavailable heater",
hvac_modes: ["heat", "off"],
hvac_mode: "heat",
min_temp: 10,
max_temp: 30,
supported_features: ClimateEntityFeature.TARGET_TEMPERATURE,
}),
];
@customElement("demo-more-info-climate")
class DemoMoreInfoClimate extends LitElement {
@property() public hass!: MockHomeAssistant;
@query("demo-more-infos") private _demoRoot!: HTMLElement;
protected render(): TemplateResult {
return html`
<demo-more-infos
.hass=${this.hass}
.entities=${ENTITIES.map((ent) => ent.entityId)}
></demo-more-infos>
`;
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-more-info-climate": DemoMoreInfoClimate;
}
}

View File

@ -19,7 +19,7 @@ import {
} from "../../common/entity/state_color";
import { iconColorCSS } from "../../common/style/icon_color_css";
import { cameraUrlWithWidthHeight } from "../../data/camera";
import { HVAC_ACTION_TO_MODE } from "../../data/climate";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import type { HomeAssistant } from "../../types";
import "../ha-state-icon";
@ -160,10 +160,10 @@ export class StateBadge extends LitElement {
}
if (stateObj.attributes.hvac_action) {
const hvacAction = stateObj.attributes.hvac_action;
if (hvacAction in HVAC_ACTION_TO_MODE) {
if (hvacAction in CLIMATE_HVAC_ACTION_TO_MODE) {
iconStyle.color = stateColorCss(
stateObj,
HVAC_ACTION_TO_MODE[hvacAction]
CLIMATE_HVAC_ACTION_TO_MODE[hvacAction]
)!;
} else {
delete iconStyle.color;

View File

@ -1,138 +0,0 @@
import { mdiChevronDown, mdiChevronUp } from "@mdi/js";
import { css, CSSResultGroup, html, LitElement, TemplateResult } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { conditionalClamp } from "../common/number/clamp";
import { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-icon-button";
@customElement("ha-climate-control")
class HaClimateControl extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public value!: number;
@property() public unit = "";
@property() public min?: number;
@property() public max?: number;
@property() public step = 1;
private _lastChanged?: number;
@query("#target_temperature") private _targetTemperature!: HTMLElement;
protected render(): TemplateResult {
return html`
<div id="target_temperature">${this.value} ${this.unit}</div>
<div class="control-buttons">
<div>
<ha-icon-button
.path=${mdiChevronUp}
.label=${this.hass.localize(
"ui.components.climate-control.temperature_up"
)}
@click=${this._incrementValue}
>
</ha-icon-button>
</div>
<div>
<ha-icon-button
.path=${mdiChevronDown}
.label=${this.hass.localize(
"ui.components.climate-control.temperature_down"
)}
@click=${this._decrementValue}
>
</ha-icon-button>
</div>
</div>
`;
}
protected updated(changedProperties) {
if (changedProperties.has("value")) {
this._valueChanged();
}
}
private _temperatureStateInFlux(inFlux) {
this._targetTemperature.classList.toggle("in-flux", inFlux);
}
private _round(value) {
// Round value to precision derived from step.
// Inspired by https://github.com/soundar24/roundSlider/blob/master/src/roundslider.js
const s = this.step.toString().split(".");
return s[1] ? parseFloat(value.toFixed(s[1].length)) : Math.round(value);
}
private _incrementValue() {
const newValue = this._round(this.value + this.step);
this._processNewValue(newValue);
}
private _decrementValue() {
const newValue = this._round(this.value - this.step);
this._processNewValue(newValue);
}
private _processNewValue(value) {
const newValue = conditionalClamp(value, this.min, this.max);
if (this.value !== newValue) {
this.value = newValue;
this._lastChanged = Date.now();
this._temperatureStateInFlux(true);
}
}
private _valueChanged() {
// When the last_changed timestamp is changed,
// trigger a potential event fire in the future,
// as long as last_changed is far enough in the past.
if (this._lastChanged) {
window.setTimeout(() => {
const now = Date.now();
if (now - this._lastChanged! >= 2000) {
fireEvent(this, "change");
this._temperatureStateInFlux(false);
this._lastChanged = undefined;
}
}, 2010);
}
}
static get styles(): CSSResultGroup {
return css`
:host {
display: flex;
justify-content: space-between;
}
.in-flux {
color: var(--error-color);
}
#target_temperature {
align-self: center;
font-size: 28px;
direction: ltr;
}
.control-buttons {
font-size: 24px;
text-align: right;
}
ha-icon-button {
--mdc-icon-size: 32px;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-climate-control": HaClimateControl;
}
}

View File

@ -409,6 +409,8 @@ export class HaControlCircularSlider extends LitElement {
value: number | undefined,
inverted: boolean | undefined
) {
if (this.disabled) return nothing;
const limit = inverted ? this.max : this.min;
const path = svgArc({
@ -437,7 +439,10 @@ export class HaControlCircularSlider extends LitElement {
const targetCircleDashArray = this._strokeCircleDashArc(target);
const currentCircleDashArray =
this.current != null && showActive
this.current != null &&
showActive &&
current <= this.max &&
current >= this.min
? this._strokeCircleDashArc(this.current)
: undefined;
@ -474,17 +479,11 @@ export class HaControlCircularSlider extends LitElement {
@keydown=${this._handleKeyDown}
@keyup=${this._handleKeyUp}
/>
<path
class="target"
d=${path}
stroke-dasharray=${targetCircleDashArray[0]}
stroke-dashoffset=${targetCircleDashArray[1]}
/>
${
currentCircleDashArray
? svg`
<path
class="current"
class="current arc-current"
d=${path}
stroke-dasharray=${currentCircleDashArray[0]}
stroke-dashoffset=${currentCircleDashArray[1]}
@ -492,6 +491,12 @@ export class HaControlCircularSlider extends LitElement {
`
: nothing
}
<path
class="target"
d=${path}
stroke-dasharray=${targetCircleDashArray[0]}
stroke-dashoffset=${targetCircleDashArray[1]}
/>
`;
}
@ -633,8 +638,8 @@ export class HaControlCircularSlider extends LitElement {
fill: none;
stroke-linecap: round;
stroke-width: 8px;
stroke: white;
opacity: 0.6;
stroke: var(--primary-text-color);
opacity: 0.5;
transition:
stroke-width 300ms ease-in-out,
stroke-dasharray 300ms ease-in-out,
@ -643,6 +648,10 @@ export class HaControlCircularSlider extends LitElement {
opacity 180ms ease-in-out;
}
.arc-current {
stroke: var(--clear-background-color);
}
.arc-clear {
stroke: var(--clear-background-color);
}

View File

@ -1,3 +1,14 @@
import {
mdiClockOutline,
mdiFan,
mdiFire,
mdiHeatWave,
mdiPower,
mdiSnowflake,
mdiSunSnowflakeVariant,
mdiThermostatAuto,
mdiWaterPercent,
} from "@mdi/js";
import {
HassEntityAttributeBase,
HassEntityBase,
@ -74,7 +85,7 @@ const hvacModeOrdering: { [key in HvacMode]: number } = {
export const compareClimateHvacModes = (mode1: HvacMode, mode2: HvacMode) =>
hvacModeOrdering[mode1] - hvacModeOrdering[mode2];
export const HVAC_ACTION_TO_MODE: Record<HvacAction, HvacMode> = {
export const CLIMATE_HVAC_ACTION_TO_MODE: Record<HvacAction, HvacMode> = {
cooling: "cool",
drying: "dry",
fan: "fan_only",
@ -83,3 +94,23 @@ export const HVAC_ACTION_TO_MODE: Record<HvacAction, HvacMode> = {
idle: "off",
off: "off",
};
export const CLIMATE_HVAC_ACTION_ICONS: Record<HvacAction, string> = {
cooling: mdiSnowflake,
drying: mdiWaterPercent,
fan: mdiFan,
heating: mdiFire,
idle: mdiClockOutline,
off: mdiPower,
preheating: mdiHeatWave,
};
export const CLIMATE_HVAC_MODE_ICONS: Record<HvacMode, string> = {
cool: mdiSnowflake,
dry: mdiWaterPercent,
fan_only: mdiFan,
auto: mdiThermostatAuto,
heat: mdiFire,
off: mdiPower,
heat_cool: mdiSunSnowflakeVariant,
};

View File

@ -0,0 +1,534 @@
import { mdiMinus, mdiPlus } from "@mdi/js";
import {
CSSResultGroup,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { computeAttributeValueDisplay } from "../../../../common/entity/compute_attribute_display";
import { stateActive } from "../../../../common/entity/state_active";
import { stateColorCss } from "../../../../common/entity/state_color";
import { supportsFeature } from "../../../../common/entity/supports-feature";
import { clamp } from "../../../../common/number/clamp";
import { formatNumber } from "../../../../common/number/format_number";
import { debounce } from "../../../../common/util/debounce";
import "../../../../components/ha-control-circular-slider";
import "../../../../components/ha-outlined-icon-button";
import "../../../../components/ha-svg-icon";
import {
CLIMATE_HVAC_ACTION_TO_MODE,
ClimateEntity,
ClimateEntityFeature,
} from "../../../../data/climate";
import { UNAVAILABLE } from "../../../../data/entity";
import { HomeAssistant } from "../../../../types";
type Target = "value" | "low" | "high";
@customElement("ha-more-info-climate-temperature")
export class HaMoreInfoClimateTemperature extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: ClimateEntity;
@state() private _targetTemperature: Partial<Record<Target, number>> = {};
@state() private _selectTargetTemperature: Target = "low";
protected willUpdate(changedProp: PropertyValues): void {
super.willUpdate(changedProp);
if (changedProp.has("stateObj")) {
this._targetTemperature = {
value: this.stateObj.attributes.temperature,
low: this.stateObj.attributes.target_temp_low,
high: this.stateObj.attributes.target_temp_high,
};
}
}
private get _step() {
return (
this.stateObj.attributes.target_temp_step ||
(this.hass.config.unit_system.temperature.indexOf("F") === -1 ? 0.5 : 1)
);
}
private get _min() {
return this.stateObj.attributes.min_temp;
}
private get _max() {
return this.stateObj.attributes.max_temp;
}
private _valueChanged(ev: CustomEvent) {
const value = (ev.detail as any).value;
if (isNaN(value)) return;
const target = ev.type.replace("-changed", "");
this._targetTemperature = {
...this._targetTemperature,
[target]: value,
};
this._selectTargetTemperature = target as Target;
this._callService(target);
}
private _valueChanging(ev: CustomEvent) {
const value = (ev.detail as any).value;
if (isNaN(value)) return;
const target = ev.type.replace("-changing", "");
this._targetTemperature = {
...this._targetTemperature,
[target]: value,
};
this._selectTargetTemperature = target as Target;
}
private _debouncedCallService = debounce(
(target: Target) => this._callService(target),
1000
);
private _callService(type: string) {
if (type === "high" || type === "low") {
this.hass.callService("climate", "set_temperature", {
entity_id: this.stateObj!.entity_id,
target_temp_low: this._targetTemperature.low,
target_temp_high: this._targetTemperature.high,
});
return;
}
this.hass.callService("climate", "set_temperature", {
entity_id: this.stateObj!.entity_id,
temperature: this._targetTemperature.value,
});
}
private _handleButton(ev) {
const target = ev.currentTarget.target as Target;
const step = ev.currentTarget.step as number;
const defaultValue = target === "high" ? this._max : this._min;
let temp = this._targetTemperature[target] ?? defaultValue;
temp += step;
temp = clamp(temp, this._min, this._max);
if (target === "high" && this._targetTemperature.low != null) {
temp = clamp(temp, this._targetTemperature.low, this._max);
}
if (target === "low" && this._targetTemperature.high != null) {
temp = clamp(temp, this._min, this._targetTemperature.high);
}
this._targetTemperature = {
...this._targetTemperature,
[target]: temp,
};
this._debouncedCallService(target);
}
private _handleSelectTemp(ev) {
const target = ev.currentTarget.target as Target;
this._selectTargetTemperature = target;
}
private _renderHvacAction() {
const action = this.stateObj.attributes.hvac_action;
const actionLabel = computeAttributeValueDisplay(
this.hass.localize,
this.stateObj,
this.hass.locale,
this.hass.config,
this.hass.entities,
"hvac_action"
) as string;
return html`
<p class="action">
${action && ["preheating", "heating", "cooling"].includes(action)
? this.hass.localize(
"ui.dialogs.more_info_control.climate.target_label",
{ action: actionLabel }
)
: action && action !== "off" && action !== "idle"
? actionLabel
: this.hass.localize("ui.dialogs.more_info_control.climate.target")}
</p>
`;
}
private _renderTemperatureButtons(target: Target, colored?: boolean) {
const lowColor = stateColorCss(this.stateObj, "heat");
const highColor = stateColorCss(this.stateObj, "cool");
const color = colored
? target === "high"
? highColor
: lowColor
: undefined;
return html`
<div class="buttons">
<ha-outlined-icon-button
style=${styleMap({
"--md-sys-color-outline": color,
})}
.target=${target}
.step=${-this._step}
@click=${this._handleButton}
>
<ha-svg-icon .path=${mdiMinus}></ha-svg-icon>
</ha-outlined-icon-button>
<ha-outlined-icon-button
style=${styleMap({
"--md-sys-color-outline": color,
})}
.target=${target}
.step=${this._step}
@click=${this._handleButton}
>
<ha-svg-icon .path=${mdiPlus}></ha-svg-icon>
</ha-outlined-icon-button>
</div>
`;
}
private _renderTargetTemperature(temperature: number) {
const digits = this._step.toString().split(".")?.[1]?.length ?? 0;
const formatted = formatNumber(temperature, this.hass.locale, {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
const [temperatureInteger] = formatted.includes(".")
? formatted.split(".")
: formatted.split(",");
const temperatureDecimal = formatted.replace(temperatureInteger, "");
return html`
<p class="temperature">
<span aria-hidden="true">
${temperatureInteger}
${digits !== 0
? html`<span class="decimal">${temperatureDecimal}</span>`
: nothing}
<span class="unit">
${this.hass.config.unit_system.temperature}
</span>
</span>
<span class="visually-hidden">
${this.stateObj.attributes.temperature}
${this.hass.config.unit_system.temperature}
</span>
</p>
`;
}
protected render() {
const supportsTargetTemperature = supportsFeature(
this.stateObj,
ClimateEntityFeature.TARGET_TEMPERATURE
);
const supportsTargetTemperatureRange = supportsFeature(
this.stateObj,
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
);
const mode = this.stateObj.state;
const action = this.stateObj.attributes.hvac_action;
const active = stateActive(this.stateObj);
const mainColor = stateColorCss(this.stateObj);
const lowColor = stateColorCss(this.stateObj, active ? "heat" : "off");
const highColor = stateColorCss(this.stateObj, active ? "cool" : "off");
let actionColor: string | undefined;
if (action && action !== "idle" && action !== "off" && active) {
actionColor = stateColorCss(
this.stateObj,
CLIMATE_HVAC_ACTION_TO_MODE[action]
);
}
const hvacModes = this.stateObj.attributes.hvac_modes;
if (supportsTargetTemperature && this._targetTemperature.value != null) {
const hasOnlyCoolMode =
hvacModes.length === 2 &&
hvacModes.includes("cool") &&
hvacModes.includes("off");
return html`
<div
class="container"
style=${styleMap({
"--main-color": mainColor,
"--action-color": actionColor,
})}
>
<ha-control-circular-slider
.inverted=${mode === "cool" || hasOnlyCoolMode}
.value=${this._targetTemperature.value}
.min=${this._min}
.max=${this._max}
.step=${this._step}
.current=${this.stateObj.attributes.current_temperature}
.disabled=${this.stateObj!.state === UNAVAILABLE}
@value-changed=${this._valueChanged}
@value-changing=${this._valueChanging}
>
</ha-control-circular-slider>
<div class="info">
<div class="action-container">${this._renderHvacAction()}</div>
<div class="temperature-container">
${this._renderTargetTemperature(this._targetTemperature.value)}
</div>
</div>
${this._renderTemperatureButtons("value")}
</div>
`;
}
if (
supportsTargetTemperatureRange &&
this._targetTemperature.low != null &&
this._targetTemperature.high != null
) {
return html`
<div
class="container"
style=${styleMap({
"--low-color": lowColor,
"--high-color": highColor,
"--action-color": actionColor,
})}
>
<ha-control-circular-slider
dual
.disabled=${this.stateObj!.state === UNAVAILABLE}
.low=${this._targetTemperature.low}
.high=${this._targetTemperature.high}
.min=${this._min}
.max=${this._max}
.step=${this._step}
.current=${this.stateObj.attributes.current_temperature}
@low-changed=${this._valueChanged}
@low-changing=${this._valueChanging}
@high-changed=${this._valueChanged}
@high-changing=${this._valueChanging}
>
</ha-control-circular-slider>
<div class="info">
<div class="action-container">${this._renderHvacAction()}</div>
<div class="temperature-container dual">
<button
@click=${this._handleSelectTemp}
.target=${"low"}
class=${classMap({
selected: this._selectTargetTemperature === "low",
})}
>
${this._renderTargetTemperature(this._targetTemperature.low)}
</button>
<button
@click=${this._handleSelectTemp}
.target=${"high"}
class=${classMap({
selected: this._selectTargetTemperature === "high",
})}
>
${this._renderTargetTemperature(this._targetTemperature.high)}
</button>
</div>
</div>
${this._renderTemperatureButtons(this._selectTargetTemperature, true)}
</div>
`;
}
return html`
<div
class="container"
style=${styleMap({
"--action-color": actionColor,
})}
>
<ha-control-circular-slider
.current=${this.stateObj.attributes.current_temperature}
.min=${this._min}
.max=${this._max}
.step=${this._step}
disabled
>
</ha-control-circular-slider>
</div>
`;
}
static get styles(): CSSResultGroup {
return css`
/* Layout */
.container {
position: relative;
}
.info {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
pointer-events: none;
font-size: 16px;
line-height: 24px;
letter-spacing: 0.1px;
}
.info * {
margin: 0;
pointer-events: auto;
}
/* Elements */
.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 .unit {
font-size: 24px;
line-height: 40px;
}
.temperature .decimal {
font-size: 24px;
line-height: 40px;
align-self: flex-end;
margin-right: -18px;
}
.action-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 200px;
height: 48px;
margin-bottom: 6px;
}
.action {
font-weight: 500;
text-align: center;
color: var(--action-color, inherit);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.dual {
display: flex;
flex-direction: row;
gap: 24px;
margin-bottom: 40px;
}
.dual button {
outline: none;
background: none;
color: inherit;
font-family: inherit;
-webkit-tap-highlight-color: transparent;
border: none;
opacity: 0.5;
padding: 0;
transition:
opacity 180ms ease-in-out,
transform 180ms ease-in-out;
cursor: pointer;
}
.dual button:focus-visible {
transform: scale(1.1);
}
.dual button.selected {
opacity: 1;
}
.buttons {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
margin: 0 auto;
width: 120px;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
}
.buttons ha-outlined-icon-button {
--md-outlined-icon-button-container-size: 48px;
--md-outlined-icon-button-icon-size: 24px;
}
/* Accessibility */
.visually-hidden {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
/* Slider */
ha-control-circular-slider {
--control-circular-slider-color: var(
--main-color,
var(--disabled-color)
);
--control-circular-slider-low-color: var(
--low-color,
var(--disabled-color)
);
--control-circular-slider-high-color: var(
--high-color,
var(--disabled-color)
);
}
ha-control-circular-slider::after {
display: block;
content: "";
position: absolute;
top: -10%;
left: -10%;
right: -10%;
bottom: -10%;
background: radial-gradient(
50% 50% at 50% 50%,
var(--action-color, transparent) 0%,
transparent 100%
);
opacity: 0.15;
pointer-events: none;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-climate-temperature": HaMoreInfoClimateTemperature;
}
}

View File

@ -19,6 +19,7 @@ export const EDITABLE_DOMAINS_WITH_UNIQUE_ID = ["script"];
export const DOMAINS_WITH_NEW_MORE_INFO = [
"alarm_control_panel",
"cover",
"climate",
"fan",
"input_boolean",
"light",

View File

@ -1,10 +1,10 @@
import "@material/mwc-list/mwc-list-item";
import {
css,
CSSResultGroup,
html,
LitElement,
PropertyValues,
css,
html,
nothing,
} from "lit";
import { property } from "lit/decorators";
@ -17,8 +17,9 @@ import {
} from "../../../common/entity/compute_attribute_display";
import { computeStateDisplay } from "../../../common/entity/compute_state_display";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { formatNumber } from "../../../common/number/format_number";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import { computeRTLDirection } from "../../../common/util/compute_rtl";
import "../../../components/ha-climate-control";
import "../../../components/ha-select";
import "../../../components/ha-slider";
import "../../../components/ha-switch";
@ -28,6 +29,8 @@ import {
compareClimateHvacModes,
} from "../../../data/climate";
import { HomeAssistant } from "../../../types";
import "../components/climate/ha-more-info-climate-temperature";
import { moreInfoControlStyle } from "../components/ha-more-info-control-style";
class MoreInfoClimate extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@ -73,13 +76,60 @@ class MoreInfoClimate extends LitElement {
ClimateEntityFeature.AUX_HEAT
);
const temperatureStepSize =
stateObj.attributes.target_temp_step ||
(hass.config.unit_system.temperature.indexOf("F") === -1 ? 0.5 : 1);
const currentTemperature = this.stateObj.attributes.current_temperature;
const currentHumidity = this.stateObj.attributes.current_humidity;
const rtlDirection = computeRTLDirection(hass);
return html`
${currentTemperature || currentHumidity
? html`<div class="current">
${currentTemperature != null
? html`
<div>
<p class="label">
${computeAttributeNameDisplay(
this.hass.localize,
this.stateObj,
this.hass.entities,
"current_temperature"
)}
</p>
<p class="value">
${formatNumber(currentTemperature, this.hass.locale)}
${this.hass.config.unit_system.temperature}
</p>
</div>
`
: nothing}
${currentHumidity != null
? html`
<div>
<p class="label">
${computeAttributeNameDisplay(
this.hass.localize,
this.stateObj,
this.hass.entities,
"current_humidity"
)}
</p>
<p class="value">
${formatNumber(
currentHumidity,
this.hass.locale
)}${blankBeforePercent(this.hass.locale)}%
</p>
</div>
`
: nothing}
</div>`
: nothing}
<div class="controls">
<ha-more-info-climate-temperature
.hass=${this.hass}
.stateObj=${this.stateObj}
></ha-more-info-climate-temperature>
</div>
<div
class=${classMap({
"has-current_temperature":
@ -94,64 +144,6 @@ class MoreInfoClimate extends LitElement {
"has-preset_mode": supportPresetMode,
})}
>
<div class="container-temperature">
<div class=${stateObj.state}>
${supportTargetTemperature || supportTargetTemperatureRange
? html`
<div>
${computeAttributeNameDisplay(
hass.localize,
stateObj,
hass.entities,
"temperature"
)}
</div>
`
: ""}
${stateObj.attributes.temperature !== undefined &&
stateObj.attributes.temperature !== null
? html`
<ha-climate-control
.hass=${this.hass}
.value=${stateObj.attributes.temperature}
.unit=${hass.config.unit_system.temperature}
.step=${temperatureStepSize}
.min=${stateObj.attributes.min_temp}
.max=${stateObj.attributes.max_temp}
@change=${this._targetTemperatureChanged}
></ha-climate-control>
`
: ""}
${(stateObj.attributes.target_temp_low !== undefined &&
stateObj.attributes.target_temp_low !== null) ||
(stateObj.attributes.target_temp_high !== undefined &&
stateObj.attributes.target_temp_high !== null)
? html`
<ha-climate-control
.hass=${this.hass}
.value=${stateObj.attributes.target_temp_low}
.unit=${hass.config.unit_system.temperature}
.step=${temperatureStepSize}
.min=${stateObj.attributes.min_temp}
.max=${stateObj.attributes.target_temp_high}
class="range-control-left"
@change=${this._targetTemperatureLowChanged}
></ha-climate-control>
<ha-climate-control
.hass=${this.hass}
.value=${stateObj.attributes.target_temp_high}
.unit=${hass.config.unit_system.temperature}
.step=${temperatureStepSize}
.min=${stateObj.attributes.target_temp_low}
.max=${stateObj.attributes.max_temp}
class="range-control-right"
@change=${this._targetTemperatureHighChanged}
></ha-climate-control>
`
: ""}
</div>
</div>
${supportTargetHumidity
? html`
<div class="container-humidity">
@ -184,34 +176,32 @@ class MoreInfoClimate extends LitElement {
: ""}
<div class="container-hvac_modes">
<div class="controls">
<ha-select
.label=${hass.localize("ui.card.climate.operation")}
.value=${stateObj.state}
fixedMenuPosition
naturalMenuWidth
@selected=${this._handleOperationmodeChanged}
@closed=${stopPropagation}
>
${stateObj.attributes.hvac_modes
.concat()
.sort(compareClimateHvacModes)
.map(
(mode) => html`
<mwc-list-item .value=${mode}>
${computeStateDisplay(
hass.localize,
stateObj,
hass.locale,
this.hass.config,
hass.entities,
mode
)}
</mwc-list-item>
`
)}
</ha-select>
</div>
<ha-select
.label=${hass.localize("ui.card.climate.operation")}
.value=${stateObj.state}
fixedMenuPosition
naturalMenuWidth
@selected=${this._handleOperationmodeChanged}
@closed=${stopPropagation}
>
${stateObj.attributes.hvac_modes
.concat()
.sort(compareClimateHvacModes)
.map(
(mode) => html`
<mwc-list-item .value=${mode}>
${computeStateDisplay(
hass.localize,
stateObj,
hass.locale,
this.hass.config,
hass.entities,
mode
)}
</mwc-list-item>
`
)}
</ha-select>
</div>
${supportPresetMode && stateObj.attributes.preset_modes
@ -358,42 +348,6 @@ class MoreInfoClimate extends LitElement {
}, 500);
}
private _targetTemperatureChanged(ev) {
const newVal = ev.target.value;
this._callServiceHelper(
this.stateObj!.attributes.temperature,
newVal,
"set_temperature",
{ temperature: newVal }
);
}
private _targetTemperatureLowChanged(ev) {
const newVal = ev.currentTarget.value;
this._callServiceHelper(
this.stateObj!.attributes.target_temp_low,
newVal,
"set_temperature",
{
target_temp_low: newVal,
target_temp_high: this.stateObj!.attributes.target_temp_high,
}
);
}
private _targetTemperatureHighChanged(ev) {
const newVal = ev.currentTarget.value;
this._callServiceHelper(
this.stateObj!.attributes.target_temp_high,
newVal,
"set_temperature",
{
target_temp_low: this.stateObj!.attributes.target_temp_low,
target_temp_high: newVal,
}
);
}
private _targetHumiditySliderChanged(ev) {
const newVal = ev.target.value;
this._callServiceHelper(
@ -492,48 +446,76 @@ class MoreInfoClimate extends LitElement {
}
static get styles(): CSSResultGroup {
return css`
:host {
color: var(--primary-text-color);
}
return [
moreInfoControlStyle,
css`
:host {
color: var(--primary-text-color);
}
ha-select {
width: 100%;
margin-top: 8px;
}
.current {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
text-align: center;
margin-bottom: 40px;
}
ha-slider {
width: 100%;
}
.current div {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
flex: 1;
}
.container-humidity .single-row {
display: flex;
height: 50px;
}
.current p {
margin: 0;
text-align: center;
color: var(--primary-text-color);
}
.target-humidity {
width: 90px;
font-size: 200%;
margin: auto;
direction: ltr;
}
.current .label {
opacity: 0.8;
font-size: 14px;
line-height: 16px;
letter-spacing: 0.4px;
margin-bottom: 4px;
}
ha-climate-control.range-control-left,
ha-climate-control.range-control-right {
float: left;
width: 46%;
}
ha-climate-control.range-control-left {
margin-right: 4%;
}
ha-climate-control.range-control-right {
margin-left: 4%;
}
.current .value {
font-size: 22px;
font-weight: 500;
line-height: 28px;
}
ha-select {
width: 100%;
margin-top: 8px;
}
.single-row {
padding: 8px 0;
}
`;
ha-slider {
width: 100%;
}
.container-humidity .single-row {
display: flex;
height: 50px;
}
.target-humidity {
width: 90px;
font-size: 200%;
margin: auto;
direction: ltr;
}
.single-row {
padding: 8px 0;
}
`,
];
}
}

View File

@ -3,6 +3,8 @@ import {
HassEntity,
HassEntityAttributeBase,
} from "home-assistant-js-websocket";
import { supportsFeature } from "../common/entity/supports-feature";
import { ClimateEntityFeature } from "../data/climate";
const now = () => new Date().toISOString();
const randomTime = () =>
@ -313,6 +315,45 @@ class ClimateEntity extends Entity {
super.handleService(domain, service, data);
}
}
public toState() {
const state = super.toState();
state.attributes.hvac_action = undefined;
if (
supportsFeature(
state as HassEntity,
ClimateEntityFeature.TARGET_TEMPERATURE
)
) {
const current = state.attributes.current_temperature;
const target = state.attributes.temperature;
if (state.state === "heat") {
state.attributes.hvac_action = target >= current ? "heating" : "idle";
}
if (state.state === "cool") {
state.attributes.hvac_action = target <= current ? "cooling" : "idle";
}
}
if (
supportsFeature(
state as HassEntity,
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
)
) {
const current = state.attributes.current_temperature;
const lowTarget = state.attributes.target_temp_low;
const highTarget = state.attributes.target_temp_high;
state.attributes.hvac_action =
lowTarget >= current
? "heating"
: highTarget <= current
? "cooling"
: "idle";
}
return state;
}
}
class GroupEntity extends Entity {

View File

@ -34,7 +34,7 @@ import { isValidEntityId } from "../../../common/entity/valid_entity_id";
import { iconColorCSS } from "../../../common/style/icon_color_css";
import { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-card";
import { HVAC_ACTION_TO_MODE } from "../../../data/climate";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../../data/climate";
import {
configContext,
entitiesContext,
@ -343,8 +343,8 @@ export class HuiButtonCard extends LitElement implements LovelaceCard {
}
if (stateObj.attributes.hvac_action) {
const hvacAction = stateObj.attributes.hvac_action;
if (hvacAction in HVAC_ACTION_TO_MODE) {
return stateColorCss(stateObj, HVAC_ACTION_TO_MODE[hvacAction]);
if (hvacAction in CLIMATE_HVAC_ACTION_TO_MODE) {
return stateColorCss(stateObj, CLIMATE_HVAC_ACTION_TO_MODE[hvacAction]);
}
return undefined;
}

View File

@ -29,7 +29,7 @@ import {
import { iconColorCSS } from "../../../common/style/icon_color_css";
import "../../../components/ha-card";
import "../../../components/ha-icon";
import { HVAC_ACTION_TO_MODE } from "../../../data/climate";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../../data/climate";
import { isUnavailableState } from "../../../data/entity";
import { HomeAssistant } from "../../../types";
import { computeCardSize } from "../common/compute-card-size";
@ -201,8 +201,8 @@ export class HuiEntityCard extends LitElement implements LovelaceCard {
private _computeColor(stateObj: HassEntity): string | undefined {
if (stateObj.attributes.hvac_action) {
const hvacAction = stateObj.attributes.hvac_action;
if (hvacAction in HVAC_ACTION_TO_MODE) {
return stateColorCss(stateObj, HVAC_ACTION_TO_MODE[hvacAction]);
if (hvacAction in CLIMATE_HVAC_ACTION_TO_MODE) {
return stateColorCss(stateObj, CLIMATE_HVAC_ACTION_TO_MODE[hvacAction]);
}
return undefined;
}

View File

@ -47,6 +47,7 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import { LovelaceCard, LovelaceCardEditor } 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,

View File

@ -1,40 +1,11 @@
import {
mdiClockOutline,
mdiFan,
mdiFire,
mdiHeatWave,
mdiPower,
mdiSnowflake,
mdiWaterPercent,
} from "@mdi/js";
import { stateColorCss } from "../../../../../common/entity/state_color";
import {
CLIMATE_HVAC_ACTION_ICONS,
CLIMATE_HVAC_ACTION_TO_MODE,
ClimateEntity,
HvacAction,
HvacMode,
} from "../../../../../data/climate";
import { ComputeBadgeFunction } from "./tile-badge";
export const CLIMATE_HVAC_ACTION_ICONS: Record<HvacAction, string> = {
cooling: mdiSnowflake,
drying: mdiWaterPercent,
fan: mdiFan,
heating: mdiFire,
idle: mdiClockOutline,
off: mdiPower,
preheating: mdiHeatWave,
};
export const CLIMATE_HVAC_ACTION_MODE: Record<HvacAction, HvacMode> = {
cooling: "cool",
drying: "dry",
fan: "fan_only",
heating: "heat",
idle: "off",
off: "off",
preheating: "heat",
};
export const computeClimateBadge: ComputeBadgeFunction = (stateObj) => {
const hvacAction = (stateObj as ClimateEntity).attributes.hvac_action;
@ -44,6 +15,6 @@ export const computeClimateBadge: ComputeBadgeFunction = (stateObj) => {
return {
iconPath: CLIMATE_HVAC_ACTION_ICONS[hvacAction],
color: stateColorCss(stateObj, CLIMATE_HVAC_ACTION_MODE[hvacAction]),
color: stateColorCss(stateObj, CLIMATE_HVAC_ACTION_TO_MODE[hvacAction]),
};
};

View File

@ -109,7 +109,7 @@ documentContainer.innerHTML = `<custom-style>
--yellow-color: #ffeb3b;
--amber-color: #ffc107;
--orange-color: #ff9800;
--deep-orange-color: #ff5722;
--deep-orange-color: #ff6f22;
--brown-color: #795548;
--light-grey-color: #bdbdbd;
--grey-color: #9e9e9e;

View File

@ -996,6 +996,10 @@
"open": "Open",
"lock": "Lock",
"unlock": "Unlock"
},
"climate": {
"target_label": "{action} to target",
"target": "Target"
}
},
"entity_registry": {