import { CallServiceActionConfig, ConfirmationRestrictionConfig, MoreInfoActionConfig, NavigateActionConfig, NoActionConfig, PerformActionActionConfig, ToggleActionConfig, UrlActionConfig, } from '@dermotduffy/custom-card-helpers'; import { HassServiceTarget } from 'home-assistant-js-websocket'; import { z } from 'zod'; import { MEDIA_CHUNK_SIZE_DEFAULT, MEDIA_CHUNK_SIZE_MAX } from '../const.js'; import { capabilityKeys } from '../types.js'; import { deepRemoveDefaults } from '../utils/zod.js'; import { keyboardShortcutsDefault, keyboardShortcutsSchema, } from './keyboard-shortcuts.js'; import { PTZ_ACTIONS } from './ptz'; // ************************************************************************* // Common Configuration Constants // ************************************************************************* export const BUTTON_SIZE_MIN = 20; export const STATUS_BAR_HEIGHT_MIN = BUTTON_SIZE_MIN; const FRIGATE_MENU_PRIORITY_DEFAULT = 50; export const FRIGATE_MENU_PRIORITY_MAX = 100; export const FRIGATE_STATUS_BAR_PRIORITY_DEFAULT = FRIGATE_MENU_PRIORITY_DEFAULT; export const FRIGATE_STATUS_BAR_PRIORITY_MAX = FRIGATE_MENU_PRIORITY_MAX; export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ 'diagnostics', 'live', 'clip', 'clips', 'snapshot', 'snapshots', 'recording', 'recordings', 'image', 'timeline', ] as const; export type FrigateCardUserSpecifiedView = (typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED)[number]; const FRIGATE_CARD_VIEWS = [ ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'diagnostics', // Media: A generic piece of media (could be clip, snapshot, recording). 'media', ] as const; export type FrigateCardView = (typeof FRIGATE_CARD_VIEWS)[number]; // The default view (may not be supported on all cameras). export const FRIGATE_CARD_VIEW_DEFAULT = 'live' as const; export const MEDIA_ACTION_NEGATIVE_CONDITIONS = ['unselected', 'hidden'] as const; export type LazyUnloadCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; export type AutoPauseCondition = (typeof MEDIA_ACTION_NEGATIVE_CONDITIONS)[number]; const MEDIA_ACTION_POSITIVE_CONDITIONS = ['selected', 'visible'] as const; export type AutoPlayCondition = (typeof MEDIA_ACTION_POSITIVE_CONDITIONS)[number]; const MEDIA_UNMUTE_CONDITIONS = [ ...MEDIA_ACTION_POSITIVE_CONDITIONS, 'microphone', ] as const; export type AutoUnmuteCondition = (typeof MEDIA_UNMUTE_CONDITIONS)[number]; const MEDIA_MUTE_CONDITIONS = [ ...MEDIA_ACTION_NEGATIVE_CONDITIONS, 'microphone', ] as const; export type AutoMuteCondition = (typeof MEDIA_MUTE_CONDITIONS)[number]; const ACTION_PHASES = ['start', 'stop'] as const; export type ActionPhase = (typeof ACTION_PHASES)[number]; const CAMERA_TRIGGER_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 CameraTriggerEventType = (typeof CAMERA_TRIGGER_EVENT_TYPES)[number]; const cardIDRegex = /^[-\w]+$/; // ************************************************************************* // Pan / Zoom // ************************************************************************* export const ZOOM_MIN = 1; export const ZOOM_MAX = 10; const panSchema = z.object({ x: z.number().min(0).max(100).optional(), y: z.number().min(0).max(100).optional(), }); const zoomSchema = z.number().min(ZOOM_MIN).max(ZOOM_MAX); // ************************************************************************* // View Display Mode // ************************************************************************* const viewDisplayModeSchema = z.enum(['single', 'grid']); export type ViewDisplayMode = z.infer; const viewDisplaySchema = z .object({ mode: viewDisplayModeSchema, grid_selected_width_factor: z.number().min(0).optional(), grid_max_columns: z.number().min(0).optional(), grid_columns: z.number().min(0).optional(), }) .optional(); export type ViewDisplayConfig = z.infer; // ************************************************************************* // Actions // // Declare schemas to existing types: // - https://github.com/colinhacks/zod/issues/372#issuecomment-826380330 // ************************************************************************* const schemaForType = () => // eslint-disable-next-line @typescript-eslint/no-explicit-any >(arg: S) => { return arg; }; // https://www.home-assistant.io/dashboards/actions/#options-for-confirmation const actionBaseSchema = z.object({ confirmation: z .boolean() .or( z.object({ text: z.string().optional(), exemptions: z .object({ user: z.string(), }) .array() .optional(), }), ) .optional(), }); // HA accepts either a boolean or a ConfirmationRestrictionConfig object. // `custom-card-helpers` currently only supports the latter. For maximum // compatibility, this card supports what HA supports. interface ExtendedConfirmationRestrictionConfig { confirmation?: boolean | ConfirmationRestrictionConfig; } const toggleActionSchema = schemaForType< ToggleActionConfig & ExtendedConfirmationRestrictionConfig >()( actionBaseSchema.extend({ action: z.literal('toggle'), }), ); const targetSchema = schemaForType()( z.object({ entity_id: z.string().optional(), device_id: z.string().optional(), area_id: z.string().optional(), }), ); const performActionActionSchema = schemaForType< PerformActionActionConfig & ExtendedConfirmationRestrictionConfig >()( actionBaseSchema.extend({ action: z.literal('perform-action'), perform_action: z.string(), data: z.object({}).passthrough().optional(), target: targetSchema.optional(), }), ); // Note: call-service is deprecated and will eventually go away. Please use // perform-action instead. // See: https://www.home-assistant.io/blog/2024/08/07/release-20248/#goodbye-service-calls-hello-actions- const callServiceActionSchema = schemaForType< CallServiceActionConfig & ExtendedConfirmationRestrictionConfig >()( actionBaseSchema.extend({ action: z.literal('call-service'), service: z.string(), data: z.object({}).passthrough().optional(), target: targetSchema.optional(), }), ); const navigateActionSchema = schemaForType< NavigateActionConfig & ExtendedConfirmationRestrictionConfig >()( actionBaseSchema.extend({ action: z.literal('navigate'), navigation_path: z.string(), }), ); const urlActionSchema = schemaForType< UrlActionConfig & ExtendedConfirmationRestrictionConfig >()( actionBaseSchema.extend({ action: z.literal('url'), url_path: z.string(), }), ); const moreInfoActionSchema = schemaForType< MoreInfoActionConfig & ExtendedConfirmationRestrictionConfig >()( actionBaseSchema.extend({ action: z.literal('more-info'), }), ); const customActionSchema = actionBaseSchema .extend({ action: z.literal('fire-dom-event'), }) .passthrough(); const noActionSchema = schemaForType< NoActionConfig & ExtendedConfirmationRestrictionConfig >()( actionBaseSchema.extend({ action: z.literal('none'), }), ); export const frigateCardCustomActionsBaseSchema = customActionSchema.extend({ action: z .literal('custom:frigate-card-action') // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. .transform((): 'fire-dom-event' => 'fire-dom-event') .or(z.literal('fire-dom-event')), // Card this command is intended for. card_id: z .string() .regex(cardIDRegex, 'card_id parameter can only contain [a-z][A-Z][0-9_]-') .optional(), }); // ************************************************************************* // Custom Actions // ************************************************************************* const FRIGATE_CARD_GENERAL_ACTIONS = [ 'camera_ui', 'default', 'download', 'expand', 'fullscreen', 'live_substream_off', 'live_substream_on', 'menu_toggle', 'microphone_connect', 'microphone_disconnect', 'microphone_mute', 'microphone_unmute', 'mute', 'pause', 'play', 'screenshot', 'unmute', ] as const; export type FrigateCardGeneralAction = (typeof FRIGATE_CARD_GENERAL_ACTIONS)[number]; const viewActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED), }); export type ViewActionConfig = z.infer; const generalActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.enum(FRIGATE_CARD_GENERAL_ACTIONS), }); export type GeneralActionConfig = z.infer; const cameraSelectActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('camera_select'), camera: z.string().optional(), triggered: z.boolean().optional(), }); export type CameraSelectActionConfig = z.infer; const substreamSelectActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('live_substream_select'), camera: z.string(), }); export type SubstreamSelectActionConfig = z.infer< typeof substreamSelectActionConfigSchema >; const mediaPlayerActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('media_player'), media_player: z.string(), media_player_action: z.enum(['play', 'stop']), }); export type MediaPlayerActionConfig = z.infer; const viewDisplayModeActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('display_mode_select'), display_mode: viewDisplayModeSchema, }); export type DisplayModeActionConfig = z.infer; const ptzActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('ptz'), camera: z.string().optional(), ptz_action: z.enum(PTZ_ACTIONS).optional(), ptz_phase: z.enum(ACTION_PHASES).optional(), ptz_preset: z.string().optional(), }); export type PTZActionConfig = z.infer; const ptzDigitalActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('ptz_digital'), target_id: z.string().optional(), absolute: z .object({ zoom: zoomSchema.optional(), pan: panSchema.optional(), }) .optional(), ptz_action: z.enum(PTZ_ACTIONS).optional(), ptz_phase: z.enum(ACTION_PHASES).optional(), }); export type PTZDigitialActionConfig = z.infer; const ptzMultiActionSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('ptz_multi'), target_id: z.string().optional(), ptz_action: z.enum(PTZ_ACTIONS).optional(), ptz_phase: z.enum(ACTION_PHASES).optional(), ptz_preset: z.string().optional(), }); export type PTZMultiActionConfig = z.infer; const ptzControlsActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('ptz_controls'), enabled: z.boolean(), }); export type PTZControlsActionConfig = z.infer; const timeDeltaSchema = z.object({ ms: z.number().optional(), s: z.number().optional(), m: z.number().optional(), h: z.number().optional(), }); export type TimeDelta = z.infer; const sleepActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('sleep'), duration: timeDeltaSchema.optional().default({ s: 1 }), }); export type SleepActionConfig = z.infer; const statusBarActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('status_bar'), status_bar_action: z.enum(['add', 'remove', 'reset']), // This needs to be lazily evaluated since statusBarItemSchema may itself // contain actions. items: z .lazy(() => statusBarItemSchema) .array() .optional(), }); export type StatusBarActionConfig = z.infer; const LOG_ACTIONS_LEVELS = ['debug', 'info', 'warn', 'error'] as const; export type LogActionLevel = (typeof LOG_ACTIONS_LEVELS)[number]; const logActionConfigSchema = frigateCardCustomActionsBaseSchema.extend({ frigate_card_action: z.literal('log'), message: z.string(), level: z.enum(LOG_ACTIONS_LEVELS).default('info'), }); export type LogActionConfig = z.infer; export const frigateCardCustomActionSchema = z.union([ cameraSelectActionConfigSchema, generalActionConfigSchema, substreamSelectActionConfigSchema, logActionConfigSchema, mediaPlayerActionConfigSchema, ptzActionConfigSchema, ptzDigitalActionConfigSchema, ptzMultiActionSchema, ptzControlsActionConfigSchema, viewActionConfigSchema, viewDisplayModeActionConfigSchema, sleepActionConfigSchema, statusBarActionConfigSchema, ]); export type FrigateCardCustomAction = z.infer; // Cannot use discriminatedUnion since frigateCardCustomActionSchema uses a // transform on the discriminated union key. export const actionSchema = z.union([ toggleActionSchema, callServiceActionSchema, performActionActionSchema, navigateActionSchema, urlActionSchema, moreInfoActionSchema, noActionSchema, customActionSchema, frigateCardCustomActionSchema, ]); export type ActionType = z.infer; const actionsBaseSchema = z .object({ tap_action: actionSchema.or(actionSchema.array()).optional(), hold_action: actionSchema.or(actionSchema.array()).optional(), double_tap_action: actionSchema.or(actionSchema.array()).optional(), start_tap_action: actionSchema.or(actionSchema.array()).optional(), end_tap_action: actionSchema.or(actionSchema.array()).optional(), }) // Passthrough to allow (at least) entity/camera_image to go through. This // card doesn't need these attributes, but handleAction() in // custom_card_helpers may depending on how the action is configured. .passthrough(); export type Actions = z.infer; export type ActionsConfig = Actions & { camera_image?: string; entity?: string; }; const actionsSchema = z.object({ actions: actionsBaseSchema.optional(), }); const elementsBaseSchema = actionsBaseSchema.extend({ style: z.record(z.string().nullable().or(z.undefined()).or(z.number())).optional(), title: z.string().nullable().optional(), }); // ************************************************************************* // Picture Elements // // All picture element types are validated (not just the Frigate card custom // ones) as a convenience to present the user with a consistent error display // up-front regardless of where they made their error. // ************************************************************************* // https://www.home-assistant.io/lovelace/picture-elements/#state-badge const stateBadgeIconSchema = elementsBaseSchema.extend({ type: z.literal('state-badge'), entity: z.string(), }); // https://www.home-assistant.io/lovelace/picture-elements/#state-icon const stateIconSchema = elementsBaseSchema.extend({ type: z.literal('state-icon'), entity: z.string(), icon: z.string().optional(), state_color: z.boolean().default(true), }); // https://www.home-assistant.io/lovelace/picture-elements/#state-label const stateLabelSchema = elementsBaseSchema.extend({ type: z.literal('state-label'), entity: z.string(), attribute: z.string().optional(), prefix: z.string().optional(), suffix: z.string().optional(), }); // https://www.home-assistant.io/lovelace/picture-elements/#service-call-button const serviceCallButtonSchema = elementsBaseSchema.extend({ type: z.literal('service-button'), // Title is required for service button. title: z.string(), service: z.string(), service_data: z.object({}).passthrough().optional(), }); // https://www.home-assistant.io/lovelace/picture-elements/#icon-element const iconSchema = elementsBaseSchema.extend({ type: z.literal('icon'), icon: z.string(), entity: z.string().optional(), }); // https://www.home-assistant.io/lovelace/picture-elements/#image-element const imageSchema = elementsBaseSchema.extend({ type: z.literal('image'), entity: z.string().optional(), image: z.string().optional(), camera_image: z.string().optional(), camera_view: z.string().optional(), state_image: z.object({}).passthrough().optional(), filter: z.string().optional(), state_filter: z.object({}).passthrough().optional(), aspect_ratio: z.string().optional(), }); // https://www.home-assistant.io/dashboards/conditional/#state const stateConditionSchema = z.object({ // If the condition is not specified, a state condition is assumed. This // allows the syntax to match a picture elements conditional: // https://www.home-assistant.io/dashboards/picture-elements/#conditional-element condition: z.literal('state').optional(), entity: z.string(), state: z.string().or(z.string().array()).optional(), state_not: z.string().or(z.string().array()).optional(), }); // https://www.home-assistant.io/dashboards/conditional/#numeric-state const numericStateConditionSchema = z.object({ condition: z.literal('numeric_state'), entity: z.string(), above: z.number().optional(), below: z.number().optional(), }); // https://www.home-assistant.io/dashboards/conditional/#screen const screenConditionSchema = z.object({ condition: z.literal('screen'), media_query: z.string(), }); // https://www.home-assistant.io/dashboards/conditional/#user const usersConditionSchema = z.object({ condition: z.literal('user'), users: z.string().array().min(1), }); const stockConditionSchema = z.discriminatedUnion('condition', [ stateConditionSchema, numericStateConditionSchema, screenConditionSchema, usersConditionSchema, ]); // https://www.home-assistant.io/lovelace/picture-elements/#image-element export const conditionalSchema = z.object({ type: z.literal('conditional'), conditions: stockConditionSchema.array(), elements: z.lazy(() => pictureElementsSchema), }); // https://www.home-assistant.io/lovelace/picture-elements/#custom-elements export const customSchema = z .object({ // Insist that Frigate card custom elements are handled by other schemas. type: z.string().superRefine((val, ctx) => { if (!val.match(/^custom:(?!frigate-card).+/)) { ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Frigate-card custom elements must match specific schemas', fatal: true, }); } }), }) .passthrough(); // ************************************************************************* // Custom Element Configuration: Menu // ************************************************************************* const menuBaseSchema = z.object({ enabled: z.boolean().default(true).optional(), priority: z .number() .min(0) .max(FRIGATE_MENU_PRIORITY_MAX) .default(FRIGATE_MENU_PRIORITY_DEFAULT) .optional(), alignment: z.enum(['matching', 'opposing']).default('matching').optional(), icon: z.string().optional(), permanent: z.boolean().default(false).optional(), }); const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({ type: z.literal('custom:frigate-card-menu-icon'), }); export type MenuIcon = z.infer; const menuStateIconSchema = menuBaseSchema .merge(stateIconSchema) .extend({ type: z.literal('custom:frigate-card-menu-state-icon'), }) .merge(menuBaseSchema); export type MenuStateIcon = z.infer; const menuSubmenuItemSchema = elementsBaseSchema.extend({ entity: z.string().optional(), icon: z.string().optional(), state_color: z.boolean().default(true), selected: z.boolean().default(false), subtitle: z.string().optional(), enabled: z.boolean().default(true), }); export type MenuSubmenuItem = z.infer; const menuSubmenuSchema = menuBaseSchema.merge(iconSchema).extend({ type: z.literal('custom:frigate-card-menu-submenu'), items: menuSubmenuItemSchema.array(), }); export type MenuSubmenu = z.infer; const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({ type: z.literal('custom:frigate-card-menu-submenu-select'), options: z.record(menuSubmenuItemSchema.deepPartial()).optional(), }); export type MenuSubmenuSelect = z.infer; export type MenuItem = MenuIcon | MenuStateIcon | MenuSubmenu | MenuSubmenuSelect; // ************************************************************************* // Custom Element Configuration: Status bar // ************************************************************************* const statusBarItemBaseSchema = z.object({ enabled: z.boolean().default(true).optional(), priority: z .number() .min(0) .max(FRIGATE_STATUS_BAR_PRIORITY_MAX) .default(FRIGATE_STATUS_BAR_PRIORITY_DEFAULT) .optional(), }); const statusBarItemElementsBaseSchema = statusBarItemBaseSchema.extend({ sufficient: z.boolean().default(false).optional(), exclusive: z.boolean().default(false).optional(), expand: z.boolean().default(false).optional(), actions: actionsBaseSchema.optional(), }); const statusBarIconItemSchema = statusBarItemElementsBaseSchema.extend({ type: z.literal('custom:frigate-card-status-bar-icon'), icon: z.string(), }); export type StatusBarIcon = z.infer; const statusBarImageItemSchema = statusBarItemElementsBaseSchema.extend({ type: z.literal('custom:frigate-card-status-bar-image'), image: z.string(), }); export type StatusBarImage = z.infer; const statusBarStringItemSchema = statusBarItemElementsBaseSchema.extend({ type: z.literal('custom:frigate-card-status-bar-string'), string: z.string(), }); export type StatusBarString = z.infer; const statusBarItemSchema = z.union([ statusBarIconItemSchema, statusBarImageItemSchema, statusBarStringItemSchema, ]); export type StatusBarItem = z.infer; // ************************************************************************* // Custom Element Configuration: Conditions // ************************************************************************* const viewConditionSchema = z.object({ condition: z.literal('view'), views: z.string().array(), }); const fullscreenConditionSchema = z.object({ condition: z.literal('fullscreen'), fullscreen: z.boolean(), }); const expandConditionSchema = z.object({ condition: z.literal('expand'), expand: z.boolean(), }); const cameraConditionSchema = z.object({ condition: z.literal('camera'), cameras: z.string().array(), }); const mediaLoadedConditionSchema = z.object({ condition: z.literal('media_loaded'), media_loaded: z.boolean(), }); const displayModeConditionSchema = z.object({ condition: z.literal('display_mode'), display_mode: viewDisplayModeSchema, }); const triggeredConditionSchema = z.object({ condition: z.literal('triggered'), triggered: z.string().array(), }); const interactionConditionSchema = z.object({ condition: z.literal('interaction'), interaction: z.boolean(), }); const microphoneConditionSchema = z.object({ condition: z.literal('microphone'), connected: z.boolean().optional(), muted: z.boolean().optional(), }); const keyConditionSchema = z.object({ condition: z.literal('key'), key: z.string(), state: z.enum(['down', 'up']).optional(), ctrl: z.boolean().optional(), shift: z.boolean().optional(), alt: z.boolean().optional(), meta: z.boolean().optional(), }); export const frigateCardConditionSchema = z.discriminatedUnion('condition', [ // Stock conditions: stateConditionSchema, numericStateConditionSchema, screenConditionSchema, usersConditionSchema, // Custom conditions: viewConditionSchema, fullscreenConditionSchema, expandConditionSchema, cameraConditionSchema, mediaLoadedConditionSchema, displayModeConditionSchema, triggeredConditionSchema, interactionConditionSchema, microphoneConditionSchema, keyConditionSchema, ]); export type FrigateCardCondition = z.infer; export const frigateConditionalSchema = z.object({ type: z.literal('custom:frigate-card-conditional'), conditions: frigateCardConditionSchema.array(), elements: z.lazy(() => pictureElementsSchema), }); export type FrigateConditional = z.infer; // ************************************************************************* // Custom Element Configuration: Stock Picture Elements + Custom // ************************************************************************* // Cannot use discriminatedUnion since customSchema uses a superRefine, which // causes false rejections. const pictureElementSchema = z.union([ conditionalSchema, customSchema, frigateConditionalSchema, iconSchema, imageSchema, menuIconSchema, menuStateIconSchema, menuSubmenuSchema, menuSubmenuSelectSchema, serviceCallButtonSchema, stateBadgeIconSchema, stateIconSchema, stateLabelSchema, statusBarIconItemSchema, statusBarImageItemSchema, statusBarStringItemSchema, ]); const pictureElementsSchema = pictureElementSchema.array().optional(); export type PictureElements = z.infer; // ************************************************************************* // Media Layout Configuration // ************************************************************************* const mediaLayoutConfigSchema = z.object({ fit: z.enum(['contain', 'cover', 'fill']).optional(), position: z .object({ x: z.number().min(0).max(100).optional(), y: z.number().min(0).max(100).optional(), }) .optional(), view_box: z .object({ bottom: z.number().min(0).max(100).optional().default(0), left: z.number().min(0).max(100).optional().default(0), right: z.number().min(0).max(100).optional().default(0), top: z.number().min(0).max(100).optional().default(0), }) .optional(), pan: panSchema.optional(), zoom: zoomSchema.optional(), }); export type MediaLayoutConfig = z.infer; // ************************************************************************* // Aspect Ratio Configuration // ************************************************************************* const aspectRatioSchema = z .number() .array() .length(2) .or( z .string() .regex(/^\s*\d+\.?\d*\s*[:/]\s*\d+\.?\d*\s*$/) .transform((input) => input.split(/[:\/]/).map((d) => Number(d))), ); // ************************************************************************* // PTZ Configuration // ************************************************************************* const ptzCameraConfigDefaults = { r2c_delay_between_calls_seconds: 0.5, c2r_delay_between_calls_seconds: 0.2, }; // To avoid lots of YAML duplication, provide an easy way to just specify the // service data as actions for each PTZ action, and it will be preprocessed // into the full form. This also provides compatability with the AlexIT/WebRTC // PTZ configuration. const dataPTZFormatToFullFormat = function (suffix: string): (data: unknown) => unknown { return (data) => { if (!data || typeof data !== 'object' || !data['service']) { return data; } const out = { ...data }; Object.keys(data).forEach((key) => { const match = key.match(/^data_(.+)$/); const name = match?.[1]; if (name && !(`${suffix}${name}` in data)) { out[`${suffix}${name}`] = { action: 'perform-action', perform_action: data['service'], data: data[key], }; delete out[key]; delete out['service']; } }); return out; }; }; const ptzCameraConfigSchema = z.preprocess( dataPTZFormatToFullFormat('actions_'), z .object({ actions_left: performActionActionSchema.optional(), actions_left_start: performActionActionSchema.optional(), actions_left_stop: performActionActionSchema.optional(), actions_right: performActionActionSchema.optional(), actions_right_start: performActionActionSchema.optional(), actions_right_stop: performActionActionSchema.optional(), actions_up: performActionActionSchema.optional(), actions_up_start: performActionActionSchema.optional(), actions_up_stop: performActionActionSchema.optional(), actions_down: performActionActionSchema.optional(), actions_down_start: performActionActionSchema.optional(), actions_down_stop: performActionActionSchema.optional(), actions_zoom_in: performActionActionSchema.optional(), actions_zoom_in_start: performActionActionSchema.optional(), actions_zoom_in_stop: performActionActionSchema.optional(), actions_zoom_out: performActionActionSchema.optional(), actions_zoom_out_start: performActionActionSchema.optional(), actions_zoom_out_stop: performActionActionSchema.optional(), // The number of seconds between subsequent relative calls when converting a // relative request into a continuous request. r2c_delay_between_calls_seconds: z .number() .default(ptzCameraConfigDefaults.r2c_delay_between_calls_seconds), // The number of seconds between the start/stop call when converting a // continuous request into a relative request. c2r_delay_between_calls_seconds: z .number() .default(ptzCameraConfigDefaults.c2r_delay_between_calls_seconds), presets: z .preprocess( dataPTZFormatToFullFormat(''), z.union([ z.record(performActionActionSchema), // This is used by the data_ style of action. z.object({ service: z.string().optional() }), ]), ) .optional(), // This is used by the data_ style of action. service: z.string().optional(), }) // We allow passthrough as there may be user-configured presets as "actions_" . .passthrough(), ); const ptzControlsDefaults = { orientation: 'horizontal' as const, mode: 'auto' as const, hide_pan_tilt: false, hide_zoom: false, hide_home: false, position: 'bottom-right' as const, }; export const ptzControlsConfigSchema = z.object({ mode: z.enum(['off', 'auto', 'on']).default(ptzControlsDefaults.mode), position: z .enum(['top-left', 'top-right', 'bottom-left', 'bottom-right']) .default(ptzControlsDefaults.position), orientation: z .enum(['vertical', 'horizontal']) .default(ptzControlsDefaults.orientation), hide_pan_tilt: z.boolean().default(ptzControlsDefaults.hide_pan_tilt), hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom), hide_home: z.boolean().default(ptzControlsDefaults.hide_home), style: z.object({}).passthrough().optional(), }); export type PTZControlsConfig = z.infer; // ************************************************************************* // Image Configuration // Image config base options are used both for the `image` live provider and the // `image` card view. // ************************************************************************* const imageConfigDefault = { mode: 'auto' as const, refresh_seconds: 1, }; const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const; export type ImageMode = (typeof IMAGE_MODES)[number]; const imageBaseConfigSchema = z.object({ mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode), refresh_seconds: z.number().min(0).default(imageConfigDefault.refresh_seconds), url: z.string().optional(), entity: z.string().optional(), entity_parameters: z.string().optional(), }); const imageConfigSchema = imageBaseConfigSchema .merge(actionsSchema) .default(imageConfigDefault); export type ImageViewConfig = z.infer; // ************************************************************************* // Thumbnail Configuration // ************************************************************************* // The min/max width thumbnail (Frigate returns a maximum of 175px). export const THUMBNAIL_WIDTH_MAX = 175; export const THUMBNAIL_WIDTH_MIN = 75; const thumbnailControlsBaseDefaults = { size: 100, show_details: true, show_favorite_control: true, show_timeline_control: true, show_download_control: true, }; // Configuration for the actual rendered thumbnail. const thumbnailsControlBaseSchema = z.object({ size: z .number() .min(THUMBNAIL_WIDTH_MIN) .max(THUMBNAIL_WIDTH_MAX) .default(thumbnailControlsBaseDefaults.size), show_details: z.boolean().default(thumbnailControlsBaseDefaults.show_details), show_favorite_control: z .boolean() .default(thumbnailControlsBaseDefaults.show_favorite_control), show_timeline_control: z .boolean() .default(thumbnailControlsBaseDefaults.show_timeline_control), show_download_control: z .boolean() .default(thumbnailControlsBaseDefaults.show_download_control), }); export type ThumbnailsControlBaseConfig = z.infer; const thumbnailControlsDefaults = { ...thumbnailControlsBaseDefaults, mode: 'right' as const, }; const thumbnailsControlSchema = thumbnailsControlBaseSchema.extend({ mode: z .enum(['none', 'above', 'below', 'left', 'right']) .default(thumbnailControlsDefaults.mode), }); export type ThumbnailsControlConfig = z.infer; // ************************************************************************* // Event Media Type Configuration // ************************************************************************* const eventsMediaTypeSchema = z.enum(['all', 'clips', 'snapshots']); // ************************************************************************* // Timeline Configuration // ************************************************************************* const timelineCoreConfigDefault = { clustering_threshold: 3, events_media_type: 'all' as const, window_seconds: 60 * 60, show_recordings: true, style: 'stack' as const, pan_mode: 'pan' as const, }; const timelinePanModeSchema = z.enum(['pan', 'seek', 'seek-in-media', 'seek-in-camera']); export type TimelinePanMode = z.infer; const timelineCoreConfigSchema = z.object({ clustering_threshold: z .number() .optional() .default(timelineCoreConfigDefault.clustering_threshold), events_media_type: eventsMediaTypeSchema .optional() .default(timelineCoreConfigDefault.events_media_type), window_seconds: z .number() .min(1 * 60) .max(24 * 60 * 60) .optional() .default(timelineCoreConfigDefault.window_seconds), show_recordings: z .boolean() .optional() .default(timelineCoreConfigDefault.show_recordings), style: z.enum(['stack', 'ribbon']).optional().default(timelineCoreConfigDefault.style), pan_mode: timelinePanModeSchema.optional().default(timelineCoreConfigDefault.pan_mode), }); export type TimelineCoreConfig = z.infer; const miniTimelineConfigDefault = { ...timelineCoreConfigDefault, mode: 'none' as const, // Mini-timeline defaults to ribbon style. style: 'ribbon' as const, }; const miniTimelineConfigSchema = timelineCoreConfigSchema.extend({ mode: z.enum(['none', 'above', 'below']).default(miniTimelineConfigDefault.mode), style: timelineCoreConfigSchema.shape.style.default(miniTimelineConfigDefault.style), }); export type MiniTimelineControlConfig = z.infer; const timelineConfigDefault = { ...timelineCoreConfigDefault, controls: { thumbnails: thumbnailControlsDefaults, }, }; const timelineConfigSchema = timelineCoreConfigSchema .extend({ controls: z .object({ thumbnails: thumbnailsControlSchema.default( timelineConfigDefault.controls.thumbnails, ), }) .default(timelineConfigDefault.controls), }) .default(timelineConfigDefault); export type TimelineConfig = z.infer; // ************************************************************************* // Next/Previous Control Configuration // ************************************************************************* const nextPreviousControlConfigSchema = z.object({ style: z.enum(['none', 'chevrons', 'icons', 'thumbnails']), size: z.number().min(BUTTON_SIZE_MIN), }); export type NextPreviousControlConfig = z.infer; // ************************************************************************* // Carousel Transition Configuration // ************************************************************************* const transitionEffectConfigSchema = z.enum(['none', 'slide']); export type TransitionEffect = z.infer; // ************************************************************************* // Live Configuration // ************************************************************************* const LIVE_PROVIDERS = [ 'auto', 'image', 'ha', 'jsmpeg', 'go2rtc', 'webrtc-card', ] as const; export type LiveProvider = (typeof LIVE_PROVIDERS)[number]; const microphoneConfigDefault = { always_connected: false, disconnect_seconds: 90, mute_after_microphone_mute_seconds: 60, }; const microphoneConfigSchema = z .object({ always_connected: z.boolean().default(microphoneConfigDefault.always_connected), disconnect_seconds: z .number() .min(0) .default(microphoneConfigDefault.disconnect_seconds), mute_after_microphone_mute_seconds: z .number() .min(0) .default(microphoneConfigDefault.mute_after_microphone_mute_seconds), }) .default(microphoneConfigDefault); export type MicrophoneConfig = z.infer; const go2rtcConfigSchema = z.object({ url: z .string() .transform((input) => input.replace(/\/+$/, '')) .optional(), host: z.string().optional(), modes: z.enum(['webrtc', 'mse', 'mp4', 'mjpeg']).array().optional(), stream: z.string().optional(), }); const webrtcCardConfigSchema = z .object({ entity: z.string().optional(), url: z.string().optional(), }) .passthrough(); 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(), }); const liveThumbnailControlsDefaults = { ...thumbnailControlsDefaults, media_type: 'events' as const, events_media_type: 'all' as const, }; const liveConfigDefault = { auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS], auto_pause: [], auto_mute: [...MEDIA_MUTE_CONDITIONS], auto_unmute: ['microphone' as const], preload: false, lazy_load: true, lazy_unload: [], draggable: true, zoomable: true, transition_effect: 'slide' as const, show_image_during_load: true, mode: 'single' as const, controls: { builtin: true, next_previous: { size: 48, style: 'chevrons' as const, }, ptz: ptzControlsDefaults, thumbnails: liveThumbnailControlsDefaults, timeline: miniTimelineConfigDefault, }, microphone: { ...microphoneConfigDefault, }, }; const livethumbnailsControlSchema = thumbnailsControlSchema.extend({ media_type: z .enum(['events', 'recordings']) .default(liveConfigDefault.controls.thumbnails.media_type), events_media_type: eventsMediaTypeSchema.default( liveConfigDefault.controls.thumbnails.events_media_type, ), }); const liveConfigSchema = z .object({ auto_pause: z .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) .array() .default(liveConfigDefault.auto_pause), auto_play: z .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) .array() .default(liveConfigDefault.auto_play), auto_mute: z .enum(MEDIA_MUTE_CONDITIONS) .array() .default(liveConfigDefault.auto_mute), auto_unmute: z .enum(MEDIA_UNMUTE_CONDITIONS) .array() .default(liveConfigDefault.auto_unmute), controls: z .object({ builtin: z.boolean().default(liveConfigDefault.controls.builtin), next_previous: nextPreviousControlConfigSchema .extend({ // Live cannot show thumbnails, remove that option. style: z .enum(['none', 'chevrons', 'icons']) .default(liveConfigDefault.controls.next_previous.style), size: nextPreviousControlConfigSchema.shape.size.default( liveConfigDefault.controls.next_previous.size, ), }) .default(liveConfigDefault.controls.next_previous), ptz: ptzControlsConfigSchema.default(liveConfigDefault.controls.ptz), thumbnails: livethumbnailsControlSchema.default( liveConfigDefault.controls.thumbnails, ), timeline: miniTimelineConfigSchema.default(liveConfigDefault.controls.timeline), }) .default(liveConfigDefault.controls), display: viewDisplaySchema, draggable: z.boolean().default(liveConfigDefault.draggable), lazy_load: z.boolean().default(liveConfigDefault.lazy_load), lazy_unload: z .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) .array() .default(liveConfigDefault.lazy_unload), microphone: microphoneConfigSchema.default(liveConfigDefault.microphone), preload: z.boolean().default(liveConfigDefault.preload), show_image_during_load: z .boolean() .default(liveConfigDefault.show_image_during_load), transition_effect: transitionEffectConfigSchema.default( liveConfigDefault.transition_effect, ), zoomable: z.boolean().default(liveConfigDefault.zoomable), }) .merge(actionsSchema) .default(liveConfigDefault); export type LiveConfig = z.infer; // This schema is used when the live config needs to be overridden (see // `live.ts`). Overrides will always be "relative" to the config root, so this // schema maintains that 'depth' from the root but without the other // requirements that frigateCardConfigSchema has. Without this, overrides // calculated in `live.ts` would fail since cameras/type are not provided (as // these are mandatory parameters in the full config). export const liveConfigAbsoluteRootSchema = z.object({ live: liveConfigSchema, }); // ************************************************************************* // 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(), }); // ************************************************************************* // Camera Configuration // ************************************************************************* const ENGINES = ['auto', 'frigate', 'generic', 'motioneye'] as const; const cameraConfigDefault = { dependencies: { all_cameras: false, cameras: [], }, engine: 'auto' as const, frigate: { client_id: 'frigate' as const, }, 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, }, }, ptz: ptzCameraConfigDefaults, triggers: { motion: false, occupancy: false, events: [...CAMERA_TRIGGER_EVENT_TYPES], entities: [], }, }; export const cameraConfigSchema = z .object({ 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(), }) .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), entities: z.string().array().default(cameraConfigDefault.triggers.entities), events: z .enum(CAMERA_TRIGGER_EVENT_TYPES) .array() .default(cameraConfigDefault.triggers.events), }) .default(cameraConfigDefault.triggers), // Engine options. engine: z.enum(ENGINES).default('auto'), frigate: z .object({ url: z.string().optional(), client_id: z.string().default(cameraConfigDefault.frigate.client_id), 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), // Live provider options. live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider), go2rtc: go2rtcConfigSchema.optional(), image: imageBaseConfigSchema.optional().default(imageConfigDefault), jsmpeg: jsmpegConfigSchema.optional(), webrtc_card: webrtcCardConfigSchema.optional(), cast: castSchema.optional(), ptz: ptzCameraConfigSchema.default(cameraConfigDefault.ptz), dimensions: z .object({ aspect_ratio: aspectRatioSchema.optional(), layout: mediaLayoutConfigSchema.optional(), }) .optional(), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; // Avoid using .nonempty() to avoid changing the inferred type // (https://github.com/colinhacks/zod#minmaxlength). const camerasConfigSchema = cameraConfigSchema.array().min(1); export type CamerasConfig = z.infer; // ************************************************************************* // View Configuration // ************************************************************************* const viewConfigDefault = { default: FRIGATE_CARD_VIEW_DEFAULT, camera_select: 'current' as const, interaction_seconds: 300, default_reset: { every_seconds: 0, after_interaction: false, entities: [], interaction_mode: 'inactive' as const, }, default_cycle_camera: false, dark_mode: 'off' as const, triggers: { show_trigger_status: false, filter_selected_camera: true, actions: { trigger: 'update' as const, untrigger: 'none' as const, }, untrigger_seconds: 0, }, keyboard_shortcuts: keyboardShortcutsDefault, }; const interactionModeSchema = z.enum(['all', 'inactive', 'active']).default('inactive'); export type InteractionMode = z.infer; export const triggersSchema = z.object({ actions: z .object({ interaction_mode: interactionModeSchema, trigger: z .enum(['default', 'live', 'media', 'none', 'update']) .default(viewConfigDefault.triggers.actions.trigger), untrigger: z .enum(['default', 'none']) .default(viewConfigDefault.triggers.actions.untrigger), }) .default(viewConfigDefault.triggers.actions), filter_selected_camera: z .boolean() .default(viewConfigDefault.triggers.filter_selected_camera), show_trigger_status: z .boolean() .default(viewConfigDefault.triggers.show_trigger_status), untrigger_seconds: z.number().default(viewConfigDefault.triggers.untrigger_seconds), }); export type TriggersOptions = z.infer; const viewConfigSchema = z .object({ default: z .enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED) .default(viewConfigDefault.default), camera_select: z .enum([...FRIGATE_CARD_VIEWS_USER_SPECIFIED, 'current']) .default(viewConfigDefault.camera_select), interaction_seconds: z.number().default(viewConfigDefault.interaction_seconds), default_cycle_camera: z.boolean().default(viewConfigDefault.default_cycle_camera), default_reset: z .object({ after_interaction: z .boolean() .default(viewConfigDefault.default_reset.after_interaction), every_seconds: z.number().default(viewConfigDefault.default_reset.every_seconds), entities: z.string().array().default(viewConfigDefault.default_reset.entities), interaction_mode: interactionModeSchema.default( viewConfigDefault.default_reset.interaction_mode, ), }) .default(viewConfigDefault.default_reset), render_entities: z.string().array().optional(), dark_mode: z.enum(['on', 'off', 'auto']).optional(), triggers: triggersSchema.default(viewConfigDefault.triggers), keyboard_shortcuts: keyboardShortcutsSchema.default( viewConfigDefault.keyboard_shortcuts, ), }) .merge(actionsSchema) .default(viewConfigDefault); // ************************************************************************* // Menu Configuration // ************************************************************************* const FRIGATE_MENU_STYLES = [ 'none', 'hidden', 'overlay', 'hover', 'hover-card', 'outside', ] as const; const FRIGATE_MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const; const FRIGATE_MENU_ALIGNMENTS = FRIGATE_MENU_POSITIONS; const visibleButtonDefault = { priority: FRIGATE_MENU_PRIORITY_DEFAULT, enabled: true, }; const hiddenButtonDefault = { priority: FRIGATE_MENU_PRIORITY_DEFAULT, enabled: false, }; const menuConfigDefault = { alignment: 'left' as const, button_size: 40, buttons: { camera_ui: visibleButtonDefault, cameras: visibleButtonDefault, clips: visibleButtonDefault, ptz_home: hiddenButtonDefault, display_mode: visibleButtonDefault, download: visibleButtonDefault, expand: hiddenButtonDefault, frigate: visibleButtonDefault, fullscreen: visibleButtonDefault, image: hiddenButtonDefault, live: visibleButtonDefault, media_player: visibleButtonDefault, microphone: { ...hiddenButtonDefault, type: 'momentary' as const, }, mute: hiddenButtonDefault, play: hiddenButtonDefault, ptz_controls: hiddenButtonDefault, recordings: hiddenButtonDefault, screenshot: hiddenButtonDefault, snapshots: visibleButtonDefault, substreams: visibleButtonDefault, timeline: visibleButtonDefault, }, position: 'top' as const, style: 'hidden' as const, }; const visibleButtonSchema = menuBaseSchema.extend({ enabled: menuBaseSchema.shape.enabled.default(visibleButtonDefault.enabled), priority: menuBaseSchema.shape.priority.default(visibleButtonDefault.priority), }); const hiddenButtonSchema = menuBaseSchema.extend({ enabled: menuBaseSchema.shape.enabled.default(hiddenButtonDefault.enabled), priority: menuBaseSchema.shape.priority.default(hiddenButtonDefault.priority), }); export const menuConfigSchema = z .object({ style: z.enum(FRIGATE_MENU_STYLES).default(menuConfigDefault.style), position: z.enum(FRIGATE_MENU_POSITIONS).default(menuConfigDefault.position), alignment: z.enum(FRIGATE_MENU_ALIGNMENTS).default(menuConfigDefault.alignment), buttons: z .object({ camera_ui: visibleButtonSchema.default(menuConfigDefault.buttons.camera_ui), cameras: visibleButtonSchema.default(menuConfigDefault.buttons.cameras), clips: visibleButtonSchema.default(menuConfigDefault.buttons.clips), ptz_home: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_home), display_mode: visibleButtonSchema.default( menuConfigDefault.buttons.display_mode, ), download: visibleButtonSchema.default(menuConfigDefault.buttons.download), expand: hiddenButtonSchema.default(menuConfigDefault.buttons.expand), frigate: visibleButtonSchema.default(menuConfigDefault.buttons.frigate), fullscreen: visibleButtonSchema.default(menuConfigDefault.buttons.fullscreen), image: hiddenButtonSchema.default(menuConfigDefault.buttons.image), live: visibleButtonSchema.default(menuConfigDefault.buttons.live), media_player: visibleButtonSchema.default( menuConfigDefault.buttons.media_player, ), microphone: hiddenButtonSchema .extend({ type: z .enum(['momentary', 'toggle']) .default(menuConfigDefault.buttons.microphone.type), }) .default(menuConfigDefault.buttons.microphone), mute: hiddenButtonSchema.default(menuConfigDefault.buttons.mute), play: hiddenButtonSchema.default(menuConfigDefault.buttons.play), ptz_controls: hiddenButtonSchema.default(menuConfigDefault.buttons.ptz_controls), recordings: hiddenButtonSchema.default(menuConfigDefault.buttons.recordings), screenshot: hiddenButtonSchema.default(menuConfigDefault.buttons.screenshot), snapshots: visibleButtonSchema.default(menuConfigDefault.buttons.snapshots), substreams: visibleButtonSchema.default(menuConfigDefault.buttons.substreams), timeline: visibleButtonSchema.default(menuConfigDefault.buttons.timeline), }) .default(menuConfigDefault.buttons), button_size: z.number().min(BUTTON_SIZE_MIN).default(menuConfigDefault.button_size), }) .default(menuConfigDefault); export type MenuConfig = z.infer; // ************************************************************************* // Status Bar Configuration // ************************************************************************* const STATUS_BAR_STYLES = [ 'none', 'overlay', 'hover', 'hover-card', 'outside', 'popup', ] as const; const STATUS_BAR_POSITIONS = ['top', 'bottom'] as const; const statusBarItemDefault = { priority: FRIGATE_STATUS_BAR_PRIORITY_DEFAULT, enabled: true, }; const statusBarConfigDefault = { height: 46, items: { engine: statusBarItemDefault, resolution: statusBarItemDefault, technology: statusBarItemDefault, title: statusBarItemDefault, }, position: 'bottom' as const, style: 'popup' as const, popup_seconds: 3, }; export const statusBarConfigSchema = z .object({ position: z.enum(STATUS_BAR_POSITIONS).default(statusBarConfigDefault.position), style: z.enum(STATUS_BAR_STYLES).default(statusBarConfigDefault.style), popup_seconds: z .number() .min(0) .max(60) .default(statusBarConfigDefault.popup_seconds), height: z.number().min(STATUS_BAR_HEIGHT_MIN).default(statusBarConfigDefault.height), items: z .object({ engine: statusBarItemBaseSchema.default(statusBarConfigDefault.items.engine), technology: statusBarItemBaseSchema.default( statusBarConfigDefault.items.technology, ), resolution: statusBarItemBaseSchema.default( statusBarConfigDefault.items.resolution, ), title: statusBarItemBaseSchema.default(statusBarConfigDefault.items.title), }) .default(statusBarConfigDefault.items), }) .default(statusBarConfigDefault); export type StatusBarConfig = z.infer; // ************************************************************************* // Event Viewer Configuration // ************************************************************************* const viewerConfigDefault = { auto_play: [...MEDIA_ACTION_POSITIVE_CONDITIONS], auto_pause: [...MEDIA_ACTION_NEGATIVE_CONDITIONS], auto_mute: [...MEDIA_ACTION_NEGATIVE_CONDITIONS], auto_unmute: [], lazy_load: true, draggable: true, zoomable: true, transition_effect: 'slide' as const, snapshot_click_plays_clip: true, display_mode: 'single' as const, controls: { builtin: true, next_previous: { size: 48, style: 'thumbnails' as const, }, thumbnails: thumbnailControlsDefaults, timeline: miniTimelineConfigDefault, ptz: { ...ptzControlsDefaults, mode: 'off' as const, }, }, }; const viewerNextPreviousControlConfigSchema = nextPreviousControlConfigSchema.extend({ style: z .enum(['none', 'thumbnails', 'chevrons']) .default(viewerConfigDefault.controls.next_previous.style), size: nextPreviousControlConfigSchema.shape.size.default( viewerConfigDefault.controls.next_previous.size, ), }); const viewerConfigSchema = z .object({ auto_play: z .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) .array() .default(viewerConfigDefault.auto_play), auto_pause: z .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) .array() .default(viewerConfigDefault.auto_pause), // Don't use MEDIA_UNMUTE_CONDITIONS and MEDIA_MUTE_CONDITIONS here, since // it includes 'microphone' which doesn't make sense for viewer media. auto_mute: z .enum(MEDIA_ACTION_NEGATIVE_CONDITIONS) .array() .default(viewerConfigDefault.auto_mute), auto_unmute: z .enum(MEDIA_ACTION_POSITIVE_CONDITIONS) .array() .default(viewerConfigDefault.auto_unmute), lazy_load: z.boolean().default(viewerConfigDefault.lazy_load), draggable: z.boolean().default(viewerConfigDefault.draggable), zoomable: z.boolean().default(viewerConfigDefault.zoomable), transition_effect: transitionEffectConfigSchema.default( viewerConfigDefault.transition_effect, ), snapshot_click_plays_clip: z .boolean() .default(viewerConfigDefault.snapshot_click_plays_clip), display: viewDisplaySchema, controls: z .object({ builtin: z.boolean().default(viewerConfigDefault.controls.builtin), next_previous: viewerNextPreviousControlConfigSchema.default( viewerConfigDefault.controls.next_previous, ), ptz: ptzControlsConfigSchema .extend({ // The media_viewer ptz has no 'auto' mode. mode: z.enum(['off', 'on']).default(viewerConfigDefault.controls.ptz.mode), }) .default(viewerConfigDefault.controls.ptz), thumbnails: thumbnailsControlSchema.default( viewerConfigDefault.controls.thumbnails, ), timeline: miniTimelineConfigSchema.default( viewerConfigDefault.controls.timeline, ), }) .default(viewerConfigDefault.controls), }) .merge(actionsSchema) .default(viewerConfigDefault); export type ViewerConfig = z.infer; // ************************************************************************* // Event Gallery Configuration // ************************************************************************* const galleryThumbnailControlsDefaults = { ...thumbnailControlsDefaults, show_details: false, }; const galleryConfigDefault = { controls: { thumbnails: galleryThumbnailControlsDefaults, filter: { mode: 'right' as const, }, }, }; const gallerythumbnailsControlSchema = thumbnailsControlSchema.extend({ show_details: z.boolean().default(galleryThumbnailControlsDefaults.show_details), }); const galleryConfigSchema = z .object({ controls: z .object({ thumbnails: gallerythumbnailsControlSchema.default( galleryConfigDefault.controls.thumbnails, ), filter: z .object({ mode: z .enum(['none', 'left', 'right']) .default(galleryConfigDefault.controls.filter.mode), }) .default(galleryConfigDefault.controls.filter), }) .default(galleryConfigDefault.controls), }) .merge(actionsSchema) .default(galleryConfigDefault); export type GalleryConfig = z.infer; // ************************************************************************* // Dimensions Configuration // ************************************************************************* const dimensionsConfigDefault = { aspect_ratio_mode: 'dynamic' as const, aspect_ratio: [16, 9], height: 'auto', }; export const dimensionsConfigSchema = z .object({ aspect_ratio_mode: z .enum(['dynamic', 'static', 'unconstrained']) .default(dimensionsConfigDefault.aspect_ratio_mode), aspect_ratio: aspectRatioSchema.default(dimensionsConfigDefault.aspect_ratio), height: z.string().default(dimensionsConfigDefault.height), }) .default(dimensionsConfigDefault); // ************************************************************************* // Override Configuration // ************************************************************************* const overridesSchema = z .object({ conditions: frigateCardConditionSchema.array(), merge: z.object({}).passthrough().optional(), set: z.object({}).passthrough().optional(), delete: z.string().array().optional(), }) .array() .optional(); export type Overrides = z.infer; // ************************************************************************* // Automation Configuration // ************************************************************************* const automationActionSchema = actionSchema.array(); export type AutomationActions = z.infer; const automationSchema = z .object({ conditions: frigateCardConditionSchema.array(), actions: automationActionSchema.optional(), actions_not: automationActionSchema.optional(), }) .refine( (data) => data.actions?.length || data.actions_not?.length, 'Automations must include at least one action', ); export type Automation = z.infer; const automationsSchema = automationSchema.array(); // ************************************************************************* // Performance Configuration // ************************************************************************* const performanceConfigDefault = { features: { animated_progress_indicator: true, media_chunk_size: MEDIA_CHUNK_SIZE_DEFAULT, }, style: { border_radius: true, box_shadow: true, }, }; export const performanceConfigSchema = z .object({ features: z .object({ animated_progress_indicator: z .boolean() .default(performanceConfigDefault.features.animated_progress_indicator), media_chunk_size: z .number() .min(0) .max(MEDIA_CHUNK_SIZE_MAX) .default(performanceConfigDefault.features.media_chunk_size), max_simultaneous_engine_requests: z.number().min(1).optional(), }) .default(performanceConfigDefault.features), style: z .object({ border_radius: z.boolean().default(performanceConfigDefault.style.border_radius), box_shadow: z.boolean().default(performanceConfigDefault.style.box_shadow), }) .default(performanceConfigDefault.style), }) .default(performanceConfigDefault); export type PerformanceConfig = z.infer; const debugConfigDefault = { logging: false, }; const debugConfigSchema = z .object({ logging: z.boolean().default(debugConfigDefault.logging), }) .default(debugConfigDefault); type DebugConfig = z.infer; // ************************************************************************* // CardWideConfig Configuration // ************************************************************************* export interface CardWideConfig { performance?: PerformanceConfig; debug?: DebugConfig; } // ************************************************************************* // *** Profile Configuration *** // ************************************************************************* const PROFILES = ['casting', 'low-performance', 'scrubbing'] as const; export type ProfileType = (typeof PROFILES)[number]; export const profilesSchema = z.enum(PROFILES).array().optional(); // ************************************************************************* // *** Card Configuration *** // ************************************************************************* export const frigateCardConfigSchema = z.object({ // Defaults are stripped out of the individual cameras, since each camera will // be merged with `cameras_global` which *does* have defaults. If we didn't do // this, the default values of each individual camera would override the // intentionally specified values in `cameras_global` during camera // initialization when the two configs are merged. cameras: deepRemoveDefaults(camerasConfigSchema), cameras_global: cameraConfigSchema, view: viewConfigSchema, menu: menuConfigSchema, status_bar: statusBarConfigSchema, live: liveConfigSchema, media_gallery: galleryConfigSchema, media_viewer: viewerConfigSchema, image: imageConfigSchema, elements: pictureElementsSchema, dimensions: dimensionsConfigSchema, timeline: timelineConfigSchema, performance: performanceConfigSchema, debug: debugConfigSchema, automations: automationsSchema.optional(), profiles: profilesSchema, // Configuration overrides. overrides: overridesSchema, // Support for card_mod (https://github.com/thomasloven/lovelace-card-mod). card_mod: z.unknown(), // Card ID (used for query string commands). Restrict contents to only values // that be easily used in a URL. card_id: z.string().regex(cardIDRegex).optional(), // Stock lovelace card config. type: z.string(), }); export type FrigateCardConfig = z.infer; export type RawFrigateCardConfig = Record; export type RawFrigateCardConfigArray = RawFrigateCardConfig[]; export const frigateCardConfigDefaults = { cameras: cameraConfigDefault, view: viewConfigDefault, menu: menuConfigDefault, status_bar: statusBarConfigDefault, live: liveConfigDefault, media_gallery: galleryConfigDefault, media_viewer: viewerConfigDefault, image: imageConfigDefault, timeline: timelineConfigDefault, performance: performanceConfigDefault, debug: debugConfigDefault, };