1
0
mirror of https://github.com/home-assistant/frontend.git synced 2026-04-02 00:27:49 +01:00

Cover favorites (#29997)

* Make favorites UI reusable

* Setup cover favorites

* Fix

* Make generic

* Remove

* Move

* Add type for keys

* Remove

* Move

* Types

* Types

* Changes for tilt position

* Cleanup

* Add missing options

* Replace cover preset features with favorites features

Editor points to more info instead of allowing cusomisation of feature

* Fix drag

* Remove learn more

* Add domains with favorites shared data

* Support recent additions: reset and copy favorites

* Move favorites logic into new file, futureproof for new domains

* Await all

* Refactor

* Use copy for primary action

* Allow empty lists

* Rename

* Use better ally labels

* Move favorites options above and use divider

* Move

* Use proper DOM types

* Use proper type

* Use proper types

* Cleanup

* Type from param

* Center align label

* Only show labels if both show
This commit is contained in:
Aidan Timson
2026-03-17 12:14:47 +00:00
committed by GitHub
parent b9568c079e
commit 16c1db5346
26 changed files with 2134 additions and 911 deletions

View File

@@ -11,6 +11,7 @@ import "./ha-svg-icon";
export interface ControlSelectOption {
value: string;
label?: string;
ariaLabel?: string;
icon?: TemplateResult;
path?: string;
}
@@ -161,8 +162,8 @@ export class HaControlSelect extends LitElement {
tabindex=${isSelected ? "0" : "-1"}
.value=${option.value}
aria-checked=${isSelected ? "true" : "false"}
aria-label=${ifDefined(option.label)}
title=${ifDefined(option.label)}
aria-label=${ifDefined(option.ariaLabel ?? option.label)}
title=${ifDefined(option.ariaLabel ?? option.label)}
@click=${this._handleOptionClick}
@focus=${this._handleOptionFocus}
@mousedown=${this._handleOptionMouseDown}

View File

@@ -18,6 +18,47 @@ export const enum CoverEntityFeature {
SET_TILT_POSITION = 128,
}
export const DEFAULT_COVER_FAVORITE_POSITIONS = [0, 25, 75, 100];
export const coverSupportsPosition = (stateObj: CoverEntity) =>
supportsFeature(stateObj, CoverEntityFeature.SET_POSITION);
export const coverSupportsTiltPosition = (stateObj: CoverEntity) =>
supportsFeature(stateObj, CoverEntityFeature.SET_TILT_POSITION);
export const coverSupportsAnyPosition = (stateObj: CoverEntity) =>
coverSupportsPosition(stateObj) || coverSupportsTiltPosition(stateObj);
export const normalizeCoverFavoritePositions = (
positions?: number[]
): number[] => {
if (!positions) {
return [];
}
const unique = new Set<number>();
const normalized: number[] = [];
for (const position of positions) {
const value = Number(position);
if (isNaN(value)) {
continue;
}
const clamped = Math.max(0, Math.min(100, Math.round(value)));
if (unique.has(clamped)) {
continue;
}
unique.add(clamped);
normalized.push(clamped);
}
return normalized;
};
export function isFullyOpen(stateObj: CoverEntity) {
if (stateObj.attributes.current_position !== undefined) {
return stateObj.attributes.current_position === 100;

View File

@@ -92,6 +92,33 @@ export interface LightEntityOptions {
favorite_colors?: LightColor[];
}
export type FavoriteOption =
| "favorite_colors"
| "favorite_positions"
| "favorite_tilt_positions";
export type FavoritesDomain = "light" | "cover";
export type FavoriteOptionValue = LightColor[] | number[];
export const DOMAINS_WITH_FAVORITES: FavoritesDomain[] = ["light", "cover"];
export const isFavoritesDomain = (domain: string): domain is FavoritesDomain =>
DOMAINS_WITH_FAVORITES.includes(domain as FavoritesDomain);
export const shouldShowFavoriteOptions = (
values?: FavoriteOptionValue | null
): boolean => values == null || values.length > 0;
export const hasCustomFavoriteOptionValues = (
values?: FavoriteOptionValue | null
): boolean => values != null;
export interface CoverEntityOptions {
favorite_positions?: number[];
favorite_tilt_positions?: number[];
}
export interface NumberEntityOptions {
unit_of_measurement?: string | null;
}
@@ -134,6 +161,7 @@ export interface EntityRegistryOptions {
lock?: LockEntityOptions;
weather?: WeatherEntityOptions;
light?: LightEntityOptions;
cover?: CoverEntityOptions;
vacuum?: VacuumEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
conversation?: Record<string, unknown>;
@@ -158,6 +186,7 @@ export interface EntityRegistryEntryUpdateParams {
| CalendarEntityOptions
| WeatherEntityOptions
| LightEntityOptions
| CoverEntityOptions
| VacuumEntityOptions;
aliases?: (string | null)[];
labels?: string[];

View File

@@ -116,6 +116,7 @@ class DialogBox extends LitElement {
.label=${this._params.inputLabel
? this._params.inputLabel
: ""}
.suffix=${this._params.inputSuffix}
.type=${this._params.inputType
? this._params.inputType
: "text"}

View File

@@ -22,6 +22,7 @@ export interface ConfirmationDialogParams extends BaseDialogBoxParams {
export interface PromptDialogParams extends BaseDialogBoxParams {
inputLabel?: string;
inputSuffix?: string;
dismissText?: string;
inputType?: string;
defaultValue?: string;

View File

@@ -0,0 +1,475 @@
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, 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 type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-control-button";
import type { CoverEntity } from "../../../../data/cover";
import {
DEFAULT_COVER_FAVORITE_POSITIONS,
coverSupportsPosition,
coverSupportsTiltPosition,
normalizeCoverFavoritePositions,
} from "../../../../data/cover";
import { UNAVAILABLE } from "../../../../data/entity/entity";
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity/entity_attributes";
import type {
CoverEntityOptions,
ExtEntityRegistryEntry,
} from "../../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../types";
import {
showConfirmationDialog,
showPromptDialog,
} from "../../../generic/show-dialog-box";
import "../ha-more-info-favorites";
import type { HaMoreInfoFavorites } from "../ha-more-info-favorites";
type FavoriteKind = "position" | "tilt";
type FavoriteLocalizeKey =
| "set"
| "edit"
| "delete"
| "delete_confirm_title"
| "delete_confirm_text"
| "delete_confirm_action"
| "add"
| "edit_title"
| "add_title";
const favoriteKindFromEvent = (ev: Event): FavoriteKind =>
(ev.currentTarget as HTMLElement).dataset.kind as FavoriteKind;
@customElement("ha-more-info-cover-favorite-positions")
export class HaMoreInfoCoverFavoritePositions extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: CoverEntity;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public editMode?: boolean;
@state() private _favoritePositions: number[] = [];
@state() private _favoriteTiltPositions: number[] = [];
protected updated(changedProps: PropertyValues<this>): void {
if (
(changedProps.has("entry") || changedProps.has("stateObj")) &&
this.entry &&
this.stateObj
) {
const options = this.entry.options?.cover;
this._favoritePositions = coverSupportsPosition(this.stateObj)
? normalizeCoverFavoritePositions(
options?.favorite_positions ?? DEFAULT_COVER_FAVORITE_POSITIONS
)
: [];
this._favoriteTiltPositions = coverSupportsTiltPosition(this.stateObj)
? normalizeCoverFavoritePositions(
options?.favorite_tilt_positions ?? DEFAULT_COVER_FAVORITE_POSITIONS
)
: [];
}
}
private _localizeFavorite(
kind: FavoriteKind,
key: FavoriteLocalizeKey,
values?: Record<string, string | number>
): string {
return this.hass.localize(
`ui.dialogs.more_info_control.cover.${kind === "position" ? "favorite_position" : "favorite_tilt_position"}.${key}`,
values
);
}
private _getFavorites(kind: FavoriteKind): number[] {
return kind === "position"
? this._favoritePositions
: this._favoriteTiltPositions;
}
private _getCurrentValue(kind: FavoriteKind): number | undefined {
const current =
kind === "position"
? this.stateObj.attributes.current_position
: this.stateObj.attributes.current_tilt_position;
return current == null ? undefined : Math.round(current);
}
private async _save(options: CoverEntityOptions): Promise<void> {
if (!this.entry) {
return;
}
const currentOptions: CoverEntityOptions = {
...(this.entry.options?.cover ?? {}),
};
if (coverSupportsPosition(this.stateObj)) {
currentOptions.favorite_positions = this._favoritePositions;
}
if (coverSupportsTiltPosition(this.stateObj)) {
currentOptions.favorite_tilt_positions = this._favoriteTiltPositions;
}
const result = await updateEntityRegistryEntry(
this.hass,
this.entry.entity_id,
{
options_domain: "cover",
options: {
...currentOptions,
...options,
},
}
);
fireEvent(this, "entity-entry-updated", result.entity_entry);
}
private async _setFavorites(
kind: FavoriteKind,
favorites: number[]
): Promise<void> {
const normalized = normalizeCoverFavoritePositions(favorites);
if (kind === "position") {
this._favoritePositions = normalized;
await this._save({ favorite_positions: normalized });
return;
}
this._favoriteTiltPositions = normalized;
await this._save({ favorite_tilt_positions: normalized });
}
private _move(kind: FavoriteKind, index: number, newIndex: number): void {
const favorites = this._getFavorites(kind).concat();
const moved = favorites.splice(index, 1)[0];
favorites.splice(newIndex, 0, moved);
this._setFavorites(kind, favorites);
}
private _applyFavorite(kind: FavoriteKind, index: number): void {
const favorite = this._getFavorites(kind)[index];
if (favorite === undefined) {
return;
}
if (kind === "position") {
this.hass.callService("cover", "set_cover_position", {
entity_id: this.stateObj.entity_id,
position: favorite,
});
return;
}
this.hass.callService("cover", "set_cover_tilt_position", {
entity_id: this.stateObj.entity_id,
tilt_position: favorite,
});
}
private async _promptFavoriteValue(
kind: FavoriteKind,
value?: number
): Promise<number | undefined> {
const response = await showPromptDialog(this, {
title: this._localizeFavorite(
kind,
value === undefined ? "add_title" : "edit_title"
),
inputLabel: this.hass.localize(
kind === "position"
? "ui.card.cover.position"
: "ui.card.cover.tilt_position"
),
inputType: "number",
inputMin: "0",
inputMax: "100",
inputSuffix: DOMAIN_ATTRIBUTES_UNITS.cover.current_position,
defaultValue: value === undefined ? undefined : String(value),
});
if (response === null || response.trim() === "") {
return undefined;
}
const number = Number(response);
if (isNaN(number)) {
return undefined;
}
return Math.max(0, Math.min(100, Math.round(number)));
}
private async _addFavorite(kind: FavoriteKind): Promise<void> {
const value = await this._promptFavoriteValue(kind);
if (value === undefined) {
return;
}
await this._setFavorites(kind, [...this._getFavorites(kind), value]);
}
private async _editFavorite(
kind: FavoriteKind,
index: number
): Promise<void> {
const favorites = this._getFavorites(kind);
const current = favorites[index];
if (current === undefined) {
return;
}
const value = await this._promptFavoriteValue(kind, current);
if (value === undefined) {
return;
}
const updated = [...favorites];
updated[index] = value;
await this._setFavorites(kind, updated);
}
private async _deleteFavorite(
kind: FavoriteKind,
index: number
): Promise<void> {
const confirmed = await showConfirmationDialog(this, {
destructive: true,
title: this._localizeFavorite(kind, "delete_confirm_title"),
text: this._localizeFavorite(kind, "delete_confirm_text"),
confirmText: this._localizeFavorite(kind, "delete_confirm_action"),
});
if (!confirmed) {
return;
}
await this._setFavorites(
kind,
this._getFavorites(kind).filter((_, itemIndex) => itemIndex !== index)
);
}
private _renderFavoriteButton =
(kind: FavoriteKind): HaMoreInfoFavorites["renderItem"] =>
(favorite, _index, editMode) => {
const currentValue = this._getCurrentValue(kind);
const active = currentValue === favorite;
const label = this._localizeFavorite(kind, editMode ? "edit" : "set", {
value: `${favorite as number}%`,
});
return html`
<ha-control-button
class=${classMap({
active,
})}
style=${styleMap({
"--control-button-border-radius": "var(--ha-border-radius-pill)",
width: "72px",
height: "36px",
})}
.label=${label}
.disabled=${this.stateObj.state === UNAVAILABLE}
>
${favorite as number}%
</ha-control-button>
`;
};
private _deleteLabel =
(kind: FavoriteKind): HaMoreInfoFavorites["deleteLabel"] =>
(index) =>
this._localizeFavorite(kind, "delete", {
number: index + 1,
});
private _handleFavoriteAction = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-action"]>
): void => {
ev.stopPropagation();
const kind = favoriteKindFromEvent(ev);
const { action, index } = ev.detail;
if (action === "hold" && this.hass.user?.is_admin) {
fireEvent(this, "toggle-edit-mode", true);
return;
}
if (this.editMode) {
this._editFavorite(kind, index);
return;
}
this._applyFavorite(kind, index);
};
private _handleFavoriteMoved = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-moved"]>
): void => {
ev.stopPropagation();
const kind = favoriteKindFromEvent(ev);
this._move(kind, ev.detail.oldIndex, ev.detail.newIndex);
};
private _handleFavoriteDelete = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-delete"]>
): void => {
ev.stopPropagation();
const kind = favoriteKindFromEvent(ev);
this._deleteFavorite(kind, ev.detail.index);
};
private _handleFavoriteAdd = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-add"]>
): void => {
ev.stopPropagation();
const kind = favoriteKindFromEvent(ev);
this._addFavorite(kind);
};
private _handleFavoriteDone = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-done"]>
): void => {
ev.stopPropagation();
fireEvent(this, "toggle-edit-mode", false);
};
private _renderKindSection(
kind: FavoriteKind,
label: string,
favorites: number[],
showDone: boolean,
showLabel: boolean
): TemplateResult | typeof nothing {
if (!this.editMode && favorites.length === 0) {
return nothing;
}
return html`
<section class="group">
${showLabel ? html`<h4>${label}</h4>` : nothing}
<ha-more-info-favorites
data-kind=${kind}
.items=${favorites}
.renderItem=${this._renderFavoriteButton(kind)}
.deleteLabel=${this._deleteLabel(kind)}
.editMode=${this.editMode ?? false}
.disabled=${this.stateObj.state === UNAVAILABLE}
.isAdmin=${Boolean(this.hass.user?.is_admin)}
.showDone=${showDone}
.addLabel=${this._localizeFavorite(kind, "add")}
.doneLabel=${this.hass.localize(
"ui.dialogs.more_info_control.exit_edit_mode"
)}
@favorite-item-action=${this._handleFavoriteAction}
@favorite-item-moved=${this._handleFavoriteMoved}
@favorite-item-delete=${this._handleFavoriteDelete}
@favorite-item-add=${this._handleFavoriteAdd}
@favorite-item-done=${this._handleFavoriteDone}
></ha-more-info-favorites>
</section>
`;
}
protected render(): TemplateResult | typeof nothing {
if (!this.stateObj || !this.entry) {
return nothing;
}
const supportsPosition = coverSupportsPosition(this.stateObj);
const supportsTiltPosition = coverSupportsTiltPosition(this.stateObj);
const showPositionSection = supportsPosition
? this.editMode || this._favoritePositions.length > 0
: false;
const showTiltSection = supportsTiltPosition
? this.editMode || this._favoriteTiltPositions.length > 0
: false;
const showLabels =
[showPositionSection, showTiltSection].filter(Boolean).length > 1;
const showDoneOnPosition = supportsPosition && !supportsTiltPosition;
return html`
<div class="groups">
${supportsPosition
? this._renderKindSection(
"position",
this.hass.localize("ui.card.cover.position"),
this._favoritePositions,
showDoneOnPosition,
showLabels
)
: nothing}
${supportsTiltPosition
? this._renderKindSection(
"tilt",
this.hass.localize("ui.card.cover.tilt_position"),
this._favoriteTiltPositions,
true,
showLabels
)
: nothing}
</div>
`;
}
static styles = css`
:host {
display: block;
width: 100%;
}
.groups {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--ha-space-3);
}
.group {
width: 100%;
max-width: 384px;
margin: 0;
}
.group ha-more-info-favorites {
--favorite-items-max-width: 384px;
--favorite-item-active-background-color: var(--state-cover-active-color);
}
h4 {
margin: 0 0 var(--ha-space-2);
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-medium);
text-align: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-cover-favorite-positions": HaMoreInfoCoverFavoritePositions;
}
}

View File

@@ -0,0 +1,267 @@
import { mdiCheck, mdiMinus, mdiPlus } from "@mdi/js";
import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-outlined-icon-button";
import "../../../components/ha-sortable";
import "../../../components/ha-svg-icon";
import { actionHandler } from "../../../panels/lovelace/common/directives/action-handler-directive";
const SORTABLE_OPTIONS = {
delay: 250,
delayOnTouchOnly: true,
};
@customElement("ha-more-info-favorites")
export class HaMoreInfoFavorites extends LitElement {
@property({ attribute: false }) public items: unknown[] = [];
@property({ attribute: false })
public renderItem?: (
item: unknown,
index: number,
editMode: boolean
) => TemplateResult;
@property({ attribute: false })
public deleteLabel?: (index: number) => string;
@property({ type: Boolean, attribute: false }) public editMode = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: false }) public isAdmin = false;
@property({ type: Boolean, attribute: false }) public showAdd = true;
@property({ type: Boolean, attribute: false }) public showDone = true;
@property({ attribute: false }) public addLabel = "";
@property({ attribute: false }) public doneLabel = "";
private _itemMoved(ev: HASSDomEvent<HASSDomEvents["item-moved"]>): void {
ev.stopPropagation();
fireEvent(this, "favorite-item-moved", ev.detail);
}
private _handleItemAction = (
ev: HASSDomEvent<HASSDomEvents["action"]>
): void => {
ev.stopPropagation();
const target = ev.currentTarget as HTMLElement;
fireEvent(this, "favorite-item-action", {
index: Number(target.dataset.index),
action: ev.detail.action,
});
};
private _handleDelete = (ev: MouseEvent): void => {
ev.stopPropagation();
const target = ev.currentTarget as HTMLElement;
fireEvent(this, "favorite-item-delete", {
index: Number(target.dataset.index),
});
};
private _handleAdd = (ev: MouseEvent): void => {
ev.stopPropagation();
fireEvent(this, "favorite-item-add");
};
private _handleDone = (ev: MouseEvent): void => {
ev.stopPropagation();
fireEvent(this, "favorite-item-done");
};
protected render(): TemplateResult {
return html`
<ha-sortable
@item-moved=${this._itemMoved}
draggable-selector=".favorite"
no-style
.disabled=${!this.editMode}
.options=${SORTABLE_OPTIONS}
>
<div class="container">
${this.items.map(
(item, index) => html`
<div class="favorite">
<div
class="favorite-bubble ${classMap({
shake: !!this.editMode,
})}"
>
<div
class="item"
data-index=${String(index)}
.actionHandler=${actionHandler({
hasHold: !this.editMode && this.isAdmin,
disabled: this.disabled,
})}
@action=${this._handleItemAction}
>
${this.renderItem
? this.renderItem(item, index, this.editMode)
: nothing}
</div>
${this.editMode
? html`
<button
@click=${this._handleDelete}
class="delete"
data-index=${String(index)}
aria-label=${ifDefined(this.deleteLabel?.(index))}
title=${ifDefined(this.deleteLabel?.(index))}
>
<ha-svg-icon .path=${mdiMinus}></ha-svg-icon>
</button>
`
: nothing}
</div>
</div>
`
)}
${this.editMode && this.showAdd
? html`
<ha-outlined-icon-button
class="button"
@click=${this._handleAdd}
.label=${this.addLabel}
>
<ha-svg-icon .path=${mdiPlus}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing}
${this.editMode && this.showDone
? html`
<ha-outlined-icon-button
@click=${this._handleDone}
class="button"
.label=${this.doneLabel}
>
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing}
</div>
</ha-sortable>
`;
}
static styles = css`
.container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: calc(var(--ha-space-2) * -1);
flex-wrap: wrap;
max-width: var(--favorite-items-max-width, 250px);
user-select: none;
}
.container > * {
margin: var(--ha-space-2);
}
.favorite {
display: block;
}
.favorite .favorite-bubble.shake {
position: relative;
display: block;
animation: shake 0.45s linear infinite;
}
.favorite:nth-child(3n + 1) .favorite-bubble.shake {
animation-delay: 0.15s;
}
.favorite:nth-child(3n + 2) .favorite-bubble.shake {
animation-delay: 0.3s;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-fallback {
display: none;
}
@keyframes shake {
0% {
transform: rotateZ(0deg) translateX(-1px) translateY(0) scale(1);
}
20% {
transform: rotateZ(-3deg) translateX(0) translateY(0);
}
40% {
transform: rotateZ(0deg) translateX(1px) translateY(0);
}
60% {
transform: rotateZ(3deg) translateX(0) translateY(0);
}
100% {
transform: rotateZ(0deg) translateX(-1px) translateY(0);
}
}
.delete {
position: absolute;
top: -6px;
right: -6px;
inset-inline-end: -6px;
inset-inline-start: initial;
width: 20px;
height: 20px;
outline: none;
background-color: var(--secondary-background-color);
padding: 0;
border-radius: var(--ha-border-radius-md);
border: none;
cursor: pointer;
display: block;
--mdc-icon-size: 12px;
color: var(--primary-text-color);
}
.delete * {
pointer-events: none;
}
ha-control-button.active {
--control-button-background-color: var(
--favorite-item-active-background-color
);
}
`;
}
declare global {
interface HASSDomEvents {
"favorite-item-action": {
index: number;
action: string;
};
"favorite-item-delete": {
index: number;
};
"favorite-item-add";
"favorite-item-done";
"favorite-item-moved": {
oldIndex: number;
newIndex: number;
};
}
interface HTMLElementTagNameMap {
"ha-more-info-favorites": HaMoreInfoFavorites;
}
}

View File

@@ -1,19 +1,17 @@
import { mdiCheck, mdiMinus, mdiPlus } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-control-slider";
import "../../../../components/ha-sortable";
import { UNAVAILABLE } from "../../../../data/entity/entity";
import type { ExtEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import { updateEntityRegistryEntry } from "../../../../data/entity/entity_registry";
import type { LightColor, LightEntity } from "../../../../data/light";
import { computeDefaultFavoriteColors } from "../../../../data/light";
import { actionHandler } from "../../../../panels/lovelace/common/directives/action-handler-directive";
import type { HomeAssistant } from "../../../../types";
import { showConfirmationDialog } from "../../../generic/show-dialog-box";
import "../ha-more-info-favorites";
import type { HaMoreInfoFavorites } from "../ha-more-info-favorites";
import "./ha-favorite-color-button";
import { showLightColorFavoriteDialog } from "./show-dialog-light-color-favorite";
@@ -36,40 +34,32 @@ export class HaMoreInfoLightFavoriteColors extends LitElement {
@state() private _favoriteColors: LightColor[] = [];
protected updated(changedProps: PropertyValues): void {
if (changedProps.has("entry")) {
if (this.entry) {
if (this.entry.options?.light?.favorite_colors) {
this._favoriteColors = this.entry.options.light.favorite_colors;
} else if (this.stateObj) {
this._favoriteColors = computeDefaultFavoriteColors(this.stateObj);
}
if (changedProps.has("entry") && this.entry) {
if (this.entry.options?.light?.favorite_colors) {
this._favoriteColors = this.entry.options.light.favorite_colors;
} else if (this.stateObj) {
this._favoriteColors = computeDefaultFavoriteColors(this.stateObj);
}
}
}
private _colorMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
this._move(oldIndex, newIndex);
}
private _move(index: number, newIndex: number) {
private _move(index: number, newIndex: number): void {
const favoriteColors = this._favoriteColors.concat();
const action = favoriteColors.splice(index, 1)[0];
favoriteColors.splice(newIndex, 0, action);
const color = favoriteColors.splice(index, 1)[0];
favoriteColors.splice(newIndex, 0, color);
this._favoriteColors = favoriteColors;
this._save(favoriteColors);
}
private _apply = (index: number) => {
private _apply(index: number): void {
const favorite = this._favoriteColors[index];
this.hass.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
entity_id: this.stateObj.entity_id,
...favorite,
});
};
}
private async _save(newFavoriteColors: LightColor[]) {
private async _save(newFavoriteColors: LightColor[]): Promise<void> {
const result = await updateEntityRegistryEntry(
this.hass,
this.entry!.entity_id,
@@ -83,23 +73,23 @@ export class HaMoreInfoLightFavoriteColors extends LitElement {
fireEvent(this, "entity-entry-updated", result.entity_entry);
}
private _add = async () => {
private _add = async (): Promise<void> => {
const color = await showLightColorFavoriteDialog(this, {
entry: this.entry!,
title: this.hass.localize(
"ui.dialogs.more_info_control.light.favorite_color.add_title"
),
});
if (color) {
const newFavoriteColors = [...this._favoriteColors, color];
this._save(newFavoriteColors);
if (!color) {
return;
}
const newFavoriteColors = [...this._favoriteColors, color];
this._save(newFavoriteColors);
};
private _edit = async (index) => {
// Make sure the current favorite color is set
private _edit = async (index: number): Promise<void> => {
fireEvent(this, "favorite-color-edit-started");
await this._apply(index);
this._apply(index);
const color = await showLightColorFavoriteDialog(this, {
entry: this.entry!,
initialColor: this._favoriteColors[index],
@@ -108,26 +98,27 @@ export class HaMoreInfoLightFavoriteColors extends LitElement {
),
});
if (color) {
const newFavoriteColors = [...this._favoriteColors];
newFavoriteColors[index] = color;
this._save(newFavoriteColors);
} else {
if (!color) {
this._apply(index);
return;
}
const newFavoriteColors = [...this._favoriteColors];
newFavoriteColors[index] = color;
this._save(newFavoriteColors);
};
private _delete = async (index) => {
private _delete = async (index: number): Promise<void> => {
const confirm = await showConfirmationDialog(this, {
destructive: true,
title: this.hass.localize(
`ui.dialogs.more_info_control.light.favorite_color.delete_confirm_title`
"ui.dialogs.more_info_control.light.favorite_color.delete_confirm_title"
),
text: this.hass.localize(
`ui.dialogs.more_info_control.light.favorite_color.delete_confirm_text`
"ui.dialogs.more_info_control.light.favorite_color.delete_confirm_text"
),
confirmText: this.hass.localize(
`ui.dialogs.more_info_control.light.favorite_color.delete_confirm_action`
"ui.dialogs.more_info_control.light.favorite_color.delete_confirm_action"
),
});
if (!confirm) {
@@ -139,196 +130,101 @@ export class HaMoreInfoLightFavoriteColors extends LitElement {
this._save(newFavoriteColors);
};
private _handleDeleteButton = (ev) => {
ev.stopPropagation();
const index = ev.target.index;
this._delete(index);
};
private _renderFavorite = (
color: LightColor,
index: number,
editMode: boolean
): TemplateResult =>
html`<ha-favorite-color-button
.label=${this.hass.localize(
`ui.dialogs.more_info_control.light.favorite_color.${
editMode ? "edit" : "set"
}`,
{ number: index + 1 }
)}
.disabled=${this.stateObj.state === UNAVAILABLE}
.color=${color}
></ha-favorite-color-button>`;
private _handleAddButton = (ev) => {
ev.stopPropagation();
this._add();
};
private _deleteLabel = (index: number): string =>
this.hass.localize(
"ui.dialogs.more_info_control.light.favorite_color.delete",
{
number: index + 1,
}
);
private _handleColorAction = (ev) => {
private _handleFavoriteAction = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-action"]>
): void => {
ev.stopPropagation();
if (ev.detail.action === "hold" && this.hass.user?.is_admin) {
const { action, index } = ev.detail;
if (action === "hold" && this.hass.user?.is_admin) {
fireEvent(this, "toggle-edit-mode", true);
return;
}
const index = ev.target.index;
if (this.editMode) {
this._edit(index);
return;
}
this._apply(index);
};
private _exitEditMode = (ev) => {
private _handleFavoriteMoved = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-moved"]>
): void => {
ev.stopPropagation();
this._move(ev.detail.oldIndex, ev.detail.newIndex);
};
private _handleFavoriteDelete = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-delete"]>
): void => {
ev.stopPropagation();
this._delete(ev.detail.index);
};
private _handleFavoriteAdd = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-add"]>
): void => {
ev.stopPropagation();
this._add();
};
private _handleFavoriteDone = (
ev: HASSDomEvent<HASSDomEvents["favorite-item-done"]>
): void => {
ev.stopPropagation();
fireEvent(this, "toggle-edit-mode", false);
};
protected render(): TemplateResult {
return html`
<ha-sortable
@item-moved=${this._colorMoved}
draggable-selector=".color"
no-style
.disabled=${!this.editMode}
>
<div class="container">
${this._favoriteColors.map(
(color, index) => html`
<div class="color">
<div
class="color-bubble ${classMap({
shake: !!this.editMode,
})}"
>
<ha-favorite-color-button
.label=${this.hass.localize(
`ui.dialogs.more_info_control.light.favorite_color.${
this.editMode ? "edit" : "set"
}`,
{ number: index }
)}
.disabled=${this.stateObj!.state === UNAVAILABLE}
.color=${color}
.index=${index}
.actionHandler=${actionHandler({
hasHold: !this.editMode && this.hass.user?.is_admin,
disabled: this.stateObj!.state === UNAVAILABLE,
})}
@action=${this._handleColorAction}
>
</ha-favorite-color-button>
${this.editMode
? html`
<button
@click=${this._handleDeleteButton}
class="delete"
.index=${index}
aria-label=${this.hass.localize(
`ui.dialogs.more_info_control.light.favorite_color.delete`,
{ number: index }
)}
.title=${this.hass.localize(
`ui.dialogs.more_info_control.light.favorite_color.delete`,
{ number: index }
)}
>
<ha-svg-icon .path=${mdiMinus}></ha-svg-icon>
</button>
`
: nothing}
</div>
</div>
`
)}
${this.editMode
? html`
<ha-outlined-icon-button
class="button"
@click=${this._handleAddButton}
>
<ha-svg-icon .path=${mdiPlus}></ha-svg-icon>
</ha-outlined-icon-button>
<ha-outlined-icon-button
@click=${this._exitEditMode}
class="button"
>
<ha-svg-icon .path=${mdiCheck}></ha-svg-icon>
</ha-outlined-icon-button>
`
: nothing}
</div>
</ha-sortable>
<ha-more-info-favorites
.items=${this._favoriteColors}
.renderItem=${this._renderFavorite as HaMoreInfoFavorites["renderItem"]}
.deleteLabel=${this._deleteLabel as HaMoreInfoFavorites["deleteLabel"]}
.editMode=${this.editMode}
.disabled=${this.stateObj.state === UNAVAILABLE}
.isAdmin=${Boolean(this.hass.user?.is_admin)}
.addLabel=${this.hass.localize(
"ui.dialogs.more_info_control.light.favorite_color.add"
)}
.doneLabel=${this.hass.localize(
"ui.dialogs.more_info_control.exit_edit_mode"
)}
@favorite-item-action=${this._handleFavoriteAction}
@favorite-item-moved=${this._handleFavoriteMoved}
@favorite-item-delete=${this._handleFavoriteDelete}
@favorite-item-add=${this._handleFavoriteAdd}
@favorite-item-done=${this._handleFavoriteDone}
></ha-more-info-favorites>
`;
}
static styles = css`
.container {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: calc(var(--ha-space-2) * -1);
flex-wrap: wrap;
max-width: 250px;
user-select: none;
}
.container > * {
margin: var(--ha-space-2);
}
.color {
display: block;
}
.color .color-bubble.shake {
position: relative;
display: block;
animation: shake 0.45s linear infinite;
}
.color:nth-child(3n + 1) .color-bubble.shake {
animation-delay: 0.15s;
}
.color:nth-child(3n + 2) .color-bubble.shake {
animation-delay: 0.3s;
}
.sortable-ghost {
opacity: 0.4;
}
.sortable-fallback {
display: none;
}
@keyframes shake {
0% {
transform: rotateZ(0deg) translateX(-1px) translateY(0) scale(1);
}
20% {
transform: rotateZ(-3deg) translateX(0) translateY();
}
40% {
transform: rotateZ(0deg) translateX(1px) translateY(0);
}
60% {
transform: rotateZ(3deg) translateX(0) translateY(0);
}
100% {
transform: rotateZ(0deg) translateX(-1px) translateY(0);
}
}
.delete {
position: absolute;
top: -6px;
right: -6px;
inset-inline-end: -6px;
inset-inline-start: initial;
width: 20px;
height: 20px;
outline: none;
background-color: var(--secondary-background-color);
padding: 0;
border-radius: var(--ha-border-radius-md);
border: none;
cursor: pointer;
display: block;
}
.delete {
--mdc-icon-size: 12px;
color: var(--primary-text-color);
}
.delete * {
pointer-events: none;
}
`;
}
declare global {

View File

@@ -5,9 +5,16 @@ import { customElement, property, state } from "lit/decorators";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-icon-button-group";
import "../../../components/ha-icon-button-toggle";
import {
shouldShowFavoriteOptions,
type ExtEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import type { CoverEntity } from "../../../data/cover";
import {
CoverEntityFeature,
coverSupportsAnyPosition,
coverSupportsPosition,
coverSupportsTiltPosition,
computeCoverPositionStateDisplay,
} from "../../../data/cover";
import "../../../state-control/cover/ha-state-control-cover-buttons";
@@ -15,6 +22,7 @@ import "../../../state-control/cover/ha-state-control-cover-position";
import "../../../state-control/cover/ha-state-control-cover-tilt-position";
import "../../../state-control/cover/ha-state-control-cover-toggle";
import type { HomeAssistant } from "../../../types";
import "../components/covers/ha-more-info-cover-favorite-positions";
import "../components/ha-more-info-state-header";
import { moreInfoControlStyle } from "../components/more-info-control-style";
@@ -26,6 +34,10 @@ class MoreInfoCover extends LitElement {
@property({ attribute: false }) public stateObj?: CoverEntity;
@property({ attribute: false }) public entry?: ExtEntityRegistryEntry | null;
@property({ attribute: false }) public editMode?: boolean;
@state() private _mode?: Mode;
private _setMode(ev) {
@@ -38,11 +50,9 @@ class MoreInfoCover extends LitElement {
const entityId = this.stateObj.entity_id;
const oldEntityId = changedProps.get("stateObj")?.entity_id;
if (!this._mode || entityId !== oldEntityId) {
this._mode =
supportsFeature(this.stateObj, CoverEntityFeature.SET_POSITION) ||
supportsFeature(this.stateObj, CoverEntityFeature.SET_TILT_POSITION)
? "position"
: "button";
this._mode = coverSupportsAnyPosition(this.stateObj)
? "position"
: "button";
}
}
}
@@ -66,14 +76,21 @@ class MoreInfoCover extends LitElement {
return nothing;
}
const supportsPosition = supportsFeature(
this.stateObj,
CoverEntityFeature.SET_POSITION
);
const supportsPosition = coverSupportsPosition(this.stateObj);
const supportsTiltPosition = supportsFeature(
this.stateObj,
CoverEntityFeature.SET_TILT_POSITION
const supportsTiltPosition = coverSupportsTiltPosition(this.stateObj);
const showFavoriteControls = Boolean(
this.entry &&
(this.editMode ||
(coverSupportsPosition(this.stateObj) &&
shouldShowFavoriteOptions(
this.entry.options?.cover?.favorite_positions
)) ||
(coverSupportsTiltPosition(this.stateObj) &&
shouldShowFavoriteOptions(
this.entry.options?.cover?.favorite_tilt_positions
)))
);
const supportsOpenClose =
@@ -174,6 +191,18 @@ class MoreInfoCover extends LitElement {
: nothing
}
</div>
${
showFavoriteControls
? html`
<ha-more-info-cover-favorite-positions
.hass=${this.hass}
.stateObj=${this.stateObj}
.entry=${this.entry}
.editMode=${this.editMode}
></ha-more-info-cover-favorite-positions>
`
: nothing
}
</div>
`;
}

View File

@@ -16,7 +16,10 @@ import "../../../components/ha-icon-button-group";
import "../../../components/ha-icon-button-toggle";
import "../../../components/ha-list-item";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
shouldShowFavoriteOptions,
type ExtEntityRegistryEntry,
} from "../../../data/entity/entity_registry";
import { forwardHaptic } from "../../../data/haptics";
import type { LightEntity } from "../../../data/light";
import {
@@ -110,10 +113,14 @@ class MoreInfoLight extends LitElement {
LightEntityFeature.EFFECT
);
const hasFavoriteColors =
const showFavoriteColors = Boolean(
this.entry &&
(this.entry.options?.light?.favorite_colors == null ||
this.entry.options.light.favorite_colors.length > 0);
(this.editMode ||
(lightSupportsFavoriteColors(this.stateObj) &&
shouldShowFavoriteOptions(
this.entry.options?.light?.favorite_colors
)))
);
return html`
<ha-more-info-state-header
@@ -239,9 +246,7 @@ class MoreInfoLight extends LitElement {
`
: nothing}
</ha-icon-button-group>
${this.entry &&
lightSupportsFavoriteColors(this.stateObj) &&
(this.editMode || hasFavoriteColors)
${showFavoriteColors
? html`
<ha-more-info-light-favorite-colors
.hass=${this.hass}

View File

@@ -0,0 +1,289 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { html, type LitElement } from "lit";
import {
hasRejectedItems,
rejectedItems,
} from "../../common/util/promise-all-settled-results";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import type { CoverEntity } from "../../data/cover";
import {
DEFAULT_COVER_FAVORITE_POSITIONS,
coverSupportsAnyPosition,
coverSupportsPosition,
coverSupportsTiltPosition,
normalizeCoverFavoritePositions,
} from "../../data/cover";
import type {
ExtEntityRegistryEntry,
FavoriteOption,
FavoritesDomain,
} from "../../data/entity/entity_registry";
import {
hasCustomFavoriteOptionValues,
isFavoritesDomain,
updateEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { LightColor, LightEntity } from "../../data/light";
import {
LightColorMode,
computeDefaultFavoriteColors,
lightSupportsColor,
lightSupportsColorMode,
lightSupportsFavoriteColors,
} from "../../data/light";
import type { HomeAssistant } from "../../types";
import { showAlertDialog } from "../generic/show-dialog-box";
import { showFormDialog } from "../form/show-form-dialog";
export interface FavoritesDialogContext {
host: LitElement;
hass: HomeAssistant;
entry: ExtEntityRegistryEntry;
stateObj: HassEntity;
}
interface FavoritesDialogLabels {
editMode: string;
reset: string;
resetText: string;
copy: string;
}
export interface FavoritesDialogHandler {
domain: FavoritesDomain;
supports: (stateObj: HassEntity) => boolean;
hasCustomFavorites: (entry: ExtEntityRegistryEntry) => boolean;
getResetOptions: (
stateObj: HassEntity
) => Partial<Record<FavoriteOption, undefined>>;
getLabels: (hass: HomeAssistant) => FavoritesDialogLabels;
copy: (ctx: FavoritesDialogContext) => Promise<void>;
}
const getFavoritesDialogLabels = (
hass: HomeAssistant,
domain: FavoritesDomain
): FavoritesDialogLabels => ({
editMode: hass.localize(`ui.dialogs.more_info_control.${domain}.edit_mode`),
reset: hass.localize(
`ui.dialogs.more_info_control.${domain}.reset_favorites`
),
resetText: hass.localize(
`ui.dialogs.more_info_control.${domain}.reset_favorites_text`
),
copy: hass.localize(`ui.dialogs.more_info_control.${domain}.copy_favorites`),
});
const copyFavoriteOptionsToEntities = async (
host: LitElement,
hass: HomeAssistant,
domain: FavoritesDomain,
includeEntities: string[],
options: object
) => {
const registryBackedEntities = includeEntities.filter(
(entityId) => entityId in hass.entities
);
const selected = await showFormDialog(host, {
title: hass.localize(
`ui.dialogs.more_info_control.${domain}.copy_favorites`
),
submitText: hass.localize("ui.common.copy"),
schema: [
{
name: "entity",
selector: {
entity: {
include_entities: registryBackedEntities,
multiple: true,
},
},
required: true,
},
],
computeLabel: () =>
hass.localize(
`ui.dialogs.more_info_control.${domain}.copy_favorites_entities`
),
computeHelper: () =>
hass.localize(
`ui.dialogs.more_info_control.${domain}.copy_favorites_helper`
),
data: {},
});
if (selected?.entity) {
const result = await Promise.allSettled(
selected.entity.map((entityId: string) =>
updateEntityRegistryEntry(hass, entityId, {
options_domain: domain,
options,
})
)
);
if (hasRejectedItems(result)) {
const rejected = rejectedItems(result);
showAlertDialog(host, {
title: hass.localize("ui.panel.config.common.multiselect.failed", {
number: rejected.length,
}),
text: html`<pre>
${rejected
.map(
(item) => item.reason.message || item.reason.code || item.reason
)
.join("\r\n")}</pre
>`,
});
}
}
};
const coverFavoritesHandler: FavoritesDialogHandler = {
domain: "cover",
supports: (stateObj) => coverSupportsAnyPosition(stateObj as CoverEntity),
hasCustomFavorites: (entry) =>
hasCustomFavoriteOptionValues(entry.options?.cover?.favorite_positions) ||
hasCustomFavoriteOptionValues(
entry.options?.cover?.favorite_tilt_positions
),
getResetOptions: (stateObj) => ({
...(coverSupportsPosition(stateObj as CoverEntity)
? { favorite_positions: undefined }
: {}),
...(coverSupportsTiltPosition(stateObj as CoverEntity)
? { favorite_tilt_positions: undefined }
: {}),
}),
getLabels: (hass) => getFavoritesDialogLabels(hass, "cover"),
copy: async ({ entry, hass, host, stateObj }) => {
const coverStateObj = stateObj as CoverEntity;
const favoritePositions = coverSupportsPosition(coverStateObj)
? normalizeCoverFavoritePositions(
entry.options?.cover?.favorite_positions ??
DEFAULT_COVER_FAVORITE_POSITIONS
)
: undefined;
const favoriteTiltPositions = coverSupportsTiltPosition(coverStateObj)
? normalizeCoverFavoritePositions(
entry.options?.cover?.favorite_tilt_positions ??
DEFAULT_COVER_FAVORITE_POSITIONS
)
: undefined;
const compatibleCovers = Object.values(hass.states).filter((candidate) => {
if (
candidate.entity_id === coverStateObj.entity_id ||
computeStateDomain(candidate) !== "cover"
) {
return false;
}
return (
(!coverSupportsPosition(coverStateObj) ||
coverSupportsPosition(candidate as CoverEntity)) &&
(!coverSupportsTiltPosition(coverStateObj) ||
coverSupportsTiltPosition(candidate as CoverEntity))
);
});
await copyFavoriteOptionsToEntities(
host,
hass,
"cover",
compatibleCovers.map((cover) => cover.entity_id),
{
...(favoritePositions !== undefined
? { favorite_positions: [...favoritePositions] }
: {}),
...(favoriteTiltPositions !== undefined
? { favorite_tilt_positions: [...favoriteTiltPositions] }
: {}),
}
);
},
};
const lightFavoritesHandler: FavoritesDialogHandler = {
domain: "light",
supports: (stateObj) => lightSupportsFavoriteColors(stateObj as LightEntity),
hasCustomFavorites: (entry) =>
hasCustomFavoriteOptionValues(entry.options?.light?.favorite_colors),
getResetOptions: () => ({
favorite_colors: undefined,
}),
getLabels: (hass) => getFavoritesDialogLabels(hass, "light"),
copy: async ({ entry, hass, host, stateObj }) => {
const lightStateObj = stateObj as LightEntity;
const favorites: LightColor[] =
entry.options?.light?.favorite_colors ??
computeDefaultFavoriteColors(lightStateObj);
const favoriteTypes = [
...new Set(favorites.map((item) => Object.keys(item)[0])),
];
const compatibleLights = Object.values(hass.states).filter(
(candidate) =>
candidate.entity_id !== lightStateObj.entity_id &&
computeStateDomain(candidate) === "light" &&
favoriteTypes.every((type) =>
type === "color_temp_kelvin"
? lightSupportsColorMode(
candidate as LightEntity,
LightColorMode.COLOR_TEMP
)
: type === "hs_color" || type === "rgb_color"
? lightSupportsColor(candidate as LightEntity)
: type === "rgbw_color"
? lightSupportsColorMode(
candidate as LightEntity,
LightColorMode.RGBW
)
: type === "rgbww_color"
? lightSupportsColorMode(
candidate as LightEntity,
LightColorMode.RGBWW
)
: false
)
);
await copyFavoriteOptionsToEntities(
host,
hass,
"light",
compatibleLights.map((light) => light.entity_id),
{
favorite_colors: favorites,
}
);
},
};
const FAVORITES_DIALOG_HANDLERS: Record<
FavoritesDomain,
FavoritesDialogHandler
> = {
cover: coverFavoritesHandler,
light: lightFavoritesHandler,
};
export const getFavoritesDialogHandler = (
stateObj: HassEntity
): FavoritesDialogHandler | undefined => {
const domain = computeStateDomain(stateObj);
if (!isFavoritesDomain(domain)) {
return undefined;
}
return FAVORITES_DIALOG_HANDLERS[domain].supports(stateObj)
? FAVORITES_DIALOG_HANDLERS[domain]
: undefined;
};

View File

@@ -55,14 +55,6 @@ import {
getExtendedEntityRegistryEntry,
updateEntityRegistryEntry,
} from "../../data/entity/entity_registry";
import type { LightColor } from "../../data/light";
import {
LightColorMode,
lightSupportsColor,
computeDefaultFavoriteColors,
lightSupportsColorMode,
lightSupportsFavoriteColors,
} from "../../data/light";
import type { ItemType } from "../../data/search";
import { SearchableDomains } from "../../data/search";
import { getSensorNumericDeviceClasses } from "../../data/sensor";
@@ -87,9 +79,9 @@ import "./ha-more-info-history-and-logbook";
import "./ha-more-info-info";
import "./ha-more-info-settings";
import "./more-info-content";
import type { FavoritesDialogContext } from "./favorites";
import { getFavoritesDialogHandler } from "./favorites";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
import { showFormDialog } from "../form/show-form-dialog";
export interface MoreInfoDialogParams {
entityId: string | null;
@@ -343,7 +335,9 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
private _toggleInfoEditMode() {
this._infoEditMode = !this._infoEditMode;
withViewTransition(() => {
this._infoEditMode = !this._infoEditMode;
});
}
private _toggleDetailsYamlMode() {
@@ -355,13 +349,32 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
private _handleToggleInfoEditModeEvent(ev) {
this._infoEditMode = ev.detail;
withViewTransition(() => {
this._infoEditMode = ev.detail;
});
}
private _goToRelated(): void {
this._setView("related");
}
private _getFavoritesContext(): FavoritesDialogContext | undefined {
const entityId = this._entityId;
const stateObj =
entityId && (this.hass.states[entityId] as HassEntity | undefined);
if (!this._entry || !stateObj) {
return undefined;
}
return {
host: this,
hass: this.hass,
entry: this._entry,
stateObj,
};
}
private _handleMenuAction(ev: HaDropdownSelectEvent) {
const action = ev.detail?.item?.value;
switch (action) {
@@ -396,16 +409,28 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
private async _resetFavorites() {
const favoritesContext = this._getFavoritesContext();
if (!favoritesContext) {
return;
}
const favoritesHandler = getFavoritesDialogHandler(
favoritesContext.stateObj
);
if (!favoritesHandler) {
return;
}
const labels = favoritesHandler.getLabels(this.hass);
if (
!(await showConfirmationDialog(this, {
title: this.hass!.localize(
"ui.dialogs.more_info_control.light.reset_favorites"
),
text: this.hass!.localize(
"ui.dialogs.more_info_control.light.reset_favorites_text"
),
dismissText: this.hass!.localize("ui.common.cancel"),
confirmText: this.hass!.localize("ui.common.reset"),
title: labels.reset,
text: labels.resetText,
dismissText: this.hass.localize("ui.common.cancel"),
confirmText: this.hass.localize("ui.common.reset"),
destructive: true,
}))
) {
@@ -414,12 +439,10 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
const result = await updateEntityRegistryEntry(
this.hass,
this._entry!.entity_id,
favoritesContext.entry.entity_id,
{
options_domain: "light",
options: {
favorite_colors: undefined,
},
options_domain: favoritesHandler.domain,
options: favoritesHandler.getResetOptions(favoritesContext.stateObj),
}
);
this._entry = result.entity_entry;
@@ -435,76 +458,21 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
}
private async _copyFavorites() {
const entityId = this._entityId;
const stateObj =
entityId && (this.hass.states[entityId] as HassEntity | undefined);
let favorites: LightColor[] | undefined;
if (this._entry!.options?.light?.favorite_colors) {
favorites = this._entry!.options.light.favorite_colors;
} else if (stateObj) {
favorites = computeDefaultFavoriteColors(stateObj);
const favoritesContext = this._getFavoritesContext();
if (!favoritesContext) {
return;
}
if (!favorites) return;
const favoriteTypes = [...new Set(favorites.map((o) => Object.keys(o)[0]))];
const compatibleLights = Object.values(this.hass.states).filter(
(s) =>
s.entity_id !== entityId &&
computeStateDomain(s) === "light" &&
favoriteTypes.every((type) =>
type === "color_temp_kelvin"
? lightSupportsColorMode(s, LightColorMode.COLOR_TEMP)
: type === "hs_color" || type === "rgb_color"
? lightSupportsColor(s)
: type === "rgbw_color"
? lightSupportsColorMode(s, LightColorMode.RGBW)
: type === "rgbww_color"
? lightSupportsColorMode(s, LightColorMode.RGBWW)
: false
)
const favoritesHandler = getFavoritesDialogHandler(
favoritesContext.stateObj
);
const schema = [
{
name: "entity",
selector: {
entity: {
include_entities: compatibleLights.map((l) => l.entity_id),
multiple: true,
},
},
required: true,
},
];
if (!favoritesHandler) {
return;
}
const computeLabel = () =>
this.hass.localize(
"ui.dialogs.more_info_control.light.copy_favorites_entities"
);
const computeHelper = () =>
this.hass.localize(
"ui.dialogs.more_info_control.light.copy_favorites_helper"
);
const selected = await showFormDialog(this, {
title: this.hass.localize(
"ui.dialogs.more_info_control.light.copy_favorites"
),
schema,
computeLabel,
computeHelper,
data: {},
});
selected?.entity.forEach((id) => {
updateEntityRegistryEntry(this.hass, id, {
options_domain: "light",
options: {
favorite_colors: favorites,
},
});
});
await favoritesHandler.copy(favoritesContext);
}
private _goToAddEntityTo(ev) {
@@ -582,6 +550,29 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
);
const title = this._childView?.viewTitle || breadcrumb.pop() || entityId;
const favoritesContext =
this._entry && stateObj
? {
host: this,
hass: this.hass,
entry: this._entry,
stateObj,
}
: undefined;
const favoritesHandler = favoritesContext
? getFavoritesDialogHandler(favoritesContext.stateObj)
: undefined;
const favoritesLabels = favoritesHandler?.getLabels(this.hass);
const supportsFavorites = Boolean(favoritesHandler && favoritesContext);
const resetFavoritesDisabled =
favoritesContext && favoritesHandler
? !favoritesHandler.hasCustomFavorites(favoritesContext.entry)
: false;
const isRTL = computeRTL(this.hass);
return html`
@@ -665,6 +656,41 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
.path=${mdiDotsVertical}
></ha-icon-button>
${supportsFavorites
? html`
<ha-dropdown-item value="toggle_edit">
<ha-svg-icon
slot="icon"
.path=${this._infoEditMode
? mdiPencilOff
: mdiPencil}
></ha-svg-icon>
${this._infoEditMode
? this.hass.localize(
"ui.dialogs.more_info_control.exit_edit_mode"
)
: favoritesLabels?.editMode}
</ha-dropdown-item>
<ha-dropdown-item
value="reset_favorites"
.disabled=${resetFavoritesDisabled}
>
<ha-svg-icon
slot="icon"
.path=${mdiBackupRestore}
></ha-svg-icon>
${favoritesLabels?.reset}
</ha-dropdown-item>
<ha-dropdown-item value="copy_favorites">
<ha-svg-icon
slot="icon"
.path=${mdiContentDuplicate}
></ha-svg-icon>
${favoritesLabels?.copy}
</ha-dropdown-item>
<wa-divider></wa-divider>
`
: nothing}
${deviceId
? html`
<ha-dropdown-item value="device">
@@ -698,50 +724,6 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
</ha-dropdown-item>
`
: nothing}
${this._entry &&
stateObj &&
domain === "light" &&
lightSupportsFavoriteColors(stateObj)
? html`
<ha-dropdown-item value="toggle_edit">
<ha-svg-icon
slot="icon"
.path=${this._infoEditMode
? mdiPencilOff
: mdiPencil}
></ha-svg-icon>
${this._infoEditMode
? this.hass.localize(
`ui.dialogs.more_info_control.exit_edit_mode`
)
: this.hass.localize(
`ui.dialogs.more_info_control.${domain}.edit_mode`
)}
</ha-dropdown-item>
<ha-dropdown-item
value="reset_favorites"
.disabled=${!this._entry.options?.light
?.favorite_colors}
>
<ha-svg-icon
slot="icon"
.path=${mdiBackupRestore}
></ha-svg-icon>
${this.hass.localize(
`ui.dialogs.more_info_control.light.reset_favorites`
)}
</ha-dropdown-item>
<ha-dropdown-item value="copy_favorites">
<ha-svg-icon
slot="icon"
.path=${mdiContentDuplicate}
></ha-svg-icon>
${this.hass.localize(
`ui.dialogs.more_info_control.light.copy_favorites`
)}
</ha-dropdown-item>
`
: nothing}
<ha-dropdown-item value="related">
<ha-svg-icon
slot="icon"

View File

@@ -6,9 +6,8 @@ import { computeAttributeNameDisplay } from "../../../common/entity/compute_attr
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateActive } from "../../../common/entity/state_active";
import { stateColorCss } from "../../../common/entity/state_color";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-control-slider";
import { CoverEntityFeature, type CoverEntity } from "../../../data/cover";
import { coverSupportsPosition, type CoverEntity } from "../../../data/cover";
import { UNAVAILABLE } from "../../../data/entity/entity";
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../data/entity/entity_attributes";
import type { HomeAssistant } from "../../../types";
@@ -28,10 +27,7 @@ export const supportsCoverPositionCardFeature = (
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "cover" &&
supportsFeature(stateObj, CoverEntityFeature.SET_POSITION)
);
return domain === "cover" && coverSupportsPosition(stateObj);
};
@customElement("hui-cover-position-card-feature")
@@ -47,11 +43,11 @@ class HuiCoverPositionCardFeature
@state() private _config?: CoverPositionCardFeatureConfig;
private get _stateObj() {
private get _stateObj(): CoverEntity | undefined {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] as CoverEntity | undefined;
return this.hass.states[this.context.entity_id!];
}
static getStubConfig(): CoverPositionCardFeatureConfig {

View File

@@ -0,0 +1,302 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-control-select";
import type { CoverEntity } from "../../../data/cover";
import {
DEFAULT_COVER_FAVORITE_POSITIONS,
coverSupportsPosition,
normalizeCoverFavoritePositions,
} from "../../../data/cover";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
getExtendedEntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity/entity_registry";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
CoverPositionFavoriteCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const supportsCoverPositionFavoriteCardFeature = (
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 === "cover" && coverSupportsPosition(stateObj);
};
@customElement("hui-cover-position-favorite-card-feature")
class HuiCoverPositionFavoriteCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@property({ attribute: false }) public color?: string;
@state() private _config?: CoverPositionFavoriteCardFeatureConfig;
@state() private _entry?: ExtEntityRegistryEntry | null;
@state() private _currentPosition?: number;
private _unsubEntityRegistry?: UnsubscribeFunc;
private _subscribedEntityId?: string;
private _subscribedConnection?: HomeAssistant["connection"];
private get _stateObj(): CoverEntity | undefined {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!];
}
public connectedCallback() {
super.connectedCallback();
this._refreshEntitySubscription();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeEntityRegistry();
}
static getStubConfig(): CoverPositionFavoriteCardFeatureConfig {
return {
type: "cover-position-favorite",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-cover-position-favorite-card-feature-editor");
return document.createElement(
"hui-cover-position-favorite-card-feature-editor"
);
}
public setConfig(config: CoverPositionFavoriteCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected willUpdate(changedProp: PropertyValues): void {
super.willUpdate(changedProp);
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._currentPosition =
this._stateObj.attributes.current_position ?? undefined;
}
}
if (
changedProp.has("context") &&
(changedProp.get("context") as LovelaceCardFeatureContext | undefined)
?.entity_id !== this.context?.entity_id
) {
this._refreshEntitySubscription();
}
if (
changedProp.has("hass") &&
(changedProp.get("hass") as HomeAssistant | undefined)?.connection !==
this.hass?.connection
) {
this._refreshEntitySubscription();
}
}
private _refreshEntitySubscription(): void {
this._ensureEntitySubscription().catch(() => undefined);
}
private _unsubscribeEntityRegistry(): void {
if (this._unsubEntityRegistry) {
this._unsubEntityRegistry();
this._unsubEntityRegistry = undefined;
}
}
private async _loadEntityEntry(entityId: string): Promise<void> {
if (!this.hass) {
return;
}
try {
const entry = await getExtendedEntityRegistryEntry(this.hass, entityId);
if (this.context?.entity_id === entityId) {
this._entry = entry;
}
} catch (_err) {
if (this.context?.entity_id === entityId) {
this._entry = null;
}
}
}
private async _subscribeEntityEntry(entityId: string): Promise<void> {
this._unsubscribeEntityRegistry();
await this._loadEntityEntry(entityId);
try {
this._unsubEntityRegistry = subscribeEntityRegistry(
this.hass!.connection,
async (entries) => {
if (this.context?.entity_id !== entityId) {
return;
}
if (entries.some((entry) => entry.entity_id === entityId)) {
await this._loadEntityEntry(entityId);
return;
}
this._entry = null;
}
);
} catch (_err) {
this._unsubEntityRegistry = undefined;
}
}
private async _ensureEntitySubscription(): Promise<void> {
const entityId = this.context?.entity_id;
const connection = this.hass?.connection;
if (!this.hass || !entityId || !connection) {
this._unsubscribeEntityRegistry();
this._subscribedEntityId = undefined;
this._subscribedConnection = undefined;
this._entry = undefined;
return;
}
if (
this._subscribedEntityId === entityId &&
this._subscribedConnection === connection &&
this._unsubEntityRegistry
) {
return;
}
this._subscribedEntityId = entityId;
this._subscribedConnection = connection;
await this._subscribeEntityEntry(entityId);
}
private async _valueChanged(
ev: HASSDomEvent<HASSDomEvents["value-changed"]>
) {
const value = ev.detail.value;
if (value == null) return;
const position = Number(value);
if (isNaN(position)) return;
const oldPosition = this._stateObj!.attributes.current_position;
if (position === oldPosition) return;
this._currentPosition = position;
try {
await this.hass!.callService("cover", "set_cover_position", {
entity_id: this._stateObj!.entity_id,
position: position,
});
} catch (_err) {
this._currentPosition = oldPosition ?? undefined;
}
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsCoverPositionFavoriteCardFeature(this.hass, this.context)
) {
return null;
}
const positions = normalizeCoverFavoritePositions(
this._entry?.options?.cover?.favorite_positions ??
DEFAULT_COVER_FAVORITE_POSITIONS
);
if (positions.length === 0) {
return null;
}
const options = positions.map((position) => ({
value: String(position),
label: `${position}%`,
ariaLabel: this.hass!.localize(
"ui.dialogs.more_info_control.cover.favorite_position.set",
{ value: `${position}%` }
),
}));
const currentValue =
this._currentPosition != null ? String(this._currentPosition) : undefined;
const color = this.color
? computeCssColor(this.color)
: stateColorCss(this._stateObj);
const style = {
"--feature-color": color,
};
return html`
<ha-control-select
style=${styleMap(style)}
.options=${options}
.value=${currentValue}
@value-changed=${this._valueChanged}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-position-favorite.label"
)}
.disabled=${this._stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cover-position-favorite-card-feature": HuiCoverPositionFavoriteCardFeature;
}
}

View File

@@ -1,172 +0,0 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorCss } from "../../../common/entity/state_color";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-control-select";
import type { CoverEntity } from "../../../data/cover";
import { CoverEntityFeature } from "../../../data/cover";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
CoverPositionPresetCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const DEFAULT_COVER_POSITION_PRESETS = [0, 25, 75, 100];
export const supportsCoverPositionPresetCardFeature = (
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 === "cover" &&
supportsFeature(stateObj, CoverEntityFeature.SET_POSITION)
);
};
@customElement("hui-cover-position-preset-card-feature")
class HuiCoverPositionPresetCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@property({ attribute: false }) public color?: string;
@state() private _config?: CoverPositionPresetCardFeatureConfig;
@state() private _currentPosition?: number;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] as CoverEntity | undefined;
}
static getStubConfig(): CoverPositionPresetCardFeatureConfig {
return {
type: "cover-position-preset",
positions: DEFAULT_COVER_POSITION_PRESETS,
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-cover-position-preset-card-feature-editor");
return document.createElement(
"hui-cover-position-preset-card-feature-editor"
);
}
public setConfig(config: CoverPositionPresetCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected willUpdate(changedProp: PropertyValues): void {
super.willUpdate(changedProp);
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._currentPosition =
this._stateObj.attributes.current_position ?? undefined;
}
}
}
private async _valueChanged(
ev: CustomEvent<{ value?: string; item?: { value: string } }>
) {
const value = ev.detail.value ?? ev.detail.item?.value;
if (value == null) return;
const position = Number(value);
if (isNaN(position)) return;
const oldPosition = this._stateObj!.attributes.current_position;
if (position === oldPosition) return;
this._currentPosition = position;
try {
await this.hass!.callService("cover", "set_cover_position", {
entity_id: this._stateObj!.entity_id,
position: position,
});
} catch (_err) {
this._currentPosition = oldPosition ?? undefined;
}
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsCoverPositionPresetCardFeature(this.hass, this.context)
) {
return null;
}
const positions = this._config.positions ?? DEFAULT_COVER_POSITION_PRESETS;
const options = positions.map((position) => ({
value: String(position),
label: `${position}%`,
}));
const currentValue =
this._currentPosition != null ? String(this._currentPosition) : undefined;
const color = this.color
? computeCssColor(this.color)
: stateColorCss(this._stateObj);
const style = {
"--feature-color": color,
};
return html`
<ha-control-select
style=${styleMap(style)}
.options=${options}
.value=${currentValue}
@value-changed=${this._valueChanged}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-position-preset.label"
)}
.disabled=${this._stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cover-position-preset-card-feature": HuiCoverPositionPresetCardFeature;
}
}

