Review queries were issued without a severity filter, so the thumbnail
bar, gallery and timeline showed every Frigate review item -- alerts and
detections alike -- regardless of what a camera is configured to alert
on. Severity could only be narrowed interactively, in the media filter
drawer, and that choice did not persist.
`media.severities` resolves into the default query the same way
`media.reviewed` does, and is overridden by an explicit severity filter
so the media filter drawer keeps working. Unset means all severities, so
existing behaviour is unchanged.
cameras:
- camera_entity: camera.office
media:
severities:
- high
359 lines
11 KiB
TypeScript
359 lines
11 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
import { capabilityKeys } from '../../types';
|
|
import { mediaLayoutConfigSchema } from './camera/media-layout';
|
|
import { ptzCameraConfigDefaults, ptzCameraConfigSchema } from './camera/ptz';
|
|
import { aspectRatioSchema } from './common/aspect-ratio';
|
|
import { eventsMediaTypeSchema } from './common/events-media';
|
|
import { haEventSchema } from './common/ha-event';
|
|
import { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
|
|
import { proxyBaseConfigDefault, proxyBaseConfigSchema } from './common/proxy';
|
|
import { severitySchema } from './common/severity';
|
|
|
|
const CAMERA_TRIGGER_MEDIA_EVENT_TYPES = [
|
|
// An event whether or not it has any media yet associated with it.
|
|
'events',
|
|
|
|
// Specific media availability.
|
|
'clips',
|
|
'snapshots',
|
|
] as const;
|
|
export type CameraTriggerMediaEventType =
|
|
(typeof CAMERA_TRIGGER_MEDIA_EVENT_TYPES)[number];
|
|
|
|
// *************************************************************************
|
|
// Live Provider Configuration
|
|
// *************************************************************************
|
|
|
|
const LIVE_PROVIDERS = [
|
|
'auto',
|
|
'image',
|
|
'ha',
|
|
'jsmpeg',
|
|
'go2rtc',
|
|
'go2rtc-experimental',
|
|
'webrtc-card',
|
|
] as const;
|
|
export type LiveProvider = (typeof LIVE_PROVIDERS)[number];
|
|
|
|
const go2rtcConfigDefault = {
|
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/2313
|
|
metadata_fetch_timeout_seconds: 2,
|
|
};
|
|
|
|
export const GO2RTC_MODES = ['webrtc', 'mse', 'mp4', 'mjpeg'] as const;
|
|
export type Go2RTCMode = (typeof GO2RTC_MODES)[number];
|
|
|
|
const go2rtcConfigSchema = z.object({
|
|
url: z
|
|
.string()
|
|
.transform((input) => input.replace(/\/+$/, ''))
|
|
.optional(),
|
|
modes: z.enum(GO2RTC_MODES).array().optional(),
|
|
stream: z.string().optional(),
|
|
metadata_fetch_timeout_seconds: z
|
|
.number()
|
|
.int()
|
|
.nonnegative()
|
|
.default(go2rtcConfigDefault.metadata_fetch_timeout_seconds),
|
|
});
|
|
|
|
const webrtcCardConfigSchema = z
|
|
.object({
|
|
entity: z.string().optional(),
|
|
url: z.string().optional(),
|
|
})
|
|
.loose();
|
|
|
|
const jsmpegConfigSchema = z.object({
|
|
options: z
|
|
.object({
|
|
// https://github.com/phoboslab/jsmpeg#usage
|
|
audio: z.boolean().optional(),
|
|
video: z.boolean().optional(),
|
|
pauseWhenHidden: z.boolean().optional(),
|
|
disableGl: z.boolean().optional(),
|
|
disableWebAssembly: z.boolean().optional(),
|
|
preserveDrawingBuffer: z.boolean().optional(),
|
|
progressive: z.boolean().optional(),
|
|
throttled: z.boolean().optional(),
|
|
chunkSize: z.number().optional(),
|
|
maxAudioLag: z.number().optional(),
|
|
videoBufferSize: z.number().optional(),
|
|
audioBufferSize: z.number().optional(),
|
|
})
|
|
.optional(),
|
|
});
|
|
|
|
// *************************************************************************
|
|
// Cast Configuration
|
|
// *************************************************************************
|
|
|
|
const castConfigDefault = {
|
|
method: 'standard' as const,
|
|
};
|
|
|
|
const castSchema = z
|
|
.object({
|
|
method: z
|
|
.enum(['standard', 'dashboard'])
|
|
.default(castConfigDefault.method)
|
|
.optional(),
|
|
dashboard: z
|
|
.object({
|
|
dashboard_path: z.string().optional(),
|
|
view_path: z.string().optional(),
|
|
})
|
|
.optional(),
|
|
})
|
|
.refine(
|
|
(cast) =>
|
|
cast.method !== 'dashboard' ||
|
|
(cast.dashboard?.dashboard_path && cast.dashboard?.view_path),
|
|
{
|
|
message:
|
|
'dashboard_path and view_path are required when cast method is "dashboard"',
|
|
path: ['dashboard'],
|
|
},
|
|
);
|
|
|
|
// *************************************************************************
|
|
// Camera Configuration
|
|
// *************************************************************************
|
|
|
|
const ENGINES = [
|
|
'auto',
|
|
'frigate',
|
|
'generic',
|
|
'motioneye',
|
|
'reolink',
|
|
'tplink',
|
|
] as const;
|
|
|
|
export const cameraConfigDefault = {
|
|
dependencies: {
|
|
all_cameras: false,
|
|
cameras: [],
|
|
},
|
|
engine: 'auto' as const,
|
|
frigate: {},
|
|
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,
|
|
},
|
|
},
|
|
reolink: {
|
|
media_resolution: 'low' as const,
|
|
},
|
|
ptz: ptzCameraConfigDefaults,
|
|
triggers: {
|
|
motion: false,
|
|
occupancy: false,
|
|
doorbell: false,
|
|
media_events: [],
|
|
entities: [],
|
|
events: [],
|
|
reviews: {
|
|
severities: ['high' as const],
|
|
description: true,
|
|
},
|
|
},
|
|
proxy: {
|
|
...proxyBaseConfigDefault,
|
|
live: 'auto' as const,
|
|
media: 'auto' as const,
|
|
},
|
|
go2rtc: go2rtcConfigDefault,
|
|
image: imageBaseConfigDefault,
|
|
always_error_if_entity_unavailable: false,
|
|
};
|
|
|
|
const proxyConfigSchema = proxyBaseConfigSchema.extend({
|
|
live: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.live),
|
|
media: z.boolean().or(z.literal('auto')).default(cameraConfigDefault.proxy.media),
|
|
});
|
|
|
|
const rotationSchema = z
|
|
.literal(0)
|
|
.or(z.literal(90))
|
|
.or(z.literal(180))
|
|
.or(z.literal(270));
|
|
export type Rotation = z.infer<typeof rotationSchema>;
|
|
|
|
const cameraDimensionsGridSchema = z.object({
|
|
width_factor: z.number().min(0.1).optional(),
|
|
});
|
|
|
|
const cameraDimensionsSchema = z.object({
|
|
aspect_ratio: aspectRatioSchema.optional(),
|
|
layout: mediaLayoutConfigSchema.optional(),
|
|
rotation: rotationSchema.optional(),
|
|
grid: cameraDimensionsGridSchema.optional(),
|
|
});
|
|
export type CameraDimensionsConfig = z.infer<typeof cameraDimensionsSchema>;
|
|
|
|
// Camera media configuration for default media type in live/timeline views.
|
|
const CAMERA_MEDIA_TYPES = [
|
|
'auto',
|
|
'reviews',
|
|
'events',
|
|
'recordings',
|
|
'folder',
|
|
] as const;
|
|
export type CameraMediaType = (typeof CAMERA_MEDIA_TYPES)[number];
|
|
|
|
const cameraMediaConfigDefault = {
|
|
type: 'auto' as CameraMediaType,
|
|
reviewed: 'unreviewed' as CameraMediaReviewedFilter,
|
|
};
|
|
|
|
const CAMERA_MEDIA_REVIEWED_FILTERS = ['unreviewed', 'reviewed', 'all'] as const;
|
|
export type CameraMediaReviewedFilter = (typeof CAMERA_MEDIA_REVIEWED_FILTERS)[number];
|
|
|
|
const cameraMediaConfigSchema = z.object({
|
|
type: z.enum(CAMERA_MEDIA_TYPES).default(cameraMediaConfigDefault.type),
|
|
events_type: eventsMediaTypeSchema.optional(),
|
|
folders: z.array(z.string()).optional(),
|
|
reviewed: z
|
|
.enum(CAMERA_MEDIA_REVIEWED_FILTERS)
|
|
.default(cameraMediaConfigDefault.reviewed),
|
|
|
|
// Restrict review media to these severities (e.g. `[high]` for Frigate
|
|
// alerts only). Unset means all severities.
|
|
severities: severitySchema.array().min(1).optional(),
|
|
});
|
|
|
|
export const cameraConfigSchema = z
|
|
.looseObject({
|
|
camera_entity: z.string().optional(),
|
|
|
|
// Used for presentation in the UI (autodetected from the entity if
|
|
// specified).
|
|
icon: z.string().optional(),
|
|
title: z.string().optional(),
|
|
|
|
capabilities: z
|
|
.object({
|
|
disable: z.enum(capabilityKeys).array().optional(),
|
|
disable_except: z.enum(capabilityKeys).array().optional(),
|
|
force: z.enum(['2-way-audio']).array().optional(),
|
|
})
|
|
.optional(),
|
|
|
|
// Optional identifier to separate different camera configurations used in
|
|
// this card.
|
|
id: z.string().optional(),
|
|
|
|
dependencies: z
|
|
.object({
|
|
all_cameras: z.boolean().default(cameraConfigDefault.dependencies.all_cameras),
|
|
cameras: z.string().array().default(cameraConfigDefault.dependencies.cameras),
|
|
})
|
|
.default(cameraConfigDefault.dependencies),
|
|
|
|
triggers: z
|
|
.object({
|
|
motion: z.boolean().default(cameraConfigDefault.triggers.motion),
|
|
occupancy: z.boolean().default(cameraConfigDefault.triggers.occupancy),
|
|
doorbell: z.boolean().default(cameraConfigDefault.triggers.doorbell),
|
|
entities: z.string().array().default(cameraConfigDefault.triggers.entities),
|
|
events: haEventSchema.array().default(cameraConfigDefault.triggers.events),
|
|
media_events: z
|
|
.enum(CAMERA_TRIGGER_MEDIA_EVENT_TYPES)
|
|
.array()
|
|
.default(cameraConfigDefault.triggers.media_events),
|
|
reviews: z
|
|
.object({
|
|
severities: severitySchema
|
|
.array()
|
|
.default([...cameraConfigDefault.triggers.reviews.severities]),
|
|
description: z
|
|
.boolean()
|
|
.default(cameraConfigDefault.triggers.reviews.description),
|
|
})
|
|
.default(cameraConfigDefault.triggers.reviews),
|
|
})
|
|
.default(cameraConfigDefault.triggers),
|
|
|
|
// Engine options.
|
|
engine: z.enum(ENGINES).default('auto'),
|
|
frigate: z
|
|
.object({
|
|
url: z.string().optional(),
|
|
client_id: z.string().optional(),
|
|
camera_name: z.string().optional(),
|
|
labels: z.string().array().optional(),
|
|
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),
|
|
reolink: z
|
|
.object({
|
|
url: z.string().optional(),
|
|
media_resolution: z
|
|
.enum(['high', 'low'])
|
|
.default(cameraConfigDefault.reolink.media_resolution),
|
|
})
|
|
.default(cameraConfigDefault.reolink),
|
|
|
|
// Live provider options.
|
|
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
|
|
go2rtc: go2rtcConfigSchema.optional().default(go2rtcConfigDefault),
|
|
image: imageBaseConfigSchema.optional().default(imageBaseConfigDefault),
|
|
jsmpeg: jsmpegConfigSchema.optional(),
|
|
webrtc_card: webrtcCardConfigSchema.optional(),
|
|
|
|
cast: castSchema.optional(),
|
|
|
|
ptz: ptzCameraConfigSchema.default(cameraConfigDefault.ptz),
|
|
|
|
dimensions: cameraDimensionsSchema.optional(),
|
|
|
|
media: cameraMediaConfigSchema.optional(),
|
|
|
|
proxy: proxyConfigSchema.default(cameraConfigDefault.proxy),
|
|
|
|
// See: https://github.com/dermotduffy/advanced-camera-card/issues/1650
|
|
always_error_if_entity_unavailable: z
|
|
.boolean()
|
|
.default(cameraConfigDefault.always_error_if_entity_unavailable),
|
|
})
|
|
.default(cameraConfigDefault);
|
|
export type CameraConfig = z.infer<typeof cameraConfigSchema>;
|
|
|
|
export const camerasConfigSchema = cameraConfigSchema.array().optional();
|