diff --git a/src/components/ha-icon-picker.ts b/src/components/ha-icon-picker.ts index 77ceb6aba1..1d4f9f8180 100644 --- a/src/components/ha-icon-picker.ts +++ b/src/components/ha-icon-picker.ts @@ -67,11 +67,14 @@ const loadCustomIconItems = async (iconsetPrefix: string) => { } }; -const rowRenderer: ComboBoxLitRenderer = (item) => - html` +const rowRenderer: ComboBoxLitRenderer = (item) => html` + ${item.icon} - `; + +`; + +export const CUSTOM_STATE_ICON = "___CUSTOM_STATE_ICON___"; @customElement("ha-icon-picker") export class HaIconPicker extends LitElement { diff --git a/src/components/ha-selector/ha-selector-icon.ts b/src/components/ha-selector/ha-selector-icon.ts index c325055911..d1bba5801b 100644 --- a/src/components/ha-selector/ha-selector-icon.ts +++ b/src/components/ha-selector/ha-selector-icon.ts @@ -35,7 +35,6 @@ export class HaIconSelector extends LitElement { const placeholder = this.selector.icon?.placeholder || - stateObj?.attributes.icon || (stateObj && until(entityIcon(this.hass, stateObj))); return html` diff --git a/src/components/ha-state-icon.ts b/src/components/ha-state-icon.ts index 41421db68f..e92c25f651 100644 --- a/src/components/ha-state-icon.ts +++ b/src/components/ha-state-icon.ts @@ -20,10 +20,8 @@ export class HaStateIcon extends LitElement { @property() public icon?: string; protected render() { - const overrideIcon = - this.icon || - (this.stateObj && this.hass?.entities[this.stateObj.entity_id]?.icon) || - this.stateObj?.attributes.icon; + const overrideIcon = this.icon; + if (overrideIcon) { return html``; } diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts index 05659e2766..35fa3ac7a1 100644 --- a/src/data/entity_registry.ts +++ b/src/data/entity_registry.ts @@ -12,10 +12,15 @@ import type { RegistryEntry } from "./registry"; type EntityCategory = "config" | "diagnostic"; +export type EntityRegistryIcon = { + default: string; + state?: Record; +}; + export interface EntityRegistryDisplayEntry { entity_id: string; name?: string; - icon?: string; + icon?: EntityRegistryIcon | string; device_id?: string; area_id?: string; labels: string[]; @@ -47,7 +52,7 @@ export interface EntityRegistryEntry extends RegistryEntry { id: string; entity_id: string; name: string | null; - icon: string | null; + icon: EntityRegistryIcon | string | null; platform: string; config_entry_id: string | null; device_id: string | null; @@ -128,7 +133,7 @@ export interface EntityRegistryOptions { export interface EntityRegistryEntryUpdateParams { name?: string | null; - icon?: string | null; + icon?: string | EntityRegistryIcon | null; device_class?: string | null; area_id?: string | null; disabled_by?: string | null; diff --git a/src/data/icons.ts b/src/data/icons.ts index 2f22a0850c..9a4a018d0c 100644 --- a/src/data/icons.ts +++ b/src/data/icons.ts @@ -178,17 +178,27 @@ export const getServiceIcons = async ( export const entityIcon = async ( hass: HomeAssistant, stateObj: HassEntity, - state?: string -) => { + stateValue?: string +): Promise => { const entry = hass.entities?.[stateObj.entity_id] as | EntityRegistryDisplayEntry | undefined; + if (entry?.icon) { - return entry.icon; + if (typeof entry.icon === "string") { + return entry.icon; + } + const state = stateValue ?? stateObj.state; + return entry.icon.state?.[state] || entry.icon.default; } + + if (stateObj?.attributes.icon) { + return stateObj.attributes.icon; + } + const domain = computeStateDomain(stateObj); - return getEntityIcon(hass, domain, stateObj, state, entry); + return getEntityIcon(hass, domain, stateObj, stateValue, entry); }; export const entryIcon = async ( @@ -196,7 +206,7 @@ export const entryIcon = async ( entry: EntityRegistryEntry | EntityRegistryDisplayEntry ) => { if (entry.icon) { - return entry.icon; + return typeof entry.icon === "string" ? entry.icon : entry.icon.default; } const stateObj = hass.states[entry.entity_id] as HassEntity | undefined; const domain = computeDomain(entry.entity_id); diff --git a/src/panels/config/entities/dialogs/dialog-entity-state-icon.ts b/src/panels/config/entities/dialogs/dialog-entity-state-icon.ts new file mode 100644 index 0000000000..5a1ebd271e --- /dev/null +++ b/src/panels/config/entities/dialogs/dialog-entity-state-icon.ts @@ -0,0 +1,106 @@ +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { fireEvent } from "../../../../common/dom/fire_event"; +import "../../../../components/ha-button"; +import "../../../../components/ha-alert"; +import "../../../../components/ha-area-picker"; +import "../../../../components/ha-dialog"; +import "../../../../components/ha-labels-picker"; +import "../../../../components/ha-textfield"; +import "../../../../components/ha-yaml-editor"; +import { EntityRegistryIcon } from "../../../../data/entity_registry"; +import { entryIcon } from "../../../../data/icons"; +import { haStyle, haStyleDialog } from "../../../../resources/styles"; +import { HomeAssistant } from "../../../../types"; +import { EntityStateIconDialogParams } from "./show-dialog-entity-state-icon"; + +@customElement("dialog-entity-state-icon") +class DialogEntityStateIcon extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _params?: EntityStateIconDialogParams; + + @state() private _submitting = false; + + @state() private _config?: EntityRegistryIcon; + + public async showDialog(params: EntityStateIconDialogParams): Promise { + this._params = params; + + const icon = this._params.icon; + + this._config = icon ?? { + default: icon || (await entryIcon(this.hass, this._params.entry)) || "", + }; + + await this.updateComplete; + } + + public closeDialog(): void { + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + + return html` + + + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.dialogs.device-registry-detail.update")} + + + `; + } + + private _dataChanged(ev): void { + this._config = ev.detail.value; + } + + private async _updateEntry(): Promise { + this._submitting = true; + try { + const icon = + Object.keys(this._config!).length === 0 ? null : this._config!; + this._params!.updateIcon(icon); + this.closeDialog(); + } catch (err: any) { + // eslint-disable-next-line no-console + console.error(err); + } finally { + this._submitting = false; + } + } + + static get styles(): CSSResultGroup { + return [haStyle, haStyleDialog, css``]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-entity-state-icon": DialogEntityStateIcon; + } +} diff --git a/src/panels/config/entities/dialogs/show-dialog-entity-state-icon.ts b/src/panels/config/entities/dialogs/show-dialog-entity-state-icon.ts new file mode 100644 index 0000000000..859b4e465d --- /dev/null +++ b/src/panels/config/entities/dialogs/show-dialog-entity-state-icon.ts @@ -0,0 +1,25 @@ +import { fireEvent } from "../../../../common/dom/fire_event"; +import { + EntityRegistryEntry, + EntityRegistryIcon, +} from "../../../../data/entity_registry"; + +export interface EntityStateIconDialogParams { + entry: EntityRegistryEntry; + icon: string | EntityRegistryIcon; + updateIcon: (icons: EntityRegistryIcon | null) => Promise; +} + +export const loadEntityStateIconDialog = () => + import("./dialog-entity-state-icon"); + +export const showEntityStateIconDialog = ( + element: HTMLElement, + params: EntityStateIconDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-entity-state-icon", + dialogImport: loadEntityStateIconDialog, + dialogParams: params, + }); +}; diff --git a/src/panels/config/entities/entity-registry-settings-editor.ts b/src/panels/config/entities/entity-registry-settings-editor.ts index 44c1e04d99..c66eb4edeb 100644 --- a/src/panels/config/entities/entity-registry-settings-editor.ts +++ b/src/panels/config/entities/entity-registry-settings-editor.ts @@ -1,6 +1,6 @@ import "@material/mwc-button/mwc-button"; import "@material/mwc-formfield/mwc-formfield"; -import { mdiContentCopy } from "@mdi/js"; +import { mdiCog, mdiContentCopy } from "@mdi/js"; import type { HassEntity } from "home-assistant-js-websocket"; import type { CSSResultGroup, PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; @@ -25,13 +25,13 @@ import "../../../components/ha-area-picker"; import "../../../components/ha-icon"; import "../../../components/ha-icon-button-next"; import "../../../components/ha-icon-picker"; +import "../../../components/ha-labels-picker"; import "../../../components/ha-list-item"; import "../../../components/ha-radio"; import "../../../components/ha-select"; import "../../../components/ha-settings-row"; import "../../../components/ha-state-icon"; import "../../../components/ha-switch"; -import "../../../components/ha-labels-picker"; import type { HaSwitch } from "../../../components/ha-switch"; import "../../../components/ha-textfield"; import { @@ -56,6 +56,7 @@ import type { AlarmControlPanelEntityOptions, EntityRegistryEntry, EntityRegistryEntryUpdateParams, + EntityRegistryIcon, ExtEntityRegistryEntry, LockEntityOptions, SensorEntityOptions, @@ -91,6 +92,7 @@ import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; import { showToast } from "../../../util/toast"; import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail"; +import { showEntityStateIconDialog } from "./dialogs/show-dialog-entity-state-icon"; const OVERRIDE_DEVICE_CLASSES = { cover: [ @@ -149,7 +151,7 @@ export class EntityRegistrySettingsEditor extends LitElement { @state() private _name!: string; - @state() private _icon!: string; + @state() private _icon!: string | EntityRegistryIcon; @state() private _entityId!: string; @@ -349,6 +351,41 @@ export class EntityRegistrySettingsEditor extends LitElement { } } + private _renderIconPicker(stateObj: HassEntity) { + const value = typeof this._icon === "object" ? "" : this._icon; + const placeholder = + this.entry.original_icon || + (stateObj && until(entityIcon(this.hass, stateObj))) || + until(entryIcon(this.hass, this.entry)); + + return html` +
+ + ${!this._icon && !stateObj?.attributes.icon && stateObj + ? html` + + ` + : nothing} + + +
+ `; + } + protected render() { if (this.entry.entity_id !== this._origEntityId) { return nothing; @@ -380,33 +417,7 @@ export class EntityRegistrySettingsEditor extends LitElement { .placeholder=${this.entry.original_name} @input=${this._nameChanged} >`} - ${this.hideIcon - ? nothing - : html` - - ${!this._icon && !stateObj?.attributes.icon && stateObj - ? html` - - ` - : nothing} - - `} + ${this.hideIcon ? nothing : this._renderIconPicker(stateObj)} ${domain === "switch" ? html` = { name: this._name.trim() || null, - icon: this._icon.trim() || null, + icon: + typeof this._icon === "string" ? this._icon.trim() : this._icon || null, area_id: this._areaId || null, labels: this._labels || [], new_entity_id: this._entityId.trim(), @@ -1455,6 +1467,17 @@ export class EntityRegistrySettingsEditor extends LitElement { }); } + private _openEntityStateIcon() { + showEntityStateIconDialog(this, { + entry: this.entry!, + icon: this._icon, + updateIcon: async (icon) => { + this._icon = icon || ""; + fireEvent(this, "change"); + }, + }); + } + private _handleVoiceAssistantsClicked() { showVoiceAssistantsView( this, @@ -1549,6 +1572,18 @@ export class EntityRegistrySettingsEditor extends LitElement { overflow: hidden; --mdc-list-side-padding: 13px; } + .icon-container { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + } + .icon-container ha-button-icon { + flex: none; + } + .icon-container ha-icon-picker { + flex: 1; + } `, ]; }