/* eslint-disable @typescript-eslint/no-explicit-any */ import { CSSResultGroup, LitElement, PropertyValues, TemplateResult, html, unsafeCSS, } from 'lit'; import { customElement, property, query, state } from 'lit/decorators'; import { classMap } from 'lit/directives/class-map.js'; import { styleMap } from 'lit/directives/style-map.js'; import { HomeAssistant, LovelaceCardEditor, fireEvent, getLovelace, stateIcon, } from 'custom-card-helpers'; import { MenuButton, frigateCardConfigSchema } from './types'; import type { BrowseMediaQueryParameters, ExtendedHomeAssistant, FrigateCardConfig, MediaLoadInfo, } from './types'; import { CARD_VERSION } from './const'; import { FrigateCardMenu } from './components/menu'; import { View } from './view'; import { getParseErrorKeys } from './common'; import { localize } from './localize/localize'; import './editor'; import './components/gallery'; import './components/live'; import './components/menu'; import './components/message'; import './components/viewer'; import './patches/ha-camera-stream'; import './patches/ha-hls-player'; import cardStyle from './scss/card.scss'; const MEDIA_HEIGHT_CUTOFF = 50; const MEDIA_WIDTH_CUTOFF = MEDIA_HEIGHT_CUTOFF; /* 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: localize('common.frigate_card'), description: localize('common.frigate_card_description'), }); // Determine whether the card should be updated based on Home Assistant changes. function shouldUpdateBasedOnHass( newHass: HomeAssistant | null, oldHass: HomeAssistant | undefined, entities: string[] | null, ): boolean { if (!newHass || !entities) { 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; } // Main FrigateCard class. @customElement('frigate-card') export class FrigateCard extends LitElement { @property({ attribute: false }) protected _hass: (HomeAssistant & ExtendedHomeAssistant) | null = null; @state() public config!: FrigateCardConfig; protected _interactionTimerID: number | null = null; @property({ attribute: false }) protected _view: View = new View(); @query('frigate-card-menu') _menu!: FrigateCardMenu; // Whether or not media is actively playing (live or clip). protected _mediaPlaying = false; // A small cache to avoid needing to create a new list of entities every time // a hass update arrives. protected _entitiesToMonitor: string[] | null = null; // Information about the most recently loaded media item. protected _mediaInfo: MediaLoadInfo | null = null; set hass(hass: HomeAssistant & ExtendedHomeAssistant) { this._hass = hass; this._updateMenu(); } // 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 {}; } 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(); if (this.config.menu_buttons?.frigate ?? true) { buttons.set('frigate', { description: localize('menu.frigate') }); } if (this.config.menu_buttons?.live ?? true) { buttons.set('live', { icon: 'mdi:cctv', description: localize('menu.live'), emphasize: this._view.is('live'), }); } if (this.config.menu_buttons?.clips ?? true) { buttons.set('clips', { icon: 'mdi:filmstrip', description: localize('menu.clips'), emphasize: this._view.is('clips'), }); } if (this.config.menu_buttons?.snapshots ?? true) { buttons.set('snapshots', { icon: 'mdi:camera', description: localize('menu.snapshots'), emphasize: this._view.is('snapshots'), }); } if ((this.config.menu_buttons?.frigate_ui ?? true) && this.config.frigate_url) { buttons.set('frigate_ui', { icon: 'mdi:web', description: localize('menu.frigate_ui'), }); } const entities = this.config.entities || []; for (let i = 0; this._hass && i < entities.length; i++) { if (!entities[i].show) { continue; } const entity = entities[i].entity; const state = this._hass.states[entity]; buttons.set(entity, { description: state.attributes.friendly_name || entity, emphasize: ['on', 'active', 'home'].includes(state.state), icon: entities[i].icon || stateIcon(state), }); } return buttons; } // Set the object configuration. public setConfig(inputConfig: FrigateCardConfig): void { if (!inputConfig) { throw new Error(localize('error.invalid_configuration:')); } const parseResult = frigateCardConfigSchema.safeParse(inputConfig); if (!parseResult.success) { const keys = getParseErrorKeys(parseResult.error); throw new Error(localize('error.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('error.invalid_configuration') + ': camera_entity'); } } this.config = config; this._entitiesToMonitor = [ ...(this.config.entities || []).map((entity) => entity.entity), this.config.camera_entity, ]; this._changeView(); } protected _changeView(view?: View | undefined): void { if (view === undefined) { this._view = new View({ view: this.config.view_default }); } else { this._view = view; } } protected _changeViewHandler(e: CustomEvent): void { this._changeView(e.detail); } // 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) { // Home Assistant pumps a lot of updates through. Re-rendering the card is // necessary at times (e.g. to update the 'clip' view as new clips // arrive), but also is a jarring experience for the user (e.g. if they // are browsing the mini-gallery). Do not allow re-rendering from a Home // Assistant update if there's been recent interaction (e.g. clicks on the // card) or if there is media active playing. if (this._interactionTimerID || this._mediaPlaying) { return false; } return shouldUpdateBasedOnHass(this._hass, oldHass, this._entitiesToMonitor); } return true; } protected _menuActionHandler(name: string): void { switch (name) { case 'frigate': this._changeView(); break; case 'live': case 'clips': case 'snapshots': this._changeView(new View({ view: name })); break; case 'frigate_ui': const frigate_url = this._getFrigateURLFromContext(); if (frigate_url) { window.open(frigate_url); } break; default: // If it's unknown, it's assumed to be an entity_id. fireEvent(this, 'hass-more-info', { entityId: name }); } } // Get the Frigate UI url. protected _getFrigateURLFromContext(): string | null { if (!this.config.frigate_url) { return null; } if (this._view.is('live')) { return `${this.config.frigate_url}/cameras/${this.config.frigate_camera_name}`; } return `${this.config.frigate_url}/events?camera=${this.config.frigate_camera_name}`; } // 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 { const classes = { 'hover-menu': this.config.menu_mode.startsWith('hover-'), }; return html` `; } protected _getBrowseMediaQueryParameters(): BrowseMediaQueryParameters { return { mediaType: this._view.view == 'clips' ? 'clips' : 'snapshots', clientId: this.config.frigate_client_id, // frigate_camera_name cannot be null, it will be set to a default value // in setConfig if not specified in the configuration. // eslint-disable-next-line @typescript-eslint/no-non-null-assertion cameraName: this.config.frigate_camera_name!, label: this.config.label, zone: this.config.zone, }; } protected _playHandler(): void { this._mediaPlaying = true; } protected _pauseHandler(): void { this._mediaPlaying = false; } protected _mediaLoadHandler(e: CustomEvent): void { const mediaInfo = e.detail; // In Safari, with WebRTC, 0x0 is occasionally returned during loading, // so treat anything less than a safety cutoff as bogus. if (mediaInfo.height < MEDIA_HEIGHT_CUTOFF || mediaInfo.width < MEDIA_WIDTH_CUTOFF) { return; } let requestRefresh = false; if ( (this.config.dimensions?.aspect_ratio_mode ?? 'dynamic') == 'dynamic' && (mediaInfo.width != this._mediaInfo?.width || mediaInfo.height != this._mediaInfo?.height) ) { requestRefresh = true; } this._mediaInfo = mediaInfo; if (requestRefresh) { this.requestUpdate(); } } protected _getAspectRatioPadding(): number | null { const aspect_ratio_mode = this.config.dimensions?.aspect_ratio_mode ?? 'dynamic'; // Do not constrain aspect ratio if either it's entire disabled or it's a // media view (i.e. not the gallery) and there's a loaded media item in // dynamic mode (as the aspect_ratio is essentially whatever the media // dimensions are). if ( aspect_ratio_mode == 'unconstrained' || (!this._view.isGalleryView() && aspect_ratio_mode == 'dynamic' && this._mediaInfo) ) { return null; } if (aspect_ratio_mode == 'dynamic' && this._mediaInfo) { return (this._mediaInfo.height / this._mediaInfo.width) * 100; } const default_aspect_ratio = this.config.dimensions?.aspect_ratio; if (default_aspect_ratio) { return (default_aspect_ratio[1] / default_aspect_ratio[0]) * 100; } else { return (9 / 16) * 100; } } // 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')); } const padding = this._getAspectRatioPadding(); let container_style_map = {}; if (padding != null) { container_style_map = { 'padding-top': `${padding}%`, }; } const content_classes = { 'frigate-card-contents': true, absolute: padding != null, }; return html` ${this.config.menu_mode == 'above' ? this._renderMenu() : ''}
${this._view.is('clips') || this._view.is('snapshots') ? html` ` : ``} ${this._view.is('clip') || this._view.is('snapshot') ? html` ` : ``} ${this._view.is('live') ? html` ` : ``}
${this.config.menu_mode != 'above' ? this._renderMenu() : ''}
`; } // Show a warning card. private _showWarning(warning: string): TemplateResult { return html` ${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(): CSSResultGroup { return unsafeCSS(cardStyle); } // Get the Lovelace card size. public getCardSize(): number { if (this._mediaInfo) { return this._mediaInfo.height / 50; } return 6; } }