From f5ce02fe4aa157ae20eaa70536f9103080a5d432 Mon Sep 17 00:00:00 2001 From: Dermot Duffy Date: Sat, 18 Feb 2023 11:03:12 -0800 Subject: [PATCH] Generalize camera endpoints. --- src/camera-manager/engine.ts | 9 +- src/camera-manager/frigate/engine-frigate.ts | 124 +++++++++++++------ src/camera-manager/generic/engine-generic.ts | 9 +- src/camera-manager/manager.ts | 10 +- src/camera-manager/types.ts | 13 +- src/card.ts | 21 ++-- src/components/live/live-go2rtc.ts | 48 ++++--- src/components/live/live-jsmpeg.ts | 68 ++++------ src/components/live/live.ts | 10 ++ src/localize/languages/en.json | 2 +- src/localize/languages/it.json | 2 +- src/localize/languages/pt-BR.json | 2 +- src/utils/endpoint.ts | 33 +++++ 13 files changed, 219 insertions(+), 132 deletions(-) create mode 100644 src/utils/endpoint.ts diff --git a/src/camera-manager/engine.ts b/src/camera-manager/engine.ts index 533a02db..833990be 100644 --- a/src/camera-manager/engine.ts +++ b/src/camera-manager/engine.ts @@ -18,9 +18,10 @@ import { CameraManagerCameraCapabilities, CameraManagerMediaCapabilities, CameraManagerCameraMetadata, - CameraURLContext, + CameraEndpointsContext, CameraConfigs, Engine, + CameraEndpoints, } from './types'; export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000; @@ -118,8 +119,8 @@ export interface CameraManagerEngine { getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null; - getCameraURL( + getCameraEndpoints( cameraConfig: CameraConfig, - context?: CameraURLContext, - ): string | null; + context?: CameraEndpointsContext, + ): CameraEndpoints | null; } diff --git a/src/camera-manager/frigate/engine-frigate.ts b/src/camera-manager/frigate/engine-frigate.ts index dc63dc76..04db5054 100644 --- a/src/camera-manager/frigate/engine-frigate.ts +++ b/src/camera-manager/frigate/engine-frigate.ts @@ -36,8 +36,10 @@ import { RecordingSegment, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, - CameraURLContext, + CameraEndpointsContext, CameraConfigs, + CameraEndpoints, + CameraEndpoint, } from '../types'; import { FrigateRecording } from './types'; import { @@ -1006,47 +1008,91 @@ export class FrigateCameraManagerEngine }; } - public getCameraURL( + public getCameraEndpoints( cameraConfig: CameraConfig, - context?: CameraURLContext, - ): string | null { - if (!cameraConfig.frigate.url) { - return null; - } - if (!cameraConfig.frigate.camera_name) { - return cameraConfig.frigate.url; - } + context?: CameraEndpointsContext, + ): CameraEndpoints | null { + const getUIEndpoint = (): CameraEndpoint | null => { + if (!cameraConfig.frigate.url) { + return null; + } + if (!cameraConfig.frigate.camera_name) { + return { endpoint: cameraConfig.frigate.url }; + } - const eventsURL = - `${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name; - const recordingsURL = - `${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name; + const cameraURL = + `${cameraConfig.frigate.url}/cameras/` + cameraConfig.frigate.camera_name; - // If media is available, use it since it may result in a more precisely - // correct URL. - switch (context?.media?.getMediaType()) { - case 'clip': - case 'snapshot': - return eventsURL; - case 'recording': - const startTime = context.media.getStartTime(); - if (startTime) { - return recordingsURL + format(startTime, 'yyyy-MM-dd/HH'); + if (context?.view === 'live') { + return { endpoint: cameraURL }; + } + + const eventsURL = + `${cameraConfig.frigate.url}/events?camera=` + cameraConfig.frigate.camera_name; + const recordingsURL = + `${cameraConfig.frigate.url}/recording/` + cameraConfig.frigate.camera_name; + + // If media is available, use it since it may result in a more precisely + // correct URL. + switch (context?.media?.getMediaType()) { + case 'clip': + case 'snapshot': + return { endpoint: eventsURL }; + case 'recording': + const startTime = context.media.getStartTime(); + if (startTime) { + return { endpoint: recordingsURL + format(startTime, 'yyyy-MM-dd/HH') }; + } + } + + // Otherwise, fall back to just using the view if we have that. + switch (context?.view) { + case 'clip': + case 'clips': + case 'snapshots': + case 'snapshot': + return { endpoint: eventsURL }; + case 'recording': + case 'recordings': + return { endpoint: recordingsURL }; + } + + return { + endpoint: cameraURL, + }; + }; + + const getGo2RTC = (): CameraEndpoint | null => { + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + // go2rtc is exposed by the integration under the (slightly + // misleading) 'mse' path, even though that path can serve all go2rtc + // modes. + `/mse/api/ws?src=${cameraConfig.frigate.camera_name}`, + sign: true, + }; + }; + + const getJSMPEG = (): CameraEndpoint | null => { + return { + endpoint: + `/api/frigate/${cameraConfig.frigate.client_id}` + + `/jsmpeg/${cameraConfig.frigate.camera_name}`, + sign: true, + }; + }; + + const ui = getUIEndpoint(); + const go2rtc = getGo2RTC(); + const jsmpeg = getJSMPEG(); + + return ui || go2rtc || jsmpeg + ? { + ...(ui && { ui: ui }), + ...(go2rtc && { go2rtc: go2rtc }), + ...(jsmpeg && { jsmpeg: jsmpeg }), } - } - - // Otherwise, fall back to just using the view if we have that. - switch (context?.view) { - case 'clip': - case 'clips': - case 'snapshots': - case 'snapshot': - return eventsURL; - case 'recording': - case 'recordings': - return recordingsURL; - } - - return `${cameraConfig.frigate.url}/cameras/${cameraConfig.frigate.camera_name}`; + : null; } } diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index 6cbd0110..3a990414 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -16,12 +16,13 @@ import { RecordingQueryResultsMap, RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, - CameraURLContext, + CameraEndpointsContext, CameraConfigs, RecordingQuery, QueryReturnType, CameraManagerCameraCapabilities, Engine, + CameraEndpoints, } from '../types'; import { getEntityIcon, getEntityTitle } from '../../utils/ha'; import { EntityRegistryManager } from '../../utils/ha/entity-registry'; @@ -177,10 +178,10 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { return null; } - public getCameraURL( + public getCameraEndpoints( _cameraConfig: CameraConfig, - _context?: CameraURLContext, - ): string | null { + _context?: CameraEndpointsContext, + ): CameraEndpoints | null { return null; } } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index a1d6b433..9665a167 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -6,7 +6,7 @@ import { CameraManagerCameraMetadata, CameraManagerCapabilities, CameraManagerMediaCapabilities, - CameraURLContext, + CameraEndpointsContext, DataQuery, EventQuery, EventQueryResults, @@ -29,6 +29,7 @@ import { RecordingSegmentsQueryResults, RecordingSegmentsQueryResultsMap, ResultsMap, + CameraEndpoints, } from './types.js'; import orderBy from 'lodash-es/orderBy'; import { CameraManagerEngineFactory } from './engine-factory.js'; @@ -643,13 +644,16 @@ export class CameraManager { ); } - public getCameraURL(cameraID: string, context?: CameraURLContext): string | null { + public getCameraEndpoints( + cameraID: string, + context?: CameraEndpointsContext, + ): CameraEndpoints | null { const cameraConfig = this._store.getCameraConfig(cameraID); const engine = this._store.getEngineForCameraID(cameraID); if (!cameraConfig || !engine) { return null; } - return engine.getCameraURL(cameraConfig, context); + return engine.getCameraEndpoints(cameraConfig, context); } public getCameraMetadata( diff --git a/src/camera-manager/types.ts b/src/camera-manager/types.ts index ee660957..73ab4506 100644 --- a/src/camera-manager/types.ts +++ b/src/camera-manager/types.ts @@ -106,11 +106,22 @@ export interface CameraManagerCameraMetadata { icon: string; } -export interface CameraURLContext { +export interface CameraEndpointsContext { media?: ViewMedia; view?: FrigateCardView; } +export interface CameraEndpoint { + endpoint: string; + sign?: boolean; +} + +export interface CameraEndpoints { + ui?: CameraEndpoint; + go2rtc?: CameraEndpoint; + jsmpeg?: CameraEndpoint; +} + export type CameraConfigs = Map; // =========== diff --git a/src/card.ts b/src/card.ts index 1b16575b..f055b4c2 100644 --- a/src/card.ts +++ b/src/card.ts @@ -1461,15 +1461,18 @@ class FrigateCard extends LitElement { * @returns The URL or null if unavailable. */ protected _getCameraURLFromContext(): string | null { - const view = this._view; - const selectedCameraID = view?.camera; - const media = view?.queryResults?.getSelectedResult() ?? null; - return this._hass && view && selectedCameraID - ? this._cameraManager?.getCameraURL(selectedCameraID, { - ...(media && { media: media }), - ...(view && { view: view.view }), - }) ?? null - : null; + if (!this._view) { + return null; + } + + const selectedCameraID = this._view.camera; + const media = this._view.queryResults?.getSelectedResult() ?? null; + const endpoints = + this._cameraManager?.getCameraEndpoints(selectedCameraID, { + view: this._view.view, + ...(media && { media: media }), + }) ?? null; + return endpoints?.ui?.endpoint ?? null; } /** diff --git a/src/components/live/live-go2rtc.ts b/src/components/live/live-go2rtc.ts index cc06f16e..20509c73 100644 --- a/src/components/live/live-go2rtc.ts +++ b/src/components/live/live-go2rtc.ts @@ -7,7 +7,7 @@ import { unsafeCSS, } from 'lit'; import { customElement, property } from 'lit/decorators.js'; -import liveMSEStyle from '../../scss/live-mse-webrtc.scss'; +import liveMSEStyle from '../../scss/live-go2rtc.scss'; import { CameraConfig, ExtendedHomeAssistant } from '../../types.js'; import '../image.js'; import { @@ -18,8 +18,8 @@ import { dispatchMediaLoadedEvent } from '../../utils/media-info'; import { localize } from '../../localize/localize'; import { dispatchErrorMessageEvent } from '../message'; import { VideoRTC } from '../../external/go2rtc/video-rtc'; -import { homeAssistantSignPath } from '../../utils/ha'; -import { errorToConsole } from '../../utils/basic'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { getEndpointAddressOrDispatchError } from '../../utils/endpoint'; // Note (2023-02-18): Depending on the behavior of the player / browser is // possible this URL will need to be re-signed in order to avoid HA spamming @@ -58,6 +58,9 @@ export class FrigateCardGo2RTC extends LitElement { @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + protected _player?: FrigateCardGo2RTCPlayer; public play(): void { @@ -87,36 +90,29 @@ export class FrigateCardGo2RTC extends LitElement { } protected async _createPlayer(): Promise { - if ( - !this.hass || - !this.cameraConfig?.frigate.client_id || - !this.cameraConfig.frigate.camera_name - ) { + if (!this.hass) { return; } - let response: string | null | undefined; - try { - response = await homeAssistantSignPath( - this.hass, - `/api/frigate/${this.cameraConfig.frigate.client_id}` + - `/mse/api/ws?src=${this.cameraConfig.frigate.camera_name}`, - GO2RTC_URL_SIGN_EXPIRY_SECONDS, - ); - } catch (e) { - errorToConsole(e as Error); - return; + const endpoint = this.cameraEndpoints?.go2rtc; + if (!endpoint) { + return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { + context: this.cameraConfig, + }); } - if (!response) { + + const address = await getEndpointAddressOrDispatchError( + this, + this.hass, + endpoint, + GO2RTC_URL_SIGN_EXPIRY_SECONDS, + ); + if (!address) { return; } - const url = response.replace(/^http/i, 'ws'); - if (!url) { - return dispatchErrorMessageEvent(this, localize('error.failed_sign')); - } this._player = new FrigateCardGo2RTCPlayer(); - this._player.src = url; + this._player.src = address; this._player.visibilityCheck = false; this._player.background = true; this._player.mode = 'webrtc'; @@ -125,7 +121,7 @@ export class FrigateCardGo2RTC extends LitElement { } protected willUpdate(changedProps: PropertyValues): void { - if (changedProps.has('cameraConfig')) { + if (changedProps.has('cameraEndpoints')) { this._createPlayer(); } } diff --git a/src/components/live/live-jsmpeg.ts b/src/components/live/live-jsmpeg.ts index 0b10e733..55f3ef9c 100644 --- a/src/components/live/live-jsmpeg.ts +++ b/src/components/live/live-jsmpeg.ts @@ -11,10 +11,11 @@ import { ExtendedHomeAssistant, JSMPEGConfig, } from '../../types.js'; -import { homeAssistantSignPath } from '../../utils/ha'; import { dispatchMediaLoadedEvent } from '../../utils/media-info.js'; import { dispatchErrorMessageEvent } from '../message.js'; -import { contentsChanged, errorToConsole } from '../../utils/basic.js'; +import { contentsChanged } from '../../utils/basic.js'; +import { CameraEndpoints } from '../../camera-manager/types.js'; +import { getEndpointAddressOrDispatchError } from '../../utils/endpoint.js'; // Number of seconds a signed URL is valid for. const JSMPEG_URL_SIGN_EXPIRY_SECONDS = 24 * 60 * 60; @@ -27,6 +28,9 @@ export class FrigateCardLiveJSMPEG extends LitElement { @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + @property({ attribute: false, hasChanged: contentsChanged }) public jsmpegConfig?: JSMPEGConfig; @@ -83,37 +87,6 @@ export class FrigateCardLiveJSMPEG extends LitElement { // JSMPEG does not support seeking. } - /** - * Get a signed player URL. - * @returns A URL or null. - */ - protected async _getURL(): Promise { - if ( - !this.hass || - !this.cameraConfig?.frigate.client_id || - !this.cameraConfig?.frigate.camera_name - ) { - return null; - } - - let response: string | null | undefined; - try { - response = await homeAssistantSignPath( - this.hass, - `/api/frigate/${this.cameraConfig.frigate.client_id}` + - `/jsmpeg/${this.cameraConfig.frigate.camera_name}`, - JSMPEG_URL_SIGN_EXPIRY_SECONDS, - ); - } catch (e) { - errorToConsole(e as Error); - return null; - } - if (!response) { - return null; - } - return response.replace(/^http/i, 'ws'); - } - /** * Create a JSMPEG player. * @param url The URL for the player to connect to. @@ -205,26 +178,35 @@ export class FrigateCardLiveJSMPEG extends LitElement { * Refresh the JSMPEG player. */ protected async _refreshPlayer(): Promise { + if (!this.hass) { + return; + } this._resetPlayer(); this._jsmpegCanvasElement = document.createElement('canvas'); this._jsmpegCanvasElement.className = 'media'; - if (!this.cameraConfig?.frigate.camera_name) { - return dispatchErrorMessageEvent(this, localize('error.no_camera_name'), { + const endpoint = this.cameraEndpoints?.jsmpeg; + if (!endpoint) { + return dispatchErrorMessageEvent(this, localize('error.live_camera_no_endpoint'), { context: this.cameraConfig, }); } - const url = await this._getURL(); - if (url) { - this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(url); - this._refreshPlayerTimerID = window.setTimeout(() => { - this.requestUpdate(); - }, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); - } else { - dispatchErrorMessageEvent(this, localize('error.jsmpeg_no_sign')); + const address = await getEndpointAddressOrDispatchError( + this, + this.hass, + endpoint, + JSMPEG_URL_SIGN_EXPIRY_SECONDS, + ); + if (!address) { + return; } + + this._jsmpegVideoPlayer = await this._createJSMPEGPlayer(address); + this._refreshPlayerTimerID = window.setTimeout(() => { + this.requestUpdate(); + }, (JSMPEG_URL_SIGN_EXPIRY_SECONDS - JSMPEG_URL_SIGN_REFRESH_THRESHOLD_SECONDS) * 1000); } /** diff --git a/src/components/live/live.ts b/src/components/live/live.ts index b6969aac..d0d008f5 100644 --- a/src/components/live/live.ts +++ b/src/components/live/live.ts @@ -54,6 +54,7 @@ import { CameraManager } from '../../camera-manager/manager.js'; import { HomeAssistant } from 'custom-card-helpers'; import { dispatchMessageEvent, dispatchErrorMessageEvent } from '../message.js'; import { HassEntity } from 'home-assistant-js-websocket'; +import { CameraEndpoints } from '../../camera-manager/types.js'; /** * Get the state object or dispatch an error. Used in `ha` and `image` live @@ -514,6 +515,10 @@ export class FrigateCardLiveCarousel extends LitElement { this.cameraManager?.getCameraEndpoints(cameraID), + )} .label=${cameraMetadata?.title ?? ''} .liveConfig=${config} .hass=${this.hass} @@ -666,6 +671,9 @@ export class FrigateCardLiveProvider extends LitElement { @property({ attribute: false }) public cameraConfig?: CameraConfig; + @property({ attribute: false }) + public cameraEndpoints?: CameraEndpoints; + @property({ attribute: false }) public liveConfig?: LiveConfig; @@ -858,6 +866,7 @@ export class FrigateCardLiveProvider extends LitElement { class=${classMap(providerClasses)} .hass=${this.hass} .cameraConfig=${this.cameraConfig} + .cameraEndpoints=${this.cameraEndpoints} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} > ` @@ -878,6 +887,7 @@ export class FrigateCardLiveProvider extends LitElement { class=${classMap(providerClasses)} .hass=${this.hass} .cameraConfig=${this.cameraConfig} + .cameraEndpoints=${this.cameraEndpoints} .jsmpegConfig=${this.liveConfig.jsmpeg} .cardWideConfig=${this.cardWideConfig} @frigate-card:media:loaded=${this._videoMediaShowHandler.bind(this)} diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index 1f818c0f..0cf1b12e 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -353,7 +353,7 @@ "invalid_elements_config": "Invalid picture elements configuration", "invalid_response": "Received invalid response from Home Assistant for request", "jsmpeg_no_player": "Could not start JSMPEG player", - "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", + "live_camera_no_endpoint": "Could not get camera endpoint for this live provider (incomplete configuration?)", "live_camera_not_found": "The configured camera_entity was not found", "live_camera_unavailable": "Camera unavailable", "no_camera_engine": "Could not determine suitable engine for camera", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index 3544dff4..e5a2f728 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -342,7 +342,7 @@ "invalid_elements_config": "Configurazione degli elementi di immagine non valida", "invalid_response": "Ricevuta una risposta non valida da Home Assistant per la richiesta", "jsmpeg_no_player": "Impossibile avviare JSMPEG Player", - "jsmpeg_no_sign": "Impossibile recuperare o firmare il percorso WebSocket JSMPEG", + "live_camera_no_endpoint": "", "live_camera_not_found": "La telecamera configurata non è stata trovata", "live_camera_unavailable": "Telecamera non disponibile", "no_camera_engine": "", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 13f24aa7..969c0c47 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -342,7 +342,7 @@ "invalid_elements_config": "Configuração de elementos de imagem inválida", "invalid_response": "Resposta inválida recebida do Home Assistant para a solicitação", "jsmpeg_no_player": "Não foi possível iniciar o player JSMPEG", - "jsmpeg_no_sign": "Não foi possível recuperar ou assinar o caminho do websocket JSMPEG", + "live_camera_no_endpoint": "", "live_camera_not_found": "", "live_camera_unavailable": "", "no_camera_engine": "", diff --git a/src/utils/endpoint.ts b/src/utils/endpoint.ts new file mode 100644 index 00000000..58e5605b --- /dev/null +++ b/src/utils/endpoint.ts @@ -0,0 +1,33 @@ +import { CameraEndpoint } from '../camera-manager/types'; +import { dispatchErrorMessageEvent } from '../components/message'; +import { localize } from '../localize/localize'; +import { ExtendedHomeAssistant } from '../types'; +import { errorToConsole } from './basic'; +import { homeAssistantSignPath } from './ha'; + +export const getEndpointAddressOrDispatchError = async ( + element: HTMLElement, + hass: ExtendedHomeAssistant, + endpoint: CameraEndpoint, + expires?: number, +): Promise => { + let address: string | null; + if (!endpoint.sign) { + address = endpoint.endpoint; + } else { + let response: string | null | undefined; + try { + response = await homeAssistantSignPath(hass, endpoint.endpoint, expires); + } catch (e) { + errorToConsole(e as Error); + return null; + } + address = response ? response.replace(/^http/i, 'ws') : null; + } + + if (!address) { + dispatchErrorMessageEvent(element, localize('error.failed_sign')); + return null; + } + return address; +};