Add fan new more info (#15843)

* Add more info fan

* Change icon

* Use backend translations

* Fix computeAttributeValueDisplay

* Clean code

* Clean code

* Fix some styles

* Improve ha-select rounded style

* Use button instead of select

* Show fan speed percentage
This commit is contained in:
Paul Bottein 2023-03-22 13:20:14 +01:00 committed by GitHub
parent cd2996734c
commit 45c153d374
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 564 additions and 249 deletions

View File

@ -11,7 +11,7 @@ export const enum FanEntityFeature {
}
interface FanEntityAttributes extends HassEntityAttributeBase {
direction?: number;
direction?: string;
oscillating?: boolean;
percentage?: number;
percentage_step?: number;

View File

@ -0,0 +1,217 @@
import {
mdiFan,
mdiFanOff,
mdiFanSpeed1,
mdiFanSpeed2,
mdiFanSpeed3,
} from "@mdi/js";
import { HassEntity } from "home-assistant-js-websocket";
import { css, CSSResultGroup, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeAttributeNameDisplay } from "../../../../common/entity/compute_attribute_display";
import { computeStateDisplay } from "../../../../common/entity/compute_state_display";
import { stateColorCss } from "../../../../common/entity/state_color";
import "../../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../../components/ha-control-select";
import "../../../../components/ha-control-slider";
import { UNAVAILABLE } from "../../../../data/entity";
import { FanEntity } from "../../../../data/fan";
import { HomeAssistant } from "../../../../types";
type Speed = "off" | "low" | "medium" | "high" | "on";
const SPEEDS: Partial<Record<number, Speed[]>> = {
2: ["off", "on"],
3: ["off", "low", "high"],
4: ["off", "low", "medium", "high"],
};
function percentageToSpeed(stateObj: HassEntity, value: number): string {
const step = stateObj.attributes.percentage_step ?? 1;
const speedValue = Math.round(value / step);
const speedCount = Math.round(100 / step) + 1;
const speeds = SPEEDS[speedCount];
return speeds?.[speedValue] ?? "off";
}
function speedToPercentage(stateObj: HassEntity, speed: Speed): number {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
const speeds = SPEEDS[speedCount];
if (!speeds) {
return 0;
}
const speedValue = speeds.indexOf(speed);
if (speedValue === -1) {
return 0;
}
return Math.round(speedValue * step);
}
const SPEED_ICON_NUMBER: string[] = [mdiFanSpeed1, mdiFanSpeed2, mdiFanSpeed3];
export function getFanSpeedCount(stateObj: HassEntity) {
const step = stateObj.attributes.percentage_step ?? 1;
const speedCount = Math.round(100 / step) + 1;
return speedCount;
}
export const FAN_SPEED_COUNT_MAX_FOR_BUTTONS = 4;
@customElement("ha-more-info-fan-speed")
export class HaMoreInfoFanSpeed extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: FanEntity;
@state() value?: number;
protected updated(changedProp: Map<string | number | symbol, unknown>): void {
if (changedProp.has("stateObj")) {
this.value =
this.stateObj.attributes.percentage != null
? Math.max(Math.round(this.stateObj.attributes.percentage), 1)
: undefined;
}
}
private _speedValueChanged(ev: CustomEvent) {
const speed = (ev.detail as any).value as Speed;
const percentage = speedToPercentage(this.stateObj, speed);
this.hass.callService("fan", "set_percentage", {
entity_id: this.stateObj!.entity_id,
percentage: percentage,
});
}
private _valueChanged(ev: CustomEvent) {
const value = (ev.detail as any).value;
if (isNaN(value)) return;
this.hass.callService("fan", "set_percentage", {
entity_id: this.stateObj!.entity_id,
percentage: value,
});
}
private _localizeSpeed(speed: Speed) {
if (speed === "on" || speed === "off") {
return computeStateDisplay(
this.hass.localize,
this.stateObj,
this.hass.locale,
this.hass.entities,
speed
);
}
return (
this.hass.localize(`ui.dialogs.more_info_control.fan.speed.${speed}`) ||
speed
);
}
protected render() {
const color = stateColorCss(this.stateObj);
const speedCount = getFanSpeedCount(this.stateObj);
if (speedCount <= FAN_SPEED_COUNT_MAX_FOR_BUTTONS) {
const options = SPEEDS[speedCount]!.map<ControlSelectOption>(
(speed, index) => ({
value: speed,
label: this._localizeSpeed(speed),
path:
speed === "on"
? mdiFan
: speed === "off"
? mdiFanOff
: SPEED_ICON_NUMBER[index - 1],
})
).reverse();
const speed = percentageToSpeed(
this.stateObj,
this.stateObj.attributes.percentage ?? 0
);
return html`
<ha-control-select
vertical
.options=${options}
.value=${speed}
@value-changed=${this._speedValueChanged}
.ariaLabel=${computeAttributeNameDisplay(
this.hass.localize,
this.stateObj,
this.hass.entities,
"percentage"
)}
style=${styleMap({
"--control-select-color": color,
})}
>
</ha-control-select>
`;
}
return html`
<ha-control-slider
vertical
.value=${this.value}
min="0"
max="100"
.step=${this.stateObj.attributes.percentage_step ?? 1}
@value-changed=${this._valueChanged}
.ariaLabel=${computeAttributeNameDisplay(
this.hass.localize,
this.stateObj,
this.hass.entities,
"percentage"
)}
style=${styleMap({
"--control-slider-color": color,
})}
.disabled=${this.stateObj.state === UNAVAILABLE}
>
</ha-control-slider>
`;
}
static get styles(): CSSResultGroup {
return css`
ha-control-slider {
height: 45vh;
max-height: 320px;
min-height: 200px;
--control-slider-thickness: 100px;
--control-slider-border-radius: 24px;
--control-slider-color: var(--primary-color);
--control-slider-background: var(--disabled-color);
--control-slider-background-opacity: 0.2;
}
ha-control-select {
height: 45vh;
max-height: 320px;
min-height: 200px;
--control-select-thickness: 100px;
--control-select-border-radius: 24px;
--control-select-color: var(--primary-color);
--control-select-background: var(--disabled-color);
--control-select-background-opacity: 0.2;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-fan-speed": HaMoreInfoFanSpeed;
}
}

View File

@ -18,6 +18,17 @@ export const moreInfoControlStyle = css`
margin-bottom: 24px;
}
.buttons {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.buttons > * {
margin: 4px;
}
ha-attributes {
width: 100%;
}

View File

@ -17,6 +17,7 @@ export const EDITABLE_DOMAINS_WITH_ID = ["scene", "automation"];
export const EDITABLE_DOMAINS_WITH_UNIQUE_ID = ["script"];
/** Domains with with new more info design. */
export const DOMAINS_WITH_NEW_MORE_INFO = [
"fan",
"input_boolean",
"light",
"siren",

View File

@ -1,238 +0,0 @@
import "@material/mwc-list/mwc-list-item";
import "@polymer/iron-flex-layout/iron-flex-layout-classes";
import { html } from "@polymer/polymer/lib/utils/html-tag";
/* eslint-plugin-disable lit */
import { PolymerElement } from "@polymer/polymer/polymer-element";
import { attributeClassNames } from "../../../common/entity/attribute_class_names";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-attributes";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-labeled-slider";
import "../../../components/ha-select";
import "../../../components/ha-switch";
import { FanEntityFeature } from "../../../data/fan";
import { EventsMixin } from "../../../mixins/events-mixin";
import LocalizeMixin from "../../../mixins/localize-mixin";
/*
* @appliesMixin EventsMixin
*/
class MoreInfoFan extends LocalizeMixin(EventsMixin(PolymerElement)) {
static get template() {
return html`
<style include="iron-flex"></style>
<style>
.container-preset_modes,
.container-direction,
.container-percentage,
.container-oscillating {
display: none;
}
.has-percentage .container-percentage,
.has-preset_modes .container-preset_modes,
.has-direction .container-direction,
.has-oscillating .container-oscillating {
display: block;
margin-top: 8px;
}
ha-select {
width: 100%;
}
</style>
<div class$="[[computeClassNames(stateObj)]]">
<div class="container-percentage">
<ha-labeled-slider
caption="[[localize('ui.card.fan.speed')]]"
min="0"
max="100"
step="[[computePercentageStepSize(stateObj)]]"
value="{{percentageSliderValue}}"
on-change="percentageChanged"
pin=""
extra=""
></ha-labeled-slider>
</div>
<div class="container-preset_modes">
<ha-select
label="[[localize('ui.card.fan.preset_mode')]]"
value="[[stateObj.attributes.preset_mode]]"
on-selected="presetModeChanged"
fixedMenuPosition
naturalMenuWidth
on-closed="stopPropagation"
>
<template
is="dom-repeat"
items="[[stateObj.attributes.preset_modes]]"
>
<mwc-list-item value="[[item]]">[[item]]</mwc-list-item>
</template>
</ha-select>
</div>
<div class="container-oscillating">
<div class="center horizontal layout single-row">
<div class="flex">[[localize('ui.card.fan.oscillate')]]</div>
<ha-switch
checked="[[oscillationToggleChecked]]"
on-change="oscillationToggleChanged"
>
</ha-switch>
</div>
</div>
<div class="container-direction">
<div class="direction">
<div>[[localize('ui.card.fan.direction')]]</div>
<ha-icon-button
on-click="onDirectionReverse"
title="[[localize('ui.card.fan.reverse')]]"
disabled="[[computeIsRotatingReverse(stateObj)]]"
>
<ha-icon icon="hass:rotate-left"></ha-icon>
</ha-icon-button>
<ha-icon-button
on-click="onDirectionForward"
title="[[localize('ui.card.fan.forward')]]"
disabled="[[computeIsRotatingForward(stateObj)]]"
>
<ha-icon icon="hass:rotate-right"></ha-icon>
</ha-icon-button>
</div>
</div>
</div>
<ha-attributes
hass="[[hass]]"
state-obj="[[stateObj]]"
extra-filters="percentage_step,speed,preset_mode,preset_modes,speed_list,percentage,oscillating,direction"
></ha-attributes>
`;
}
static get properties() {
return {
hass: {
type: Object,
},
stateObj: {
type: Object,
observer: "stateObjChanged",
},
oscillationToggleChecked: {
type: Boolean,
},
percentageSliderValue: {
type: Number,
},
};
}
stateObjChanged(newVal, oldVal) {
if (newVal) {
this.setProperties({
oscillationToggleChecked: newVal.attributes.oscillating,
percentageSliderValue: newVal.attributes.percentage,
});
}
if (oldVal) {
setTimeout(() => {
this.fire("iron-resize");
}, 500);
}
}
computePercentageStepSize(stateObj) {
if (stateObj.attributes.percentage_step) {
return stateObj.attributes.percentage_step;
}
return 1;
}
computeClassNames(stateObj) {
return (
"more-info-fan " +
(supportsFeature(stateObj, FanEntityFeature.SET_SPEED)
? "has-percentage "
: "") +
(stateObj.attributes.preset_modes &&
stateObj.attributes.preset_modes.length
? "has-preset_modes "
: "") +
attributeClassNames(stateObj, ["oscillating", "direction"])
);
}
presetModeChanged(ev) {
const oldVal = this.stateObj.attributes.preset_mode;
const newVal = ev.target.value;
if (!newVal || oldVal === newVal) return;
this.hass.callService("fan", "set_preset_mode", {
entity_id: this.stateObj.entity_id,
preset_mode: newVal,
});
}
stopPropagation(ev) {
ev.stopPropagation();
}
percentageChanged(ev) {
const oldVal = parseInt(this.stateObj.attributes.percentage, 10);
const newVal = ev.target.value;
if (isNaN(newVal) || oldVal === newVal) return;
this.hass.callService("fan", "set_percentage", {
entity_id: this.stateObj.entity_id,
percentage: newVal,
});
}
oscillationToggleChanged(ev) {
const oldVal = this.stateObj.attributes.oscillating;
const newVal = ev.target.checked;
if (oldVal === newVal) return;
this.hass.callService("fan", "oscillate", {
entity_id: this.stateObj.entity_id,
oscillating: newVal,
});
}
onDirectionReverse() {
this.hass.callService("fan", "set_direction", {
entity_id: this.stateObj.entity_id,
direction: "reverse",
});
}
onDirectionForward() {
this.hass.callService("fan", "set_direction", {
entity_id: this.stateObj.entity_id,
direction: "forward",
});
}
computeIsRotatingReverse(stateObj) {
return stateObj.attributes.direction === "reverse";
}
computeIsRotatingForward(stateObj) {
return stateObj.attributes.direction === "forward";
}
}
customElements.define("more-info-fan", MoreInfoFan);

View File

@ -0,0 +1,323 @@
import "@material/web/button/outlined-button";
import "@material/web/iconbutton/outlined-icon-button";
import {
mdiAutorenew,
mdiAutorenewOff,
mdiCreation,
mdiFan,
mdiFanOff,
mdiPower,
mdiRotateLeft,
mdiRotateRight,
} from "@mdi/js";
import {
css,
CSSResultGroup,
html,
LitElement,
nothing,
PropertyValues,
TemplateResult,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import {
computeAttributeNameDisplay,
computeAttributeValueDisplay,
} from "../../../common/entity/compute_attribute_display";
import { supportsFeature } from "../../../common/entity/supports-feature";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/ha-attributes";
import { UNAVAILABLE } from "../../../data/entity";
import { FanEntity, FanEntityFeature } from "../../../data/fan";
import { forwardHaptic } from "../../../data/haptics";
import type { HomeAssistant } from "../../../types";
import {
FAN_SPEED_COUNT_MAX_FOR_BUTTONS,
getFanSpeedCount,
} from "../components/fan/ha-more-info-fan-speed";
import { moreInfoControlStyle } from "../components/ha-more-info-control-style";
import "../components/ha-more-info-state-header";
import "../components/ha-more-info-toggle";
@customElement("more-info-fan")
class MoreInfoFan extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: FanEntity;
@state() public _presetMode?: string;
@state() private _selectedPercentage?: number;
private _percentageChanged(ev) {
const value = (ev.detail as any).value;
if (isNaN(value)) return;
this._selectedPercentage = value;
}
private _toggle = () => {
const service = this.stateObj?.state === "on" ? "turn_off" : "turn_on";
forwardHaptic("light");
this.hass.callService("fan", service, {
entity_id: this.stateObj!.entity_id,
});
};
_setReverseDirection() {
this.hass.callService("fan", "set_direction", {
entity_id: this.stateObj!.entity_id,
direction: "reverse",
});
}
_setForwardDirection() {
this.hass.callService("fan", "set_direction", {
entity_id: this.stateObj!.entity_id,
direction: "forward",
});
}
_toggleOscillate() {
const oscillating = this.stateObj!.attributes.oscillating;
this.hass.callService("fan", "oscillate", {
entity_id: this.stateObj!.entity_id,
oscillating: !oscillating,
});
}
_handlePresetMode(ev) {
ev.stopPropagation();
ev.preventDefault();
const index = ev.detail.index;
const newVal = this.stateObj!.attributes.preset_modes![index];
const oldVal = this._presetMode;
if (!newVal || oldVal === newVal) return;
this._presetMode = newVal;
this.hass.callService("fan", "set_preset_mode", {
entity_id: this.stateObj!.entity_id,
preset_mode: newVal,
});
}
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("stateObj")) {
this._presetMode = this.stateObj?.attributes.preset_mode;
this._selectedPercentage = this.stateObj?.attributes.percentage
? Math.round(this.stateObj.attributes.percentage)
: undefined;
}
}
protected render(): TemplateResult | null {
if (!this.hass || !this.stateObj) {
return null;
}
const supportsSpeed = supportsFeature(
this.stateObj,
FanEntityFeature.SET_SPEED
);
const supportsDirection = supportsFeature(
this.stateObj,
FanEntityFeature.DIRECTION
);
const supportsOscillate = supportsFeature(
this.stateObj,
FanEntityFeature.OSCILLATE
);
const supportsPresetMode = supportsFeature(
this.stateObj,
FanEntityFeature.PRESET_MODE
);
const supportSpeedPercentage =
supportsSpeed &&
getFanSpeedCount(this.stateObj) > FAN_SPEED_COUNT_MAX_FOR_BUTTONS;
const stateOverride = this._selectedPercentage
? `${Math.round(this._selectedPercentage)}${blankBeforePercent(
this.hass!.locale
)}%`
: undefined;
return html`
<ha-more-info-state-header
.hass=${this.hass}
.stateObj=${this.stateObj}
.stateOverride=${stateOverride}
></ha-more-info-state-header>
<div class="controls">
${
supportsSpeed
? html`
<ha-more-info-fan-speed
.stateObj=${this.stateObj}
.hass=${this.hass}
@slider-moved=${this._percentageChanged}
>
</ha-more-info-fan-speed>
`
: html`
<ha-more-info-toggle
.stateObj=${this.stateObj}
.hass=${this.hass}
.iconPathOn=${mdiFan}
.iconPathOff=${mdiFanOff}
></ha-more-info-toggle>
`
}
${
supportSpeedPercentage || supportsDirection || supportsOscillate
? html`<div class="buttons">
${supportSpeedPercentage
? html`
<md-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
@click=${this._toggle}
>
<ha-svg-icon .path=${mdiPower}></ha-svg-icon>
</md-outlined-icon-button>
`
: null}
${supportsDirection
? html`
<md-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE ||
this.stateObj.attributes.direction === "reverse"}
.title=${this.hass.localize(
"ui.dialogs.more_info_control.fan.set_reverse_direction"
)}
.ariaLabel=${this.hass.localize(
"ui.dialogs.more_info_control.fan.set_reverse_direction"
)}
@click=${this._setReverseDirection}
>
<ha-svg-icon .path=${mdiRotateLeft}></ha-svg-icon>
</md-outlined-icon-button>
<md-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE ||
this.stateObj.attributes.direction === "forward"}
.title=${this.hass.localize(
"ui.dialogs.more_info_control.fan.set_forward_direction"
)}
.ariaLabel=${this.hass.localize(
"ui.dialogs.more_info_control.fan.set_forward_direction"
)}
@click=${this._setForwardDirection}
>
<ha-svg-icon .path=${mdiRotateRight}></ha-svg-icon>
</md-outlined-icon-button>
`
: nothing}
${supportsOscillate
? html`
<md-outlined-icon-button
.disabled=${this.stateObj.state === UNAVAILABLE}
.title=${this.hass.localize(
`ui.dialogs.more_info_control.fan.${
this.stateObj.attributes.oscillating
? "turn_off_oscillating"
: "turn_on_oscillating"
}`
)}
.ariaLabel=${this.hass.localize(
`ui.dialogs.more_info_control.fan.${
this.stateObj.attributes.oscillating
? "turn_off_oscillating"
: "turn_on_oscillating"
}`
)}
@click=${this._toggleOscillate}
>
<ha-svg-icon
.path=${this.stateObj.attributes.oscillating
? mdiAutorenew
: mdiAutorenewOff}
></ha-svg-icon>
</md-outlined-icon-button>
`
: nothing}
</div> `
: nothing
}
${
supportsPresetMode && this.stateObj.attributes.preset_modes
? html`
<ha-button-menu
corner="BOTTOM_START"
@action=${this._handlePresetMode}
@closed=${stopPropagation}
fixed
.disabled=${this.stateObj.state === UNAVAILABLE}
>
<md-outlined-button
slot="trigger"
.disabled=${this.stateObj.state === UNAVAILABLE}
.label=${this._presetMode ||
computeAttributeNameDisplay(
this.hass.localize,
this.stateObj,
this.hass.entities,
"preset_mode"
)}
>
<ha-svg-icon
slot="icon"
path=${mdiCreation}
></ha-svg-icon>
</md-outlined-button>
${this.stateObj.attributes.preset_modes?.map(
(mode) =>
html`
<ha-list-item
.value=${mode}
.activated=${this._presetMode === mode}
>
${computeAttributeValueDisplay(
this.hass.localize,
this.stateObj!,
this.hass.locale,
this.hass.entities,
"preset_mode",
mode
)}
</ha-list-item>
`
)}
</ha-button-menu>
`
: nothing
}
</div>
<ha-attributes
.hass=${this.hass}
.stateObj=${this.stateObj}
extra-filters="percentage_step,speed,preset_mode,preset_modes,speed_list,percentage,oscillating,direction"
></ha-attributes>
</div>
`;
}
static get styles(): CSSResultGroup {
return [
moreInfoControlStyle,
css`
md-outlined-button {
--ha-icon-display: block;
--md-sys-color-primary: var(--primary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"more-info-fan": MoreInfoFan;
}
}

View File

@ -266,16 +266,6 @@ class MoreInfoLight extends LitElement {
return [
moreInfoControlStyle,
css`
.buttons {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 12px;
}
.buttons > * {
margin: 4px;
}
md-outlined-icon-button-toggle,
md-outlined-icon-button {
--ha-icon-display: block;

View File

@ -909,6 +909,17 @@
"color_temp": "Temperature"
}
}
},
"fan": {
"set_forward_direction": "Set forward direction",
"set_reverse_direction": "Set reverse direction",
"turn_on_oscillating": "Turn on oscillating",
"turn_off_oscillating": "Turn off oscillating",
"speed": {
"low": "Low",
"medium": "Medium",
"high": "High"
}
}
},
"entity_registry": {