refactor: Migrate config schema from Zod v3 to Zod v4 (#2357)

This commit is contained in:
Dermot Duffy
2026-02-18 21:47:43 -08:00
committed by GitHub
parent a15376602f
commit 529500ed94
51 changed files with 1110 additions and 443 deletions
+1 -1
View File
@@ -167,7 +167,7 @@ export class CameraManager {
// order, to ensure that the defaults in the cameras global config do not
// override the values specified in the per-camera config.
const cameras = (config.cameras ?? []).map((camera) =>
recursivelyMergeObjectsNotArrays({}, cloneDeep(config?.cameras_global), camera),
recursivelyMergeObjectsNotArrays(config?.cameras_global, camera),
);
try {
+17 -7
View File
@@ -1,5 +1,5 @@
import { isEqual } from 'lodash-es';
import { isConfigUpgradeable } from '../../config/management.js';
import { copyConfig, isConfigUpgradeable } from '../../config/management.js';
import { setProfiles } from '../../config/profiles/set-profiles.js';
import {
AdvancedCameraCardConfig,
@@ -8,7 +8,7 @@ import {
} from '../../config/schema/types.js';
import { RawAdvancedCameraCardConfig } from '../../config/types.js';
import { localize } from '../../localize/localize.js';
import { getParseErrorPaths } from '../../utils/zod.js';
import { getParseError } from '../../utils/zod/parse-errors.js';
import { InitializationAspect } from '../initialization-manager.js';
import { CardConfigAPI } from '../types.js';
import { setAutomationsFromConfig } from './load-automations.js';
@@ -64,7 +64,7 @@ export class ConfigManager {
const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig);
if (!parseResult.success) {
const configUpgradeable = isConfigUpgradeable(inputConfig);
const hint = getParseErrorPaths(parseResult.error);
const hint = getParseError(parseResult.error);
let upgradeMessage = '';
if (configUpgradeable) {
upgradeMessage = `${localize('error.upgrade_available')}. `;
@@ -72,12 +72,22 @@ export class ConfigManager {
throw new Error(
upgradeMessage +
`${localize('error.invalid_configuration')}: ` +
(hint && hint.size
? JSON.stringify([...hint], null, ' ')
: localize('error.invalid_configuration_no_hint')),
(hint ?? localize('error.invalid_configuration_no_hint')),
);
}
const config = setProfiles(inputConfig, parseResult.data, parseResult.data.profiles);
const config = advancedCameraCardConfigSchema.parse(
setProfiles(
inputConfig,
// The config is cloned here because Zod 4 returns shared constant
// defaults by reference. Since setProfiles() mutates the configuration
// in-place, those mutations would "pollute" the global defaults and break
// test isolation if we didn't use a fresh clone here.
copyConfig(parseResult.data),
parseResult.data.profiles,
),
);
this._rawConfig = inputConfig;
if (isEqual(this._config, config)) {
@@ -94,7 +94,7 @@ export class OverridesManager {
if (!parseResult.success) {
throw new OverrideConfigurationError(
localize('error.invalid_configuration_override'),
[parseResult.error.errors, output],
[parseResult.error.issues, output],
);
}
return parseResult.data;
+3 -4
View File
@@ -16,8 +16,7 @@ import { getCameraEntityFromConfig } from '../camera-manager/utils/camera-entity
import { CachedValueController } from '../components-lib/cached-value-controller.js';
import { UpdatingImageMediaPlayerController } from '../components-lib/media-player/updating-image.js';
import { CameraConfig } from '../config/schema/cameras.js';
import { ImageMode } from '../config/schema/common/image.js';
import { ImageViewConfig } from '../config/schema/image.js';
import { type ImageBaseConfig, ImageMode } from '../config/schema/common/image.js';
import { isHassDifferent } from '../ha/is-hass-different.js';
import { HomeAssistant } from '../ha/types.js';
import defaultImage from '../images/iris-screensaver.jpg';
@@ -43,7 +42,7 @@ import { renderMessage } from './message.js';
const HASS_REJECTION_CUTOFF_MS = 5 * 60 * 1000;
export const resolveImageMode = (options?: {
imageConfig?: ImageViewConfig;
imageConfig?: ImageBaseConfig;
cameraConfig?: CameraConfig;
}): Exclude<ImageMode, 'auto'> => {
if (!options?.imageConfig?.mode) {
@@ -87,7 +86,7 @@ export class AdvancedCameraCardImageUpdatingPlayer
// 'show_image_during_load' option is true for live views, an overridden
// config may be used here).
@property({ attribute: false, hasChanged: contentsChanged })
public imageConfig?: ImageViewConfig;
public imageConfig?: ImageBaseConfig;
@state()
protected _message: Message | null = null;
+2 -2
View File
@@ -1,4 +1,4 @@
import { deepRemoveDefaults } from '../../utils/zod.js';
import { deepRemoveDefaults } from '../../utils/zod/deep-remove-defaults.js';
import { getConfigValue, setConfigValue } from '../management.js';
import { ProfileType } from '../schema/profiles.js';
import { advancedCameraCardConfigSchema } from '../schema/types.js';
@@ -31,7 +31,7 @@ export const setProfiles = <T extends RawAdvancedCameraCardConfig>(
if (!defaultLessParseResult.success) {
return outputConfig;
}
const defaultLessConfig = defaultLessParseResult.data;
const defaultLessConfig = defaultLessParseResult.data as RawAdvancedCameraCardConfig;
const setIfNotSpecified = (key: string, value: unknown) => {
if (getConfigValue(defaultLessConfig, key) === undefined) {
+1 -1
View File
@@ -9,7 +9,7 @@ export const internalCallbackActionConfigSchema =
advanced_camera_card_action: z.literal(INTERNAL_CALLBACK_ACTION),
// The callback is expected to be called with a CardController API object.
callback: z.function().args(z.any()).returns(z.promise(z.void())),
callback: z.function().input([z.any()]).output(z.promise(z.void())),
});
export type InternalCallbackActionConfig = z.infer<
typeof internalCallbackActionConfigSchema
@@ -9,7 +9,7 @@ import { targetSchema } from './target';
export const callServiceActionSchema = actionBaseSchema.extend({
action: z.literal('call-service'),
service: z.string(),
data: z.object({}).passthrough().optional(),
data: z.looseObject({}).optional(),
target: targetSchema.optional(),
});
export type CallServiceActionConfig = z.infer<typeof callServiceActionSchema>;
+1 -1
View File
@@ -5,5 +5,5 @@ export const customActionSchema = actionBaseSchema
.extend({
action: z.literal('fire-dom-event'),
})
.passthrough();
.loose();
export type CustomActionConfig = z.infer<typeof customActionSchema>;
@@ -5,7 +5,7 @@ import { targetSchema } from './target';
export const performActionActionSchema = actionBaseSchema.extend({
action: z.literal('perform-action'),
perform_action: z.string(),
data: z.object({}).passthrough().optional(),
data: z.looseObject({}).optional(),
target: targetSchema.optional(),
});
export type PerformActionActionConfig = z.infer<typeof performActionActionSchema>;
+6 -6
View File
@@ -1,10 +1,10 @@
import { HassServiceTarget } from 'home-assistant-js-websocket';
import { z } from 'zod';
export const targetSchema: z.ZodSchema<HassServiceTarget, z.ZodTypeDef> = z.object({
entity_id: z.string().or(z.string().array()).optional(),
device_id: z.string().or(z.string().array()).optional(),
area_id: z.string().or(z.string().array()).optional(),
floor_id: z.string().or(z.string().array()).optional(),
label_id: z.string().or(z.string().array()).optional(),
export const targetSchema: z.ZodSchema<HassServiceTarget> = z.object({
entity_id: z.union([z.string(), z.string().array()]).optional(),
device_id: z.union([z.string(), z.string().array()]).optional(),
area_id: z.union([z.string(), z.string().array()]).optional(),
label_id: z.union([z.string(), z.string().array()]).optional(),
floor_id: z.union([z.string(), z.string().array()]).optional(),
});
+10 -14
View File
@@ -29,18 +29,15 @@ export type StatusBarActionConfig = z.infer<
status_bar_action: 'add' | 'remove' | 'reset';
items?: StatusBarItem[];
};
export const statusBarActionConfigSchema: z.ZodSchema<
StatusBarActionConfig,
z.ZodTypeDef,
unknown
> = advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('status_bar'),
status_bar_action: z.enum(['add', 'remove', 'reset']),
items: z
.lazy(() => statusBarItemSchema)
.array()
.optional(),
});
export const statusBarActionConfigSchema: z.ZodSchema<StatusBarActionConfig> =
advancedCameraCardCustomActionsBaseSchema.extend({
advanced_camera_card_action: z.literal('status_bar'),
status_bar_action: z.enum(['add', 'remove', 'reset']),
items: z
.lazy(() => statusBarItemSchema)
.array()
.optional(),
});
const advancedCameraCardCustomActionSchema = z.union([
cameraSelectActionConfigSchema,
@@ -69,7 +66,6 @@ export const actionConfigSchema = z.union([
advancedCameraCardCustomActionSchema,
]);
export type ActionConfig = z.infer<typeof actionConfigSchema>;
export const actionsBaseSchema = z
.object({
tap_action: actionConfigSchema.or(actionConfigSchema.array()).optional(),
@@ -81,7 +77,7 @@ export const actionsBaseSchema = z
// 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();
.loose();
export type Actions = z.infer<typeof actionsBaseSchema>;
export interface AuxillaryActionConfig {
+1 -1
View File
@@ -77,7 +77,7 @@ export const ptzCameraConfigSchema = z.preprocess(
.preprocess(
dataPTZFormatToFullFormat(''),
z.union([
z.record(performActionActionSchema),
z.record(z.string(), performActionActionSchema),
// This is used by the data_ style of action.
z.object({ service: z.string().optional() }),
+6 -4
View File
@@ -4,8 +4,8 @@ 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 { imageBaseConfigDefault, imageBaseConfigSchema } from './common/image';
import { severitySchema } from './common/severity';
import { imageBaseConfigSchema, imageConfigDefault } from './common/image';
const CAMERA_TRIGGER_EVENT_TYPES = [
// An event whether or not it has any media yet associated with it.
@@ -56,7 +56,7 @@ const webrtcCardConfigSchema = z
entity: z.string().optional(),
url: z.string().optional(),
})
.passthrough();
.loose();
const jsmpegConfigSchema = z.object({
options: z
@@ -150,6 +150,8 @@ export const cameraConfigDefault = {
ssl_ciphers: 'auto' as const,
ssl_verification: 'auto' as const,
},
go2rtc: go2rtcConfigDefault,
image: imageBaseConfigDefault,
always_error_if_entity_unavailable: false,
};
@@ -217,7 +219,7 @@ const cameraMediaConfigSchema = z.object({
});
export const cameraConfigSchema = z
.object({
.looseObject({
camera_entity: z.string().optional(),
// Used for presentation in the UI (autodetected from the entity if
@@ -318,7 +320,7 @@ export const cameraConfigSchema = z
// Live provider options.
live_provider: z.enum(LIVE_PROVIDERS).default(cameraConfigDefault.live_provider),
go2rtc: go2rtcConfigSchema.optional().default(go2rtcConfigDefault),
image: imageBaseConfigSchema.optional().default(imageConfigDefault),
image: imageBaseConfigSchema.optional().default(imageBaseConfigDefault),
jsmpeg: jsmpegConfigSchema.optional(),
webrtc_card: webrtcCardConfigSchema.optional(),
+1 -1
View File
@@ -22,6 +22,6 @@ export const ptzControlsConfigSchema = z.object({
hide_zoom: z.boolean().default(ptzControlsDefaults.hide_zoom),
hide_home: z.boolean().default(ptzControlsDefaults.hide_home),
style: z.object({}).passthrough().optional(),
style: z.looseObject({}).optional(),
});
export type PTZControlsConfig = z.infer<typeof ptzControlsConfigSchema>;
@@ -5,7 +5,7 @@ export const THUMBNAIL_WIDTH_MIN = 75;
export const THUMBNAIL_WIDTH_DEFAULT = 100;
export const THUMBNAIL_WIDTH_MAX = 300;
const thumbnailControlsBaseDefaults = {
export const thumbnailControlsBaseDefaults = {
size: THUMBNAIL_WIDTH_DEFAULT,
show_details: true,
show_favorite_control: true,
+9 -3
View File
@@ -1,8 +1,12 @@
import { z } from 'zod';
export const imageConfigDefault = {
export const imageBaseConfigDefault = {
mode: 'auto' as const,
refresh_seconds: 1,
};
export const imageConfigDefault = {
...imageBaseConfigDefault,
zoomable: true,
};
@@ -10,11 +14,13 @@ const IMAGE_MODES = ['auto', 'camera', 'entity', 'screensaver', 'url'] as const;
export type ImageMode = (typeof IMAGE_MODES)[number];
export const imageBaseConfigSchema = z.object({
mode: z.enum(IMAGE_MODES).default(imageConfigDefault.mode),
mode: z.enum(IMAGE_MODES).default(imageBaseConfigDefault.mode),
refresh_seconds: z.number().min(0).default(imageConfigDefault.refresh_seconds),
refresh_seconds: z.number().min(0).default(imageBaseConfigDefault.refresh_seconds),
url: z.string().optional(),
entity: z.string().optional(),
entity_parameters: z.string().optional(),
});
export type ImageBaseConfig = z.infer<typeof imageBaseConfigSchema>;
@@ -10,4 +10,3 @@ export const stockConditionSchema = z.discriminatedUnion('condition', [
screenConditionSchema,
usersConditionSchema,
]);
export type StockCondition = z.infer<typeof stockConditionSchema>;
+3 -1
View File
@@ -2,6 +2,8 @@ import { z } from 'zod';
import { actionsBaseSchema } from '../actions/types';
export const elementsBaseSchema = actionsBaseSchema.extend({
style: z.record(z.string().nullable().or(z.undefined()).or(z.number())).optional(),
style: z
.record(z.string(), z.string().nullable().or(z.undefined()).or(z.number()))
.optional(),
title: z.string().nullable().optional(),
});
@@ -11,5 +11,6 @@ export const menuBaseSchema = z.object({
.optional(),
alignment: z.enum(['matching', 'opposing']).default('matching').optional(),
icon: z.string().optional(),
state_color: z.boolean().default(true).optional(),
permanent: z.boolean().default(false).optional(),
});
@@ -2,7 +2,7 @@ import { z } from 'zod';
import { iconSchema } from '../../stock/icon';
import { menuBaseSchema } from './base';
export const menuIconSchema = menuBaseSchema.merge(iconSchema).extend({
export const menuIconSchema = menuBaseSchema.extend(iconSchema.shape).extend({
type: z.literal('custom:advanced-camera-card-menu-icon'),
});
export type MenuIcon = z.infer<typeof menuIconSchema>;
@@ -3,9 +3,9 @@ import { stateIconSchema } from '../../stock/state-icon';
import { menuBaseSchema } from './base';
export const menuStateIconSchema = menuBaseSchema
.merge(stateIconSchema)
.extend(stateIconSchema.shape)
.extend({
type: z.literal('custom:advanced-camera-card-menu-state-icon'),
})
.merge(menuBaseSchema);
.extend(menuBaseSchema.shape);
export type MenuStateIcon = z.infer<typeof menuStateIconSchema>;
@@ -3,8 +3,10 @@ import { stateIconSchema } from '../../stock/state-icon';
import { menuBaseSchema } from './base';
import { menuSubmenuItemSchema } from './submenu';
export const menuSubmenuSelectSchema = menuBaseSchema.merge(stateIconSchema).extend({
type: z.literal('custom:advanced-camera-card-menu-submenu-select'),
options: z.record(menuSubmenuItemSchema.deepPartial()).optional(),
});
export const menuSubmenuSelectSchema = menuBaseSchema
.extend(stateIconSchema.shape)
.extend({
type: z.literal('custom:advanced-camera-card-menu-submenu-select'),
options: z.record(z.string(), menuSubmenuItemSchema.partial()).optional(),
});
export type MenuSubmenuSelect = z.infer<typeof menuSubmenuSelectSchema>;
@@ -13,7 +13,7 @@ export const menuSubmenuItemSchema = elementsBaseSchema.extend({
});
export type MenuSubmenuItem = z.infer<typeof menuSubmenuItemSchema>;
export const menuSubmenuSchema = menuBaseSchema.merge(iconSchema).extend({
export const menuSubmenuSchema = menuBaseSchema.extend(iconSchema.shape).extend({
type: z.literal('custom:advanced-camera-card-menu-submenu'),
items: menuSubmenuItemSchema.array(),
});
+2 -2
View File
@@ -7,11 +7,11 @@ export const customSchema = z
type: z.string().superRefine((val, ctx) => {
if (!val.match(/^custom:(?!advanced-camera-card).+/)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
code: 'custom',
message: 'advanced-camera-card custom elements must match specific schemas',
fatal: true,
});
}
}),
})
.passthrough();
.loose();
+2 -2
View File
@@ -8,8 +8,8 @@ export const imageSchema = elementsBaseSchema.extend({
image: z.string().optional(),
camera_image: z.string().optional(),
camera_view: z.string().optional(),
state_image: z.object({}).passthrough().optional(),
state_image: z.looseObject({}).optional(),
filter: z.string().optional(),
state_filter: z.object({}).passthrough().optional(),
state_filter: z.looseObject({}).optional(),
aspect_ratio: z.string().optional(),
});
@@ -8,5 +8,5 @@ export const serviceCallButtonSchema = elementsBaseSchema.extend({
// Title is required for service button.
title: z.string(),
service: z.string(),
service_data: z.object({}).passthrough().optional(),
service_data: z.looseObject({}).optional(),
});
+18 -27
View File
@@ -4,11 +4,8 @@ import {
statusBarImageItemSchema,
statusBarStringItemSchema,
} from '../actions/types';
import { StockCondition, stockConditionSchema } from '../conditions/stock/types';
import {
AdvancedCameraCardCondition,
advancedCameraCardConditionSchema,
} from '../conditions/types';
import { stockConditionSchema } from '../conditions/stock/types';
import { advancedCameraCardConditionSchema } from '../conditions/types';
import { menuIconSchema } from './custom/menu/icon';
import { menuStateIconSchema } from './custom/menu/state-icon';
import { menuSubmenuSchema } from './custom/menu/submenu';
@@ -25,36 +22,30 @@ import { stateLabelSchema } from './stock/state-label';
// include other elements. Putting these elements elsewhere would cause
// typescript circular dependency errors as the types need to be both included
// in the master pictureElementSchema, but also refer to it internally.
//
// Provide a manual type definition to avoid the `any` that would be created by
// the lazy() evaluation below.
// See: https://zod.dev/?id=recursive-types
// https://www.home-assistant.io/lovelace/picture-elements/#image-element
type Conditional = {
type: 'conditional';
conditions: StockCondition[];
elements?: PictureElements;
};
export const conditionalSchema: z.ZodSchema<Conditional, z.ZodTypeDef> = z.object({
export const conditionalSchema = z.object({
type: z.literal('conditional'),
conditions: stockConditionSchema.array(),
elements: z.lazy(() => pictureElementsSchema),
get elements() {
// Recursive schema.
return pictureElementsSchema;
},
});
export type AdvancedCameraCardConditional = {
type: 'custom:advanced-camera-card-conditional';
conditions: AdvancedCameraCardCondition[];
elements?: PictureElements;
};
const advancedCameraCardConditionalSchema: z.ZodSchema<
AdvancedCameraCardConditional,
z.ZodTypeDef
> = z.object({
const advancedCameraCardConditionalSchema = z.object({
type: z.literal('custom:advanced-camera-card-conditional'),
conditions: advancedCameraCardConditionSchema.array(),
elements: z.lazy(() => pictureElementsSchema),
get elements() {
// Recursive schema.
return pictureElementsSchema;
},
});
export type AdvancedCameraCardConditional = z.infer<
typeof advancedCameraCardConditionalSchema
>;
// Cannot use discriminatedUnion since customSchema uses a superRefine, which
// causes false rejections.
@@ -77,5 +68,5 @@ const pictureElementSchema = z.union([
statusBarStringItemSchema,
]);
export const pictureElementsSchema = pictureElementSchema.array().optional();
export const pictureElementsSchema = pictureElementSchema.array().optional().default([]);
export type PictureElements = z.infer<typeof pictureElementsSchema>;
+7 -6
View File
@@ -60,14 +60,15 @@ const titleMatcherSchema = z.object({
});
export type TitleMatcher = z.infer<typeof titleMatcherSchema>;
type OrMatcher = {
type: 'or';
matchers: Matcher[];
};
const orMatcherSchema: z.ZodSchema<OrMatcher, z.ZodTypeDef> = z.object({
const orMatcherSchema = z.object({
type: z.literal('or'),
matchers: z.array(z.lazy(() => matcherSchema)),
get matchers() {
// Recursive schema.
return z.array(matcherSchema);
},
});
export const matcherSchema = z.union([
dateMatcherSchema,
orMatcherSchema,
+1 -1
View File
@@ -6,7 +6,7 @@ export const imageConfigSchema = imageBaseConfigSchema
.extend({
zoomable: z.boolean().default(imageConfigDefault.zoomable),
})
.merge(actionsSchema)
.extend(actionsSchema.shape)
.default(imageConfigDefault);
export type ImageViewConfig = z.infer<typeof imageConfigSchema>;
+1 -2
View File
@@ -52,7 +52,6 @@ export const liveConfigDefault = {
zoomable: true,
transition_effect: 'slide' as const,
show_image_during_load: true,
mode: 'single' as const,
controls: {
builtin: true,
next_previous: {
@@ -126,6 +125,6 @@ export const liveConfigSchema = z
),
zoomable: z.boolean().default(liveConfigDefault.zoomable),
})
.merge(actionsSchema)
.extend(actionsSchema.shape)
.default(liveConfigDefault);
export type LiveConfig = z.infer<typeof liveConfigSchema>;
+3 -3
View File
@@ -1,12 +1,12 @@
import { z } from 'zod';
import { actionsSchema } from './actions/types';
import {
thumbnailControlsDefaults,
thumbnailControlsBaseDefaults,
thumbnailsControlBaseSchema,
} from './common/controls/thumbnails';
const mediaGalleryThumbnailControlsDefaults = {
...thumbnailControlsDefaults,
...thumbnailControlsBaseDefaults,
show_details: false,
};
@@ -43,6 +43,6 @@ export const mediaGalleryConfigSchema = z
})
.default(mediaGalleryConfigDefault.controls),
})
.merge(actionsSchema)
.extend(actionsSchema.shape)
.default(mediaGalleryConfigDefault);
export type MediaGalleryConfig = z.infer<typeof mediaGalleryConfigSchema>;
+36 -27
View File
@@ -13,13 +13,20 @@ const MENU_STYLES = [
const MENU_POSITIONS = ['left', 'right', 'top', 'bottom'] as const;
const MENU_ALIGNMENTS = MENU_POSITIONS;
const visibleButtonDefault = {
const baseButtonDefault = {
alignment: 'matching' as const,
state_color: true,
permanent: false,
priority: MENU_PRIORITY_DEFAULT,
};
const visibleButtonDefault = {
...baseButtonDefault,
enabled: true,
};
const hiddenButtonDefault = {
priority: MENU_PRIORITY_DEFAULT,
...baseButtonDefault,
enabled: false,
};
@@ -27,35 +34,37 @@ export const menuConfigDefault = {
alignment: 'left' as const,
button_size: 40,
buttons: {
camera_ui: visibleButtonDefault,
cameras: visibleButtonDefault,
clips: hiddenButtonDefault,
ptz_home: hiddenButtonDefault,
display_mode: visibleButtonDefault,
download: visibleButtonDefault,
expand: hiddenButtonDefault,
folders: visibleButtonDefault,
iris: visibleButtonDefault,
fullscreen: visibleButtonDefault,
image: hiddenButtonDefault,
info: visibleButtonDefault,
gallery: visibleButtonDefault,
live: visibleButtonDefault,
media_player: visibleButtonDefault,
// Clone per key so each button has its own default object. This avoids
// shared nested default references between keys.
camera_ui: { ...visibleButtonDefault },
cameras: { ...visibleButtonDefault },
clips: { ...hiddenButtonDefault },
ptz_home: { ...hiddenButtonDefault },
display_mode: { ...visibleButtonDefault },
download: { ...visibleButtonDefault },
expand: { ...hiddenButtonDefault },
folders: { ...visibleButtonDefault },
iris: { ...visibleButtonDefault },
fullscreen: { ...visibleButtonDefault },
image: { ...hiddenButtonDefault },
info: { ...visibleButtonDefault },
gallery: { ...visibleButtonDefault },
live: { ...visibleButtonDefault },
media_player: { ...visibleButtonDefault },
microphone: {
...hiddenButtonDefault,
type: 'momentary' as const,
},
mute: hiddenButtonDefault,
play: hiddenButtonDefault,
ptz_controls: hiddenButtonDefault,
recordings: hiddenButtonDefault,
reviews: hiddenButtonDefault,
set_review: visibleButtonDefault,
screenshot: hiddenButtonDefault,
snapshots: hiddenButtonDefault,
substreams: visibleButtonDefault,
timeline: visibleButtonDefault,
mute: { ...hiddenButtonDefault },
play: { ...hiddenButtonDefault },
ptz_controls: { ...hiddenButtonDefault },
recordings: { ...hiddenButtonDefault },
reviews: { ...hiddenButtonDefault },
set_review: { ...visibleButtonDefault },
screenshot: { ...hiddenButtonDefault },
snapshots: { ...hiddenButtonDefault },
substreams: { ...visibleButtonDefault },
timeline: { ...visibleButtonDefault },
},
position: 'top' as const,
style: 'hidden' as const,
+2 -2
View File
@@ -3,8 +3,8 @@ import { advancedCameraCardConditionSchema } from './conditions/types';
const overrideSchema = z.object({
conditions: advancedCameraCardConditionSchema.array(),
merge: z.object({}).passthrough().optional(),
set: z.object({}).passthrough().optional(),
merge: z.looseObject({}).optional(),
set: z.looseObject({}).optional(),
delete: z.string().array().optional(),
});
export type Override = z.infer<typeof overrideSchema>;
+1 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod';
import { deepRemoveDefaults } from '../../utils/zod';
import { deepRemoveDefaults } from '../../utils/zod/deep-remove-defaults';
import { automationsSchema } from './automations';
import { cameraConfigDefault, cameraConfigSchema, camerasConfigSchema } from './cameras';
import { cardIDRegex } from './common/const';
+10 -7
View File
@@ -45,6 +45,8 @@ export type PTZKeyboardShortcutName =
| 'ptz_zoom_in'
| 'ptz_zoom_out';
const interactionModeDefault = 'inactive' as const;
export const viewConfigDefault = {
default: 'auto' as const,
camera_select: 'current' as const,
@@ -53,7 +55,7 @@ export const viewConfigDefault = {
every_seconds: 0,
after_interaction: false,
entities: [],
interaction_mode: 'inactive' as const,
interaction_mode: interactionModeDefault,
},
default_cycle_camera: false,
dim: false,
@@ -64,6 +66,7 @@ export const viewConfigDefault = {
show_trigger_status: false,
filter_selected_camera: true,
actions: {
interaction_mode: interactionModeDefault,
trigger: 'update' as const,
untrigger: 'none' as const,
},
@@ -73,7 +76,9 @@ export const viewConfigDefault = {
keyboard_shortcuts: keyboardShortcutsDefault,
};
const interactionModeSchema = z.enum(['all', 'inactive', 'active']).default('inactive');
const interactionModeSchema = z
.enum(['all', 'inactive', 'active'])
.default(interactionModeDefault);
export type InteractionMode = z.infer<typeof interactionModeSchema>;
export const triggersSchema = z.object({
@@ -108,7 +113,7 @@ export type ThemeName = z.infer<typeof themeName>;
const themeConfigSchema = z.object({
themes: themeName.array().default(viewConfigDefault.theme.themes),
overrides: z.record(z.string()).optional(),
overrides: z.record(z.string(), z.string()).optional(),
});
export type ThemeConfig = z.infer<typeof themeConfigSchema>;
@@ -128,9 +133,7 @@ export const viewConfigSchema = z
.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,
),
interaction_mode: interactionModeSchema,
})
.default(viewConfigDefault.default_reset),
@@ -144,5 +147,5 @@ export const viewConfigSchema = z
viewConfigDefault.keyboard_shortcuts,
),
})
.merge(actionsSchema)
.extend(actionsSchema.shape)
.default(viewConfigDefault);
+1 -2
View File
@@ -27,7 +27,6 @@ export const viewerConfigDefault = {
zoomable: true,
transition_effect: 'slide' as const,
snapshot_click_plays_clip: true,
display_mode: 'single' as const,
controls: {
builtin: true,
next_previous: {
@@ -106,6 +105,6 @@ export const viewerConfigSchema = z
})
.default(viewerConfigDefault.controls),
})
.merge(actionsSchema)
.extend(actionsSchema.shape)
.default(viewerConfigDefault);
export type ViewerConfig = z.infer<typeof viewerConfigSchema>;
+16 -28
View File
@@ -7,36 +7,24 @@ export interface BrowseMediaMetadata {
endDate?: Date;
what?: string[];
}
// Recursive type, cannot use type interference:
// See: https://github.com/colinhacks/zod#recursive-types
//
// Server side data-type defined here: https://github.com/home-assistant/core/blob/dev/homeassistant/components/media_player/browse_media.py#L90
export const browseMediaSchema = z.object({
title: z.string(),
media_class: z.string(),
media_content_type: z.string(),
media_content_id: z.string(),
can_play: z.boolean(),
can_expand: z.boolean(),
children_media_class: z.string().nullable().optional(),
thumbnail: z.string().nullable(),
export interface BrowseMedia {
title: string;
media_class: string;
media_content_type: string;
media_content_id: string;
can_play: boolean;
can_expand: boolean;
children_media_class?: string | null;
thumbnail: string | null;
children?: BrowseMedia[] | null;
}
export const browseMediaSchema: z.ZodSchema<BrowseMedia> = z.lazy(() =>
z.object({
title: z.string(),
media_class: z.string(),
media_content_type: z.string(),
media_content_id: z.string(),
can_play: z.boolean(),
can_expand: z.boolean(),
children_media_class: z.string().nullable().optional(),
thumbnail: z.string().nullable(),
children: z.array(browseMediaSchema).nullable().optional(),
}),
);
get children() {
// Recursive schema.
return z.array(browseMediaSchema).nullable().optional();
},
});
export type BrowseMedia = z.infer<typeof browseMediaSchema>;
export interface RichBrowseMedia<M> extends BrowseMedia {
_metadata?: M;
+6 -6
View File
@@ -247,16 +247,16 @@ export const getChildrenFromElement = (parent: HTMLElement): HTMLElement[] => {
return children.filter(isHTMLElement);
};
export const recursivelyMergeObjectsNotArrays = <T>(target: T, src1: T, src2: T): T => {
return mergeWith(target, src1, src2, (_a, b) => (Array.isArray(b) ? b : undefined));
export const recursivelyMergeObjectsNotArrays = <T>(
...srcs: (Partial<T> | undefined | null)[]
): T => {
return mergeWith({}, ...srcs, (_a, b) => (Array.isArray(b) ? b : undefined));
};
export const recursivelyMergeObjectsConcatenatingArraysUniquely = <T>(
target: T,
src1: T,
src2: T,
...srcs: (Partial<T> | undefined | null)[]
): T => {
return mergeWith(target, src1, src2, (a, b) =>
return mergeWith({}, ...srcs, (a, b) =>
Array.isArray(a) ? uniq(a.concat(b)) : undefined,
);
};
-114
View File
@@ -1,114 +0,0 @@
import { z } from 'zod';
/**
* Recursively remove defaults from a zod schema.
*
* See: https://github.com/colinhacks/zod/discussions/845#discussioncomment-1936943
*
* @param schema The Zod schema.
* @returns A new Zod schema.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function deepRemoveDefaults<T extends z.ZodTypeAny>(schema: T): any {
if (schema instanceof z.ZodDefault) {
return deepRemoveDefaults(schema.removeDefault());
}
if (schema instanceof z.ZodObject) {
const newShape = {};
for (const key in schema.shape) {
const fieldSchema = schema.shape[key];
newShape[key] = z.ZodOptional.create(deepRemoveDefaults(fieldSchema));
}
return new z.ZodObject({
...schema._def,
shape: () => newShape,
});
}
if (schema instanceof z.ZodArray) {
return z.ZodArray.create(deepRemoveDefaults(schema.element))
.min(schema._def.minLength?.value, schema._def.minLength?.message)
.max(schema._def.maxLength?.value, schema._def.maxLength?.message)
.length(schema._def.exactLength?.value, schema._def.exactLength?.message);
}
if (schema instanceof z.ZodOptional) {
return z.ZodOptional.create(deepRemoveDefaults(schema.unwrap()));
}
if (schema instanceof z.ZodNullable) {
return z.ZodNullable.create(deepRemoveDefaults(schema.unwrap()));
}
if (schema instanceof z.ZodTuple) {
return z.ZodTuple.create(
schema.items.map((item: z.ZodTypeAny) => deepRemoveDefaults(item)),
);
}
return schema;
}
/**
* Get the keys that didn't parse from a ZodError.
* @param error The zoderror to extract the keys from.
* @returns An array of error keys.
*/
export function getParseErrorKeys<T>(error: z.ZodError<T>): string[] {
const errors = error.format();
return Object.keys(errors).filter((v) => !v.startsWith('_'));
}
/**
* Get configuration parse errors.
* @param error The ZodError object from parsing.
* @returns An array of string error paths.
*/
export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> => {
/* Zod errors involving unions are complex, as Zod may not be able to tell
* where the 'real' error is vs simply a union option not matching. This
* function finds all ZodError "issues" that don't have an error with 'type'
* in that object ('type' is the union discriminator for picture elements,
* the major union in the schema). An array of user-readable error
* locations is returned, or an empty list if none is available. None being
* available suggests the configuration has an error, but we can't tell
* exactly why (or rather Zod simply says it doesn't match any of the
* available unions). This usually suggests the user specified an incorrect
* type name entirely. */
const contenders = new Set<string>();
if (error.issues.length) {
for (const issue of error.issues) {
if (issue.code === 'invalid_union') {
const unionErrors = (issue as z.ZodInvalidUnionIssue).unionErrors;
for (const unionError of unionErrors) {
getParseErrorPaths(unionError).forEach(contenders.add, contenders);
}
} else {
contenders.add(getParseErrorPathString(issue.path));
}
}
}
return contenders;
};
/**
* Convert an array of strings and indices into a more user readable string,
* e.g. [a, 1, b, 2] => 'a[1] -> b[2]'
* @param path An array of strings and numbers.
* @returns A single string.
*/
const getParseErrorPathString = (path: (string | number)[]): string => {
let out = '';
for (let i = 0; i < path.length; i++) {
const item = path[i];
if (typeof item == 'number') {
out += '[' + item + ']';
} else if (out) {
out += ' -> ' + item;
} else {
out = item;
}
}
return out;
};
+147
View File
@@ -0,0 +1,147 @@
import { z } from 'zod';
/**
* This utility intentionally supports classic/full Zod schemas only
* (i.e. schemas created via `import { z } from 'zod'`).
* It does not target `zod/mini` schema instances.
*
* In Zod 4, internal accessors (.shape values, .unwrap() results, etc.)
* return core.$ZodType instead of the classic ZodType.
*/
const toClassic = (schema: z.ZodType | z.core.$ZodType): z.ZodType => {
if (schema instanceof z.ZodType) {
return schema;
}
throw new TypeError('deepRemoveDefaults supports full zod schemas only');
};
/**
* Check whether an object field originally had a default/prefault wrapper,
* meaning it should become optional after stripping. Walks through
* transparent wrappers (nullable, readonly, etc.) to find defaults.
*/
function fieldWasDefaulted(schema: z.ZodType, seen = new Set<z.ZodType>()): boolean {
if (seen.has(schema)) {
return false;
}
seen.add(schema);
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) {
return true;
}
if (schema instanceof z.ZodOptional) {
return false;
}
// Walk through transparent wrappers.
if (schema instanceof z.ZodNullable) {
return fieldWasDefaulted(toClassic(schema.unwrap()), seen);
}
if (schema instanceof z.ZodReadonly) {
return fieldWasDefaulted(toClassic(schema.unwrap()), seen);
}
if (schema instanceof z.ZodNonOptional) {
return fieldWasDefaulted(toClassic(schema.unwrap()), seen);
}
if (schema instanceof z.ZodLazy) {
return fieldWasDefaulted(toClassic(schema.unwrap()), seen);
}
if (schema instanceof z.ZodPipe) {
return fieldWasDefaulted(toClassic(schema.in), seen);
}
if (schema instanceof z.ZodUnion) {
return [...schema.options].some((option) =>
fieldWasDefaulted(toClassic(option), seen),
);
}
return false;
}
/**
* Core recursive implementation. Strips all default/prefault wrappers
* and makes previously-defaulted object fields optional instead.
*/
function strip(schema: z.ZodType, cache: Map<z.ZodType, z.ZodType>): z.ZodType {
const cached = cache.get(schema);
if (cached) {
return cached;
}
// Seed the cache with a forward reference before recursing so cycles
// (including getter-based recursive objects) do not overflow the stack.
const reference: { schema: z.ZodType } = { schema };
const forward = z.lazy(() => reference.schema);
cache.set(schema, forward);
let result: z.ZodType;
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) {
// Unwrap the default — don't cache the wrapper itself.
result = strip(toClassic(schema.unwrap()), cache);
} else if (schema instanceof z.ZodObject) {
const newShape: Record<string, z.core.$ZodType> = {};
for (const [key, field] of Object.entries(schema.shape)) {
const classicField = toClassic(field);
const stripped = strip(classicField, cache);
const makeOptional =
fieldWasDefaulted(classicField) && !(stripped instanceof z.ZodOptional);
newShape[key] = makeOptional ? stripped.optional() : stripped;
}
result = z.clone(schema, { ...schema.def, shape: newShape });
} else if (schema instanceof z.ZodArray) {
result = z.clone(schema, {
...schema.def,
element: strip(toClassic(schema.element), cache),
});
} else if (schema instanceof z.ZodTuple) {
result = z.clone(schema, {
...schema.def,
items: schema.def.items.map((item) => strip(toClassic(item), cache)),
rest: schema.def.rest ? strip(toClassic(schema.def.rest), cache) : null,
});
} else if (schema instanceof z.ZodUnion) {
const options = [...schema.options].map((opt) => strip(toClassic(opt), cache));
result = z.clone(schema, { ...schema.def, options });
} else if (schema instanceof z.ZodLazy) {
result = z.lazy(() => strip(toClassic(schema.unwrap()), cache));
} else if (schema instanceof z.ZodPipe) {
result = z.clone(schema, {
...schema.def,
in: strip(toClassic(schema.in), cache),
out: strip(toClassic(schema.out), cache),
});
} else if (
schema instanceof z.ZodOptional ||
schema instanceof z.ZodNullable ||
schema instanceof z.ZodReadonly ||
schema instanceof z.ZodNonOptional ||
schema instanceof z.ZodCatch ||
schema instanceof z.ZodSuccess ||
schema instanceof z.ZodPromise
) {
// All of these are single-child wrappers with .unwrap().
result = z.clone(schema, {
...schema.def,
innerType: strip(toClassic(schema.unwrap()), cache),
});
} else {
// Leaf types (string, number, boolean, enum, literal, etc.).
result = schema;
}
reference.schema = result;
cache.set(schema, result);
return result;
}
/**
* Recursively strips `z.default()` and `z.prefault()` wrappers from a schema.
* Object fields that had defaults become optional instead.
* Uses a cache to safely handle recursive (z.lazy) schemas.
*/
export function deepRemoveDefaults<T extends z.ZodType>(
schema: T,
cache = new Map<z.ZodType, z.ZodType>(),
): T {
return strip(schema, cache) as T;
}
+43
View File
@@ -0,0 +1,43 @@
import { z } from 'zod';
/**
* Get configuration parse errors.
* @param error The ZodError object from parsing.
* @returns A set of string error paths.
*/
export const getParseErrorPaths = <T>(error: z.ZodError<T>): Set<string> => {
/* Zod errors involving unions are complex, as Zod may not be able to tell
* where the 'real' error is vs simply a union option not matching. This
* function recursively extracts all error paths from all branches of a union.
* It returns a Set of dot-notation strings. If no paths are found, it suggests
* the configuration has an error but Zod cannot tell exactly why (usually an
* entirely incorrect type name). */
const contenders = new Set<string>();
if (error.issues.length) {
for (const issue of error.issues) {
if (issue.code === 'invalid_union') {
const unionErrors = (issue as z.core.$ZodIssueInvalidUnion).errors;
for (const issues of unionErrors) {
const nestedPaths = getParseErrorPaths(new z.ZodError(issues));
const prefix = z.core.toDotPath(issue.path);
nestedPaths.forEach((path) => {
contenders.add(prefix ? `${prefix}.${path}` : path);
});
}
} else {
contenders.add(z.core.toDotPath(issue.path));
}
}
}
return contenders;
};
/**
* Get configuration parse errors.
* @param error The ZodError object from parsing.
* @returns A string error message or null.
*/
export const getParseError = <T>(error: z.ZodError<T>): string | null => {
const paths = getParseErrorPaths(error);
return paths.size === 0 ? null : JSON.stringify([...paths], null, ' ');
};