import { cloneDeep, get, isEqual, set, unset } from 'lodash-es'; import { arrayify, arrayMove, isRecord } from '../utils/basic'; import { CONF_AUTOMATIONS, CONF_CAMERAS, CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT, CONF_CAMERAS_GLOBAL_PTZ, CONF_DIMENSIONS_HEIGHT, CONF_ELEMENTS, CONF_OVERRIDES, CONF_PROFILES, CONF_STATUS_BAR, CONF_UPGRADE_FAILURE, CONF_VIEW_DEFAULT_CYCLE_CAMERA, CONF_VIEW_DEFAULT_RESET_ENTITIES, CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, CONF_VIEW_DIM, CONF_VIEW_INTERACTION_SECONDS, CONF_VIEW_TRIGGERS, CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, } from './const'; import { getCompositeConditionsKey } from './schema/condition-trigger/conditions/composite'; import type { RawAdvancedCameraCardConfig, RawAdvancedCameraCardConfigArray, } from './types'; // ************************************************************************* // General Config Management Functions // ************************************************************************* /** * Set a configuration value. * @param obj The configuration. * @param keys The key to the property to set. * @param value The value to set. */ export const setConfigValue = ( obj: RawAdvancedCameraCardConfig, keys: string | (string | number)[], value: unknown, ): void => { set(obj, keys, value); }; /** * Get a configuration value. * @param obj The configuration. * @param keys The key to the property to retrieve. * @param def Default if key not found. * @returns The property or undefined if not found. */ export const getConfigValue = ( obj: RawAdvancedCameraCardConfig, keys: string | (string | number)[], def?: unknown, ): unknown => { return get(obj, keys, def); }; /** * Delete a configuration value. * @param obj The configuration. * @param key The key to the property to delete. */ export const deleteConfigValue = ( obj: RawAdvancedCameraCardConfig, path: string | (string | number)[], ): void => { unset(obj, path); }; /** * Copy a configuration. * @param obj Configuration to copy. * @returns A new deeply-copied configuration. */ export const copyConfig = (obj: T): T => { return cloneDeep(obj); }; // Apply an in-place mutation to the array at `arrayPath`, returning whether the // mutation reported a change. const mutateConfigArray = ( obj: RawAdvancedCameraCardConfig, arrayPath: string | (string | number)[], mutator: (array: unknown[]) => boolean, ): boolean => { const array = getConfigValue(obj, arrayPath); return Array.isArray(array) && mutator(array); }; /** * Append an item to a configuration array. The array is created when the * configuration does not have one at that path yet. * @param obj The configuration to modify. * @param arrayPath The configuration path of the array. * @param item The item to append. * @returns `true` if the configuration was modified. */ export const addConfigArrayItem = ( obj: RawAdvancedCameraCardConfig, arrayPath: string | (string | number)[], item: unknown, ): boolean => { const array = getConfigValue(obj, arrayPath); if (!Array.isArray(array)) { setConfigValue(obj, arrayPath, [item]); return true; } array.push(item); return true; }; /** * Move an item within a configuration array. * @param obj The configuration to modify. * @param arrayPath The configuration path of the array. * @param fromIndex The index of the item to move. * @param toIndex The index to move the item to. * @returns `true` if the configuration was modified. */ export const moveConfigArrayItem = ( obj: RawAdvancedCameraCardConfig, arrayPath: string | (string | number)[], fromIndex: number, toIndex: number, ): boolean => mutateConfigArray(obj, arrayPath, (array) => { if ( !Number.isInteger(fromIndex) || !Number.isInteger(toIndex) || fromIndex === toIndex || fromIndex < 0 || toIndex < 0 || fromIndex >= array.length || toIndex >= array.length ) { return false; } arrayMove(array, fromIndex, toIndex); return true; }); /** * Delete an item from a configuration array. * @param obj The configuration to modify. * @param arrayPath The configuration path of the array. * @param index The index of the item to delete. * @returns `true` if the configuration was modified. */ export const deleteConfigArrayItem = ( obj: RawAdvancedCameraCardConfig, arrayPath: string | (string | number)[], index: number, ): boolean => mutateConfigArray(obj, arrayPath, (array) => { if (!Number.isInteger(index) || index < 0 || index >= array.length) { return false; } array.splice(index, 1); return true; }); // ************************************************************************* // Upgrade Related Functions // ************************************************************************* /** * Upgrade a configuration. * @param obj The configuration to upgrade. * @returns `true` if the configuration is modified. */ export const upgradeConfig = function (obj: RawAdvancedCameraCardConfig): boolean { let upgraded = false; for (let i = 0; i < UPGRADES.length; i++) { upgraded = UPGRADES[i](obj) || upgraded; } return upgraded; }; /** * Determine if a configuration is automatically upgradeable. * @param obj The configuration. It is not modified. * @returns `true` if the configuration is upgradeable. */ export const isConfigUpgradeable = function (obj: RawAdvancedCameraCardConfig): boolean { return upgradeConfig(copyConfig(obj)); }; /** * Move a property from one location to another. * @param obj The configuration object in which the property resides. * @param oldPath The old property path. * @param newPath The new property path. * @param transform An optional transform for the value. * @returns `true` if the configuration was modified. */ export const moveConfigValue = ( obj: RawAdvancedCameraCardConfig, oldPath: string, newPath: string, options?: { transform?: (valueIn: unknown) => unknown; keepOriginal?: boolean; }, ): boolean => { const inValue = getConfigValue(obj, oldPath); if (inValue === undefined) { return false; } const outValue = options?.transform ? options.transform(inValue) : inValue; if (oldPath === newPath && isEqual(inValue, outValue)) { return false; } if (outValue === null) { if (!options?.keepOriginal) { deleteConfigValue(obj, oldPath); return true; } return false; } if (outValue !== undefined) { if (!options?.keepOriginal) { deleteConfigValue(obj, oldPath); } setConfigValue(obj, newPath, outValue); return true; } return false; }; /** * Upgrade by moving a property from one location to another. * @param oldPath The old property path. * @param newPath The new property path. * @param transform An optional transform for the value. * @returns `true` if the configuration was modified. */ export const upgradeMoveTo = function ( oldPath: string, newPath: string, options?: { transform?: (valueIn: unknown) => unknown; keepOriginal?: boolean; }, ): (obj: RawAdvancedCameraCardConfig) => boolean { return function (obj: RawAdvancedCameraCardConfig): boolean { return moveConfigValue(obj, oldPath, newPath, options); }; }; /** * Upgrade by moving a property from one location to another, and moving a * property specified in a top-level overrides object. * @param oldPath The old property path. * @param newPath The new property path. * @param transform An optional transform for the value. * @returns A function that returns `true` if the configuration was modified. */ export const upgradeMoveToWithOverrides = function ( oldPath: string, newPath: string, options?: { transform?: (valueIn: unknown) => unknown; keepOriginal?: boolean; }, ): (obj: RawAdvancedCameraCardConfig) => boolean { return function (obj: RawAdvancedCameraCardConfig): boolean { let modified = upgradeMoveTo(oldPath, newPath, options)(obj); modified = upgradeArrayOfObjects( CONF_OVERRIDES, upgradeMoveTo(oldPath, newPath, options), (obj) => obj.merge && typeof obj.merge === 'object' ? (obj.merge as RawAdvancedCameraCardConfig | undefined) : undefined, )(obj) || modified; modified = upgradeArrayOfObjects( CONF_OVERRIDES, upgradeMoveTo(oldPath, newPath, options), (obj) => obj.set && typeof obj.set === 'object' ? (obj.set as RawAdvancedCameraCardConfig | undefined) : undefined, )(obj) || modified; return modified; }; }; /** * Upgrade a property in place with overrides. * @param path The old property path. * @param transform An optional transform for the value. * @returns A function that returns `true` if the configuration was modified. */ export const upgradeWithOverrides = function ( path: string, transform: (valueIn: unknown) => unknown, ): (obj: RawAdvancedCameraCardConfig) => boolean { return upgradeMoveToWithOverrides(path, path, { transform: transform }); }; /** * Delete a property in place with overrides. * @param path The property path. * @returns A function that returns `true` if the configuration was modified. */ export const deleteWithOverrides = function ( path: string, ): (obj: RawAdvancedCameraCardConfig) => boolean { // eslint-disable-next-line @typescript-eslint/no-unused-vars return upgradeMoveToWithOverrides(path, path, { transform: (_) => null }); }; /** * Given a path to an array, apply an upgrade to each object in the array. * @param arrayPath The path to the array to upgrade. * @param upgrade A function that applies an upgrade to an object. * @param getObject A optional function that takes an item in the array and * returns the object to modify within it. * @returns A function that returns `true` if the configuration was modified. */ export const upgradeArrayOfObjects = function ( arrayPath: string, upgrade: (obj: RawAdvancedCameraCardConfig) => boolean, getObject?: ( obj: RawAdvancedCameraCardConfig, ) => RawAdvancedCameraCardConfig | undefined, ): (obj: RawAdvancedCameraCardConfig) => boolean { return function (obj: RawAdvancedCameraCardConfig): boolean { let modified = false; const array = getConfigValue(obj, arrayPath); if (Array.isArray(array)) { array.forEach((item) => { const object = getObject ? getObject(item) : item; if (object && typeof object === 'object') { modified = upgrade(object) || modified; } }); } return modified; }; }; /** * Recursively upgrade an object. The entries recorded under * {@link CONF_UPGRADE_FAILURE} are the user's own configuration, kept for them * to migrate by hand, so this never descends into them. * @param transform A transform applied to each object recursively. * @param getObject A function to get the object to be upgraded. * @returns An upgrade function. */ export const upgradeObjectRecursively = ( transform: (data: RawAdvancedCameraCardConfig) => boolean, getObject?: ( data: RawAdvancedCameraCardConfig, ) => RawAdvancedCameraCardConfig | undefined | null, ): ((data: RawAdvancedCameraCardConfig) => boolean) => { const recurse = (data: RawAdvancedCameraCardConfig): boolean => { let result = false; if (data && typeof data === 'object') { const object = getObject ? getObject(data) : data; if (object) { result = transform(object) || result; } if (Array.isArray(data)) { data.forEach((item: RawAdvancedCameraCardConfig) => { result = recurse(item) || result; }); } else { Object.keys(data).forEach((key) => { if (key === CONF_UPGRADE_FAILURE) { return; } result = recurse(data[key] as RawAdvancedCameraCardConfig) || result; }); } } return result; }; return recurse; }; // ************************************************************************* // Upgrade Related Functions: Generic Transforms // ************************************************************************* /** * Create a transform that will cap a numeric value. * @param value The value. * @returns A number or null. */ export const createRangedTransform = function ( transform: (value: unknown) => unknown, min?: number, max?: number, ): (valueIn: unknown) => unknown { return (value: unknown): unknown => { let transformed = transform(value); if (typeof transformed !== 'number') { return transformed; } transformed = min !== undefined ? Math.max(min, transformed as number) : transformed; transformed = max !== undefined ? Math.min(max, transformed as number) : transformed; return transformed; }; }; /** * Request a property be deleted. * @param _value Inbound value (not required). * @returns `null` to request the property be deleted. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars export const deleteTransform = function (_value: unknown): number | null | undefined { return null; }; // ************************************************************************* // Upgrade Related Functions: Specific Transforms / Upgraders // ************************************************************************* /** * Transform a single object with multiple conditions to multiple objects with * single conditions * @param data Input data. * @returns `true` if the configuration was modified. */ const conditionToConditionsTransform = (data: unknown): boolean => { if (!isRecord(data) || !isRecord(data['conditions'])) { return false; } const oldConditions = data['conditions']; // The legacy values are copied across unvalidated; the schema rejects // anything malformed when the migrated configuration is later parsed. const newConditions: RawAdvancedCameraCardConfig[] = []; if (oldConditions['view'] !== undefined) { newConditions.push({ condition: 'view' as const, views: oldConditions['view'], }); } if (oldConditions['fullscreen'] !== undefined) { newConditions.push({ condition: 'fullscreen' as const, fullscreen: oldConditions['fullscreen'], }); } if (oldConditions['expand'] !== undefined) { newConditions.push({ condition: 'expand' as const, expand: oldConditions['expand'], }); } if (oldConditions['camera'] !== undefined) { newConditions.push({ condition: 'camera' as const, cameras: oldConditions['camera'], }); } if (oldConditions['media_loaded'] !== undefined) { newConditions.push({ condition: 'media_loaded' as const, media_loaded: oldConditions['media_loaded'], }); } if (oldConditions['state'] !== undefined && Array.isArray(oldConditions['state'])) { for (const stateCondition of oldConditions['state']) { if ( isRecord(stateCondition) && (stateCondition['state'] !== undefined || stateCondition['state_not'] !== undefined || stateCondition['entity'] !== undefined) ) { newConditions.push({ condition: 'state' as const, ...(!!stateCondition['state'] && { state: stateCondition['state'] }), ...(!!stateCondition['state_not'] && { state_not: stateCondition['state_not'], }), ...(!!stateCondition['entity'] && { entity_id: stateCondition['entity'] }), }); } } } if (oldConditions['media_query'] !== undefined) { newConditions.push({ condition: 'screen' as const, media_query: oldConditions['media_query'], }); } // These conditions did not exist prior to v6.0.0 and so are not converted: // - display_mode // - triggered // - interaction // - microphone if (newConditions.length) { data['conditions'] = newConditions; return true; } return false; }; const isCompositeCondition = (condition: unknown): boolean => getCompositeConditionsKey(condition) !== null; // Triggers are a flat OR list with no composites, so a composite condition is // reduced to its leaf conditions for the trigger list (the composite itself is // retained on the `conditions:` side). const flattenConditionLeaves = (condition: unknown): unknown[] => { const key = getCompositeConditionsKey(condition); if (!key || !isRecord(condition)) { return [condition]; } return arrayify(condition[key]).flatMap(flattenConditionLeaves); }; // Whether promoting this condition to a trigger would lose something, so the // automation keeps the condition. `user` and `user_agent` have no trigger at // all; a `state` condition holding both `state` and `state_not` loses one, // since a state trigger carries `to` or `not_to`, never both. const mustRetainCondition = (condition: unknown): boolean => { if (!isRecord(condition)) { return false; } const kind = condition['condition']; if (kind === 'user' || kind === 'user_agent') { return true; } if (kind === 'state' || kind === undefined) { return condition['state'] !== undefined && condition['state_not'] !== undefined; } return false; }; // A condition that fired on a *change* rather than describing an ongoing state // was really a trigger (the legacy "conditions-as-triggers" model). Migration // promotes it to a trigger and drops it from the retained `conditions:`. // // Note: `config` is no longer a condition; and although the current schema // reads a valueless `camera` as "any camera selected", a *legacy* valueless // `camera` meant the change, so it is still trigger-only here). const isTriggerOnlyCondition = (condition: unknown): boolean => { if (!isRecord(condition)) { return false; } const kind = condition['condition']; if (kind === 'config') { // `config` is no longer a condition at all. return true; } if (kind === 'camera') { return !(Array.isArray(condition['cameras']) && condition['cameras'].length); } if (kind === 'view') { return !(Array.isArray(condition['views']) && condition['views'].length); } if (kind === 'state' || kind === undefined) { return condition['state'] === undefined && condition['state_not'] === undefined; } return false; }; // Drop trigger-only conditions from a retained `conditions:` list, recursing // into composites and discarding any the recursion empties. const dropTriggerOnlyConditions = (conditions: unknown[]): unknown[] => { const kept: unknown[] = []; for (const condition of conditions) { const key = getCompositeConditionsKey(condition); if (key && isRecord(condition)) { const children = arrayify(condition[key]); const inner = dropTriggerOnlyConditions(children); // An empty `or` never matches and an empty `and` always does, so a // composite the user wrote empty is kept; only one the recursion emptied // is dropped. if (inner.length || !children.length) { // `arrayify` has already normalised `children`, so rebuilding a // composite the recursion left alone would rewrite Home Assistant's // single-condition spelling (`or: `) into a list the user // never wrote. kept.push(isEqual(inner, children) ? condition : { ...condition, [key]: inner }); } continue; } if (!isTriggerOnlyCondition(condition)) { kept.push(condition); } } return kept; }; const rewriteConditionAsTrigger = (condition: unknown): unknown => { if (!isRecord(condition)) { return condition; } const kind = condition['condition']; // Only the renamed fields are consumed; anything else the condition carries // (`enabled`, and the fields it already shares with its trigger) is preserved, // so promoting never silently discards user configuration. const withoutKeys = (...keys: string[]): RawAdvancedCameraCardConfig => { const rest = { ...condition }; for (const key of keys) { delete rest[key]; } return rest; }; // A `state` condition maps onto the HA state trigger (`state` -> `to`, // `state_not` -> `not_to`). A discriminator-less condition is the bare // picture-element state form -- the only condition that may omit `condition`. if (kind === 'state' || kind === undefined) { const entityId = condition['entity_id'] ?? condition['entity']; const matchFields = mustRetainCondition(condition) ? {} : { ...(condition['state'] !== undefined && { to: condition['state'] }), ...(condition['state_not'] !== undefined && { not_to: condition['state_not'], }), }; return { trigger: 'state', ...withoutKeys('condition', 'entity', 'entity_id', 'state', 'state_not'), ...(entityId !== undefined && { entity_id: entityId }), ...matchFields, }; } // `user` and `user_agent` are fixed for the session, so no trigger matches // them. if (kind === 'user' || kind === 'user_agent') { return null; } // A `call` condition describes a phase; as a trigger it is the arrival at // that phase. if (kind === 'call') { return { trigger: 'call', ...withoutKeys('condition', 'call'), ...(condition['call'] !== undefined && { to: condition['call'] }), }; } // Every other condition -- the stock `numeric_state`/`template` and all the // card-specific kinds -- shares its field names with the matching trigger // (only `state` and `call` involve internal field renames), so promoting is // just a discriminator swap. return { trigger: kind, ...withoutKeys('condition') }; }; // Home Assistant accepts the singular `trigger`/`condition`/`action` keys as // aliases for the plural ones (the schema renames them), and a single item in // place of a list. Migration reads whichever spelling the automation was written // in so that it can write back under the same key. const getAutomationList = ( automation: RawAdvancedCameraCardConfig, plural: string, singular: string, ): { key: string; items: unknown[] } => { // Mirroring the schema's rename, the singular key is honoured only when the // plural is absent; an automation with neither is written under the plural. const key = singular in automation && !(plural in automation) ? singular : plural; return { key, items: arrayify(automation[key]) }; }; const hasAutomationTriggers = (automation: RawAdvancedCameraCardConfig): boolean => 'triggers' in automation || 'trigger' in automation; /** * Promote an automation's `conditions:` into HA-native `triggers:`. * * A single condition that its trigger fully expresses becomes that trigger, and * the `conditions:` block is dropped. Everything else -- multiple conditions, a * composite, or a condition the trigger cannot express -- becomes one trigger * per leaf, and the original `conditions:` are retained as an ongoing predicate * (dual-list) minus any trigger-only forms, which would no longer be valid * conditions. Idempotent: an automation that already has triggers is left * untouched. */ const promoteConditionsToTriggersTransform = (data: unknown): boolean => { if (!isRecord(data) || hasAutomationTriggers(data)) { return false; } const { key, items: conditions } = getAutomationList(data, 'conditions', 'condition'); if (!conditions.length) { return false; } if ( conditions.length === 1 && !isCompositeCondition(conditions[0]) && !mustRetainCondition(conditions[0]) ) { data['triggers'] = [rewriteConditionAsTrigger(conditions[0])]; delete data[key]; return true; } const triggers = conditions .flatMap(flattenConditionLeaves) .map(rewriteConditionAsTrigger) .filter((trigger) => trigger !== null); // Conditions that produce no trigger never change after startup, so // evaluating them once at startup is faithful. data['triggers'] = triggers.length ? triggers : [{ trigger: 'initialized' }]; const ongoing = dropTriggerOnlyConditions(conditions); if (ongoing.length) { data[key] = ongoing; } else { delete data[key]; } return true; }; /** * Whether the upgrade recorded any config it could not faithfully convert, * under {@link CONF_UPGRADE_FAILURE} (only ever written non-empty). The config * is not modified. * @param obj The configuration. * @returns `true` if any failures remain. */ export const hasConfigUpgradeFailures = ( obj: RawAdvancedCameraCardConfig | null, ): boolean => { const failures = obj?.[CONF_UPGRADE_FAILURE]; return isRecord(failures) && Object.keys(failures).length > 0; }; /** * Record entries the upgrade could not faithfully convert under * `__UPGRADE_FAILURE__.` (the namespace shadows the main config -- see * {@link CONF_UPGRADE_FAILURE}), appending to any already recorded there. * @param data The configuration, modified in place. * @param path The config path the entries came from (e.g. `automations`). * @param failures The original entries, recorded untouched. */ const addUpgradeFailures = ( data: RawAdvancedCameraCardConfig, path: string, failures: unknown[], ): void => { const upgradeFailures = isRecord(data[CONF_UPGRADE_FAILURE]) ? data[CONF_UPGRADE_FAILURE] : {}; const existing = upgradeFailures[path]; upgradeFailures[path] = [...(Array.isArray(existing) ? existing : []), ...failures]; data[CONF_UPGRADE_FAILURE] = upgradeFailures; }; // `template`/`screen` conditions have no "any change" trigger -- their only // trigger fires on the rising edge alone (HA's own template/numeric_state // triggers behave identically, and HA has no `screen` trigger at all). An // automation resting on one cannot re-fire when it stops matching, so its // migrated `else` will not run on that falling edge. const RISING_EDGE_ONLY_CONDITIONS = ['template', 'screen']; // Build the "fire on any change" trigger that drives a migrated `if`/`then`/ // `else` for a single condition leaf, plus whether that trigger only sees the // rising edge. Returns a null trigger for conditions that cannot change at // runtime (`user`/`user_agent`), which therefore contribute none. const synthesizeAnyChangeTrigger = ( leaf: unknown, ): { trigger: RawAdvancedCameraCardConfig | null; risingEdgeOnly: boolean } => { if (!isRecord(leaf)) { return { trigger: null, risingEdgeOnly: false }; } const kind = leaf['condition'] ?? 'state'; // Static within a session: no runtime change, so no trigger. if (kind === 'user' || kind === 'user_agent') { return { trigger: null, risingEdgeOnly: false }; } // Entity-backed: a plain `state` watch (no `to`) fires on every change of the // entity, so the wrapped `if(state)`/`if(numeric_state)` re-evaluates on both // edges -- the same trigger a user would hand-write in Home Assistant. if (kind === 'state' || kind === 'numeric_state') { const entityId = leaf['entity_id'] ?? leaf['entity']; if (entityId !== undefined) { return { trigger: { trigger: 'state', entity_id: entityId }, risingEdgeOnly: false, }; } } // `config` is trigger-only; its `paths` scope a config-change watch (still any // change), so they are preserved rather than dropped like a match value. if (kind === 'config') { const paths = leaf['paths']; return { trigger: { trigger: 'config', ...(paths !== undefined && { paths }) }, risingEdgeOnly: false, }; } // Rising-edge-only kinds (and a `numeric_state` with only a `value_template`, // which has no entity to watch): best-effort reuse of their own trigger. if ( (typeof kind === 'string' && RISING_EDGE_ONLY_CONDITIONS.includes(kind)) || kind === 'numeric_state' ) { const rest = { ...leaf }; delete rest['condition']; return { trigger: { trigger: kind, ...rest }, risingEdgeOnly: true }; } // Card-state kinds: the valueless trigger fires on any change. return { trigger: { trigger: kind }, risingEdgeOnly: false }; }; // Synthesize the deduplicated set of "any change" triggers for the condition // leaves. Conditions that are all static never change after startup, so a // single `initialized` evaluation is faithful. const synthesizeAnyChangeTriggers = ( conditions: unknown[], ): RawAdvancedCameraCardConfig[] => { const triggers: RawAdvancedCameraCardConfig[] = []; for (const leaf of conditions.flatMap(flattenConditionLeaves)) { const { trigger } = synthesizeAnyChangeTrigger(leaf); if (trigger && !triggers.some((existing) => isEqual(existing, trigger))) { triggers.push(trigger); } } if (!triggers.length) { triggers.push({ trigger: 'initialized' }); } return triggers; }; // A condition leaf whose only trigger fires on the rising edge (`template`/ // `screen`, or a `numeric_state` with no entity to watch) cannot drive the // `else` branch when it stops matching, so such an automation cannot be // faithfully converted. const hasRisingEdgeOnlyCondition = (conditions: unknown[]): boolean => conditions .flatMap(flattenConditionLeaves) .some((leaf) => synthesizeAnyChangeTrigger(leaf).risingEdgeOnly); /** * Convert one legacy `actions_not` automation in place to an HA-native * `if`/`then`/`else` action, or report that it failed to convert. * * `{ conditions: C, actions: A, actions_not: B }` becomes `{ triggers: * , actions: [{ if: C, then: A, else: B }] }`: * the `if` retains both branches and the synthesized triggers re-evaluate it on * every change of the conditions. When `C` has no ongoing predicate for the * `if` to test -- it is absent, or holds only trigger-only conditions (legacy * change-detectors such as a bare `camera` or a `config` condition) -- the * `else` branch could never run, so `actions_not` is dropped rather than * wrapped. Conditions with a rising-edge-only leaf are returned as `'failed'`, * untouched, because their `else` cannot be reproduced faithfully. Idempotent: * a converted automation has no `actions_not` left to reconvert. */ const convertActionsNotAutomation = ( automation: RawAdvancedCameraCardConfig, ): 'converted' | 'failed' => { const { key: conditionsKey, items: conditions } = getAutomationList( automation, 'conditions', 'condition', ); if (!conditions.length) { // No conditions -- `actions_not` could never have run; it is simply dropped. delete automation['actions_not']; return 'converted'; } if (hasRisingEdgeOnlyCondition(conditions)) { return 'failed'; } const actionsNot = automation['actions_not']; const { key: actionsKey, items: actions } = getAutomationList( automation, 'actions', 'action', ); automation['triggers'] = synthesizeAnyChangeTriggers(conditions); delete automation['actions_not']; // The conditions move *into* the `if` below; they must not also remain as a // top-level ongoing condition, which would block the automation (and so the // `else` branch) whenever they fail -- exactly the case `else` exists to handle. delete automation[conditionsKey]; // The `if` tests only the ongoing predicates; dropping the trigger-only // conditions can leave nothing, in which case `else` could never run. const ongoing = dropTriggerOnlyConditions(conditions); if (!ongoing.length) { automation[actionsKey] = actions; return 'converted'; } automation[actionsKey] = [ { if: ongoing, then: actions, ...(Array.isArray(actionsNot) && { else: actionsNot }), }, ]; return 'converted'; }; /** * Migrate every legacy `actions_not` automation: convert the faithful ones in * place to `if`/`then`/`else`, and record the rest -- conditions with a * rising-edge-only leaf, whose `else` cannot be reproduced -- as failures, * untouched, under `__UPGRADE_FAILURE__.automations` for the user to migrate by * hand. Runs before * the conditions->triggers promotion, which then skips the converted ones (they * now have `triggers:`) and never sees the failed ones. */ const migrateActionsNotTransform = (data: unknown): boolean => { if (!isRecord(data) || !Array.isArray(data[CONF_AUTOMATIONS])) { return false; } const kept: unknown[] = []; const failed: unknown[] = []; let modified = false; for (const automation of data[CONF_AUTOMATIONS]) { if (isRecord(automation) && 'actions_not' in automation) { modified = true; if (convertActionsNotAutomation(automation) === 'failed') { failed.push(automation); continue; } } kept.push(automation); } if (!modified) { return false; } data[CONF_AUTOMATIONS] = kept; if (failed.length) { addUpgradeFailures(data, CONF_AUTOMATIONS, failed); } return true; }; // Picture-element conditional wrapper types (shared with upgradePTZElementsToLive). const CONDITIONAL_ELEMENT_TYPES = [ 'conditional', 'custom:advanced-camera-card-conditional', ]; const isConditionalElementType = (type: unknown): boolean => typeof type === 'string' && CONDITIONAL_ELEMENT_TYPES.includes(type); // Strip the trigger-only conditions from a single entry's `conditions:`, in // place. `keep` is false when stripping emptied a non-empty `conditions:`, // meaning the entry has no meaningful conditions left and the caller should drop // it; an already-empty `conditions:` (or an entry with none) is the user's and // left untouched. const stripTriggerOnlyConditionsFromEntry = ( entry: RawAdvancedCameraCardConfig, ): { keep: boolean; modified: boolean } => { const original = entry['conditions']; if (!Array.isArray(original)) { return { keep: true, modified: false }; } const stripped = dropTriggerOnlyConditions(original); if (original.length && !stripped.length) { return { keep: false, modified: true }; } if (!isEqual(stripped, original)) { entry['conditions'] = stripped; return { keep: true, modified: true }; } return { keep: true, modified: false }; }; // Strip trigger-only conditions from the conditional elements in a picture- // element tree, recursing into the kept conditionals. Conditional elements // nest, so this is recursive; overrides are a flat list handled inline by the // parent transform. const stripTriggerOnlyConditionsFromElements = ( elements: RawAdvancedCameraCardConfigArray, ): { elements: RawAdvancedCameraCardConfigArray; modified: boolean } => { let modified = false; const kept: RawAdvancedCameraCardConfigArray = []; for (const element of elements) { if ( typeof element === 'object' && element && isConditionalElementType(element['type']) ) { const { keep, modified: entryModified } = stripTriggerOnlyConditionsFromEntry(element); modified = entryModified || modified; if (!keep) { continue; } if (Array.isArray(element['elements'])) { const inner = stripTriggerOnlyConditionsFromElements(element['elements']); modified = inner.modified || modified; element['elements'] = inner.elements; } } kept.push(element); } return { elements: kept, modified }; }; /** * Drop the now-invalid trigger-only conditions (including the removed `config` * condition) from the `conditions:` of overrides and conditional elements. An * entry whose conditions become empty has no meaningful conditions left, so it * is dropped entirely. Automations are handled by the promote transform. */ const stripTriggerOnlyConditionsFromOverridesElementsTransform = ( data: unknown, ): boolean => { if (!isRecord(data)) { return false; } let modified = false; const overrides = data[CONF_OVERRIDES]; if (Array.isArray(overrides)) { let overridesModified = false; const kept: RawAdvancedCameraCardConfigArray = []; for (const override of overrides) { if (typeof override === 'object' && override) { const { keep, modified: entryModified } = stripTriggerOnlyConditionsFromEntry(override); overridesModified = entryModified || overridesModified; if (!keep) { continue; } } kept.push(override); } if (overridesModified) { data[CONF_OVERRIDES] = kept; modified = true; } } const elements = data[CONF_ELEMENTS]; if (Array.isArray(elements)) { const result = stripTriggerOnlyConditionsFromElements(elements); if (result.modified) { modified = true; if (result.elements.length) { data[CONF_ELEMENTS] = result.elements; } else { delete data[CONF_ELEMENTS]; } } } return modified; }; // Legacy nested trigger template paths -> the HA-native top-level `trigger.*`. const TRIGGER_TEMPLATE_PATH_REWRITES: { suffix: string; modern: string }[] = [ { suffix: 'trigger.state.entity', modern: 'trigger.entity_id' }, { suffix: 'trigger.state.from', modern: 'trigger.from_state.state' }, { suffix: 'trigger.state.to', modern: 'trigger.to_state.state' }, { suffix: 'trigger.camera.from', modern: 'trigger.from_acc.camera' }, { suffix: 'trigger.camera.to', modern: 'trigger.to_acc.camera' }, { suffix: 'trigger.view.from', modern: 'trigger.from_acc.view' }, { suffix: 'trigger.view.to', modern: 'trigger.to_acc.view' }, { suffix: 'trigger.config.from', modern: 'trigger.from_acc.config' }, { suffix: 'trigger.config.to', modern: 'trigger.to_acc.config' }, ]; // Both the released `acc` alias and the full `advanced_camera_card` namespace are // migrated. const TRIGGER_TEMPLATE_PREFIXES = ['acc.', 'advanced_camera_card.']; const rewriteTriggerTemplatePaths = (value: string): string => { let result = value; for (const prefix of TRIGGER_TEMPLATE_PREFIXES) { for (const { suffix, modern } of TRIGGER_TEMPLATE_PATH_REWRITES) { result = result.replaceAll(prefix + suffix, modern); } } return result; }; // Apply a string rewrite to every template-string value of a single object, in // place. Only touches values containing a nunjucks delimiter (`{{` expression // or `{%` statement), i.e. template strings. const rewriteTemplateStrings = ( data: RawAdvancedCameraCardConfig, rewrite: (value: string) => string, ): boolean => { let modified = false; for (const key of Object.keys(data)) { const value = data[key]; if (typeof value === 'string' && (value.includes('{{') || value.includes('{%'))) { const rewritten = rewrite(value); if (rewritten !== value) { data[key] = rewritten; modified = true; } } } return modified; }; /** * Rewrite the legacy nested `acc.trigger.*` / `advanced_camera_card.trigger.*` * template paths to the top-level `trigger.*` surface, in place on a single * object's string values. Idempotent (a migrated path matches no legacy * pattern). * * @returns `true` if any value was rewritten. */ const migrateTriggerTemplatePathsTransform = ( data: RawAdvancedCameraCardConfig, ): boolean => rewriteTemplateStrings(data, rewriteTriggerTemplatePaths); /** * Retire the ambient `advanced_camera_card.*` template namespace in favour of its * shorter `acc` alias (the only spelling the trigger surface uses), rewriting the * prefix in a single object's string values. Idempotent. * * @returns `true` if any value was rewritten. */ const migrateAmbientTemplateNamespaceTransform = ( data: RawAdvancedCameraCardConfig, ): boolean => rewriteTemplateStrings(data, (value) => value.replaceAll('advanced_camera_card.', 'acc.'), ); const callServiceToPerformActionTransform = (data: unknown): boolean => { if ( !isRecord(data) || data['action'] !== 'call-service' || typeof data['service'] !== 'string' ) { return false; } data['action'] = 'perform-action'; data['perform_action'] = data['service']; delete data['service']; return true; }; /** * Transform service_data -> data * See: https://github.com/dermotduffy/advanced-camera-card/issues/1103 * @param data Input data. * @returns `true` if the configuration was modified. */ const serviceDataToDataTransform = (data: unknown): boolean => { if ( isRecord(data) && data['action'] === 'call-service' && data['service'] !== undefined && data['service_data'] !== undefined && data['data'] === undefined ) { data['data'] = data['service_data']; delete data['service_data']; return true; } return false; }; /** * Transform element PTZ to native live PTZ. * @param data Input data. * @returns `true` if the configuration was modified. */ const upgradePTZElementsToLive = function (): (data: unknown) => boolean { return function (data: unknown): boolean { if ( !isRecord(data) || !(CONF_ELEMENTS in data) || !Array.isArray(data[CONF_ELEMENTS]) ) { return false; } let foundPTZ = false; const movePTZ = (element: RawAdvancedCameraCardConfig): void => { if (!foundPTZ) { if (!get(data, 'live.controls.ptz')) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { type: _, ...newPTZ } = element; set(data, 'live.controls.ptz', newPTZ); } foundPTZ = true; } }; const processElements = ( elements: RawAdvancedCameraCardConfigArray, ): RawAdvancedCameraCardConfigArray => { const newElements: RawAdvancedCameraCardConfigArray = []; for (const element of elements) { if (element['type'] === 'custom:advanced-camera-card-ptz') { movePTZ(element); } else if ( isConditionalElementType(element['type']) && Array.isArray(element['elements']) ) { const newConditionalElements = processElements(element['elements']); if (newConditionalElements.length) { element['elements'] = newConditionalElements; newElements.push(element); } } else { newElements.push(element); } } return newElements; }; const newElements = processElements(data[CONF_ELEMENTS]); if (foundPTZ) { if (newElements.length) { data[CONF_ELEMENTS] = newElements; } else { delete data[CONF_ELEMENTS]; } } return foundPTZ; }; }; // Upgrade old internal `data_*_stop` / `data_*_start` keys to // WebRTC-compatible `data_end_*` / `data_start_*` ordering. // WebRTC uses `data_start_left` / `data_end_left` (not `data_left_start` / // `data_left_stop`). // See: https://github.com/dermotduffy/advanced-camera-card/issues/2385 // See: https://github.com/AlexxIT/WebRTC/blob/master/custom_components/webrtc/www/webrtc-camera.js const ptzIncorrectDataToWebRTCDataTransform = (data: unknown): unknown => { if (!isRecord(data)) { return undefined; } let modified = false; const out = { ...data }; for (const key of Object.keys(out)) { const match = key.match(/^data_(.+)_(start|stop)$/); if (match) { const phase = match[2] === 'stop' ? 'end' : match[2]; const webrtcKey = `data_${phase}_${match[1]}`; if (!(webrtcKey in out)) { out[webrtcKey] = out[key]; } delete out[key]; modified = true; } } return modified ? out : undefined; }; const ptzActionsToCamerasGlobalTransform = (data: unknown): unknown => { if (!isRecord(data)) { return undefined; } const NON_PRESET_DATA_KEYS = [ 'data_left', 'data_right', 'data_up', 'data_down', 'data_zoom_in', 'data_zoom_out', 'service', ]; const NON_PRESET_ACTION_KEYS = [ // 'actions_' will overwrite 'data_*' if there's duplication. 'actions_left', 'actions_right', 'actions_up', 'actions_down', 'actions_zoom_in', 'actions_zoom_out', ]; const PRESET_TRANSFORM_KEYS = ['data_home', 'actions_home']; const TRANSFORM_KEYS = [ ...NON_PRESET_DATA_KEYS, ...NON_PRESET_ACTION_KEYS, ...PRESET_TRANSFORM_KEYS, ]; const keys = Object.keys(data); const hasTransformable = keys.some((key) => TRANSFORM_KEYS.includes(key)); if (!hasTransformable) { return undefined; } const output: RawAdvancedCameraCardConfig = {}; NON_PRESET_DATA_KEYS.filter((key) => key in data).reduce((obj, key) => { obj[key] = data[key]; return obj; }, output); NON_PRESET_ACTION_KEYS.filter((key) => key in data).reduce((obj, key) => { const action = data[key]; if (isRecord(action) && 'tap_action' in action) { obj[key] = action['tap_action']; } return obj; }, output); // Returns the preset collection so callers can add to it after it is // attached to the output. const createPresets = (): RawAdvancedCameraCardConfig => { const existing = data['presets']; const presets = isRecord(existing) ? existing : {}; output['presets'] = presets; return presets; }; const actionsHome = data['actions_home']; const dataHome = data['data_home']; if (isRecord(actionsHome) && 'tap_action' in actionsHome) { createPresets()['home'] = actionsHome['tap_action']; } else if (isRecord(dataHome) && typeof data['service'] === 'string') { const presets = createPresets(); presets['service'] = data['service']; presets['data_home'] = dataHome; } return output; }; const ptzControlSettingsTransform = (data: unknown): unknown => { if (!isRecord(data)) { return data; } const TRANSFORM_KEYS = [ 'mode', 'position', 'orientation', 'hide_pan_tilt', 'hide_zoom', 'hide_home', 'hide_type', 'style', 'type', ]; const keys = Object.keys(data); const hasSomethingToFilter = keys.some((key) => !TRANSFORM_KEYS.includes(key)); if (!hasSomethingToFilter) { return undefined; } return keys .filter((key) => TRANSFORM_KEYS.includes(key)) .reduce((obj, key) => { obj[key] = data[key]; return obj; }, {}); }; const titleControlTransform = (data: unknown): unknown => { if (!isRecord(data) || typeof data['mode'] !== 'string') { return null; } if (data['mode'] === 'none') { return { style: 'none', }; } if (data['mode'].includes('bottom')) { return { position: 'bottom', }; } else if (data['mode'].includes('top')) { return { position: 'top', }; } return null; }; const frigateCardToAdvancedCameraCardTransform = ( data: RawAdvancedCameraCardConfig, ): boolean => { interface Substitution { oldKey: string; oldValue?: string; newKey?: string; newValue?: string; } const substitutions: Substitution[] = [ { oldKey: 'type', oldValue: 'custom:frigate-card', newValue: 'custom:advanced-camera-card', }, { oldKey: 'action', oldValue: 'custom:frigate-card-action', newValue: 'custom:advanced-camera-card-action', }, { oldKey: 'type', oldValue: 'custom:frigate-card-menu-icon', newValue: 'custom:advanced-camera-card-menu-icon', }, { oldKey: 'type', oldValue: 'custom:frigate-card-menu-state-icon', newValue: 'custom:advanced-camera-card-menu-state-icon', }, { oldKey: 'type', oldValue: 'custom:frigate-card-menu-submenu', newValue: 'custom:advanced-camera-card-menu-submenu', }, { oldKey: 'type', oldValue: 'custom:frigate-card-menu-submenu-select', newValue: 'custom:advanced-camera-card-menu-submenu-select', }, { oldKey: 'type', oldValue: 'custom:frigate-card-status-bar-icon', newValue: 'custom:advanced-camera-card-status-bar-icon', }, { oldKey: 'type', oldValue: 'custom:frigate-card-status-bar-image', newValue: 'custom:advanced-camera-card-status-bar-image', }, { oldKey: 'type', oldValue: 'custom:frigate-card-status-bar-string', newValue: 'custom:advanced-camera-card-status-bar-string', }, { oldKey: 'type', oldValue: 'custom:frigate-card-conditional', newValue: 'custom:advanced-camera-card-conditional', }, { oldKey: 'type', oldValue: 'custom:frigate-card-conditional', newValue: 'custom:advanced-camera-card-conditional', }, { oldKey: 'frigate_card_action', newKey: 'advanced_camera_card_action', }, ]; let modified = false; for (const substitution of substitutions) { if ( substitution.oldValue && substitution.newValue && data[substitution.oldKey] === substitution.oldValue ) { data[substitution.newKey ?? substitution.oldKey] = substitution.newValue; modified = true; } if (substitution.oldKey in data && substitution.newKey) { data[substitution.newKey] = data[substitution.oldKey]; delete data[substitution.oldKey]; modified = true; } } return modified; }; // Unify the legacy trio `live_substream_{on,off,select}` into the new // `substream_{on,off}` pair. `live_substream_select` carried the substream ID // in its `camera` field; that field becomes `stream` on `substream_on`. const substreamActionsUnifyTransform = (data: RawAdvancedCameraCardConfig): boolean => { if ( data['action'] !== 'fire-dom-event' && data['action'] !== 'custom:advanced-camera-card-action' ) { return false; } const action = data['advanced_camera_card_action']; if (action === 'live_substream_on') { data['advanced_camera_card_action'] = 'substream_on'; return true; } if (action === 'live_substream_off') { data['advanced_camera_card_action'] = 'substream_off'; return true; } if (action === 'live_substream_select') { data['advanced_camera_card_action'] = 'substream_on'; if ('camera' in data) { data['stream'] = data['camera']; delete data['camera']; } return true; } return false; }; const frigateCardToAdvancedCameraCardStyleTransform = (data: unknown): unknown => { if (!isRecord(data) || Array.isArray(data)) { return data; } const newStyleOverrides = { ...data }; const frigateCardStyleRegexp = new RegExp(/^--frigate-card-/); for (const key of Object.keys(data)) { if (key.match(frigateCardStyleRegexp)) { const newKey = key.replace(frigateCardStyleRegexp, '--advanced-camera-card-'); newStyleOverrides[newKey] = data[key]; delete newStyleOverrides[key]; } } return newStyleOverrides; }; // Legacy `triggers.events: string[]` (Frigate engine media-availability filter) // was renamed to `triggers.media_events` to free up `triggers.events` for the // new HA-bus-event trigger list (object shape). Distinguish old from new by // element type: an array holding at least one string is legacy; any non-string // element marks the new shape and must not be touched. An empty list says // nothing about which of the two it is, and is valid under the new schema, so // it is left alone -- a user who deletes their last event trigger is not // offered an upgrade that would rename the key underneath them. If // `media_events` already exists we refuse to overwrite it -- but we still drop // the legacy `events` (otherwise it would fail the new schema, which expects // objects). const triggersEventsToMediaEventsTransform = (triggers: unknown): unknown => { if (!isRecord(triggers)) { return undefined; } const events = triggers['events']; if ( !Array.isArray(events) || !events.length || events.some((x) => typeof x !== 'string') ) { return undefined; } const result = { ...triggers }; delete result['events']; if (!('media_events' in result)) { result['media_events'] = events; } return result; }; const REMOVED_MICROPHONE_ACTIONS = ['microphone_connect', 'microphone_disconnect']; // The properties that hold actions: the tap handlers of elements, menu buttons, // notification controls and views (`actionsBaseSchema`), the actions of an // automation, and the branches of an `if` action. Each holds either a single // action or a list of them. const ACTION_PROPERTIES = [ 'actions', 'double_tap_action', 'else', 'end_tap_action', 'hold_action', 'start_tap_action', 'tap_action', 'then', ]; // The action properties that are required wherever they appear, so removing // their last action must leave an empty list. const REQUIRED_ACTION_PROPERTIES = ['actions', 'then']; const isRemovedMicrophoneAction = (data: unknown): boolean => isRecord(data) && (data['action'] === 'fire-dom-event' || data['action'] === 'custom:advanced-camera-card-action') && typeof data['advanced_camera_card_action'] === 'string' && REMOVED_MICROPHONE_ACTIONS.includes(data['advanced_camera_card_action']); /** * Remove the `microphone_connect` / `microphone_disconnect` actions wherever * they appear. The whole tree is walked because card actions can appear * anywhere (menu buttons, elements, automations, view-action handlers, etc.), * but only the properties that hold actions are touched, so an object that * merely resembles an action -- the `data` of a `perform-action`, for * instance -- is left as the user wrote it. * * A property that empties is left as an empty list where it is required * (`automations[].actions`, an `if` action's `then`), and deleted elsewhere. An * empty list is valid wherever one is accepted. */ const removeMicrophoneActionsTransform = (data: unknown): boolean => { // Arrays are records too, so their entries are walked by the loop below. if (!isRecord(data)) { return false; } let modified = false; for (const key of Object.keys(data)) { if (ACTION_PROPERTIES.includes(key)) { const value = data[key]; if (isRemovedMicrophoneAction(value)) { if (REQUIRED_ACTION_PROPERTIES.includes(key)) { data[key] = []; } else { delete data[key]; } modified = true; continue; } if (Array.isArray(value)) { const kept = value.filter((item) => !isRemovedMicrophoneAction(item)); if (kept.length !== value.length) { data[key] = kept; modified = true; } } } // The entries recorded under `__UPGRADE_FAILURE__` are kept as the user // wrote them. if (key !== CONF_UPGRADE_FAILURE) { modified = removeMicrophoneActionsTransform(data[key]) || modified; } } return modified; }; /** * Remove values from an array. The array is kept even when it empties: inside * an override, deleting it would restore whatever the base configuration says * rather than leaving nothing. An array that holds none of the values is left * alone. */ const removeFromArrayTransform = ( ...removed: unknown[] ): ((value: unknown) => unknown) => { return (value: unknown): unknown => { if (!Array.isArray(value) || !value.some((item) => removed.includes(item))) { return undefined; } return value.filter((item) => !removed.includes(item)); }; }; const UPGRADES = [ // v5.2.0 -> v6.0.0 (data: unknown): boolean => { return upgradeObjectRecursively(serviceDataToDataTransform)( isRecord(data) ? data : {}, ); }, upgradePTZElementsToLive(), upgradeMoveToWithOverrides('view.timeout_seconds', CONF_VIEW_INTERACTION_SECONDS), upgradeWithOverrides('live.lazy_unload', (data) => data === 'all' ? ['unselected', 'hidden'] : data === 'never' ? null : arrayify(data), ), upgradeWithOverrides('live.auto_play', (data) => data === 'all' ? null : data === 'never' ? [] : arrayify(data), ), upgradeWithOverrides('live.auto_pause', (data) => data === 'all' ? ['unselected', 'hidden'] : data === 'never' ? null : arrayify(data), ), upgradeWithOverrides('live.auto_mute', (data) => data === 'all' ? null : data === 'never' ? [] : arrayify(data), ), upgradeWithOverrides('live.auto_unmute', (data) => data === 'all' ? ['selected', 'visible', 'microphone'] : data === 'never' ? null : arrayify(data), ), upgradeWithOverrides('media_viewer.auto_play', (data) => data === 'all' ? null : data === 'never' ? [] : arrayify(data), ), upgradeWithOverrides('media_viewer.auto_pause', (data) => data === 'all' ? null : data === 'never' ? [] : arrayify(data), ), upgradeWithOverrides('media_viewer.auto_mute', (data) => data === 'all' ? null : data === 'never' ? [] : arrayify(data), ), upgradeWithOverrides('media_viewer.auto_unmute', (data) => data === 'all' ? ['selected', 'visible'] : data === 'never' ? null : arrayify(data), ), upgradeMoveToWithOverrides( 'live.controls.thumbnails.media', 'live.controls.thumbnails.events_media_type', ), upgradeMoveToWithOverrides('timeline.media', 'timeline.events_media_type'), upgradeMoveToWithOverrides( 'live.controls.timeline.media', 'live.controls.timeline.events_media_type', ), upgradeMoveToWithOverrides( 'media_viewer.controls.timeline.media', 'media_viewer.controls.timeline.events_media_type', ), upgradeMoveToWithOverrides('view.scan', CONF_VIEW_TRIGGERS), upgradeMoveToWithOverrides( 'view.triggers.enabled', CONF_VIEW_TRIGGERS_ACTIONS_TRIGGER, { transform: (data) => (data === true ? 'live' : null), // Keep it around, for the following transform. keepOriginal: true, }, ), upgradeMoveToWithOverrides( 'view.triggers.enabled', CONF_VIEW_TRIGGERS_FILTER_SELECTED_CAMERA, { transform: (data) => (data === true ? false : null), }, ), upgradeMoveToWithOverrides( 'view.triggers.untrigger_reset', CONF_VIEW_TRIGGERS_ACTIONS_UNTRIGGER, { // Delete the value if it's set to the default. transform: (val) => (val ? 'default' : null), }, ), upgradeMoveToWithOverrides('live.layout', CONF_CAMERAS_GLOBAL_DIMENSIONS_LAYOUT), deleteWithOverrides('media_viewer.layout'), deleteWithOverrides('image.layout'), upgradeArrayOfObjects(CONF_OVERRIDES, conditionToConditionsTransform), (data: unknown): boolean => { const elements = isRecord(data) ? data[CONF_ELEMENTS] : null; return upgradeObjectRecursively(conditionToConditionsTransform)( isRecord(elements) ? elements : {}, ); }, (data: unknown): boolean => { const automations = isRecord(data) ? data[CONF_AUTOMATIONS] : null; return upgradeObjectRecursively(conditionToConditionsTransform)( isRecord(automations) ? automations : {}, ); }, upgradeArrayOfObjects( CONF_CAMERAS, upgradeMoveToWithOverrides('hide', 'capabilities', { transform: (val) => (val === true ? { disable_except: ['substream'] } : null), }), ), upgradeMoveToWithOverrides('performance.profile', CONF_PROFILES, { // Delete the value if it's set to the default. transform: (val) => (val === 'low' ? ['low-performance'] : null), }), upgradeArrayOfObjects(CONF_OVERRIDES, upgradeMoveTo('overrides', 'merge')), upgradeMoveToWithOverrides('live.controls.ptz', CONF_CAMERAS_GLOBAL_PTZ, { transform: ptzActionsToCamerasGlobalTransform, keepOriginal: true, }), upgradeWithOverrides('live.controls.ptz', ptzControlSettingsTransform), upgradeMoveToWithOverrides('view.update_cycle_camera', CONF_VIEW_DEFAULT_CYCLE_CAMERA), upgradeMoveToWithOverrides( 'view.update_force', CONF_VIEW_DEFAULT_RESET_INTERACTION_MODE, { transform: (val) => (val === true ? 'all' : null), }, ), upgradeMoveToWithOverrides( 'view.update_seconds', CONF_VIEW_DEFAULT_RESET_EVERY_SECONDS, ), upgradeMoveToWithOverrides('view.update_entities', CONF_VIEW_DEFAULT_RESET_ENTITIES), upgradeMoveTo('live.controls.title', CONF_STATUS_BAR, { transform: titleControlTransform, }), deleteWithOverrides('live.controls.title'), deleteWithOverrides('media_viewer.controls.title'), // Upgrade call-service calls throughout the card config. They could show up // attached to any element, any automation, or any card/view action (i.e. very // broadly across the config), so it's challenging to better target this // upgrade. As written, this will convert things that look like call-service // calls recurseively throughout the whole card config, but this could // conceivably be an overreach if (e.g.) some totally unrelated object has { // action: 'call-service', service: '' } that means something // different. (data: unknown): boolean => { return upgradeObjectRecursively(callServiceToPerformActionTransform)( isRecord(data) ? data : {}, ); }, upgradeMoveToWithOverrides('dimensions.max_height', CONF_DIMENSIONS_HEIGHT), deleteWithOverrides('dimensions.min_height'), // v6.1.2+ upgradeMoveToWithOverrides('view.dark_mode', CONF_VIEW_DIM, { transform: (val) => val === 'on', }), // v7.0.0+ (data: unknown): boolean => { return upgradeObjectRecursively(frigateCardToAdvancedCameraCardTransform)( isRecord(data) ? data : {}, ); }, upgradeWithOverrides( 'view.theme.overrides', frigateCardToAdvancedCameraCardStyleTransform, ), upgradeMoveToWithOverrides('menu.buttons.frigate', 'menu.buttons.iris'), // v8.0.0+ upgradeMoveToWithOverrides( 'live.controls.thumbnails.media_type', 'cameras_global.media.type', ), upgradeMoveToWithOverrides( 'live.controls.thumbnails.events_media_type', 'cameras_global.media.events_type', { transform: (val) => { // 'all' is the default, delete it if (val === 'all') { return null; } return val; }, }, ), upgradeMoveToWithOverrides( 'view.triggers.untrigger_seconds', CONF_VIEW_TRIGGERS_UNTRIGGER_DELAY_SECONDS, ), upgradeWithOverrides('cameras_global.ptz', ptzIncorrectDataToWebRTCDataTransform), upgradeArrayOfObjects( CONF_CAMERAS, upgradeWithOverrides('ptz', ptzIncorrectDataToWebRTCDataTransform), ), // Unify `live_substream_{on,off,select}` actions. Walked over the entire // tree because card actions can appear anywhere (menu buttons, elements, // automations, view-action handlers, etc.). (data: unknown): boolean => { return upgradeObjectRecursively(substreamActionsUnifyTransform)( isRecord(data) ? data : {}, ); }, // Legacy `triggers.events: string[]` -> `triggers.media_events`. Targets the // two known places a camera config lives: `cameras_global` and `cameras[]`. // Mirrors the PTZ rename migration above. upgradeWithOverrides('cameras_global.triggers', triggersEventsToMediaEventsTransform), upgradeArrayOfObjects( CONF_CAMERAS, upgradeWithOverrides('triggers', triggersEventsToMediaEventsTransform), ), // Convert `actions_not` automations to an `if`/`then`/`else` action (or record // the unfaithful ones as failures). Runs before the promotion below, which // then skips the converted ones (they gain `triggers:`). migrateActionsNotTransform, // Promote automation `conditions:` into HA-native `triggers:`. Runs last so it // sees conditions in their final, fully-migrated form. upgradeArrayOfObjects(CONF_AUTOMATIONS, promoteConditionsToTriggersTransform), // Drop the now-invalid trigger-only conditions (incl. the removed `config` // condition) from overrides/elements, dropping any entry left ungated. stripTriggerOnlyConditionsFromOverridesElementsTransform, // Rewrite legacy nested trigger template paths to the top-level `trigger.*`. (data: unknown): boolean => { return upgradeObjectRecursively(migrateTriggerTemplatePathsTransform)( isRecord(data) ? data : {}, ); }, // Rewrite the retired ambient `advanced_camera_card.*` namespace to `acc.*`. (data: unknown): boolean => { return upgradeObjectRecursively(migrateAmbientTemplateNamespaceTransform)( isRecord(data) ? data : {}, ); }, // Retire microphone parameters/actions not needed with 'call'. The selection // conditions go with them: the card ends any call when the selected camera // changes, and the microphone only carries audio during a call. // See: https://github.com/dermotduffy/advanced-camera-card/issues/2681 // See: https://github.com/dermotduffy/advanced-camera-card/issues/2679 deleteWithOverrides('live.microphone.disconnect_seconds'), upgradeWithOverrides( 'live.microphone.auto_mute', removeFromArrayTransform('call', 'unselected'), ), upgradeWithOverrides( 'live.microphone.auto_unmute', removeFromArrayTransform('selected'), ), removeMicrophoneActionsTransform, ];