diff --git a/gallery/src/pages/lovelace/thermostat-card.ts b/gallery/src/pages/lovelace/thermostat-card.ts index cc33346a0eb6..623adf5e3436 100644 --- a/gallery/src/pages/lovelace/thermostat-card.ts +++ b/gallery/src/pages/lovelace/thermostat-card.ts @@ -35,6 +35,18 @@ const ENTITIES = [ friendly_name: "Nest", supported_features: 43, }), + getEntity("climate", "sensibo", "fan_only", { + current_temperature: null, + temperature: null, + min_temp: 0, + max_temp: 1, + target_temp_step: 1, + hvac_modes: ["fan_only", "off"], + friendly_name: "Sensibo purifier", + fan_modes: ["low", "high"], + fan_mode: "low", + supported_features: 9, + }), getEntity("climate", "unavailable", "unavailable", { supported_features: 43, }), @@ -57,6 +69,23 @@ const CONFIGS = [ entity: climate.nest `, }, + { + heading: "Fan only example", + config: ` +- type: thermostat + entity: climate.sensibo + features: + - type: climate-hvac-modes + hvac_modes: + - fan_only + - 'off' + - type: climate-fan-modes + style: icons + fan_modes: + - low + - high + `, + }, { heading: "Unavailable", config: ` diff --git a/gallery/src/pages/more-info/climate.ts b/gallery/src/pages/more-info/climate.ts index daa3554c5035..f6216a55b1c4 100644 --- a/gallery/src/pages/more-info/climate.ts +++ b/gallery/src/pages/more-info/climate.ts @@ -31,6 +31,21 @@ const ENTITIES = [ max_temp: 30, supported_features: ClimateEntityFeature.TARGET_TEMPERATURE, }), + getEntity("climate", "fan", "fan_only", { + friendly_name: "Basic fan", + hvac_modes: ["fan_only", "off"], + hvac_mode: "fan_only", + fan_modes: ["low", "high"], + fan_mode: "low", + current_temperature: null, + temperature: null, + min_temp: 0, + max_temp: 1, + target_temp_step: 1, + supported_features: + // eslint-disable-next-line no-bitwise + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE, + }), getEntity("climate", "hvac", "auto", { friendly_name: "Basic hvac", hvac_modes: ["auto", "off"], diff --git a/src/panels/lovelace/card-features/hui-climate-fan-modes-card-feature.ts b/src/panels/lovelace/card-features/hui-climate-fan-modes-card-feature.ts new file mode 100644 index 000000000000..bfdf039b94f6 --- /dev/null +++ b/src/panels/lovelace/card-features/hui-climate-fan-modes-card-feature.ts @@ -0,0 +1,217 @@ +import { mdiFan } from "@mdi/js"; +import { HassEntity } from "home-assistant-js-websocket"; +import { css, html, LitElement, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { stopPropagation } from "../../../common/dom/stop_propagation"; +import { computeDomain } from "../../../common/entity/compute_domain"; +import { supportsFeature } from "../../../common/entity/supports-feature"; +import "../../../components/ha-control-select"; +import type { ControlSelectOption } from "../../../components/ha-control-select"; +import "../../../components/ha-control-select-menu"; +import type { HaControlSelectMenu } from "../../../components/ha-control-select-menu"; +import { + ClimateEntity, + ClimateEntityFeature, + computeFanModeIcon, +} from "../../../data/climate"; +import { UNAVAILABLE } from "../../../data/entity"; +import { HomeAssistant } from "../../../types"; +import { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types"; +import { ClimateFanModesCardFeatureConfig } from "./types"; + +export const supportsClimateFanModesCardFeature = (stateObj: HassEntity) => { + const domain = computeDomain(stateObj.entity_id); + return ( + domain === "climate" && + supportsFeature(stateObj, ClimateEntityFeature.FAN_MODE) + ); +}; + +@customElement("hui-climate-fan-modes-card-feature") +class HuiClimateFanModesCardFeature + extends LitElement + implements LovelaceCardFeature +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public stateObj?: ClimateEntity; + + @state() private _config?: ClimateFanModesCardFeatureConfig; + + @state() _currentFanMode?: string; + + @query("ha-control-select-menu", true) + private _haSelect?: HaControlSelectMenu; + + static getStubConfig( + _, + stateObj?: HassEntity + ): ClimateFanModesCardFeatureConfig { + return { + type: "climate-fan-modes", + style: "dropdown", + fan_modes: stateObj?.attributes.fan_modes || [], + }; + } + + public static async getConfigElement(): Promise { + await import( + "../editor/config-elements/hui-climate-fan-modes-card-feature-editor" + ); + return document.createElement("hui-climate-fan-modes-card-feature-editor"); + } + + public setConfig(config: ClimateFanModesCardFeatureConfig): void { + if (!config) { + throw new Error("Invalid configuration"); + } + this._config = config; + } + + protected willUpdate(changedProp: PropertyValues): void { + super.willUpdate(changedProp); + if (changedProp.has("stateObj") && this.stateObj) { + this._currentFanMode = this.stateObj.attributes.fan_mode; + } + } + + protected updated(changedProps: PropertyValues) { + super.updated(changedProps); + if (this._haSelect && changedProps.has("hass")) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + if ( + this.hass && + this.hass.formatEntityAttributeValue !== + oldHass?.formatEntityAttributeValue + ) { + this._haSelect.layoutOptions(); + } + } + } + + private async _valueChanged(ev: CustomEvent) { + const fanMode = + (ev.detail as any).value ?? ((ev.target as any).value as string); + + const oldFanMode = this.stateObj!.attributes.fan_mode; + + if (fanMode === oldFanMode) return; + + this._currentFanMode = fanMode; + + try { + await this._setMode(fanMode); + } catch (err) { + this._currentFanMode = oldFanMode; + } + } + + private async _setMode(mode: string) { + await this.hass!.callService("climate", "set_fan_mode", { + entity_id: this.stateObj!.entity_id, + fan_mode: mode, + }); + } + + protected render(): TemplateResult | null { + if ( + !this._config || + !this.hass || + !this.stateObj || + !supportsClimateFanModesCardFeature(this.stateObj) + ) { + return null; + } + + const stateObj = this.stateObj; + + const modes = stateObj.attributes.fan_modes || []; + + const options = modes + .filter((mode) => (this._config!.fan_modes || []).includes(mode)) + .map((mode) => ({ + value: mode, + label: this.hass!.formatEntityAttributeValue( + this.stateObj!, + "fan_mode", + mode + ), + path: computeFanModeIcon(mode), + })); + + if (this._config.style === "icons") { + return html` +
+ + +
+ `; + } + + return html` +
+ + + ${options.map( + (option) => html` + + + ${option.label} + + ` + )} + +
+ `; + } + + static get styles() { + return css` + ha-control-select-menu { + box-sizing: border-box; + --control-select-menu-height: 40px; + --control-select-menu-border-radius: 10px; + line-height: 1.2; + display: block; + width: 100%; + } + ha-control-select { + --control-select-color: var(--feature-color); + --control-select-padding: 0; + --control-select-thickness: 40px; + --control-select-border-radius: 10px; + --control-select-button-border-radius: 10px; + } + .container { + padding: 0 12px 12px 12px; + width: auto; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-climate-fan-modes-card-feature": HuiClimateFanModesCardFeature; + } +} diff --git a/src/panels/lovelace/card-features/types.ts b/src/panels/lovelace/card-features/types.ts index bd8ef53443a6..f70c792bb77c 100644 --- a/src/panels/lovelace/card-features/types.ts +++ b/src/panels/lovelace/card-features/types.ts @@ -35,6 +35,12 @@ export interface AlarmModesCardFeatureConfig { modes?: AlarmMode[]; } +export interface ClimateFanModesCardFeatureConfig { + type: "climate-fan-modes"; + style?: "dropdown" | "icons"; + fan_modes?: string[]; +} + export interface ClimateHvacModesCardFeatureConfig { type: "climate-hvac-modes"; style?: "dropdown" | "icons"; @@ -105,6 +111,7 @@ export interface LawnMowerCommandsCardFeatureConfig { export type LovelaceCardFeatureConfig = | AlarmModesCardFeatureConfig + | ClimateFanModesCardFeatureConfig | ClimateHvacModesCardFeatureConfig | ClimatePresetModesCardFeatureConfig | CoverOpenCloseCardFeatureConfig diff --git a/src/panels/lovelace/create-element/create-card-feature-element.ts b/src/panels/lovelace/create-element/create-card-feature-element.ts index 875a6f357caa..a5d099c20de4 100644 --- a/src/panels/lovelace/create-element/create-card-feature-element.ts +++ b/src/panels/lovelace/create-element/create-card-feature-element.ts @@ -1,4 +1,5 @@ import "../card-features/hui-alarm-modes-card-feature"; +import "../card-features/hui-climate-fan-modes-card-feature"; import "../card-features/hui-climate-hvac-modes-card-feature"; import "../card-features/hui-climate-preset-modes-card-feature"; import "../card-features/hui-cover-open-close-card-feature"; @@ -25,6 +26,7 @@ import { const TYPES: Set = new Set([ "alarm-modes", + "climate-fan-modes", "climate-hvac-modes", "climate-preset-modes", "cover-open-close", diff --git a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts index 9405377c1ad3..051f31e4d16e 100644 --- a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts @@ -22,6 +22,7 @@ import { sortableStyles } from "../../../../resources/ha-sortable-style"; import type { SortableInstance } from "../../../../resources/sortable"; import { HomeAssistant } from "../../../../types"; import { supportsAlarmModesCardFeature } from "../../card-features/hui-alarm-modes-card-feature"; +import { supportsClimateFanModesCardFeature } from "../../card-features/hui-climate-fan-modes-card-feature"; import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-climate-hvac-modes-card-feature"; import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature"; import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature"; @@ -48,6 +49,7 @@ type SupportsFeature = (stateObj: HassEntity) => boolean; const UI_FEATURE_TYPES = [ "alarm-modes", + "climate-fan-modes", "climate-hvac-modes", "climate-preset-modes", "cover-open-close", @@ -86,6 +88,7 @@ const SUPPORTS_FEATURE_TYPES: Record< SupportsFeature | undefined > = { "alarm-modes": supportsAlarmModesCardFeature, + "climate-fan-modes": supportsClimateFanModesCardFeature, "climate-hvac-modes": supportsClimateHvacModesCardFeature, "climate-preset-modes": supportsClimatePresetModesCardFeature, "cover-open-close": supportsCoverOpenCloseCardFeature, diff --git a/src/panels/lovelace/editor/config-elements/hui-climate-fan-modes-card-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-climate-fan-modes-card-feature-editor.ts new file mode 100644 index 000000000000..3316c26b5c47 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-climate-fan-modes-card-feature-editor.ts @@ -0,0 +1,129 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import { FormatEntityAttributeValueFunc } from "../../../../common/translations/entity-state"; +import { LocalizeFunc } from "../../../../common/translations/localize"; +import "../../../../components/ha-form/ha-form"; +import type { + HaFormSchema, + SchemaUnion, +} from "../../../../components/ha-form/types"; +import type { HomeAssistant } from "../../../../types"; +import { + ClimateFanModesCardFeatureConfig, + LovelaceCardFeatureContext, +} from "../../card-features/types"; +import type { LovelaceCardFeatureEditor } from "../../types"; + +@customElement("hui-climate-fan-modes-card-feature-editor") +export class HuiClimateFanModesCardFeatureEditor + extends LitElement + implements LovelaceCardFeatureEditor +{ + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public context?: LovelaceCardFeatureContext; + + @state() private _config?: ClimateFanModesCardFeatureConfig; + + public setConfig(config: ClimateFanModesCardFeatureConfig): void { + this._config = config; + } + + private _schema = memoizeOne( + ( + localize: LocalizeFunc, + formatEntityAttributeValue: FormatEntityAttributeValueFunc, + stateObj?: HassEntity + ) => + [ + { + name: "style", + selector: { + select: { + multiple: false, + mode: "list", + options: ["dropdown", "icons"].map((mode) => ({ + value: mode, + label: localize( + `ui.panel.lovelace.editor.features.types.climate-fan-modes.style_list.${mode}` + ), + })), + }, + }, + }, + { + name: "fan_modes", + selector: { + select: { + multiple: true, + mode: "list", + options: + stateObj?.attributes.fan_modes?.map((mode) => ({ + value: mode, + label: formatEntityAttributeValue(stateObj, "fan_mode", mode), + })) || [], + }, + }, + }, + ] as const satisfies readonly HaFormSchema[] + ); + + protected render() { + if (!this.hass || !this._config) { + return nothing; + } + + const stateObj = this.context?.entity_id + ? this.hass.states[this.context?.entity_id] + : undefined; + + const data: ClimateFanModesCardFeatureConfig = { + style: "dropdown", + fan_modes: [], + ...this._config, + }; + + const schema = this._schema( + this.hass.localize, + this.hass.formatEntityAttributeValue, + stateObj + ); + + return html` + + `; + } + + private _valueChanged(ev: CustomEvent): void { + fireEvent(this, "config-changed", { config: ev.detail.value }); + } + + private _computeLabelCallback = ( + schema: SchemaUnion> + ) => { + switch (schema.name) { + case "style": + case "fan_modes": + return this.hass!.localize( + `ui.panel.lovelace.editor.features.types.climate-fan-modes.${schema.name}` + ); + default: + return ""; + } + }; +} + +declare global { + interface HTMLElementTagNameMap { + "hui-climate-fan-modes-card-feature-editor": HuiClimateFanModesCardFeatureEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-thermostat-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-thermostat-card-editor.ts index 09d2efe0cfe5..a126496d0aa8 100644 --- a/src/panels/lovelace/editor/config-elements/hui-thermostat-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-thermostat-card-editor.ts @@ -33,6 +33,7 @@ import type { FeatureType } from "./hui-card-features-editor"; const COMPATIBLE_FEATURES_TYPES: FeatureType[] = [ "climate-hvac-modes", "climate-preset-modes", + "climate-fan-modes", ]; const cardConfigStruct = assign( diff --git a/src/translations/en.json b/src/translations/en.json index 31c4211789d4..ae036e293fde 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5325,6 +5325,15 @@ "return_home": "[%key:ui::dialogs::more_info_control::vacuum::return_home%]" } }, + "climate-fan-modes": { + "label": "Climate fan modes", + "style": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style%]", + "style_list": { + "dropdown": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style_list::dropdown%]", + "icons": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style_list::icons%]" + }, + "fan_modes": "Fan modes" + }, "climate-hvac-modes": { "label": "Climate HVAC modes", "hvac_modes": "HVAC modes",