Add support for image entity (#16877)

This commit is contained in:
Bram Kragten 2023-06-21 17:46:40 +02:00 committed by GitHub
parent 1fe5d66a68
commit eb552530e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 287 additions and 202 deletions

View File

@ -33,6 +33,7 @@ import {
mdiGoogleCirclesCommunities, mdiGoogleCirclesCommunities,
mdiHomeAssistant, mdiHomeAssistant,
mdiHomeAutomation, mdiHomeAutomation,
mdiImage,
mdiImageFilterFrames, mdiImageFilterFrames,
mdiLightbulb, mdiLightbulb,
mdiLightningBolt, mdiLightningBolt,
@ -90,6 +91,7 @@ export const FIXED_DOMAIN_ICONS = {
group: mdiGoogleCirclesCommunities, group: mdiGoogleCirclesCommunities,
homeassistant: mdiHomeAssistant, homeassistant: mdiHomeAssistant,
homekit: mdiHomeAutomation, homekit: mdiHomeAutomation,
image: mdiImage,
image_processing: mdiImageFilterFrames, image_processing: mdiImageFilterFrames,
input_button: mdiGestureTapButton, input_button: mdiGestureTapButton,
input_datetime: mdiCalendarClock, input_datetime: mdiCalendarClock,

View File

@ -191,7 +191,9 @@ export const computeStateDisplayFromEntityAttributes = (
// state is a timestamp // state is a timestamp
if ( if (
["button", "input_button", "scene", "stt", "tts"].includes(domain) || ["button", "image", "input_button", "scene", "stt", "tts"].includes(
domain
) ||
(domain === "sensor" && attributes.device_class === "timestamp") (domain === "sensor" && attributes.device_class === "timestamp")
) { ) {
try { try {

View File

@ -2,7 +2,7 @@ import { mdiImagePlus } from "@mdi/js";
import { html, LitElement, TemplateResult } from "lit"; import { html, LitElement, TemplateResult } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event"; 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 { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { import {
CropOptions, CropOptions,

View File

@ -1,54 +1,15 @@
import { HomeAssistant } from "../types"; import {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
interface Image { interface ImageEntityAttributes extends HassEntityAttributeBase {
filesize: number; access_token: string;
name: string;
uploaded_at: string; // isoformat date
content_type: string;
id: string;
} }
export interface ImageMutableParams { export interface ImageEntity extends HassEntityBase {
name: string; attributes: ImageEntityAttributes;
} }
export const generateImageThumbnailUrl = (mediaId: string, size: number) => export const computeImageUrl = (entity: ImageEntity): string =>
`/api/image/serve/${mediaId}/${size}x${size}`; `/api/image_proxy/${entity.entity_id}?token=${entity.attributes.access_token}&state=${entity.state}`;
export const fetchImages = (hass: HomeAssistant) =>
hass.callWS<Image[]>({ type: "image/list" });
export const createImage = async (
hass: HomeAssistant,
file: File
): Promise<Image> => {
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<ImageMutableParams>
) =>
hass.callWS<Image>({
type: "image/update",
media_id: id,
...updates,
});
export const deleteImage = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "image/delete",
media_id: id,
});

54
src/data/image_upload.ts Normal file
View File

@ -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<Image[]>({ type: "image/list" });
export const createImage = async (
hass: HomeAssistant,
file: File
): Promise<Image> => {
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<ImageMutableParams>
) =>
hass.callWS<Image>({
type: "image/update",
media_id: id,
...updates,
});
export const deleteImage = (hass: HomeAssistant, id: string) =>
hass.callWS({
type: "image/delete",
media_id: id,
});

View File

@ -40,6 +40,7 @@ export const DOMAINS_WITH_MORE_INFO = [
"fan", "fan",
"group", "group",
"humidifier", "humidifier",
"image",
"input_boolean", "input_boolean",
"input_datetime", "input_datetime",
"light", "light",

View File

@ -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`<img
alt=${this.stateObj.attributes.friendly_name || this.stateObj.entity_id}
src=${this.hass.hassUrl(computeImageUrl(this.stateObj))}
/> `;
}
static get styles(): CSSResultGroup {
return css`
:host {
display: block;
text-align: center;
}
img {
max-width: 100%;
}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"more-info-image": MoreInfoImage;
}
}

View File

@ -18,6 +18,7 @@ const LAZY_LOADED_MORE_INFO_CONTROL = {
fan: () => import("./controls/more-info-fan"), fan: () => import("./controls/more-info-fan"),
group: () => import("./controls/more-info-group"), group: () => import("./controls/more-info-group"),
humidifier: () => import("./controls/more-info-humidifier"), humidifier: () => import("./controls/more-info-humidifier"),
image: () => import("./controls/more-info-image"),
input_boolean: () => import("./controls/more-info-input_boolean"), input_boolean: () => import("./controls/more-info-input_boolean"),
input_datetime: () => import("./controls/more-info-input_datetime"), input_datetime: () => import("./controls/more-info-input_datetime"),
light: () => import("./controls/more-info-light"), light: () => import("./controls/more-info-light"),

View File

@ -3,19 +3,22 @@ import {
CSSResultGroup, CSSResultGroup,
html, html,
LitElement, LitElement,
PropertyValues,
nothing, nothing,
PropertyValues,
} from "lit"; } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
import { handleAction } from "../common/handle-action"; import { handleAction } from "../common/handle-action";
import { hasAction } from "../common/has-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 { LovelaceCard, LovelaceCardEditor } from "../types";
import { PictureCardConfig } from "./types"; import { PictureCardConfig } from "./types";
@ -30,8 +33,6 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
return { return {
type: "picture", type: "picture",
image: "https://demo.home-assistant.io/stub_config/t-shirt-promo.png", 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 { public setConfig(config: PictureCardConfig): void {
if (!config || !config.image) { if (!config || (!config.image && !config.image_entity)) {
throw new Error("Image required"); throw new Error("Image required");
} }
@ -52,10 +53,21 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
} }
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
if (changedProps.size === 1 && changedProps.has("hass")) { if (!this._config || hasConfigChanged(this, changedProps)) {
return !changedProps.get("hass"); 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 { protected updated(changedProps: PropertyValues): void {
@ -83,6 +95,17 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
return nothing; 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`<hui-warning>
${createEntityNotFoundWarning(this.hass, this._config.image_entity)}
</hui-warning>`;
}
}
return html` return html`
<ha-card <ha-card
@action=${this._handleAction} @action=${this._handleAction}
@ -91,19 +114,29 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
hasDoubleClick: hasAction(this._config!.double_tap_action), hasDoubleClick: hasAction(this._config!.double_tap_action),
})} })}
tabindex=${ifDefined( tabindex=${ifDefined(
hasAction(this._config.tap_action) ? "0" : undefined hasAction(this._config.tap_action) || this._config.image_entity
? "0"
: undefined
)} )}
class=${classMap({ class=${classMap({
clickable: Boolean( clickable: Boolean(
this._config.tap_action || (this._config.image_entity && !this._config.tap_action) ||
this._config.hold_action || (this._config.tap_action &&
this._config.double_tap_action this._config.tap_action.action !== "none") ||
(this._config.hold_action &&
this._config.hold_action.action !== "none") ||
(this._config.double_tap_action &&
this._config.double_tap_action.action !== "none")
), ),
})} })}
> >
<img <img
alt=${this._config.alt_text} alt=${ifDefined(
src=${this.hass.hassUrl(this._config.image)} this._config.alt_text || stateObj?.attributes.friendly_name
)}
src=${this.hass.hassUrl(
stateObj ? computeImageUrl(stateObj) : this._config.image
)}
/> />
</ha-card> </ha-card>
`; `;

View File

@ -9,6 +9,7 @@ import {
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element"; import { applyThemesOnElement } from "../../../common/dom/apply_themes_on_element";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { ImageEntity, computeImageUrl } from "../../../data/image";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { findEntities } from "../common/find-entities"; import { findEntities } from "../common/find-entities";
import { LovelaceElement, LovelaceElementConfig } from "../elements/types"; import { LovelaceElement, LovelaceElementConfig } from "../elements/types";
@ -62,7 +63,12 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
if (!config) { if (!config) {
throw new Error("Invalid configuration"); throw new Error("Invalid configuration");
} else if ( } 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) (config.state_image && !config.entity)
) { ) {
throw new Error("Image required"); throw new Error("Image required");
@ -115,12 +121,17 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined;
if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity;
}
return html` return html`
<ha-card .header=${this._config.title}> <ha-card .header=${this._config.title}>
<div id="root"> <div id="root">
<hui-image <hui-image
.hass=${this.hass} .hass=${this.hass}
.image=${this._config.image} .image=${stateObj ? computeImageUrl(stateObj) : this._config.image}
.stateImage=${this._config.state_image} .stateImage=${this._config.state_image}
.stateFilter=${this._config.state_filter} .stateFilter=${this._config.state_filter}
.cameraImage=${this._config.camera_image} .cameraImage=${this._config.camera_image}

View File

@ -14,6 +14,7 @@ import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDisplay } from "../../../common/entity/compute_state_display"; import { computeStateDisplay } from "../../../common/entity/compute_state_display";
import { computeStateName } from "../../../common/entity/compute_state_name"; import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
@ -68,7 +69,7 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
} }
if ( if (
computeDomain(config.entity) !== "camera" && !["camera", "image"].includes(computeDomain(config.entity)) &&
!config.image && !config.image &&
!config.state_image && !config.state_image &&
!config.camera_image !config.camera_image
@ -141,14 +142,18 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
footer = html`<div class="footer single">${entityState}</div>`; footer = html`<div class="footer single">${entityState}</div>`;
} }
const domain = computeDomain(this._config.entity);
return html` return html`
<ha-card> <ha-card>
<hui-image <hui-image
.hass=${this.hass} .hass=${this.hass}
.image=${this._config.image} .image=${domain === "image"
? computeImageUrl(stateObj as ImageEntity)
: this._config.image}
.stateImage=${this._config.state_image} .stateImage=${this._config.state_image}
.stateFilter=${this._config.state_filter} .stateFilter=${this._config.state_filter}
.cameraImage=${computeDomain(this._config.entity) === "camera" .cameraImage=${domain === "camera"
? this._config.entity ? this._config.entity
: this._config.camera_image} : this._config.camera_image}
.cameraView=${this._config.camera_view} .cameraView=${this._config.camera_view}

View File

@ -3,9 +3,9 @@ import {
CSSResultGroup, CSSResultGroup,
html, html,
LitElement, LitElement,
nothing,
PropertyValues, PropertyValues,
TemplateResult, TemplateResult,
nothing,
} from "lit"; } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
@ -18,6 +18,7 @@ import { computeStateName } from "../../../common/entity/compute_state_name";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-icon-button"; import "../../../components/ha-icon-button";
import "../../../components/ha-state-icon"; import "../../../components/ha-state-icon";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { actionHandler } from "../common/directives/action-handler-directive"; import { actionHandler } from "../common/directives/action-handler-directive";
@ -63,7 +64,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
}; };
} }
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@state() private _config?: PictureGlanceCardConfig; @state() private _config?: PictureGlanceCardConfig;
@ -80,7 +81,12 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
!config || !config ||
!config.entities || !config.entities ||
!Array.isArray(config.entities) || !Array.isArray(config.entities) ||
!(config.image || config.camera_image || config.state_image) || !(
config.image ||
config.image_entity ||
config.camera_image ||
config.state_image
) ||
(config.state_image && !config.entity) (config.state_image && !config.entity)
) { ) {
throw new Error("Invalid configuration"); throw new Error("Invalid configuration");
@ -108,25 +114,35 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
} }
protected shouldUpdate(changedProps: PropertyValues): boolean { protected shouldUpdate(changedProps: PropertyValues): boolean {
if (hasConfigOrEntityChanged(this, changedProps)) { if (!this._config || hasConfigOrEntityChanged(this, changedProps)) {
return true; return true;
} }
if (!changedProps.has("hass")) {
return false;
}
const oldHass = changedProps.get("hass") as HomeAssistant | undefined; const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
if ( if (
!oldHass || !oldHass ||
oldHass.themes !== this.hass!.themes || oldHass.themes !== this.hass.themes ||
oldHass.locale !== this.hass!.locale oldHass.locale !== this.hass.locale
) {
return true;
}
if (
this._config.image_entity &&
oldHass.states[this._config.image_entity] !==
this.hass.states[this._config.image_entity]
) { ) {
return true; return true;
} }
if (this._entitiesDialog) { if (this._entitiesDialog) {
for (const entity of this._entitiesDialog) { for (const entity of this._entitiesDialog) {
if ( if (oldHass.states[entity.entity] !== this.hass.states[entity.entity]) {
oldHass!.states[entity.entity] !== this.hass!.states[entity.entity]
) {
return true; return true;
} }
} }
@ -134,9 +150,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
if (this._entitiesToggle) { if (this._entitiesToggle) {
for (const entity of this._entitiesToggle) { for (const entity of this._entitiesToggle) {
if ( if (oldHass.states[entity.entity] !== this.hass.states[entity.entity]) {
oldHass!.states[entity.entity] !== this.hass!.states[entity.entity]
) {
return true; return true;
} }
} }
@ -170,6 +184,11 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined;
if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity;
}
return html` return html`
<ha-card> <ha-card>
<hui-image <hui-image
@ -177,7 +196,8 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
clickable: Boolean( clickable: Boolean(
this._config.tap_action || this._config.tap_action ||
this._config.hold_action || this._config.hold_action ||
this._config.camera_image this._config.camera_image ||
this._config.image_entity
), ),
})} })}
@action=${this._handleAction} @action=${this._handleAction}
@ -190,7 +210,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
)} )}
.config=${this._config} .config=${this._config}
.hass=${this.hass} .hass=${this.hass}
.image=${this._config.image} .image=${stateObj ? computeImageUrl(stateObj) : this._config.image}
.stateImage=${this._config.state_image} .stateImage=${this._config.state_image}
.stateFilter=${this._config.state_filter} .stateFilter=${this._config.state_filter}
.cameraImage=${this._config.camera_image} .cameraImage=${this._config.camera_image}
@ -200,7 +220,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
></hui-image> ></hui-image>
<div class="box"> <div class="box">
${this._config.title ${this._config.title
? html` <div class="title">${this._config.title}</div> ` ? html`<div class="title">${this._config.title}</div>`
: ""} : ""}
<div class="row"> <div class="row">
${this._entitiesDialog!.map((entityConf) => ${this._entitiesDialog!.map((entityConf) =>

View File

@ -335,6 +335,7 @@ export interface StatisticCardConfig extends LovelaceCardConfig {
export interface PictureCardConfig extends LovelaceCardConfig { export interface PictureCardConfig extends LovelaceCardConfig {
image?: string; image?: string;
image_entity?: string;
tap_action?: ActionConfig; tap_action?: ActionConfig;
hold_action?: ActionConfig; hold_action?: ActionConfig;
double_tap_action?: ActionConfig; double_tap_action?: ActionConfig;
@ -345,6 +346,7 @@ export interface PictureCardConfig extends LovelaceCardConfig {
export interface PictureElementsCardConfig extends LovelaceCardConfig { export interface PictureElementsCardConfig extends LovelaceCardConfig {
title?: string; title?: string;
image?: string; image?: string;
image_entity?: string;
camera_image?: string; camera_image?: string;
camera_view?: HuiImage["cameraView"]; camera_view?: HuiImage["cameraView"];
state_image?: Record<string, unknown>; state_image?: Record<string, unknown>;

View File

@ -20,6 +20,7 @@ import {
AlarmPanelCardConfig, AlarmPanelCardConfig,
EntitiesCardConfig, EntitiesCardConfig,
HumidifierCardConfig, HumidifierCardConfig,
PictureCardConfig,
PictureEntityCardConfig, PictureEntityCardConfig,
ThermostatCardConfig, ThermostatCardConfig,
} from "../cards/types"; } from "../cards/types";
@ -125,6 +126,12 @@ export const computeCards = (
entity: entityId, entity: entityId,
}; };
cards.push(cardConfig); cards.push(cardConfig);
} else if (domain === "image") {
const cardConfig: PictureCardConfig = {
type: "picture",
image_entity: entityId,
};
cards.push(cardConfig);
} else if (domain === "climate") { } else if (domain === "climate") {
const cardConfig: ThermostatCardConfig = { const cardConfig: ThermostatCardConfig = {
type: "thermostat", type: "thermostat",

View File

@ -18,6 +18,7 @@ declare global {
export type ActionConfigParams = { export type ActionConfigParams = {
entity?: string; entity?: string;
camera_image?: string; camera_image?: string;
image_entity?: string;
hold_action?: ActionConfig; hold_action?: ActionConfig;
tap_action?: ActionConfig; tap_action?: ActionConfig;
double_tap_action?: ActionConfig; double_tap_action?: ActionConfig;
@ -87,9 +88,11 @@ export const handleAction = async (
switch (actionConfig.action) { switch (actionConfig.action) {
case "more-info": { case "more-info": {
if (config.entity || config.camera_image) { if (config.entity || config.camera_image || config.image_entity) {
fireEvent(node, "hass-more-info", { fireEvent(node, "hass-more-info", {
entityId: config.entity ? config.entity : config.camera_image!, entityId: (config.entity ||
config.camera_image ||
config.image_entity)!,
}); });
} else { } else {
showToast(node, { showToast(node, {

View File

@ -10,12 +10,14 @@ import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
import { STATES_OFF } from "../../../common/const"; import { STATES_OFF } from "../../../common/const";
import { computeDomain } from "../../../common/entity/compute_domain";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio"; import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
import "../../../components/ha-camera-stream"; import "../../../components/ha-camera-stream";
import type { HaCameraStream } from "../../../components/ha-camera-stream"; import type { HaCameraStream } from "../../../components/ha-camera-stream";
import "../../../components/ha-circular-progress"; import "../../../components/ha-circular-progress";
import { CameraEntity, fetchThumbnailUrlWithCache } from "../../../data/camera"; import { CameraEntity, fetchThumbnailUrlWithCache } from "../../../data/camera";
import { UNAVAILABLE } from "../../../data/entity"; import { UNAVAILABLE } from "../../../data/entity";
import { computeImageUrl, ImageEntity } from "../../../data/image";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
const UPDATE_INTERVAL = 10000; const UPDATE_INTERVAL = 10000;
@ -164,6 +166,8 @@ export class HuiImage extends LitElement {
} }
} else if (this.darkModeImage && this.hass.themes.darkMode) { } else if (this.darkModeImage && this.hass.themes.darkMode) {
imageSrc = this.darkModeImage; imageSrc = this.darkModeImage;
} else if (stateObj && computeDomain(stateObj.entity_id) === "image") {
imageSrc = computeImageUrl(stateObj as ImageEntity);
} else { } else {
imageSrc = this.image; imageSrc = this.image;
} }

View File

@ -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 { customElement, property, state } from "lit/decorators";
import { assert, assign, object, optional, string } from "superstruct"; import { assert, assign, object, optional, string } from "superstruct";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-theme-picker"; import "../../../../components/ha-theme-picker";
import { ActionConfig } from "../../../../data/lovelace";
import { HomeAssistant } from "../../../../types"; import { HomeAssistant } from "../../../../types";
import { PictureCardConfig } from "../../cards/types"; import { PictureCardConfig } from "../../cards/types";
import "../../components/hui-action-editor"; import "../../components/hui-action-editor";
import { LovelaceCardEditor } from "../../types"; import { LovelaceCardEditor } from "../../types";
import { actionConfigStruct } from "../structs/action-struct"; import { actionConfigStruct } from "../structs/action-struct";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import { EditorTarget } from "../types";
import { configElementStyle } from "./config-elements-style";
const cardConfigStruct = assign( const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
object({ object({
image: optional(string()), image: optional(string()),
image_entity: optional(string()),
tap_action: optional(actionConfigStruct), tap_action: optional(actionConfigStruct),
hold_action: optional(actionConfigStruct), hold_action: optional(actionConfigStruct),
theme: optional(string()), 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") @customElement("hui-picture-card-editor")
export class HuiPictureCardEditor export class HuiPictureCardEditor
extends LitElement extends LitElement
@ -38,129 +52,45 @@ export class HuiPictureCardEditor
this._config = config; 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() { protected render() {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
return nothing; return nothing;
} }
const actions = ["navigate", "url", "call-service", "none"];
return html` return html`
<div class="card-config"> <ha-form
<ha-textfield .hass=${this.hass}
.label="${this.hass.localize( .data=${this._config}
"ui.panel.lovelace.editor.card.generic.image" .schema=${SCHEMA}
)} (${this.hass.localize( .computeLabel=${this._computeLabelCallback}
"ui.panel.lovelace.editor.card.config.required" @value-changed=${this._valueChanged}
)})" ></ha-form>
.value=${this._image}
.configValue=${"image"}
@input=${this._valueChanged}
></ha-textfield>
<ha-textfield
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.alt_text"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.value=${this._alt_text}
.configValue=${"alt_text"}
@input=${this._valueChanged}
></ha-textfield>
<ha-theme-picker
.hass=${this.hass}
.value=${this._theme}
.label=${`${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`}
.configValue=${"theme"}
@value-changed=${this._valueChanged}
></ha-theme-picker>
<hui-action-editor
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.tap_action"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.hass=${this.hass}
.config=${this._tap_action}
.actions=${actions}
.configValue=${"tap_action"}
@value-changed=${this._valueChanged}
></hui-action-editor>
<hui-action-editor
.label="${this.hass.localize(
"ui.panel.lovelace.editor.card.generic.hold_action"
)} (${this.hass.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})"
.hass=${this.hass}
.config=${this._hold_action}
.actions=${actions}
.configValue=${"hold_action"}
@value-changed=${this._valueChanged}
></hui-action-editor>
</div>
`; `;
} }
private _valueChanged(ev: CustomEvent): void { private _valueChanged(ev: CustomEvent): void {
if (!this._config || !this.hass) { fireEvent(this, "config-changed", { config: ev.detail.value });
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 });
} }
static get styles(): CSSResultGroup { private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => {
return [ switch (schema.name) {
configElementStyle, case "theme":
css` return `${this.hass!.localize(
ha-textfield { "ui.panel.lovelace.editor.card.generic.theme"
display: block; )} (${this.hass!.localize(
margin-bottom: 8px; "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 { declare global {

View File

@ -22,6 +22,7 @@ const cardConfigStruct = assign(
title: optional(string()), title: optional(string()),
entity: optional(string()), entity: optional(string()),
image: optional(string()), image: optional(string()),
image_entity: optional(string()),
camera_image: optional(string()), camera_image: optional(string()),
camera_view: optional(string()), camera_view: optional(string()),
aspect_ratio: optional(string()), aspect_ratio: optional(string()),
@ -35,6 +36,7 @@ const cardConfigStruct = assign(
const SCHEMA = [ const SCHEMA = [
{ name: "title", selector: { text: {} } }, { name: "title", selector: { text: {} } },
{ name: "image", selector: { text: {} } }, { name: "image", selector: { text: {} } },
{ name: "image_entity", selector: { entity: { domain: "image" } } },
{ name: "camera_image", selector: { entity: { domain: "camera" } } }, { name: "camera_image", selector: { entity: { domain: "camera" } } },
{ {
name: "", name: "",

View File

@ -1,6 +1,7 @@
import { css, CSSResultGroup, html, LitElement, nothing } from "lit"; import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { ImageEntity, computeImageUrl } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace"; import { ActionHandlerEvent } from "../../../data/lovelace";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
import { computeTooltip } from "../common/compute-tooltip"; import { computeTooltip } from "../common/compute-tooltip";
@ -34,12 +35,16 @@ export class HuiImageElement extends LitElement implements LovelaceElement {
if (!this._config || !this.hass) { if (!this._config || !this.hass) {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined;
if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity;
}
return html` return html`
<hui-image <hui-image
.hass=${this.hass} .hass=${this.hass}
.entity=${this._config.entity} .entity=${this._config.entity}
.image=${this._config.image} .image=${stateObj ? computeImageUrl(stateObj) : this._config.image}
.stateImage=${this._config.state_image} .stateImage=${this._config.state_image}
.cameraImage=${this._config.camera_image} .cameraImage=${this._config.camera_image}
.cameraView=${this._config.camera_view} .cameraView=${this._config.camera_view}

View File

@ -42,6 +42,7 @@ export interface ImageElementConfig extends LovelaceElementConfigBase {
hold_action?: ActionConfig; hold_action?: ActionConfig;
double_tap_action?: ActionConfig; double_tap_action?: ActionConfig;
image?: string; image?: string;
image_entity?: string;
state_image?: string; state_image?: string;
camera_image?: string; camera_image?: string;
camera_view?: HuiImage["cameraView"]; camera_view?: HuiImage["cameraView"];

View File

@ -4667,6 +4667,7 @@
"aspect_ratio": "Aspect Ratio", "aspect_ratio": "Aspect Ratio",
"attribute": "Attribute", "attribute": "Attribute",
"camera_image": "Camera Entity", "camera_image": "Camera Entity",
"image_entity": "Image Entity",
"camera_view": "Camera View", "camera_view": "Camera View",
"double_tap_action": "Double Tap Action", "double_tap_action": "Double Tap Action",
"entities": "Entities", "entities": "Entities",