From 16c1db5346c2a14ae2a89bc7cf5fad6d9a1370c8 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Tue, 17 Mar 2026 12:14:47 +0000 Subject: [PATCH] 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 --- src/components/ha-control-select.ts | 5 +- src/data/cover.ts | 41 ++ src/data/entity/entity_registry.ts | 29 ++ src/dialogs/generic/dialog-box.ts | 1 + src/dialogs/generic/show-dialog-box.ts | 1 + .../ha-more-info-cover-favorite-positions.ts | 475 ++++++++++++++++++ .../components/ha-more-info-favorites.ts | 267 ++++++++++ .../ha-more-info-light-favorite-colors.ts | 316 ++++-------- .../more-info/controls/more-info-cover.ts | 53 +- .../more-info/controls/more-info-light.ts | 19 +- src/dialogs/more-info/favorites.ts | 289 +++++++++++ src/dialogs/more-info/ha-more-info-dialog.ts | 250 +++++---- .../hui-cover-position-card-feature.ts | 12 +- ...ui-cover-position-favorite-card-feature.ts | 302 +++++++++++ .../hui-cover-position-preset-card-feature.ts | 172 ------- .../hui-cover-tilt-favorite-card-feature.ts | 303 +++++++++++ .../hui-cover-tilt-position-card-feature.ts | 6 +- .../hui-cover-tilt-preset-card-feature.ts | 172 ------- src/panels/lovelace/card-features/types.ts | 14 +- .../create-card-feature-element.ts | 8 +- .../hui-card-features-editor.ts | 16 +- ...r-position-favorite-card-feature-editor.ts | 45 ++ ...ver-position-preset-card-feature-editor.ts | 80 --- ...cover-tilt-favorite-card-feature-editor.ts | 45 ++ ...i-cover-tilt-preset-card-feature-editor.ts | 80 --- src/translations/en.json | 44 +- 26 files changed, 2134 insertions(+), 911 deletions(-) create mode 100644 src/dialogs/more-info/components/covers/ha-more-info-cover-favorite-positions.ts create mode 100644 src/dialogs/more-info/components/ha-more-info-favorites.ts create mode 100644 src/dialogs/more-info/favorites.ts create mode 100644 src/panels/lovelace/card-features/hui-cover-position-favorite-card-feature.ts delete mode 100644 src/panels/lovelace/card-features/hui-cover-position-preset-card-feature.ts create mode 100644 src/panels/lovelace/card-features/hui-cover-tilt-favorite-card-feature.ts delete mode 100644 src/panels/lovelace/card-features/hui-cover-tilt-preset-card-feature.ts create mode 100644 src/panels/lovelace/editor/config-elements/hui-cover-position-favorite-card-feature-editor.ts delete mode 100644 src/panels/lovelace/editor/config-elements/hui-cover-position-preset-card-feature-editor.ts create mode 100644 src/panels/lovelace/editor/config-elements/hui-cover-tilt-favorite-card-feature-editor.ts delete mode 100644 src/panels/lovelace/editor/config-elements/hui-cover-tilt-preset-card-feature-editor.ts diff --git a/src/components/ha-control-select.ts b/src/components/ha-control-select.ts index 40220e179b..2065d9a1f3 100644 --- a/src/components/ha-control-select.ts +++ b/src/components/ha-control-select.ts @@ -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} diff --git a/src/data/cover.ts b/src/data/cover.ts index d86ba7e578..e1013a9cff 100644 --- a/src/data/cover.ts +++ b/src/data/cover.ts @@ -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(); + 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; diff --git a/src/data/entity/entity_registry.ts b/src/data/entity/entity_registry.ts index ab791abb8c..a3a8b1dd50 100644 --- a/src/data/entity/entity_registry.ts +++ b/src/data/entity/entity_registry.ts @@ -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; @@ -158,6 +186,7 @@ export interface EntityRegistryEntryUpdateParams { | CalendarEntityOptions | WeatherEntityOptions | LightEntityOptions + | CoverEntityOptions | VacuumEntityOptions; aliases?: (string | null)[]; labels?: string[]; diff --git a/src/dialogs/generic/dialog-box.ts b/src/dialogs/generic/dialog-box.ts index 7ce2a8354c..5251819b6f 100644 --- a/src/dialogs/generic/dialog-box.ts +++ b/src/dialogs/generic/dialog-box.ts @@ -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"} diff --git a/src/dialogs/generic/show-dialog-box.ts b/src/dialogs/generic/show-dialog-box.ts index dc219d27b3..3ced5e5588 100644 --- a/src/dialogs/generic/show-dialog-box.ts +++ b/src/dialogs/generic/show-dialog-box.ts @@ -22,6 +22,7 @@ export interface ConfirmationDialogParams extends BaseDialogBoxParams { export interface PromptDialogParams extends BaseDialogBoxParams { inputLabel?: string; + inputSuffix?: string; dismissText?: string; inputType?: string; defaultValue?: string; diff --git a/src/dialogs/more-info/components/covers/ha-more-info-cover-favorite-positions.ts b/src/dialogs/more-info/components/covers/ha-more-info-cover-favorite-positions.ts new file mode 100644 index 0000000000..6863129aa9 --- /dev/null +++ b/src/dialogs/more-info/components/covers/ha-more-info-cover-favorite-positions.ts @@ -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): 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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` + + ${favorite as number}% + + `; + }; + + private _deleteLabel = + (kind: FavoriteKind): HaMoreInfoFavorites["deleteLabel"] => + (index) => + this._localizeFavorite(kind, "delete", { + number: index + 1, + }); + + private _handleFavoriteAction = ( + ev: HASSDomEvent + ): 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 + ): void => { + ev.stopPropagation(); + const kind = favoriteKindFromEvent(ev); + this._move(kind, ev.detail.oldIndex, ev.detail.newIndex); + }; + + private _handleFavoriteDelete = ( + ev: HASSDomEvent + ): void => { + ev.stopPropagation(); + const kind = favoriteKindFromEvent(ev); + this._deleteFavorite(kind, ev.detail.index); + }; + + private _handleFavoriteAdd = ( + ev: HASSDomEvent + ): void => { + ev.stopPropagation(); + const kind = favoriteKindFromEvent(ev); + this._addFavorite(kind); + }; + + private _handleFavoriteDone = ( + ev: HASSDomEvent + ): 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` +
+ ${showLabel ? html`

${label}

` : nothing} + +
+ `; + } + + 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` +
+ ${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} +
+ `; + } + + 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; + } +} diff --git a/src/dialogs/more-info/components/ha-more-info-favorites.ts b/src/dialogs/more-info/components/ha-more-info-favorites.ts new file mode 100644 index 0000000000..3764b88232 --- /dev/null +++ b/src/dialogs/more-info/components/ha-more-info-favorites.ts @@ -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): void { + ev.stopPropagation(); + fireEvent(this, "favorite-item-moved", ev.detail); + } + + private _handleItemAction = ( + ev: HASSDomEvent + ): 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` + +
+ ${this.items.map( + (item, index) => html` +
+
+
+ ${this.renderItem + ? this.renderItem(item, index, this.editMode) + : nothing} +
+ ${this.editMode + ? html` + + ` + : nothing} +
+
+ ` + )} + ${this.editMode && this.showAdd + ? html` + + + + ` + : nothing} + ${this.editMode && this.showDone + ? html` + + + + ` + : nothing} +
+
+ `; + } + + 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; + } +} diff --git a/src/dialogs/more-info/components/lights/ha-more-info-light-favorite-colors.ts b/src/dialogs/more-info/components/lights/ha-more-info-light-favorite-colors.ts index 0aa2f55c7e..b2a26b966b 100644 --- a/src/dialogs/more-info/components/lights/ha-more-info-light-favorite-colors.ts +++ b/src/dialogs/more-info/components/lights/ha-more-info-light-favorite-colors.ts @@ -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 { 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 => { 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 => { 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 => { 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``; - 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 + ): 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 + ): void => { + ev.stopPropagation(); + this._move(ev.detail.oldIndex, ev.detail.newIndex); + }; + + private _handleFavoriteDelete = ( + ev: HASSDomEvent + ): void => { + ev.stopPropagation(); + this._delete(ev.detail.index); + }; + + private _handleFavoriteAdd = ( + ev: HASSDomEvent + ): void => { + ev.stopPropagation(); + this._add(); + }; + + private _handleFavoriteDone = ( + ev: HASSDomEvent + ): void => { ev.stopPropagation(); fireEvent(this, "toggle-edit-mode", false); }; protected render(): TemplateResult { return html` - -
- ${this._favoriteColors.map( - (color, index) => html` -
-
- - - ${this.editMode - ? html` - - ` - : nothing} -
-
- ` - )} - ${this.editMode - ? html` - - - - - - - ` - : nothing} -
-
+ `; } - - 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 { diff --git a/src/dialogs/more-info/controls/more-info-cover.ts b/src/dialogs/more-info/controls/more-info-cover.ts index f362f3712c..ca3b2f7f80 100644 --- a/src/dialogs/more-info/controls/more-info-cover.ts +++ b/src/dialogs/more-info/controls/more-info-cover.ts @@ -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 } + ${ + showFavoriteControls + ? html` + + ` + : nothing + } `; } diff --git a/src/dialogs/more-info/controls/more-info-light.ts b/src/dialogs/more-info/controls/more-info-light.ts index 3035829276..a5d1db04a1 100644 --- a/src/dialogs/more-info/controls/more-info-light.ts +++ b/src/dialogs/more-info/controls/more-info-light.ts @@ -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` - ${this.entry && - lightSupportsFavoriteColors(this.stateObj) && - (this.editMode || hasFavoriteColors) + ${showFavoriteColors ? html` boolean; + hasCustomFavorites: (entry: ExtEntityRegistryEntry) => boolean; + getResetOptions: ( + stateObj: HassEntity + ) => Partial>; + getLabels: (hass: HomeAssistant) => FavoritesDialogLabels; + copy: (ctx: FavoritesDialogContext) => Promise; +} + +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`
+${rejected
+            .map(
+              (item) => item.reason.message || item.reason.code || item.reason
+            )
+            .join("\r\n")}
`, + }); + } + } +}; + +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; +}; diff --git a/src/dialogs/more-info/ha-more-info-dialog.ts b/src/dialogs/more-info/ha-more-info-dialog.ts index 57d43f8713..fb6fca74e0 100644 --- a/src/dialogs/more-info/ha-more-info-dialog.ts +++ b/src/dialogs/more-info/ha-more-info-dialog.ts @@ -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} > + ${supportsFavorites + ? html` + + + ${this._infoEditMode + ? this.hass.localize( + "ui.dialogs.more_info_control.exit_edit_mode" + ) + : favoritesLabels?.editMode} + + + + ${favoritesLabels?.reset} + + + + ${favoritesLabels?.copy} + + + ` + : nothing} ${deviceId ? html` @@ -698,50 +724,6 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) { ` : nothing} - ${this._entry && - stateObj && - domain === "light" && - lightSupportsFavoriteColors(stateObj) - ? html` - - - ${this._infoEditMode - ? this.hass.localize( - `ui.dialogs.more_info_control.exit_edit_mode` - ) - : this.hass.localize( - `ui.dialogs.more_info_control.${domain}.edit_mode` - )} - - - - ${this.hass.localize( - `ui.dialogs.more_info_control.light.reset_favorites` - )} - - - - ${this.hass.localize( - `ui.dialogs.more_info_control.light.copy_favorites` - )} - - ` - : nothing} { + 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 { + 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 { + 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 { + 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 { + 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 + ) { + 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` + + + `; + } + + static get styles() { + return cardFeatureStyles; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-cover-position-favorite-card-feature": HuiCoverPositionFavoriteCardFeature; + } +} diff --git a/src/panels/lovelace/card-features/hui-cover-position-preset-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-position-preset-card-feature.ts deleted file mode 100644 index efdcf692ea..0000000000 --- a/src/panels/lovelace/card-features/hui-cover-position-preset-card-feature.ts +++ /dev/null @@ -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 { - 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` - - - `; - } - - static get styles() { - return cardFeatureStyles; - } -} - -declare global { - interface HTMLElementTagNameMap { - "hui-cover-position-preset-card-feature": HuiCoverPositionPresetCardFeature; - } -} diff --git a/src/panels/lovelace/card-features/hui-cover-tilt-favorite-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-tilt-favorite-card-feature.ts new file mode 100644 index 0000000000..79be193ea2 --- /dev/null +++ b/src/panels/lovelace/card-features/hui-cover-tilt-favorite-card-feature.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + ) { + 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` + + + `; + } + + static get styles() { + return cardFeatureStyles; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-cover-tilt-favorite-card-feature": HuiCoverTiltFavoriteCardFeature; + } +} diff --git a/src/panels/lovelace/card-features/hui-cover-tilt-position-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-tilt-position-card-feature.ts index 6c8d1d1d50..d917acfb2a 100644 --- a/src/panels/lovelace/card-features/hui-cover-tilt-position-card-feature.ts +++ b/src/panels/lovelace/card-features/hui-cover-tilt-position-card-feature.ts @@ -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) ); }; diff --git a/src/panels/lovelace/card-features/hui-cover-tilt-preset-card-feature.ts b/src/panels/lovelace/card-features/hui-cover-tilt-preset-card-feature.ts deleted file mode 100644 index f65935f106..0000000000 --- a/src/panels/lovelace/card-features/hui-cover-tilt-preset-card-feature.ts +++ /dev/null @@ -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 { - 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` - - - `; - } - - static get styles() { - return cardFeatureStyles; - } -} - -declare global { - interface HTMLElementTagNameMap { - "hui-cover-tilt-preset-card-feature": HuiCoverTiltPresetCardFeature; - } -} diff --git a/src/panels/lovelace/card-features/types.ts b/src/panels/lovelace/card-features/types.ts index 185cecbbc3..1a9fbcb6c5 100644 --- a/src/panels/lovelace/card-features/types.ts +++ b/src/panels/lovelace/card-features/types.ts @@ -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 diff --git a/src/panels/lovelace/create-element/create-card-feature-element.ts b/src/panels/lovelace/create-element/create-card-feature-element.ts index 894e56e33e..e4f03c657b 100644 --- a/src/panels/lovelace/create-element/create-card-feature-element.ts +++ b/src/panels/lovelace/create-element/create-card-feature-element.ts @@ -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([ "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", diff --git a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts index afcc7a72b0..21d92b3b37 100644 --- a/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-card-features-editor.ts @@ -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([ "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, diff --git a/src/panels/lovelace/editor/config-elements/hui-cover-position-favorite-card-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-cover-position-favorite-card-feature-editor.ts new file mode 100644 index 0000000000..34d52cd543 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-cover-position-favorite-card-feature-editor.ts @@ -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` + + ${this.hass.localize( + "ui.panel.lovelace.editor.features.types.cover-position-favorite.description" + )} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-cover-position-favorite-card-feature-editor": HuiCoverPositionFavoriteCardFeatureEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-cover-position-preset-card-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-cover-position-preset-card-feature-editor.ts deleted file mode 100644 index fc0f64560d..0000000000 --- a/src/panels/lovelace/editor/config-elements/hui-cover-position-preset-card-feature-editor.ts +++ /dev/null @@ -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` - - `; - } - - 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; - } -} diff --git a/src/panels/lovelace/editor/config-elements/hui-cover-tilt-favorite-card-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-cover-tilt-favorite-card-feature-editor.ts new file mode 100644 index 0000000000..639fdc7633 --- /dev/null +++ b/src/panels/lovelace/editor/config-elements/hui-cover-tilt-favorite-card-feature-editor.ts @@ -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` + + ${this.hass.localize( + "ui.panel.lovelace.editor.features.types.cover-tilt-favorite.description" + )} + + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "hui-cover-tilt-favorite-card-feature-editor": HuiCoverTiltFavoriteCardFeatureEditor; + } +} diff --git a/src/panels/lovelace/editor/config-elements/hui-cover-tilt-preset-card-feature-editor.ts b/src/panels/lovelace/editor/config-elements/hui-cover-tilt-preset-card-feature-editor.ts deleted file mode 100644 index 50fa0d20d2..0000000000 --- a/src/panels/lovelace/editor/config-elements/hui-cover-tilt-preset-card-feature-editor.ts +++ /dev/null @@ -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` - - `; - } - - 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; - } -} diff --git a/src/translations/en.json b/src/translations/en.json index a41ef02cc4..fbf94db9bf 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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"