import { CallServiceActionConfig, CustomActionConfig, LovelaceCard, LovelaceCardEditor, MoreInfoActionConfig, NavigateActionConfig, ToggleActionConfig, UrlActionConfig, } from 'custom-card-helpers'; import { z } from 'zod'; declare global { interface HTMLElementTagNameMap { 'frigate-card-editor': LovelaceCardEditor; 'hui-error-card': LovelaceCard; } } /** * Internal types. */ const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ 'live', // Live view. 'clip', // Most recent clip. 'clips', // Clips gallery. 'snapshot', // Most recent snapshot. 'snapshots', // Snapshots gallery. 'image', // Static image. ] as const; const FRIGATE_CARD_VIEWS_INTERNAL = [ 'clip-specific', // A specific clip. 'snapshot-specific', // A specific snapshot. ] as const; export type FrigateCardView = | typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED[number] | typeof FRIGATE_CARD_VIEWS_INTERNAL[number]; const FRIGATE_MENU_MODES = [ 'none', 'hidden-top', 'hidden-left', 'hidden-bottom', 'hidden-right', 'overlay-top', 'overlay-left', 'overlay-bottom', 'overlay-right', 'hover-top', 'hover-left', 'hover-bottom', 'hover-right', 'above', 'below', ] as const; export const NEXT_PREVIOUS_CONTROL_STYLES = ['none', 'thumbnails', 'chevrons'] as const; export const LIVE_PROVIDERS = ['frigate', 'frigate-jsmpeg', 'webrtc'] as const; /** * Action Types (for "Picture Elements" / Menu) */ // 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; }; const toggleActionSchema = schemaForType()( z.object({ action: z.literal('toggle'), }), ); const callServiceActionSchema = schemaForType()( z.object({ action: z.literal('call-service'), service: z.string(), service_data: z.object({}).passthrough().optional(), }), ); const navigateActionSchema = schemaForType()( z.object({ action: z.literal('navigate'), navigation_path: z.string(), }), ); const urlActionSchema = schemaForType()( z.object({ action: z.literal('url'), url_path: z.string(), }), ); const moreInfoActionSchema = schemaForType()( z.object({ action: z.literal('more-info'), }), ); const customActionSchema = schemaForType()( z.object({ action: z.literal('fire-dom-event'), }), ); export const frigateCardCustomActionSchema = customActionSchema.merge( z.object({ // Syntactic sugar to avoid 'fire-dom-event' as part of an external API. action: z .literal('custom:frigate-card-action') .transform((): 'fire-dom-event' => 'fire-dom-event') .or(z.literal('fire-dom-event')), frigate_card_action: z.string(), }), ); export type FrigateCardCustomAction = z.infer; const actionSchema = z.union([ toggleActionSchema, callServiceActionSchema, navigateActionSchema, urlActionSchema, moreInfoActionSchema, frigateCardCustomActionSchema, ]); export type ActionType = z.infer; const actionBaseSchema = z .object({ tap_action: actionSchema.optional(), hold_action: actionSchema.optional(), double_tap_action: actionSchema.optional(), }) .passthrough(); export type Actions = z.infer; const actionsSchema = z.object({ // 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. actions: actionBaseSchema.optional(), }); const elementsBaseSchema = actionBaseSchema.merge( z.object({ style: z.object({}).passthrough().optional(), title: z.string().nullable().optional(), }), ); /** * Picture Element Configuration. * * 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.merge( z.object({ type: z.literal('state-badge'), entity: z.string(), }), ); // https://www.home-assistant.io/lovelace/picture-elements/#state-icon const stateIconSchema = elementsBaseSchema.merge( z.object({ 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.merge( z.object({ 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.merge( z.object({ 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 const iconSchema = elementsBaseSchema.merge( z.object({ type: z.literal('icon'), icon: z.string(), entity: z.string().optional(), }), ); // https://www.home-assistant.io/lovelace/picture-elements/#image-element const imageSchema = elementsBaseSchema.merge( z.object({ 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/lovelace/picture-elements/#image-element const conditionalSchema = z.object({ type: z.literal('conditional'), conditions: z .object({ entity: z.string(), state: z.string().optional(), state_not: z.string().optional(), }) .array(), elements: z.lazy(() => pictureElementsSchema), }); // https://www.home-assistant.io/lovelace/picture-elements/#custom-elements 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 Types. */ export const menuIconSchema = iconSchema.merge( z.object({ type: z.literal('custom:frigate-card-menu-icon'), }), ); export type MenuIcon = z.infer; export const menuStateIconSchema = stateIconSchema.merge( z.object({ type: z.literal('custom:frigate-card-menu-state-icon'), }), ); export type MenuStateIcon = z.infer; const frigateCardConditionSchema = z.object({ view: z.string().array().optional(), fullscreen: z.boolean().optional(), }); export type FrigateCardCondition = z.infer; const frigateConditionalSchema = z.object({ type: z.literal('custom:frigate-card-conditional'), conditions: frigateCardConditionSchema, elements: z.lazy(() => pictureElementsSchema), }); export type FrigateConditional = z.infer; const pictureElementSchema = z.union([ menuStateIconSchema, menuIconSchema, frigateConditionalSchema, stateBadgeIconSchema, stateIconSchema, stateLabelSchema, serviceCallButtonSchema, iconSchema, imageSchema, conditionalSchema, customSchema, ]); export type PictureElement = z.infer; const pictureElementsSchema = pictureElementSchema.array().optional(); export type PictureElements = z.infer; /** * Frigate configuration section. */ const frigateConfigDefault = { client_id: 'frigate' as const, }; const frigateConfigDefaultSchema = z .object({ // No URL validation to allow relative URLs within HA (e.g. addons). url: z.string().optional(), client_id: z.string().optional().default(frigateConfigDefault.client_id), camera_name: z.string().optional(), label: z.string().optional(), zone: z.string().optional(), }) .default(frigateConfigDefault); /** * View configuration section. */ const viewConfigDefault = { default: 'live' as const, timeout: 180, }; const viewConfigSchema = z .object({ default: z .enum(FRIGATE_CARD_VIEWS_USER_SPECIFIED) .optional() .default(viewConfigDefault.default), timeout: z .number() .or( z .string() .regex(/^\d+$/) .transform((val) => Number(val)), ) .optional() .default(viewConfigDefault.timeout), }) .merge(actionsSchema) .default(viewConfigDefault); /** * Image view configuration section. */ const imageConfigSchema = z .object({ src: z.string().optional(), }) .merge(actionsSchema) .optional(); export type ImageViewConfig = z.infer; /** * Thumbnail controls configuration section. */ // This type is not inferred from a schema since different (compatible) schemas // have different defaults. export type ThumbnailsControlConfig = { mode: 'above' | 'below' | 'none'; size?: string; } /** * Live view configuration section. */ const liveConfigDefault = { provider: 'frigate' as const, preload: false, controls: { thumbnails: { mode: 'none' as const, media: 'clips' as const, }, }, }; const webrtcConfigSchema = z .object({ entity: z.string().optional(), url: z.string().optional(), }) .passthrough() .optional(); export type WebRTCConfig = z.infer; 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(), }) .optional(); export type JSMPEGConfig = z.infer; const liveConfigSchema = z .object({ provider: z.enum(LIVE_PROVIDERS).default(liveConfigDefault.provider), preload: z.boolean().default(liveConfigDefault.preload), webrtc: webrtcConfigSchema, jsmpeg: jsmpegConfigSchema, controls: z .object({ thumbnails: z .object({ media: z .enum(['clips', 'snapshots']) .default(liveConfigDefault.controls.thumbnails.media), mode: z.enum(['none', 'above', 'below']).default(liveConfigDefault.controls.thumbnails.mode), size: z.string().optional(), }) .default(liveConfigDefault.controls.thumbnails), }) .default(liveConfigDefault.controls), }) .merge(actionsSchema) .default(liveConfigDefault); /** * Menu configuration section. */ const menuConfigDefault = { mode: 'hidden-top' as const, buttons: { frigate: true, live: true, clips: true, snapshots: true, image: false, download: true, frigate_ui: true, fullscreen: true, }, button_size: '40px', }; const menuConfigSchema = z .object({ mode: z.enum(FRIGATE_MENU_MODES).optional().default(menuConfigDefault.mode), buttons: z .object({ frigate: z.boolean().default(menuConfigDefault.buttons.frigate), live: z.boolean().default(menuConfigDefault.buttons.live), clips: z.boolean().default(menuConfigDefault.buttons.clips), snapshots: z.boolean().default(menuConfigDefault.buttons.snapshots), image: z.boolean().default(menuConfigDefault.buttons.image), download: z.boolean().default(menuConfigDefault.buttons.download), frigate_ui: z.boolean().default(menuConfigDefault.buttons.frigate_ui), fullscreen: z.boolean().default(menuConfigDefault.buttons.fullscreen), }) .default(menuConfigDefault.buttons), button_size: z.string().default(menuConfigDefault.button_size), conditions: frigateCardConditionSchema.optional(), }) .default(menuConfigDefault); export type MenuConfig = z.infer; /** * Event viewer configuration section (clip, snapshot, clip-specific, snapshot-specific). */ const viewerConfigDefault = { autoplay_clip: false, lazy_load: true, draggable: true, controls: { next_previous: { size: '48px', style: 'thumbnails' as const, }, thumbnails: { mode: 'below' as const, }, }, }; const nextPreviousControlConfigSchema = z .object({ style: z .enum(NEXT_PREVIOUS_CONTROL_STYLES) .default(viewerConfigDefault.controls.next_previous.style), size: z.string().default(viewerConfigDefault.controls.next_previous.size), }) .default(viewerConfigDefault.controls.next_previous); export type NextPreviousControlConfig = z.infer; const viewerConfigSchema = z .object({ autoplay_clip: z.boolean().default(viewerConfigDefault.autoplay_clip), lazy_load: z.boolean().default(viewerConfigDefault.lazy_load), draggable: z.boolean().default(viewerConfigDefault.draggable), controls: z .object({ next_previous: nextPreviousControlConfigSchema, thumbnails: z.object({ mode: z.enum(['none', 'above', 'below']).default(viewerConfigDefault.controls.thumbnails.mode), size: z.string().optional(), }).default(viewerConfigDefault.controls.thumbnails), }) .default(viewerConfigDefault.controls), }) .merge(actionsSchema) .default(viewerConfigDefault); export type ViewerConfig = z.infer; /** * Event gallery configuration section (clips, snapshots). */ const galleryConfigSchema = actionsSchema.optional(); /** * Dimensions configuration section. */ const dimensionsConfigDefault = { aspect_ratio_mode: 'dynamic' as const, aspect_ratio: [16, 9], }; const dimensionsConfigSchema = z .object({ aspect_ratio_mode: z .enum(['dynamic', 'static', 'unconstrained']) .default(dimensionsConfigDefault.aspect_ratio_mode), aspect_ratio: z .number() .array() .length(2) .or( z .string() .regex(/^\s*\d+\s*[:\/]\s*\d+\s*$/) .transform((input) => input.split(/[:\/]/).map((d) => Number(d))), ) .default(dimensionsConfigDefault.aspect_ratio), }) .default(dimensionsConfigDefault); /** * Main card config. */ export const frigateCardConfigSchema = z.object({ camera_entity: z.string().optional(), // Main configuration sections. frigate: frigateConfigDefaultSchema, view: viewConfigSchema, menu: menuConfigSchema, live: liveConfigSchema, event_viewer: viewerConfigSchema, event_gallery: galleryConfigSchema, image: imageConfigSchema, elements: pictureElementsSchema, dimensions: dimensionsConfigSchema, // Entities that should trigger a card update. update_entities: z.string().array().optional(), // Stock lovelace card config. type: z.string(), test_gui: z.boolean().optional(), }); export type FrigateCardConfig = z.infer; export type RawFrigateCardConfig = Record; export const frigateCardConfigDefaults = { frigate: frigateConfigDefault, view: viewConfigDefault, menu: menuConfigDefault, live: liveConfigDefault, event_viewer: viewerConfigDefault, }; const menuButtonSchema = z.union([menuIconSchema, menuStateIconSchema]); export type MenuButton = z.infer; export interface ExtendedHomeAssistant { hassUrl(path?): string; } export interface BrowseMediaQueryParameters { mediaType: 'clips' | 'snapshots'; clientId: string; cameraName: string; label?: string; zone?: string; before?: number; after?: number; } export interface GetFrigateCardMenuButtonParameters { icon: string; title: string; tap_action: string; hold_action?: string; emphasize?: boolean; } export interface BrowseMediaNeighbors { previous: BrowseMediaSource | null; previousIndex: number | null; next: BrowseMediaSource | null; nextIndex: number | null; } export interface MediaShowInfo { width: number; height: number; } export interface Message { message: string; type: 'error' | 'info'; icon?: string; } /** * Home Assistant API types. */ // 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/__init__.py export interface BrowseMediaSource { 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?: BrowseMediaSource[] | null; } export const browseMediaSourceSchema: z.ZodSchema = 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(), thumbnail: z.string().nullable(), children: z.array(browseMediaSourceSchema).nullable().optional(), }), ); // Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_source/models.py export const resolvedMediaSchema = z.object({ url: z.string(), mime_type: z.string(), }); export type ResolvedMedia = z.infer; export const signedPathSchema = z.object({ path: z.string(), }); export type SignedPath = z.infer; export const entitySchema = z.object({ entity_id: z.string(), unique_id: z.string(), platform: z.string(), }); export type Entity = z.infer;