Add fan direction feature (#26467)

* Create fan-direction feature

* Translate direction buttons tooltip

* Update src/translations/en.json

fix fan-direction label to sentence-cased

Co-authored-by: Norbert Rittel <norbert@rittel.de>

* Update src/translations/en.json

Remove usued translation

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

---------

Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
pcan08
2025-08-11 15:32:42 +02:00
committed by GitHub
parent 3439d1d663
commit 60a1d25e1e
6 changed files with 166 additions and 0 deletions

View File

@@ -34,6 +34,8 @@ export interface FanEntity extends HassEntityBase {
attributes: FanEntityAttributes; attributes: FanEntityAttributes;
} }
export type FanDirection = "forward" | "reverse";
export type FanSpeed = "off" | "low" | "medium" | "high" | "on"; export type FanSpeed = "off" | "low" | "medium" | "high" | "on";
export const FAN_SPEEDS: Partial<Record<number, FanSpeed[]>> = { export const FAN_SPEEDS: Partial<Record<number, FanSpeed[]>> = {

View File

@@ -0,0 +1,151 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attribute-icon";
import "../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../components/ha-control-select";
import { UNAVAILABLE } from "../../../data/entity";
import type { FanEntity, FanDirection } from "../../../data/fan";
import { FanEntityFeature } from "../../../data/fan";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
FanDirectionCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const supportsFanDirectionCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "fan" && supportsFeature(stateObj, FanEntityFeature.DIRECTION)
);
};
@customElement("hui-fan-direction-card-feature")
class HuiFanDirectionCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: FanDirectionCardFeatureConfig;
@state() _currentDirection?: FanDirection;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] as FanEntity | undefined;
}
static getStubConfig(): FanDirectionCardFeatureConfig {
return {
type: "fan-direction",
};
}
public setConfig(config: FanDirectionCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected willUpdate(changedProp: PropertyValues): void {
if (
(changedProp.has("hass") || changedProp.has("context")) &&
this._stateObj
) {
const oldHass = changedProp.get("hass") as HomeAssistant | undefined;
const oldStateObj = oldHass?.states[this.context!.entity_id!];
if (oldStateObj !== this._stateObj) {
this._currentDirection = this._stateObj.attributes
.direction as FanDirection;
}
}
}
private async _valueChanged(ev: CustomEvent) {
const newDirection = (ev.detail as any).value as FanDirection;
if (newDirection === this._stateObj!.attributes.direction) return;
const oldDirection = this._stateObj!.attributes.direction as FanDirection;
this._currentDirection = newDirection;
try {
await this._setDirection(newDirection);
} catch (_err) {
this._currentDirection = oldDirection;
}
}
private async _setDirection(direction: string) {
await this.hass!.callService("fan", "set_direction", {
entity_id: this._stateObj!.entity_id,
direction: direction,
});
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsFanDirectionCardFeature(this.hass, this.context)
) {
return null;
}
const stateObj = this._stateObj;
const FAN_DIRECTION_MAP: FanDirection[] = ["forward", "reverse"];
const options = FAN_DIRECTION_MAP.map<ControlSelectOption>((direction) => ({
value: direction,
label: this.hass!.localize(`ui.card.fan.${direction}`),
icon: html`<ha-attribute-icon
slot="graphic"
.hass=${this.hass}
.stateObj=${stateObj}
attribute="direction"
.attributeValue=${direction}
></ha-attribute-icon>`,
}));
return html`
<ha-control-select
.options=${options}
.value=${this._currentDirection}
@value-changed=${this._valueChanged}
hide-option-label
.label=${this.hass!.formatEntityAttributeName(stateObj, "direction")}
.disabled=${this._stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-fan-direction-card-feature": HuiFanDirectionCardFeature;
}
}

View File

@@ -43,6 +43,10 @@ export interface MediaPlayerVolumeSliderCardFeatureConfig {
type: "media-player-volume-slider"; type: "media-player-volume-slider";
} }
export interface FanDirectionCardFeatureConfig {
type: "fan-direction";
}
export interface FanPresetModesCardFeatureConfig { export interface FanPresetModesCardFeatureConfig {
type: "fan-preset-modes"; type: "fan-preset-modes";
style?: "dropdown" | "icons"; style?: "dropdown" | "icons";
@@ -201,6 +205,7 @@ export type LovelaceCardFeatureConfig =
| CoverPositionCardFeatureConfig | CoverPositionCardFeatureConfig
| CoverTiltPositionCardFeatureConfig | CoverTiltPositionCardFeatureConfig
| CoverTiltCardFeatureConfig | CoverTiltCardFeatureConfig
| FanDirectionCardFeatureConfig
| FanPresetModesCardFeatureConfig | FanPresetModesCardFeatureConfig
| FanSpeedCardFeatureConfig | FanSpeedCardFeatureConfig
| HumidifierToggleCardFeatureConfig | HumidifierToggleCardFeatureConfig

View File

@@ -10,6 +10,7 @@ import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-card-feature"; import "../card-features/hui-cover-position-card-feature";
import "../card-features/hui-cover-tilt-card-feature"; import "../card-features/hui-cover-tilt-card-feature";
import "../card-features/hui-cover-tilt-position-card-feature"; import "../card-features/hui-cover-tilt-position-card-feature";
import "../card-features/hui-fan-direction-card-feature";
import "../card-features/hui-fan-preset-modes-card-feature"; import "../card-features/hui-fan-preset-modes-card-feature";
import "../card-features/hui-fan-speed-card-feature"; import "../card-features/hui-fan-speed-card-feature";
import "../card-features/hui-humidifier-modes-card-feature"; import "../card-features/hui-humidifier-modes-card-feature";
@@ -50,6 +51,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"cover-position", "cover-position",
"cover-tilt-position", "cover-tilt-position",
"cover-tilt", "cover-tilt",
"fan-direction",
"fan-preset-modes", "fan-preset-modes",
"fan-speed", "fan-speed",
"humidifier-modes", "humidifier-modes",

View File

@@ -30,6 +30,7 @@ import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover
import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature"; import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature";
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature"; import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature"; import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
import { supportsFanPresetModesCardFeature } from "../../card-features/hui-fan-preset-modes-card-feature"; import { supportsFanPresetModesCardFeature } from "../../card-features/hui-fan-preset-modes-card-feature";
import { supportsFanSpeedCardFeature } from "../../card-features/hui-fan-speed-card-feature"; import { supportsFanSpeedCardFeature } from "../../card-features/hui-fan-speed-card-feature";
import { supportsHumidifierModesCardFeature } from "../../card-features/hui-humidifier-modes-card-feature"; import { supportsHumidifierModesCardFeature } from "../../card-features/hui-humidifier-modes-card-feature";
@@ -75,6 +76,7 @@ const UI_FEATURE_TYPES = [
"cover-position", "cover-position",
"cover-tilt-position", "cover-tilt-position",
"cover-tilt", "cover-tilt",
"fan-direction",
"fan-preset-modes", "fan-preset-modes",
"fan-speed", "fan-speed",
"humidifier-modes", "humidifier-modes",
@@ -135,6 +137,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"cover-position": supportsCoverPositionCardFeature, "cover-position": supportsCoverPositionCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature, "cover-tilt-position": supportsCoverTiltPositionCardFeature,
"cover-tilt": supportsCoverTiltCardFeature, "cover-tilt": supportsCoverTiltCardFeature,
"fan-direction": supportsFanDirectionCardFeature,
"fan-preset-modes": supportsFanPresetModesCardFeature, "fan-preset-modes": supportsFanPresetModesCardFeature,
"fan-speed": supportsFanSpeedCardFeature, "fan-speed": supportsFanSpeedCardFeature,
"humidifier-modes": supportsHumidifierModesCardFeature, "humidifier-modes": supportsHumidifierModesCardFeature,

View File

@@ -7835,6 +7835,9 @@
"cover-tilt-position": { "cover-tilt-position": {
"label": "Cover tilt position" "label": "Cover tilt position"
}, },
"fan-direction": {
"label": "Fan direction"
},
"fan-speed": { "fan-speed": {
"label": "Fan speed" "label": "Fan speed"
}, },