Files
advanced-camera-card/src/config/management.ts
T

1919 lines
63 KiB
TypeScript

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 = <T>(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: <condition>`) 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__.<path>` (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:
* <any-change for each leaf of C>, 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<RawAdvancedCameraCardConfig>((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: '<any string>' } 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,
];