feat: Add experimental reolink media support (#1694)
* feat: Add experimental rich reolink support. * Formatting fix
This commit is contained in:
1 parent
e898b73d63
commit
dada65e008
63 files changed
+3402
-483
No files matched your search
@@ -1,24 +1,15 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { StateWatcherSubscriptionInterface } from '../../card-controller/hass/state-watcher';
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { ExtendedHomeAssistant } from '../../types';
|
||||
import { canonicalizeHAURL } from '../../utils/ha';
|
||||
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import {
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
MEDIA_CLASS_IMAGE,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import { BROWSE_MEDIA_CACHE_SECONDS } from '../../utils/ha/browse-media/types';
|
||||
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { RequestCache } from '../cache';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CameraManagerEngine } from '../engine';
|
||||
import { GenericCameraManagerEngine } from '../generic/engine-generic';
|
||||
import { rangesOverlap } from '../range';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
@@ -29,93 +20,8 @@ import {
|
||||
PartialEventQuery,
|
||||
QueryType,
|
||||
} from '../types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
import { BrowseMediaCamera } from './camera';
|
||||
import { BrowseMediaViewMediaFactory } from './media';
|
||||
import { BrowseMediaMetadata } from './types';
|
||||
|
||||
/**
|
||||
* A utility method to determine if a browse media object matches against a
|
||||
* start and end date.
|
||||
* @param media The browse media object (with rich metadata).
|
||||
* @param start The optional start date.
|
||||
* @param end The optional end date.
|
||||
* @returns `true` if the media falls within the provided dates.
|
||||
*/
|
||||
export const isMediaWithinDates = (
|
||||
media: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
start?: Date,
|
||||
end?: Date,
|
||||
): boolean => {
|
||||
// If no date is specified at all, everything matches.
|
||||
const dateReference = start ?? end;
|
||||
if (!dateReference) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If there's no metadata, nothing matches.
|
||||
if (!media._metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Determine if:
|
||||
// - The media starts within the query timeframe.
|
||||
// - The media ends within the query timeframe.
|
||||
// - The media entirely encompasses the query timeframe.
|
||||
return rangesOverlap(
|
||||
{
|
||||
start: media._metadata.startDate,
|
||||
end: media._metadata.endDate,
|
||||
},
|
||||
{
|
||||
start: start ?? dateReference,
|
||||
end: end ?? dateReference,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getViewMediaFromBrowseMediaArray = (
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||
): ViewMedia[] | null => {
|
||||
const lookup: Map<string, ViewMedia> = new Map();
|
||||
for (const browseMediaItem of browseMedia) {
|
||||
const cameraID = browseMediaItem._metadata?.cameraID;
|
||||
if (!cameraID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaType =
|
||||
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
|
||||
? 'clip'
|
||||
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
|
||||
? 'snapshot'
|
||||
: null;
|
||||
|
||||
if (!mediaType) {
|
||||
continue;
|
||||
}
|
||||
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
|
||||
mediaType,
|
||||
browseMediaItem,
|
||||
cameraID,
|
||||
);
|
||||
|
||||
if (media) {
|
||||
const id = media.getID();
|
||||
const existing = lookup.get(id);
|
||||
// De-duplicate events with precisely the same ID (same
|
||||
// hour/minute/second) choosing clip > snapshot.
|
||||
if (
|
||||
!existing ||
|
||||
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
|
||||
) {
|
||||
lookup.set(id, media);
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...lookup.values()];
|
||||
};
|
||||
|
||||
/**
|
||||
* A base class for cameras that read events from HA BrowseMedia interface.
|
||||
*/
|
||||
@@ -143,38 +49,6 @@ export class BrowseMediaCameraManagerEngine
|
||||
this._requestCache = requestCache;
|
||||
}
|
||||
|
||||
public async createCamera(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
const camera = new BrowseMediaCamera(cameraConfig, this, {
|
||||
capabilities: new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: true,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: true,
|
||||
substream: true,
|
||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||
},
|
||||
),
|
||||
eventCallback: this._eventCallback,
|
||||
});
|
||||
return await camera.initialize({
|
||||
entityRegistryManager: this._entityRegistryManager,
|
||||
hass,
|
||||
stateWatcher: this._stateWatcher,
|
||||
});
|
||||
}
|
||||
|
||||
public generateDefaultEventQuery(
|
||||
_store: CameraManagerReadOnlyConfigStore,
|
||||
cameraIDs: Set<string>,
|
||||
|
||||
@@ -38,7 +38,7 @@ class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
|
||||
return this._browseMedia._metadata?.startDate ?? null;
|
||||
}
|
||||
public getEndTime(): Date | null {
|
||||
return null;
|
||||
return this._browseMedia._metadata?.endDate ?? null;
|
||||
}
|
||||
public getVideoContentType(): VideoContentType | null {
|
||||
return VideoContentType.MP4;
|
||||
@@ -79,7 +79,7 @@ export class BrowseMediaViewMediaFactory {
|
||||
mediaType: 'clip' | 'snapshot',
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
cameraID: string,
|
||||
): BrowseMediaEventViewMedia | null {
|
||||
): BrowseMediaEventViewMedia {
|
||||
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
RichBrowseMedia,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
MEDIA_CLASS_IMAGE,
|
||||
} from '../../../utils/ha/browse-media/types';
|
||||
import { ViewMedia } from '../../../view/media';
|
||||
import { BrowseMediaViewMediaFactory } from '../media';
|
||||
import { BrowseMediaMetadata } from '../types';
|
||||
|
||||
export const getViewMediaFromBrowseMediaArray = (
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||
): ViewMedia[] | null => {
|
||||
const lookup: Map<string, ViewMedia> = new Map();
|
||||
for (const browseMediaItem of browseMedia) {
|
||||
const cameraID = browseMediaItem._metadata?.cameraID;
|
||||
if (!cameraID) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mediaType =
|
||||
browseMediaItem.media_class === MEDIA_CLASS_VIDEO
|
||||
? 'clip'
|
||||
: browseMediaItem.media_class === MEDIA_CLASS_IMAGE
|
||||
? 'snapshot'
|
||||
: null;
|
||||
|
||||
if (!mediaType) {
|
||||
continue;
|
||||
}
|
||||
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
|
||||
mediaType,
|
||||
browseMediaItem,
|
||||
cameraID,
|
||||
);
|
||||
|
||||
const id = media.getID();
|
||||
const existing = lookup.get(id);
|
||||
|
||||
// De-duplicate events with precisely the same ID (same
|
||||
// hour/minute/second) choosing clip > snapshot.
|
||||
if (
|
||||
!existing ||
|
||||
(existing.getMediaType() === 'snapshot' && media.getMediaType() === 'clip')
|
||||
) {
|
||||
lookup.set(id, media);
|
||||
}
|
||||
}
|
||||
return [...lookup.values()];
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { RichBrowseMedia } from '../../../utils/ha/browse-media/types';
|
||||
import { rangesOverlap } from '../../range';
|
||||
import { BrowseMediaMetadata } from '../types';
|
||||
|
||||
/**
|
||||
* A utility method to determine if a browse media object matches against a
|
||||
* start and end date.
|
||||
* @param media The browse media object (with rich metadata).
|
||||
* @param start The optional start date.
|
||||
* @param end The optional end date.
|
||||
* @returns `true` if the media falls within the provided dates.
|
||||
*/
|
||||
|
||||
export const isMediaWithinDates = (
|
||||
media: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
start?: Date,
|
||||
end?: Date,
|
||||
): boolean => {
|
||||
// If there's no metadata, nothing matches.
|
||||
if (!media._metadata) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (start && end) {
|
||||
// Determine if:
|
||||
// - The media starts within the query timeframe.
|
||||
// - The media ends within the query timeframe.
|
||||
// - The media entirely encompasses the query timeframe.
|
||||
return rangesOverlap(
|
||||
{
|
||||
start: media._metadata.startDate,
|
||||
end: media._metadata.endDate,
|
||||
},
|
||||
{
|
||||
start: start,
|
||||
end: end,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!start && end) {
|
||||
return media._metadata.startDate <= end;
|
||||
}
|
||||
if (start && !end) {
|
||||
return media._metadata.startDate >= start;
|
||||
}
|
||||
|
||||
// If no date is specified at all, everything matches.
|
||||
return true;
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import { HassStateDifference, isTriggeredState } from '../utils/ha';
|
||||
import { Capabilities } from './capabilities';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraNoIDError } from './error';
|
||||
import { CameraEventCallback } from './types';
|
||||
import { CameraEventCallback, CameraProxyConfig } from './types';
|
||||
|
||||
export interface CameraInitializationOptions {
|
||||
stateWatcher: StateWatcherSubscriptionInterface;
|
||||
@@ -69,6 +69,18 @@ export class Camera {
|
||||
return this._capabilities ?? null;
|
||||
}
|
||||
|
||||
public getProxyConfig(): CameraProxyConfig {
|
||||
return {
|
||||
dynamic: this._config.proxy.dynamic,
|
||||
media: this._config.proxy.media === 'auto' ? false : this._config.proxy.media,
|
||||
ssl_verification: this._config.proxy.ssl_verification !== false,
|
||||
ssl_ciphers:
|
||||
this._config.proxy.ssl_ciphers === 'auto'
|
||||
? 'default'
|
||||
: this._config.proxy.ssl_ciphers,
|
||||
};
|
||||
}
|
||||
|
||||
protected _stateChangeHandler = (difference: HassStateDifference): void => {
|
||||
this._eventCallback?.({
|
||||
cameraID: this.getID(),
|
||||
|
||||
@@ -3,10 +3,9 @@ import { StateWatcherSubscriptionInterface } from '../card-controller/hass/state
|
||||
import { CameraConfig } from '../config/types';
|
||||
import { localize } from '../localize/localize';
|
||||
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
|
||||
import { BrowseMedia } from '../utils/ha/browse-media/types';
|
||||
import { EntityRegistryManager } from '../utils/ha/registry/entity';
|
||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
|
||||
import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { RecordingSegmentsCache, RequestCache } from './cache';
|
||||
import { CameraManagerEngine } from './engine';
|
||||
import { CameraInitializationError } from './error';
|
||||
import { CameraEventCallback, Engine } from './types';
|
||||
@@ -56,7 +55,18 @@ export class CameraManagerEngineFactory {
|
||||
cameraManagerEngine = new MotionEyeCameraManagerEngine(
|
||||
this._entityRegistryManager,
|
||||
options.stateWatcher,
|
||||
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
|
||||
new BrowseMediaManager(),
|
||||
options.resolvedMediaCache,
|
||||
new RequestCache(),
|
||||
options.eventCallback,
|
||||
);
|
||||
break;
|
||||
case Engine.Reolink:
|
||||
const { ReolinkCameraManagerEngine } = await import('./reolink/engine-reolink');
|
||||
cameraManagerEngine = new ReolinkCameraManagerEngine(
|
||||
this._entityRegistryManager,
|
||||
options.stateWatcher,
|
||||
new BrowseMediaManager(),
|
||||
options.resolvedMediaCache,
|
||||
new RequestCache(),
|
||||
options.eventCallback,
|
||||
@@ -76,6 +86,8 @@ export class CameraManagerEngineFactory {
|
||||
engine = Engine.MotionEye;
|
||||
} else if (cameraConfig.engine === 'generic') {
|
||||
engine = Engine.Generic;
|
||||
} else if (cameraConfig.engine === 'reolink') {
|
||||
engine = Engine.Reolink;
|
||||
} else {
|
||||
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
|
||||
|
||||
@@ -101,6 +113,9 @@ export class CameraManagerEngineFactory {
|
||||
case 'motioneye':
|
||||
engine = Engine.MotionEye;
|
||||
break;
|
||||
case 'reolink':
|
||||
engine = Engine.Reolink;
|
||||
break;
|
||||
default:
|
||||
engine = Engine.Generic;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { FrigateCardError } from '../../types';
|
||||
import { homeAssistantWSRequest } from '../../utils/ha';
|
||||
import { homeAssistantWSRequest } from '../../utils/ha/ws-request';
|
||||
import { RecordingSegment } from '../types';
|
||||
import {
|
||||
EventSummary,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||
import { CameraProxyConfig } from '../types';
|
||||
|
||||
export class MotionEyeCamera extends BrowseMediaCamera {
|
||||
public getProxyConfig(): CameraProxyConfig {
|
||||
return {
|
||||
...super.getProxyConfig(),
|
||||
|
||||
// For motionEye, media is always proxied unless explicitly turned off.
|
||||
media: this._config.proxy.media === 'auto' ? true : this._config.proxy.media,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,20 +8,21 @@ import {
|
||||
BrowseMediaTarget,
|
||||
} from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import {
|
||||
BrowseMedia,
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMedia,
|
||||
MEDIA_CLASS_IMAGE,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||
import {
|
||||
BrowseMediaCameraManagerEngine,
|
||||
getViewMediaFromBrowseMediaArray,
|
||||
isMediaWithinDates,
|
||||
} from '../browse-media/engine-browse-media';
|
||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
|
||||
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
|
||||
import { MemoryRequestCache } from '../cache';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
@@ -41,7 +42,9 @@ import {
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
} from '../types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
import motioneyeLogo from './assets/motioneye.svg';
|
||||
import { MotionEyeCamera } from './camera';
|
||||
import { MotionEyeEventQueryResults } from './types';
|
||||
|
||||
class MotionEyeQueryResultsClassifier {
|
||||
@@ -65,10 +68,45 @@ const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
|
||||
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
|
||||
|
||||
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
protected _directoryCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||
|
||||
public getEngineType(): Engine {
|
||||
return Engine.MotionEye;
|
||||
}
|
||||
|
||||
public async createCamera(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
const camera = new MotionEyeCamera(cameraConfig, this, {
|
||||
capabilities: new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: true,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: true,
|
||||
substream: true,
|
||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||
},
|
||||
),
|
||||
eventCallback: this._eventCallback,
|
||||
});
|
||||
return await camera.initialize({
|
||||
entityRegistryManager: this._entityRegistryManager,
|
||||
hass,
|
||||
stateWatcher: this._stateWatcher,
|
||||
});
|
||||
}
|
||||
|
||||
protected _convertMotionEyeTimeFormatToDateFNS(part: string): string {
|
||||
return part.replace(
|
||||
MOTIONEYE_REPL_REGEXP,
|
||||
@@ -202,7 +240,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
: []),
|
||||
],
|
||||
{
|
||||
useCache: engineOptions?.useCache,
|
||||
...(engineOptions?.useCache !== false && { cache: this._directoryCache }),
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -251,6 +289,7 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
cameraConfig.motioneye.images.file_pattern,
|
||||
);
|
||||
|
||||
const limit = perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT;
|
||||
const media = await this._browseMediaManager.walkBrowseMedias(
|
||||
hass,
|
||||
[
|
||||
@@ -275,12 +314,15 @@ export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine
|
||||
}
|
||||
return null;
|
||||
},
|
||||
earlyExit: (media) => media.length >= limit,
|
||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||
!media.can_expand &&
|
||||
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
||||
},
|
||||
],
|
||||
{ useCache: engineOptions?.useCache },
|
||||
{
|
||||
...(engineOptions?.useCache !== false && { cache: this._fileCache }),
|
||||
},
|
||||
);
|
||||
|
||||
// Sort by most recent then slice at the query limit.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
version="1.1"
|
||||
id="svg62"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
sodipodi:docname="reolink.svg"
|
||||
inkscape:version="1.2.2 (b0a8486541, 2022-12-01)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs66" />
|
||||
<sodipodi:namedview
|
||||
id="namedview64"
|
||||
pagecolor="#505050"
|
||||
bordercolor="#eeeeee"
|
||||
borderopacity="1"
|
||||
inkscape:showpageshadow="0"
|
||||
inkscape:pageopacity="0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#505050"
|
||||
showgrid="true"
|
||||
inkscape:zoom="36.417984"
|
||||
inkscape:cx="18.191562"
|
||||
inkscape:cy="8.6633022"
|
||||
inkscape:window-width="3840"
|
||||
inkscape:window-height="1527"
|
||||
inkscape:window-x="1080"
|
||||
inkscape:window-y="227"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="g68">
|
||||
<inkscape:grid
|
||||
type="xygrid"
|
||||
id="grid1175"
|
||||
dotted="false"
|
||||
snapvisiblegridlinesonly="true" />
|
||||
</sodipodi:namedview>
|
||||
<g
|
||||
inkscape:groupmode="layer"
|
||||
inkscape:label="Reolink Logo"
|
||||
id="g68">
|
||||
<path
|
||||
style="fill:#ffffff;stroke-width:0.0393282;fill-opacity:1"
|
||||
d="M 17.037254,23.964909 C 16.764634,23.917573 16.561708,23.83617 16.325735,23.67954 16.196719,23.593913 14.543544,21.952905 12.217956,19.602014 10.077426,17.438191 8.2198993,15.582673 8.0901166,15.478642 7.6038998,15.088895 7.0877741,14.869424 6.4986827,14.801908 6.3140805,14.780765 5.079061,14.763305 3.7541964,14.763126 l -2.408845,-4.78e-4 0.00713,-7.3937713 v -7.3935985 l 6.2040046,3.536e-5 c 3.9799342,1.969e-5 6.3590882,0.01465833 6.6366112,0.0408227 2.331195,0.21982024 4.364771,1.49885914 5.578726,3.50085534 0.686043,1.1313823 1.020901,2.2783695 1.067186,3.6554196 0.06335,1.8844953 -0.57426,3.6597378 -1.831469,5.0992438 -0.756457,0.866146 -1.861127,1.62991 -2.928729,2.024919 l -0.319718,0.118287 -1.951287,-1.979284 -1.951286,-1.979278 1.046741,-0.02346 c 0.575706,-0.01291 1.099833,-0.03689 1.164724,-0.0533 0.596068,-0.150649 0.949703,-0.313968 1.322206,-0.6106282 0.643573,-0.5125401 1.046169,-1.2439257 1.137985,-2.0673522 0.167852,-1.505317 -0.771478,-2.8704888 -2.263547,-3.2897068 L 13.950001,4.3234481 9.7981831,4.3106846 5.6392389,4.29801 v 3.0692702 3.0692648 l 1.4185165,7.9e-4 c 1.6275801,8.87e-4 1.7789152,0.01912 2.3105245,0.27984 0.3095679,0.15183 0.389559,0.219499 1.0817441,0.915083 3.027399,3.042274 12.19404,12.30837 12.204589,12.337002 0.0161,0.04373 -5.364393,0.03953 -5.617359,-0.0045 z"
|
||||
id="path1173"
|
||||
inkscape:label="R" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,64 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { EntityRegistryManager } from '../../utils/ha/registry/entity';
|
||||
import { BrowseMediaCamera } from '../browse-media/camera';
|
||||
import { Camera, CameraInitializationOptions } from '../camera';
|
||||
import { CameraInitializationError } from '../error';
|
||||
import { CameraProxyConfig } from '../types';
|
||||
|
||||
interface ReolinkCameraInitializationOptions extends CameraInitializationOptions {
|
||||
entityRegistryManager: EntityRegistryManager;
|
||||
hass: HomeAssistant;
|
||||
}
|
||||
|
||||
class ReolinkInitializationError extends CameraInitializationError {}
|
||||
|
||||
export class ReolinkCamera extends BrowseMediaCamera {
|
||||
protected _channel: number | null = null;
|
||||
|
||||
public async initialize(options: ReolinkCameraInitializationOptions): Promise<Camera> {
|
||||
await super.initialize(options);
|
||||
this._initializeChannel();
|
||||
return this;
|
||||
}
|
||||
|
||||
protected _initializeChannel(): void {
|
||||
const uniqueID = this._entity?.unique_id;
|
||||
const match = uniqueID ? String(uniqueID).match(/(.*)_(?<channel>\d+)/) : null;
|
||||
const channel = match && match.groups?.channel ? Number(match.groups.channel) : null;
|
||||
|
||||
if (channel === null) {
|
||||
throw new ReolinkInitializationError(
|
||||
localize('error.camera_initialization_reolink'),
|
||||
this.getConfig(),
|
||||
);
|
||||
}
|
||||
this._channel = channel;
|
||||
}
|
||||
|
||||
public getChannel(): number | null {
|
||||
return this._channel;
|
||||
}
|
||||
|
||||
public getProxyConfig(): CameraProxyConfig {
|
||||
return {
|
||||
...super.getProxyConfig(),
|
||||
|
||||
// For reolink, media is always proxied unless explicitly turned off.
|
||||
media: this._config.proxy.media === 'auto' ? true : this._config.proxy.media,
|
||||
|
||||
// Reolink does not verify SSL certificates since they may be self-signed.
|
||||
ssl_verification:
|
||||
this._config.proxy.ssl_verification === 'auto'
|
||||
? false
|
||||
: this._config.proxy.ssl_verification,
|
||||
|
||||
// Through experimentation 'intermediate' is the "highest
|
||||
// lowest-common-denominator" Reolink devices appear to support.
|
||||
ssl_ciphers:
|
||||
this._config.proxy.ssl_ciphers === 'auto'
|
||||
? 'intermediate'
|
||||
: this._config.proxy.ssl_ciphers,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { add, endOfDay, parse, startOfDay } from 'date-fns';
|
||||
import { orderBy } from 'lodash-es';
|
||||
import { CameraConfig } from '../../config/types';
|
||||
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
|
||||
import { sortMediaByStartDate } from '../../utils/ha/browse-media/browse-media-manager';
|
||||
import {
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMedia,
|
||||
MEDIA_CLASS_VIDEO,
|
||||
RichBrowseMedia,
|
||||
} from '../../utils/ha/browse-media/types';
|
||||
import { ViewMedia } from '../../view/media';
|
||||
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
import { getViewMediaFromBrowseMediaArray } from '../browse-media/utils/browse-media-to-view-media';
|
||||
import { isMediaWithinDates } from '../browse-media/utils/within-dates';
|
||||
import { MemoryRequestCache } from '../cache';
|
||||
import { Camera } from '../camera';
|
||||
import { Capabilities } from '../capabilities';
|
||||
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
|
||||
import { CameraManagerReadOnlyConfigStore } from '../store';
|
||||
import {
|
||||
CameraEndpoint,
|
||||
CameraEndpoints,
|
||||
CameraEndpointsContext,
|
||||
CameraManagerCameraMetadata,
|
||||
Engine,
|
||||
EngineOptions,
|
||||
EventQuery,
|
||||
EventQueryResults,
|
||||
EventQueryResultsMap,
|
||||
MediaMetadataQuery,
|
||||
MediaMetadataQueryResults,
|
||||
MediaMetadataQueryResultsMap,
|
||||
QueryResults,
|
||||
QueryResultsType,
|
||||
QueryReturnType,
|
||||
} from '../types';
|
||||
import { getPTZCapabilitiesFromCameraConfig } from '../utils/ptz';
|
||||
import reolinkLogo from './assets/reolink.svg';
|
||||
import { ReolinkCamera } from './camera';
|
||||
import { ReolinkEventQueryResults } from './types';
|
||||
|
||||
export class ReolinkQueryResultsClassifier {
|
||||
public static isReolinkEventQueryResults(
|
||||
results: QueryResults,
|
||||
): results is ReolinkEventQueryResults {
|
||||
return results.engine === Engine.Reolink && results.type === QueryResultsType.Event;
|
||||
}
|
||||
}
|
||||
|
||||
export class ReolinkCameraManagerEngine extends BrowseMediaCameraManagerEngine {
|
||||
protected _directoryCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||
protected _fileCache = new MemoryRequestCache<string, BrowseMedia>();
|
||||
|
||||
public getEngineType(): Engine {
|
||||
return Engine.Reolink;
|
||||
}
|
||||
|
||||
protected _reolinkFileMetadataGenerator(
|
||||
cameraID: string,
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
): BrowseMediaMetadata | null {
|
||||
/* istanbul ignore next: This situation cannot happen as the directory would
|
||||
be rejected by _reolinkDirectoryMetadataGenerator if there was no start date
|
||||
-- @preserve */
|
||||
if (!parent?._metadata?.startDate || media.media_class !== MEDIA_CLASS_VIDEO) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Title of the form "21:47:03 0:00:44"
|
||||
const parts = media.title.split(/ +/);
|
||||
if (parts.length !== 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startDate = parse(parts[0], 'HH:mm:ss', parent._metadata.startDate);
|
||||
if (!isValidDate(startDate)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const durationMatch = parts[1].match(
|
||||
/(?<hours>\d+):(?<minutes>\d+):(?<seconds>\d+)/,
|
||||
);
|
||||
const duration = durationMatch?.groups
|
||||
? {
|
||||
hours: Number(durationMatch.groups.hours),
|
||||
minutes: Number(durationMatch.groups.minutes),
|
||||
seconds: Number(durationMatch.groups.seconds),
|
||||
}
|
||||
: null;
|
||||
|
||||
return {
|
||||
cameraID: cameraID,
|
||||
startDate: startDate,
|
||||
endDate: duration ? add(startDate, duration) : startDate,
|
||||
};
|
||||
}
|
||||
|
||||
protected _reolinkDirectoryMetadataGenerator(
|
||||
cameraID: string,
|
||||
media: BrowseMedia,
|
||||
): BrowseMediaMetadata | null {
|
||||
// Title of the form: "2024/9/29"
|
||||
const parsedDate = parse(media.title, 'yyyy/M/d', new Date());
|
||||
|
||||
return isValidDate(parsedDate)
|
||||
? {
|
||||
cameraID: cameraID,
|
||||
startDate: startOfDay(parsedDate),
|
||||
endDate: endOfDay(parsedDate),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
public async createCamera(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): Promise<Camera> {
|
||||
const camera = new ReolinkCamera(cameraConfig, this, {
|
||||
capabilities: new Capabilities(
|
||||
{
|
||||
'favorite-events': false,
|
||||
'favorite-recordings': false,
|
||||
clips: true,
|
||||
live: true,
|
||||
menu: true,
|
||||
recordings: false,
|
||||
seek: false,
|
||||
snapshots: false,
|
||||
substream: true,
|
||||
ptz: getPTZCapabilitiesFromCameraConfig(cameraConfig) ?? undefined,
|
||||
},
|
||||
{
|
||||
disable: cameraConfig.capabilities?.disable,
|
||||
disableExcept: cameraConfig.capabilities?.disable_except,
|
||||
},
|
||||
),
|
||||
eventCallback: this._eventCallback,
|
||||
});
|
||||
return await camera.initialize({
|
||||
entityRegistryManager: this._entityRegistryManager,
|
||||
hass,
|
||||
stateWatcher: this._stateWatcher,
|
||||
});
|
||||
}
|
||||
|
||||
protected async _getMatchingDirectories(
|
||||
hass: HomeAssistant,
|
||||
camera: ReolinkCamera,
|
||||
matchOptions?: {
|
||||
start?: Date;
|
||||
end?: Date;
|
||||
} | null,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
|
||||
const cameraConfig = camera.getConfig();
|
||||
const entity = camera.getEntity();
|
||||
const configID = entity?.config_entry_id;
|
||||
|
||||
if (camera.getChannel() === null || !configID) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this._browseMediaManager.walkBrowseMedias(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
targets: [
|
||||
`media-source://reolink/RES|${configID}|${camera.getChannel()}|` +
|
||||
`${cameraConfig.reolink?.media_resolution === 'low' ? 'sub' : 'main'}`,
|
||||
],
|
||||
concurrency: Infinity,
|
||||
metadataGenerator: (
|
||||
media: BrowseMedia,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
) => this._reolinkDirectoryMetadataGenerator(camera.getID(), media),
|
||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||
media.can_expand &&
|
||||
isMediaWithinDates(media, matchOptions?.start, matchOptions?.end),
|
||||
sorter: (media: RichBrowseMedia<BrowseMediaMetadata>[]) =>
|
||||
sortMediaByStartDate(media),
|
||||
},
|
||||
],
|
||||
{
|
||||
...(engineOptions?.useCache !== false && { cache: this._directoryCache }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public async getEvents(
|
||||
hass: HomeAssistant,
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
query: EventQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<EventQueryResultsMap | null> {
|
||||
// Reolink does not support these query types and they will never match.
|
||||
if (
|
||||
query.favorite ||
|
||||
query.tags?.size ||
|
||||
query.what?.size ||
|
||||
query.where?.size ||
|
||||
query.hasSnapshot
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output: EventQueryResultsMap = new Map();
|
||||
const getEventsForCamera = async (cameraID: string): Promise<void> => {
|
||||
const perCameraQuery = { ...query, cameraIDs: new Set([cameraID]) };
|
||||
const cachedResult =
|
||||
engineOptions?.useCache ?? true ? this._requestCache.get(perCameraQuery) : null;
|
||||
if (cachedResult) {
|
||||
output.set(perCameraQuery, cachedResult as EventQueryResults);
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = store.getCamera(cameraID);
|
||||
const directories =
|
||||
camera && camera instanceof ReolinkCamera
|
||||
? await this._getMatchingDirectories(
|
||||
hass,
|
||||
camera,
|
||||
perCameraQuery,
|
||||
engineOptions,
|
||||
)
|
||||
: null;
|
||||
const limit = perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT;
|
||||
let media: RichBrowseMedia<BrowseMediaMetadata>[] = [];
|
||||
|
||||
if (directories?.length) {
|
||||
media = await this._browseMediaManager.walkBrowseMedias(
|
||||
hass,
|
||||
[
|
||||
{
|
||||
targets: directories,
|
||||
concurrency: 1,
|
||||
metadataGenerator: (
|
||||
media: BrowseMedia,
|
||||
parent?: RichBrowseMedia<BrowseMediaMetadata>,
|
||||
) => this._reolinkFileMetadataGenerator(cameraID, media, parent),
|
||||
earlyExit: (media) => media.length >= limit,
|
||||
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
|
||||
!media.can_expand &&
|
||||
isMediaWithinDates(media, perCameraQuery.start, perCameraQuery.end),
|
||||
sorter: (media: RichBrowseMedia<BrowseMediaMetadata>[]) =>
|
||||
sortMediaByStartDate(media),
|
||||
},
|
||||
],
|
||||
{
|
||||
...(engineOptions?.useCache !== false && { cache: this._fileCache }),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Sort by most recent then slice at the query limit.
|
||||
const sortedMedia = orderBy(
|
||||
media,
|
||||
(media: RichBrowseMedia<BrowseMediaMetadata>) => media._metadata?.startDate,
|
||||
'desc',
|
||||
).slice(0, limit);
|
||||
|
||||
const result: ReolinkEventQueryResults = {
|
||||
type: QueryResultsType.Event,
|
||||
engine: Engine.Reolink,
|
||||
browseMedia: sortedMedia,
|
||||
};
|
||||
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._requestCache.set(
|
||||
perCameraQuery,
|
||||
{ ...result, cached: true },
|
||||
result.expiry,
|
||||
);
|
||||
}
|
||||
output.set(perCameraQuery, result);
|
||||
};
|
||||
|
||||
await allPromises(query.cameraIDs, (cameraID) => getEventsForCamera(cameraID));
|
||||
return output;
|
||||
}
|
||||
|
||||
public generateMediaFromEvents(
|
||||
_hass: HomeAssistant,
|
||||
_store: CameraManagerReadOnlyConfigStore,
|
||||
_query: EventQuery,
|
||||
results: QueryReturnType<EventQuery>,
|
||||
): ViewMedia[] | null {
|
||||
if (!ReolinkQueryResultsClassifier.isReolinkEventQueryResults(results)) {
|
||||
return null;
|
||||
}
|
||||
return getViewMediaFromBrowseMediaArray(results.browseMedia);
|
||||
}
|
||||
|
||||
public async getMediaMetadata(
|
||||
hass: HomeAssistant,
|
||||
store: CameraManagerReadOnlyConfigStore,
|
||||
query: MediaMetadataQuery,
|
||||
engineOptions?: EngineOptions,
|
||||
): Promise<MediaMetadataQueryResultsMap | null> {
|
||||
const output: MediaMetadataQueryResultsMap = new Map();
|
||||
const cachedResult =
|
||||
engineOptions?.useCache ?? true ? this._requestCache.get(query) : null;
|
||||
|
||||
if (cachedResult) {
|
||||
output.set(query, cachedResult as MediaMetadataQueryResults);
|
||||
return output;
|
||||
}
|
||||
|
||||
const days: Set<string> = new Set();
|
||||
const getDaysForCamera = async (cameraID: string): Promise<void> => {
|
||||
const camera = store.getCamera(cameraID);
|
||||
if (!camera || !(camera instanceof ReolinkCamera)) {
|
||||
return;
|
||||
}
|
||||
const directories = await this._getMatchingDirectories(
|
||||
hass,
|
||||
camera,
|
||||
null,
|
||||
engineOptions,
|
||||
);
|
||||
for (const dayDirectory of directories ?? []) {
|
||||
/* istanbul ignore next: This situation cannot happen as the directory
|
||||
will not match without metadata -- @preserve */
|
||||
if (dayDirectory._metadata) {
|
||||
days.add(formatDate(dayDirectory._metadata?.startDate));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await allPromises(query.cameraIDs, (cameraID) => getDaysForCamera(cameraID));
|
||||
|
||||
const result: MediaMetadataQueryResults = {
|
||||
type: QueryResultsType.MediaMetadata,
|
||||
engine: Engine.Reolink,
|
||||
metadata: {
|
||||
...(days.size && { days: days }),
|
||||
},
|
||||
expiry: add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||
cached: false,
|
||||
};
|
||||
|
||||
if (engineOptions?.useCache ?? true) {
|
||||
this._requestCache.set(query, { ...result, cached: true }, result.expiry);
|
||||
}
|
||||
output.set(query, result);
|
||||
return output;
|
||||
}
|
||||
|
||||
public getCameraMetadata(
|
||||
hass: HomeAssistant,
|
||||
cameraConfig: CameraConfig,
|
||||
): CameraManagerCameraMetadata {
|
||||
const metadata = super.getCameraMetadata(hass, cameraConfig);
|
||||
return {
|
||||
...metadata,
|
||||
engineLogo: reolinkLogo,
|
||||
};
|
||||
}
|
||||
|
||||
public getCameraEndpoints(
|
||||
cameraConfig: CameraConfig,
|
||||
context?: CameraEndpointsContext,
|
||||
): CameraEndpoints | null {
|
||||
const getUIEndpoint = (): CameraEndpoint | null => {
|
||||
return cameraConfig.reolink?.url
|
||||
? {
|
||||
endpoint: cameraConfig.reolink.url,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
const ui = getUIEndpoint();
|
||||
return {
|
||||
...super.getCameraEndpoints(cameraConfig, context),
|
||||
...(ui && { ui: ui }),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { RichBrowseMedia } from '../../utils/ha/browse-media/types';
|
||||
import { BrowseMediaMetadata } from '../browse-media/types';
|
||||
import { Engine, EventQueryResults } from '../types';
|
||||
|
||||
// ==============================
|
||||
// Reolink concrete query results
|
||||
// ==============================
|
||||
|
||||
export interface ReolinkEventQueryResults extends EventQueryResults {
|
||||
engine: Engine.Reolink;
|
||||
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[];
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { CapabilityKey } from '../types';
|
||||
import { FrigateCardView } from '../config/types';
|
||||
import { FrigateCardView, SSLCiphers } from '../config/types';
|
||||
import { ViewMedia } from '../view/media';
|
||||
|
||||
// ====
|
||||
@@ -24,6 +24,7 @@ export enum Engine {
|
||||
Frigate = 'frigate',
|
||||
Generic = 'generic',
|
||||
MotionEye = 'motioneye',
|
||||
Reolink = 'reolink',
|
||||
}
|
||||
|
||||
export interface DataQuery {
|
||||
@@ -126,6 +127,13 @@ export interface CameraEndpoints {
|
||||
webrtcCard?: CameraEndpoint;
|
||||
}
|
||||
|
||||
export interface CameraProxyConfig {
|
||||
dynamic: boolean;
|
||||
media: boolean;
|
||||
ssl_verification: boolean;
|
||||
ssl_ciphers: SSLCiphers;
|
||||
}
|
||||
|
||||
export interface EngineOptions {
|
||||
useCache?: boolean;
|
||||
}
|
||||
|
||||
@@ -48,35 +48,31 @@ export class FrigateCardNextPreviousControl extends LitElement {
|
||||
return html``;
|
||||
}
|
||||
|
||||
const renderIcon =
|
||||
!this.thumbnail || ['chevrons', 'icons'].includes(this._controlConfig.style);
|
||||
|
||||
const classes = {
|
||||
controls: true,
|
||||
previous: this.direction === 'previous',
|
||||
next: this.direction === 'next',
|
||||
thumbnails: this._controlConfig.style === 'thumbnails',
|
||||
icons: ['chevrons', 'icons'].includes(this._controlConfig.style),
|
||||
button: ['chevrons', 'icons'].includes(this._controlConfig.style),
|
||||
thumbnails: !renderIcon,
|
||||
icons: renderIcon,
|
||||
button: renderIcon,
|
||||
};
|
||||
|
||||
if (['chevrons', 'icons'].includes(this._controlConfig.style)) {
|
||||
let icon: string;
|
||||
if (this._controlConfig.style === 'chevrons') {
|
||||
icon = this.direction === 'previous' ? 'mdi:chevron-left' : 'mdi:chevron-right';
|
||||
} else {
|
||||
if (!this.icon) {
|
||||
return html``;
|
||||
}
|
||||
icon = this.icon;
|
||||
}
|
||||
if (renderIcon) {
|
||||
const icon =
|
||||
!this.thumbnail || !this.icon || this._controlConfig.style === 'chevrons'
|
||||
? this.direction === 'previous'
|
||||
? 'mdi:chevron-left'
|
||||
: 'mdi:chevron-right'
|
||||
: this.icon;
|
||||
|
||||
return html` <ha-icon-button class="${classMap(classes)}" .label=${this.label}>
|
||||
<ha-icon icon=${icon}></ha-icon>
|
||||
</ha-icon-button>`;
|
||||
}
|
||||
|
||||
if (!this.thumbnail) {
|
||||
return html``;
|
||||
}
|
||||
|
||||
return renderTask(
|
||||
this,
|
||||
this._embedThumbnailTask,
|
||||
|
||||
+44
-29
@@ -11,11 +11,13 @@ import {
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { classMap } from 'lit/directives/class-map.js';
|
||||
import { CameraManager } from '../camera-manager/manager.js';
|
||||
import { CameraManagerCameraMetadata } from '../camera-manager/types.js';
|
||||
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
|
||||
import { ViewManagerEpoch } from '../card-controller/view/types.js';
|
||||
import { localize } from '../localize/localize.js';
|
||||
import thumbnailDetailsStyle from '../scss/thumbnail-details.scss';
|
||||
import thumbnailFeatureEventStyle from '../scss/thumbnail-feature-event.scss';
|
||||
import thumbnailFeatureRecordingStyle from '../scss/thumbnail-feature-recording.scss';
|
||||
import thumbnailFeatureTextStyle from '../scss/thumbnail-feature-text.scss';
|
||||
import thumbnailFeatureThumbnailStyle from '../scss/thumbnail-feature-thumbnail.scss';
|
||||
import thumbnailStyle from '../scss/thumbnail.scss';
|
||||
import type { ExtendedHomeAssistant } from '../types.js';
|
||||
import { stopEventFromActivatingCardWideActions } from '../utils/action.js';
|
||||
@@ -31,13 +33,12 @@ import { createFetchThumbnailTask, FetchThumbnailTaskArgs } from '../utils/thumb
|
||||
import { ViewMediaClassifier } from '../view/media-classifier.js';
|
||||
import { EventViewMedia, RecordingViewMedia, ViewMedia } from '../view/media.js';
|
||||
import { dispatchFrigateCardErrorEvent } from './message.js';
|
||||
import { RemoveContextViewModifier } from '../card-controller/view/modifiers/remove-context.js';
|
||||
|
||||
// The minimum width of a thumbnail with details enabled.
|
||||
export const THUMBNAIL_DETAILS_WIDTH_MIN = 300;
|
||||
|
||||
@customElement('frigate-card-thumbnail-feature-event')
|
||||
export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
||||
@customElement('frigate-card-thumbnail-feature-thumbnail')
|
||||
export class FrigateCardThumbnailFeatureThumbnail extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public thumbnail?: string;
|
||||
|
||||
@@ -101,14 +102,15 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
||||
}
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this._embedThumbnailTask) {
|
||||
return;
|
||||
}
|
||||
const imageOff = html`<ha-icon
|
||||
icon="mdi:image-off"
|
||||
title=${localize('thumbnail.no_thumbnail')}
|
||||
></ha-icon> `;
|
||||
|
||||
if (!this._embedThumbnailTask) {
|
||||
return imageOff;
|
||||
}
|
||||
|
||||
return html`${this.thumbnail
|
||||
? renderTask(
|
||||
this,
|
||||
@@ -121,31 +123,41 @@ export class FrigateCardThumbnailFeatureEvent extends LitElement {
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
return unsafeCSS(thumbnailFeatureEventStyle);
|
||||
return unsafeCSS(thumbnailFeatureThumbnailStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@customElement('frigate-card-thumbnail-feature-recording')
|
||||
export class FrigateCardThumbnailFeatureRecording extends LitElement {
|
||||
@customElement('frigate-card-thumbnail-feature-text')
|
||||
export class FrigateCardThumbnailFeatureText extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public date?: Date;
|
||||
|
||||
@property({ attribute: false })
|
||||
public cameraTitle?: string;
|
||||
public cameraMetadata?: CameraManagerCameraMetadata;
|
||||
|
||||
@property({ attribute: false })
|
||||
public showCameraTitle?: boolean;
|
||||
|
||||
protected render(): TemplateResult | void {
|
||||
if (!this.date) {
|
||||
return;
|
||||
}
|
||||
return html`
|
||||
<div class="title">${format(this.date, 'HH:mm')}</div>
|
||||
<div class="subtitle">${format(this.date, 'MMM do')}</div>
|
||||
${this.cameraTitle ? html`<div class="camera">${this.cameraTitle}</div>` : html``}
|
||||
${this.cameraMetadata?.engineLogo
|
||||
? html`<img class="background" src="${this.cameraMetadata.engineLogo}" />`
|
||||
: ''}
|
||||
<div class="content">
|
||||
<div class="title">${format(this.date, 'HH:mm')}</div>
|
||||
<div class="subtitle">${format(this.date, 'MMM do')}</div>
|
||||
${this.showCameraTitle && this.cameraMetadata?.title
|
||||
? html`<div class="camera">${this.cameraMetadata.title}</div>`
|
||||
: html``}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResult {
|
||||
return unsafeCSS(thumbnailFeatureRecordingStyle);
|
||||
return unsafeCSS(thumbnailFeatureTextStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,25 +417,28 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
this.media.getID() &&
|
||||
mediaCapabilities?.canDownload;
|
||||
|
||||
const cameraTitle = this.cameraManager.getCameraMetadata(
|
||||
const cameraMetadata = this.cameraManager.getCameraMetadata(
|
||||
this.media.getCameraID(),
|
||||
)?.title;
|
||||
);
|
||||
|
||||
return html`
|
||||
${ViewMediaClassifier.isEvent(this.media)
|
||||
? html`<frigate-card-thumbnail-feature-event
|
||||
${ViewMediaClassifier.isEvent(this.media) && thumbnail
|
||||
? html`<frigate-card-thumbnail-feature-thumbnail
|
||||
aria-label="${title ?? ''}"
|
||||
title=${title}
|
||||
.hass=${this.hass}
|
||||
.date=${this.media.getStartTime() ?? undefined}
|
||||
.thumbnail=${thumbnail ?? undefined}
|
||||
></frigate-card-thumbnail-feature-event>`
|
||||
: ViewMediaClassifier.isRecording(this.media)
|
||||
? html`<frigate-card-thumbnail-feature-recording
|
||||
></frigate-card-thumbnail-feature-thumbnail>`
|
||||
: ViewMediaClassifier.isEvent(this.media) ||
|
||||
ViewMediaClassifier.isRecording(this.media)
|
||||
? html`<frigate-card-thumbnail-feature-text
|
||||
aria-label="${title ?? ''}"
|
||||
title="${title ?? ''}"
|
||||
.cameraTitle=${this.details ? undefined : cameraTitle}
|
||||
.cameraMetadata=${cameraMetadata}
|
||||
.showCameraTitle=${!this.details}
|
||||
.date=${this.media.getStartTime() ?? undefined}
|
||||
></frigate-card-thumbnail-feature-recording>`
|
||||
></frigate-card-thumbnail-feature-text>`
|
||||
: html``}
|
||||
${shouldShowFavoriteControl
|
||||
? html` <ha-icon
|
||||
@@ -450,13 +465,13 @@ export class FrigateCardThumbnail extends LitElement {
|
||||
${this.details && ViewMediaClassifier.isEvent(this.media)
|
||||
? html`<frigate-card-thumbnail-details-event
|
||||
.media=${this.media ?? undefined}
|
||||
.cameraTitle=${cameraTitle}
|
||||
.cameraTitle=${cameraMetadata?.title}
|
||||
.seek=${this.seek}
|
||||
></frigate-card-thumbnail-details-event>`
|
||||
: this.details && ViewMediaClassifier.isRecording(this.media)
|
||||
? html`<frigate-card-thumbnail-details-recording
|
||||
.media=${this.media ?? undefined}
|
||||
.cameraTitle=${cameraTitle}
|
||||
.cameraTitle=${cameraMetadata?.title}
|
||||
.seek=${this.seek}
|
||||
></frigate-card-thumbnail-details-recording>`
|
||||
: html``}
|
||||
@@ -516,7 +531,7 @@ declare global {
|
||||
'frigate-card-thumbnail': FrigateCardThumbnail;
|
||||
'frigate-card-thumbnail-details-recording': FrigateCardThumbnailDetailsRecording;
|
||||
'frigate-card-thumbnail-details-event': FrigateCardThumbnailDetailsEvent;
|
||||
'frigate-card-thumbnail-feature-recording': FrigateCardThumbnailFeatureRecording;
|
||||
'frigate-card-thumbnail-feature-event': FrigateCardThumbnailFeatureEvent;
|
||||
'frigate-card-thumbnail-feature-text': FrigateCardThumbnailFeatureText;
|
||||
'frigate-card-thumbnail-feature-thumbnail': FrigateCardThumbnailFeatureThumbnail;
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TemplateResult,
|
||||
unsafeCSS,
|
||||
} from 'lit';
|
||||
import { customElement, property } from 'lit/decorators.js';
|
||||
import { customElement, property, state } from 'lit/decorators.js';
|
||||
import { guard } from 'lit/directives/guard.js';
|
||||
import { createRef, Ref, ref } from 'lit/directives/ref.js';
|
||||
import { CameraManager } from '../../camera-manager/manager.js';
|
||||
@@ -16,11 +16,24 @@ import { handleZoomSettingsObservedEvent } from '../../components-lib/zoom/zoom-
|
||||
import { CardWideConfig, ViewerConfig } from '../../config/types.js';
|
||||
import '../../patches/ha-hls-player.js';
|
||||
import viewerProviderStyle from '../../scss/viewer-provider.scss';
|
||||
import { ExtendedHomeAssistant, FrigateCardMediaPlayer } from '../../types.js';
|
||||
import {
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardMediaPlayer,
|
||||
ResolvedMedia,
|
||||
} from '../../types.js';
|
||||
import { mayHaveAudio } from '../../utils/audio.js';
|
||||
import { aspectRatioToString } from '../../utils/basic.js';
|
||||
import { canonicalizeHAURL } from '../../utils/ha/index.js';
|
||||
import { aspectRatioToString, errorToConsole } from '../../utils/basic.js';
|
||||
import {
|
||||
canonicalizeHAURL,
|
||||
homeAssistantSignPath,
|
||||
isHARelativeURL,
|
||||
} from '../../utils/ha/index.js';
|
||||
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media.js';
|
||||
import {
|
||||
addDynamicProxyURL,
|
||||
getWebProxiedURL,
|
||||
shouldUseWebProxy,
|
||||
} from '../../utils/ha/web-proxy.js';
|
||||
import {
|
||||
dispatchMediaLoadedEvent,
|
||||
dispatchMediaPauseEvent,
|
||||
@@ -77,6 +90,9 @@ export class FrigateCardViewerProvider
|
||||
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
|
||||
protected _refImageProvider: Ref<HTMLImageElement> = createRef();
|
||||
|
||||
@state()
|
||||
protected _url: string | null = null;
|
||||
|
||||
public async play(): Promise<void> {
|
||||
await playMediaMutingIfNecessary(
|
||||
this,
|
||||
@@ -191,21 +207,77 @@ export class FrigateCardViewerProvider
|
||||
});
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
const mediaContentID = this.media ? this.media.getContentID() : null;
|
||||
|
||||
protected async _setURL(): Promise<void> {
|
||||
const mediaContentID = this.media?.getContentID();
|
||||
if (
|
||||
(changedProps.has('load') ||
|
||||
changedProps.has('media') ||
|
||||
changedProps.has('viewerConfig') ||
|
||||
changedProps.has('resolvedMediaCache') ||
|
||||
changedProps.has('hass')) &&
|
||||
this.hass &&
|
||||
mediaContentID &&
|
||||
!this.resolvedMediaCache?.has(mediaContentID) &&
|
||||
(!this.viewerConfig?.lazy_load || this.load)
|
||||
!this.media ||
|
||||
!mediaContentID ||
|
||||
!this.hass ||
|
||||
(this.viewerConfig?.lazy_load && !this.load)
|
||||
) {
|
||||
resolveMedia(this.hass, mediaContentID, this.resolvedMediaCache).then(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
let resolvedMedia: ResolvedMedia | null =
|
||||
this.resolvedMediaCache?.get(mediaContentID) ?? null;
|
||||
if (!resolvedMedia) {
|
||||
resolvedMedia = await resolveMedia(
|
||||
this.hass,
|
||||
mediaContentID,
|
||||
this.resolvedMediaCache,
|
||||
);
|
||||
}
|
||||
|
||||
if (!resolvedMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsignedURL = resolvedMedia.url;
|
||||
if (isHARelativeURL(unsignedURL)) {
|
||||
// No need to proxy or sign local resolved URLs.
|
||||
this._url = canonicalizeHAURL(this.hass, unsignedURL);
|
||||
return;
|
||||
}
|
||||
|
||||
const camera = this.cameraManager?.getStore().getCamera(this.media.getCameraID());
|
||||
const proxyConfig = camera?.getProxyConfig();
|
||||
|
||||
if (proxyConfig && shouldUseWebProxy(this.hass, proxyConfig, 'media')) {
|
||||
if (proxyConfig.dynamic) {
|
||||
// Don't use URL() parsing, since that will strip the port number if
|
||||
// it's the default, just need to strip any hash part of the URL.
|
||||
const urlWithoutQSorHash = unsignedURL.split(/#/)[0];
|
||||
await addDynamicProxyURL(this.hass, urlWithoutQSorHash, {
|
||||
sslVerification: proxyConfig.ssl_verification,
|
||||
sslCiphers: proxyConfig.ssl_ciphers,
|
||||
|
||||
// The link may need to be opened multiple times.
|
||||
openLimit: 0,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
this._url = await homeAssistantSignPath(
|
||||
this.hass,
|
||||
getWebProxiedURL(unsignedURL),
|
||||
);
|
||||
} catch (e) {
|
||||
errorToConsole(e as Error);
|
||||
}
|
||||
} else {
|
||||
this._url = unsignedURL;
|
||||
}
|
||||
}
|
||||
|
||||
protected willUpdate(changedProps: PropertyValues): void {
|
||||
if (
|
||||
changedProps.has('load') ||
|
||||
changedProps.has('media') ||
|
||||
changedProps.has('viewerConfig') ||
|
||||
changedProps.has('resolvedMediaCache') ||
|
||||
changedProps.has('hass')
|
||||
) {
|
||||
this._setURL().then(() => {
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
@@ -262,13 +334,7 @@ export class FrigateCardViewerProvider
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaContentID = this.media.getContentID();
|
||||
const resolvedMedia = mediaContentID
|
||||
? this.resolvedMediaCache?.get(mediaContentID)
|
||||
: null;
|
||||
if (!resolvedMedia) {
|
||||
// Media will be resolved with the call in willUpdate() then this will be
|
||||
// re-rendered.
|
||||
if (!this._url) {
|
||||
return renderProgressIndicator({
|
||||
cardWideConfig: this.cardWideConfig,
|
||||
});
|
||||
@@ -288,7 +354,7 @@ export class FrigateCardViewerProvider
|
||||
muted
|
||||
playsinline
|
||||
title="${this.media.getTitle() ?? ''}"
|
||||
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
||||
url=${this._url}
|
||||
.hass=${this.hass}
|
||||
?controls=${this.viewerConfig.controls.builtin}
|
||||
>
|
||||
@@ -325,16 +391,13 @@ export class FrigateCardViewerProvider
|
||||
@play=${() => dispatchMediaPlayEvent(this)}
|
||||
@pause=${() => dispatchMediaPauseEvent(this)}
|
||||
>
|
||||
<source
|
||||
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
|
||||
type="video/mp4"
|
||||
/>
|
||||
<source src=${this._url} type="video/mp4" />
|
||||
</video>
|
||||
`
|
||||
: html`<img
|
||||
${ref(this._refImageProvider)}
|
||||
aria-label="${this.media.getTitle() ?? ''}"
|
||||
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
|
||||
src="${this._url}"
|
||||
title="${this.media.getTitle() ?? ''}"
|
||||
@click=${() => {
|
||||
if (this.viewerConfig?.snapshot_click_plays_clip) {
|
||||
|
||||
@@ -21,6 +21,7 @@ import viewsStyle from '../scss/views.scss';
|
||||
import { ExtendedHomeAssistant } from '../types.js';
|
||||
import { DeviceRegistryManager } from '../utils/ha/registry/device/index.js';
|
||||
import { ResolvedMediaCache } from '../utils/ha/resolved-media.js';
|
||||
import './surround.js';
|
||||
|
||||
// As a special case: The diagnostics view is not dynamically loaded in case
|
||||
// something goes wrong.
|
||||
|
||||
+37
-1
@@ -1321,7 +1321,7 @@ const castSchema = z.object({
|
||||
// Camera Configuration
|
||||
// *************************************************************************
|
||||
|
||||
const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const;
|
||||
const ENGINES = ['auto', 'frigate', 'generic', 'motioneye', 'reolink'] as const;
|
||||
|
||||
const cameraConfigDefault = {
|
||||
dependencies: {
|
||||
@@ -1343,6 +1343,9 @@ const cameraConfigDefault = {
|
||||
file_pattern: '%H-%M-%S' as const,
|
||||
},
|
||||
},
|
||||
reolink: {
|
||||
media_resolution: 'low' as const,
|
||||
},
|
||||
ptz: ptzCameraConfigDefaults,
|
||||
triggers: {
|
||||
motion: false,
|
||||
@@ -1350,8 +1353,31 @@ const cameraConfigDefault = {
|
||||
events: [...CAMERA_TRIGGER_EVENT_TYPES],
|
||||
entities: [],
|
||||
},
|
||||
proxy: {
|
||||
media: 'auto' as const,
|
||||
dynamic: true,
|
||||
ssl_verification: 'auto' as const,
|
||||
ssl_ciphers: 'auto' as const,
|
||||
},
|
||||
};
|
||||
|
||||
const SSL_CIPHERS = ['default', 'insecure', 'intermediate', 'modern'] as const;
|
||||
export type SSLCiphers = (typeof SSL_CIPHERS)[number];
|
||||
|
||||
const proxyConfigSchema = z.object({
|
||||
media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media),
|
||||
dynamic: z.boolean().default(cameraConfigDefault.proxy.dynamic),
|
||||
ssl_verification: z
|
||||
.boolean()
|
||||
.or(z.literal('auto'))
|
||||
.default(cameraConfigDefault.proxy.ssl_verification),
|
||||
ssl_ciphers: z
|
||||
.enum(SSL_CIPHERS)
|
||||
.or(z.literal('auto'))
|
||||
.default(cameraConfigDefault.proxy.ssl_ciphers),
|
||||
});
|
||||
export type ProxyConfig = z.infer<typeof proxyConfigSchema>;
|
||||
|
||||
export const cameraConfigSchema = z
|
||||
.object({
|
||||
camera_entity: z.string().optional(),
|
||||
@@ -1431,6 +1457,14 @@ export const cameraConfigSchema = z
|
||||
.default(cameraConfigDefault.motioneye.movies),
|
||||
})
|
||||
.default(cameraConfigDefault.motioneye),
|
||||
reolink: z
|
||||
.object({
|
||||
url: z.string().optional(),
|
||||
media_resolution: z
|
||||
.enum(['high', 'low'])
|
||||
.default(cameraConfigDefault.reolink.media_resolution),
|
||||
})
|
||||
.default(cameraConfigDefault.reolink),
|
||||
|
||||
// Live provider options.
|
||||
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
||||
@@ -1449,6 +1483,8 @@ export const cameraConfigSchema = z
|
||||
layout: mediaLayoutConfigSchema.optional(),
|
||||
})
|
||||
.optional(),
|
||||
|
||||
proxy: proxyConfigSchema.default(cameraConfigDefault.proxy),
|
||||
})
|
||||
.default(cameraConfigDefault);
|
||||
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
||||
|
||||
@@ -79,6 +79,16 @@ export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_VIEW_BOX_RIGHT =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.view_box.right` as const;
|
||||
export const CONF_CAMERAS_ARRAY_DIMENSIONS_LAYOUT_ZOOM_FACTOR =
|
||||
`${CONF_CAMERAS}.#.dimensions.layout.zoom` as const;
|
||||
export const CONF_CAMERAS_ARRAY_PROXY_DYNAMIC =
|
||||
`${CONF_CAMERAS}.#.proxy.dynamic` as const;
|
||||
export const CONF_CAMERAS_ARRAY_PROXY_MEDIA = `${CONF_CAMERAS}.#.proxy.media` as const;
|
||||
export const CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS =
|
||||
`${CONF_CAMERAS}.#.proxy.ssl_ciphers` as const;
|
||||
export const CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION =
|
||||
`${CONF_CAMERAS}.#.proxy.ssl_verification` as const;
|
||||
export const CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION =
|
||||
`${CONF_CAMERAS}.#.reolink.media_resolution` as const;
|
||||
export const CONF_CAMERAS_ARRAY_REOLINK_URL = `${CONF_CAMERAS}.#.reolink.url` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_MOTION =
|
||||
`${CONF_CAMERAS}.#.triggers.motion` as const;
|
||||
export const CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY =
|
||||
|
||||
+177
-41
@@ -32,10 +32,9 @@ import {
|
||||
THUMBNAIL_WIDTH_MIN,
|
||||
} from './config/types.js';
|
||||
import {
|
||||
CONF_CAMERAS,
|
||||
CONF_CAMERAS_ARRAY_CAMERA_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
|
||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE_EXCEPT,
|
||||
CONF_CAMERAS_ARRAY_CAPABILITIES_DISABLE,
|
||||
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_DASHBOARD_PATH,
|
||||
CONF_CAMERAS_ARRAY_CAST_DASHBOARD_VIEW_PATH,
|
||||
CONF_CAMERAS_ARRAY_CAST_METHOD,
|
||||
@@ -61,8 +60,8 @@ import {
|
||||
CONF_CAMERAS_ARRAY_GO2RTC_STREAM,
|
||||
CONF_CAMERAS_ARRAY_ICON,
|
||||
CONF_CAMERAS_ARRAY_ID,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_ENTITY_PARAMETERS,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_MODE,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
|
||||
CONF_CAMERAS_ARRAY_IMAGE_URL,
|
||||
@@ -72,6 +71,12 @@ import {
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
|
||||
CONF_CAMERAS_ARRAY_PROXY_DYNAMIC,
|
||||
CONF_CAMERAS_ARRAY_PROXY_MEDIA,
|
||||
CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS,
|
||||
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION,
|
||||
CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION,
|
||||
CONF_CAMERAS_ARRAY_REOLINK_URL,
|
||||
CONF_CAMERAS_ARRAY_TITLE,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_EVENTS,
|
||||
@@ -79,11 +84,12 @@ import {
|
||||
CONF_CAMERAS_ARRAY_TRIGGERS_OCCUPANCY,
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY,
|
||||
CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||
CONF_CAMERAS,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO_MODE,
|
||||
CONF_DIMENSIONS_ASPECT_RATIO,
|
||||
CONF_DIMENSIONS_HEIGHT,
|
||||
CONF_IMAGE_ENTITY,
|
||||
CONF_IMAGE_ENTITY_PARAMETERS,
|
||||
CONF_IMAGE_ENTITY,
|
||||
CONF_IMAGE_MODE,
|
||||
CONF_IMAGE_REFRESH_SECONDS,
|
||||
CONF_IMAGE_URL,
|
||||
@@ -194,15 +200,14 @@ import {
|
||||
CONF_TIMELINE_WINDOW_SECONDS,
|
||||
CONF_VIEW_CAMERA_SELECT,
|
||||
CONF_VIEW_DARK_MODE,
|
||||
CONF_VIEW_DEFAULT,
|
||||
CONF_VIEW_DEFAULT_CYCLE_CAMERA,
|
||||
CONF_VIEW_DEFAULT_RESET,
|
||||
CONF_VIEW_DEFAULT_RESET_AFTER_INTERACTION,
|
||||
CONF_VIEW_DEFAULT_RESET_ENTITIES,
|
||||
CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS,
|
||||
CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE,
|
||||
CONF_VIEW_DEFAULT_RESET,
|
||||
CONF_VIEW_DEFAULT,
|
||||
CONF_VIEW_INTERACTION_SECONDS,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_ENABLED,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_DOWN,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_HOME,
|
||||
@@ -211,14 +216,15 @@ import {
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_UP,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_IN,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS_PTZ_ZOOM_OUT,
|
||||
CONF_VIEW_TRIGGERS,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS,
|
||||
CONF_VIEW_KEYBOARD_SHORTCUTS,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_INTERACTION_MODE,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER,
|
||||
CONF_VIEW_TRIGGERS_ACTIONS,
|
||||
CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA,
|
||||
CONF_VIEW_TRIGGERS_SHOW_TRIGGER_STATUS,
|
||||
CONF_VIEW_TRIGGERS_UNTRIGGER_SECONDS,
|
||||
CONF_VIEW_TRIGGERS,
|
||||
MEDIA_CHUNK_SIZE_MAX,
|
||||
} from './const.js';
|
||||
import { localize } from './localize/localize.js';
|
||||
@@ -244,6 +250,8 @@ const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
|
||||
const MENU_CAMERAS_IMAGE = 'cameras.image';
|
||||
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
|
||||
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
|
||||
const MENU_CAMERAS_PROXY = 'cameras.proxy';
|
||||
const MENU_CAMERAS_REOLINK = 'cameras.reolink';
|
||||
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
|
||||
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
|
||||
const MENU_LIVE_CONTROLS = 'live.controls';
|
||||
@@ -281,7 +289,7 @@ interface EditorOptions {
|
||||
}
|
||||
|
||||
interface EditorSelectOption {
|
||||
value: string;
|
||||
value: unknown;
|
||||
label: string;
|
||||
}
|
||||
|
||||
@@ -820,6 +828,74 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
},
|
||||
];
|
||||
|
||||
protected _proxyMedia: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'auto',
|
||||
label: localize('config.cameras.proxy.media.auto'),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: localize('config.cameras.proxy.media.true'),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
label: localize('config.cameras.proxy.media.false'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _proxySSLCiphers: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'auto',
|
||||
label: localize('config.cameras.proxy.ssl_ciphers.auto'),
|
||||
},
|
||||
{
|
||||
value: 'default',
|
||||
label: localize('config.cameras.proxy.ssl_ciphers.default'),
|
||||
},
|
||||
{
|
||||
value: 'insecure',
|
||||
label: localize('config.cameras.proxy.ssl_ciphers.insecure'),
|
||||
},
|
||||
{
|
||||
value: 'intermediate',
|
||||
label: localize('config.cameras.proxy.ssl_ciphers.intermediate'),
|
||||
},
|
||||
{
|
||||
value: 'modern',
|
||||
label: localize('config.cameras.proxy.ssl_ciphers.modern'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _proxySSLVerification: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'auto',
|
||||
label: localize('config.cameras.proxy.ssl_verification.auto'),
|
||||
},
|
||||
{
|
||||
value: true,
|
||||
label: localize('config.cameras.proxy.ssl_verification.true'),
|
||||
},
|
||||
{
|
||||
value: false,
|
||||
label: localize('config.cameras.proxy.ssl_verification.false'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _reolinkMediaResolution: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{
|
||||
value: 'high',
|
||||
label: localize('config.cameras.reolink.media_resolution.high'),
|
||||
},
|
||||
{
|
||||
value: 'low',
|
||||
label: localize('config.cameras.reolink.media_resolution.low'),
|
||||
},
|
||||
];
|
||||
|
||||
protected _statusBarStyles: EditorSelectOption[] = [
|
||||
{ value: '', label: '' },
|
||||
{ value: 'hover', label: localize('config.status_bar.styles.hover') },
|
||||
@@ -942,7 +1018,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
*/
|
||||
protected _renderOptionSelector(
|
||||
configPath: string,
|
||||
options: string[] | { value: string; label: string }[] = [],
|
||||
options: string[] | { value: unknown; label: string }[] = [],
|
||||
params?: {
|
||||
multiple?: boolean;
|
||||
label?: string;
|
||||
@@ -1980,36 +2056,55 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
cameraIndex,
|
||||
'config.cameras.motioneye.editor_label',
|
||||
'motioneye',
|
||||
html`
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
||||
cameraIndex,
|
||||
html` ${this._renderStringInput(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_MOTIONEYE_URL, cameraIndex),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_REOLINK,
|
||||
cameraIndex,
|
||||
'config.cameras.reolink.editor_label',
|
||||
'reolink',
|
||||
html` ${this._renderStringInput(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_REOLINK_URL, cameraIndex),
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_REOLINK_MEDIA_RESOLUTION,
|
||||
cameraIndex,
|
||||
),
|
||||
this._reolinkMediaResolution,
|
||||
{
|
||||
label: localize(
|
||||
'config.cameras.reolink.media_resolution.editor_label',
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
${this._renderStringInput(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
|
||||
cameraIndex,
|
||||
),
|
||||
)}
|
||||
`,
|
||||
)} `,
|
||||
},
|
||||
)}`,
|
||||
)}`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_LIVE_PROVIDER,
|
||||
@@ -2239,6 +2334,47 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
${this._putInSubmenu(
|
||||
MENU_CAMERAS_PROXY,
|
||||
cameraIndex,
|
||||
'config.cameras.proxy.editor_label',
|
||||
'mdi:arrow-decision',
|
||||
html`
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_MEDIA, cameraIndex),
|
||||
this._proxyMedia,
|
||||
{
|
||||
label: localize('config.cameras.proxy.media.editor_label'),
|
||||
},
|
||||
)}
|
||||
${this._renderSwitch(
|
||||
getArrayConfigPath(CONF_CAMERAS_ARRAY_PROXY_DYNAMIC, cameraIndex),
|
||||
this._defaults.cameras.proxy.dynamic,
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_PROXY_SSL_VERIFICATION,
|
||||
cameraIndex,
|
||||
),
|
||||
this._proxySSLVerification,
|
||||
{
|
||||
label: localize(
|
||||
'config.cameras.proxy.ssl_verification.editor_label',
|
||||
),
|
||||
},
|
||||
)}
|
||||
${this._renderOptionSelector(
|
||||
getArrayConfigPath(
|
||||
CONF_CAMERAS_ARRAY_PROXY_SSL_CIPHERS,
|
||||
cameraIndex,
|
||||
),
|
||||
this._proxySSLCiphers,
|
||||
{
|
||||
label: localize('config.cameras.proxy.ssl_ciphers.editor_label'),
|
||||
},
|
||||
)}
|
||||
`,
|
||||
)}
|
||||
</div>`
|
||||
: ``}
|
||||
</div>
|
||||
|
||||
@@ -127,6 +127,39 @@
|
||||
},
|
||||
"url": "MotionEye UI URL"
|
||||
},
|
||||
"proxy": {
|
||||
"dynamic": "Dynamic proxying",
|
||||
"editor_label": "Camera proxying",
|
||||
"media": {
|
||||
"auto": "Media proxying automatically configured",
|
||||
"editor_label": "Media proxying",
|
||||
"false": "Media proxying disabled",
|
||||
"true": "Media proxying enabled"
|
||||
},
|
||||
"ssl_ciphers": {
|
||||
"auto": "SSL ciphers automatically configured",
|
||||
"default": "Default SSL ciphers",
|
||||
"editor_label": "SSL ciphers",
|
||||
"insecure": "Insecure SSL ciphers",
|
||||
"intermediate": "Intermediate SSL ciphers",
|
||||
"modern": "Modern SSL ciphers"
|
||||
},
|
||||
"ssl_verification": {
|
||||
"auto": "SSL verification automatically configured",
|
||||
"editor_label": "SSL verification",
|
||||
"false": "SSL verification disabled",
|
||||
"true": "SSL verification enabled"
|
||||
}
|
||||
},
|
||||
"reolink": {
|
||||
"editor_label": "Reolink options",
|
||||
"media_resolution": {
|
||||
"editor_label": "Media Resolution",
|
||||
"high": "High Resolution",
|
||||
"low": "Low Resolution"
|
||||
},
|
||||
"url": "Reolink UI URL"
|
||||
},
|
||||
"title": "Title for this camera (Autodetected from entity)",
|
||||
"triggers": {
|
||||
"editor_label": "Trigger options",
|
||||
@@ -575,6 +608,7 @@
|
||||
"error": {
|
||||
"awaiting_live": "Waiting for live stream to load ...",
|
||||
"camera_initialization": "Camera initialization failed",
|
||||
"camera_initialization_reolink": "Could not initialize Reolink camera",
|
||||
"could_not_render_elements": "Could not render picture elements",
|
||||
"could_not_resolve": "Could not resolve media URL",
|
||||
"diagnostics": "Card diagnostics. Please review for confidential information prior to sharing",
|
||||
@@ -587,8 +621,8 @@
|
||||
"failed_sign": "Could not sign Home Assistant URL",
|
||||
"image_load_error": "The image could not be loaded",
|
||||
"invalid_configuration": "Invalid configuration",
|
||||
"invalid_configuration_override": "Invalid override configuration",
|
||||
"invalid_configuration_no_hint": "No location hint available (bad or missing type?)",
|
||||
"invalid_configuration_override": "Invalid override configuration",
|
||||
"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",
|
||||
|
||||
@@ -17,6 +17,7 @@ div.title {
|
||||
|
||||
div.details {
|
||||
flex: 1;
|
||||
justify-content: space-evenly;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
aspect-ratio: 1 / 1;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
max-width: var(--frigate-card-thumbnail-size);
|
||||
max-height: var(--frigate-card-thumbnail-size);
|
||||
padding: 10px;
|
||||
|
||||
aspect-ratio: 1 / 1;
|
||||
|
||||
border: 1px solid var(--secondary-color);
|
||||
background-color: var(--secondary-background-color);
|
||||
@@ -16,8 +11,41 @@
|
||||
--frigate-card-css-border-radius,
|
||||
var(--ha-card-border-radius, 4px)
|
||||
);
|
||||
|
||||
overflow: hidden;
|
||||
padding: 10px;
|
||||
|
||||
box-sizing: border-box;
|
||||
|
||||
// Ensure img.background is relative to this.
|
||||
position: relative;
|
||||
}
|
||||
|
||||
img.background {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
padding: 10%;
|
||||
box-sizing: border-box;
|
||||
|
||||
pointer-events: none;
|
||||
|
||||
opacity: 0.1;
|
||||
}
|
||||
|
||||
div.content {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -1,5 +1,6 @@
|
||||
import frigateSVG from '../camera-manager/frigate/assets/frigate.svg';
|
||||
import motioneyeSVG from '../camera-manager/motioneye/assets/motioneye.svg';
|
||||
import reolinkSVG from '../camera-manager/reolink/assets/reolink.svg';
|
||||
|
||||
export const getCustomIconURL = (icon?: string): string | null => {
|
||||
switch (icon) {
|
||||
@@ -7,6 +8,8 @@ export const getCustomIconURL = (icon?: string): string | null => {
|
||||
return frigateSVG;
|
||||
case 'motioneye':
|
||||
return motioneyeSVG;
|
||||
case 'reolink':
|
||||
return reolinkSVG;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import pkg from '../../package.json';
|
||||
import { RawFrigateCardConfig } from '../config/types';
|
||||
import { getLanguage } from '../localize/localize';
|
||||
import { getIntegrationManifest } from './ha/integration';
|
||||
import { IntegrationManifest } from './ha/integration/types';
|
||||
import { DeviceRegistryManager } from './ha/registry/device';
|
||||
import { HASS_WEB_PROXY_DOMAIN } from './ha/web-proxy';
|
||||
|
||||
type FrigateVersions = Record<string, string>;
|
||||
type FrigateDevices = Record<string, string>;
|
||||
|
||||
interface GitDiagnostics {
|
||||
build_version?: string;
|
||||
@@ -12,6 +15,11 @@ interface GitDiagnostics {
|
||||
commit_date?: string;
|
||||
}
|
||||
|
||||
interface IntegrationDiagnostics {
|
||||
detected: boolean;
|
||||
version?: string;
|
||||
}
|
||||
|
||||
export const getReleaseVersion = (): string => {
|
||||
const releaseVersion = '__FRIGATE_CARD_RELEASE_VERSION__';
|
||||
|
||||
@@ -36,11 +44,39 @@ export interface Diagnostics {
|
||||
timezone: string;
|
||||
git: GitDiagnostics;
|
||||
|
||||
frigate_versions?: FrigateVersions;
|
||||
ha_version?: string;
|
||||
config?: RawFrigateCardConfig;
|
||||
|
||||
integrations: {
|
||||
frigate: IntegrationDiagnostics & {
|
||||
devices?: FrigateDevices;
|
||||
};
|
||||
hass_web_proxy: IntegrationDiagnostics;
|
||||
reolink: IntegrationDiagnostics;
|
||||
motioneye: IntegrationDiagnostics;
|
||||
};
|
||||
}
|
||||
|
||||
export const getIntegrationDiagnostics = async (
|
||||
integration: string,
|
||||
hass?: HomeAssistant,
|
||||
): Promise<IntegrationDiagnostics> => {
|
||||
let manifest: IntegrationManifest | null = null;
|
||||
|
||||
if (hass) {
|
||||
try {
|
||||
manifest = await getIntegrationManifest(hass, integration);
|
||||
} catch (e) {
|
||||
// Silently ignore integrations not being found.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
detected: !!manifest,
|
||||
...(manifest?.version && { version: manifest.version }),
|
||||
};
|
||||
};
|
||||
|
||||
export const getDiagnostics = async (
|
||||
hass?: HomeAssistant,
|
||||
deviceRegistryManager?: DeviceRegistryManager,
|
||||
@@ -69,9 +105,6 @@ export const getDiagnostics = async (
|
||||
card_version: getReleaseVersion(),
|
||||
browser: navigator.userAgent,
|
||||
date: new Date(),
|
||||
...(frigateVersionMap.size && {
|
||||
frigate_versions: Object.fromEntries(frigateVersionMap),
|
||||
}),
|
||||
lang: getLanguage(),
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
git: {
|
||||
@@ -80,6 +113,17 @@ export const getDiagnostics = async (
|
||||
...(pkg['gitDate'] && { commit_date: pkg['gitDate'] }),
|
||||
},
|
||||
...(hass && { ha_version: hass.config.version }),
|
||||
integrations: {
|
||||
reolink: await getIntegrationDiagnostics('reolink', hass),
|
||||
frigate: {
|
||||
...(await getIntegrationDiagnostics('frigate', hass)),
|
||||
...(frigateVersionMap.size && {
|
||||
devices: Object.fromEntries(frigateVersionMap),
|
||||
}),
|
||||
},
|
||||
hass_web_proxy: await getIntegrationDiagnostics(HASS_WEB_PROXY_DOMAIN, hass),
|
||||
motioneye: await getIntegrationDiagnostics('motioneye', hass),
|
||||
},
|
||||
...(rawConfig && { config: rawConfig }),
|
||||
};
|
||||
};
|
||||
@@ -1,12 +1,15 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { add } from 'date-fns';
|
||||
import { homeAssistantWSRequest } from '..';
|
||||
import chunk from 'lodash-es/chunk';
|
||||
import orderBy from 'lodash-es/orderBy';
|
||||
import { BrowseMediaMetadata } from '../../../camera-manager/browse-media/types';
|
||||
import { MemoryRequestCache } from '../../../camera-manager/cache';
|
||||
import { allPromises } from '../../basic';
|
||||
import { homeAssistantWSRequest } from '../ws-request';
|
||||
import {
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
BrowseMedia,
|
||||
browseMediaSchema,
|
||||
BROWSE_MEDIA_CACHE_SECONDS,
|
||||
RichBrowseMedia,
|
||||
} from './types';
|
||||
|
||||
@@ -19,10 +22,19 @@ type RichMetadataGenerator<M> = (
|
||||
export type BrowseMediaTarget<M> = string | RichBrowseMedia<M>;
|
||||
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
|
||||
|
||||
export const sortMediaByStartDate = (
|
||||
media: RichBrowseMedia<BrowseMediaMetadata>[],
|
||||
): RichBrowseMedia<BrowseMediaMetadata>[] => {
|
||||
return orderBy(media, (media) => media._metadata?.startDate, 'desc');
|
||||
};
|
||||
|
||||
export interface BrowseMediaStep<M> {
|
||||
// The targets to start the media walk from.
|
||||
targets: BrowseMediaTarget<M>[];
|
||||
|
||||
// How many children to process concurrently. Default is infinite.
|
||||
concurrency?: number;
|
||||
|
||||
// All children of the target have the metadata generator applied to them
|
||||
// first.
|
||||
metadataGenerator?: RichMetadataGenerator<M>;
|
||||
@@ -31,6 +43,12 @@ export interface BrowseMediaStep<M> {
|
||||
// output.
|
||||
matcher: RichBrowseMediaPredicate<M>;
|
||||
|
||||
// Children (once past the matcher) will be sorted before the next step.
|
||||
sorter?: (media: RichBrowseMedia<M>[]) => RichBrowseMedia<M>[];
|
||||
|
||||
// Whether to exit the walk early with the given output.
|
||||
earlyExit?: (media: RichBrowseMedia<M>[]) => boolean;
|
||||
|
||||
// advance will be called to generate a next step (or null if the child should
|
||||
// just be included straight through to the output with no further steps).
|
||||
advance?: BrowseMediaStepAdvancer<M>;
|
||||
@@ -39,23 +57,18 @@ export interface BrowseMediaStep<M> {
|
||||
type BrowseMediaStepAdvancer<M> = (media: RichBrowseMedia<M>[]) => BrowseMediaStep<M>[];
|
||||
|
||||
export class BrowseMediaManager<M> {
|
||||
protected _cache: BrowseMediaCache<M>;
|
||||
|
||||
constructor(cache: BrowseMediaCache<M>) {
|
||||
this._cache = cache;
|
||||
}
|
||||
|
||||
// Walk down a browse media tree according to instructions included in `steps`.
|
||||
public async walkBrowseMedias(
|
||||
hass: HomeAssistant,
|
||||
steps: BrowseMediaStep<M>[] | null,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
cache?: BrowseMediaCache<M>;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>[]> {
|
||||
if (!steps || !steps.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (
|
||||
await allPromises(
|
||||
steps,
|
||||
@@ -68,60 +81,57 @@ export class BrowseMediaManager<M> {
|
||||
hass: HomeAssistant,
|
||||
step: BrowseMediaStep<M>,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
cache?: BrowseMediaCache<M>;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>[]> {
|
||||
const media = await allPromises(
|
||||
step.targets,
|
||||
async (target) =>
|
||||
await this._browseMedia(hass, target, {
|
||||
useCache: options?.useCache,
|
||||
metadataGenerator: step.metadataGenerator,
|
||||
}),
|
||||
);
|
||||
let output: RichBrowseMedia<M>[] = [];
|
||||
|
||||
const newTargets: RichBrowseMedia<M>[] = [];
|
||||
for (const parent of media) {
|
||||
for (const child of parent.children ?? []) {
|
||||
if (step.matcher(child)) {
|
||||
newTargets.push(child);
|
||||
for (const targetChunk of chunk(step.targets, step.concurrency ?? Infinity)) {
|
||||
const mediaChunk = await allPromises(
|
||||
targetChunk,
|
||||
async (target) =>
|
||||
await this._browseMedia(hass, target, {
|
||||
cache: options?.cache,
|
||||
matcher: step.matcher,
|
||||
metadataGenerator: step.metadataGenerator,
|
||||
}),
|
||||
);
|
||||
|
||||
for (const parent of mediaChunk) {
|
||||
for (const child of parent.children ?? []) {
|
||||
if (step.matcher(child)) {
|
||||
output.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nextSteps = step.advance ? step.advance(newTargets) : null;
|
||||
if (!nextSteps || !nextSteps.length) {
|
||||
return newTargets;
|
||||
}
|
||||
if (step.sorter) {
|
||||
output = step.sorter(output);
|
||||
}
|
||||
|
||||
const targetsIncludedInNextSteps = new Set(
|
||||
nextSteps.map((nextStep) => nextStep.targets).flat(),
|
||||
);
|
||||
const finished: RichBrowseMedia<M>[] = [];
|
||||
|
||||
// Any new target that doesn't have a proposed 'next step' is assumed to be
|
||||
// ready to return.
|
||||
for (const target of newTargets) {
|
||||
if (!targetsIncludedInNextSteps.has(target)) {
|
||||
finished.push(target);
|
||||
if (step.earlyExit && step.earlyExit(output)) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const downstream = await this.walkBrowseMedias(hass, nextSteps, options);
|
||||
return finished.concat(downstream);
|
||||
const nextSteps = step.advance ? step.advance(output) : null;
|
||||
if (!nextSteps?.length) {
|
||||
return output;
|
||||
}
|
||||
return await this.walkBrowseMedias(hass, nextSteps, options);
|
||||
}
|
||||
|
||||
protected async _browseMedia(
|
||||
hass: HomeAssistant,
|
||||
target: string | RichBrowseMedia<M>,
|
||||
options?: {
|
||||
useCache?: boolean;
|
||||
cache?: BrowseMediaCache<M>;
|
||||
matcher?: RichBrowseMediaPredicate<M>;
|
||||
metadataGenerator?: RichMetadataGenerator<M>;
|
||||
},
|
||||
): Promise<RichBrowseMedia<M>> {
|
||||
const mediaContentID = typeof target === 'object' ? target.media_content_id : target;
|
||||
const cachedResult =
|
||||
options?.useCache ?? true ? this._cache.get(mediaContentID) : null;
|
||||
const cachedResult = options?.cache ? options.cache.get(mediaContentID) : null;
|
||||
if (cachedResult) {
|
||||
return cachedResult;
|
||||
}
|
||||
@@ -130,11 +140,11 @@ export class BrowseMediaManager<M> {
|
||||
type: 'media_source/browse_media',
|
||||
media_content_id: mediaContentID,
|
||||
};
|
||||
const browseMedia = (await homeAssistantWSRequest(
|
||||
const browseMedia = await homeAssistantWSRequest<RichBrowseMedia<M>>(
|
||||
hass,
|
||||
browseMediaSchema,
|
||||
request,
|
||||
)) as RichBrowseMedia<M>;
|
||||
);
|
||||
|
||||
if (options?.metadataGenerator) {
|
||||
for (const child of browseMedia.children ?? []) {
|
||||
@@ -146,8 +156,8 @@ export class BrowseMediaManager<M> {
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.useCache ?? true) {
|
||||
this._cache.set(
|
||||
if (options?.cache) {
|
||||
options.cache.set(
|
||||
mediaContentID,
|
||||
browseMedia,
|
||||
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
|
||||
|
||||
+13
-53
@@ -3,73 +3,25 @@ import {
|
||||
computeStateDomain,
|
||||
HomeAssistant,
|
||||
} from '@dermotduffy/custom-card-helpers';
|
||||
import { HassEntity, MessageBase } from 'home-assistant-js-websocket';
|
||||
import { HassEntity } from 'home-assistant-js-websocket';
|
||||
import { StyleInfo } from 'lit/directives/style-map.js';
|
||||
import { ZodSchema } from 'zod';
|
||||
import { localize } from '../../localize/localize.js';
|
||||
import {
|
||||
CardHelpers,
|
||||
ExtendedHomeAssistant,
|
||||
FrigateCardError,
|
||||
LovelaceCardWithEditor,
|
||||
SignedPath,
|
||||
signedPathSchema,
|
||||
StateParameters,
|
||||
} from '../../types.js';
|
||||
import { domainIcon } from '../icons/domain-icon.js';
|
||||
import { getParseErrorKeys } from '../zod.js';
|
||||
|
||||
/**
|
||||
* Make a HomeAssistant websocket request. May throw.
|
||||
* @param hass The HomeAssistant object to send the request with.
|
||||
* @param schema The expected Zod schema of the response.
|
||||
* @param request The request to make.
|
||||
* @returns The parsed valid response or null on malformed.
|
||||
*/
|
||||
export async function homeAssistantWSRequest<T>(
|
||||
hass: HomeAssistant,
|
||||
schema: ZodSchema<T>,
|
||||
request: MessageBase,
|
||||
passthrough = false,
|
||||
): Promise<T> {
|
||||
let response;
|
||||
try {
|
||||
response = await hass.callWS<T>(request);
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) {
|
||||
throw new FrigateCardError(localize('error.failed_response'), {
|
||||
request: request,
|
||||
response: e,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new FrigateCardError(localize('error.empty_response'), {
|
||||
request: request,
|
||||
});
|
||||
}
|
||||
// Some endpoints on the integration pass through JSON directly from Frigate
|
||||
// These end up wrapped in a string and must be unwrapped first
|
||||
const parseResult = passthrough
|
||||
? schema.safeParse(JSON.parse(response))
|
||||
: schema.safeParse(response);
|
||||
if (!parseResult.success) {
|
||||
throw new FrigateCardError(localize('error.invalid_response'), {
|
||||
request: request,
|
||||
response: response,
|
||||
invalid_keys: getParseErrorKeys<T>(parseResult.error),
|
||||
});
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
import { homeAssistantWSRequest } from './ws-request.js';
|
||||
|
||||
/**
|
||||
* Request that HA sign a path. May throw.
|
||||
* @param hass The HomeAssistant object used to request the signature.
|
||||
* @param path The path to sign.
|
||||
* @param expires An optional number of seconds to sign the path for.
|
||||
* @param expires An optional number of seconds to sign the path for (by default
|
||||
* HA will sign for 30 seconds).
|
||||
* @returns The signed URL, or null if the response was malformed.
|
||||
*/
|
||||
export async function homeAssistantSignPath(
|
||||
@@ -379,17 +331,25 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
|
||||
);
|
||||
};
|
||||
|
||||
export function isHARelativeURL(url?: string): boolean {
|
||||
return !!url?.startsWith('/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure URLs use the correct HA URL (relevant for Chromecast where the default
|
||||
* location will be the Chromecast receiver, not HA).
|
||||
* @param url The media URL
|
||||
*/
|
||||
export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
|
||||
export function canonicalizeHAURL(
|
||||
hass: ExtendedHomeAssistant,
|
||||
url?: string,
|
||||
): string | null;
|
||||
export function canonicalizeHAURL(
|
||||
hass: ExtendedHomeAssistant,
|
||||
url?: string,
|
||||
): string | null {
|
||||
if (hass && url && url.startsWith('/')) {
|
||||
if (isHARelativeURL(url)) {
|
||||
return hass.hassUrl(url);
|
||||
}
|
||||
return url ?? null;
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { homeAssistantWSRequest } from '../ws-request';
|
||||
import { IntegrationManifest, integrationManifestSchema } from './types';
|
||||
|
||||
export const getIntegrationManifest = async (
|
||||
hass: HomeAssistant,
|
||||
integration: string,
|
||||
): Promise<IntegrationManifest> => {
|
||||
return await homeAssistantWSRequest(hass, integrationManifestSchema, {
|
||||
type: 'manifest/get',
|
||||
integration: integration,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const integrationManifestSchema = z
|
||||
.object({
|
||||
domain: z.string(),
|
||||
version: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
export type IntegrationManifest = z.infer<typeof integrationManifestSchema>;
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { homeAssistantWSRequest } from '../..';
|
||||
import { homeAssistantWSRequest } from '../../ws-request';
|
||||
import { errorToConsole } from '../../../basic';
|
||||
import { RegistryCache } from '../cache';
|
||||
import { Device, DeviceList, deviceListSchema } from './types';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { homeAssistantWSRequest } from '../..';
|
||||
import { homeAssistantWSRequest } from '../../ws-request';
|
||||
import { errorToConsole } from '../../../basic';
|
||||
import { RegistryCache } from '../cache';
|
||||
import { Entity, EntityList, entityListSchema, entitySchema } from './types.js';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import { homeAssistantWSRequest } from '.';
|
||||
import { homeAssistantWSRequest } from './ws-request';
|
||||
import { ResolvedMedia, resolvedMediaSchema } from '../../types.js';
|
||||
import { errorToConsole } from '../basic';
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { CameraProxyConfig } from '../../camera-manager/types';
|
||||
import { ExtendedHomeAssistant } from '../../types';
|
||||
|
||||
export const HASS_WEB_PROXY_DOMAIN = 'hass_web_proxy';
|
||||
|
||||
const hasWebProxyAvailable = (hass: HomeAssistant): boolean => {
|
||||
return hass.config.components.includes(HASS_WEB_PROXY_DOMAIN);
|
||||
};
|
||||
|
||||
export const getWebProxiedURL = (url: string, v?: number): string => {
|
||||
return `/api/${HASS_WEB_PROXY_DOMAIN}/v${v ?? 0}/?url=${encodeURIComponent(url)}`;
|
||||
};
|
||||
|
||||
export const shouldUseWebProxy = (
|
||||
hass: HomeAssistant,
|
||||
proxyConfig: CameraProxyConfig,
|
||||
context: 'media' = 'media',
|
||||
): boolean => {
|
||||
return hasWebProxyAvailable(hass) && !!proxyConfig[context];
|
||||
};
|
||||
|
||||
/**
|
||||
* Request that HA sign a path. May throw.
|
||||
* @param hass The HomeAssistant object used to request the signature.
|
||||
* @param path The path to sign.
|
||||
* @param expires An optional number of seconds to sign the path for (by default
|
||||
* HA will sign for 30 seconds).
|
||||
* @returns The signed URL, or null if the response was malformed.
|
||||
*/
|
||||
export async function addDynamicProxyURL(
|
||||
hass: ExtendedHomeAssistant,
|
||||
url_pattern: string,
|
||||
options?: {
|
||||
urlID?: string;
|
||||
sslVerification?: boolean;
|
||||
sslCiphers?: string;
|
||||
openLimit?: number;
|
||||
ttl?: number;
|
||||
allowUnauthenticated?: boolean;
|
||||
},
|
||||
): Promise<void> {
|
||||
await hass.callService(HASS_WEB_PROXY_DOMAIN, 'create_proxied_url', {
|
||||
url_pattern: url_pattern,
|
||||
...(options && {
|
||||
url_id: options.urlID,
|
||||
ssl_verification: options.sslVerification,
|
||||
ssl_ciphers: options.sslCiphers,
|
||||
open_limit: options.openLimit,
|
||||
ttl: options.ttl,
|
||||
allow_unauthenticated: options.allowUnauthenticated,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HomeAssistant } from '@dermotduffy/custom-card-helpers';
|
||||
import { MessageBase } from 'home-assistant-js-websocket';
|
||||
import { ZodSchema } from 'zod';
|
||||
import { localize } from '../../localize/localize';
|
||||
import { FrigateCardError } from '../../types';
|
||||
import { getParseErrorKeys } from '../zod';
|
||||
|
||||
/**
|
||||
* Make a HomeAssistant websocket request. May throw.
|
||||
* @param hass The HomeAssistant object to send the request with.
|
||||
* @param schema The expected Zod schema of the response.
|
||||
* @param request The request to make.
|
||||
* @returns The parsed valid response or null on malformed.
|
||||
*/
|
||||
|
||||
export async function homeAssistantWSRequest<T>(
|
||||
hass: HomeAssistant,
|
||||
schema: ZodSchema<T>,
|
||||
request: MessageBase,
|
||||
passthrough = false,
|
||||
): Promise<T> {
|
||||
let response;
|
||||
try {
|
||||
response = await hass.callWS<T>(request);
|
||||
} catch (e) {
|
||||
if (!(e instanceof Error)) {
|
||||
throw new FrigateCardError(localize('error.failed_response'), {
|
||||
request: request,
|
||||
response: e,
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
throw new FrigateCardError(localize('error.empty_response'), {
|
||||
request: request,
|
||||
});
|
||||
}
|
||||
// Some endpoints on the integration pass through JSON directly from Frigate
|
||||
// These end up wrapped in a string and must be unwrapped first
|
||||
const parseResult = passthrough
|
||||
? schema.safeParse(JSON.parse(response))
|
||||
: schema.safeParse(response);
|
||||
if (!parseResult.success) {
|
||||
throw new FrigateCardError(localize('error.invalid_response'), {
|
||||
request: request,
|
||||
response: response,
|
||||
invalid_keys: getParseErrorKeys<T>(parseResult.error),
|
||||
});
|
||||
}
|
||||
return parseResult.data;
|
||||
}
|
||||
Reference in new issue
Block a user