diff --git a/src/common/entity/compute_attribute_display.ts b/src/common/entity/compute_attribute_display.ts index 0a6d16e55c..e4210a2509 100644 --- a/src/common/entity/compute_attribute_display.ts +++ b/src/common/entity/compute_attribute_display.ts @@ -1,7 +1,6 @@ import { HassConfig, HassEntity } from "home-assistant-js-websocket"; -import { html, TemplateResult } from "lit"; -import { until } from "lit/directives/until"; import { EntityRegistryDisplayEntry } from "../../data/entity_registry"; +import { FrontendLocaleData } from "../../data/translation"; import { HomeAssistant } from "../../types"; import checkValidDate from "../datetime/check_valid_date"; import { formatDate } from "../datetime/format_date"; @@ -12,9 +11,6 @@ import { isDate } from "../string/is_date"; import { isTimestamp } from "../string/is_timestamp"; import { LocalizeFunc } from "../translations/localize"; import { computeDomain } from "./compute_domain"; -import { FrontendLocaleData } from "../../data/translation"; - -let jsYamlPromise: Promise; export const computeAttributeValueDisplay = ( localize: LocalizeFunc, @@ -24,7 +20,7 @@ export const computeAttributeValueDisplay = ( entities: HomeAssistant["entities"], attribute: string, value?: any -): string | TemplateResult => { +): string => { const attributeValue = value !== undefined ? value : stateObj.attributes[attribute]; @@ -40,23 +36,6 @@ export const computeAttributeValueDisplay = ( // Special handling in case this is a string with an known format if (typeof attributeValue === "string") { - // URL handling - if (attributeValue.startsWith("http")) { - try { - // If invalid URL, exception will be raised - const url = new URL(attributeValue); - if (url.protocol === "http:" || url.protocol === "https:") - return html`${attributeValue}`; - } catch (_) { - // Nothing to do here - } - } - // Date handling if (isDate(attributeValue, true)) { // Timestamp handling @@ -81,13 +60,8 @@ export const computeAttributeValueDisplay = ( attributeValue.some((val) => val instanceof Object)) || (!Array.isArray(attributeValue) && attributeValue instanceof Object) ) { - if (!jsYamlPromise) { - jsYamlPromise = import("../../resources/js-yaml-dump"); - } - const yaml = jsYamlPromise.then((jsYaml) => jsYaml.dump(attributeValue)); - return html`
${until(yaml, "")}
`; + return JSON.stringify(attributeValue); } - // If this is an array, try to determine the display value for each item if (Array.isArray(attributeValue)) { return attributeValue diff --git a/src/common/translations/entity-state.ts b/src/common/translations/entity-state.ts new file mode 100644 index 0000000000..29073222c5 --- /dev/null +++ b/src/common/translations/entity-state.ts @@ -0,0 +1,47 @@ +import type { HassConfig, HassEntity } from "home-assistant-js-websocket"; +import type { FrontendLocaleData } from "../../data/translation"; +import type { HomeAssistant } from "../../types"; +import type { LocalizeFunc } from "./localize"; + +export type FormatEntityStateFunc = { + formatEntityState: (stateObj: HassEntity, state?: string) => string; + formatEntityAttributeValue: ( + stateObj: HassEntity, + attribute: string, + value?: any + ) => string; + formatEntityAttributeName: ( + stateObj: HassEntity, + attribute: string + ) => string; +}; + +export const computeFormatFunctions = async ( + localize: LocalizeFunc, + locale: FrontendLocaleData, + config: HassConfig, + entities: HomeAssistant["entities"] +): Promise => { + const { computeStateDisplay } = await import( + "../entity/compute_state_display" + ); + const { computeAttributeValueDisplay, computeAttributeNameDisplay } = + await import("../entity/compute_attribute_display"); + + return { + formatEntityState: (stateObj, state) => + computeStateDisplay(localize, stateObj, locale, config, entities, state), + formatEntityAttributeValue: (stateObj, attribute, value) => + computeAttributeValueDisplay( + localize, + stateObj, + locale, + config, + entities, + attribute, + value + ), + formatEntityAttributeName: (stateObj, attribute) => + computeAttributeNameDisplay(localize, stateObj, entities, attribute), + }; +}; diff --git a/src/components/ha-attribute-value.ts b/src/components/ha-attribute-value.ts new file mode 100644 index 0000000000..e081f3e29a --- /dev/null +++ b/src/components/ha-attribute-value.ts @@ -0,0 +1,64 @@ +import { HassEntity } from "home-assistant-js-websocket"; +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import { until } from "lit/directives/until"; +import { HomeAssistant } from "../types"; + +let jsYamlPromise: Promise; + +@customElement("ha-attribute-value") +class HaAttributeValue extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public stateObj?: HassEntity; + + @property() public attribute!: string; + + protected render() { + if (!this.stateObj) { + return nothing; + } + const attributeValue = this.stateObj.attributes[this.attribute]; + if (typeof attributeValue === "string") { + // URL handling + if (attributeValue.startsWith("http")) { + try { + // If invalid URL, exception will be raised + const url = new URL(attributeValue); + if (url.protocol === "http:" || url.protocol === "https:") + return html` + + ${attributeValue} + + `; + } catch (_) { + // Nothing to do here + } + } + } + + if ( + (Array.isArray(attributeValue) && + attributeValue.some((val) => val instanceof Object)) || + (!Array.isArray(attributeValue) && attributeValue instanceof Object) + ) { + if (!jsYamlPromise) { + jsYamlPromise = import("../resources/js-yaml-dump"); + } + const yaml = jsYamlPromise.then((jsYaml) => jsYaml.dump(attributeValue)); + return html`
${until(yaml, "")}
`; + } + + return this.hass.formatEntityAttributeValue(this.stateObj!, this.attribute); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-attribute-value": HaAttributeValue; + } +} diff --git a/src/components/ha-attributes.ts b/src/components/ha-attributes.ts index dce1fd4c07..80cc25031c 100644 --- a/src/components/ha-attributes.ts +++ b/src/components/ha-attributes.ts @@ -1,15 +1,12 @@ import { HassEntity } from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import { - computeAttributeNameDisplay, - computeAttributeValueDisplay, -} from "../common/entity/compute_attribute_display"; +import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display"; import { STATE_ATTRIBUTES } from "../data/entity_attributes"; import { haStyle } from "../resources/styles"; import { HomeAssistant } from "../types"; - import "./ha-expansion-panel"; +import "./ha-attribute-value"; @customElement("ha-attributes") class HaAttributes extends LitElement { @@ -58,14 +55,11 @@ class HaAttributes extends LitElement { )}
- ${computeAttributeValueDisplay( - this.hass.localize, - this.stateObj!, - this.hass.locale, - this.hass.config, - this.hass.entities, - attribute - )} +
` diff --git a/src/layouts/home-assistant.ts b/src/layouts/home-assistant.ts index 5570f0a696..ba9f321c79 100644 --- a/src/layouts/home-assistant.ts +++ b/src/layouts/home-assistant.ts @@ -68,7 +68,8 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) { `; } - willUpdate(changedProps: PropertyValues) { + protected willUpdate(changedProps: PropertyValues) { + super.willUpdate(changedProps); if ( this._databaseMigration === undefined && changedProps.has("hass") && @@ -79,7 +80,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) { } } - update(changedProps: PropertyValues) { + protected update(changedProps: PropertyValues) { if ( this.hass?.states && this.hass.config && diff --git a/src/panels/lovelace/cards/hui-entity-card.ts b/src/panels/lovelace/cards/hui-entity-card.ts index ca3ffdeaf0..94b9285c1a 100644 --- a/src/panels/lovelace/cards/hui-entity-card.ts +++ b/src/panels/lovelace/cards/hui-entity-card.ts @@ -12,13 +12,12 @@ import { ifDefined } from "lit/directives/if-defined"; import { styleMap } from "lit/directives/style-map"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { fireEvent } from "../../../common/dom/fire_event"; -import { computeAttributeValueDisplay } from "../../../common/entity/compute_attribute_display"; import { computeStateDisplay } from "../../../common/entity/compute_state_display"; import { computeStateDomain } from "../../../common/entity/compute_state_domain"; import { computeStateName } from "../../../common/entity/compute_state_name"; import { - stateColorCss, stateColorBrightness, + stateColorCss, } from "../../../common/entity/state_color"; import { isValidEntityId } from "../../../common/entity/valid_entity_id"; import { @@ -27,6 +26,7 @@ import { isNumericState, } from "../../../common/number/format_number"; import { iconColorCSS } from "../../../common/style/icon_color_css"; +import "../../../components/ha-attribute-value"; import "../../../components/ha-card"; import "../../../components/ha-icon"; import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../../data/climate"; @@ -157,14 +157,14 @@ export class HuiEntityCard extends LitElement implements LovelaceCard { ${"attribute" in this._config ? stateObj.attributes[this._config.attribute!] !== undefined - ? computeAttributeValueDisplay( - this.hass.localize, - stateObj, - this.hass.locale, - this.hass.config, - this.hass.entities, - this._config.attribute! - ) + ? html` + + + ` : this.hass.localize("state.default.unknown") : isNumericState(stateObj) || this._config.unit ? formatNumber( diff --git a/src/panels/lovelace/cards/hui-tile-card.ts b/src/panels/lovelace/cards/hui-tile-card.ts index bcde89cfa6..6fd542e593 100644 --- a/src/panels/lovelace/cards/hui-tile-card.ts +++ b/src/panels/lovelace/cards/hui-tile-card.ts @@ -25,7 +25,6 @@ import { computeCssColor } from "../../../common/color/compute-color"; import { hsv2rgb, rgb2hex, rgb2hsv } from "../../../common/color/convert-color"; import { DOMAINS_TOGGLE } from "../../../common/const"; import { computeDomain } from "../../../common/entity/compute_domain"; -import { computeStateDisplay } from "../../../common/entity/compute_state_display"; import { stateActive } from "../../../common/entity/state_active"; import { stateColorCss } from "../../../common/entity/state_color"; import { stateIconPath } from "../../../common/entity/state_icon_path"; @@ -234,13 +233,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard { } } - const stateDisplay = computeStateDisplay( - this.hass!.localize, - stateObj, - this.hass!.locale, - this.hass!.config, - this.hass!.entities - ); + const stateDisplay = this.hass!.formatEntityState(stateObj); if (domain === "cover") { const positionStateDisplay = computeCoverPositionStateDisplay( diff --git a/src/panels/lovelace/special-rows/hui-attribute-row.ts b/src/panels/lovelace/special-rows/hui-attribute-row.ts index b8db4ebf56..400eac4edc 100644 --- a/src/panels/lovelace/special-rows/hui-attribute-row.ts +++ b/src/panels/lovelace/special-rows/hui-attribute-row.ts @@ -1,20 +1,20 @@ import { - css, CSSResultGroup, - html, LitElement, PropertyValues, + css, + html, nothing, } from "lit"; import { customElement, property, state } from "lit/decorators"; import checkValidDate from "../../../common/datetime/check_valid_date"; +import "../../../components/ha-attribute-value"; import { HomeAssistant } from "../../../types"; import { hasConfigOrEntityChanged } from "../common/has-changed"; import "../components/hui-generic-entity-row"; import "../components/hui-timestamp-display"; import { createEntityNotFoundWarning } from "../components/hui-warning"; import { AttributeRowConfig, LovelaceRow } from "../entity-rows/types"; -import { computeAttributeValueDisplay } from "../../../common/entity/compute_attribute_display"; @customElement("hui-attribute-row") class HuiAttributeRow extends LitElement implements LovelaceRow { @@ -71,15 +71,14 @@ class HuiAttributeRow extends LitElement implements LovelaceRow { capitalize >` : attribute !== undefined - ? computeAttributeValueDisplay( - this.hass.localize, - stateObj, - this.hass.locale, - this.hass.config, - this.hass.entities, - this._config.attribute, - attribute - ) + ? html` + + + ` : "—"} ${this._config.suffix} diff --git a/src/state/connection-mixin.ts b/src/state/connection-mixin.ts index ac5b27773f..66c3f211ba 100644 --- a/src/state/connection-mixin.ts +++ b/src/state/connection-mixin.ts @@ -19,9 +19,9 @@ import { forwardHaptic } from "../data/haptics"; import { DEFAULT_PANEL } from "../data/panel"; import { serviceCallWillDisconnect } from "../data/service"; import { + DateFormat, FirstWeekday, NumberFormat, - DateFormat, TimeFormat, TimeZone, } from "../data/translation"; @@ -176,6 +176,11 @@ export const connectionMixin = >( loadFragmentTranslation: (fragment) => // @ts-ignore this._loadFragmentTranslations(this.hass?.language, fragment), + formatEntityState: (stateObj, state) => + (state !== null ? state : stateObj.state) ?? "", + formatEntityAttributeName: (_stateObj, attribute) => attribute, + formatEntityAttributeValue: (stateObj, attribute, value) => + value !== null ? value : stateObj.attributes[attribute] ?? "", ...getState(), ...this._pendingHass, }; diff --git a/src/state/hass-element.ts b/src/state/hass-element.ts index e017d5c3ef..bd2bc0d4a8 100644 --- a/src/state/hass-element.ts +++ b/src/state/hass-element.ts @@ -14,6 +14,7 @@ import { panelTitleMixin } from "./panel-title-mixin"; import SidebarMixin from "./sidebar-mixin"; import ThemesMixin from "./themes-mixin"; import TranslationsMixin from "./translations-mixin"; +import StateDisplayMixin from "./state-display-mixin"; import { urlSyncMixin } from "./url-sync-mixin"; const ext = (baseClass: T, mixins): T => @@ -23,6 +24,7 @@ export class HassElement extends ext(HassBaseEl, [ AuthMixin, ThemesMixin, TranslationsMixin, + StateDisplayMixin, MoreInfoMixin, ActionMixin, SidebarMixin, diff --git a/src/state/state-display-mixin.ts b/src/state/state-display-mixin.ts new file mode 100644 index 0000000000..5f160c41e0 --- /dev/null +++ b/src/state/state-display-mixin.ts @@ -0,0 +1,52 @@ +import { computeFormatFunctions } from "../common/translations/entity-state"; +import { Constructor, HomeAssistant } from "../types"; +import { HassBaseEl } from "./hass-base-mixin"; + +export default >(superClass: T) => { + class StateDisplayMixin extends superClass { + protected hassConnected() { + super.hassConnected(); + this._updateStateDisplay(); + } + + protected willUpdate(changedProps) { + super.willUpdate(changedProps); + + if (!changedProps.has("hass")) { + return; + } + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + + if (this.hass) { + if ( + this.hass.localize !== oldHass?.localize || + this.hass.locale !== oldHass.locale || + this.hass.config !== oldHass.config || + this.hass.entities !== oldHass.entities + ) { + this._updateStateDisplay(); + } + } + } + + private _updateStateDisplay = async () => { + if (!this.hass) return; + const { + formatEntityState, + formatEntityAttributeName, + formatEntityAttributeValue, + } = await computeFormatFunctions( + this.hass.localize, + this.hass.locale, + this.hass.config, + this.hass.entities + ); + this._updateHass({ + formatEntityState, + formatEntityAttributeName, + formatEntityAttributeValue, + }); + }; + } + return StateDisplayMixin; +}; diff --git a/src/types.ts b/src/types.ts index bfdf9768e2..7547981ab5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,7 @@ import { Connection, HassConfig, HassEntities, + HassEntity, HassServices, HassServiceTarget, MessageBase, @@ -256,6 +257,13 @@ export interface HomeAssistant { configFlow?: Parameters[4] ): Promise; loadFragmentTranslation(fragment: string): Promise; + formatEntityState(stateObj: HassEntity, state?: string): string; + formatEntityAttributeValue( + stateObj: HassEntity, + attribute: string, + value?: string + ): string; + formatEntityAttributeName(stateObj: HassEntity, attribute: string): string; } export interface Route {