// TODO Check for HA state presence and validity before using it, otherwise warn. // TODO Add material tooltips // TODO Action handlers. // TODO _getEvents may throw errors, catch them when called. // TODO Can I use Zod for FrigateCardConfig validation? /* eslint-disable @typescript-eslint/no-explicit-any */ import { LitElement, html, customElement, property, CSSResult, TemplateResult, PropertyValues, state, unsafeCSS, } from 'lit-element'; import { until } from 'lit-html/directives/until.js'; import { HomeAssistant, ActionHandlerEvent, handleAction, LovelaceCardEditor, getLovelace, } from 'custom-card-helpers'; import './editor'; import style from './frigate-card.scss' import { frigateEventSchema, frigateGetEventsResponseSchema } from './types'; import type { FrigateCardConfig, FrigateEvent, FrigateGetEventsResponse, GetEventsParameters, ControlVideosParameters } from './types'; import { actionHandler } from './action-handler-directive'; import { CARD_VERSION } from './const'; import { localize } from './localize/localize'; /* 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', }); enum FrigateCardView { LIVE, // Show the live camera. CLIP, // Show a clip video. CLIPS, // Show the clips gallery. SNAPSHOT, // Show a snapshot. SNAPSHOTS, // Show the snapshots gallery. } // Main FrigateCard class. @customElement('frigate-card') export class FrigateCard extends LitElement { // Constructor for FrigateCard. constructor() { super(); this._viewMode = FrigateCardView.LIVE; this._viewEvent = null; this._interactionTimerID = null; } // 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 {}; } @property({ attribute: false }) public hass!: HomeAssistant; @state() public config!: FrigateCardConfig; @property({ attribute: false }) protected _viewMode: FrigateCardView; @property({ attribute: false }) protected _viewEvent: FrigateEvent | null; protected _interactionTimerID: number | null; // Set the object configuration. public setConfig(inputConfig: FrigateCardConfig): void { if (!inputConfig) { throw new Error(localize('common.invalid_configuration:')); } // inputConfig is not "extensible" (i.e. preventExtensions() has been // called on it), need to make a copy to allow modifications. const cardConfig = Object.assign({ name: 'Frigate' }, inputConfig); if (cardConfig.test_gui) { getLovelace().setEditMode(true); } if (!cardConfig.frigate_url) { throw new Error(localize('common.invalid_configuration_missing') + ": frigate_url"); } if (!cardConfig.frigate_camera_name) { // No camera name specified, so just assume it's the same as the entity name. if (cardConfig.camera_entity.includes(".")) { cardConfig.frigate_camera_name = cardConfig.camera_entity.split('.', 2)[1] } else { throw new Error(localize('common.invalid_configuration_missing') + ": camera_entity"); } } if (cardConfig.view_timeout) { if (isNaN(Number(cardConfig.view_timeout))) { throw new Error(localize('common.invalid_configuration') + ": view_timeout"); } } if (cardConfig.view_default) { if (!["live", "clips", "clip", "snapshots", "snapshot"].includes(cardConfig.view_default)) { throw new Error(localize('common.invalid_configuration') + ": view_default"); } } this.config = cardConfig; this._setViewModeToDefault(); } // Set the view mode to the configured default. protected _setViewModeToDefault(): void { if (this.config.view_default == "live") { this._viewMode = FrigateCardView.LIVE; } else if (this.config.view_default == "clips") { this._viewMode = FrigateCardView.CLIPS; } else if (this.config.view_default == "clip") { this._viewMode = FrigateCardView.CLIP; this._viewEvent = null; } else if (this.config.view_default == "snapshots") { this._viewMode = FrigateCardView.SNAPSHOTS; } else if (this.config.view_default == "snapshot") { this._viewMode = FrigateCardView.SNAPSHOT; this._viewEvent = null; } } // == RTC experimentation == // const div = document.createElement("div"); // const webrtcElement = customElements.get('webrtc-camera'); // const webrtc = new webrtcElement(); // webrtc.setConfig({ "entity": "camera.landing_rtsp" }); // webrtc.hass = this.hass; // div.appendChild(webrtc); // this.renderRoot.appendChild(div); // == // Determine whether the card should be updated. protected shouldUpdate(changedProps: PropertyValues): boolean { if (!this.config || !this.hass) { return false; } const cameraEntity = this.config.camera_entity; const motionEntity = this.config.motion_entity; if (!cameraEntity) { return false; } if (changedProps.has('config')) { return true; } const oldHass = changedProps.get('hass') as HomeAssistant | undefined; if (oldHass) { if (oldHass.states[cameraEntity] !== this.hass.states[cameraEntity]) { return true; } if (motionEntity && oldHass.states[motionEntity] !== this.hass.states[motionEntity]) { return true; } return false; } return true; } // Get FrigateEvents from the Frigate server API. protected async _getEvents({ has_clip = false, has_snapshot = false, limit = 100, }: GetEventsParameters): Promise { let url = `${this.config.frigate_url}/api/events?camera=${this.config.frigate_camera_name}`; if (has_clip) { url += `&has_clip=1` } if (has_snapshot) { url += `&has_snapshot=1` } if (limit > 0) { url += `&limit=${limit}` } if (this.config.label) { url += `&label=${this.config.label}`; } const response = await fetch(url); if (response.ok) { let raw_json; try { raw_json = await response.json(); } catch(e) { throw new Error(`Could not JSON decode Frigate API response: ${e}`); } try { return frigateGetEventsResponseSchema.parse(raw_json); } catch(e) { throw new Error(`Frigate events were malformed: ${e}`); } } else { // TODO: Catch when json decoding fails. throw new Error(`Frigate API request failed with status: ${response.status}`); } } // Render Frigate events into a card gallery. protected async _renderEvents() : Promise { const want_clips = this._viewMode == FrigateCardView.CLIPS; const events = await this._getEvents({ has_clip: want_clips, has_snapshot: !want_clips, }); if (!events.length) { return html`
` } return html`
    ${events.map(event => html`
  • { this._viewEvent = event; this._viewMode = want_clips ? FrigateCardView.CLIP : FrigateCardView.SNAPSHOT }} >
  • `)}
`; } // 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) { // 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( `#frigate-card-clip-player`) 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