Files
advanced-camera-card/src/frigate-hass-card.ts
T

918 lines
28 KiB
TypeScript

/* eslint-disable @typescript-eslint/no-explicit-any */
import {
LitElement,
html,
customElement,
property,
CSSResult,
TemplateResult,
PropertyValues,
state,
unsafeCSS,
query,
} from 'lit-element';
import { NodePart } from 'lit-html';
import { until } from 'lit-html/directives/until.js';
import {
HomeAssistant,
fireEvent,
LovelaceCardEditor,
getLovelace,
} from 'custom-card-helpers';
import './editor';
import frigate_card_style from './frigate-hass-card.scss';
import frigate_card_menu_style from './frigate-hass-card-menu.scss';
import {
browseMediaSourceSchema,
frigateCardConfigSchema,
MenuButton,
resolvedMediaSchema,
} from './types';
import type {
BrowseMediaSource,
ControlVideosParameters,
FrigateCardView,
FrigateCardConfig,
FrigateMenuMode,
MediaBeingShown,
ResolvedMedia,
} from './types';
import { CARD_VERSION } from './const';
import { localize } from './localize/localize';
import dayjs from 'dayjs';
import dayjs_custom_parse_format from 'dayjs/plugin/customParseFormat';
import { z, ZodSchema } from 'zod';
import { MessageBase } from 'home-assistant-js-websocket';
const URL_TROUBLESHOOTING =
'https://github.com/dermotduffy/frigate-hass-card#troubleshooting';
// Load dayjs plugin(s).
dayjs.extend(dayjs_custom_parse_format);
/* eslint no-console: 0 */
console.info(
`%c FRIGATE-HASS-CARD \n%c ${localize('common.version')} ${CARD_VERSION} `,
'color: pink; font-weight: bold; background: black',
'color: white; font-weight: bold; background: dimgray',
);
// This puts your card into the UI card picker dialog
(window as any).customCards = (window as any).customCards || [];
(window as any).customCards.push({
type: 'frigate-card',
name: 'Frigate Card',
description: 'A lovelace card for use with Frigate',
});
type FrigateCardMenuCallback = (name: string) => any;
// Determine whether the card should be updated based on Home Assistant changes.
function shouldUpdateBasedOnHass(
newHass: HomeAssistant | null,
oldHass: HomeAssistant | undefined,
entities: (string | null | undefined)[],
): boolean {
if (!newHass) {
return false;
}
if (!entities.length) {
return false;
}
if (oldHass) {
for (let i = 0; i < entities.length; i++) {
const entity = entities[i];
if (!entity) {
continue;
}
if (oldHass.states[entity] !== newHass.states[entity]) {
return true;
}
}
return false;
}
return false;
}
// A menu for the Frigate card.
@customElement('frigate-card-menu')
export class FrigateCardMenu extends LitElement {
static FRIGATE_CARD_MENU_ID: string = 'frigate-card-menu-id' as const;
@property({ attribute: false })
protected menuMode: FrigateMenuMode = 'hidden';
@property({ attribute: false })
protected expand = false;
@property({ attribute: false })
protected actionCallback: FrigateCardMenuCallback | null = null;
@property({ attribute: false })
public buttons: Map<string, MenuButton> = new Map();
// Call the callback.
protected _callAction(name: string): void {
if (this.menuMode == 'hidden') {
if (name == 'frigate') {
this.expand = !this.expand;
return;
}
// Collapse menu after the user clicks on something.
this.expand = false;
}
if (this.actionCallback) {
this.actionCallback(name);
}
}
// Render a menu button.
protected _renderButton(name: string, button: MenuButton): TemplateResult {
return html` <ha-icon-button
class=${button.emphasize ? 'emphasized-button' : 'button'}
icon=${button.icon || 'mdi:gesture-tap-button'}
data-toggle="tooltip"
title=${button.description}
@click=${() => this._callAction(name)}
></ha-icon-button>`;
}
// Render the Frigate menu button.
protected _renderFrigateButton(name: string, button: MenuButton): TemplateResult {
const icon =
this.menuMode != 'hidden' || this.expand
? 'mdi:alpha-f-box'
: 'mdi:alpha-f-box-outline';
return this._renderButton(name, Object.assign({}, button, { icon: icon }));
}
// Render the menu.
protected render(): TemplateResult | void | ((part: NodePart) => Promise<void>) {
let menuClass = 'frigate-card-menu-full';
if (['hidden', 'overlay'].includes(this.menuMode)) {
if (this.menuMode == 'overlay' || this.expand) {
menuClass = 'frigate-card-menu-overlay';
} else {
menuClass = 'frigate-card-menu-hidden';
}
}
return html`
<div class=${menuClass}>
${Array.from(this.buttons.keys()).map((name) => {
const button = this.buttons.get(name);
if (button) {
return name === 'frigate'
? this._renderFrigateButton(name, button)
: this._renderButton(name, button);
}
return html``;
})}
</div>
`;
}
// Return compiled CSS styles (thus safe to use with unsafeCSS).
static get styles(): CSSResult {
return unsafeCSS(frigate_card_menu_style);
}
}
// Main FrigateCard class.
@customElement('frigate-card')
export class FrigateCard extends LitElement {
// Get the configuration element.
public static async getConfigElement(): Promise<LovelaceCardEditor> {
return document.createElement('frigate-card-editor');
}
// Get a stub basic config.
public static getStubConfig(): Record<string, string> {
return {};
}
set hass(hass: HomeAssistant) {
if (this._webrtcElement) {
this._webrtcElement.hass = hass;
}
this._hass = hass;
this._updateMenu();
}
@property({ attribute: false })
protected _hass: HomeAssistant | null = null;
@state()
public config!: FrigateCardConfig;
@property({ attribute: false })
protected _viewMode: FrigateCardView = 'live';
protected _interactionTimerID: number | null = null;
protected _webrtcElement: any | null = null;
// A specific media source requested by the user.
@property({ attribute: false })
protected _requestedMediaSource: BrowseMediaSource | null = null;
// Media (both browse item & resolved media) actually being shown to the user.
// This may be different from _requestedMediaSource when no particular event is
// requested (e.g. 'clip' view that views the most recent) -- in that case the
// requestedEvent will be null, but _mediaBeingShown will be the actual event
// shown.
protected _mediaBeingShown: MediaBeingShown | null = null;
// Whether or not there is an active clip being played.
protected _clipPlaying = false;
@query(FrigateCardMenu.FRIGATE_CARD_MENU_ID)
_menu!: FrigateCardMenu | null;
protected _updateMenu(): void {
// 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) {
return;
}
this._menu.buttons = this._getMenuButtons();
}
protected _getMenuButtons(): Map<string, MenuButton> {
const buttons: Map<string, MenuButton> = new Map();
buttons.set('frigate', { description: 'Frigate Menu' });
buttons.set('live', { icon: 'mdi:cctv', description: 'View Live' });
buttons.set('clips', { icon: 'mdi:filmstrip', description: 'View Clips' });
buttons.set('snapshots', { icon: 'mdi:camera', description: 'View Snapshots' });
if (this.config.frigate_url) {
buttons.set('frigate_ui', { icon: 'mdi:web', description: 'View Frigate UI' });
}
if (this._hass && this.config.motion_entity) {
const on = this._hass.states[this.config.motion_entity]?.state == 'on';
const motionIcon = on ? 'mdi:motion-sensor' : 'mdi:walk';
buttons.set('motion', {
icon: motionIcon,
description: 'View Motion Sensor',
emphasize: on,
});
}
return buttons;
}
protected _getParseErrorKeys(error: z.ZodError): string[] {
const errors = error.format();
return Object.keys(errors).filter((v) => !v.startsWith('_'));
}
// Set the object configuration.
public setConfig(inputConfig: FrigateCardConfig): void {
if (!inputConfig) {
throw new Error(localize('common.invalid_configuration:'));
}
const parseResult = frigateCardConfigSchema.safeParse(inputConfig);
if (!parseResult.success) {
const keys = this._getParseErrorKeys(parseResult.error);
throw new Error(localize('common.invalid_configuration') + ': ' + keys.join(', '));
}
const config = parseResult.data;
if (config.test_gui) {
getLovelace().setEditMode(true);
}
if (!config.frigate_camera_name) {
// No camera name specified, so just assume it's the same as the entity name.
if (config.camera_entity.includes('.')) {
config.frigate_camera_name = config.camera_entity.split('.', 2)[1];
} else {
throw new Error(localize('common.invalid_configuration') + ': camera_entity');
}
}
if (config.live_provider == 'webrtc') {
// Create a WebRTC element (https://github.com/AlexxIT/WebRTC)
const webrtcElement = customElements.get('webrtc-camera') as any;
if (webrtcElement) {
const webrtc = new webrtcElement();
webrtc.setConfig(config.webrtc || {});
webrtc.hass = this._hass;
this._webrtcElement = webrtc;
} else {
throw new Error(localize('common.missing_webrtc'));
}
}
this.config = config;
this._changeView();
}
// Update the card view.
protected _changeView(
view?: FrigateCardView | undefined,
mediaSource?: BrowseMediaSource | undefined,
): void {
this._viewMode = view || this.config.view_default;
this._requestedMediaSource = mediaSource || null;
this._mediaBeingShown = null;
}
// Determine whether the card should be updated.
protected shouldUpdate(changedProps: PropertyValues): boolean {
if (!this.config) {
return false;
}
if (changedProps.has('config')) {
return true;
}
const oldHass = changedProps.get('_hass') as HomeAssistant | undefined;
if (oldHass) {
// A re-render will interrupt a clip that is playing. Do not allow this
// for hass state updates.
if (this._clipPlaying) {
return false;
}
return shouldUpdateBasedOnHass(this._hass, oldHass, [
this.config.camera_entity,
this.config.motion_entity,
]);
}
return true;
}
// Make a websocket request to Home Assistant.
protected async _makeWSRequest<T>(
schema: ZodSchema<T>,
request: MessageBase,
): Promise<T | null> {
if (!this._hass) {
return null;
}
const response = await this._hass.callWS<T>(request);
if (!response) {
const error_message = `Received empty response from Home Assistant for request ${JSON.stringify(
request,
)}`;
console.warn(error_message);
throw new Error(error_message);
}
const parseResult = schema.safeParse(response);
if (!parseResult.success) {
const keys = this._getParseErrorKeys(parseResult.error);
const error_message =
`Received invalid response from Home Assistant for request ${JSON.stringify(
request,
)}, ` + `invalid keys were '${keys}'`;
console.warn(error_message);
throw new Error(error_message);
}
return parseResult.data;
}
// Browse Frigate media with a media content id.
protected async _browseMedia(
media_content_id: string,
): Promise<BrowseMediaSource | null> {
const request = {
type: 'media_source/browse_media',
media_content_id: media_content_id,
};
return this._makeWSRequest(browseMediaSourceSchema, request);
}
// Browse Frigate media with query parameters.
protected async _browseMediaQuery(
want_clips?: boolean,
before?: number,
after?: number,
): Promise<BrowseMediaSource | null> {
return this._browseMedia(
// Defined in:
// https://github.com/blakeblackshear/frigate-hass-integration/blob/master/custom_components/frigate/media_source.py
[
'media-source://frigate',
this.config.frigate_client_id,
'event-search',
want_clips ? 'clips' : 'snapshots',
'', // Name/Title to render (not necessary here)
after ? String(after) : '',
before ? String(before) : '',
this.config.frigate_camera_name,
this.config.label,
this.config.zone,
].join('/'),
);
}
// Resolve Frigate media identifier to a real URL.
protected async _resolveMedia(
mediaSource: BrowseMediaSource,
): Promise<ResolvedMedia | null> {
const request = {
type: 'media_source/resolve_media',
media_content_id: mediaSource.media_content_id,
};
return this._makeWSRequest(resolvedMediaSchema, request);
}
// Render an attention grabbing icon.
protected _renderAttentionIcon(
icon: string,
message: string | TemplateResult | null = null,
): TemplateResult {
return html` <div class="frigate-card-attention">
<span>
<ha-icon icon="${icon}"> </ha-icon>
${message ? html`&nbsp;${message}` : ''}
</span>
</div>`;
}
// Render an embedded error situation.
protected _renderError(error: string): TemplateResult {
return this._renderAttentionIcon(
'mdi:alert-circle',
html`${
error ? `${error} .` : `Unknown error`
}Check <a href="${URL_TROUBLESHOOTING}">troubleshooting</a></span>.`,
);
}
// Render Frigate events into a card gallery.
protected async _renderEvents(): Promise<TemplateResult> {
const want_clips = this._viewMode == 'clips';
let media;
try {
if (this._requestedMediaSource) {
media = await this._browseMedia(this._requestedMediaSource.media_content_id);
} else {
media = await this._browseMediaQuery(want_clips);
}
} catch (e: any) {
return this._renderError(e.message);
}
const firstMediaItem = this._getFirstTrueMediaItem(media);
if (!firstMediaItem) {
return this._renderAttentionIcon(
want_clips ? 'mdi:filmstrip-off' : 'mdi:camera-off',
want_clips ? 'No clips' : 'No snapshots',
);
}
return html` <ul class="mdc-image-list frigate-card-image-list">
${media.children.map(
(mediaSource) =>
html` <li class="mdc-image-list__item">
<div class="mdc-image-list__image-aspect-container">
${mediaSource.can_expand
? html`<div class="mdc-image-list__image">
<ha-card
@click=${() => {
this._changeView(this._viewMode, mediaSource);
}}
outlined=""
class="frigate-card-image-list-folder"
>
<div>${mediaSource.title}</div>
</ha-card>
</div>`
: html`<img
data-toggle="tooltip"
title="${mediaSource.title}"
class="mdc-image-list__image"
src="${mediaSource.thumbnail}"
@click=${() => {
this._changeView(want_clips ? 'clip' : 'snapshot', mediaSource);
}}
/>`}
</div>
</li>`,
)}
</ul>`;
}
// Render a progress spinner while content loads.
protected _renderProgressIndicator(): TemplateResult {
return html` <div class="frigate-card-attention">
<ha-circular-progress active="true" size="large"></ha-circular-progress>
</div>`;
}
// Stop/Play video controls.
protected _controlVideos({
stop,
control_live = false,
control_clip = false,
}: ControlVideosParameters): void {
const controlVideo = (stop: boolean, is_live: boolean, video: HTMLVideoElement) => {
if (video) {
if (stop) {
video.pause();
video.currentTime = 0;
} else if (is_live) {
// Duration on webrtc is infinity so cannot fast-forward.
if (!this._webrtcElement) {
// If it's a live view, 'fast-forward' to most recent content.
const duration = video.duration;
video.currentTime = duration;
}
video.play();
}
}
};
if (!this.shadowRoot) {
return;
}
if (control_clip) {
controlVideo(
stop,
false,
this.shadowRoot?.querySelector('video.frigate-card-viewer') as HTMLVideoElement,
);
}
if (control_live) {
// Don't have direct access to the live video player as it is buried in
// multiple components/shadow-roots, so need to navigate the path to get to <video>.
if (this._webrtcElement) {
controlVideo(
stop,
true,
this.shadowRoot?.querySelector('webrtc-camera video') as HTMLVideoElement,
);
} else {
controlVideo(
stop,
true,
this.shadowRoot
?.querySelector('ha-camera-stream')
?.shadowRoot?.querySelector('ha-hls-player')
?.shadowRoot?.querySelector('video') as HTMLVideoElement,
);
}
}
}
protected _menuActionHandler(name: string): void {
switch (name) {
case 'frigate':
this._controlVideos({ stop: true, control_clip: true });
this._controlVideos({ stop: true, control_live: true });
this._changeView();
break;
case 'live':
this._controlVideos({ stop: true, control_clip: true });
this._controlVideos({ stop: false, control_live: true });
this._changeView(name);
break;
case 'clips':
this._controlVideos({ stop: true, control_live: true });
this._changeView(name);
break;
case 'snapshots':
this._controlVideos({ stop: true, control_clip: true, control_live: true });
this._changeView(name);
break;
case 'frigate_ui':
const frigate_url = this._getFrigateURLFromContext();
if (frigate_url) {
window.open(frigate_url);
break;
}
break;
case 'motion':
if (this.config.motion_entity) {
fireEvent(this, 'hass-more-info', { entityId: this.config.motion_entity });
}
break;
default:
console.warn('Unknown Frigate card menu option.');
}
}
// Extract the Frigate event id from the resolved media. Unfortunately, there
// is no way to attach metadata to BrowseMediaSource so this must suffice.
protected _extractEventIDFromResolvedMedia(
resolvedMedia: ResolvedMedia,
): string | null {
// Example: /api/frigate/frigate/clips/camera-1630123639.21596-l1y9af.jpg?authSig=[large_string]
const result = resolvedMedia.url.match(/-(?<id>[\w]+)\.(jpg|m3u8|mp4)($|\?)/i);
if (result && result.groups) {
return result.groups['id'] || null;
}
return null;
}
protected _extractEventStartTimeFromBrowseMedia(
browseMedia: BrowseMediaSource,
): number | null {
// Example: 2021-08-27 20:57:22 [10s, Person 76%]
const result = browseMedia.title.match(/^(?<iso_datetime>.+) \[/);
if (result && result.groups) {
const iso_datetime_str = result.groups['iso_datetime'];
if (iso_datetime_str) {
const iso_datetime = dayjs(iso_datetime_str, 'YYYY-MM-DD HH:mm:ss', true);
if (iso_datetime.isValid()) {
return iso_datetime.unix();
}
}
}
return null;
}
// Get the Frigate UI url.
protected _getFrigateURLFromContext(): string | null {
if (!this.config.frigate_url) {
return null;
}
if (this._mediaBeingShown) {
const eventID = this._extractEventIDFromResolvedMedia(
this._mediaBeingShown.resolvedMedia,
);
if (eventID) {
return `${this.config.frigate_url}/events/${eventID}`;
}
}
return `${this.config.frigate_url}/cameras/${this.config.frigate_camera_name}`;
}
// From a BrowseMediaSource item extract the first true media item (i.e. a
// clip/snapshot, not a folder).
protected _getFirstTrueMediaItem(media: BrowseMediaSource): BrowseMediaSource | null {
return media.children?.find((mediaSource) => !mediaSource.can_expand) || null;
}
// Render the player for a saved clip.
protected async _renderClipPlayer(): Promise<TemplateResult> {
let mediaSource: BrowseMediaSource;
let autoplay = true;
if (this._requestedMediaSource) {
mediaSource = this._requestedMediaSource;
} else {
let media;
try {
media = await this._browseMediaQuery(true);
} catch (e: any) {
return this._renderError(e.message);
}
const firstMediaItem = this._getFirstTrueMediaItem(media);
if (!firstMediaItem) {
return this._renderAttentionIcon('mdi:filmstrip-off', 'No recent clip');
}
mediaSource = firstMediaItem;
// In this block, no clip has been manually selected, so this is loading
// the most recent clip on card load. In this mode, autoplay of the clip
// may be disabled by configuration. If does not make sense to disable
// autoplay when the user has explicitly picked an event to play in the
// gallery.
autoplay = this.config.autoplay_clip;
}
const resolvedMedia = await this._resolveMedia(mediaSource);
if (!resolvedMedia) {
// Home Assistant could not resolve media item.
return this._renderError('Could not resolve clip URL');
}
this._mediaBeingShown = {
browseMedia: mediaSource,
resolvedMedia: resolvedMedia,
};
return html`
<ha-hls-player
.hass=${this._hass}
.url=${resolvedMedia.url}
class="frigate-card-viewer"
muted
controls
playsinline
allow-exoplayer
?autoplay="${autoplay}"
>
</ha-hls-player>
`;
}
public updated(): void {
this.updateComplete.then(() => {
// DOM elements are not always present until after updateComplete promise
// is resolved. Note that children of children (i.e. the underlying video
// element) is not always present even when the promise returns, so
// capture the event at the upper shadow root instead.
const hls_player = this.renderRoot
?.querySelector('ha-card')
?.querySelector('ha-hls-player');
if (hls_player) {
hls_player.shadowRoot?.addEventListener(
'play',
() => {
this._clipPlaying = true;
},
true,
);
hls_player.shadowRoot?.addEventListener(
'pause',
() => {
this._clipPlaying = true;
},
true,
);
}
});
}
// Get a clip at the same time as a snapshot.
protected async _findRelatedClips(
snapshot: BrowseMediaSource,
): Promise<BrowseMediaSource | null> {
const startTime = this._extractEventStartTimeFromBrowseMedia(snapshot);
if (startTime) {
try {
// Fetch clips within the same second (same camera/zone/label, etc).
const clipsAtSameTime = await this._browseMediaQuery(
true,
startTime + 1,
startTime,
);
if (clipsAtSameTime) {
return this._getFirstTrueMediaItem(clipsAtSameTime);
}
} catch (e: any) {
// Pass. This is best effort.
}
}
return null;
}
// Render a snapshot.
protected async _renderSnapshotViewer(): Promise<TemplateResult> {
let mediaSource: BrowseMediaSource;
if (this._requestedMediaSource) {
mediaSource = this._requestedMediaSource;
} else {
let media;
try {
media = await this._browseMediaQuery(false);
} catch (e: any) {
return this._renderError(e.message);
}
const firstMediaItem = this._getFirstTrueMediaItem(media);
if (!firstMediaItem) {
return this._renderAttentionIcon('mdi:camera-off', 'No recent snapshots');
}
mediaSource = firstMediaItem;
}
const resolvedMedia = await this._resolveMedia(mediaSource);
if (!resolvedMedia) {
// Home Assistant could not resolve media item.
return this._renderError('Could not resolve snapshot URL');
}
this._mediaBeingShown = {
browseMedia: mediaSource,
resolvedMedia: resolvedMedia,
};
return html` <img
class="frigate-card-viewer"
src="${resolvedMedia.url}"
@click=${() => {
// Get clips potentially related to this snapshot.
this._findRelatedClips(mediaSource).then((relatedClip) => {
if (relatedClip) {
this._changeView('clip', relatedClip);
}
});
}}
/>`;
}
// Render the live viewer.
// Note: The live viewer is the main element used to size the overall card. It
// is always rendered (but sometimes hidden).
protected _renderLiveViewer(): TemplateResult {
if (!this._hass || !(this.config.camera_entity in this._hass.states)) {
return this._renderAttentionIcon('mdi:camera-off', 'No live camera');
}
if (this._webrtcElement) {
return html`${this._webrtcElement}`;
}
return html` <ha-camera-stream
.hass=${this._hass}
.stateObj=${this._hass.states[this.config.camera_entity]}
.controls=${true}
.muted=${true}
>
</ha-camera-stream>`;
}
// Record interactions with the card.
protected _interactionHandler(): void {
if (!this.config.view_timeout) {
return;
}
if (this._interactionTimerID) {
window.clearTimeout(this._interactionTimerID);
}
this._interactionTimerID = window.setTimeout(() => {
this._interactionTimerID = null;
this._changeView();
}, this.config.view_timeout * 1000);
}
protected _renderMenu(): TemplateResult | void {
return html`
<frigate-card-menu
id="${FrigateCardMenu.FRIGATE_CARD_MENU_ID}"
.actionCallback=${this._menuActionHandler.bind(this)}
.menuMode=${this.config.menu_mode}
.buttons=${this._getMenuButtons()}
></frigate-card-menu>
`;
}
// Render the call (master render method).
protected render(): TemplateResult | void {
if (this.config.show_warning) {
return this._showWarning(localize('common.show_warning'));
}
if (this.config.show_error) {
return this._showError(localize('common.show_error'));
}
return html` <ha-card @click=${this._interactionHandler}>
${this.config.menu_mode != 'below' ? this._renderMenu() : ''}
<div class="frigate-card-contents">
${this._viewMode == 'clips'
? html`<div class="frigate-card-gallery">
${until(this._renderEvents(), this._renderProgressIndicator())}
</div>`
: ``}
${this._viewMode == 'snapshots'
? html`<div class="frigate-card-gallery">
${until(this._renderEvents(), this._renderProgressIndicator())}
</div>`
: ``}
${this._viewMode == 'clip'
? html`<div class="frigate-card-viewer">
${until(this._renderClipPlayer(), this._renderProgressIndicator())}
</div>`
: ``}
${this._viewMode == 'snapshot'
? html`<div class="frigate-card-viewer">
${until(this._renderSnapshotViewer(), this._renderProgressIndicator())}
</div>`
: ``}
${this._viewMode == 'live'
? html`<div class="frigate-card-viewer">${this._renderLiveViewer()}</div>`
: ``}
</div>
${this.config.menu_mode == 'below' ? this._renderMenu() : ''}
</ha-card>`;
}
// Show a warning card.
private _showWarning(warning: string): TemplateResult {
return html` <hui-warning> ${warning} </hui-warning> `;
}
// Show an error card.
private _showError(error: string): TemplateResult {
const errorCard = document.createElement('hui-error-card');
errorCard.setConfig({
type: 'error',
error,
origConfig: this.config,
});
return html` ${errorCard} `;
}
// Return compiled CSS styles (thus safe to use with unsafeCSS).
static get styles(): CSSResult {
return unsafeCSS(frigate_card_style);
}
// Get the Lovelace card size.
static getCardSize(): number {
return 5;
}
}