View File

@@ -0,0 +1,303 @@
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import type { HASSDomEvent } from "../../../common/dom/fire_event";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-control-select";
import type { CoverEntity } from "../../../data/cover";
import {
DEFAULT_COVER_FAVORITE_POSITIONS,
coverSupportsTiltPosition,
normalizeCoverFavoritePositions,
} from "../../../data/cover";
import type { ExtEntityRegistryEntry } from "../../../data/entity/entity_registry";
import {
getExtendedEntityRegistryEntry,
subscribeEntityRegistry,
} from "../../../data/entity/entity_registry";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
CoverTiltFavoriteCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const supportsCoverTiltFavoriteCardFeature = (
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 === "cover" && coverSupportsTiltPosition(stateObj);
};
@customElement("hui-cover-tilt-favorite-card-feature")
class HuiCoverTiltFavoriteCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@property({ attribute: false }) public color?: string;
@state() private _config?: CoverTiltFavoriteCardFeatureConfig;
@state() private _entry?: ExtEntityRegistryEntry | null;
@state() private _currentTiltPosition?: number;
private _unsubEntityRegistry?: UnsubscribeFunc;
private _subscribedEntityId?: string;
private _subscribedConnection?: HomeAssistant["connection"];
private get _stateObj(): CoverEntity | undefined {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!];
}
public connectedCallback() {
super.connectedCallback();
this._refreshEntitySubscription();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeEntityRegistry();
}
static getStubConfig(): CoverTiltFavoriteCardFeatureConfig {
return {
type: "cover-tilt-favorite",
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-cover-tilt-favorite-card-feature-editor");
return document.createElement(
"hui-cover-tilt-favorite-card-feature-editor"
);
}
public setConfig(config: CoverTiltFavoriteCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected willUpdate(changedProp: PropertyValues): void {
super.willUpdate(changedProp);
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._currentTiltPosition =
this._stateObj.attributes.current_tilt_position ?? undefined;
}
}
if (
changedProp.has("context") &&
(changedProp.get("context") as LovelaceCardFeatureContext | undefined)
?.entity_id !== this.context?.entity_id
) {
this._refreshEntitySubscription();
}
if (
changedProp.has("hass") &&
(changedProp.get("hass") as HomeAssistant | undefined)?.connection !==
this.hass?.connection
) {
this._refreshEntitySubscription();
}
}
private _refreshEntitySubscription(): void {
this._ensureEntitySubscription().catch(() => undefined);
}
private _unsubscribeEntityRegistry(): void {
if (this._unsubEntityRegistry) {
this._unsubEntityRegistry();
this._unsubEntityRegistry = undefined;
}
}
private async _loadEntityEntry(entityId: string): Promise<void> {
if (!this.hass) {
return;
}
try {
const entry = await getExtendedEntityRegistryEntry(this.hass, entityId);
if (this.context?.entity_id === entityId) {
this._entry = entry;
}
} catch (_err) {
if (this.context?.entity_id === entityId) {
this._entry = null;
}
}
}
private async _subscribeEntityEntry(entityId: string): Promise<void> {
this._unsubscribeEntityRegistry();
await this._loadEntityEntry(entityId);
try {
this._unsubEntityRegistry = subscribeEntityRegistry(
this.hass!.connection,
async (entries) => {
if (this.context?.entity_id !== entityId) {
return;
}
if (entries.some((entry) => entry.entity_id === entityId)) {
await this._loadEntityEntry(entityId);
return;
}
this._entry = null;
}
);
} catch (_err) {
this._unsubEntityRegistry = undefined;
}
}
private async _ensureEntitySubscription(): Promise<void> {
const entityId = this.context?.entity_id;
const connection = this.hass?.connection;
if (!this.hass || !entityId || !connection) {
this._unsubscribeEntityRegistry();
this._subscribedEntityId = undefined;
this._subscribedConnection = undefined;
this._entry = undefined;
return;
}
if (
this._subscribedEntityId === entityId &&
this._subscribedConnection === connection &&
this._unsubEntityRegistry
) {
return;
}
this._subscribedEntityId = entityId;
this._subscribedConnection = connection;
await this._subscribeEntityEntry(entityId);
}
private async _valueChanged(
ev: HASSDomEvent<HASSDomEvents["value-changed"]>
) {
if (ev.detail.value == null) return;
const tiltPosition = Number(ev.detail.value);
if (isNaN(tiltPosition)) return;
const oldTiltPosition = this._stateObj!.attributes.current_tilt_position;
if (tiltPosition === oldTiltPosition) return;
this._currentTiltPosition = tiltPosition;
try {
await this.hass!.callService("cover", "set_cover_tilt_position", {
entity_id: this._stateObj!.entity_id,
tilt_position: tiltPosition,
});
} catch (_err) {
this._currentTiltPosition = oldTiltPosition ?? undefined;
}
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsCoverTiltFavoriteCardFeature(this.hass, this.context)
) {
return null;
}
const positions = normalizeCoverFavoritePositions(
this._entry?.options?.cover?.favorite_tilt_positions ??
DEFAULT_COVER_FAVORITE_POSITIONS
);
if (positions.length === 0) {
return null;
}
const options = positions.map((position) => ({
value: String(position),
label: `${position}%`,
ariaLabel: this.hass!.localize(
"ui.dialogs.more_info_control.cover.favorite_tilt_position.set",
{ value: `${position}%` }
),
}));
const currentValue =
this._currentTiltPosition != null
? String(this._currentTiltPosition)
: undefined;
const color = this.color
? computeCssColor(this.color)
: stateColorCss(this._stateObj);
const style = {
"--feature-color": color,
};
return html`
<ha-control-select
style=${styleMap(style)}
.options=${options}
.value=${currentValue}
@value-changed=${this._valueChanged}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-tilt-favorite.label"
)}
.disabled=${this._stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cover-tilt-favorite-card-feature": HuiCoverTiltFavoriteCardFeature;
}
}

View File

@@ -5,9 +5,8 @@ import { computeCssColor } from "../../../common/color/compute-color";
import { computeAttributeNameDisplay } from "../../../common/entity/compute_attribute_display";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorCss } from "../../../common/entity/state_color";
import { supportsFeature } from "../../../common/entity/supports-feature";
import type { CoverEntity } from "../../../data/cover";
import { CoverEntityFeature } from "../../../data/cover";
import { coverSupportsTiltPosition } from "../../../data/cover";
import { UNAVAILABLE } from "../../../data/entity/entity";
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../data/entity/entity_attributes";
import { generateTiltSliderTrackBackgroundGradient } from "../../../state-control/cover/ha-state-control-cover-tilt-position";
@@ -31,8 +30,7 @@ export const supportsCoverTiltPositionCardFeature = (
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "cover" &&
supportsFeature(stateObj, CoverEntityFeature.SET_TILT_POSITION)
domain === "cover" && coverSupportsTiltPosition(stateObj as CoverEntity)
);
};

View File

@@ -1,172 +0,0 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeCssColor } from "../../../common/color/compute-color";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorCss } from "../../../common/entity/state_color";
import { supportsFeature } from "../../../common/entity/supports-feature";
import "../../../components/ha-control-select";
import type { CoverEntity } from "../../../data/cover";
import { CoverEntityFeature } from "../../../data/cover";
import { UNAVAILABLE } from "../../../data/entity/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
CoverTiltPresetCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
export const DEFAULT_COVER_TILT_PRESETS = [0, 25, 75, 100];
export const supportsCoverTiltPresetCardFeature = (
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 === "cover" &&
supportsFeature(stateObj, CoverEntityFeature.SET_TILT_POSITION)
);
};
@customElement("hui-cover-tilt-preset-card-feature")
class HuiCoverTiltPresetCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@property({ attribute: false }) public color?: string;
@state() private _config?: CoverTiltPresetCardFeatureConfig;
@state() private _currentTiltPosition?: number;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] as CoverEntity | undefined;
}
static getStubConfig(): CoverTiltPresetCardFeatureConfig {
return {
type: "cover-tilt-preset",
positions: DEFAULT_COVER_TILT_PRESETS,
};
}
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import("../editor/config-elements/hui-cover-tilt-preset-card-feature-editor");
return document.createElement("hui-cover-tilt-preset-card-feature-editor");
}
public setConfig(config: CoverTiltPresetCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected willUpdate(changedProp: PropertyValues): void {
super.willUpdate(changedProp);
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._currentTiltPosition =
this._stateObj.attributes.current_tilt_position ?? undefined;
}
}
}
private async _valueChanged(
ev: CustomEvent<{ value?: string; item?: { value: string } }>
) {
const value = ev.detail.value ?? ev.detail.item?.value;
if (value == null) return;
const tiltPosition = Number(value);
if (isNaN(tiltPosition)) return;
const oldTiltPosition = this._stateObj!.attributes.current_tilt_position;
if (tiltPosition === oldTiltPosition) return;
this._currentTiltPosition = tiltPosition;
try {
await this.hass!.callService("cover", "set_cover_tilt_position", {
entity_id: this._stateObj!.entity_id,
tilt_position: tiltPosition,
});
} catch (_err) {
this._currentTiltPosition = oldTiltPosition ?? undefined;
}
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsCoverTiltPresetCardFeature(this.hass, this.context)
) {
return null;
}
const positions = this._config.positions ?? DEFAULT_COVER_TILT_PRESETS;
const options = positions.map((position) => ({
value: String(position),
label: `${position}%`,
}));
const currentValue =
this._currentTiltPosition != null
? String(this._currentTiltPosition)
: undefined;
const color = this.color
? computeCssColor(this.color)
: stateColorCss(this._stateObj);
const style = {
"--feature-color": color,
};
return html`
<ha-control-select
style=${styleMap(style)}
.options=${options}
.value=${currentValue}
@value-changed=${this._valueChanged}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-tilt-preset.label"
)}
.disabled=${this._stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cover-tilt-preset-card-feature": HuiCoverTiltPresetCardFeature;
}
}

