Generalize camera endpoints.

This commit is contained in:
Dermot Duffy
2023-02-18 11:03:12 -08:00
parent 5767e98501
commit f5ce02fe4a
13 changed files with 219 additions and 132 deletions
+5 -4
View File
@@ -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;
}
+85 -39
View File
@@ -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;
}
}
+5 -4
View File
@@ -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;
}
}
+7 -3
View File
@@ -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(
+12 -1
View File
@@ -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<string, CameraConfig>;
// ===========
+12 -9
View File
@@ -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;
}
/**
+22 -26
View File
@@ -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<void> {
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();
}
}
+25 -43
View File
@@ -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<string | null> {
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<void> {
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);
}
/**
+10
View File
@@ -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 {
<frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${guard(
[this.cameraManager],
() => 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)}
>
</frigate-card-live-webrtc-card>`
@@ -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)}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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": "",
+1 -1
View File
@@ -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": "",
+33
View File
@@ -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<string | null> => {
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;
};