picture cards: add person image support (#20593)

* picture cards: add person image support

* fix: person attributes typing

* review: apply comment from @coderabbitai

* fix lint:types

* review: put person domain in image_entity config

* add picture card compatibility & exemple in gallery

* fix lint

* Allow only image or person domains on image_entity editor config

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>

* fix domain type

* gracefully use the default config.image if the person don't have an image

* gracefully use the default config.image if the person don't have an image (that works)

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
This commit is contained in:
Quentame 2024-07-23 09:50:34 +02:00 committed by GitHub
parent 567a2ea019
commit 87ba0e73dd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 214 additions and 20 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

View File

@ -0,0 +1,3 @@
---
title: Picture Card
---

View File

@ -0,0 +1,61 @@
import { html, LitElement, PropertyValues, TemplateResult } from "lit";
import { customElement, query } from "lit/decorators";
import { getEntity } from "../../../../src/fake_data/entity";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-cards";
import { mockIcons } from "../../../../demo/src/stubs/icons";
const ENTITIES = [
getEntity("person", "paulus", "home", {
friendly_name: "Paulus",
entity_picture: "/images/paulus.jpg",
}),
];
const CONFIGS = [
{
heading: "Image URL",
config: `
- type: picture
image: /images/living_room.png
`,
},
{
heading: "Person entity",
config: `
- type: picture
image_entity: person.paulus
`,
},
{
heading: "Error: Image required",
config: `
- type: picture
entity: person.paulus
`,
},
];
@customElement("demo-lovelace-picture-card")
class DemoPicture extends LitElement {
@query("#demos") private _demoRoot!: HTMLElement;
protected render(): TemplateResult {
return html`<demo-cards id="demos" .configs=${CONFIGS}></demo-cards>`;
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.updateTranslations("lovelace", "en");
hass.addEntities(ENTITIES);
mockIcons(hass);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-lovelace-picture-card": DemoPicture;
}
}

View File

@ -25,6 +25,15 @@ const ENTITIES = [
friendly_name: "Movement Backyard", friendly_name: "Movement Backyard",
device_class: "motion", device_class: "motion",
}), }),
getEntity("person", "paulus", "home", {
friendly_name: "Paulus",
entity_picture: "/images/paulus.jpg",
}),
getEntity("sensor", "battery", 35, {
device_class: "battery",
friendly_name: "Battery",
unit_of_measurement: "%",
}),
]; ];
const CONFIGS = [ const CONFIGS = [
@ -123,6 +132,19 @@ const CONFIGS = [
left: 35% left: 35%
`, `,
}, },
{
heading: "Person entity",
config: `
- type: picture-elements
image_entity: person.paulus
elements:
- type: state-icon
entity: sensor.battery
style:
top: 8%
left: 8%
`,
},
]; ];
@customElement("demo-lovelace-picture-elements-card") @customElement("demo-lovelace-picture-elements-card")

View File

