From eb552530e2514a51c995a4e750e8ceff8684342b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 21 Jun 2023 17:46:40 +0200 Subject: [PATCH] Add support for image entity (#16877) --- src/common/const.ts | 2 + src/common/entity/compute_state_display.ts | 4 +- src/components/ha-picture-upload.ts | 2 +- src/data/image.ts | 59 ++----- src/data/image_upload.ts | 54 ++++++ src/dialogs/more-info/const.ts | 1 + .../more-info/controls/more-info-image.ts | 40 +++++ .../more-info/state_more_info_control.ts | 1 + src/panels/lovelace/cards/hui-picture-card.ts | 59 +++++-- .../cards/hui-picture-elements-card.ts | 15 +- .../lovelace/cards/hui-picture-entity-card.ts | 11 +- .../lovelace/cards/hui-picture-glance-card.ts | 50 ++++-- src/panels/lovelace/cards/types.ts | 2 + .../common/generate-lovelace-config.ts | 7 + src/panels/lovelace/common/handle-action.ts | 7 +- src/panels/lovelace/components/hui-image.ts | 4 + .../hui-picture-card-editor.ts | 160 +++++------------- .../hui-picture-glance-card-editor.ts | 2 + .../lovelace/elements/hui-image-element.ts | 7 +- src/panels/lovelace/elements/types.ts | 1 + src/translations/en.json | 1 + 21 files changed, 287 insertions(+), 202 deletions(-) create mode 100644 src/data/image_upload.ts create mode 100644 src/dialogs/more-info/controls/more-info-image.ts diff --git a/src/common/const.ts b/src/common/const.ts index 35bd81a3e2..76275f55a2 100644 --- a/src/common/const.ts +++ b/src/common/const.ts @@ -33,6 +33,7 @@ import { mdiGoogleCirclesCommunities, mdiHomeAssistant, mdiHomeAutomation, + mdiImage, mdiImageFilterFrames, mdiLightbulb, mdiLightningBolt, @@ -90,6 +91,7 @@ export const FIXED_DOMAIN_ICONS = { group: mdiGoogleCirclesCommunities, homeassistant: mdiHomeAssistant, homekit: mdiHomeAutomation, + image: mdiImage, image_processing: mdiImageFilterFrames, input_button: mdiGestureTapButton, input_datetime: mdiCalendarClock, diff --git a/src/common/entity/compute_state_display.ts b/src/common/entity/compute_state_display.ts index 34db8700d1..9c768bd55e 100644 --- a/src/common/entity/compute_state_display.ts +++ b/src/common/entity/compute_state_display.ts @@ -191,7 +191,9 @@ export const computeStateDisplayFromEntityAttributes = ( // state is a timestamp if ( - ["button", "input_button", "scene", "stt", "tts"].includes(domain) || + ["button", "image", "input_button", "scene", "stt", "tts"].includes( + domain + ) || (domain === "sensor" && attributes.device_class === "timestamp") ) { try { diff --git a/src/components/ha-picture-upload.ts b/src/components/ha-picture-upload.ts index 15d2fef7ae..0a21f82ec7 100644 --- a/src/components/ha-picture-upload.ts +++ b/src/components/ha-picture-upload.ts @@ -2,7 +2,7 @@ import { mdiImagePlus } from "@mdi/js"; import { html, LitElement, TemplateResult } from "lit"; import { customElement, property, state } from "lit/decorators"; import { fireEvent } from "../common/dom/fire_event"; -import { createImage, generateImageThumbnailUrl } from "../data/image"; +import { createImage, generateImageThumbnailUrl } from "../data/image_upload"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { CropOptions, diff --git a/src/data/image.ts b/src/data/image.ts index 94198de6dc..f28ea74860 100644 --- a/src/data/image.ts +++ b/src/data/image.ts @@ -1,54 +1,15 @@ -import { HomeAssistant } from "../types"; +import { + HassEntityAttributeBase, + HassEntityBase, +} from "home-assistant-js-websocket"; -interface Image { - filesize: number; - name: string; - uploaded_at: string; // isoformat date - content_type: string; - id: string; +interface ImageEntityAttributes extends HassEntityAttributeBase { + access_token: string; } -export interface ImageMutableParams { - name: string; +export interface ImageEntity extends HassEntityBase { + attributes: ImageEntityAttributes; } -export const generateImageThumbnailUrl = (mediaId: string, size: number) => - `/api/image/serve/${mediaId}/${size}x${size}`; - -export const fetchImages = (hass: HomeAssistant) => - hass.callWS({ type: "image/list" }); - -export const createImage = async ( - hass: HomeAssistant, - file: File -): Promise => { - const fd = new FormData(); - fd.append("file", file); - const resp = await hass.fetchWithAuth("/api/image/upload", { - method: "POST", - body: fd, - }); - if (resp.status === 413) { - throw new Error(`Uploaded image is too large (${file.name})`); - } else if (resp.status !== 200) { - throw new Error("Unknown error"); - } - return resp.json(); -}; - -export const updateImage = ( - hass: HomeAssistant, - id: string, - updates: Partial -) => - hass.callWS({ - type: "image/update", - media_id: id, - ...updates, - }); - -export const deleteImage = (hass: HomeAssistant, id: string) => - hass.callWS({ - type: "image/delete", - media_id: id, - }); +export const computeImageUrl = (entity: ImageEntity): string => + `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`; diff --git a/src/data/image_upload.ts b/src/data/image_upload.ts new file mode 100644 index 0000000000..94198de6dc --- /dev/null +++ b/src/data/image_upload.ts @@ -0,0 +1,54 @@ +import { HomeAssistant } from "../types"; + +interface Image { + filesize: number; + name: string; + uploaded_at: string; // isoformat date + content_type: string; + id: string; +} + +export interface ImageMutableParams { + name: string; +} + +export const generateImageThumbnailUrl = (mediaId: string, size: number) => + `/api/image/serve/${mediaId}/${size}x${size}`; + +export const fetchImages = (hass: HomeAssistant) => + hass.callWS({ type: "image/list" }); + +export const createImage = async ( + hass: HomeAssistant, + file: File +): Promise => { + const fd = new FormData(); + fd.append("file", file); + const resp = await hass.fetchWithAuth("/api/image/upload", { + method: "POST", + body: fd, + }); + if (resp.status === 413) { + throw new Error(`Uploaded image is too large (${file.name})`); + } else if (resp.status !== 200) { + throw new Error("Unknown error"); + } + return resp.json(); +}; + +export const updateImage = ( + hass: HomeAssistant, + id: string, + updates: Partial +) => + hass.callWS({ + type: "image/update", + media_id: id, + ...updates, + }); + +export const deleteImage = (hass: HomeAssistant, id: string) => + hass.callWS({ + type: "image/delete", + media_id: id, + }); diff --git a/src/dialogs/more-info/const.ts b/src/dialogs/more-info/const.ts index 79af1e13d3..d6d6d615aa 100644 --- a/src/dialogs/more-info/const.ts +++ b/src/dialogs/more-info/const.ts @@ -40,6 +40,7 @@ export const DOMAINS_WITH_MORE_INFO = [ "fan", "group", "humidifier", + "image", "input_boolean", "input_datetime", "light", diff --git a/src/dialogs/more-info/controls/more-info-image.ts b/src/dialogs/more-info/controls/more-info-image.ts new file mode 100644 index 0000000000..d54ed0cf43 --- /dev/null +++ b/src/dialogs/more-info/controls/more-info-image.ts @@ -0,0 +1,40 @@ +import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; +import "../../../components/ha-camera-stream"; +import { computeImageUrl, ImageEntity } from "../../../data/image"; +import type { HomeAssistant } from "../../../types"; + +@customElement("more-info-image") +class MoreInfoImage extends LitElement { + @property({ attribute: false }) public hass?: HomeAssistant; + + @property({ attribute: false }) public stateObj?: ImageEntity; + + protected render() { + if (!this.hass || !this.stateObj) { + return nothing; + } + return html`${this.stateObj.attributes.friendly_name `; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + text-align: center; + } + img { + max-width: 100%; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "more-info-image": MoreInfoImage; + } +} diff --git a/src/dialogs/more-info/state_more_info_control.ts b/src/dialogs/more-info/state_more_info_control.ts index 7c85f0db71..cc0d07eb67 100644 --- a/src/dialogs/more-info/state_more_info_control.ts +++ b/src/dialogs/more-info/state_more_info_control.ts @@ -18,6 +18,7 @@ const LAZY_LOADED_MORE_INFO_CONTROL = { fan: () => import("./controls/more-info-fan"), group: () => import("./controls/more-info-group"), humidifier: () => import("./controls/more-info-humidifier"), + image: () => import("./controls/more-info-image"), input_boolean: () => import("./controls/more-info-input_boolean"), input_datetime: () => import("./controls/more-info-input_datetime"), light: () => import("./controls/more-info-light"), diff --git a/src/panels/lovelace/cards/hui-picture-card.ts b/src/panels/lovelace/cards/hui-picture-card.ts index c72bf72bc9..843d447a67 100644 --- a/src/panels/lovelace/cards/hui-picture-card.ts +++ b/src/panels/lovelace/cards/hui-picture-card.ts @@ -3,19 +3,22 @@ import { CSSResultGroup, html, LitElement, - PropertyValues, nothing, + PropertyValues, } from "lit"; import { customElement, property } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { ifDefined } from "lit/directives/if-defined"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import "../../../components/ha-card"; +import { computeImageUrl, ImageEntity } from "../../../data/image"; import { ActionHandlerEvent } from "../../../data/lovelace"; import { HomeAssistant } from "../../../types"; import { actionHandler } from "../common/directives/action-handler-directive"; import { handleAction } from "../common/handle-action"; import { hasAction } from "../common/has-action"; +import { hasConfigChanged } from "../common/has-changed"; +import { createEntityNotFoundWarning } from "../components/hui-warning"; import { LovelaceCard, LovelaceCardEditor } from "../types"; import { PictureCardConfig } from "./types"; @@ -30,8 +33,6 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { return { type: "picture", image: "https://demo.home-assistant.io/stub_config/t-shirt-promo.png", - tap_action: { action: "none" }, - hold_action: { action: "none" }, }; } @@ -44,7 +45,7 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { } public setConfig(config: PictureCardConfig): void { - if (!config || !config.image) { + if (!config || (!config.image && !config.image_entity)) { throw new Error("Image required"); } @@ -52,10 +53,21 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { } protected shouldUpdate(changedProps: PropertyValues): boolean { - if (changedProps.size === 1 && changedProps.has("hass")) { - return !changedProps.get("hass"); + if (!this._config || hasConfigChanged(this, changedProps)) { + return true; } - return true; + if (this._config.image_entity && changedProps.has("hass")) { + const oldHass = changedProps.get("hass") as HomeAssistant | undefined; + if ( + !oldHass || + oldHass.states[this._config.image_entity] !== + this.hass!.states[this._config.image_entity] + ) { + return true; + } + } + + return false; } protected updated(changedProps: PropertyValues): void { @@ -83,6 +95,17 @@ export class HuiPictureCard extends LitElement implements LovelaceCard { return nothing; } + let stateObj: ImageEntity | undefined; + + if (this._config.image_entity) { + stateObj = this.hass.states[this._config.image_entity] as ImageEntity; + if (!stateObj) { + return html` + ${createEntityNotFoundWarning(this.hass, this._config.image_entity)} + `; + } + } + return html` ${this._config.alt_text} `; diff --git a/src/panels/lovelace/cards/hui-picture-elements-card.ts b/src/panels/lovelace/cards/hui-picture-elements-card.ts index fad22f2c66..15c7f02e1f 100644 --- a/src/panels/lovelace/cards/hui-picture-elements-card.ts +++ b/src/panels/lovelace/cards/hui-picture-elements-card.ts @@ -9,6 +9,7 @@ import { import { customElement, property, state } from "lit/decorators"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import "../../../components/ha-card"; +import { ImageEntity, computeImageUrl } from "../../../data/image"; import { HomeAssistant } from "../../../types"; import { findEntities } from "../common/find-entities"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; @@ -62,7 +63,12 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard { if (!config) { throw new Error("Invalid configuration"); } else if ( - !(config.image || config.camera_image || config.state_image) || + !( + config.image || + config.image_entity || + config.camera_image || + config.state_image + ) || (config.state_image && !config.entity) ) { throw new Error("Image required"); @@ -115,12 +121,17 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard { return nothing; } + let stateObj: ImageEntity | undefined; + if (this._config.image_entity) { + stateObj = this.hass.states[this._config.image_entity] as ImageEntity; + } + return html`
${entityState}
`; } + const domain = computeDomain(this._config.entity); + return html`
${this._config.title - ? html`
${this._config.title}
` + ? html`
${this._config.title}
` : ""}
${this._entitiesDialog!.map((entityConf) => diff --git a/src/panels/lovelace/cards/types.ts b/src/panels/lovelace/cards/types.ts index fac076ad93..082b6c4b53 100644 --- a/src/panels/lovelace/cards/types.ts +++ b/src/panels/lovelace/cards/types.ts @@ -335,6 +335,7 @@ export interface StatisticCardConfig extends LovelaceCardConfig { export interface PictureCardConfig extends LovelaceCardConfig { image?: string; + image_entity?: string; tap_action?: ActionConfig; hold_action?: ActionConfig; double_tap_action?: ActionConfig; @@ -345,6 +346,7 @@ export interface PictureCardConfig extends LovelaceCardConfig { export interface PictureElementsCardConfig extends LovelaceCardConfig { title?: string; image?: string; + image_entity?: string; camera_image?: string; camera_view?: HuiImage["cameraView"]; state_image?: Record; diff --git a/src/panels/lovelace/common/generate-lovelace-config.ts b/src/panels/lovelace/common/generate-lovelace-config.ts index 99bfbdc634..65b66c3ce1 100644 --- a/src/panels/lovelace/common/generate-lovelace-config.ts +++ b/src/panels/lovelace/common/generate-lovelace-config.ts @@ -20,6 +20,7 @@ import { AlarmPanelCardConfig, EntitiesCardConfig, HumidifierCardConfig, + PictureCardConfig, PictureEntityCardConfig, ThermostatCardConfig, } from "../cards/types"; @@ -125,6 +126,12 @@ export const computeCards = ( entity: entityId, }; cards.push(cardConfig); + } else if (domain === "image") { + const cardConfig: PictureCardConfig = { + type: "picture", + image_entity: entityId, + }; + cards.push(cardConfig); } else if (domain === "climate") { const cardConfig: ThermostatCardConfig = { type: "thermostat", diff --git a/src/panels/lovelace/common/handle-action.ts b/src/panels/lovelace/common/handle-action.ts index f782d6ad53..83deadf5db 100644 --- a/src/panels/lovelace/common/handle-action.ts +++ b/src/panels/lovelace/common/handle-action.ts @@ -18,6 +18,7 @@ declare global { export type ActionConfigParams = { entity?: string; camera_image?: string; + image_entity?: string; hold_action?: ActionConfig; tap_action?: ActionConfig; double_tap_action?: ActionConfig; @@ -87,9 +88,11 @@ export const handleAction = async ( switch (actionConfig.action) { case "more-info": { - if (config.entity || config.camera_image) { + if (config.entity || config.camera_image || config.image_entity) { fireEvent(node, "hass-more-info", { - entityId: config.entity ? config.entity : config.camera_image!, + entityId: (config.entity || + config.camera_image || + config.image_entity)!, }); } else { showToast(node, { diff --git a/src/panels/lovelace/components/hui-image.ts b/src/panels/lovelace/components/hui-image.ts index 56c0873608..33201fd7af 100644 --- a/src/panels/lovelace/components/hui-image.ts +++ b/src/panels/lovelace/components/hui-image.ts @@ -10,12 +10,14 @@ import { customElement, property, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import { styleMap } from "lit/directives/style-map"; import { STATES_OFF } from "../../../common/const"; +import { computeDomain } from "../../../common/entity/compute_domain"; import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; import "../../../components/ha-camera-stream"; import type { HaCameraStream } from "../../../components/ha-camera-stream"; import "../../../components/ha-circular-progress"; import { CameraEntity, fetchThumbnailUrlWithCache } from "../../../data/camera"; import { UNAVAILABLE } from "../../../data/entity"; +import { computeImageUrl, ImageEntity } from "../../../data/image"; import { HomeAssistant } from "../../../types"; const UPDATE_INTERVAL = 10000; @@ -164,6 +166,8 @@ export class HuiImage extends LitElement { } } else if (this.darkModeImage && this.hass.themes.darkMode) { imageSrc = this.darkModeImage; + } else if (stateObj && computeDomain(stateObj.entity_id) === "image") { + imageSrc = computeImageUrl(stateObj as ImageEntity); } else { imageSrc = this.image; } diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts index 29e001688d..1ce62d6a78 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-card-editor.ts @@ -1,22 +1,21 @@ -import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; +import { html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { assert, assign, object, optional, string } from "superstruct"; import { fireEvent } from "../../../../common/dom/fire_event"; +import { SchemaUnion } from "../../../../components/ha-form/types"; import "../../../../components/ha-theme-picker"; -import { ActionConfig } from "../../../../data/lovelace"; import { HomeAssistant } from "../../../../types"; import { PictureCardConfig } from "../../cards/types"; import "../../components/hui-action-editor"; import { LovelaceCardEditor } from "../../types"; import { actionConfigStruct } from "../structs/action-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct"; -import { EditorTarget } from "../types"; -import { configElementStyle } from "./config-elements-style"; const cardConfigStruct = assign( baseLovelaceCardConfig, object({ image: optional(string()), + image_entity: optional(string()), tap_action: optional(actionConfigStruct), hold_action: optional(actionConfigStruct), theme: optional(string()), @@ -24,6 +23,21 @@ const cardConfigStruct = assign( }) ); +const SCHEMA = [ + { name: "image", selector: { text: {} } }, + { name: "image_entity", selector: { entity: { domain: "image" } } }, + { name: "alt_text", selector: { text: {} } }, + { name: "theme", selector: { theme: {} } }, + { + name: "tap_action", + selector: { ui_action: {} }, + }, + { + name: "hold_action", + selector: { ui_action: {} }, + }, +] as const; + @customElement("hui-picture-card-editor") export class HuiPictureCardEditor extends LitElement @@ -38,129 +52,45 @@ export class HuiPictureCardEditor this._config = config; } - get _image(): string { - return this._config!.image || ""; - } - - get _tap_action(): ActionConfig { - return this._config!.tap_action || { action: "none" }; - } - - get _hold_action(): ActionConfig { - return this._config!.hold_action || { action: "none" }; - } - - get _theme(): string { - return this._config!.theme || ""; - } - - get _alt_text(): string { - return this._config!.alt_text || ""; - } - protected render() { if (!this.hass || !this._config) { return nothing; } - const actions = ["navigate", "url", "call-service", "none"]; - return html` -
- - - - - -
+ `; } private _valueChanged(ev: CustomEvent): void { - if (!this._config || !this.hass) { - return; - } - const target = ev.target! as EditorTarget; - const value = ev.detail?.value ?? target.value; - - if (this[`_${target.configValue}`] === value) { - return; - } - if (target.configValue) { - if (value !== false && !value) { - this._config = { ...this._config }; - delete this._config[target.configValue!]; - } else { - this._config = { - ...this._config, - [target.configValue!]: value, - }; - } - } - fireEvent(this, "config-changed", { config: this._config }); + fireEvent(this, "config-changed", { config: ev.detail.value }); } - static get styles(): CSSResultGroup { - return [ - configElementStyle, - css` - ha-textfield { - display: block; - margin-bottom: 8px; - } - `, - ]; - } + private _computeLabelCallback = (schema: SchemaUnion) => { + switch (schema.name) { + case "theme": + return `${this.hass!.localize( + "ui.panel.lovelace.editor.card.generic.theme" + )} (${this.hass!.localize( + "ui.panel.lovelace.editor.card.config.optional" + )})`; + default: + return ( + this.hass!.localize( + `ui.panel.lovelace.editor.card.picture-card.${schema.name}` + ) || + this.hass!.localize( + `ui.panel.lovelace.editor.card.generic.${schema.name}` + ) + ); + } + }; } declare global { diff --git a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts index 76c0634698..c6c3d12146 100644 --- a/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts +++ b/src/panels/lovelace/editor/config-elements/hui-picture-glance-card-editor.ts @@ -22,6 +22,7 @@ const cardConfigStruct = assign( title: optional(string()), entity: optional(string()), image: optional(string()), + image_entity: optional(string()), camera_image: optional(string()), camera_view: optional(string()), aspect_ratio: optional(string()), @@ -35,6 +36,7 @@ const cardConfigStruct = assign( const SCHEMA = [ { name: "title", selector: { text: {} } }, { name: "image", selector: { text: {} } }, + { name: "image_entity", selector: { entity: { domain: "image" } } }, { name: "camera_image", selector: { entity: { domain: "camera" } } }, { name: "", diff --git a/src/panels/lovelace/elements/hui-image-element.ts b/src/panels/lovelace/elements/hui-image-element.ts index e54e23fb68..e86058a183 100644 --- a/src/panels/lovelace/elements/hui-image-element.ts +++ b/src/panels/lovelace/elements/hui-image-element.ts @@ -1,6 +1,7 @@ import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import { ifDefined } from "lit/directives/if-defined"; +import { ImageEntity, computeImageUrl } from "../../../data/image"; import { ActionHandlerEvent } from "../../../data/lovelace"; import { HomeAssistant } from "../../../types"; import { computeTooltip } from "../common/compute-tooltip"; @@ -34,12 +35,16 @@ export class HuiImageElement extends LitElement implements LovelaceElement { if (!this._config || !this.hass) { return nothing; } + let stateObj: ImageEntity | undefined; + if (this._config.image_entity) { + stateObj = this.hass.states[this._config.image_entity] as ImageEntity; + } return html`