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; 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; // 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; export const camerasConfigSchema = cameraConfigSchema.array().optional();