@ -12,6 +12,10 @@ const ENTITIES = [
getEntity("light", "bed_light", "off", { getEntity("light", "bed_light", "off", {
friendly_name: "Bed Light", friendly_name: "Bed Light",
}), }),
getEntity("person", "paulus", "home", {
friendly_name: "Paulus",
entity_picture: "/images/paulus.jpg",
}),
]; ];
const CONFIGS = [ const CONFIGS = [
@ -50,6 +54,13 @@ const CONFIGS = [
entity: camera.demo_camera entity: camera.demo_camera
`, `,
}, },
{
heading: "Person entity",
config: `
- type: picture-entity
entity: person.paulus
`,
},
{ {
heading: "Hidden name", heading: "Hidden name",
config: ` config: `

View File

@ -20,6 +20,15 @@ const ENTITIES = [
friendly_name: "Basement Floor Wet", friendly_name: "Basement Floor Wet",
device_class: "moisture", device_class: "moisture",
}), }),
getEntity("person", "paulus", "home", {
friendly_name: "Paulus",
entity_picture: "/images/paulus.jpg",
}),
getEntity("sensor", "battery", 35, {
device_class: "battery",
friendly_name: "Battery",
unit_of_measurement: "%",
}),
]; ];
const CONFIGS = [ const CONFIGS = [
@ -90,6 +99,15 @@ const CONFIGS = [
- light.ceiling_lights - light.ceiling_lights
`, `,
}, },
{
heading: "Person entity",
config: `
- type: picture-glance
image_entity: person.paulus
entities:
- sensor.battery
`,
},
{ {
heading: "Custom icon", heading: "Custom icon",
config: ` config: `

View File

@ -1,3 +1,7 @@
import {
HassEntityAttributeBase,
HassEntityBase,
} from "home-assistant-js-websocket";
import { HomeAssistant } from "../types"; import { HomeAssistant } from "../types";
export interface BasePerson { export interface BasePerson {
@ -18,6 +22,20 @@ export interface PersonMutableParams {
picture: string | null; picture: string | null;
} }
interface PersonEntityAttributes extends HassEntityAttributeBase {
id?: string;
user_id?: string;
device_trackers?: string[];
editable?: boolean;
gps_accuracy?: number;
latitude?: number;
longitude?: number;
}
export interface PersonEntity extends HassEntityBase {
attributes: PersonEntityAttributes;
}
export const fetchPersons = (hass: HomeAssistant) => export const fetchPersons = (hass: HomeAssistant) =>
hass.callWS<{ hass.callWS<{
storage: Person[]; storage: Person[];

View File

@ -10,6 +10,7 @@ import { customElement, property, state } 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 { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { computeImageUrl, ImageEntity } from "../../../data/image"; import { computeImageUrl, ImageEntity } from "../../../data/image";
import { ActionHandlerEvent } from "../../../data/lovelace/action_handler"; import { ActionHandlerEvent } from "../../../data/lovelace/action_handler";
@ -21,6 +22,7 @@ import { hasConfigChanged } from "../common/has-changed";
import { createEntityNotFoundWarning } from "../components/hui-warning"; import { createEntityNotFoundWarning } from "../components/hui-warning";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { PictureCardConfig } from "./types"; import { PictureCardConfig } from "./types";
import { PersonEntity } from "../../../data/person";
@customElement("hui-picture-card") @customElement("hui-picture-card")
export class HuiPictureCard extends LitElement implements LovelaceCard { export class HuiPictureCard extends LitElement implements LovelaceCard {
@ -95,10 +97,10 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined; let stateObj: ImageEntity | PersonEntity | undefined;
if (this._config.image_entity) { if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity; stateObj = this.hass.states[this._config.image_entity];
if (!stateObj) { if (!stateObj) {
return html`<hui-warning> return html`<hui-warning>
${createEntityNotFoundWarning(this.hass, this._config.image_entity)} ${createEntityNotFoundWarning(this.hass, this._config.image_entity)}
@ -106,6 +108,21 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
} }
} }
let image: string | undefined = this._config.image;
if (this._config.image_entity) {
const domain: string = computeDomain(this._config.image_entity);
switch (domain) {
case "image":
image = computeImageUrl(stateObj as ImageEntity);
break;
case "person":
if ((stateObj as PersonEntity).attributes.entity_picture) {
image = (stateObj as PersonEntity).attributes.entity_picture;
}
break;
}
}
return html` return html`
<ha-card <ha-card
@action=${this._handleAction} @action=${this._handleAction}
@ -134,9 +151,7 @@ export class HuiPictureCard extends LitElement implements LovelaceCard {
alt=${ifDefined( alt=${ifDefined(
this._config.alt_text || stateObj?.attributes.friendly_name this._config.alt_text || stateObj?.attributes.friendly_name
)} )}
src=${this.hass.hassUrl( src=${this.hass.hassUrl(image)}
stateObj ? computeImageUrl(stateObj) : this._config.image
)}
/> />
</ha-card> </ha-card>
`; `;

View File

@ -8,6 +8,7 @@ import {
} from "lit"; } from "lit";
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 { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/ha-card"; import "../../../components/ha-card";
import { ImageEntity, computeImageUrl } from "../../../data/image"; import { ImageEntity, computeImageUrl } from "../../../data/image";
import { HomeAssistant } from "../../../types"; import { HomeAssistant } from "../../../types";
@ -16,6 +17,7 @@ import { LovelaceElement, LovelaceElementConfig } from "../elements/types";
import { LovelaceCard } from "../types"; import { LovelaceCard } from "../types";
import { createStyledHuiElement } from "./picture-elements/create-styled-hui-element"; import { createStyledHuiElement } from "./picture-elements/create-styled-hui-element";
import { PictureElementsCardConfig } from "./types"; import { PictureElementsCardConfig } from "./types";
import { PersonEntity } from "../../../data/person";
@customElement("hui-picture-elements-card") @customElement("hui-picture-elements-card")
class HuiPictureElementsCard extends LitElement implements LovelaceCard { class HuiPictureElementsCard extends LitElement implements LovelaceCard {
@ -116,9 +118,21 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined; let image: string | undefined = this._config.image;
if (this._config.image_entity) { if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity; const stateObj: ImageEntity | PersonEntity | undefined =
this.hass.states[this._config.image_entity];
const domain: string = computeDomain(this._config.image_entity);
switch (domain) {
case "image":
image = computeImageUrl(stateObj as ImageEntity);
break;
case "person":
if ((stateObj as PersonEntity).attributes.entity_picture) {
image = (stateObj as PersonEntity).attributes.entity_picture;
}
break;
}
} }
return html` return html`
@ -126,7 +140,7 @@ class HuiPictureElementsCard extends LitElement implements LovelaceCard {
<div id="root"> <div id="root">
<hui-image <hui-image
.hass=${this.hass} .hass=${this.hass}
.image=${stateObj ? computeImageUrl(stateObj) : this._config.image} .image=${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

@ -25,6 +25,8 @@ import "../components/hui-image";
import { createEntityNotFoundWarning } from "../components/hui-warning"; import { createEntityNotFoundWarning } from "../components/hui-warning";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { PictureEntityCardConfig } from "./types"; import { PictureEntityCardConfig } from "./types";
import { CameraEntity } from "../../../data/camera";
import { PersonEntity } from "../../../data/person";
@customElement("hui-picture-entity-card") @customElement("hui-picture-entity-card")
class HuiPictureEntityCard extends LitElement implements LovelaceCard { class HuiPictureEntityCard extends LitElement implements LovelaceCard {
@ -68,7 +70,7 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
} }
if ( if (
!["camera", "image"].includes(computeDomain(config.entity)) && !["camera", "image", "person"].includes(computeDomain(config.entity)) &&
!config.image && !config.image &&
!config.state_image && !config.state_image &&
!config.camera_image !config.camera_image
@ -108,7 +110,8 @@ class HuiPictureEntityCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
const stateObj = this.hass.states[this._config.entity]; const stateObj: CameraEntity | ImageEntity | PersonEntity | undefined =
this.hass.states[this._config.entity];
if (!stateObj) { if (!stateObj) {
return html` return html`
@ -135,15 +138,24 @@ 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); const domain: string = computeDomain(this._config.entity);
let image: string | undefined = this._config.image;
switch (domain) {
case "image":
image = computeImageUrl(stateObj as ImageEntity);
break;
case "person":
if ((stateObj as PersonEntity).attributes.entity_picture) {
image = (stateObj as PersonEntity).attributes.entity_picture;
}
break;
}
return html` return html`
<ha-card> <ha-card>
<hui-image <hui-image
.hass=${this.hass} .hass=${this.hass}
.image=${domain === "image" .image=${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=${domain === "camera" .cameraImage=${domain === "camera"

View File

@ -31,6 +31,7 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import "../components/hui-warning-element"; import "../components/hui-warning-element";
import { LovelaceCard, LovelaceCardEditor } from "../types"; import { LovelaceCard, LovelaceCardEditor } from "../types";
import { PictureGlanceCardConfig, PictureGlanceEntityConfig } from "./types"; import { PictureGlanceCardConfig, PictureGlanceEntityConfig } from "./types";
import { PersonEntity } from "../../../data/person";
const STATES_OFF = new Set(["closed", "locked", "not_home", "off"]); const STATES_OFF = new Set(["closed", "locked", "not_home", "off"]);
@ -183,9 +184,21 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
return nothing; return nothing;
} }
let stateObj: ImageEntity | undefined; let image: string | undefined = this._config.image;
if (this._config.image_entity) { if (this._config.image_entity) {
stateObj = this.hass.states[this._config.image_entity] as ImageEntity; const stateObj: ImageEntity | PersonEntity | undefined =
this.hass.states[this._config.image_entity];
const domain: string = computeDomain(this._config.image_entity);
switch (domain) {
case "image":
image = computeImageUrl(stateObj as ImageEntity);
break;
case "person":
if ((stateObj as PersonEntity).attributes.entity_picture) {
image = (stateObj as PersonEntity).attributes.entity_picture;
}
break;
}
} }
return html` return html`
@ -209,7 +222,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
)} )}
.config=${this._config} .config=${this._config}
.hass=${this.hass} .hass=${this.hass}
.image=${stateObj ? computeImageUrl(stateObj) : this._config.image} .image=${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

@ -406,6 +406,7 @@ export interface PictureGlanceCardConfig extends LovelaceCardConfig {
entities: Array<string | PictureGlanceEntityConfig>; entities: Array<string | PictureGlanceEntityConfig>;
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

@ -115,7 +115,7 @@ export const computeSection = (
type: "tile", type: "tile",
entity, entity,
show_entity_picture: show_entity_picture:
["person", "camera", "image"].includes(computeDomain(entity)) || ["camera", "image", "person"].includes(computeDomain(entity)) ||
undefined, undefined,
}) as TileCardConfig }) as TileCardConfig
), ),

View File

@ -25,7 +25,10 @@ const cardConfigStruct = assign(
const SCHEMA = [ const SCHEMA = [
{ name: "image", selector: { image: {} } }, { name: "image", selector: { image: {} } },
{ name: "image_entity", selector: { entity: { domain: "image" } } }, {
name: "image_entity",
selector: { entity: { domain: ["image", "person"] } },
},
{ name: "alt_text", selector: { text: {} } }, { name: "alt_text", selector: { text: {} } },
{ name: "theme", selector: { theme: {} } }, { name: "theme", selector: { theme: {} } },
{ {

View File

@ -36,7 +36,10 @@ const cardConfigStruct = assign(
const SCHEMA = [ const SCHEMA = [
{ name: "title", selector: { text: {} } }, { name: "title", selector: { text: {} } },
{ name: "image", selector: { image: {} } }, { name: "image", selector: { image: {} } },
{ name: "image_entity", selector: { entity: { domain: "image" } } }, {
name: "image_entity",
selector: { entity: { domain: ["image", "person"] } },
},
{ name: "camera_image", selector: { entity: { domain: "camera" } } }, { name: "camera_image", selector: { entity: { domain: "camera" } } },
{ {
name: "", name: "",