/* 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 = 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` this._callAction(name)} >`; } // 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) { 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`
${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``; })}
`; } // 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 { return document.createElement('frigate-card-editor'); } // Get a stub basic config. public static getStubConfig(): Record { 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 { const buttons: Map = 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( schema: ZodSchema, request: MessageBase, ): Promise { if (!this._hass) { return null; } const response = await this._hass.callWS(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 { 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 { 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 { 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`
${message ? html` ${message}` : ''}
`; } // Render an embedded error situation. protected _renderError(error: string): TemplateResult { return this._renderAttentionIcon( 'mdi:alert-circle', html`${ error ? `${error} .` : `Unknown error` }Check troubleshooting.`, ); } // Render Frigate events into a card gallery. protected async _renderEvents(): Promise { 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`
    ${media.children.map( (mediaSource) => html`
  • ${mediaSource.can_expand ? html`
    { this._changeView(this._viewMode, mediaSource); }} outlined="" class="frigate-card-image-list-folder" >
    ${mediaSource.title}
    ` : html` { this._changeView(want_clips ? 'clip' : 'snapshot', mediaSource); }} />`}
  • `, )}
`; } // Render a progress spinner while content loads. protected _renderProgressIndicator(): TemplateResult { return html`
`; } // 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