View File

@@ -26,14 +26,12 @@ export interface CoverTiltPositionCardFeatureConfig {
type: "cover-tilt-position";
}
export interface CoverPositionPresetCardFeatureConfig {
type: "cover-position-preset";
positions?: number[];
export interface CoverPositionFavoriteCardFeatureConfig {
type: "cover-position-favorite";
}
export interface CoverTiltPresetCardFeatureConfig {
type: "cover-tilt-preset";
positions?: number[];
export interface CoverTiltFavoriteCardFeatureConfig {
type: "cover-tilt-favorite";
}
export interface LightBrightnessCardFeatureConfig {
@@ -260,8 +258,8 @@ export type LovelaceCardFeatureConfig =
| CounterActionsCardFeatureConfig
| CoverOpenCloseCardFeatureConfig
| CoverPositionCardFeatureConfig
| CoverPositionPresetCardFeatureConfig
| CoverTiltPresetCardFeatureConfig
| CoverPositionFavoriteCardFeatureConfig
| CoverTiltFavoriteCardFeatureConfig
| CoverTiltPositionCardFeatureConfig
| CoverTiltCardFeatureConfig
| DateSetCardFeatureConfig

View File

@@ -7,10 +7,10 @@ import "../card-features/hui-climate-swing-horizontal-modes-card-feature";
import "../card-features/hui-climate-swing-modes-card-feature";
import "../card-features/hui-counter-actions-card-feature";
import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-favorite-card-feature";
import "../card-features/hui-cover-position-card-feature";
import "../card-features/hui-cover-position-preset-card-feature";
import "../card-features/hui-cover-tilt-card-feature";
import "../card-features/hui-cover-tilt-preset-card-feature";
import "../card-features/hui-cover-tilt-favorite-card-feature";
import "../card-features/hui-cover-tilt-position-card-feature";
import "../card-features/hui-date-set-card-feature";
import "../card-features/hui-fan-direction-card-feature";
@@ -60,9 +60,9 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"climate-preset-modes",
"counter-actions",
"cover-open-close",
"cover-position-favorite",
"cover-position",
"cover-position-preset",
"cover-tilt-preset",
"cover-tilt-favorite",
"cover-tilt-position",
"cover-tilt",
"date-set",

View File

@@ -35,10 +35,10 @@ import { supportsClimateSwingHorizontalModesCardFeature } from "../../card-featu
import { supportsClimateSwingModesCardFeature } from "../../card-features/hui-climate-swing-modes-card-feature";
import { supportsCounterActionsCardFeature } from "../../card-features/hui-counter-actions-card-feature";
import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature";
import { supportsCoverPositionFavoriteCardFeature } from "../../card-features/hui-cover-position-favorite-card-feature";
import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature";
import { supportsCoverPositionPresetCardFeature } from "../../card-features/hui-cover-position-preset-card-feature";
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
import { supportsCoverTiltPresetCardFeature } from "../../card-features/hui-cover-tilt-preset-card-feature";
import { supportsCoverTiltFavoriteCardFeature } from "../../card-features/hui-cover-tilt-favorite-card-feature";
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsDateSetCardFeature } from "../../card-features/hui-date-set-card-feature";
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
@@ -92,9 +92,9 @@ const UI_FEATURE_TYPES = [
"climate-swing-horizontal-modes",
"counter-actions",
"cover-open-close",
"cover-position-favorite",
"cover-position",
"cover-position-preset",
"cover-tilt-preset",
"cover-tilt-favorite",
"cover-tilt-position",
"cover-tilt",
"date-set",
@@ -139,8 +139,8 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"climate-swing-modes",
"climate-swing-horizontal-modes",
"counter-actions",
"cover-position-preset",
"cover-tilt-preset",
"cover-position-favorite",
"cover-tilt-favorite",
"fan-preset-modes",
"humidifier-modes",
"lawn-mower-commands",
@@ -169,9 +169,9 @@ const SUPPORTS_FEATURE_TYPES: Record<
"climate-preset-modes": supportsClimatePresetModesCardFeature,
"counter-actions": supportsCounterActionsCardFeature,
"cover-open-close": supportsCoverOpenCloseCardFeature,
"cover-position-favorite": supportsCoverPositionFavoriteCardFeature,
"cover-position": supportsCoverPositionCardFeature,
"cover-position-preset": supportsCoverPositionPresetCardFeature,
"cover-tilt-preset": supportsCoverTiltPresetCardFeature,
"cover-tilt-favorite": supportsCoverTiltFavoriteCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
"cover-tilt": supportsCoverTiltCardFeature,
"date-set": supportsDateSetCardFeature,

View File

@@ -0,0 +1,45 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-alert";
import type { HomeAssistant } from "../../../../types";
import type {
CoverPositionFavoriteCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-cover-position-favorite-card-feature-editor")
export class HuiCoverPositionFavoriteCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: CoverPositionFavoriteCardFeatureConfig;
public setConfig(config: CoverPositionFavoriteCardFeatureConfig): void {
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-position-favorite.description"
)}
</ha-alert>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cover-position-favorite-card-feature-editor": HuiCoverPositionFavoriteCardFeatureEditor;
}
}

View File

@@ -1,80 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-multi-textfield";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_COVER_POSITION_PRESETS } from "../../card-features/hui-cover-position-preset-card-feature";
import type {
CoverPositionPresetCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-cover-position-preset-card-feature-editor")
export class HuiCoverPositionPresetCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: CoverPositionPresetCardFeatureConfig;
public setConfig(config: CoverPositionPresetCardFeatureConfig): void {
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const positions = this._config.positions ?? DEFAULT_COVER_POSITION_PRESETS;
const stringValues = positions.map((p) => String(p));
return html`
<ha-multi-textfield
.hass=${this.hass}
.value=${stringValues}
.max=${6}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-position-preset.position"
)}
.inputType=${"number"}
.inputSuffix=${"%"}
.addLabel=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-position-preset.add_position"
)}
.removeLabel=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-position-preset.remove_position"
)}
@value-changed=${this._valueChanged}
></ha-multi-textfield>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const stringValues = ev.detail.value as (string | null | undefined)[];
const positions = stringValues
.filter((v): v is string => !!v && !isNaN(Number(v)))
.map((v) => Math.min(100, Math.max(0, Number(v))));
const config: CoverPositionPresetCardFeatureConfig = {
...this._config!,
positions,
};
this._config = config;
fireEvent(this, "config-changed", { config });
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cover-position-preset-card-feature-editor": HuiCoverPositionPresetCardFeatureEditor;
}
}

View File

@@ -0,0 +1,45 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../../../components/ha-alert";
import type { HomeAssistant } from "../../../../types";
import type {
CoverTiltFavoriteCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-cover-tilt-favorite-card-feature-editor")
export class HuiCoverTiltFavoriteCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: CoverTiltFavoriteCardFeatureConfig;
public setConfig(config: CoverTiltFavoriteCardFeatureConfig): void {
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-tilt-favorite.description"
)}
</ha-alert>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cover-tilt-favorite-card-feature-editor": HuiCoverTiltFavoriteCardFeatureEditor;
}
}

View File

@@ -1,80 +0,0 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-multi-textfield";
import type { HomeAssistant } from "../../../../types";
import { DEFAULT_COVER_TILT_PRESETS } from "../../card-features/hui-cover-tilt-preset-card-feature";
import type {
CoverTiltPresetCardFeatureConfig,
LovelaceCardFeatureContext,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-cover-tilt-preset-card-feature-editor")
export class HuiCoverTiltPresetCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: CoverTiltPresetCardFeatureConfig;
public setConfig(config: CoverTiltPresetCardFeatureConfig): void {
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const positions = this._config.positions ?? DEFAULT_COVER_TILT_PRESETS;
const stringValues = positions.map((p) => String(p));
return html`
<ha-multi-textfield
.hass=${this.hass}
.value=${stringValues}
.max=${6}
.label=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-tilt-preset.position"
)}
.inputType=${"number"}
.inputSuffix=${"%"}
.addLabel=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-tilt-preset.add_position"
)}
.removeLabel=${this.hass.localize(
"ui.panel.lovelace.editor.features.types.cover-tilt-preset.remove_position"
)}
@value-changed=${this._valueChanged}
></ha-multi-textfield>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
const stringValues = ev.detail.value as (string | null | undefined)[];
const positions = stringValues
.filter((v): v is string => !!v && !isNaN(Number(v)))
.map((v) => Math.min(100, Math.max(0, Number(v))));
const config: CoverTiltPresetCardFeatureConfig = {
...this._config!,
positions,
};
this._config = config;
fireEvent(this, "config-changed", { config });
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-cover-tilt-preset-card-feature-editor": HuiCoverTiltPresetCardFeatureEditor;
}
}

