Merge pull request #523 from dermotduffy/event-cameras

Allow cameras to link to dependent_cameras to show events from multiple cameras.
This commit is contained in:
Dermot Duffy
2022-04-23 16:58:57 -07:00
committed by GitHub
16 changed files with 528 additions and 232 deletions
+22
View File
@@ -101,6 +101,7 @@ cameras:
| `icon` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. | | `icon` | Autodetected from `camera_entity` if that is specified. | :heavy_multiplication_x: | The icon to use for this camera in the camera menu and in the next & previous controls when using the `icon` style. |
| `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. | | `webrtc_card` | | :heavy_multiplication_x: | The WebRTC entity/URL to use for this camera with the `webrtc-card` live provider. See below. |
| `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). | | `id` | `camera_entity`, `webrtc_card.entity` or `camera_name` if set (in that preference order). | :heavy_multiplication_x: | An optional identifier to use throughout the card configuration to refer unambiguously to this camera. See [camera IDs](#camera-ids). |
| `dependent_cameras` | | :heavy_multiplication_x: | An optional array of other camera identifiers (see [camera IDs](#camera-ids)). If specified the card will fetch events for this camera and *also* recursively events for the named `dependent_cameras`. All `dependent_cameras` must themselves be a configured camera in the card. This can be useful to group events for cameras that are close together, or to show events for the `birdseye` camera that otherwise would not have events itself.|
<a name="live-providers"></a> <a name="live-providers"></a>
@@ -1287,6 +1288,27 @@ card_mod:
``` ```
</details> </details>
### Using a dependent camera
`dependent_cameras` allows events for other cameras to be shown along with the currently selected camera. For example, this can be used to show events with the `birdseye` camera (since it will not have events of its own).
<details>
<summary>Expand: Using dependent cameras with birdseye</summary>
This example shows events for two other cameras when `birdseye` is selected.
```yaml
[...]
cameras:
- camera_entity: camera.kitchen
- camera_entity: camera.sitting_room
- camera_name: birdseye
dependent_cameras:
- camera.kitchen
- camera.sitting_room
```
</details>
<a name="card-updates"></a> <a name="card-updates"></a>
## Card Refreshes ## Card Refreshes
+6
View File
@@ -54,5 +54,11 @@ export default [
format: 'es', format: 'es',
}, },
plugins: [...plugins], plugins: [...plugins],
// These two files use this at the toplevel, which causes rollup warning
// spam on build: `this` has been rewritten to `undefined`
moduleContext: {
'./node_modules/@formatjs/intl-utils/lib/src/diff.js': 'window',
'./node_modules/@formatjs/intl-utils/lib/src/resolve-locale.js': 'window',
},
}, },
]; ];
+220 -51
View File
@@ -1,16 +1,18 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import type { import {
BrowseMediaQueryParametersBase,
BrowseMediaQueryParameters, BrowseMediaQueryParameters,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
CameraConfig, CameraConfig,
MEDIA_CLASS_PLAYLIST,
MEDIA_TYPE_PLAYLIST,
} from './types.js'; } from './types.js';
import { View } from './view.js'; import { View } from './view.js';
import { frigateBrowseMediaSourceSchema } from './types.js'; import { frigateBrowseMediaSourceSchema } from './types.js';
import { import {
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
dispatchMessageEvent, dispatchMessageEvent,
getCameraTitle,
homeAssistantWSRequest, homeAssistantWSRequest,
} from './common.js'; } from './common.js';
import { localize } from './localize/localize.js'; import { localize } from './localize/localize.js';
@@ -73,11 +75,9 @@ export class BrowseMediaUtil {
type: 'media_source/browse_media', type: 'media_source/browse_media',
media_content_id: media_content_id, media_content_id: media_content_id,
}; };
return homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request); return await homeAssistantWSRequest(hass, frigateBrowseMediaSourceSchema, request);
} }
// Browse Frigate media with query parameters.
/** /**
* Browse Frigate media with a media query. May throw. * Browse Frigate media with a media query. May throw.
* @param hass The HomeAssistant object. * @param hass The HomeAssistant object.
@@ -109,13 +109,100 @@ export class BrowseMediaUtil {
); );
} }
/**
* Browse multiple Frigate media queries. May throw.
* @param hass The HomeAssistant object.
* @param params An array of search parameters to use to search for media.
* @returns A map of FrigateBrowseMediaSource object or null on malformed.
*/
static async multipleBrowseMediaQuery(
hass: HomeAssistant,
params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
): Promise<Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>> {
params = Array.isArray(params) ? params : [params];
const output: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource> = new Map();
await Promise.all(
params.map(async (param: BrowseMediaQueryParameters): Promise<void> => {
output.set(param, await this.browseMediaQuery(hass, param));
}),
);
return output;
}
/**
* Browse multiple Frigate media queries, then merged them. May throw.
* @param hass The HomeAssistant object.
* @param params An array of search parameters to use to search for media.
* @returns A single FrigateBrowseMediaSource object or null on malformed.
*/
static async multipleBrowseMediaQueryMerged(
hass: HomeAssistant,
params: BrowseMediaQueryParameters | BrowseMediaQueryParameters[],
): Promise<FrigateBrowseMediaSource> {
return this.mergeFrigateBrowseMediaSources(
await this.multipleBrowseMediaQuery(hass, params),
);
}
/**
* Merge multiple FrigateBrowseMediaSource into a single. Note that this may
* use information from the query to differentiate results that may otherwise
* be identical.
* @param input A map of query -> result.
* @returns A single FrigateBrowseMediaSource object.
*/
static mergeFrigateBrowseMediaSources(
input: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>,
): FrigateBrowseMediaSource {
const children: FrigateBrowseMediaSource[] = [];
for (const [query, result] of input.entries()) {
for (const child of result.children || []) {
if (this.isTrueMedia(child)) {
children.push(child);
} else {
if (query.title) {
children.push({ ...child, title: `[${query.title}] ${child.title}` });
} else {
children.push(child);
}
}
}
}
const eventSort = (
a: FrigateBrowseMediaSource,
b: FrigateBrowseMediaSource,
): number => {
if (
!a.frigate?.event ||
(b.frigate?.event && b.frigate.event.start_time > a.frigate.event.start_time)
) {
return 1;
}
if (
!b.frigate?.event ||
(a.frigate?.event && b.frigate.event.start_time < a.frigate.event.start_time)
) {
return -1;
}
return 0;
};
return this.createEventParentForChildren('Merged events', children.sort(eventSort));
}
/** /**
* Get the parameters to search for media. * Get the parameters to search for media.
* @returns A BrowseMediaQueryParameters object. * @returns A BrowseMediaQueryParameters object.
*/ */
static getBrowseMediaQueryParametersBase( static getBrowseMediaQueryParameters(
hass: HomeAssistant,
cameraID: string,
cameraConfig?: CameraConfig, cameraConfig?: CameraConfig,
): BrowseMediaQueryParametersBase | null { overrides?: Partial<BrowseMediaQueryParameters>,
): BrowseMediaQueryParameters | null {
if (!cameraConfig || !cameraConfig.camera_name) { if (!cameraConfig || !cameraConfig.camera_name) {
return null; return null;
} }
@@ -124,84 +211,143 @@ export class BrowseMediaUtil {
cameraName: cameraConfig.camera_name, cameraName: cameraConfig.camera_name,
label: cameraConfig.label, label: cameraConfig.label,
zone: cameraConfig.zone, zone: cameraConfig.zone,
title: getCameraTitle(hass, cameraConfig),
cameraID: cameraID,
...overrides,
}; };
} }
/** /**
* Set the mediaType parameter from the current view. * Apply overrides to multiple query parameters.
* @param browseMediaQueryParametersBase The base media query parameters object. * @param parameters An array of query parameters.
* @param view The current view. * @param overrides The overrides to apply.
* @returns A fully populated BrowseMediaQueryParameters or null. * @returns The override query parameters.
*/ */
static setMediaTypeFromView( static overrideMultiBrowseMediaQueryParameters(
browseMediaQueryParametersBase: BrowseMediaQueryParametersBase | null, parameters: BrowseMediaQueryParameters[],
view: View, overrides: Partial<BrowseMediaQueryParameters>,
): BrowseMediaQueryParameters | null { ): BrowseMediaQueryParameters[] {
if ( const output: BrowseMediaQueryParameters[] = [];
!browseMediaQueryParametersBase || parameters.forEach((param) => {
!(view.isClipRelatedView() || view.isSnapshotRelatedView()) output.push({ ...param, ...overrides });
) { });
return null; return output;
}
/**
* Get BrowseMediaQueryParameters for a camera (including its dependencies).
* @param hass Home Assistant object.
* @param cameras Cameras map.
* @param camera Name of the current camera.
* @param mediaType Optional media type to include in the parameters.
* @returns An array of query parameters.
*/
static getFullDependentBrowseMediaQueryParameters(
hass: HomeAssistant,
cameras: Map<string, CameraConfig>,
camera: string,
mediaType?: 'clips' | 'snapshots',
): BrowseMediaQueryParameters[] | null {
const cameraIDs: Set<string> = new Set();
const getDependentCameras = (camera: string): void => {
const cameraConfig = cameras.get(camera);
if (cameraConfig) {
cameraIDs.add(camera);
for (const eventCameraID of cameraConfig.dependent_cameras || []) {
if (!cameraIDs.has(eventCameraID)) {
getDependentCameras(eventCameraID);
}
}
}
};
getDependentCameras(camera);
const params: BrowseMediaQueryParameters[] = [];
for (const cameraID of cameraIDs) {
const param = BrowseMediaUtil.getBrowseMediaQueryParameters(
hass,
cameraID,
cameras.get(cameraID),
mediaType ? { mediaType: mediaType } : {},
);
// Fail on a single bad camera, as it's almost certainly a user error that
// should be fixed rather than hidden.
if (!param) {
return null;
}
params.push(param);
} }
return { return params.length ? params : null;
...browseMediaQueryParametersBase,
mediaType: view.isClipRelatedView() ? 'clips' : 'snapshots',
};
} }
/** /**
* Get the parameters to search for media related to the current view. * Get BrowseMediaQueryParameters for a camera (including its dependencies) or dispatch an error.
* @returns A BrowseMediaQueryParameters object. * @param element The element from which to dispatch the error.
* @param hass Home Assistant object.
* @param cameras Cameras map.
* @param camera Name of the current camera.
* @param mediaType Optional media type to include in the parameters.
* @returns An array of query parameters.
*/ */
static getBrowseMediaQueryParametersBaseOrDispatchError( static getFullDependentBrowseMediaQueryParametersOrDispatchError(
node: HTMLElement, element: HTMLElement,
cameraConfig: CameraConfig, hass: HomeAssistant,
): BrowseMediaQueryParametersBase | null { cameras: Map<string, CameraConfig>,
// Verify there is a camera name, otherwise getBrowseMediaQueryParametersBase() camera: string,
// will return undefined. mediaType?: 'clips' | 'snapshots',
if (!cameraConfig.camera_name) { ): BrowseMediaQueryParameters[] | null {
const params = this.getFullDependentBrowseMediaQueryParameters(
hass,
cameras,
camera,
mediaType,
);
if (!params) {
dispatchErrorMessageEvent( dispatchErrorMessageEvent(
node, element,
localize('error.no_camera_name') + `: ${JSON.stringify(cameraConfig)}`, localize('error.no_camera_name'),
cameras.get(camera),
); );
return null; return null;
} }
return params;
return BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig);
} }
/** /**
* Fetch the latest media and dispatch a change view event to reflect the * Fetch the latest media and dispatch a change view event to reflect the
* results. If no media is found a suitable message event will be triggered * results. If no media is found a suitable message event will be triggered
* instead. * instead.
* @param node The HTMLElement to dispatch events from. * @param element The HTMLElement to dispatch events from.
* @param hass The Home Assistant object. * @param hass The Home Assistant object.
* @param view The current view to evolve. * @param view The current view to evolve.
* @param browseMediaQueryParameters The media parameters to query with. * @param browseMediaQueryParameters The media parameters to query with.
* @returns * @returns
*/ */
static async fetchLatestMediaAndDispatchViewChange( static async fetchLatestMediaAndDispatchViewChange(
node: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,
view: Readonly<View>, view: Readonly<View>,
browseMediaQueryParameters: BrowseMediaQueryParameters, browseMediaQueryParameters:
| BrowseMediaQueryParameters
| BrowseMediaQueryParameters[],
): Promise<void> { ): Promise<void> {
let parent: FrigateBrowseMediaSource | null; let parent: FrigateBrowseMediaSource | null;
try { try {
parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaQueryParameters); parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(
hass,
browseMediaQueryParameters,
);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(node, (e as Error).message); return dispatchErrorMessageEvent(element, (e as Error).message);
} }
const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent); const childIndex = BrowseMediaUtil.getFirstTrueMediaChildIndex(parent);
if (!parent || !parent.children || childIndex == null) { if (!parent || !parent.children || childIndex == null) {
return dispatchMessageEvent( return dispatchMessageEvent(
node, element,
browseMediaQueryParameters.mediaType == 'clips' view.isClipRelatedView()
? localize('common.no_clip') ? localize('common.no_clip')
: localize('common.no_snapshot'), : localize('common.no_snapshot'),
browseMediaQueryParameters.mediaType == 'clips' view.isClipRelatedView() ? 'mdi:filmstrip-off' : 'mdi:camera-off',
? 'mdi:filmstrip-off'
: 'mdi:camera-off',
); );
} }
@@ -210,7 +356,7 @@ export class BrowseMediaUtil {
target: parent, target: parent,
childIndex: childIndex, childIndex: childIndex,
}) })
.dispatchChangeEvent(node); .dispatchChangeEvent(element);
} }
/** /**
@@ -223,7 +369,7 @@ export class BrowseMediaUtil {
* @returns * @returns
*/ */
static async fetchChildMediaAndDispatchViewChange( static async fetchChildMediaAndDispatchViewChange(
node: HTMLElement, element: HTMLElement,
hass: HomeAssistant, hass: HomeAssistant,
view: Readonly<View>, view: Readonly<View>,
child: Readonly<FrigateBrowseMediaSource>, child: Readonly<FrigateBrowseMediaSource>,
@@ -232,13 +378,36 @@ export class BrowseMediaUtil {
try { try {
parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id); parent = await BrowseMediaUtil.browseMedia(hass, child.media_content_id);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(node, (e as Error).message); return dispatchErrorMessageEvent(element, (e as Error).message);
} }
view view
.evolve({ .evolve({
target: parent, target: parent,
}) })
.dispatchChangeEvent(node); .dispatchChangeEvent(element);
}
/**
* Given an array of media children, create a parent for them.
* @param title The title to use for the parent.
* @param children The children media items.
* @returns A single parent containing the children.
*/
static createEventParentForChildren(
title: string,
children: FrigateBrowseMediaSource[],
): FrigateBrowseMediaSource {
return {
title: title,
media_class: MEDIA_CLASS_PLAYLIST,
media_content_type: MEDIA_TYPE_PLAYLIST,
media_content_id: '',
can_play: false,
can_expand: true,
children_media_class: MEDIA_CLASS_PLAYLIST,
thumbnail: null,
children: children,
};
} }
} }
+12 -14
View File
@@ -8,7 +8,7 @@ import {
} from 'lit'; } from 'lit';
import { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers'; import { HomeAssistant, LovelaceCardEditor, getLovelace } from 'custom-card-helpers';
import { StyleInfo, styleMap } from 'lit/directives/style-map.js'; import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { customElement, property, query, state } from 'lit/decorators.js'; import { customElement, property, state } from 'lit/decorators.js';
import { classMap } from 'lit/directives/class-map.js'; import { classMap } from 'lit/directives/class-map.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js'; import { createRef, ref, Ref } from 'lit/directives/ref.js';
import screenfull from 'screenfull'; import screenfull from 'screenfull';
@@ -35,7 +35,7 @@ import type {
Message, Message,
} from './types.js'; } from './types.js';
import { CARD_VERSION, REPO_URL } from './const.js'; import { CAMERA_BIRDSEYE, CARD_VERSION, REPO_URL } from './const.js';
import { FrigateCardElements } from './components/elements.js'; import { FrigateCardElements } from './components/elements.js';
import { FrigateCardImage } from './components/image.js'; import { FrigateCardImage } from './components/image.js';
import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js'; import { FRIGATE_BUTTON_MENU_ICON, FrigateCardMenu } from './components/menu.js';
@@ -48,6 +48,7 @@ import {
frigateCardHasAction, frigateCardHasAction,
getActionConfigGivenAction, getActionConfigGivenAction,
getCameraIcon, getCameraIcon,
getCameraID,
getCameraTitle, getCameraTitle,
homeAssistantSignPath, homeAssistantSignPath,
homeAssistantWSRequest, homeAssistantWSRequest,
@@ -328,11 +329,12 @@ export class FrigateCard extends LitElement {
const cameraConfig = this._getSelectedCameraConfig(); const cameraConfig = this._getSelectedCameraConfig();
// Don't show `clips` button if there's no `camera_name` (e.g. non-Frigate // Don't show `clips` button if there's no `camera_name` (e.g. non-Frigate
// cameras), or is birdseye. // cameras), or is birdseye (unless there are dependent cameras).
if ( if (
this._getConfig().menu.buttons.clips && this._getConfig().menu.buttons.clips &&
cameraConfig?.camera_name && cameraConfig?.camera_name &&
cameraConfig?.camera_name !== 'birdseye' (cameraConfig?.camera_name !== CAMERA_BIRDSEYE ||
cameraConfig?.dependent_cameras?.length)
) { ) {
buttons.push({ buttons.push({
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
@@ -345,11 +347,12 @@ export class FrigateCard extends LitElement {
} }
// Don't show `snapshots` button if there's no `camera_name` (e.g. non-Frigate // Don't show `snapshots` button if there's no `camera_name` (e.g. non-Frigate
// cameras), or is birdseye. // cameras), or is birdseye (unless there are dependent cameras).
if ( if (
this._getConfig().menu.buttons.snapshots && this._getConfig().menu.buttons.snapshots &&
cameraConfig?.camera_name && cameraConfig?.camera_name &&
cameraConfig?.camera_name !== 'birdseye' (cameraConfig?.camera_name !== CAMERA_BIRDSEYE ||
cameraConfig?.dependent_cameras?.length)
) { ) {
buttons.push({ buttons.push({
type: 'custom:frigate-card-menu-icon', type: 'custom:frigate-card-menu-icon',
@@ -465,12 +468,7 @@ export class FrigateCard extends LitElement {
} }
} }
const id = const id = getCameraID(config);
config.id ||
config.camera_entity ||
config.webrtc_card?.entity ||
config.camera_name;
if (!id) { if (!id) {
this._setMessageAndUpdate({ this._setMessageAndUpdate({
message: localize('error.no_camera_id'), message: localize('error.no_camera_id'),
@@ -1299,7 +1297,7 @@ export class FrigateCard extends LitElement {
? html` <frigate-card-gallery ? html` <frigate-card-gallery
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.cameraConfig=${cameraConfig} .cameras=${this._cameras}
.galleryConfig=${this._getConfig().event_gallery} .galleryConfig=${this._getConfig().event_gallery}
> >
</frigate-card-gallery>` </frigate-card-gallery>`
@@ -1308,7 +1306,7 @@ export class FrigateCard extends LitElement {
? html` <frigate-card-viewer ? html` <frigate-card-viewer
.hass=${this._hass} .hass=${this._hass}
.view=${this._view} .view=${this._view}
.cameraConfig=${cameraConfig} .cameras=${this._cameras}
.viewerConfig=${this._getConfig().event_viewer} .viewerConfig=${this._getConfig().event_viewer}
.resolvedMediaCache=${this._resolvedMediaCache} .resolvedMediaCache=${this._resolvedMediaCache}
> >
+39 -5
View File
@@ -28,6 +28,7 @@ import {
FrigateEvent, FrigateEvent,
MediaShowInfo, MediaShowInfo,
Message, Message,
RawFrigateCardConfig,
SignedPath, SignedPath,
signedPathSchema, signedPathSchema,
StateParameters, StateParameters,
@@ -463,20 +464,53 @@ export function getEntityIcon(
return hass && entity ? stateIcon(hass.states[entity]) : undefined; return hass && entity ? stateIcon(hass.states[entity]) : undefined;
} }
/**
* Get a camera id.
* @param config The camera config (either parsed or raw).
* @returns A camera id.
*/
export function getCameraID(
config?: CameraConfig | RawFrigateCardConfig | null,
): string {
return (
(typeof config?.id === 'string' && config.id) ||
(typeof config?.camera_entity === 'string' && config.camera_entity) ||
(typeof config?.webrtc_card === 'object' &&
config.webrtc_card &&
typeof config.webrtc_card['entity'] === 'string' &&
config.webrtc_card['entity']) ||
(typeof config?.camera_name === 'string' && config.camera_name) ||
''
);
}
/** /**
* Get a camera text title. * Get a camera text title.
* @param hass The Home Assistant object. * @param hass The Home Assistant object.
* @param config The camera config. * @param config The camera config (either parsed or raw).
* @returns A title string. * @returns A title string.
*/ */
export function getCameraTitle( export function getCameraTitle(
hass?: HomeAssistant, hass?: HomeAssistant,
config?: CameraConfig | null, config?: CameraConfig | RawFrigateCardConfig | null,
): string { ): string {
// Attempt to render a recognizable name for the camera,
// starting with the most likely to be useful and working our
// ways towards the least useful. Extra type checking here since this is also
// used on raw configuration in the editor.
return ( return (
config?.title || (typeof config?.title === 'string' && config.title) ||
(config?.camera_entity ? getEntityTitle(hass, config.camera_entity) : '') || (typeof config?.camera_entity === 'string'
(config?.camera_name ? prettifyFrigateName(config.camera_name) : '') || ? getEntityTitle(hass, config.camera_entity)
: '') ||
(typeof config?.webrtc_card === 'object' &&
config.webrtc_card &&
typeof config.webrtc_card['entity'] === 'string' &&
config.webrtc_card['entity']) ||
(typeof config?.camera_name === 'string'
? prettifyFrigateName(config.camera_name)
: '') ||
(typeof config?.id === 'string' && config.id) ||
'' ''
); );
} }
+19 -14
View File
@@ -11,11 +11,7 @@ import {
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { customElement, property } from 'lit/decorators.js'; import { customElement, property } from 'lit/decorators.js';
import { import { CameraConfig, GalleryConfig, frigateCardConfigDefaults } from '../types.js';
CameraConfig,
GalleryConfig,
frigateCardConfigDefaults,
} from '../types.js';
import { BrowseMediaUtil } from '../browse-media-util.js'; import { BrowseMediaUtil } from '../browse-media-util.js';
import { View } from '../view.js'; import { View } from '../view.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js'; import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
@@ -35,28 +31,37 @@ export class FrigateCardGallery extends LitElement {
protected view?: Readonly<View>; protected view?: Readonly<View>;
@property({ attribute: false }) @property({ attribute: false })
protected cameraConfig?: CameraConfig; protected galleryConfig?: GalleryConfig;
@property({ attribute: false }) @property({ attribute: false })
protected galleryConfig?: GalleryConfig; protected cameras?: Map<string, CameraConfig>;
/** /**
* Master render method. * Master render method.
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.cameraConfig || !this.view.isGalleryView()) { const mediaType = this.view?.getMediaType();
if (
!this.hass ||
!this.view ||
!this.cameras ||
!this.view.isGalleryView() ||
!mediaType
) {
return; return;
} }
if (!this.view.target) { if (!this.view.target) {
const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView( const browseMediaQueryParameters =
BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError( BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError(
this, this,
this.cameraConfig, this.hass,
), this.cameras,
this.view, this.view.camera,
); mediaType,
);
if (!browseMediaQueryParameters) { if (!browseMediaQueryParameters) {
return; return;
} }
+10 -9
View File
@@ -19,7 +19,6 @@ import {
LiveProvider, LiveProvider,
TransitionEffect, TransitionEffect,
frigateCardConfigDefaults, frigateCardConfigDefaults,
BrowseMediaQueryParameters,
} from '../types.js'; } from '../types.js';
import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel'; import { EmblaOptionsType, EmblaPluginType } from 'embla-carousel';
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
@@ -127,16 +126,18 @@ export class FrigateCardLive extends LitElement {
this.conditionState, this.conditionState,
) as LiveConfig; ) as LiveConfig;
const browseMediaParamsBase = BrowseMediaUtil.getBrowseMediaQueryParametersBase( const browseMediaParams =
this.cameras.get(this.view.camera), BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError(
); this,
if (!browseMediaParamsBase) { this.hass,
this.cameras,
this.view.camera,
config.controls.thumbnails.media,
);
if (!browseMediaParams) {
return; return;
} }
const browseMediaParams: BrowseMediaQueryParameters = {
...browseMediaParamsBase,
mediaType: config.controls.thumbnails.media,
}
// Note use of liveConfig and not config below -- the carousel will // Note use of liveConfig and not config below -- the carousel will
// independently override the liveconfig to reflect the camera in the // independently override the liveconfig to reflect the camera in the
+9 -4
View File
@@ -34,7 +34,9 @@ export class FrigateCardSurround extends LitElement {
protected targetView?: FrigateCardView; protected targetView?: FrigateCardView;
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaParams?: BrowseMediaQueryParameters; protected browseMediaParams?:
| BrowseMediaQueryParameters
| BrowseMediaQueryParameters[];
// A task to await the load of the WebRTC component. // A task to await the load of the WebRTC component.
protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [ protected _browseTask = new Task(this, this._fetchMedia.bind(this), () => [
@@ -53,22 +55,25 @@ export class FrigateCardSurround extends LitElement {
| HomeAssistant | HomeAssistant
| Readonly<View> | Readonly<View>
| BrowseMediaQueryParameters | BrowseMediaQueryParameters
| BrowseMediaQueryParameters[]
| undefined | undefined
)[]): Promise<void> { )[]): Promise<void> {
hass = hass as HomeAssistant; hass = hass as HomeAssistant;
view = view as Readonly<View>; view = view as Readonly<View>;
browseMediaParams = browseMediaParams as BrowseMediaQueryParameters; browseMediaParams = browseMediaParams as
| BrowseMediaQueryParameters
| BrowseMediaQueryParameters[];
if (!hass || !view || view.target || !browseMediaParams) { if (!hass || !view || view.target || !browseMediaParams) {
return; return;
} }
let parent: FrigateBrowseMediaSource | null; let parent: FrigateBrowseMediaSource | null;
try { try {
parent = await BrowseMediaUtil.browseMediaQuery(hass, browseMediaParams); parent = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(hass, browseMediaParams);
} catch (e) { } catch (e) {
return dispatchErrorMessageEvent(this, (e as Error).message); return dispatchErrorMessageEvent(this, (e as Error).message);
} }
if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) != null) { if (BrowseMediaUtil.getFirstTrueMediaChildIndex(parent) !== null) {
this.view this.view
?.evolve({ ?.evolve({
...(this.targetView && { view: this.targetView }), ...(this.targetView && { view: this.targetView }),
+55 -59
View File
@@ -25,14 +25,14 @@ import { isEqual } from 'lodash-es';
import { BrowseMediaUtil } from '../browse-media-util'; import { BrowseMediaUtil } from '../browse-media-util';
import { import {
BrowseMediaQueryParameters,
CameraConfig, CameraConfig,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
MEDIA_CLASS_PLAYLIST,
MEDIA_TYPE_PLAYLIST,
TimelineConfig, TimelineConfig,
FrigateEvent, FrigateEvent,
frigateCardConfigDefaults, frigateCardConfigDefaults,
} from '../types'; } from '../types';
import { CAMERA_BIRDSEYE } from '../const';
import { View, ViewContext } from '../view'; import { View, ViewContext } from '../view';
import { import {
dispatchErrorMessageEvent, dispatchErrorMessageEvent,
@@ -278,49 +278,51 @@ class TimelineEventManager {
} }
this._dateFetch = new Date(); this._dateFetch = new Date();
const fetchCameraEvents = async ( const params: BrowseMediaQueryParameters[] = [];
camera: string, cameras.forEach((cameraConfig, cameraID) => {
fetchMedia: 'clips' | 'snapshots', (media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => {
): Promise<void> => { if (
const cameraConfig = cameras.get(camera); this._dateEnd &&
if (!cameraConfig || !this._dateStart || !this._dateEnd) { this._dateStart &&
return; cameraConfig.camera_name !== CAMERA_BIRDSEYE
} ) {
const browseMediaQueryParametersBase = const param = BrowseMediaUtil.getBrowseMediaQueryParameters(
BrowseMediaUtil.getBrowseMediaQueryParametersBase(cameraConfig); hass,
if (!browseMediaQueryParametersBase) { cameraID,
return; cameraConfig,
} {
try { // Events are always fetched for the maximum extent of the managed
this._addMediaSource( // range. This is because events may change at any point in time
camera, // (e.g. a long-running event that ends).
media, before: this._dateEnd.getTime() / 1000,
await BrowseMediaUtil.browseMediaQuery(hass, { after: this._dateStart.getTime() / 1000,
...browseMediaQueryParametersBase, unlimited: true,
mediaType: mediaType as 'clips' | 'snapshots',
},
);
if (param) {
params.push(param);
}
}
});
});
// Events are always fetched for the maximum extent of the managed if (!params.length) {
// range. This is because events may change at any point in time return;
// (e.g. a long-running event that ends). }
before: this._dateEnd.getTime() / 1000,
after: this._dateStart.getTime() / 1000,
unlimited: true,
mediaType: fetchMedia,
}),
);
} catch (e) {
return dispatchErrorMessageEvent(element, (e as Error).message);
}
};
const promises: Promise<void>[] = []; let results: Map<BrowseMediaQueryParameters, FrigateBrowseMediaSource>;
(media === 'all' ? ['clips', 'snapshots'] : [media]).forEach((mediaType) => try {
promises.push( results = await BrowseMediaUtil.multipleBrowseMediaQuery(hass, params);
...Array.from(cameras.keys()).map((camera) => } catch (e) {
fetchCameraEvents(camera, mediaType as 'clips' | 'snapshots'), return dispatchErrorMessageEvent(element, (e as Error).message);
), }
),
); for (const [query, result] of results.entries()) {
await Promise.all(promises); if (query.cameraID) {
this._addMediaSource(query.cameraID, media, result);
}
}
} }
} }
@@ -574,18 +576,10 @@ export class FrigateCardTimelineCore extends LitElement {
return null; return null;
} }
const target = { const target = BrowseMediaUtil.createEventParentForChildren(
title: `Timeline events`, 'Timeline events',
media_class: MEDIA_CLASS_PLAYLIST, children,
media_content_type: MEDIA_TYPE_PLAYLIST, );
media_content_id: '',
can_play: false,
can_expand: true,
children_media_class: MEDIA_CLASS_PLAYLIST,
thumbnail: null,
children: children,
};
return { return {
target: target, target: target,
childIndex: childIndex < 0 ? null : childIndex, childIndex: childIndex < 0 ? null : childIndex,
@@ -599,10 +593,12 @@ export class FrigateCardTimelineCore extends LitElement {
protected _getGroups(): DataGroupCollectionType { protected _getGroups(): DataGroupCollectionType {
const groups: FrigateCardGroupData[] = []; const groups: FrigateCardGroupData[] = [];
this.cameras?.forEach((cameraConfig, camera) => { this.cameras?.forEach((cameraConfig, camera) => {
groups.push({ if (cameraConfig.camera_name !== CAMERA_BIRDSEYE) {
id: camera, groups.push({
content: getCameraTitle(this.hass, cameraConfig), id: camera,
}); content: getCameraTitle(this.hass, cameraConfig),
});
}
}); });
return new DataSet(groups); return new DataSet(groups);
} }
+29 -19
View File
@@ -17,7 +17,7 @@ import { ref } from 'lit/directives/ref.js';
import { AutoMediaPlugin } from './embla-plugins/automedia.js'; import { AutoMediaPlugin } from './embla-plugins/automedia.js';
import type { import type {
BrowseMediaNeighbors, BrowseMediaNeighbors,
BrowseMediaQueryParametersBase, BrowseMediaQueryParameters,
FrigateBrowseMediaSource, FrigateBrowseMediaSource,
CameraConfig, CameraConfig,
MediaShowInfo, MediaShowInfo,
@@ -54,7 +54,7 @@ export class FrigateCardViewer extends LitElement {
protected viewerConfig?: ViewerConfig; protected viewerConfig?: ViewerConfig;
@property({ attribute: false }) @property({ attribute: false })
protected cameraConfig?: CameraConfig; protected cameras?: Map<string, CameraConfig>;
@property({ attribute: false }) @property({ attribute: false })
protected resolvedMediaCache?: ResolvedMediaCache; protected resolvedMediaCache?: ResolvedMediaCache;
@@ -64,22 +64,25 @@ export class FrigateCardViewer extends LitElement {
* @returns A rendered template. * @returns A rendered template.
*/ */
protected render(): TemplateResult | void { protected render(): TemplateResult | void {
if (!this.hass || !this.view || !this.cameraConfig || !this.viewerConfig) { if (!this.hass || !this.view || !this.cameras || !this.viewerConfig) {
return; return;
} }
const browseMediaQueryParametersBase = const browseMediaQueryParameters =
BrowseMediaUtil.getBrowseMediaQueryParametersBaseOrDispatchError( BrowseMediaUtil.getFullDependentBrowseMediaQueryParametersOrDispatchError(
this, this,
this.cameraConfig, this.hass,
this.cameras,
this.view.camera,
); );
if (!this.view.target) { if (!this.view.target) {
const browseMediaQueryParameters = BrowseMediaUtil.setMediaTypeFromView( // If the target is not specified, the view must tell us which mediaType
browseMediaQueryParametersBase, // to search for. When the target *is* specified, the view is not required
this.view, // to indicate the media type (e.g. the mixed 'events' view from the
); // timeline).
if (!browseMediaQueryParameters) { const mediaType = this.view.getMediaType();
if (!browseMediaQueryParameters || !mediaType) {
return; return;
} }
@@ -87,7 +90,10 @@ export class FrigateCardViewer extends LitElement {
this, this,
this.hass, this.hass,
this.view, this.view,
browseMediaQueryParameters, BrowseMediaUtil.overrideMultiBrowseMediaQueryParameters(
browseMediaQueryParameters,
{ mediaType: mediaType },
),
); );
return renderProgressIndicator(); return renderProgressIndicator();
} }
@@ -101,7 +107,7 @@ export class FrigateCardViewer extends LitElement {
.hass=${this.hass} .hass=${this.hass}
.view=${this.view} .view=${this.view}
.viewerConfig=${this.viewerConfig} .viewerConfig=${this.viewerConfig}
.browseMediaQueryParametersBase=${browseMediaQueryParametersBase} .browseMediaQueryParameters=${browseMediaQueryParameters}
.resolvedMediaCache=${this.resolvedMediaCache} .resolvedMediaCache=${this.resolvedMediaCache}
> >
</frigate-card-viewer-carousel> </frigate-card-viewer-carousel>
@@ -133,7 +139,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
protected viewerConfig?: ViewerConfig; protected viewerConfig?: ViewerConfig;
@property({ attribute: false }) @property({ attribute: false })
protected browseMediaQueryParametersBase?: BrowseMediaQueryParametersBase; protected browseMediaQueryParameters?: BrowseMediaQueryParameters[] | null;
@property({ attribute: false }) @property({ attribute: false })
protected resolvedMediaCache?: ResolvedMediaCache; protected resolvedMediaCache?: ResolvedMediaCache;
@@ -338,7 +344,7 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
!this.view.target || !this.view.target ||
!this.view.target.children || !this.view.target.children ||
!this.view.target.children.length || !this.view.target.children.length ||
!this.browseMediaQueryParametersBase !this.browseMediaQueryParameters
) { ) {
return null; return null;
} }
@@ -379,13 +385,17 @@ export class FrigateCardViewerCarousel extends FrigateCardMediaCarousel {
let clips: FrigateBrowseMediaSource | null; let clips: FrigateBrowseMediaSource | null;
try { const params = BrowseMediaUtil.overrideMultiBrowseMediaQueryParameters(
clips = await BrowseMediaUtil.browseMediaQuery(this.hass, { this.browseMediaQueryParameters,
...this.browseMediaQueryParametersBase, {
mediaType: 'clips', mediaType: 'clips',
before: latest, before: latest,
after: earliest, after: earliest,
}); },
);
try {
clips = await BrowseMediaUtil.multipleBrowseMediaQueryMerged(this.hass, params);
} catch (e) { } catch (e) {
// This is best effort. // This is best effort.
return null; return null;
+4
View File
@@ -1,4 +1,6 @@
export const CARD_VERSION = '3.0.0' as const; export const CARD_VERSION = '3.0.0' as const;
export const CAMERA_BIRDSEYE = 'birdseye' as const;
export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const; export const REPO_URL = 'https://github.com/dermotduffy/frigate-hass-card' as const;
export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const; export const TROUBLESHOOTING_URL = `${REPO_URL}#troubleshooting` as const;
@@ -18,6 +20,8 @@ export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = `${CONF_CAMERAS}.#.webrtc_card.url` as const; export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL = `${CONF_CAMERAS}.#.webrtc_card.url` as const;
export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER = export const CONF_CAMERAS_ARRAY_LIVE_PROVIDER =
`${CONF_CAMERAS}.#.live_provider` as const; `${CONF_CAMERAS}.#.live_provider` as const;
export const CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS =
`${CONF_CAMERAS}.#.dependent_cameras` as const;
export const CONF_VIEW = 'view' as const; export const CONF_VIEW = 'view' as const;
export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const; export const CONF_VIEW_CAMERA_SELECT = `${CONF_VIEW}.camera_select` as const;
+64 -45
View File
@@ -18,6 +18,7 @@ import {
CONF_CAMERAS_ARRAY_CAMERA_ENTITY, CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
CONF_CAMERAS_ARRAY_CAMERA_NAME, CONF_CAMERAS_ARRAY_CAMERA_NAME,
CONF_CAMERAS_ARRAY_CLIENT_ID, CONF_CAMERAS_ARRAY_CLIENT_ID,
CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS,
CONF_CAMERAS_ARRAY_ICON, CONF_CAMERAS_ARRAY_ICON,
CONF_CAMERAS_ARRAY_ID, CONF_CAMERAS_ARRAY_ID,
CONF_CAMERAS_ARRAY_LABEL, CONF_CAMERAS_ARRAY_LABEL,
@@ -88,7 +89,11 @@ import {
CONF_VIEW_UPDATE_FORCE, CONF_VIEW_UPDATE_FORCE,
CONF_VIEW_UPDATE_SECONDS, CONF_VIEW_UPDATE_SECONDS,
} from './const.js'; } from './const.js';
import { arrayMove, getEntityTitle, prettifyFrigateName } from './common.js'; import {
arrayMove,
getCameraID,
getCameraTitle,
} from './common.js';
import { import {
copyConfig, copyConfig,
deleteConfigValue, deleteConfigValue,
@@ -119,6 +124,11 @@ interface EditorOptionSetTarget {
optionSetName: string; optionSetName: string;
} }
interface EditorSelectOption {
value: string;
label: string;
}
const options: EditorOptions = { const options: EditorOptions = {
cameras: { cameras: {
icon: 'video', icon: 'video',
@@ -193,7 +203,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
@property({ attribute: false }) @property({ attribute: false })
protected _expandedCameraIndex: number | null = null; protected _expandedCameraIndex: number | null = null;
protected _viewModes = [ protected _viewModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'live', label: localize('config.view.views.live') }, { value: 'live', label: localize('config.view.views.live') },
{ value: 'clips', label: localize('config.view.views.clips') }, { value: 'clips', label: localize('config.view.views.clips') },
@@ -204,12 +214,12 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'timeline', label: localize('config.view.views.timeline') }, { value: 'timeline', label: localize('config.view.views.timeline') },
]; ];
protected _cameraSelectViewModes = [ protected _cameraSelectViewModes: EditorSelectOption[] = [
...this._viewModes, ...this._viewModes,
{ value: 'current', label: localize('config.view.views.current') }, { value: 'current', label: localize('config.view.views.current') },
]; ];
protected _menuModes = [ protected _menuModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'none', label: localize('config.menu.modes.none') }, { value: 'none', label: localize('config.menu.modes.none') },
{ value: 'hidden-top', label: localize('config.menu.modes.hidden-top') }, { value: 'hidden-top', label: localize('config.menu.modes.hidden-top') },
@@ -228,7 +238,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'below', label: localize('config.menu.modes.below') }, { value: 'below', label: localize('config.menu.modes.below') },
]; ];
protected _eventViewerNextPreviousControlStyles = [ protected _eventViewerNextPreviousControlStyles: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ {
value: 'thumbnails', value: 'thumbnails',
@@ -244,7 +254,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}, },
]; ];
protected _liveNextPreviousControlStyles = [ protected _liveNextPreviousControlStyles: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ {
value: 'chevrons', value: 'chevrons',
@@ -257,7 +267,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
{ value: 'none', label: localize('config.live.controls.next_previous.styles.none') }, { value: 'none', label: localize('config.live.controls.next_previous.styles.none') },
]; ];
protected _aspectRatioModes = [ protected _aspectRatioModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ {
value: 'dynamic', value: 'dynamic',
@@ -270,7 +280,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}, },
]; ];
protected _thumbnailModes = [ protected _thumbnailModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ {
value: 'none', value: 'none',
@@ -294,7 +304,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}, },
]; ];
protected _thumbnailMedias = [ protected _thumbnailMedias: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'clips', label: localize('config.live.controls.thumbnails.medias.clips') }, { value: 'clips', label: localize('config.live.controls.thumbnails.medias.clips') },
{ {
@@ -303,7 +313,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}, },
]; ];
protected _titleModes = [ protected _titleModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'none', label: localize('config.event_viewer.controls.title.modes.none') }, { value: 'none', label: localize('config.event_viewer.controls.title.modes.none') },
{ {
@@ -324,27 +334,27 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}, },
]; ];
protected _transitionEffects = [ protected _transitionEffects: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'none', label: localize('config.event_viewer.transition_effects.none') }, { value: 'none', label: localize('config.event_viewer.transition_effects.none') },
{ value: 'slide', label: localize('config.event_viewer.transition_effects.slide') }, { value: 'slide', label: localize('config.event_viewer.transition_effects.slide') },
]; ];
protected _imageModes = [ protected _imageModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'camera', label: localize('config.image.modes.camera') }, { value: 'camera', label: localize('config.image.modes.camera') },
{ value: 'screensaver', label: localize('config.image.modes.screensaver') }, { value: 'screensaver', label: localize('config.image.modes.screensaver') },
{ value: 'url', label: localize('config.image.modes.url') }, { value: 'url', label: localize('config.image.modes.url') },
]; ];
protected _timelineMediaTypes = [ protected _timelineMediaTypes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'all', label: localize('config.timeline.medias.all') }, { value: 'all', label: localize('config.timeline.medias.all') },
{ value: 'clips', label: localize('config.timeline.medias.clips') }, { value: 'clips', label: localize('config.timeline.medias.clips') },
{ value: 'snapshots', label: localize('config.timeline.medias.snapshots') }, { value: 'snapshots', label: localize('config.timeline.medias.snapshots') },
]; ];
protected _darkModes = [ protected _darkModes: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'on', label: localize('config.view.dark_modes.on') }, { value: 'on', label: localize('config.view.dark_modes.on') },
{ value: 'off', label: localize('config.view.dark_modes.off') }, { value: 'off', label: localize('config.view.dark_modes.off') },
@@ -456,6 +466,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
protected _renderOptionSelector( protected _renderOptionSelector(
configPath: string, configPath: string,
options: string[] | { value: string; label: string }[], options: string[] | { value: string; label: string }[],
multiple?: boolean,
): TemplateResult | void { ): TemplateResult | void {
if (!this._config) { if (!this._config) {
return; return;
@@ -464,7 +475,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
return html` return html`
<ha-selector <ha-selector
.hass=${this.hass} .hass=${this.hass}
.selector=${{ select: { options: options } }} .selector=${{
select: { mode: 'dropdown', multiple: !!multiple, options: options },
}}
.label=${this._getLabel(configPath)} .label=${this._getLabel(configPath)}
.value=${getConfigValue(this._config, configPath, '')} .value=${getConfigValue(this._config, configPath, '')}
.required=${false} .required=${false}
@@ -516,6 +529,22 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
return html` <span class="info">${info}</span>`; return html` <span class="info">${info}</span>`;
} }
/**
* Get an editor title for the camera.
* @param cameraIndex The index of the camera in the cameras array.
* @param cameraConfig The raw camera configuration object.
* @returns A string title.
*/
protected _getEditorCameraTitle(
cameraIndex: number,
cameraConfig: RawFrigateCardConfig,
): string {
return (
getCameraTitle(this.hass, cameraConfig) ||
localize('editor.camera') + ' #' + cameraIndex
);
}
/** /**
* Render a camera header. * Render a camera header.
* @param cameraIndex The index of the camera to edit/add. * @param cameraIndex The index of the camera to edit/add.
@@ -540,31 +569,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
? html` <span class="new-camera"> ? html` <span class="new-camera">
[${localize('editor.add_new_camera')}...] [${localize('editor.add_new_camera')}...]
</span>` </span>`
: // Attempt to render a recognizable name for the camera, : html`<span
// starting with the most likely to be useful and working our >${this._getEditorCameraTitle(cameraIndex, cameraConfig || {})}</span
// ways towards the least useful. >`}
html` <span>
${cameraConfig?.title ||
cameraConfig?.id ||
[
cameraConfig?.camera_entity
? getEntityTitle(this.hass, String(cameraConfig.camera_entity))
: '',
cameraConfig?.client_id,
cameraConfig?.camera_name
? prettifyFrigateName(String(cameraConfig.camera_name))
: '',
cameraConfig?.label
? prettifyFrigateName(String(cameraConfig.label))
: '',
cameraConfig?.zone
? prettifyFrigateName(String(cameraConfig.zone))
: '',
]
.filter(Boolean)
.join(' / ') ||
localize('editor.camera') + ' #' + cameraIndex}
</span>`}
</span> </span>
</div> </div>
`; `;
@@ -582,7 +589,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
cameraIndex: number, cameraIndex: number,
addNewCamera?: boolean, addNewCamera?: boolean,
): TemplateResult | void { ): TemplateResult | void {
const liveProviders = [ const liveProviders: EditorSelectOption[] = [
{ value: '', label: '' }, { value: '', label: '' },
{ value: 'auto', label: localize('config.cameras.live_providers.auto') }, { value: 'auto', label: localize('config.cameras.live_providers.auto') },
{ value: 'ha', label: localize('config.cameras.live_providers.ha') }, { value: 'ha', label: localize('config.cameras.live_providers.ha') },
@@ -596,6 +603,16 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
}, },
]; ];
const dependentCameras: EditorSelectOption[] = [];
cameras.forEach((camera, index) => {
if (index !== cameraIndex) {
dependentCameras.push({
value: getCameraID(camera),
label: this._getEditorCameraTitle(index, camera),
});
}
});
// Make a new config and update the editor with changes on it, // Make a new config and update the editor with changes on it,
const modifyConfig = (func: (config: RawFrigateCardConfig) => boolean): void => { const modifyConfig = (func: (config: RawFrigateCardConfig) => boolean): void => {
if (this._config) { if (this._config) {
@@ -711,6 +728,11 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${this._renderStringInput( ${this._renderStringInput(
getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, cameraIndex), getArrayConfigPath(CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL, cameraIndex),
)} )}
${this._renderOptionSelector(
getArrayConfigPath(CONF_CAMERAS_ARRAY_DEPENDENT_CAMERAS, cameraIndex),
dependentCameras,
true,
)}
</div>` </div>`
: ``} : ``}
`; `;
@@ -841,10 +863,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
CONF_VIEW_CAMERA_SELECT, CONF_VIEW_CAMERA_SELECT,
this._cameraSelectViewModes, this._cameraSelectViewModes,
)} )}
${this._renderOptionSelector( ${this._renderOptionSelector(CONF_VIEW_DARK_MODE, this._darkModes)}
CONF_VIEW_DARK_MODE,
this._darkModes,
)}
${this._renderNumberInput(CONF_VIEW_TIMEOUT_SECONDS)} ${this._renderNumberInput(CONF_VIEW_TIMEOUT_SECONDS)}
${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)} ${this._renderNumberInput(CONF_VIEW_UPDATE_SECONDS)}
${this._renderSwitch(CONF_VIEW_UPDATE_FORCE, defaults.view.update_force)} ${this._renderSwitch(CONF_VIEW_UPDATE_FORCE, defaults.view.update_force)}
+2 -1
View File
@@ -14,6 +14,7 @@
"camera_entity": "Camera Entity", "camera_entity": "Camera Entity",
"camera_name": "Frigate camera name (Autodetected from entity)", "camera_name": "Frigate camera name (Autodetected from entity)",
"client_id": "Frigate client id (For >1 Frigate server)", "client_id": "Frigate client id (For >1 Frigate server)",
"dependent_cameras": "Dependent cameras to also show events for",
"id": "Unique id for this camera in this card", "id": "Unique id for this camera in this card",
"label": "Frigate label/object filter", "label": "Frigate label/object filter",
"frigate_url": "Frigate server URL", "frigate_url": "Frigate server URL",
@@ -276,7 +277,7 @@
"invalid_elements_config": "Invalid picture elements configuration", "invalid_elements_config": "Invalid picture elements configuration",
"jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path", "jsmpeg_no_sign": "Could not retrieve or sign JSMPEG websocket path",
"jsmpeg_no_player": "Could not start JSMPEG player", "jsmpeg_no_player": "Could not start JSMPEG player",
"no_camera_name": "Could not determine Frigate camera name for camera, please specify either 'camera_entity' or 'camera_name' for the following camera", "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'",
"download_no_media": "No media to download", "download_no_media": "No media to download",
"download_no_event_id": "Could not extract Frigate event id from media", "download_no_event_id": "Could not extract Frigate event id from media",
"download_sign_failed": "Could not sign media URL for download" "download_sign_failed": "Could not sign media URL for download"
+6 -5
View File
@@ -28,10 +28,6 @@
display: grid; display: grid;
margin-bottom: 10px; margin-bottom: 10px;
} }
ha-formfield {
padding-bottom: 8px;
}
div.upgrade { div.upgrade {
width: auto; width: auto;
border: 1px dotted var(--primary-color); border: 1px dotted var(--primary-color);
@@ -78,4 +74,9 @@ div.upgrade span {
} }
span.info { span.info {
padding: 4px; padding: 4px;
} }
ha-selector {
padding: 10px;
border: 1px solid var(--divider-color);
}
+18 -5
View File
@@ -327,6 +327,9 @@ const cameraConfigSchema = z
// Camera identifiers for WebRTC. // Camera identifiers for WebRTC.
webrtc_card: webrtcCardCameraConfigSchema.optional(), webrtc_card: webrtcCardCameraConfigSchema.optional(),
// Set of cameras IDs upon which this camera depends.
dependent_cameras: z.string().array().optional(),
}) })
.default(cameraConfigDefault); .default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>; export type CameraConfig = z.infer<typeof cameraConfigSchema>;
@@ -936,11 +939,14 @@ export type MenuButton = z.infer<typeof menuButtonSchema>;
export interface ExtendedHomeAssistant extends HomeAssistant { export interface ExtendedHomeAssistant extends HomeAssistant {
hassUrl(path?): string; hassUrl(path?): string;
themes: Themes & { themes: Themes & {
darkMode?: boolean darkMode?: boolean;
}; };
} }
export interface BrowseMediaQueryParametersBase { export interface BrowseMediaQueryParameters {
// ========================================
// Parameters used to construct media query
// ========================================
mediaType?: 'clips' | 'snapshots'; mediaType?: 'clips' | 'snapshots';
clientId: string; clientId: string;
cameraName: string; cameraName: string;
@@ -949,10 +955,17 @@ export interface BrowseMediaQueryParametersBase {
before?: number; before?: number;
after?: number; after?: number;
unlimited?: boolean; unlimited?: boolean;
}
export interface BrowseMediaQueryParameters extends BrowseMediaQueryParametersBase { // ========================================
mediaType: 'clips' | 'snapshots'; // Parameters used to differentiate results
// ========================================
// Optional title to be used for separating results when merging multiple
// sets of results. See `mergeFrigateBrowseMediaSources()` .
title?: string;
// Optional camera-id to which this query is associated. May be used to map
// results to a particular camera within the card.
cameraID?: string;
} }
export interface BrowseMediaNeighbors { export interface BrowseMediaNeighbors {
+13 -1
View File
@@ -61,7 +61,7 @@ export class View {
target: params.target !== undefined ? params.target : this.target, target: params.target !== undefined ? params.target : this.target,
childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex, childIndex: params.childIndex !== undefined ? params.childIndex : this.childIndex,
context: params.context !== undefined ? params.context : this.context, context: params.context !== undefined ? params.context : this.context,
// Special case: Set the previous to this of the evolved view (rather than // Special case: Set the previous to this of the evolved view (rather than
// the previous of this). // the previous of this).
previous: params.previous !== undefined ? params.previous : this, previous: params.previous !== undefined ? params.previous : this,
@@ -110,6 +110,18 @@ export class View {
return ['snapshot', 'snapshots'].includes(this.view); return ['snapshot', 'snapshots'].includes(this.view);
} }
/**
* Get the media type for this view if available.
* @returns Whether the media is `clips` or `snapshots` or unknown (`null`)
*/
public getMediaType(): 'clips' | 'snapshots' | null {
return this.isClipRelatedView()
? 'clips'
: this.isSnapshotRelatedView()
? 'snapshots'
: null;
}
/** /**
* Get the media item that should be played. * Get the media item that should be played.
**/ **/