diff --git a/README.md b/README.md index 2f154237..262987b9 100644 --- a/README.md +++ b/README.md @@ -209,21 +209,25 @@ entities: ### Picture Elements / Menu customizations -This card supports a subset of the [Picture Elements -configuration](https://www.home-assistant.io/lovelace/picture-elements/) to +This card supports the [Picture Elements configuration +syntax](https://www.home-assistant.io/lovelace/picture-elements/) to seamlessly allow the user to add custom elements to the card, which may be configured to -perform different actions on `tap`, `double_tap` and `hold`. +perform a variety of actions on `tap`, `double_tap` and `hold`. In the card YAML configuration, elements may be manually added under an `elements` key. -#### Supported Elements +#### Special Elements + +This card supports all [Picture Elements](https://www.home-assistant.io/lovelace/picture-elements/#icon-element) using the same syntax. The card also supports two special elements to add plain icons and state-based icons to the Frigate card menu. | Element name | Description | | ------------- | --------------------------------------------- | -| `menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is identical to that of the [Picture elements icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element).| -| `menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is identical to that of the [Picture elements state icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon).| +| `menu-icon` | Add an arbitrary icon to the Frigate Card menu. Configuration is ~identical to that of the [Picture elements icon](https://www.home-assistant.io/lovelace/picture-elements/#icon-element).| +| `menu-state-icon` | Add a state icon to the Frigate Card menu that represents the state of a Home Assistant entity. Configuration is ~identical to that of the [Picture elements state icon](https://www.home-assistant.io/lovelace/picture-elements/#state-icon).| -#### Example +See the [action documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for more information on the action options available. + +#### Elements Example Add an icon that represents the state of the `light.office_main_lights` entity, that shows more information on single click (the default action) and toggles the light on double click. @@ -245,7 +249,19 @@ Add an icon that navigates the brower to the releases page for this card: url_path: https://github.com/dermotduffy/frigate-hass-card/releases ``` -See the [action documentation](https://www.home-assistant.io/lovelace/actions/#hold-action) for more information on the action options available. +Add a state badge showing the temperature but hide the label text: + +```yaml + - type: state-badge + entity: sensor.kitchen_temperature + style: + right: '-20px' + top: 100px + color: rgba(0,0,0,0) + opacity: 0.5 +``` + +Picture elements temperature example diff --git a/images/picture_elements_temperature.png b/images/picture_elements_temperature.png new file mode 100644 index 00000000..8b4f3370 Binary files /dev/null and b/images/picture_elements_temperature.png differ diff --git a/src/card.ts b/src/card.ts index 96acfc09..29d2ba04 100644 --- a/src/card.ts +++ b/src/card.ts @@ -16,11 +16,10 @@ import { LovelaceCardEditor, getLovelace, handleAction, - ActionHandlerEvent, } from 'custom-card-helpers'; import screenfull from 'screenfull'; -import { entitySchema, frigateCardConfigSchema } from './types'; +import { entitySchema, frigateCardConfigSchema, Message } from './types'; import type { BrowseMediaQueryParameters, Entity, @@ -33,16 +32,16 @@ import type { import { CARD_VERSION } from './const'; import { FrigateCardMenu, MENU_HEIGHT } from './components/menu'; import { View } from './view'; -import { actionHandler } from './action-handler-directive'; import { getParseErrorKeys, homeAssistantWSRequest, shouldUpdateBasedOnHass, } from './common'; import { localize } from './localize/localize'; -import { renderErrorMessage, renderProgressIndicator } from './components/message'; +import { renderMessage, renderProgressIndicator } from './components/message'; import './editor'; +import './components/elements'; import './components/gallery'; import './components/live'; import './components/menu'; @@ -52,6 +51,7 @@ import './patches/ha-camera-stream'; import './patches/ha-hls-player'; import cardStyle from './scss/card.scss'; +import { FrigateCardElements } from './components/elements'; const MEDIA_HEIGHT_CUTOFF = 50; const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; @@ -105,6 +105,9 @@ export class FrigateCard extends LitElement { @query('frigate-card-menu') _menu!: FrigateCardMenu; + @query('frigate-card-elements') + _elements!: FrigateCardElements; + // Whether or not media is actively playing (live or clip). protected _mediaPlaying = false; @@ -119,14 +122,22 @@ export class FrigateCard extends LitElement { // derived). protected _frigateCameraName: string | null = null; + // Error/info message to render. + protected _message: Message | null = null; + set hass(hass: HomeAssistant & ExtendedHomeAssistant) { this._hass = hass; - // Manually set hass in the menu. This is to allow the menu to update, - // without necessarily re-rendering the entire card (re-rendering interrupts - // clip playing). - if (this._menu && this._hass) { - this._menu.hass = this._hass; + // Manually set hass in the menu & elements. This is to allow these to + // update, without necessarily re-rendering the entire card (re-rendering + // interrupts clip playing). + if (this._hass) { + if (this._menu) { + this._menu.hass = this._hass; + } + if (this._elements) { + this._elements.hass = this._hass; + } } } @@ -197,7 +208,7 @@ export class FrigateCard extends LitElement { const elements = this.config.elements || []; for (let i = 0; this._hass && i < elements.length; i++) { const element = elements[i]; - if (['menu-icon', 'menu-state-icon'].includes(element.type)) { + if (element.type == 'menu-icon' || element.type == 'menu-state-icon') { buttons.push(element); } } @@ -404,6 +415,27 @@ export class FrigateCard extends LitElement { this._mediaPlaying = false; } + protected _setMessageAndUpdate(message: Message): void { + // Only register the first message. + if (!this._message) { + this._message = message; + this.requestUpdate(); + } + } + + protected _messageHandler(e: CustomEvent): void { + return this._setMessageAndUpdate(e.detail); + } + + protected _renderAndResetMessage(): TemplateResult | void { + if (this._message) { + const message = this._message; + this._message = null; + return renderMessage(message); + } + return html``; + } + protected _mediaLoadHandler(e: CustomEvent): void { const mediaInfo = e.detail; // In Safari, with WebRTC, 0x0 is occasionally returned during loading, @@ -527,57 +559,79 @@ export class FrigateCard extends LitElement { ${this.config.menu_mode == 'above' ? this._renderMenu() : ''}
- ${until(this._render(), renderProgressIndicator())} + ${this._message + ? this._renderAndResetMessage() + : until(this._render(), renderProgressIndicator())}
${this.config.menu_mode != 'above' ? this._renderMenu() : ''} `; } - protected async _render(): Promise { + protected async _render(): Promise { if (!this._frigateCameraName) { this._frigateCameraName = await this._getFrigateCameraName(); } const mediaQueryParameters = this._getBrowseMediaQueryParameters(); if (!this._frigateCameraName || !mediaQueryParameters) { - return renderErrorMessage(localize('error.no_frigate_camera_name')); + return this._setMessageAndUpdate({ + message: localize('error.no_frigate_camera_name'), + type: 'error', + }); } + const pictureElementsClasses = { + 'picture-elements': true, + gallery: this._view.isGalleryView(), + }; + return html` - ${this._view.is('clips') || this._view.is('snapshots') - ? html` - ` - : ``} - ${this._view.is('clip') || this._view.is('snapshot') - ? html` - ` - : ``} - ${this._view.is('live') - ? html` - ` - : ``} +
+ ${this._view.is('clips') || this._view.is('snapshots') + ? html` + ` + : ``} + ${this._view.is('clip') || this._view.is('snapshot') + ? html` + ` + : ``} + ${this._view.is('live') + ? html` + + + ` + : ``} + + +
`; } diff --git a/src/common.ts b/src/common.ts index 7b93310a..1fc8fc08 100644 --- a/src/common.ts +++ b/src/common.ts @@ -7,6 +7,7 @@ import type { BrowseMediaSource, ExtendedHomeAssistant, MediaLoadInfo, + Message, } from './types'; import { browseMediaSourceSchema } from './types'; @@ -144,6 +145,28 @@ export function dispatchMediaLoadEvent( } } +export function dispatchMessageEvent( + element: HTMLElement, + message: string, + icon?: string, +): void { + dispatchEvent(element, 'message', { + message: message, + type: 'info', + icon: icon, + }); +} + +export function dispatchErrorMessageEvent( + element: HTMLElement, + message: string, +): void { + dispatchEvent(element, 'message', { + message: message, + type: 'error', + }); +} + // Determine whether the card should be updated based on Home Assistant changes. export function shouldUpdateBasedOnHass( newHass: HomeAssistant | null, diff --git a/src/components/elements.ts b/src/components/elements.ts new file mode 100644 index 00000000..15e5d573 --- /dev/null +++ b/src/components/elements.ts @@ -0,0 +1,103 @@ +import { LitElement, TemplateResult, html, CSSResultGroup, unsafeCSS } from 'lit'; +import { HomeAssistant } from 'custom-card-helpers'; +import { customElement, property } from 'lit/decorators'; + +import { ExtendedHomeAssistant, PictureElement, PictureElements } from '../types'; + +import elementsStyle from '../scss/elements.scss'; + +@customElement('frigate-card-elements') +export class FrigateCardElements extends LitElement { + @property({ attribute: false }) + protected _pictureElements: PictureElements; + + protected _hass!: HomeAssistant & ExtendedHomeAssistant; + protected _elements: HTMLElement[] = []; + + set hass(hass: HomeAssistant & ExtendedHomeAssistant) { + for (let i = 0; hass && i < this._elements.length; i++) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (this._elements[i] as any).hass = hass; + } + this._hass = hass; + } + + set pictureElements(pictureElements: PictureElements) { + if (this._elements.length > 0) { + this._elements.forEach((el: HTMLElement) => { + if (el.parentElement) { + el.parentElement.removeChild(el); + } + }); + this._elements = []; + } + if (!pictureElements) { + return; + } + for (let i = 0; i < pictureElements.length && pictureElements[i]; i++) { + const element = this._createPictureElement(pictureElements[i]); + if (element) { + this._elements.push(element); + } + } + } + + @property({ attribute: false }) + protected _createPictureElement(pictureElement: PictureElement): HTMLElement | null { + let customElementName: string | null = null; + + switch (pictureElement.type) { + case 'state-badge': + case 'state-icon': + case 'state-label': + case 'service-button': + case 'icon': + case 'image': + case 'conditional': + customElementName = `hui-${pictureElement.type}-element`; + break; + } + + if (!customElementName) { + return null; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const elementConstructor = customElements.get(customElementName) as any; + if (!elementConstructor) { + return null; + } + + const element = new elementConstructor(); + element.hass = this._hass; + try { + element.setConfig(pictureElement); + } catch (e) { + console.error(e, (e as Error).stack); + return null; + } + element.classList.add('element'); + + const targetStyle = pictureElement.style || {}; + Object.keys(targetStyle).forEach((prop) => { + element.style.setProperty(prop, targetStyle[prop]); + }); + return element; + } + + protected render(): TemplateResult { + return html`${this._elements.map((element) => element)}`; + } + + static get styles(): CSSResultGroup { + return unsafeCSS(elementsStyle); + } +} + +export function renderFrigateCardElements( + hass: HomeAssistant & ExtendedHomeAssistant, + pictureElements: PictureElements, +): TemplateResult { + return html` + `; +} diff --git a/src/components/gallery.ts b/src/components/gallery.ts index 01254b44..85faf32b 100644 --- a/src/components/gallery.ts +++ b/src/components/gallery.ts @@ -11,9 +11,15 @@ import type { } from '../types'; import { View } from '../view'; -import { browseMedia, browseMediaQuery, getFirstTrueMediaChildIndex } from '../common'; +import { + browseMedia, + browseMediaQuery, + dispatchErrorMessageEvent, + dispatchMessageEvent, + getFirstTrueMediaChildIndex, +} from '../common'; import { localize } from '../localize/localize'; -import { renderMessage, renderErrorMessage, renderProgressIndicator } from './message'; +import { renderProgressIndicator } from './message'; import galleryStyle from '../scss/gallery.scss'; import { styleMap } from 'lit/directives/style-map.js'; @@ -65,7 +71,7 @@ export class FrigateCardGallery extends LitElement { return html`${until(this._render(), renderProgressIndicator())}`; } - protected async _render(): Promise { + protected async _render(): Promise { let parent: BrowseMediaSource | null; try { if (this.view.target) { @@ -74,11 +80,12 @@ export class FrigateCardGallery extends LitElement { parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); } } catch (e: any) { - return renderErrorMessage(e.message); + return dispatchErrorMessageEvent(this, e.message); } if (!parent || !parent.children || getFirstTrueMediaChildIndex(parent) == null) { - return renderMessage( + return dispatchMessageEvent( + this, this._getMediaType() == 'clips' ? localize('common.no_clips') : localize('common.no_snapshots'), diff --git a/src/components/live.ts b/src/components/live.ts index 964e540e..6c6fdd0f 100644 --- a/src/components/live.ts +++ b/src/components/live.ts @@ -8,16 +8,14 @@ import type { ExtendedHomeAssistant, FrigateCardConfig } from '../types'; import { localize } from '../localize/localize'; import { + dispatchErrorMessageEvent, dispatchMediaLoadEvent, + dispatchMessageEvent, dispatchPauseEvent, dispatchPlayEvent, homeAssistantWSRequest, } from '../common'; -import { - renderMessage, - renderErrorMessage, - renderProgressIndicator, -} from '../components/message'; +import { renderProgressIndicator } from '../components/message'; import JSMpeg from '@cycjimmy/jsmpeg-player'; @@ -74,7 +72,11 @@ export class FrigateCardViewerFrigate extends LitElement { protected render(): TemplateResult | void { if (!(this.cameraEntity in this.hass.states)) { - return renderMessage(localize('error.no_live_camera'), 'mdi:camera-off'); + return dispatchMessageEvent( + this, + localize('error.no_live_camera'), + 'mdi:camera-off', + ); } return html` { + protected async _render(): Promise { if (!this._jsmpegCanvasElement) { this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement.className = 'media'; @@ -223,7 +225,10 @@ export class FrigateCardViewerJSMPEG extends LitElement { const jsmpeg_url = await this._getURL(); if (!jsmpeg_url) { - return renderErrorMessage('Could not retrieve or sign JSMPEG websocket path'); + return dispatchErrorMessageEvent( + this, + 'Could not retrieve or sign JSMPEG websocket path', + ); } let videoDecoded = false; diff --git a/src/components/menu.ts b/src/components/menu.ts index 977839df..9c57cbe5 100644 --- a/src/components/menu.ts +++ b/src/components/menu.ts @@ -74,6 +74,7 @@ export class FrigateCardMenu extends LitElement { let emphasize = false; let title = button.title; let icon = button.icon; + const style = ('style' in button ? button.style : {}) || {}; if (button.type === 'menu-state-icon') { state = this.hass.states[button.entity]; @@ -100,7 +101,7 @@ export class FrigateCardMenu extends LitElement { return html` this._callAction(ev, button)} diff --git a/src/components/message.ts b/src/components/message.ts index 939bb0ae..e63961ca 100644 --- a/src/components/message.ts +++ b/src/components/message.ts @@ -3,6 +3,8 @@ import { customElement, property } from 'lit/decorators'; import { localize } from '../localize/localize'; +import { Message } from '../types'; + import messageStyle from '../scss/message.scss'; const URL_TROUBLESHOOTING = @@ -14,13 +16,14 @@ export class FrigateCardMessage extends LitElement { protected message = ''; @property({ attribute: false }) - protected icon = 'mdi:information-outline'; + protected icon?; // Render the menu. protected render(): TemplateResult { + const icon = this.icon ? this.icon : 'mdi:information-outline'; return html`
- + ${this.message ? html` ${this.message}` : ''}
`; @@ -59,16 +62,18 @@ export class FrigateCardProgressIndicator extends LitElement { } } -export function renderErrorMessage(error: string): TemplateResult { - return html` - - `; -} - -export function renderMessage(message: string, icon: string): TemplateResult { - return html` - - `; +export function renderMessage(message: Message): TemplateResult { + if (message.type == 'error') { + return html` `; + } else if (message.type == 'info') { + return html` `; + } + return html``; } export function renderProgressIndicator(): TemplateResult { diff --git a/src/components/viewer.ts b/src/components/viewer.ts index cafecd34..53c1557a 100644 --- a/src/components/viewer.ts +++ b/src/components/viewer.ts @@ -18,7 +18,9 @@ import type { import { localize } from '../localize/localize'; import { browseMediaQuery, + dispatchErrorMessageEvent, dispatchMediaLoadEvent, + dispatchMessageEvent, dispatchPauseEvent, dispatchPlayEvent, getFirstTrueMediaChildIndex, @@ -27,8 +29,6 @@ import { import { View } from '../view'; import { - renderMessage, - renderErrorMessage, renderProgressIndicator, } from '../components/message'; @@ -158,7 +158,7 @@ export class FrigateCardViewer extends LitElement { return html`${until(this._render(), renderProgressIndicator())}`; } - protected async _render(): Promise { + protected async _render(): Promise { let autoplay = true; let parent: BrowseMediaSource | null = null; @@ -173,11 +173,12 @@ export class FrigateCardViewer extends LitElement { try { parent = await browseMediaQuery(this.hass, this.browseMediaQueryParameters); } catch (e) { - return renderErrorMessage((e as Error).message); + return dispatchErrorMessageEvent(this, (e as Error).message); } childIndex = getFirstTrueMediaChildIndex(parent); if (!parent || !parent.children || childIndex == null) { - return renderMessage( + return dispatchMessageEvent( + this, this.view.is('clip') ? localize('common.no_clip') : localize('common.no_snapshot'), @@ -196,7 +197,7 @@ export class FrigateCardViewer extends LitElement { const resolvedMedia = await this._resolveMedia(mediaToRender); if (!mediaToRender || !resolvedMedia) { // Home Assistant could not resolve media item. - return renderErrorMessage(localize('error.could_not_resolve')); + return dispatchErrorMessageEvent(this, localize('error.could_not_resolve')); } const neighbors = this._getMediaNeighbors(parent, childIndex); diff --git a/src/scss/card.scss b/src/scss/card.scss index 4c851ee0..f1d9006d 100644 --- a/src/scss/card.scss +++ b/src/scss/card.scss @@ -1,15 +1,16 @@ .container { position: relative; overflow: auto; - height: 100%; width: 100%; + height: 100%; margin: auto; display: flex; justify-content: center; } .frigate-card-contents { - width: 100%; + width: inherit; + height: inherit; margin: auto; overflow: auto; -ms-overflow-style: none; /* Hide scrollbar: IE and Edge */ @@ -43,6 +44,17 @@ .outer:hover + .hover-menu, .hover-menu:hover { opacity: 1.0; } +/* A relative div to place absolute picture elements onto */ +.picture-elements { + position: relative; + width: inherit; +} + +/* Enforce picture elements to only be the size of the card/fullscreen (and not +larger) when in gallery mode so that the picture elements do not scroll. */ +.picture-elements.gallery { + height: 100%; +} ha-card { display: flex; @@ -56,7 +68,7 @@ ha-card { background-color: var(--secondary-background-color, black); } -frigate-card-gallery, frigate-card-viewer, frigate-card-live { +frigate-card-gallery, frigate-card-viewer, frigate-card-live, frigate-card-message, frigate-card-error-message { width: 100%; display: block; } diff --git a/src/scss/elements.scss b/src/scss/elements.scss new file mode 100644 index 00000000..f363ee4d --- /dev/null +++ b/src/scss/elements.scss @@ -0,0 +1,4 @@ +.element { + position: absolute; + transform: translate(-50%, -50%); +} \ No newline at end of file diff --git a/src/scss/gallery.scss b/src/scss/gallery.scss index 7de07add..0ea92f8c 100644 --- a/src/scss/gallery.scss +++ b/src/scss/gallery.scss @@ -1,6 +1,10 @@ @use "@material/image-list/mdc-image-list"; @use "@material/image-list"; +:host { + overflow: auto; +} + .frigate-card-gallery { // Note: In fullscreen, number of columns is overwritten in Javascript based // on dimensions. diff --git a/src/types.ts b/src/types.ts index 0a9c8d87..35bcf67b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -60,9 +60,9 @@ export type LiveProvider = typeof LIVE_PROVIDERS[number]; // Declare schemas to existing types: // - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 -// eslint-disable-next-line @typescript-eslint/no-explicit-any const schemaForType = () => + // eslint-disable-next-line @typescript-eslint/no-explicit-any >(arg: S) => { return arg; }; @@ -104,50 +104,113 @@ const elementsActionSchema = z.union([ ]); export type ElementsActionType = z.infer; -const elementsActionsSchema = z.object({ +const elementsBaseSchema = z.object({ + style: z.object({}).passthrough().optional(), + title: z.string().nullable().optional(), tap_action: elementsActionSchema.optional(), hold_action: elementsActionSchema.optional(), double_tap_action: elementsActionSchema.optional(), }); /** - * Menu Types + * Picture Element Types */ -const menuItemBaseSchema = z.object({ - title: z.string().optional(), - style: z.object({}).passthrough().optional(), -}); +// https://www.home-assistant.io/lovelace/picture-elements/#state-badge +const stateBadgeIconSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('state-badge'), + entity: z.string(), + })); -const menuIconSchema = menuItemBaseSchema - .merge( - z.object({ - type: z.literal('menu-icon'), - icon: z.string(), - }), +// https://www.home-assistant.io/lovelace/picture-elements/#state-icon +const stateIconSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('state-icon'), + entity: z.string(), + icon: z.string().optional(), + state_color: z.boolean().default(true), + })); + +// https://www.home-assistant.io/lovelace/picture-elements/#state-label +const stateLabelSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('state-label'), + entity: z.string(), + attribute: z.string().optional(), + prefix: z.string().optional(), + suffix: z.string().optional(), + })); + +// https://www.home-assistant.io/lovelace/picture-elements/#service-call-button +const serviceCallButtonSchema = + elementsBaseSchema.merge(z + .object({ + type: z.literal('service-button'), + // Title is required for service button. + title: z.string(), + service: z.string(), + service_data: z.object({}).passthrough().optional(), + }) ) - .merge(elementsActionsSchema); -const menuStateIconSchema = menuItemBaseSchema - .merge( - z.object({ - type: z.literal('menu-state-icon'), +// https://www.home-assistant.io/lovelace/picture-elements/#icon +const iconSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('icon'), + icon: z.string(), + entity: z.string().optional(), + })); + +// https://www.home-assistant.io/lovelace/picture-elements/#image-element +const imageSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('image'), + entity: z.string().optional(), + image: z.string().optional(), + camera_image: z.string().optional(), + camera_view: z.string().optional(), + state_image: z.object({}).passthrough().optional(), + filter: z.string().optional(), + state_filter: z.object({}).passthrough().optional(), + aspect_ratio: z.string().optional(), +})); + +// https://www.home-assistant.io/lovelace/picture-elements/#image-element +const conditionalSchema = elementsBaseSchema.merge( + z.object({ + type: z.literal('conditional'), + conditions: z.object({ entity: z.string(), - icon: z.string().optional(), - state_color: z.boolean().default(true), - }), - ) - .merge(elementsActionsSchema); + state: z.string().optional(), + state_not: z.string().optional(), + }).array(), + elements: z.lazy(() => pictureElementsSchema), + })); + +/** + * Menu Element Types + */ + +const menuIconSchema = iconSchema.merge( + z.object({ + type: z.literal('menu-icon'), + })); + +const menuStateIconSchema = stateIconSchema.merge( + z.object({ + type: z.literal('menu-state-icon'), + })); // Schema for card (non-user configured) menu icons. -const internalMenuIconSchema = menuItemBaseSchema.merge( - z.object({ +const internalMenuIconSchema = z + .object({ type: z.literal('internal-menu-icon'), + title: z.string(), icon: z.string().optional(), emphasize: z.boolean().default(false).optional(), card_action: z.string(), - }), -); + }); const menuButtonSchema = z.union([ menuIconSchema, @@ -158,7 +221,21 @@ export type MenuButton = z.infer; // 'internalMenuIconSchema' is excluded to disallow the user from manually // changing the internal menu buttons. -const elementsSchema = z.union([menuStateIconSchema, menuIconSchema]); +const pictureElementSchema = z.union([ + menuStateIconSchema, + menuIconSchema, + stateBadgeIconSchema, + stateIconSchema, + stateLabelSchema, + serviceCallButtonSchema, + iconSchema, + imageSchema, + conditionalSchema, +]); +export type PictureElement = z.infer; + +const pictureElementsSchema = pictureElementSchema.array().optional(); +export type PictureElements = z.infer; export const frigateCardConfigSchema = z.object({ camera_entity: z.string(), @@ -200,7 +277,7 @@ export const frigateCardConfigSchema = z.object({ }) .optional(), update_entities: z.string().array().optional(), - elements: elementsSchema.array().optional(), + elements: pictureElementsSchema, controls: z .object({ nextprev: z.enum(NEXT_PREVIOUS_CONTROL_STYLES).default('thumbnails'), @@ -260,6 +337,12 @@ export interface MediaLoadInfo { height: number; } +export interface Message { + message: string; + type: 'error' | 'info'; + icon?: string; +} + /** * Home Assistant API types. */