feat: Add experimental reolink media support (#1694)

* feat: Add experimental rich reolink support.

* Formatting fix
This commit is contained in:
Dermot Duffy
2024-11-25 20:19:12 -08:00
committed by GitHub
parent e898b73d63
commit dada65e008
63 changed files with 3402 additions and 483 deletions
@@ -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>,
+2 -2
View File
@@ -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;
};
+13 -1
View File
@@ -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(),
+18 -3
View File
@@ -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 -1
View File
@@ -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,
+13
View File
@@ -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

+64
View File
@@ -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 }),
};
}
}
+12
View File
@@ -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>[];
}
+9 -1
View File
@@ -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;
}