Compare commits

...
Author SHA1 Message Date
Paul Bottein 4dd0b02d94 Add entity state and range icons 2026-08-24 11:28:12 +02:00
8 changed files with 457 additions and 31 deletions
+5 -1
View File
@@ -15,6 +15,7 @@ import {
import {
DEFAULT_DOMAIN_ICON,
entityIcon,
entityStateIconOverride,
FALLBACK_DOMAIN_ICONS,
} from "../data/icons";
import "./ha-icon";
@@ -48,9 +49,12 @@ export class HaStateIcon extends LitElement {
protected _entities?: ContextType<typeof entitiesContext>;
private get _overrideIcon(): string | undefined {
const entry = this.stateObj && this._entities?.[this.stateObj.entity_id];
const stateValue = this.stateValue ?? this.stateObj?.state;
return (
this.icon ||
(this.stateObj && this._entities?.[this.stateObj.entity_id]?.icon) ||
(entry && stateValue && entityStateIconOverride(entry, stateValue)) ||
entry?.icon ||
this.stateObj?.attributes.icon
);
}
+8
View File
@@ -17,6 +17,8 @@ export interface EntityRegistryDisplayEntry {
entity_id: string;
name?: string;
icon?: string;
state_icons?: Record<string, string>;
range_icons?: Record<string, string>;
device_id?: string;
area_id?: string;
labels: string[];
@@ -37,6 +39,8 @@ export interface EntityRegistryDisplayEntryResponse {
ec?: number;
en?: string;
ic?: string;
si?: Record<string, string>;
ri?: Record<string, string>;
pl?: string;
tk?: string;
hb?: boolean;
@@ -74,6 +78,8 @@ export interface ExtEntityRegistryEntry extends EntityRegistryEntry {
device_class?: string;
original_device_class?: string;
aliases: (string | null)[];
state_icons: Record<string, string> | null;
range_icons: Record<string, string> | null;
}
export interface UpdateEntityRegistryEntryResult {
@@ -184,6 +190,8 @@ export interface EntityRegistryOptions {
export interface EntityRegistryEntryUpdateParams {
name?: string | null;
icon?: string | null;
state_icons?: Record<string, string> | null;
range_icons?: Record<string, string> | null;
device_class?: string | null;
area_id?: string | null;
disabled_by?: string | null;
+19
View File
@@ -450,6 +450,19 @@ const getIconFromTranslations = (
return translations.default;
};
export const entityStateIconOverride = (
entry: EntityRegistryDisplayEntry,
state: string
): string | undefined => {
if (entry.state_icons?.[state]) {
return entry.state_icons[state];
}
if (entry.range_icons && !isNaN(Number(state))) {
return getIconFromRange(Number(state), entry.range_icons);
}
return undefined;
};
export const entityIcon = async (
entities: HomeAssistant["entities"],
hassConfig: HomeAssistant["config"],
@@ -459,6 +472,12 @@ export const entityIcon = async (
) => {
const entry = entities?.[stateObj.entity_id] as
EntityRegistryDisplayEntry | undefined;
if (entry) {
const override = entityStateIconOverride(entry, state ?? stateObj.state);
if (override) {
return override;
}
}
if (entry?.icon) {
return entry.icon;
}
@@ -0,0 +1,264 @@
import { mdiDeleteOutline, mdiPlus } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { isNumericState } from "../../../../common/number/format_number";
import "../../../../components/entity/ha-entity-state-picker";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-svg-icon";
import "../../../../components/input/ha-input";
import type { HomeAssistant } from "../../../../types";
import type { EntityStateIconsViewParams } from "./show-view-entity-state-icons";
interface IconRule {
key: string;
icon: string;
}
@customElement("ha-more-info-view-entity-state-icons")
export class HaMoreInfoViewEntityStateIcons extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public params!: EntityStateIconsViewParams;
@state() private _rules: IconRule[] = [];
@state() private _defaultIcon = "";
protected willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (!changedProperties.has("params") || !this.params) {
return;
}
const icons = this._numeric
? this.params.rangeIcons
: this.params.stateIcons;
this._rules = Object.entries(icons || {})
.sort(([a], [b]) => (this._numeric ? Number(a) - Number(b) : 0))
.map(([key, icon]) => ({ key, icon }));
this._defaultIcon = this.params.defaultIcon;
}
private get _numeric(): boolean {
const stateObj = this.hass.states[this.params.entityId];
return stateObj ? isNumericState(stateObj) : false;
}
private _emitChange() {
const icons: Record<string, string> = {};
for (const rule of this._rules) {
if (!rule.icon || rule.key === "") {
continue;
}
if (this._numeric) {
const threshold = Number(rule.key);
if (!Number.isFinite(threshold)) {
continue;
}
icons[String(threshold)] = rule.icon;
} else {
icons[rule.key] = rule.icon;
}
}
const value = Object.keys(icons).length ? icons : null;
this.params.onChange({
stateIcons: this._numeric ? null : value,
rangeIcons: this._numeric ? value : null,
defaultIcon: this._defaultIcon,
});
}
private _addRule() {
this._rules = [...this._rules, { key: "", icon: "" }];
}
private _removeRule(ev: Event) {
const index = (ev.currentTarget as HTMLElement & { index: number }).index;
this._rules = this._rules.filter((_, i) => i !== index);
this._emitChange();
}
private _updateRule(index: number, rule: Partial<IconRule>) {
this._rules = this._rules.map((current, i) =>
i === index ? { ...current, ...rule } : current
);
this._emitChange();
}
private _thresholdChanged(ev: Event) {
const target = ev.currentTarget as HTMLInputElement & { index: number };
this._updateRule(target.index, { key: target.value });
}
private _ruleStateChanged(ev: CustomEvent) {
const target = ev.currentTarget as HTMLElement & { index: number };
this._updateRule(target.index, { key: ev.detail.value || "" });
}
private _ruleIconChanged(ev: CustomEvent) {
ev.stopPropagation();
const target = ev.currentTarget as HTMLElement & { index: number };
this._updateRule(target.index, { icon: ev.detail.value || "" });
}
private _defaultIconChanged(ev: CustomEvent) {
ev.stopPropagation();
this._defaultIcon = ev.detail.value || "";
this._emitChange();
}
private _renderRule(rule: IconRule, index: number) {
const stateObj = this.hass.states[this.params.entityId];
const usedStates = this._rules
.filter((_, i) => i !== index)
.map((r) => r.key);
return html`
<div class="rule">
${
this._numeric
? html`
<ha-input
inset-label
type="number"
.index=${index}
.value=${rule.key}
.label=${this.hass.localize(
"ui.dialogs.entity_state_icons.from"
)}
@input=${this._thresholdChanged}
>
${
stateObj?.attributes.unit_of_measurement
? html`<span slot="end"
>${stateObj.attributes.unit_of_measurement}</span
>`
: nothing
}
</ha-input>
`
: html`
<ha-entity-state-picker
.hass=${this.hass}
.index=${index}
.entityId=${this.params.entityId}
.value=${rule.key}
.label=${this.hass.localize(
"ui.dialogs.entity_state_icons.state"
)}
.hideStates=${usedStates}
allow-custom-value
@value-changed=${this._ruleStateChanged}
></ha-entity-state-picker>
`
}
<ha-icon-picker
.index=${index}
.value=${rule.icon}
.label=${this.hass.localize("ui.dialogs.entity_state_icons.icon")}
@value-changed=${this._ruleIconChanged}
></ha-icon-picker>
<ha-icon-button
.index=${index}
.path=${mdiDeleteOutline}
.label=${this.hass.localize("ui.common.remove")}
@click=${this._removeRule}
></ha-icon-button>
</div>
`;
}
protected render() {
if (!this.params) {
return nothing;
}
return html`
<div class="content">
<p class="description">
${this.hass.localize(
this._numeric
? "ui.dialogs.entity_state_icons.description_range"
: "ui.dialogs.entity_state_icons.description_state"
)}
</p>
${this._rules.map((rule, index) => this._renderRule(rule, index))}
<ha-button
class="add"
appearance="plain"
size="s"
@click=${this._addRule}
>
<ha-svg-icon slot="start" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize(
this._numeric
? "ui.dialogs.entity_state_icons.add_range"
: "ui.dialogs.entity_state_icons.add_state"
)}
</ha-button>
<ha-icon-picker
class="default-icon"
.value=${this._defaultIcon}
.label=${this.hass.localize(
"ui.dialogs.entity_state_icons.default_icon"
)}
.helper=${this.hass.localize(
"ui.dialogs.entity_state_icons.default_icon_helper"
)}
.placeholder=${this.params.placeholderIcon}
@value-changed=${this._defaultIconChanged}
></ha-icon-picker>
</div>
`;
}
static styles: CSSResultGroup = css`
:host {
display: block;
}
.content {
display: flex;
flex-direction: column;
padding: 0 var(--ha-space-6) var(--ha-space-6);
}
.description {
margin: 0 0 var(--ha-space-4);
color: var(--secondary-text-color);
}
.rule {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-bottom: var(--ha-space-2);
}
.rule > ha-input,
.rule > ha-entity-state-picker,
.rule > ha-icon-picker {
flex: 1;
min-width: 0;
}
.add {
align-self: flex-start;
}
.default-icon {
display: block;
margin-top: var(--ha-space-6);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-more-info-view-entity-state-icons": HaMoreInfoViewEntityStateIcons;
}
}
@@ -0,0 +1,33 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
export interface EntityStateIconsValue {
stateIcons: Record<string, string> | null;
rangeIcons: Record<string, string> | null;
defaultIcon: string;
}
export interface EntityStateIconsViewParams {
entityId: string;
stateIcons: Record<string, string> | null;
rangeIcons: Record<string, string> | null;
defaultIcon: string;
placeholderIcon?: string;
onChange: (value: EntityStateIconsValue) => void;
}
export const loadEntityStateIconsView = () =>
import("./ha-more-info-view-entity-state-icons");
export const showEntityStateIconsView = (
element: HTMLElement,
localize: LocalizeFunc,
params: EntityStateIconsViewParams
): void => {
fireEvent(element, "show-child-view", {
viewTag: "ha-more-info-view-entity-state-icons",
viewImport: loadEntityStateIconsView,
viewTitle: localize("ui.dialogs.entity_state_icons.title"),
viewParams: params,
});
};
@@ -1,5 +1,5 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiContentCopy, mdiRestore } from "@mdi/js";
import { mdiCog, mdiContentCopy, mdiRestore } 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,6 +25,7 @@ import "../../../components/ha-color-picker";
import "../../../components/ha-dropdown-item";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-picker";
import "../../../components/ha-labels-picker";
@@ -95,6 +96,7 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { showEntityStateIconsView } from "../../../dialogs/more-info/components/entity/show-view-entity-state-icons";
import { showVacuumSegmentMappingView } from "../../../dialogs/more-info/components/vacuum/show-view-vacuum-segment-mapping";
import { showVoiceAssistantsView } from "../../../dialogs/more-info/components/voice/show-view-voice-assistants";
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
@@ -168,6 +170,8 @@ export interface EntitySettingsState {
windSpeedUnit: string | null | undefined;
switchAsDomain: string;
switchAsInvert: boolean;
stateIcons: Record<string, string> | null;
rangeIcons: Record<string, string> | null;
}
@customElement("entity-registry-settings-editor")
@@ -195,6 +199,10 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _icon!: string;
@state() private _stateIcons?: Record<string, string> | null;
@state() private _rangeIcons?: Record<string, string> | null;
@state() private _entityId!: EntitySettingsState["entityId"];
@state() private _deviceClass?: EntitySettingsState["deviceClass"];
@@ -263,6 +271,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._name = this.entry.name || "";
this._icon = this.entry.icon || "";
this._stateIcons = this.entry.state_icons;
this._rangeIcons = this.entry.range_icons;
this._deviceClass =
this.entry.device_class || this.entry.original_device_class;
this._origEntityId = this.entry.entity_id;
@@ -405,6 +415,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
windSpeedUnit: this._wind_speed_unit,
switchAsDomain: this._switchAsDomain,
switchAsInvert: this._switchAsInvert,
stateIcons: this._stateIcons ?? null,
rangeIcons: this._rangeIcons ?? null,
},
"entity-registry"
);
@@ -504,39 +516,72 @@ export class EntityRegistrySettingsEditor extends LitElement {
this.hideIcon
? nothing
: html`
<ha-icon-picker
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.icon"
)}
.placeholder=${
this.entry.original_icon ||
stateObj?.attributes.icon ||
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
)) ||
until(entryIcon(this.hass, this.entry))
}
.disabled=${this.disabled}
>
<div class="icon-row">
${
!this._icon && !stateObj?.attributes.icon && stateObj
this._iconRulesCount > 0
? html`
<ha-state-icon
slot="start"
.stateObj=${stateObj}
></ha-state-icon>
<ha-input
inset-label
disabled
.value=${this.hass.localize(
"ui.dialogs.entity_registry.editor.dynamic_icon",
{ count: this._iconRulesCount }
)}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.icon"
)}
.hint=${this.hass.localize(
"ui.dialogs.entity_registry.editor.dynamic_icon_hint"
)}
></ha-input>
`
: html`
<ha-icon-picker
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.icon"
)}
.placeholder=${
this.entry.original_icon ||
stateObj?.attributes.icon ||
(stateObj &&
until(
entityIcon(
this.hass.entities,
this.hass.config,
this.hass.connection,
stateObj
)
)) ||
until(entryIcon(this.hass, this.entry))
}
.disabled=${this.disabled}
>
${
!this._icon &&
!stateObj?.attributes.icon &&
stateObj
? html`
<ha-state-icon
slot="start"
.stateObj=${stateObj}
></ha-state-icon>
`
: nothing
}
</ha-icon-picker>
`
: nothing
}
</ha-icon-picker>
<ha-icon-button
.path=${mdiCog}
.label=${this.hass.localize(
"ui.dialogs.entity_state_icons.title"
)}
.disabled=${this.disabled}
@click=${this._handleStateIconsClicked}
></ha-icon-button>
</div>
`
}
${
@@ -1247,6 +1292,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
const params: Partial<EntityRegistryEntryUpdateParams> = {
name: this._name.trim() || null,
icon: this._icon.trim() || null,
state_icons: this._stateIcons ?? null,
range_icons: this._rangeIcons ?? null,
area_id: this._areaId || null,
labels: this._labels || [],
new_entity_id: this._entityId.trim(),
@@ -1721,6 +1768,28 @@ export class EntityRegistrySettingsEditor extends LitElement {
);
}
private get _iconRulesCount(): number {
return (
Object.keys(this._stateIcons || {}).length +
Object.keys(this._rangeIcons || {}).length
);
}
private _handleStateIconsClicked() {
showEntityStateIconsView(this, this.hass.localize, {
entityId: this.entry.entity_id,
stateIcons: this._stateIcons ?? null,
rangeIcons: this._rangeIcons ?? null,
defaultIcon: this._icon,
placeholderIcon: this.entry.original_icon,
onChange: (value) => {
this._stateIcons = value.stateIcons;
this._rangeIcons = value.rangeIcons;
this._icon = value.defaultIcon;
},
});
}
private async _showOptionsFlow() {
showOptionsFlowDialog(this, this.helperConfigEntry!, {
manifest: await fetchIntegrationManifest(
@@ -1821,6 +1890,19 @@ export class EntityRegistrySettingsEditor extends LitElement {
margin: var(--ha-space-2) 0;
width: 100%;
}
.icon-row {
display: flex;
align-items: flex-start;
gap: var(--ha-space-2);
}
.icon-row > ha-icon-picker,
.icon-row > ha-input {
flex: 1;
min-width: 0;
}
.icon-row > ha-icon-button {
margin-top: var(--ha-space-2);
}
.menu-item {
border-radius: var(--ha-border-radius-sm);
margin-top: 3px;
+2
View File
@@ -263,6 +263,8 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
has_entity_name: entity.hn,
name: entity.en,
icon: entity.ic,
state_icons: entity.si,
range_icons: entity.ri,
hidden: entity.hb,
display_precision: entity.dp,
};
+14
View File
@@ -1500,6 +1500,18 @@
"area_label": "Area",
"description": "Configure which areas correspond to each vacuum segment"
},
"entity_state_icons": {
"title": "State icons",
"description_range": "Customize the icon for value ranges. Each threshold applies from its value up to the next one.",
"description_state": "Customize the icon for specific states.",
"from": "From",
"state": "State",
"icon": "Icon",
"add_range": "Add range",
"add_state": "Add state",
"default_icon": "Default icon",
"default_icon_helper": "Used when no rule matches, like the unknown and unavailable states."
},
"codemirror": {
"open_documentation": "Open documentation"
},
@@ -1897,6 +1909,8 @@
"name": "Name",
"icon": "Icon",
"icon_error": "Icons should be in the format 'prefix:iconname', like 'mdi:home'",
"dynamic_icon": "Dynamic icon · {count} {count, plural,\n one {rule}\n other {rules}\n}",
"dynamic_icon_hint": "The icon changes with the state",
"default_code": "Default code",
"default_code_error": "Code does not match code format",
"calendar_color": "Calendar color",