Initial motionEye commit.

This commit is contained in:
Dermot Duffy
2023-03-26 16:42:21 -07:00
parent 05e9db4cc5
commit 304c7e3164
55 changed files with 1780 additions and 245 deletions
+2 -2
View File
@@ -43,7 +43,7 @@
"vis-util": "^5.0.2",
"web-dialog": "^0.0.11",
"xss": "^1.0.14",
"zod": "^3.20.6"
"zod": "^3.21.4"
},
"devDependencies": {
"@babel/core": "^7.19.0",
@@ -51,7 +51,7 @@
"@babel/plugin-proposal-decorators": "^7.19.0",
"@rollup/plugin-babel": "^5.3.1",
"@rollup/plugin-commonjs": "^22.0.2",
"@rollup/plugin-image": "^2.1.1",
"@rollup/plugin-image": "^3.0.2",
"@rollup/plugin-json": "^4.1.0",
"@rollup/plugin-node-resolve": "^13.3.0",
"@rollup/plugin-replace": "^4.0.0",
@@ -0,0 +1,170 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
import { ViewMedia } from '../../view/media';
import {
CameraManagerMediaCapabilities,
DataQuery,
EventQuery,
PartialEventQuery,
CameraConfigs,
CameraManagerCameraCapabilities,
QueryType,
CameraEndpoint,
} from '../types';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
import { CameraManagerEngine } from '../engine';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import { CameraInitializationError } from '../error';
import { localize } from '../../localize/localize';
import { Entity } from '../../utils/ha/entity-registry/types';
import { BrowseMediaManager } from '../../utils/ha/browse-media/browse-media-manager';
import {
BROWSE_MEDIA_CACHE_SECONDS,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import { BrowseMediaMetadata } from './types';
import { rangesOverlap } from '../range';
import { ResolvedMediaCache, resolveMedia } from '../../utils/ha/resolved-media';
import { canonicalizeHAURL } from '../../utils/ha';
import { RequestCache } from '../cache';
/**
* A base class for cameras that read events from HA BrowseMedia interface.
*/
export class BrowseMediaCameraManagerEngine
extends GenericCameraManagerEngine
implements CameraManagerEngine
{
protected _cameraEntities: Map<string, Entity> = new Map();
protected _browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _requestCache: RequestCache;
public constructor(
browseMediaManager: BrowseMediaManager<BrowseMediaMetadata>,
resolvedMediaCache: ResolvedMediaCache,
requestCache: RequestCache,
) {
super();
this._browseMediaManager = browseMediaManager;
this._resolvedMediaCache = resolvedMediaCache;
this._requestCache = requestCache;
}
public async initializeCamera(
hass: HomeAssistant,
entityRegistryManager: EntityRegistryManager,
cameraConfig: CameraConfig,
): Promise<CameraConfig> {
const entity = cameraConfig.camera_entity
? await entityRegistryManager.getEntity(hass, cameraConfig.camera_entity)
: null;
if (!entity || !cameraConfig.camera_entity) {
throw new CameraInitializationError(
localize('error.no_camera_entity'),
cameraConfig,
);
}
this._cameraEntities.set(cameraConfig.camera_entity, entity);
return cameraConfig;
}
public generateDefaultEventQuery(
_cameras: CameraConfigs,
cameraIDs: Set<string>,
query: PartialEventQuery,
): EventQuery[] | null {
return [
{
type: QueryType.Event,
cameraIDs: cameraIDs,
...query,
},
];
}
/**
* 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.
*/
protected _mediaIsWithinDates = (
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,
},
);
};
public async getMediaDownloadPath(
hass: ExtendedHomeAssistant,
_cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
const contentID = media.getContentID();
if (!contentID) {
return null;
}
const resolvedMedia = await resolveMedia(hass, contentID, this._resolvedMediaCache);
return resolvedMedia
? { endpoint: canonicalizeHAURL(hass, resolvedMedia.url) }
: null;
}
public getQueryResultMaxAge(query: DataQuery): number | null {
if (query.type === QueryType.Event) {
return BROWSE_MEDIA_CACHE_SECONDS;
}
return null;
}
public getCameraCapabilities(
cameraConfig: CameraConfig,
): CameraManagerCameraCapabilities | null {
const parentCapabilities = super.getCameraCapabilities(cameraConfig);
if (!parentCapabilities) {
return null;
}
return {
...parentCapabilities,
supportsClips: true,
supportsSnapshots: true,
supportsTimeline: true,
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities {
return {
canFavorite: false,
canDownload: true,
};
}
}
+75
View File
@@ -0,0 +1,75 @@
import isEqual from 'lodash-es/isEqual';
import { formatDateAndTime } from '../../utils/basic';
import { MEDIA_CLASS_VIDEO, RichBrowseMedia } from '../../utils/ha/browse-media/types';
import {
ViewMedia,
EventViewMedia,
ViewMediaType,
VideoContentType,
} from '../../view/media';
import { BrowseMediaMetadata } from '../browse-media/types';
class BrowseMediaEventViewMedia extends ViewMedia implements EventViewMedia {
protected _browseMedia: RichBrowseMedia<BrowseMediaMetadata>;
constructor(
mediaType: ViewMediaType,
cameraID: string,
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
) {
super(mediaType, cameraID);
this._browseMedia = browseMedia;
}
public hasClip(): boolean {
return this._browseMedia.media_class === MEDIA_CLASS_VIDEO;
}
public getStartTime(): Date | null {
return this._browseMedia._metadata?.startDate ?? null;
}
public getEndTime(): Date | null {
return null;
}
public getVideoContentType(): VideoContentType | null {
return VideoContentType.MP4;
}
public getID(): string {
return this.getContentID();
}
public getContentID(): string {
return this._browseMedia.media_content_id;
}
public getTitle(): string | null {
const startTime = this.getStartTime();
return startTime ? formatDateAndTime(startTime) : this._browseMedia.title;
}
public getThumbnail(): string | null {
return this._browseMedia.thumbnail;
}
public getWhat(): string[] | null {
return null;
}
public getScore(): number | null {
return null;
}
public getTags(): string[] | null {
return null;
}
public isGroupableWith(that: EventViewMedia): boolean {
return (
this.getMediaType() === that.getMediaType() &&
isEqual(this.getWhere(), that.getWhere()) &&
isEqual(this.getWhat(), that.getWhat())
);
}
}
export class BrowseMediaViewMediaFactory {
static createEventViewMedia(
mediaType: 'clip' | 'snapshot',
browseMedia: RichBrowseMedia<BrowseMediaMetadata>,
cameraID: string,
): BrowseMediaEventViewMedia | null {
return new BrowseMediaEventViewMedia(mediaType, cameraID, browseMedia);
}
}
+5
View File
@@ -0,0 +1,5 @@
export interface BrowseMediaMetadata {
cameraID: string;
startDate: Date;
endDate: Date;
}
+1 -1
View File
@@ -16,7 +16,7 @@ interface CameraManagerCache<Request, Response> {
set(request: Request, response: Response, expiry?: Date): void;
}
class MemoryRequestCache<Request, Response>
export class MemoryRequestCache<Request, Response>
implements CameraManagerCache<Request, Response>
{
protected _data: RequestCacheItem<Request, Response>[] = [];
+19 -1
View File
@@ -1,25 +1,32 @@
import { HomeAssistant } from 'custom-card-helpers';
import { localize } from '../localize/localize';
import { CameraConfig, CardWideConfig } from '../types';
import { BrowseMediaManager } from '../utils/ha/browse-media/browse-media-manager';
import { BrowseMedia } from '../utils/ha/browse-media/types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { Entity } from '../utils/ha/entity-registry/types';
import { RecordingSegmentsCache, RequestCache } from './cache';
import { ResolvedMediaCache } from '../utils/ha/resolved-media';
import { MemoryRequestCache, RecordingSegmentsCache, RequestCache } from './cache';
import { CameraManagerEngine } from './engine';
import { CameraInitializationError } from './error';
import { FrigateCameraManagerEngine } from './frigate/engine-frigate';
import { GenericCameraManagerEngine } from './generic/engine-generic';
import { MotionEyeCameraManagerEngine } from './motioneye/engine-motioneye';
import { Engine } from './types';
export class CameraManagerEngineFactory {
protected _entityRegistryManager: EntityRegistryManager;
protected _resolvedMediaCache: ResolvedMediaCache;
protected _cardWideConfig: CardWideConfig;
constructor(
entityRegistryManager: EntityRegistryManager,
resolvedMediaCache: ResolvedMediaCache,
cardWideConfig: CardWideConfig,
) {
this._entityRegistryManager = entityRegistryManager;
this._cardWideConfig = cardWideConfig;
this._resolvedMediaCache = resolvedMediaCache;
}
public createEngine(engine: Engine): CameraManagerEngine | null {
@@ -35,6 +42,12 @@ export class CameraManagerEngineFactory {
new RequestCache(),
);
break;
case Engine.MotionEye:
cameraManagerEngine = new MotionEyeCameraManagerEngine(
new BrowseMediaManager(new MemoryRequestCache<string, BrowseMedia>()),
this._resolvedMediaCache,
new RequestCache(),
);
}
return cameraManagerEngine;
}
@@ -50,6 +63,8 @@ export class CameraManagerEngineFactory {
let engine: Engine | null = null;
if (cameraConfig.engine === 'frigate') {
engine = Engine.Frigate;
} else if (cameraConfig.engine === 'motioneye') {
engine = Engine.MotionEye;
} else if (cameraConfig.engine === 'auto') {
const cameraEntity = cameraConfig.camera_entity;
@@ -70,6 +85,9 @@ export class CameraManagerEngineFactory {
case 'frigate':
engine = Engine.Frigate;
break;
case 'motioneye':
engine = Engine.MotionEye;
break;
default:
engine = Engine.Generic;
}
+7 -2
View File
@@ -1,5 +1,5 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../types';
import { CameraConfig, ExtendedHomeAssistant } from '../types';
import { EntityRegistryManager } from '../utils/ha/entity-registry';
import { ViewMedia } from '../view/media';
import {
@@ -24,6 +24,7 @@ import {
MediaMetadataQuery,
MediaMetadataQueryResultsMap,
EngineOptions,
CameraEndpoint,
} from './types';
export const CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT = 10000;
@@ -90,7 +91,11 @@ export interface CameraManagerEngine {
results: QueryReturnType<RecordingQuery>,
): ViewMedia[] | null;
getMediaDownloadPath(cameraConfig: CameraConfig, media: ViewMedia): string | null;
getMediaDownloadPath(
hass: ExtendedHomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
): Promise<CameraEndpoint | null>;
favoriteMedia(
hass: HomeAssistant,
@@ -0,0 +1,3 @@
<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M130 446.5C131.6 459.3 145 468 137 470C129 472 94 406.5 86 378.5C78 350.5 73.5 319 75.4999 301C77.4999 283 181 255 181 247.5C181 240 147.5 247 146 241C144.5 235 171.3 238.6 178.5 229C189.75 214 204 216.5 213 208.5C222 200.5 233 170 235 157C237 144 215 129 209 119C203 109 222 102 268 83C314 64 460 22 462 27C464 32 414 53 379 66C344 79 287 104 287 111C287 118 290 123.5 288 139.5C286 155.5 285.76 162.971 282 173.5C279.5 180.5 277 197 282 212C286 224 299 233 305 235C310 235.333 323.8 235.8 339 235C358 234 385 236 385 241C385 246 344 243 344 250C344 257 386 249 385 256C384 263 350 260 332 260C317.6 260 296.333 259.333 287 256L285 263C281.667 263 274.7 265 267.5 265C258.5 265 258 268 241.5 268C225 268 230 267 215 266C200 265 144 308 134 322C124 336 130 370 130 385.5C130 399.428 128 430.5 130 446.5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 936 B

+31 -26
View File
@@ -2,7 +2,7 @@ import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add';
import endOfHour from 'date-fns/endOfHour';
import startOfHour from 'date-fns/startOfHour';
import { CameraConfig, CardWideConfig } from '../../types';
import { CameraConfig, CardWideConfig, ExtendedHomeAssistant } from '../../types';
import { ViewMedia } from '../../view/media';
import { RecordingSegmentsCache, RequestCache } from '../cache';
import {
@@ -80,6 +80,7 @@ import { localize } from '../../localize/localize';
import uniq from 'lodash-es/uniq';
import format from 'date-fns/format';
import { GenericCameraManagerEngine } from '../generic/engine-generic';
import frigateLogo from './assets/frigate-logo-dark.svg';
const EVENT_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
const RECORDING_SUMMARY_REQUEST_CACHE_MAX_AGE_SECONDS = 60;
@@ -302,26 +303,32 @@ export class FrigateCameraManagerEngine
return null;
}
public getMediaDownloadPath(
public async getMediaDownloadPath(
_hass: ExtendedHomeAssistant,
cameraConfig: CameraConfig,
media: ViewMedia,
): string | null {
let path: string | null = null;
): Promise<CameraEndpoint | null> {
if (FrigateViewMediaClassifier.isFrigateEvent(media)) {
path =
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/notifications/${media.getID()}/` +
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`;
return {
endpoint:
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/notifications/${media.getID()}/` +
`${ViewMediaClassifier.isClip(media) ? 'clip.mp4' : 'snapshot.jpg'}` +
`?download=true`,
sign: true,
};
} else if (FrigateViewMediaClassifier.isFrigateRecording(media)) {
path =
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/recording/${cameraConfig.frigate.camera_name}` +
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
`?download=true`;
return {
endpoint:
`/api/frigate/${cameraConfig.frigate.client_id}` +
`/recording/${cameraConfig.frigate.camera_name}` +
`/start/${Math.floor(media.getStartTime().getTime() / 1000)}` +
`/end/${Math.floor(media.getEndTime().getTime() / 1000)}}` +
`?download=true`,
sign: true,
};
}
return path;
return null;
}
public generateDefaultEventQuery(
@@ -1097,6 +1104,7 @@ export class FrigateCameraManagerEngine
cameraConfig.id ??
'',
icon: metadata.icon,
engineLogo: frigateLogo,
};
}
@@ -1178,7 +1186,7 @@ export class FrigateCameraManagerEngine
};
const getWebRTCCard = (): CameraEndpoint | null => {
// By defaykt use the frigate camera name which is the default recommended
// By default use the frigate camera name which is the default recommended
// setup as per:
// https://deploy-preview-4055--frigate-docs.netlify.app/guides/configuring_go2rtc/
//
@@ -1194,14 +1202,11 @@ export class FrigateCameraManagerEngine
const jsmpeg = getJSMPEG();
const webrtcCard = getWebRTCCard();
return ui || go2rtc || jsmpeg
? {
...(ui && { ui: ui }),
...(go2rtc && { go2rtc: go2rtc }),
...(jsmpeg && { jsmpeg: jsmpeg }),
...(jsmpeg && { jsmpeg: jsmpeg }),
...(webrtcCard && { webrtcCard: webrtcCard }),
}
: null;
return {
...(ui && { ui: ui }),
...(go2rtc && { go2rtc: go2rtc }),
...(jsmpeg && { jsmpeg: jsmpeg }),
...(webrtcCard && { webrtcCard: webrtcCard }),
};
}
}
+14
View File
@@ -6,6 +6,7 @@ import {
EventViewMedia,
RecordingViewMedia,
ViewMediaType,
VideoContentType,
} from '../../view/media';
import { FrigateEvent, FrigateRecording } from './types';
import {
@@ -51,6 +52,14 @@ export class FrigateEventViewMedia extends ViewMedia implements EventViewMedia {
public getEndTime(): Date | null {
return this._event.end_time ? fromUnixTime(this._event.end_time) : null;
}
public inProgress(): boolean | null {
// In Frigate, events/recordings always have end times unless they are in
// progress.
return !this.getEndTime();
}
public getVideoContentType(): VideoContentType | null {
return VideoContentType.HLS;
}
public getID(): string {
return this._event.id;
}
@@ -123,6 +132,11 @@ export class FrigateRecordingViewMedia extends ViewMedia implements RecordingVie
public getEndTime(): Date {
return this._recording.endTime;
}
public inProgress(): boolean | null {
// In Frigate, events/recordings always have end times unless they are in
// progress.
return !this.getEndTime();
}
public getContentID(): string | null {
return this._contentID;
}
+1
View File
@@ -67,6 +67,7 @@ export interface FrigateRecording {
export const eventSummarySchema = z
.object({
camera: z.string(),
// Days in RFC3339 format.
day: z.string(),
label: z.string(),
sub_label: z.string().nullable(),
+7 -5
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../../types';
import { CameraConfig, ExtendedHomeAssistant } from '../../types';
import { ViewMedia } from '../../view/media';
import {
CameraManagerCameraMetadata,
@@ -25,6 +25,7 @@ import {
MediaMetadataQuery,
MediaMetadataQueryResultsMap,
EngineOptions,
CameraEndpoint,
} from '../types';
import { getEntityIcon, getEntityTitle } from '../../utils/ha';
import { EntityRegistryManager } from '../../utils/ha/entity-registry';
@@ -112,10 +113,11 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
return null;
}
public getMediaDownloadPath(
public async getMediaDownloadPath(
_hass: ExtendedHomeAssistant,
_cameraConfig: CameraConfig,
_media: ViewMedia,
): string | null {
): Promise<CameraEndpoint | null> {
return null;
}
@@ -174,12 +176,12 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
): CameraManagerCameraCapabilities | null {
return {
canFavoriteEvents: false,
canFavoriteRecordings:false,
canFavoriteRecordings: false,
supportsClips: false,
supportsRecordings: false,
supportsSnapshots: false,
supportsTimeline: false,
}
};
}
public getMediaCapabilities(_media: ViewMedia): CameraManagerMediaCapabilities | null {
+25 -22
View File
@@ -1,5 +1,10 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig, CamerasConfig, CardWideConfig } from '../types.js';
import {
CameraConfig,
CamerasConfig,
CardWideConfig,
ExtendedHomeAssistant,
} from '../types.js';
import { allPromises, arrayify, setify } from '../utils/basic.js';
import {
CameraManagerCameraCapabilities,
@@ -34,11 +39,10 @@ import {
MediaMetadataQuery,
MediaMetadataQueryResults,
EngineOptions,
CameraEndpoint,
} from './types.js';
import orderBy from 'lodash-es/orderBy';
import { CameraManagerEngineFactory } from './engine-factory.js';
import { ViewMedia } from '../view/media.js';
import uniqBy from 'lodash-es/uniqBy';
import { CameraManagerEngine } from './engine.js';
import sum from 'lodash-es/sum';
import add from 'date-fns/add';
@@ -50,6 +54,7 @@ import { CameraInitializationError } from './error.js';
import { CameraManagerReadOnlyConfigStore, CameraManagerStore } from './store.js';
import cloneDeep from 'lodash-es/cloneDeep';
import { MEDIA_CHUNK_SIZE_DEFAULT } from '../const.js';
import { sortMedia } from './util.js';
class QueryClassifier {
public static isEventQuery(query: DataQuery | PartialDataQuery): query is EventQuery {
@@ -356,7 +361,7 @@ export class CameraManager {
concreteQueries.push(query as PartialQueryConcreteType<PQT>);
}
}
return concreteQueries;
return concreteQueries.length ? concreteQueries : null;
}
public async getEvents(
@@ -459,20 +464,31 @@ export class CameraManager {
return null;
}
const outputMedia = sortMedia(results.concat(newChunkMedia));
// If the media did not _ACTUALLY_ get longer, there is no new media despite
// the increased limit, so just return null.
if (outputMedia.length === results.length) {
return null;
}
return {
queries: extendedQueries,
results: this._sortMedia(results.concat(newChunkMedia)),
results: outputMedia,
};
}
public getMediaDownloadPath(media: ViewMedia): string | null {
public async getMediaDownloadPath(
hass: ExtendedHomeAssistant,
media: ViewMedia,
): Promise<CameraEndpoint | null> {
const cameraConfig = this._store.getCameraConfigForMedia(media);
const engine = this._store.getEngineForMedia(media);
if (!cameraConfig || !engine) {
return null;
}
return engine.getMediaDownloadPath(cameraConfig, media);
return await engine.getMediaDownloadPath(hass, cameraConfig, media);
}
public getMediaCapabilities(media: ViewMedia): CameraManagerMediaCapabilities | null {
@@ -623,7 +639,7 @@ export class CameraManager {
await Promise.all(_queries.map((query) => processQuery(query)));
const cachedOutputQueries = sum(
Array.from(results.values()).map((result) => Number(result.cached)),
Array.from(results.values()).map((result) => Number(result.cached ?? 0)),
);
log(
@@ -681,20 +697,7 @@ export class CameraManager {
}
}
}
return this._sortMedia(mediaArray);
}
protected _sortMedia(mediaArray: ViewMedia[]): ViewMedia[] {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading oldest -> youngest (so media is loaded in this
// order in the viewer which matches the left-to-right timeline order).
(media) => media.getStartTime(),
'asc',
);
return sortMedia(mediaArray);
}
public getCameraEndpoints(
@@ -0,0 +1,242 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
id="svg2"
version="1.1"
inkscape:version="0.91 r13725"
width="64"
height="64"
xml:space="preserve"
sodipodi:docname="motioneye-icon.svg"
inkscape:export-filename="/home/ccrisan/projects/motioneye/static/img/motioneye-logo.png"
inkscape:export-xdpi="960"
inkscape:export-ydpi="960"><metadata
id="metadata8"><rdf:RDF><cc:Work
rdf:about=""><dc:format>image/svg+xml</dc:format><dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" /><dc:title /></cc:Work></rdf:RDF></metadata><defs
id="defs6"><linearGradient
id="linearGradient4351"
inkscape:collect="always"><stop
id="stop4353"
offset="0"
style="stop-color:#737373;stop-opacity:1" /><stop
id="stop4355"
offset="1"
style="stop-color:#585858;stop-opacity:1" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient4205"><stop
style="stop-color:#4aa3e0;stop-opacity:1"
offset="0"
id="stop4207" /><stop
style="stop-color:#3096db;stop-opacity:1"
offset="1"
id="stop4209" /></linearGradient><linearGradient
inkscape:collect="always"
id="linearGradient4197"><stop
style="stop-color:#787878;stop-opacity:1"
offset="0"
id="stop4199" /><stop
style="stop-color:#585858;stop-opacity:1"
offset="1"
id="stop4201" /></linearGradient><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4351"
id="linearGradient4203"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768"
gradientUnits="userSpaceOnUse" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4205"
id="linearGradient4211"
x1="26.602072"
y1="43.034946"
x2="26.602072"
y2="29.466328"
gradientUnits="userSpaceOnUse"
gradientTransform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)" /><filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4285"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4287" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4289" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4291" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4293" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4295" /></filter><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4197"
id="linearGradient4309"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4197"
id="linearGradient4311"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><linearGradient
inkscape:collect="always"
xlink:href="#linearGradient4197"
id="linearGradient4313"
gradientUnits="userSpaceOnUse"
x1="26.445793"
y1="47.517574"
x2="26.445793"
y2="3.8183768" /><filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4315"
x="-0.10000000000000001"
y="-0.16000000000000003"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4317" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4319" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4321" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4323" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4325" /></filter><filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4327"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4329" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4331" /><feGaussianBlur
in="composite1"
stdDeviation="0.6"
result="blur"
id="feGaussianBlur4333" /><feOffset
dx="0"
dy="-1"
result="offset"
id="feOffset4335" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4337" /></filter><filter
style="color-interpolation-filters:sRGB;"
inkscape:label="Drop Shadow"
id="filter4339"><feFlood
flood-opacity="0.588235"
flood-color="rgb(0,0,0)"
result="flood"
id="feFlood4341" /><feComposite
in="flood"
in2="SourceGraphic"
operator="in"
result="composite1"
id="feComposite4343" /><feGaussianBlur
in="composite1"
stdDeviation="0.2"
result="blur"
id="feGaussianBlur4345" /><feOffset
dx="0"
dy="-0.5"
result="offset"
id="feOffset4347" /><feComposite
in="SourceGraphic"
in2="offset"
operator="over"
result="composite2"
id="feComposite4349" /></filter></defs><sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1920"
inkscape:window-height="1025"
id="namedview4"
showgrid="false"
inkscape:zoom="2"
inkscape:cx="-94.597631"
inkscape:cy="10.226517"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="1"
inkscape:current-layer="g10"
showguides="true"
inkscape:guide-bbox="true" /><g
id="g10"
inkscape:groupmode="layer"
inkscape:label="ink_ext_XXXXXX"
transform="matrix(1.25,0,0,-1.25,0,64)"><g
id="g4170"
style="fill:url(#linearGradient4203);fill-opacity:1;filter:url(#filter4327)"
transform="matrix(0.96428571,0,0,0.96428571,0.91428571,0.91428571)"><path
id="path4244"
d="M 8.9346154,40.515385 C 5.3647588,36.547307 3.2,31.357779 3.2,25.6 3.2,13.228821 13.228821,3.2 25.6,3.2 37.971179,3.2 48,13.228821 48,25.6 c 0,5.736682 -2.161128,10.952493 -5.707692,14.915385 -1.695935,-0.623286 -3.387833,-1.349065 -5.061539,-2.288462 3.2394,-0.937363 5.6,-3.937988 5.6,-7.457692 0,-4.260339 -3.469626,-7.753846 -7.753846,-7.753846 -3.633936,0 -6.690552,2.51055 -7.538461,5.869231 l -3.876924,0 c -0.840685,-3.360193 -3.903443,-5.869231 -7.538461,-5.869231 -4.284219,0 -7.7807693,3.493507 -7.7807693,7.753846 0,3.56112 2.4570323,6.5856 5.7615383,7.484616 -1.676267,0.912203 -3.404813,1.620556 -5.1692306,2.261538 z M 25.6,26.461538 c 0.532632,-1.981435 1.101793,-3.947553 3.446154,-5.16923 L 25.6,16.123077 22.153846,21.292308 c 2.053593,1.454966 3.000771,3.237758 3.446154,5.16923 z"
style="fill:url(#linearGradient4309);fill-opacity:1;stroke:none"
inkscape:connector-curvature="0" /><path
id="path4242"
d="m 16.123077,33.353847 c -1.427443,0 -2.584616,-1.157173 -2.584616,-2.584616 0,-1.427444 1.157173,-2.584615 2.584616,-2.584615 1.427444,0 2.584615,1.157171 2.584615,2.584615 0,1.427443 -1.157171,2.584616 -2.584615,2.584616 z"
style="fill:url(#linearGradient4311);fill-opacity:1;stroke:none"
inkscape:connector-curvature="0" /><path
id="path4240"
d="m 35.076923,33.353847 c -1.427443,0 -2.584615,-1.157173 -2.584615,-2.584616 0,-1.427444 1.157172,-2.584615 2.584615,-2.584615 1.427443,0 2.584616,1.157171 2.584616,2.584615 0,1.427443 -1.157173,2.584616 -2.584616,2.584616 z"
style="fill:url(#linearGradient4313);fill-opacity:1;stroke:none"
inkscape:connector-curvature="0" /></g><path
inkscape:connector-curvature="0"
style="fill:#737373;fill-opacity:1;stroke:none;filter:url(#filter4339)"
d="m 25.6,47.2 c -4.373944,0 -8.437159,-1.399808 -11.838461,-3.634616 3.677605,-0.394237 7.305921,-1.342945 11.423077,-3.375 4.166157,2.122533 8.434154,3.008875 12.279808,3.452886 C 34.057131,45.890032 29.986674,47.2 25.6,47.2 Z"
id="path4248" /><path
inkscape:connector-curvature="0"
style="fill:url(#linearGradient4211);fill-opacity:1;stroke:none;filter:url(#filter4315)"
d="M 39.723077,42.552884 C 35.394064,42.5242 29.479588,40.397223 25.184616,38.432418 20.668821,40.064102 16.035448,42.649343 10.801923,42.526924 10.453022,42.51873 10.118061,42.50105 9.7634616,42.475 L 5.6615384,42.1375 9.5557693,40.839424 c 5.3417977,-1.74056 10.0398397,-2.851302 14.1749997,-10.025963 0.959101,0 2.845924,-4.15e-4 3.738462,-4.15e-4 4.11884,7.134039 9.059296,8.324614 14.149039,10.026378 L 45.460577,42.085577 41.4625,42.475 c -0.544847,0.05181 -1.120992,0.08198 -1.739423,0.07788 z"
id="path4246"
sodipodi:nodetypes="cccccccccccc" /></g></svg>

After

Width:  |  Height:  |  Size: 10 KiB

@@ -0,0 +1,427 @@
import { HomeAssistant } from 'custom-card-helpers';
import { CameraConfig } from '../../types';
import { ViewMedia } from '../../view/media';
import {
CameraConfigs,
CameraEndpoint,
CameraEndpoints,
CameraEndpointsContext,
CameraManagerCameraMetadata,
Engine,
EngineOptions,
EventQuery,
EventQueryResults,
EventQueryResultsMap,
MediaMetadataQuery,
MediaMetadataQueryResults,
MediaMetadataQueryResultsMap,
QueryResults,
QueryResultsType,
QueryReturnType,
} from '../types';
import { CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT } from '../engine';
import {
BrowseMediaStep,
BrowseMediaTarget,
} from '../../utils/ha/browse-media/browse-media-manager';
import { allPromises, formatDate, isValidDate } from '../../utils/basic';
import endOfDay from 'date-fns/endOfDay';
import {
BROWSE_MEDIA_CACHE_SECONDS,
BrowseMedia,
MEDIA_CLASS_IMAGE,
MEDIA_CLASS_VIDEO,
RichBrowseMedia,
} from '../../utils/ha/browse-media/types';
import parse from 'date-fns/parse';
import { MotionEyeEventQueryResults } from './types';
import orderBy from 'lodash-es/orderBy';
import startOfDay from 'date-fns/startOfDay';
import add from 'date-fns/add';
import { BrowseMediaCameraManagerEngine } from '../browse-media/engine-browse-media';
import { BrowseMediaMetadata } from '../browse-media/types';
import { BrowseMediaViewMediaFactory } from '../browse-media/media';
import motioneyeLogo from './assets/motioneye-logo.svg';
class MotionEyeQueryResultsClassifier {
public static isMotionEyeEventQueryResults(
results: QueryResults,
): results is MotionEyeEventQueryResults {
return (
results.engine === Engine.MotionEye && results.type === QueryResultsType.Event
);
}
}
const MOTIONEYE_REPL_SUBSTITUTIONS: Record<string, string> = {
'%Y': 'yyyy',
'%m': 'MM',
'%d': 'dd',
'%H': 'HH',
'%M': 'mm',
'%S': 'SS',
};
const MOTIONEYE_REPL_REGEXP = new RegExp(/(%Y|%m|%d|%H|%M|%S)/g);
export class MotionEyeCameraManagerEngine extends BrowseMediaCameraManagerEngine {
public getEngineType(): Engine {
return Engine.MotionEye;
}
protected _convertMotionEyeTimeFormatToDateFNS(part: string): string {
return part.replace(
MOTIONEYE_REPL_REGEXP,
(_, key) => MOTIONEYE_REPL_SUBSTITUTIONS[key],
);
}
// Get metadata for a MotionEye media file.
protected _motionEyeMetadataGeneratorFile(
cameraID: string,
dateFormat: string | null,
media: BrowseMedia,
parent?: RichBrowseMedia<BrowseMediaMetadata>,
): BrowseMediaMetadata | null {
let startDate = parent?._metadata?.startDate ?? new Date();
if (dateFormat) {
const extensionlessTitle = media.title.replace(/\.[^/.]+$/, '');
startDate = parse(extensionlessTitle, dateFormat, startDate);
if (!isValidDate(startDate)) {
return null;
}
}
return {
cameraID: cameraID,
startDate: startDate,
// MotionEye only has start times, the event is effectively a 'point'
endDate: startDate,
};
}
// Get metadata for a MotionEye media directory.
protected _motionEyeMetadataGeneratorDirectory(
cameraID: string,
dateFormat: string | null,
media: BrowseMedia,
parent?: RichBrowseMedia<BrowseMediaMetadata>,
): BrowseMediaMetadata | null {
let startDate = parent?._metadata?.startDate ?? new Date();
if (dateFormat) {
const parsedDate = parse(media.title, dateFormat, startDate);
if (!isValidDate(parsedDate)) {
return null;
}
startDate = startOfDay(parsedDate);
}
return {
cameraID: cameraID,
startDate: startDate,
endDate: parent?._metadata?.endDate ?? endOfDay(startDate),
};
}
// Get media directories that match a given criteria.
protected async _getMatchingDirectories(
hass: HomeAssistant,
cameras: CameraConfigs,
cameraID: string,
matchOptions?: {
start?: Date;
end?: Date;
hasClip?: boolean;
hasSnapshot?: boolean;
} | null,
engineOptions?: EngineOptions,
): Promise<RichBrowseMedia<BrowseMediaMetadata>[] | null> {
const cameraEntityID = cameras.get(cameraID)?.camera_entity;
const entity = cameraEntityID ? this._cameraEntities.get(cameraEntityID) : null;
const configID = entity?.config_entry_id;
const deviceID = entity?.device_id;
const cameraConfig = cameras.get(cameraID);
if (!configID || !deviceID || !cameraConfig) {
return null;
}
const generateNextStep = (
parts: string[],
media: BrowseMediaTarget<BrowseMediaMetadata>[],
): BrowseMediaStep<BrowseMediaMetadata>[] => {
const next = parts.shift();
if (!next) {
return [];
}
const dateFormat = next.includes('%')
? this._convertMotionEyeTimeFormatToDateFNS(next)
: null;
return [
{
targets: media,
metadataGenerator: (
media: BrowseMedia,
parent?: RichBrowseMedia<BrowseMediaMetadata>,
) =>
this._motionEyeMetadataGeneratorDirectory(
cameraID,
dateFormat,
media,
parent,
),
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
media.can_expand &&
(!!dateFormat || media.title === next) &&
this._mediaIsWithinDates(media, matchOptions?.start, matchOptions?.end),
advance: (media) => generateNextStep(parts, media),
},
];
};
// For motionEye snapshots and clips are mutually exclusive.
return await this._browseMediaManager.walkBrowseMedias(
hass,
[
...(matchOptions?.hasClip !== false && !matchOptions?.hasSnapshot
? generateNextStep(
cameraConfig.motioneye.movies.directory_pattern.split('/'),
[`media-source://motioneye/${configID}#${deviceID}#movies`],
)
: []),
...(matchOptions?.hasSnapshot !== false && !matchOptions?.hasClip
? generateNextStep(
cameraConfig.motioneye.images.directory_pattern.split('/'),
[`media-source://motioneye/${configID}#${deviceID}#images`],
)
: []),
],
{
useCache: engineOptions?.useCache,
},
);
}
public async getEvents(
hass: HomeAssistant,
cameras: CameraConfigs,
query: EventQuery,
engineOptions?: EngineOptions,
): Promise<EventQueryResultsMap | null> {
// MotionEye does not support these query types and they will never match.
if (query.favorite || query.tags?.size || query.what?.size || query.where?.size) {
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 cameraConfig = cameras.get(cameraID);
if (!cameraConfig) {
return;
}
const directories = await this._getMatchingDirectories(
hass,
cameras,
cameraID,
perCameraQuery,
engineOptions,
);
if (!directories || !directories.length) {
return;
}
const moviesDateFormat = this._convertMotionEyeTimeFormatToDateFNS(
cameraConfig.motioneye.movies.file_pattern,
);
const imagesDateFormat = this._convertMotionEyeTimeFormatToDateFNS(
cameraConfig.motioneye.images.file_pattern,
);
const media = await this._browseMediaManager.walkBrowseMedias(
hass,
[
{
targets: directories,
metadataGenerator: (
media: BrowseMedia,
parent?: RichBrowseMedia<BrowseMediaMetadata>,
) => {
if (
media.media_class === MEDIA_CLASS_IMAGE ||
media.media_class === MEDIA_CLASS_VIDEO
) {
return this._motionEyeMetadataGeneratorFile(
cameraID,
media.media_class === MEDIA_CLASS_IMAGE
? imagesDateFormat
: moviesDateFormat,
media,
parent,
);
}
return null;
},
matcher: (media: RichBrowseMedia<BrowseMediaMetadata>) =>
!media.can_expand &&
this._mediaIsWithinDates(media, perCameraQuery.start, perCameraQuery.end),
},
],
{ useCache: engineOptions?.useCache },
);
// Sort by most recent then slice at the query limit.
const sortedMedia = orderBy(
media,
(media: RichBrowseMedia<BrowseMediaMetadata>) => media._metadata?.startDate,
'desc',
).slice(0, perCameraQuery.limit ?? CAMERA_MANAGER_ENGINE_EVENT_LIMIT_DEFAULT);
const result: MotionEyeEventQueryResults = {
type: QueryResultsType.Event,
engine: Engine.MotionEye,
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.size ? output : null;
}
public generateMediaFromEvents(
_hass: HomeAssistant,
_cameras: CameraConfigs,
_query: EventQuery,
results: QueryReturnType<EventQuery>,
): ViewMedia[] | null {
if (!MotionEyeQueryResultsClassifier.isMotionEyeEventQueryResults(results)) {
return null;
}
const output: ViewMedia[] = [];
for (const browseMedia of results.browseMedia) {
const cameraID = browseMedia._metadata?.cameraID;
if (!cameraID) {
continue;
}
const mediaType =
browseMedia.media_class === MEDIA_CLASS_VIDEO
? 'clip'
: browseMedia.media_class === MEDIA_CLASS_IMAGE
? 'snapshot'
: null;
if (!mediaType) {
continue;
}
const media = BrowseMediaViewMediaFactory.createEventViewMedia(
mediaType,
browseMedia,
cameraID,
);
if (media) {
output.push(media);
}
}
return output;
}
public async getMediaMetadata(
hass: HomeAssistant,
cameras: CameraConfigs,
query: MediaMetadataQuery,
engineOptions?: EngineOptions,
): Promise<MediaMetadataQueryResultsMap | null> {
const output: MediaMetadataQueryResultsMap = new Map();
if ((engineOptions?.useCache ?? true) && this._requestCache.has(query)) {
const cachedResult = <MediaMetadataQueryResults | null>(
this._requestCache.get(query)
);
if (cachedResult) {
output.set(query, cachedResult as MediaMetadataQueryResults);
return output;
}
}
const days: Set<string> = new Set();
const getDaysForCamera = async (cameraID: string): Promise<void> => {
const directories = await this._getMatchingDirectories(
hass,
cameras,
cameraID,
null,
engineOptions,
);
for (const dayDirectory of directories ?? []) {
if (dayDirectory._metadata) {
days.add(formatDate(dayDirectory._metadata?.startDate));
}
}
};
await allPromises(query.cameraIDs, (cameraID) => getDaysForCamera(cameraID));
const result: MediaMetadataQueryResults = {
type: QueryResultsType.MediaMetadata,
engine: Engine.MotionEye,
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: motioneyeLogo,
};
}
public getCameraEndpoints(
cameraConfig: CameraConfig,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_context?: CameraEndpointsContext,
): CameraEndpoints | null {
const getUIEndpoint = (): CameraEndpoint | null => {
return cameraConfig.motioneye?.url
? {
endpoint: cameraConfig.motioneye.url,
}
: null;
};
const ui = getUIEndpoint();
return {
...(ui && { ui: ui }),
};
}
}
+51
View File
@@ -0,0 +1,51 @@
// Converted from https://raw.githubusercontent.com/motioneye-project/motioneye/python2/motioneye/static/img/motioneye-icon.svg .
export const MOTIONEYE_ICON_SVG_VIEWBOX = '0 0 64 64';
export const MOTIONEYE_ICON_SVG_PATH =
'M 49.65,10.81 ' +
'C 44.24,10.84 36.85,13.50 31.48,15.96 ' +
'25.84,13.92 20.04,10.69 13.50,10.84 ' +
'13.07,10.85 12.65,10.87 12.20,10.91 ' +
'12.20,10.91 7.08,11.33 7.08,11.33 ' +
'7.08,11.33 11.94,12.95 11.94,12.95 ' +
'18.62,15.13 24.49,16.51 29.66,25.48 ' +
'30.86,25.48 33.22,25.48 34.34,25.48 ' +
'39.49,16.57 45.66,15.08 52.02,12.95 ' +
'52.02,12.95 56.83,11.39 56.83,11.39 ' +
'56.83,11.39 51.83,10.91 51.83,10.91 ' +
'51.15,10.84 50.43,10.80 49.65,10.81 ' +
'49.65,10.81 49.65,10.81 49.65,10.81 Z ' +
'M 32.00,5.00 ' +
'C 26.53,5.00 21.45,6.75 17.20,9.54 ' +
'21.80,10.04 26.33,11.22 31.48,13.76 ' +
'36.69,11.11 42.02,10.00 46.83,9.45 ' +
'42.57,6.64 37.48,5.00 32.00,5.00 Z ' +
'M 43.42,22.65 ' +
'C 41.70,22.65 40.31,24.05 40.31,25.77 ' +
'40.31,27.49 41.70,28.88 43.42,28.88 ' +
'45.14,28.88 46.54,27.49 46.54,25.77 ' +
'46.54,24.05 45.14,22.65 43.42,22.65 Z ' +
'M 20.58,22.65 ' +
'C 18.86,22.65 17.46,24.05 17.46,25.77 ' +
'17.46,27.49 18.86,28.88 20.58,28.88 ' +
'22.30,28.88 23.69,27.49 23.69,25.77 ' +
'23.69,24.05 22.30,22.65 20.58,22.65 Z ' +
'M 11.91,14.02 ' +
'C 7.61,18.80 5.00,25.06 5.00,32.00 ' +
'5.00,46.91 17.09,59.00 32.00,59.00 ' +
'46.91,59.00 59.00,46.91 59.00,32.00 ' +
'59.00,25.09 56.40,18.80 52.12,14.02 ' +
'50.08,14.77 48.04,15.65 46.02,16.78 ' +
'49.92,17.91 52.77,21.53 52.77,25.77 ' +
'52.77,30.90 48.59,35.12 43.42,35.12 ' +
'39.04,35.12 35.36,32.09 34.34,28.04 ' +
'34.34,28.04 29.66,28.04 29.66,28.04 ' +
'28.65,32.09 24.96,35.12 20.58,35.12 ' +
'15.41,35.12 11.20,30.90 11.20,25.77 ' +
'11.20,21.48 14.16,17.83 18.14,16.75 ' +
'16.12,15.65 14.04,14.79 11.91,14.02 ' +
'11.91,14.02 11.91,14.02 11.91,14.02 Z ' +
'M 32.00,30.96 ' +
'C 32.64,33.35 33.33,35.72 36.15,37.19 ' +
'36.15,37.19 32.00,43.42 32.00,43.42 ' +
'32.00,43.42 27.85,37.19 27.85,37.19 ' +
'30.32,35.44 31.46,33.29 32.00,30.96 Z';
+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';
// ================================
// MotionEye concrete query results
// ================================
export interface MotionEyeEventQueryResults extends EventQueryResults {
engine: Engine.MotionEye;
browseMedia: RichBrowseMedia<BrowseMediaMetadata>[];
}
+1 -1
View File
@@ -80,7 +80,7 @@ export const rangesOverlap = (a: DateRange, b: DateRange): boolean => {
return (
// a starts within the range of b.
(a.start >= b.start && a.start <= b.end) ||
// a events within the range of b.
// a ends within the range of b.
(a.end >= b.start && a.end <= b.end) ||
// a encompasses the entire range of b.
(a.start <= b.start && a.end >= b.end)
+2
View File
@@ -22,6 +22,7 @@ export enum QueryResultsType {
export enum Engine {
Frigate = 'frigate',
Generic = 'generic',
MotionEye = 'motioneye',
}
export interface DataQuery {
@@ -110,6 +111,7 @@ export interface CameraManagerMediaCapabilities {
export interface CameraManagerCameraMetadata {
title: string;
icon: string;
engineLogo?: string;
}
export interface CameraEndpointsContext {
+16
View File
@@ -4,6 +4,9 @@ import startOfDay from 'date-fns/startOfDay';
import endOfDay from 'date-fns/endOfDay';
import endOfMinute from 'date-fns/endOfMinute';
import { DateRange } from './range';
import orderBy from 'lodash-es/orderBy';
import uniqBy from 'lodash-es/uniqBy';
import { ViewMedia } from '../view/media';
export const convertRangeToCacheFriendlyTimes = (
range: DateRange,
@@ -37,3 +40,16 @@ export const capEndDate = (end: Date): Date => {
const now = new Date();
return end > now ? now : end;
};
export const sortMedia = (mediaArray: ViewMedia[]): ViewMedia[] => {
return orderBy(
// Ensure uniqueness by the ID (if specified), otherwise all elements
// are assumed to be unique.
uniqBy(mediaArray, (media) => media.getID() ?? media),
// Sort all items leading oldest -> youngest (so media is loaded in this
// order in the viewer which matches the left-to-right timeline order).
(media) => media.getStartTime(),
'asc',
);
};
+5 -1
View File
@@ -1091,7 +1091,11 @@ class FrigateCard extends LitElement {
cardWideConfig: CardWideConfig,
): Promise<void> {
this._cameraManager = new CameraManager(
new CameraManagerEngineFactory(this._entityRegistryManager, cardWideConfig),
new CameraManagerEngineFactory(
this._entityRegistryManager,
this._resolvedMediaCache,
cardWideConfig,
),
this._cardWideConfig,
);
+10 -4
View File
@@ -23,7 +23,7 @@ import {
} from '../utils/media-to-view.js';
import { CameraManager, ExtendedMediaQueryResult } from '../camera-manager/manager.js';
import { View } from '../view/view.js';
import { dispatchMessageEvent, renderProgressIndicator } from './message.js';
import { renderMessage, renderProgressIndicator } from './message.js';
import './thumbnail.js';
import { THUMBNAIL_DETAILS_WIDTH_MIN } from './thumbnail.js';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
@@ -432,13 +432,19 @@ export class FrigateCardGalleryCore extends LitElement {
}
if ((this.view?.queryResults?.getResultsCount() ?? 0) === 0) {
return dispatchMessageEvent(this, localize('common.no_media'), 'info', {
// Note that this is not throwing up an error message for the card to
// handle (as typical), but rather directly rendering the message into the
// gallery. This is to allow the filter to still be available when a given
// filter selection returns no media.
return renderMessage({
type: 'info',
message: localize('common.no_media'),
icon: 'mdi:multimedia',
});
}
const selected = this.view?.queryResults?.getSelectedResult();
return html`
return html` <div class="grid">
${this._showLoaderTop
? html`${renderProgressIndicator({
cardWideConfig: this.cardWideConfig,
@@ -490,7 +496,7 @@ export class FrigateCardGalleryCore extends LitElement {
componentRef: this._refLoaderBottom,
})}`
: ''}
`;
</div>`;
}
public updated(changedProps: PropertyValues): void {
+5 -3
View File
@@ -539,8 +539,9 @@ export class FrigateCardLiveCarousel extends LitElement {
<frigate-card-live-provider
?disabled=${this.liveConfig.lazy_load}
.cameraConfig=${cameraConfig}
.cameraEndpoints=${guard([this.cameraManager, cameraID], () =>
this.cameraManager?.getCameraEndpoints(cameraID),
.cameraEndpoints=${guard(
[this.cameraManager, cameraID],
() => this.cameraManager?.getCameraEndpoints(cameraID) ?? undefined,
)}
.label=${cameraMetadata?.title ?? ''}
.liveConfig=${config}
@@ -638,6 +639,7 @@ export class FrigateCardLiveCarousel extends LitElement {
.label="${cameraMetadataCurrent
? `${localize('common.live')}: ${cameraMetadataCurrent.title}`
: ''}"
.logo="${cameraMetadataCurrent?.engineLogo}"
.titlePopupConfig=${config.controls.title}
.selected=${this._getSelectedCameraIndex()}
transitionEffect=${this._getTransitionEffect()}
@@ -723,7 +725,7 @@ export class FrigateCardLiveProvider
protected _refProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
public async play(): Promise<void> {
playMediaMutingIfNecessary(this._refProvider.value);
playMediaMutingIfNecessary(this, this._refProvider.value);
}
public pause(): void {
+4
View File
@@ -115,6 +115,9 @@ export class FrigateCardMediaCarousel extends LitElement {
@property({ attribute: false })
public label?: string;
@property({ attribute: false })
public logo?: string;
@property({ attribute: false })
public titlePopupConfig?: TitleControlConfig;
@@ -420,6 +423,7 @@ export class FrigateCardMediaCarousel extends LitElement {
${ref(this._titleControlRef)}
.config=${this.titlePopupConfig}
.text="${this.label}"
.logo="${this.logo}"
.fitInto=${this as HTMLElement}
>
</frigate-card-title-control> `
+1 -1
View File
@@ -247,7 +247,7 @@ export class FrigateCardMenu extends LitElement {
</frigate-card-submenu-select>`;
}
let stateParameters: StateParameters = { ...button };
let stateParameters = { ...button } as StateParameters;
const svgPath =
stateParameters.icon === FRIGATE_BUTTON_MENU_ICON ? FRIGATE_ICON_SVG_PATH : '';
+4 -4
View File
@@ -9,7 +9,7 @@ import {
} from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { ifDefined } from 'lit/directives/if-defined.js';
import { styleMap } from 'lit/directives/style-map.js';
import { StyleInfo, styleMap } from 'lit/directives/style-map.js';
import { actionHandler } from '../action-handler-directive.js';
import submenuStyle from '../scss/submenu.scss';
import {
@@ -39,7 +39,7 @@ export class FrigateCardSubmenu extends LitElement {
if (!this.hass) {
return;
}
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item });
const stateParameters = refreshDynamicStateParameters(this.hass, { ...item } as StateParameters);
const getIcon = (stateParameters: StateParameters): TemplateResult => {
if (stateParameters.icon) {
return html` <ha-icon
@@ -95,7 +95,7 @@ export class FrigateCardSubmenu extends LitElement {
@click=${(ev) => stopEventFromActivatingCardWideActions(ev)}
>
<ha-icon-button
style="${styleMap(this.submenu.style || {})}"
style="${styleMap(this.submenu.style as StyleInfo || {})}"
class="button"
slot="trigger"
.label=${this.submenu.title || ''}
@@ -205,7 +205,7 @@ export class FrigateCardSubmenuSelect extends LitElement {
icon: domainIcon('select'),
// Pull out the dynamic properties (like icon, and title) from the state.
...refreshDynamicStateParameters(this.hass, this.submenuSelect),
...refreshDynamicStateParameters(this.hass, this.submenuSelect as StateParameters),
// Override it with anything explicitly set in the submenuSelect.
...this.submenuSelect,
+34 -26
View File
@@ -170,11 +170,9 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
const rawEndTime = this.media.getEndTime();
const endTime = rawStartTime
? rawEndTime
? getDurationString(rawStartTime, rawEndTime)
: localize('event.in_progress')
: null;
const duration =
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
const inProgress = this.media.inProgress() ? localize('event.in_progress') : null;
const what = prettifyTitle(this.media.getWhat()?.join(', ')) ?? null;
const where = prettifyTitle(this.media.getWhere()?.join(', ')) ?? null;
@@ -200,13 +198,18 @@ export class FrigateCardThumbnailDetailsEvent extends LitElement {
></ha-icon>
<span title="${startTime}">${startTime}</span>
</div>
<div>
<ha-icon
title=${localize('event.duration')}
.icon=${'mdi:clock-outline'}
></ha-icon>
<span title="${endTime}">${endTime}</span>
</div>`
${duration || inProgress
? html` <div>
<ha-icon
title=${localize('event.duration')}
.icon=${'mdi:clock-outline'}
></ha-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
: ''}
</div>`
: ''}`
: ''}
${this.cameraTitle
? html` <div>
@@ -266,11 +269,9 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
const startTime = rawStartTime ? formatDateAndTime(rawStartTime) : null;
const rawEndTime = this.media.getEndTime();
const endTime = rawStartTime
? rawEndTime
? getDurationString(rawStartTime, rawEndTime)
: localize('event.in_progress')
: null;
const duration =
rawStartTime && rawEndTime ? getDurationString(rawStartTime, rawEndTime) : null;
const inProgress = this.media.inProgress() ? localize('recording.in_progress') : null;
const seek = this.seek ? format(this.seek, 'HH:mm:ss') : null;
@@ -284,17 +285,24 @@ export class FrigateCardThumbnailDetailsRecording extends LitElement {
<div class="details">
${startTime
? html` <div>
<ha-icon title=${localize(
'recording.start',
)} .icon=${'mdi:calendar-clock-outline'}></ha-icon>
<ha-icon
title=${localize('recording.start')}
.icon=${'mdi:calendar-clock-outline'}
></ha-icon>
<span title="${startTime}">${startTime}</span>
</div>
<div>
<ha-icon title=${localize(
'recording.duration',
)} .icon=${'mdi:clock-outline'}></ha-icon>
<span title="${endTime}">${endTime}</span>
</div>`
${duration || inProgress
? html` <div>
<ha-icon
title=${localize('recording.duration')}
.icon=${'mdi:clock-outline'}
></ha-icon>
${duration ? html`<span title="${duration}">${duration}</span>` : ''}
${inProgress
? html`<span title="${inProgress}">${inProgress}</span>`
: ''}
</div>`
: ''}`
: ''}
${seek
? html` <div>
+12 -10
View File
@@ -525,10 +525,13 @@ export class FrigateCardTimelineCore extends LitElement {
.selectResultIfFound((media) => media.getID() === properties.item);
if (!newResults || !newResults.hasSelectedResult()) {
// This can happen if this is a recording query (with recorded hours)
// and an event is clicked on the timeline, or if the current thumbnails
// is a filtered view from the media gallery (i.e. any case where the
// thumbnails may not be match the events on the timeline).
// This can happen in a few situations:
// - If this is a recording query (with recorded hours) and an event is
// clicked on the timeline
// - If the current thumbnails/results is a filtered view from the media
// gallery (i.e. any case where the thumbnails may not be match the
// events on the timeline, e.g. in the snapshots viewer but
// mini-timeline showing all media).
const fullEventView = await this._createViewWithEventMediaQuery(
this._createEventMediaQuerys(),
{
@@ -551,10 +554,7 @@ export class FrigateCardTimelineCore extends LitElement {
}
if (view) {
view
// If the user is clicking something in the timeline, don't
// subsequently shift the window (it's pretty jarring).
.dispatchChangeEvent(this);
view.dispatchChangeEvent(this);
if (this.view?.is('timeline')) {
dispatchFrigateCardEvent(this, 'thumbnails:open');
@@ -895,8 +895,10 @@ export class FrigateCardTimelineCore extends LitElement {
const mediaStartTime = media?.getStartTime();
const mediaEndTime = media?.getEndTime();
const mediaWindow: TimelineWindow | null =
media && mediaStartTime && mediaEndTime
? { start: mediaStartTime, end: mediaEndTime }
media && mediaStartTime
// If this media has no end time, it's just a "point" in time so the
// range effectively starts/ends at the same time.
? { start: mediaStartTime, end: mediaEndTime ?? mediaStartTime }
: null;
const context = this.view.context?.timeline;
+8 -5
View File
@@ -1,7 +1,6 @@
import { CSSResultGroup, LitElement, TemplateResult, html, unsafeCSS } from 'lit';
import { createRef, ref, Ref } from 'lit/directives/ref.js';
import { customElement, property } from 'lit/decorators.js';
import { TitleControlConfig } from '../types.js';
import titleStyle from '../scss/title-control.scss';
@@ -21,6 +20,9 @@ export class FrigateCardTitleControl extends LitElement {
@property({ attribute: false })
public fitInto?: HTMLElement;
@property({ attribute: false })
public logo?: string;
protected _toastRef: Ref<PaperToast> = createRef();
/**
@@ -44,6 +46,7 @@ export class FrigateCardTitleControl extends LitElement {
.text="${this.text}"
.fitInto=${this.fitInto}
>
${this.logo ? html`<img src=${this.logo} />` : ''}
</paper-toast>`;
}
@@ -58,7 +61,7 @@ export class FrigateCardTitleControl extends LitElement {
/**
* Show the toast.
*/
public hide(): void {
public hide(): void {
if (this._toastRef.value) {
// Set it to false first, to ensure the timer resets.
this._toastRef.value.opened = false;
@@ -85,7 +88,7 @@ export class FrigateCardTitleControl extends LitElement {
}
declare global {
interface HTMLElementTagNameMap {
"frigate-card-title-control": FrigateCardTitleControl
}
interface HTMLElementTagNameMap {
'frigate-card-title-control': FrigateCardTitleControl;
}
}
+89 -22
View File
@@ -45,7 +45,7 @@ import {
changeViewToRecentEventsForCameraAndDependents,
changeViewToRecentRecordingForCameraAndDependents,
} from '../utils/media-to-view.js';
import { ViewMedia } from '../view/media.js';
import { VideoContentType, ViewMedia } from '../view/media.js';
import { ViewMediaClassifier } from '../view/media-classifier';
import { guard } from 'lit/directives/guard.js';
import { localize } from '../localize/localize.js';
@@ -53,6 +53,10 @@ import { MediaQueriesResults } from '../view/media-queries-results.js';
import { canonicalizeHAURL } from '../utils/ha/index.js';
import { dispatchMediaLoadedEvent } from '../utils/media-info.js';
import { playMediaMutingIfNecessary } from '../utils/media.js';
import {
hideMediaControlsTemporarily,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
} from '../utils/media.js';
export interface MediaViewerViewContext {
seek?: Date;
@@ -399,7 +403,13 @@ export class FrigateCardViewerCarousel extends LitElement {
const media =
this.view?.queryResults?.getSelectedResult() ??
this.view?.queryResults?.getResult(resultCount - 1);
if (!media || !this.view || !this.view.queryResults) {
if (
!this.hass ||
!this.cameraManager ||
!media ||
!this.view ||
!this.view.queryResults
) {
return;
}
@@ -416,6 +426,11 @@ export class FrigateCardViewerCarousel extends LitElement {
}
};
const cameraMetadata = this.cameraManager.getCameraMetadata(
this.hass,
media.getCameraID(),
);
return html` <frigate-card-media-carousel
${ref(this._refMediaCarousel)}
.carouselOptions=${guard([this.viewerConfig], () => ({
@@ -426,6 +441,7 @@ export class FrigateCardViewerCarousel extends LitElement {
this._getPlugins.bind(this),
)}
.label=${media.getTitle() ?? undefined}
.logo=${cameraMetadata?.engineLogo}
.titlePopupConfig=${this.viewerConfig?.controls.title}
.selected=${this.view?.queryResults?.getSelectedIndex() ?? 0}
transitionEffect=${this._getTransitionEffect()}
@@ -544,30 +560,53 @@ export class FrigateCardViewerProvider
@property({ attribute: false })
public cardWideConfig?: CardWideConfig;
protected _refVideoProvider: Ref<Element & FrigateCardMediaPlayer> = createRef();
protected _refFrigateCardMediaPlayer: Ref<Element & FrigateCardMediaPlayer> =
createRef();
protected _refVideoProvider: Ref<HTMLVideoElement> = createRef();
public async play(): Promise<void> {
playMediaMutingIfNecessary(this._refVideoProvider.value);
playMediaMutingIfNecessary(
this,
this._refFrigateCardMediaPlayer.value ?? this._refVideoProvider.value,
);
}
public pause(): void {
this._refVideoProvider.value?.pause();
(this._refFrigateCardMediaPlayer.value || this._refVideoProvider.value)?.pause();
}
public mute(): void {
this._refVideoProvider.value?.mute();
if (this._refFrigateCardMediaPlayer.value) {
this._refFrigateCardMediaPlayer.value?.mute();
} else if (this._refVideoProvider.value) {
this._refVideoProvider.value.muted = true;
}
}
public unmute(): void {
this._refVideoProvider.value?.unmute();
if (this._refFrigateCardMediaPlayer.value) {
this._refFrigateCardMediaPlayer.value?.mute();
} else if (this._refVideoProvider.value) {
this._refVideoProvider.value.muted = false;
}
}
public isMuted(): boolean {
return this._refVideoProvider.value?.isMuted() ?? true;
if (this._refFrigateCardMediaPlayer.value) {
return this._refFrigateCardMediaPlayer.value?.isMuted() ?? true;
} else if (this._refVideoProvider.value) {
return this._refVideoProvider.value.muted;
}
return true;
}
public seek(seconds: number): void {
this._refVideoProvider.value?.seek(seconds);
if (this._refFrigateCardMediaPlayer.value) {
return this._refFrigateCardMediaPlayer.value.seek(seconds);
} else if (this._refVideoProvider.value) {
hideMediaControlsTemporarily(this._refVideoProvider.value);
this._refVideoProvider.value.currentTime = seconds;
}
}
/**
@@ -666,19 +705,47 @@ export class FrigateCardViewerProvider
}
return ViewMediaClassifier.isVideo(this.media)
? html`<frigate-card-ha-hls-player
${ref(this._refVideoProvider)}
allow-exoplayer
aria-label="${this.media.getTitle() ?? ''}"
?autoplay=${false}
controls
muted
playsinline
title="${this.media.getTitle() ?? ''}"
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
.hass=${this.hass}
>
</frigate-card-ha-hls-player>`
? this.media.getVideoContentType() === VideoContentType.HLS
? html`<frigate-card-ha-hls-player
${ref(this._refFrigateCardMediaPlayer)}
allow-exoplayer
aria-label="${this.media.getTitle() ?? ''}"
?autoplay=${false}
controls
muted
playsinline
title="${this.media.getTitle() ?? ''}"
url=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
.hass=${this.hass}
>
</frigate-card-ha-hls-player>`
: html`
<video
${ref(this._refVideoProvider)}
aria-label="${this.media.getTitle() ?? ''}"
title="${this.media.getTitle() ?? ''}"
muted
controls
playsinline
?autoplay=${false}
@loadedmetadata=${(ev: Event) => {
if (ev.target) {
hideMediaControlsTemporarily(
ev.target as HTMLVideoElement,
MEDIA_LOAD_CONTROLS_HIDE_SECONDS,
);
}
}}
@loadeddata=${(ev: Event) => {
dispatchMediaLoadedEvent(this, ev);
}}
>
<source
src=${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}
type="video/mp4"
/>
</video>
`
: html`<img
aria-label="${this.media.getTitle() ?? ''}"
src="${canonicalizeHAURL(this.hass, resolvedMedia?.url) ?? ''}"
+13 -2
View File
@@ -11,17 +11,28 @@ export const CONF_CAMERAS_ARRAY_FRIGATE_CLIENT_ID =
export const CONF_CAMERAS_ARRAY_FRIGATE_LABELS =
`${CONF_CAMERAS}.#.frigate.labels` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_URL = `${CONF_CAMERAS}.#.frigate.url` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES = `${CONF_CAMERAS}.#.frigate.zones` as const;
export const CONF_CAMERAS_ARRAY_FRIGATE_ZONES =
`${CONF_CAMERAS}.#.frigate.zones` as const;
export const CONF_CAMERAS_ARRAY_GO2RTC_MODES = `${CONF_CAMERAS}.#.go2rtc.modes` as const;
export const CONF_CAMERAS_ARRAY_GO2RTC_STREAM =
`${CONF_CAMERAS}.#.go2rtc.stream` as const;
export const CONF_CAMERAS_ARRAY_HIDE = `${CONF_CAMERAS}.#.hide` as const;
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
export const CONF_CAMERAS_ARRAY_ID = `${CONF_CAMERAS}.#.id` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS =
`${CONF_CAMERAS}.#.image.refresh_seconds` as const;
export const CONF_CAMERAS_ARRAY_IMAGE_URL = `${CONF_CAMERAS}.#.image.url` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN =
`${CONF_CAMERAS}.#.motioneye.images.directory_pattern` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN =
`${CONF_CAMERAS}.#.motioneye.images.file_pattern` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN =
`${CONF_CAMERAS}.#.motioneye.movies.directory_pattern` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN =
`${CONF_CAMERAS}.#.motioneye.movies.file_pattern` as const;
export const CONF_CAMERAS_ARRAY_MOTIONEYE_URL =
`${CONF_CAMERAS}.#.motioneye.url` as const;
export const CONF_CAMERAS_ARRAY_TITLE = `${CONF_CAMERAS}.#.title` as const;
export const CONF_CAMERAS_ARRAY_ICON = `${CONF_CAMERAS}.#.icon` as const;
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_ENTITY =
`${CONF_CAMERAS}.#.webrtc_card.entity` as const;
export const CONF_CAMERAS_ARRAY_WEBRTC_CARD_URL =
+1
View File
@@ -1,4 +1,5 @@
declare module '*.scss';
declare module '*.svg';
declare module '*.jpg';
declare module 'view' {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
+50 -2
View File
@@ -28,6 +28,11 @@ import {
CONF_CAMERAS_ARRAY_IMAGE_REFRESH_SECONDS,
CONF_CAMERAS_ARRAY_IMAGE_URL,
CONF_CAMERAS_ARRAY_LIVE_PROVIDER,
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_DIRECTORY_PATTERN,
CONF_CAMERAS_ARRAY_MOTIONEYE_IMAGES_FILE_PATTERN,
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_DIRECTORY_PATTERN,
CONF_CAMERAS_ARRAY_MOTIONEYE_MOVIES_FILE_PATTERN,
CONF_CAMERAS_ARRAY_MOTIONEYE_URL,
CONF_CAMERAS_ARRAY_TITLE,
CONF_CAMERAS_ARRAY_TRIGGERS_ENTITIES,
CONF_CAMERAS_ARRAY_TRIGGERS_MOTION,
@@ -159,6 +164,10 @@ import {
sideLoadHomeAssistantElements,
} from './utils/ha';
import { setLowPerformanceProfile } from './performance.js';
import {
MOTIONEYE_ICON_SVG_PATH,
MOTIONEYE_ICON_SVG_VIEWBOX,
} from './camera-manager/motioneye/icon.js';
const MENU_BUTTONS = 'buttons';
const MENU_CAMERAS = 'cameras';
@@ -166,6 +175,7 @@ const MENU_CAMERAS_DEPENDENCIES = 'cameras.dependencies';
const MENU_CAMERAS_FRIGATE = 'cameras.frigate';
const MENU_CAMERAS_GO2RTC = 'cameras.go2rtc';
const MENU_CAMERAS_IMAGE = 'cameras.image';
const MENU_CAMERAS_MOTIONEYE = 'cameras.motioneye';
const MENU_CAMERAS_TRIGGERS = 'cameras.triggers';
const MENU_CAMERAS_WEBRTC_CARD = 'cameras.webrtc_card';
const MENU_CAMERAS_LIVE_PROVIDER = 'cameras.live_provider';
@@ -888,6 +898,7 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
icon: {
name?: string;
path?: string;
viewBox?: string;
},
template: TemplateResult,
): TemplateResult {
@@ -907,7 +918,9 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
${icon.name
? html` <ha-icon .icon=${icon.name}></ha-icon> `
: icon.path
? html` <ha-svg-icon .path="${icon.path}"></ha-svg-icon> `
? html`
<ha-svg-icon .viewBox=${icon.viewBox} .path="${icon.path}"></ha-svg-icon>
`
: ``}
<span>${localize(labelPath)}</span>
</div>
@@ -1411,7 +1424,42 @@ export class FrigateCardEditor extends LitElement implements LovelaceCardEditor
),
)}
`,
)}`,
)}
${this._putInSubmenu(
MENU_CAMERAS_MOTIONEYE,
cameraIndex,
'config.cameras.motioneye.editor_label',
{ path: MOTIONEYE_ICON_SVG_PATH, viewBox: MOTIONEYE_ICON_SVG_VIEWBOX },
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_LIVE_PROVIDER,
+15 -2
View File
@@ -57,6 +57,18 @@
"image": "Home Assistant images",
"webrtc-card": "WebRTC Card (i.e. AlexxIT's WebRTC Card)"
},
"motioneye": {
"editor_label": "MotionEye Options",
"images": {
"directory_pattern": "Images directory pattern",
"file_pattern": "Images file pattern"
},
"movies": {
"directory_pattern": "Movies directory pattern",
"file_pattern": "Movies file pattern"
},
"url": "MotionEye UI URL"
},
"title": "Title for this camera (Autodetected from entity)",
"triggers": {
"entities": "Trigger from other entities",
@@ -428,14 +440,15 @@
"whens": {
"past_month": "Past Month",
"past_week": "Past Week",
"today": "Today",
"yesterday": "Yesterday"
"today": "Today",
"yesterday": "Yesterday"
},
"where": "Where"
},
"recording": {
"camera": "Camera",
"duration": "Duration",
"in_progress": "In Progress",
"events": "Events",
"seek": "Seek",
"start": "Start"
+13
View File
@@ -57,6 +57,18 @@
"image": "",
"webrtc-card": "Scheda WebRTC (ovvero la scheda WebRTC di Alexxit)"
},
"motioneye": {
"editor_label": "",
"images": {
"directory_pattern": "",
"file_pattern": ""
},
"movies": {
"directory_pattern": "",
"file_pattern": ""
},
"url": ""
},
"title": "Titolo per questa telecamera (Autoidentificato dall'entità)",
"triggers": {
"entities": "Trigger da altre entità",
@@ -427,6 +439,7 @@
"camera": "",
"duration": "",
"events": "Eventi",
"in_progress": "In corso",
"seek": "Cercare",
"start": ""
},
+13
View File
@@ -57,6 +57,18 @@
"image": "",
"webrtc-card": "Cartão WebRTC (de @AlexxIT)"
},
"motioneye": {
"editor_label": "",
"images": {
"directory_pattern": "",
"file_pattern": ""
},
"movies": {
"directory_pattern": "",
"file_pattern": ""
},
"url": ""
},
"title": "Título para esta câmera (detectado automaticamente pela entidade)",
"triggers": {
"entities": "Acionar a partir de outras entidades",
@@ -427,6 +439,7 @@
"camera": "",
"duration": "",
"events": "Eventos",
"in_progress": "Em andamento",
"seek": "Procurar",
"start": ""
},
-9
View File
@@ -28,14 +28,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
const computeMJPEGStreamUrl = (entity: CameraEntity): string =>
`/api/camera_proxy_stream/${entity.entity_id}?token=${entity.attributes.access_token}`;
const computeObjectId = (entityId: string): string =>
entityId.substr(entityId.indexOf('.') + 1);
const computeStateName = (stateObj: HassEntity): string =>
stateObj.attributes.friendly_name === undefined
? computeObjectId(stateObj.entity_id).replace(/_/g, ' ')
: stateObj.attributes.friendly_name || '';
const STREAM_TYPE_HLS = 'hls';
const STREAM_TYPE_WEB_RTC = 'web_rtc';
@@ -100,7 +92,6 @@ customElements.whenDefined('ha-camera-stream').then(() => {
.src=${typeof this._connected == 'undefined' || this._connected
? computeMJPEGStreamUrl(this.stateObj)
: ''}
.alt=${`Preview of the ${computeStateName(this.stateObj)} camera.`}
/>
`;
}
+1 -1
View File
@@ -57,7 +57,7 @@ customElements.whenDefined('ha-hls-player').then(() => {
}
public isMuted(): boolean {
return this._video?.muted() ?? true;
return this._video?.muted ?? true;
}
public seek(seconds: number): void {
+4 -1
View File
@@ -1,6 +1,7 @@
:host {
width: 100%;
height: auto;
height: 100%;
display: block;
overflow: auto;
// Hide scrollbar: IE and Edge
@@ -11,7 +12,9 @@
--frigate-card-gallery-gap: 3px;
--frigate-card-gallery-columns: 4;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--frigate-card-gallery-columns), minmax(0, 1fr));
grid-auto-rows: min-content;
+3 -1
View File
@@ -1,8 +1,10 @@
@use 'dotdotdot.scss';
:host {
min-height: 100%;
display: block;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
+6
View File
@@ -1,6 +1,12 @@
:host {
display: block;
overflow: hidden;
aspect-ratio: 1 / 1;
display: flex;
justify-content: center;
align-items: center;
}
img {
+7
View File
@@ -6,4 +6,11 @@
paper-toast {
max-width: unset;
min-width: unset;
display: flex;
align-items: center;
}
paper-toast img {
max-height: 24px;
padding-left: 10px;
}
+1
View File
@@ -7,6 +7,7 @@
}
img,
video,
frigate-card-ha-hls-player {
display: block;
width: 100%;
+54 -22
View File
@@ -1,7 +1,6 @@
import {
CallServiceActionConfig,
ConfirmationRestrictionConfig,
CustomActionConfig,
HomeAssistant,
LovelaceCardConfig,
MoreInfoActionConfig,
@@ -95,7 +94,7 @@ const MEDIA_ACTION_POSITIVE_CONDITIONS = [
export type AutoUnmuteCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number];
const ENGINES = ['auto', 'frigate', 'generic'] as const;
const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const;
export class FrigateCardError extends Error {
context?: unknown;
@@ -182,15 +181,13 @@ const moreInfoActionSchema = schemaForType<
action: z.literal('more-info'),
}),
);
const customActionSchema = schemaForType<
CustomActionConfig & ExtendedConfirmationRestrictionConfig
>()(
actionBaseSchema
.extend({
action: z.literal('fire-dom-event'),
})
.passthrough(),
);
const customActionSchema = actionBaseSchema
.extend({
action: z.literal('fire-dom-event'),
})
.passthrough();
const noActionSchema = schemaForType<
NoActionConfig & ExtendedConfirmationRestrictionConfig
>()(
@@ -450,19 +447,29 @@ const jsmpegConfigSchema = z.object({
* Camera configuration section
*/
const cameraConfigDefault = {
live_provider: 'auto' as const,
engine: 'auto' as const,
frigate: {
client_id: 'frigate' as const,
},
dependencies: {
all_cameras: false,
cameras: [],
},
engine: 'auto' as const,
frigate: {
client_id: 'frigate' as const,
},
hide: false,
image: {
refresh_seconds: 1,
},
hide: false,
live_provider: 'auto' as const,
motioneye: {
images: {
directory_pattern: '%Y-%m-%d' as const,
file_pattern: '%H-%M-%S' as const,
},
movies: {
directory_pattern: '%Y-%m-%d' as const,
file_pattern: '%H-%M-%S' as const,
},
},
triggers: {
motion: false,
occupancy: true,
@@ -505,7 +512,6 @@ const cameraConfigSchema = z
engine: z.enum(ENGINES).default('auto'),
frigate: z
.object({
// No URL validation to allow relative URLs within HA (e.g. Frigate addon).
url: z.string().optional(),
client_id: z.string().default(cameraConfigDefault.frigate.client_id),
camera_name: z.string().optional(),
@@ -513,6 +519,35 @@ const cameraConfigSchema = z
zones: z.string().array().optional(),
})
.default(cameraConfigDefault.frigate),
motioneye: z
.object({
url: z.string().optional(),
images: z
.object({
directory_pattern: z
.string()
.includes('%')
.default(cameraConfigDefault.motioneye.images.directory_pattern),
file_pattern: z
.string()
.includes('%')
.default(cameraConfigDefault.motioneye.images.file_pattern),
})
.default(cameraConfigDefault.motioneye.images),
movies: z
.object({
directory_pattern: z
.string()
.includes('%')
.default(cameraConfigDefault.motioneye.movies.directory_pattern),
file_pattern: z
.string()
.includes('%')
.default(cameraConfigDefault.motioneye.movies.file_pattern),
})
.default(cameraConfigDefault.motioneye.movies),
})
.default(cameraConfigDefault.motioneye),
// Live provider options.
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
@@ -1321,10 +1356,7 @@ export const frigateCardConfigSchema = z.object({
// Card ID (used for query string commands). Restrict contents to only values
// that be easily used in a URL.
card_id: z
.string()
.regex(/^\w+$/)
.optional(),
card_id: z.string().regex(/^\w+$/).optional(),
// Stock lovelace card config.
type: z.string(),
+4
View File
@@ -196,3 +196,7 @@ export const isSuperset = (superset: Set<unknown>, subset: Set<unknown>) => {
export const sleep = async (seconds: number) => {
await new Promise((r) => setTimeout(r, seconds * 1000));
};
export const isValidDate = (date: Date): boolean => {
return !isNaN(date.getTime());
}
+22 -13
View File
@@ -10,23 +10,32 @@ export const downloadMedia = async (
cameraManager: CameraManager,
media: ViewMedia,
): Promise<void> => {
const path = cameraManager.getMediaDownloadPath(media);
if (!path) {
const download = await cameraManager.getMediaDownloadPath(hass, media);
if (!download) {
throw new FrigateCardError(localize('error.download_no_media'));
}
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, path);
} catch (e) {
errorToConsole(e as Error);
let finalURL = download.endpoint;
if (download.sign) {
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, download.endpoint);
} catch (e) {
errorToConsole(e as Error);
}
if (!response) {
throw new FrigateCardError(localize('error.download_sign_failed'));
}
finalURL = response;
}
if (!response) {
throw new FrigateCardError(localize('error.download_sign_failed'));
}
// The download attribute only works on the same origin.
// See: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#attributes
const isSameOrigin = new URL(finalURL).origin === window.location.origin;
if (
!isSameOrigin ||
navigator.userAgent.startsWith('Home Assistant/') ||
navigator.userAgent.startsWith('HomeAssistant/')
) {
@@ -36,13 +45,13 @@ export const downloadMedia = async (
// User-agents are specified here:
// - Android: https://github.com/home-assistant/android/blob/master/app/src/main/java/io/homeassistant/companion/android/webview/WebViewActivity.kt#L107
// - iOS: https://github.com/home-assistant/iOS/blob/master/Sources/Shared/API/HAAPI.swift#L75
window.open(response, '_blank');
window.open(finalURL, '_blank');
} else {
// Use the HTML5 download attribute to prevent a new window from
// temporarily opening.
const link = document.createElement('a');
link.setAttribute('download', '');
link.href = response;
link.setAttribute('download', 'download');
link.href = finalURL;
link.click();
link.remove();
}
+12 -13
View File
@@ -11,23 +11,22 @@ export const getEndpointAddressOrDispatchError = async (
endpoint: CameraEndpoint,
expires?: number,
): Promise<string | null> => {
let address: string | null;
if (!endpoint.sign) {
address = endpoint.endpoint;
} else {
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
} catch (e) {
errorToConsole(e as Error);
return null;
}
address = response ? response.replace(/^http/i, 'ws') : null;
return endpoint.endpoint;
}
if (!address) {
let response: string | null | undefined;
try {
response = await homeAssistantSignPath(hass, endpoint.endpoint, expires);
} catch (e) {
errorToConsole(e as Error);
return null;
}
if (!response) {
dispatchErrorMessageEvent(element, localize('error.failed_sign'));
return null;
}
return address;
return response.replace(/^http/i, 'ws');
};
@@ -0,0 +1,158 @@
import { HomeAssistant } from 'custom-card-helpers';
import add from 'date-fns/add';
import { homeAssistantWSRequest } from '..';
import { MemoryRequestCache } from '../../../camera-manager/cache';
import { allPromises } from '../../basic';
import {
BrowseMedia,
browseMediaSchema,
BROWSE_MEDIA_CACHE_SECONDS,
RichBrowseMedia,
} from './types';
type BrowseMediaCache<M> = MemoryRequestCache<string, RichBrowseMedia<M>>;
type RichMetadataGenerator<M> = (
media: BrowseMedia,
parent?: RichBrowseMedia<M>,
) => M | null;
export type BrowseMediaTarget<M> = string | RichBrowseMedia<M>;
type RichBrowseMediaPredicate<M> = (media: RichBrowseMedia<M>) => boolean;
export interface BrowseMediaStep<M> {
// The targets to start the media walk from.
targets: BrowseMediaTarget<M>[];
// All children of the target have the metadata generator applied to them
// first.
metadataGenerator?: RichMetadataGenerator<M>;
// If those children pass this matcher, then they will be included in the
// output.
matcher: RichBrowseMediaPredicate<M>;
// 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>;
}
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;
},
): Promise<RichBrowseMedia<M>[]> {
if (!steps || !steps.length) {
return [];
}
return (
await allPromises(
steps,
async (step) => await this._walkBrowseMedia(hass, step, options),
)
).flat();
}
protected async _walkBrowseMedia(
hass: HomeAssistant,
step: BrowseMediaStep<M>,
options?: {
useCache?: boolean;
},
): Promise<RichBrowseMedia<M>[]> {
const media = await allPromises(
step.targets,
async (target) =>
await this._browseMedia(hass, target, {
useCache: options?.useCache,
metadataGenerator: step.metadataGenerator,
}),
);
const newTargets: RichBrowseMedia<M>[] = [];
for (const parent of media) {
for (const child of parent.children ?? []) {
if (step.matcher(child)) {
newTargets.push(child);
}
}
}
const nextSteps = step.advance ? step.advance(newTargets) : null;
if (!nextSteps || !nextSteps.length) {
return newTargets;
}
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);
}
}
const downstream = await this.walkBrowseMedias(hass, nextSteps, options);
return finished.concat(downstream);
}
protected async _browseMedia(
hass: HomeAssistant,
target: string | RichBrowseMedia<M>,
options?: {
useCache?: boolean;
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;
if (cachedResult) {
return cachedResult;
}
const request = {
type: 'media_source/browse_media',
media_content_id: mediaContentID,
};
const browseMedia = (await homeAssistantWSRequest(
hass,
browseMediaSchema,
request,
)) as RichBrowseMedia<M>;
if (options?.metadataGenerator) {
for (const child of browseMedia.children ?? []) {
child._metadata =
options.metadataGenerator(
child,
typeof target === 'object' ? target : undefined,
) ?? undefined;
}
}
if (options?.useCache ?? true) {
this._cache.set(
mediaContentID,
browseMedia,
add(new Date(), { seconds: BROWSE_MEDIA_CACHE_SECONDS }),
);
}
return browseMedia;
}
}
+41
View File
@@ -0,0 +1,41 @@
import { z } from 'zod';
// Recursive type, cannot use type interference:
// See: https://github.com/colinhacks/zod#recursive-types
//
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L90
export interface BrowseMedia {
title: string;
media_class: string;
media_content_type: string;
media_content_id: string;
can_play: boolean;
can_expand: boolean;
children_media_class?: string | null;
thumbnail: string | null;
children?: BrowseMedia[] | null;
}
export const browseMediaSchema: z.ZodSchema<BrowseMedia> = z.lazy(() =>
z.object({
title: z.string(),
media_class: z.string(),
media_content_type: z.string(),
media_content_id: z.string(),
can_play: z.boolean(),
can_expand: z.boolean(),
children_media_class: z.string().nullable().optional(),
thumbnail: z.string().nullable(),
children: z.array(browseMediaSchema).nullable().optional(),
}),
);
export interface RichBrowseMedia<M> extends BrowseMedia {
_metadata?: M;
children?: RichBrowseMedia<M>[] | null;
}
export const MEDIA_CLASS_VIDEO = 'video' as const;
export const MEDIA_CLASS_IMAGE = 'image' as const;
export const BROWSE_MEDIA_CACHE_SECONDS = 60 as const;
+1
View File
@@ -2,6 +2,7 @@ import { z } from 'zod';
export const entitySchema = z.object({
config_entry_id: z.string().nullable(),
device_id: z.string().nullable(),
disabled_by: z.string().nullable(),
entity_id: z.string(),
hidden_by: z.string().nullable(),
+4 -3
View File
@@ -354,12 +354,13 @@ export const isCardInPanel = (card: HTMLElement): boolean => {
* location will be the Chromecast receiver, not HA).
* @param url The media URL
*/
export const canonicalizeHAURL = (
export function canonicalizeHAURL(hass: ExtendedHomeAssistant, url: string): string;
export function canonicalizeHAURL(
hass: ExtendedHomeAssistant,
url?: string,
): string | null => {
): string | null {
if (hass && url && url.startsWith('/')) {
return hass.hassUrl(url);
}
return url ?? null;
};
}
+8 -6
View File
@@ -31,20 +31,22 @@ export const hideMediaControlsTemporarily = (
};
/**
* Play a piece of media, muting it if necessary.
* @param underlyingPlayer
*
* @param player The Frigate Card Media Player object.
* @param video An underlying video or media player upon which to call play.
*/
export const playMediaMutingIfNecessary = async (
player?: FrigateCardMediaPlayer,
player: FrigateCardMediaPlayer,
video?: HTMLVideoElement | FrigateCardMediaPlayer,
): Promise<void> => {
// If the play call fails, and the media is not already muted, mute it first
// and then try again. This works around some browsers that prevent
// auto-play unless the video is muted.
if (player?.play) {
player.play().catch((ev) => {
if (video?.play) {
video.play().catch((ev) => {
if (ev.name === 'NotAllowedError' && !player.isMuted()) {
player.mute();
player.play().catch();
video.play().catch();
}
});
}
+20 -18
View File
@@ -2,6 +2,11 @@ import { Task } from '@lit-labs/task';
import { ReactiveControllerHost } from '@lit/reactive-element';
import { HomeAssistant } from 'custom-card-helpers';
// See: https://github.com/sindresorhus/is-absolute-url
// Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
// Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
/**
* Fetch a thumbnail URL and return a data URL.
* @param hass Home Assistant object.
@@ -12,10 +17,10 @@ const fetchThumbnail = async (
hass: HomeAssistant,
thumbnailURL: string,
): Promise<string | null> => {
if (!hass) {
if (!hass || !thumbnailURL) {
return null;
}
if (thumbnailURL?.startsWith('data:')) {
if (thumbnailURL.startsWith('data:') || thumbnailURL.match(ABSOLUTE_URL_REGEX)) {
return thumbnailURL;
}
return new Promise((resolve, reject) => {
@@ -57,21 +62,18 @@ export const createFetchThumbnailTask = (
getThumbnailURL: () => string | undefined,
autoRun = true,
): Task<FetchThumbnailTaskArgs, string | null> => {
return new Task(
host,
{
// Do not re-run the task if hass changes, unless it was previously undefined.
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
string | null
> => {
const hass = getHASS();
if (!haveHASS || !hass || !thumbnailURL) {
return null;
}
return fetchThumbnail(hass, thumbnailURL);
},
autoRun: autoRun,
return new Task(host, {
// Do not re-run the task if hass changes, unless it was previously undefined.
args: (): FetchThumbnailTaskArgs => [!!getHASS(), getThumbnailURL()],
task: async ([haveHASS, thumbnailURL]: FetchThumbnailTaskArgs): Promise<
string | null
> => {
const hass = getHASS();
if (!haveHASS || !hass || !thumbnailURL) {
return null;
}
return fetchThumbnail(hass, thumbnailURL);
},
);
autoRun: autoRun,
});
};
+11
View File
@@ -1,5 +1,10 @@
export type ViewMediaType = 'clip' | 'snapshot' | 'recording';
export enum VideoContentType {
MP4 = "mp4",
HLS = "hls",
}
export class ViewMedia {
protected _mediaType: ViewMediaType;
protected _cameraID: string;
@@ -17,6 +22,9 @@ export class ViewMedia {
public getMediaType(): ViewMediaType {
return this._mediaType;
}
public getVideoContentType(): VideoContentType | null {
return null;
}
public getID(): string | null {
return null;
}
@@ -26,6 +34,9 @@ export class ViewMedia {
public getEndTime(): Date | null {
return null;
}
public inProgress(): boolean | null {
return null;
}
public getContentID(): string | null {
return null;
}
+35 -16
View File
@@ -689,15 +689,18 @@ __metadata:
languageName: node
linkType: hard
"@rollup/plugin-image@npm:^2.1.1":
version: 2.1.1
resolution: "@rollup/plugin-image@npm:2.1.1"
"@rollup/plugin-image@npm:^3.0.2":
version: 3.0.2
resolution: "@rollup/plugin-image@npm:3.0.2"
dependencies:
"@rollup/pluginutils": ^3.1.0
mini-svg-data-uri: ^1.2.3
"@rollup/pluginutils": ^5.0.1
mini-svg-data-uri: ^1.4.4
peerDependencies:
rollup: ^1.20.0 || ^2.0.0
checksum: a629c8f22233ca159c23655fdbc3449dab3c939372178ed4462fc9c525cc4ecd8b11fae359eb94be4f769d26f48b85fb18eb16ce1fbc33ed16b6a7c1f84391f6
rollup: ^1.20.0||^2.0.0||^3.0.0
peerDependenciesMeta:
rollup:
optional: true
checksum: f9d8f587f10c51398fa8c23f1543e3073f969cf7e4acd7f401e02a3e3752702a9eb289ddb14009733ce37d04474c549aba9e7d13ebf50e26226266ba51546b69
languageName: node
linkType: hard
@@ -763,6 +766,22 @@ __metadata:
languageName: node
linkType: hard
"@rollup/pluginutils@npm:^5.0.1":
version: 5.0.2
resolution: "@rollup/pluginutils@npm:5.0.2"
dependencies:
"@types/estree": ^1.0.0
estree-walker: ^2.0.2
picomatch: ^2.3.1
peerDependencies:
rollup: ^1.20.0||^2.0.0||^3.0.0
peerDependenciesMeta:
rollup:
optional: true
checksum: edea15e543bebc7dcac3b0ac8bc7b8e8e6dbd46e2864dbe5dd28072de1fbd5b0e10d545a610c0edaa178e8a7ac432e2a2a52e547ece1308471412caba47db8ce
languageName: node
linkType: hard
"@stencil/core@npm:^2.20.0, @stencil/core@npm:^2.3.0":
version: 2.22.2
resolution: "@stencil/core@npm:2.22.2"
@@ -814,7 +833,7 @@ __metadata:
languageName: node
linkType: hard
"@types/estree@npm:*":
"@types/estree@npm:*, @types/estree@npm:^1.0.0":
version: 1.0.0
resolution: "@types/estree@npm:1.0.0"
checksum: 910d97fb7092c6738d30a7430ae4786a38542023c6302b95d46f49420b797f21619cdde11fa92b338366268795884111c2eb10356e4bd2c8ad5b92941e9e6443
@@ -2251,7 +2270,7 @@ __metadata:
languageName: node
linkType: hard
"estree-walker@npm:^2.0.1":
"estree-walker@npm:^2.0.1, estree-walker@npm:^2.0.2":
version: 2.0.2
resolution: "estree-walker@npm:2.0.2"
checksum: 6151e6f9828abe2259e57f5fd3761335bb0d2ebd76dc1a01048ccee22fabcfef3c0859300f6d83ff0d1927849368775ec5a6d265dde2f6de5a1be1721cd94efc
@@ -2418,7 +2437,7 @@ __metadata:
"@lit-labs/task": ^1.1.3
"@rollup/plugin-babel": ^5.3.1
"@rollup/plugin-commonjs": ^22.0.2
"@rollup/plugin-image": ^2.1.1
"@rollup/plugin-image": ^3.0.2
"@rollup/plugin-json": ^4.1.0
"@rollup/plugin-node-resolve": ^13.3.0
"@rollup/plugin-replace": ^4.0.0
@@ -2464,7 +2483,7 @@ __metadata:
vis-util: ^5.0.2
web-dialog: ^0.0.11
xss: ^1.0.14
zod: ^3.20.6
zod: ^3.21.4
languageName: unknown
linkType: soft
@@ -3504,7 +3523,7 @@ __metadata:
languageName: node
linkType: hard
"mini-svg-data-uri@npm:^1.2.3":
"mini-svg-data-uri@npm:^1.4.4":
version: 1.4.4
resolution: "mini-svg-data-uri@npm:1.4.4"
bin:
@@ -5483,9 +5502,9 @@ __metadata:
languageName: node
linkType: hard
"zod@npm:^3.20.6":
version: 3.20.6
resolution: "zod@npm:3.20.6"
checksum: 804b1934b8b5e2fa3750bec90043e8118b201f330b9957b8b768389a971acadf812d2060cf62921086512dab4af691d10490acb03333da58fc485c0791893c89
"zod@npm:^3.21.4":
version: 3.21.4
resolution: "zod@npm:3.21.4"
checksum: f185ba87342ff16f7a06686767c2b2a7af41110c7edf7c1974095d8db7a73792696bcb4a00853de0d2edeb34a5b2ea6a55871bc864227dace682a0a28de33e1f
languageName: node
linkType: hard