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

544 lines
16 KiB
TypeScript

// 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<LovelaceCardEditor> {
return document.createElement('frigate-card-editor');
}
// Get a stub basic config.
public static getStubConfig(): Record<string, string> {
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<FrigateGetEventsResponse> {
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<TemplateResult> {
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`
<div class="frigate-card-exception">
<ha-icon
icon="${want_clips ? "mdi:filmstrip-off" : "mdi:camera-off"}"
></ha-icon>
</div>`
}
return html`
<ul class= "mdc-image-list frigate-card-image-list">
${events.map(event => html`
<li class="mdc-image-list__item">
<div class="mdc-image-list__image-aspect-container">
<img
class="mdc-image-list__image"
src="data:image/png;base64,${event.thumbnail}"
@click=${() => {
this._viewEvent = event;
this._viewMode = want_clips ?
FrigateCardView.CLIP : FrigateCardView.SNAPSHOT
}}
>
</div>
</li>`)}
</ul>`;
}
// Render a progress spinner while content loads.
protected _renderProgressIndicator(): TemplateResult {
return html`
<div class="frigate-card-exception">
<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) {
// 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 <video>.
const ha_camera_stream = this.shadowRoot?.querySelector(`#frigate-card-live-player`);
if (ha_camera_stream && ha_camera_stream?.shadowRoot) {
const ha_hls_player = ha_camera_stream.shadowRoot.querySelector(`ha-hls-player`);
if (ha_hls_player && ha_hls_player?.shadowRoot) {
controlVideo(
stop,
true,
ha_hls_player.shadowRoot.querySelector(`video`) as HTMLVideoElement);
}
}
}
}
// Render the main navbar (live, clips, snapshots).
protected _renderNavigationBar(): TemplateResult {
return html`
<div class="frigate-card-navbar" >
<ha-icon-button
class="button"
icon="mdi:cctv"
@click=${() => {
this._controlVideos({stop: true, control_clip: true});
this._controlVideos({stop: false, control_live: true});
this._viewMode = FrigateCardView.LIVE
}}
></ha-icon-button>
<ha-icon-button
class="button"
icon = "mdi:filmstrip"
@click=${() => {
this._controlVideos({stop: true, control_live: true});
this._viewMode = FrigateCardView.CLIPS
}}
></ha-icon-button>
<ha-icon-button
class="button"
icon = "mdi:camera"
@click=${() => {
this._controlVideos({stop: true, control_clip: true, control_live: true});
this._viewMode = FrigateCardView.SNAPSHOTS
}}
></ha-icon-button>
</div>`
}
// Render the player for a saved clip.
protected async _renderClipPlayer(): Promise<TemplateResult> {
let event: FrigateEvent;
if (this._viewEvent) {
event = this._viewEvent;
} else {
const events = await this._getEvents({
has_clip: true,
limit: 1
});
if (!events.length) {
return html`
<div class="frigate-card-exception">
<ha-icon icon="mdi:camera-off">
</ha-icon>
</div>`
}
event = events[0];
}
const url = `${this.config.frigate_url}/clips/` +
`${event.camera}-${event.id}.mp4`;
return html`
<video id="frigate-card-clip-player" class="frigate-card-viewer" autoplay controls>
<source src="${url}" type="video/mp4">
</video>`
}
// Render a snapshot.
protected async _renderSnapshotViewer(): Promise<TemplateResult> {
let event: FrigateEvent;
if (this._viewEvent) {
event = this._viewEvent;
} else {
const events = await this._getEvents({
has_snapshot: true,
limit: 1
});
if (!events.length) {
return html`
<div class="frigate-card-exception">
<ha-icon icon="mdi:filmstrip-off">
</ha-icon>
</div>`
}
event = events[0];
}
const url = `${this.config.frigate_url}/clips/${event.camera}-${event.id}.jpg`;
return html`<img class="frigate-card-viewer" src="${url}">`
}
// Render the status bar (motion icon).
protected _renderStatusBar(): TemplateResult {
if (!this.config.motion_entity || !(this.config.motion_entity in this.hass.states)) {
return html``;
}
const icon = this.hass.states[this.config.motion_entity].state == "on" ?
"mdi:motion-sensor" : "mdi:walk"
return html`
<div class="frigate-card-statusbar ${
this._viewMode == FrigateCardView.LIVE ? 'visible' : 'invisible'}
">
<ha-icon-button
class="button"
icon="${icon}"
></ha-icon-button>
</div>`
}
// 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 {
return html`
<ha-camera-stream
id="frigate-card-live-player"
.hass=${this.hass}
.stateObj=${this.hass.states[this.config.camera_entity]}
.controls=${true}
.muted=${true}
class=${this._viewMode == FrigateCardView.LIVE ? 'visible' : 'invisible'}
>
</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._setViewModeToDefault();
}, this.config.view_timeout * 1000);
}
// 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`
<div
class="frigate-card-container"
@click=${this._interactionHandler}
>
${this._renderNavigationBar()}
${this._viewMode == FrigateCardView.CLIPS ?
html`<div class="frigate-card-gallery">
${until(this._renderEvents(), this._renderProgressIndicator())}
</div>` : ``
}
${this._viewMode == FrigateCardView.SNAPSHOTS ?
html`<div class="frigate-card-gallery">
${until(this._renderEvents(), this._renderProgressIndicator())}
</div>` : ``
}
${this._viewMode == FrigateCardView.CLIP ?
html`<div class="frigate-card-viewer">
${until(this._renderClipPlayer(), this._renderProgressIndicator())}
</div>` : ``
}
${this._viewMode == FrigateCardView.SNAPSHOT ?
html`<div class="frigate-card-viewer">
${until(this._renderSnapshotViewer(), this._renderProgressIndicator())}
</div>` : ``
}
${this._renderStatusBar()}
${this._renderLiveViewer()}
</div>`;
}
// private _handleAction(ev: ActionHandlerEvent): void {
// if (this.hass && this.config && ev.detail.action) {
// handleAction(this, this.hass, this.config, ev.detail.action);
// }
// }
// 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}
`;
}
// Get the CSS styles. CSS is compiled from frigate-card.scss, so this is
// safe to be piped through `unsafeCSS`.
static get styles(): CSSResult {
return unsafeCSS(style);
}
// Get the Lovelace card size.
static getCardSize(): number {
return 5;
}
}