View File

@@ -1603,9 +1603,37 @@
"create_zone": "Create zone from current location"
},
"cover": {
"edit_mode": "Edit favorites",
"reset_favorites": "Reset favorites",
"reset_favorites_text": "Do you want to reset all favorites for this cover back to default?",
"copy_favorites": "Copy favorites to...",
"copy_favorites_helper": "Select one or more covers to receive the copied favorites from this cover. Covers must support the same position controls.",
"copy_favorites_entities": "[%key:ui::panel::lovelace::editor::card::generic::entities%]",
"switch_mode": {
"button": "Switch to button mode",
"position": "Switch to position mode"
},
"favorite_position": {
"set": "Set position to {value}",
"edit": "Set position to {value}",
"delete": "Delete favorite position {number}",
"delete_confirm_title": "Delete favorite position?",
"delete_confirm_text": "This favorite position will be permanently deleted.",
"delete_confirm_action": "Delete",
"add": "Add new favorite position",
"edit_title": "Edit favorite position",
"add_title": "Add favorite position"
},
"favorite_tilt_position": {
"set": "Set tilt position to {value}",
"edit": "Set tilt position to {value}",
"delete": "Delete favorite tilt position {number}",
"delete_confirm_title": "Delete favorite tilt position?",
"delete_confirm_text": "This favorite tilt position will be permanently deleted.",
"delete_confirm_action": "Delete",
"add": "Add new favorite tilt position",
"edit_title": "Edit favorite tilt position",
"add_title": "Add favorite tilt position"
}
},
"zone": {
@@ -9512,20 +9540,16 @@
"cover-position": {
"label": "Cover position"
},
"cover-position-preset": {
"label": "Cover position preset",
"position": "Position",
"add_position": "Add position",
"remove_position": "Remove position"
"cover-position-favorite": {
"label": "Cover favorite positions",
"description": "This feature uses the cover's favorite positions. To edit them, open the cover's more info dialog and press and hold a favorite."
},
"cover-tilt": {
"label": "Cover tilt"
},
"cover-tilt-preset": {
"label": "Cover tilt preset",
"position": "Position",
"add_position": "Add position",
"remove_position": "Remove position"
"cover-tilt-favorite": {
"label": "Cover favorite tilt positions",
"description": "This feature uses the cover's favorite tilt positions. To edit them, open the cover's more info dialog and press and hold a favorite."
},
"cover-tilt-position": {
"label": "Cover tilt position"