import { isEqual } from 'lodash-es'; import { copyConfig, isConfigUpgradeable } from '../../config/management.js'; import { setProfiles } from '../../config/profiles/set-profiles.js'; import { advancedCameraCardConfigSchema, type AdvancedCameraCardConfig, type CardWideConfig, } from '../../config/schema/types.js'; import type { RawAdvancedCameraCardConfig } from '../../config/types.js'; import { localize } from '../../localize/localize.js'; import { getParseError } from '../../utils/zod/parse-errors.js'; import { InitializationAspect } from '../initialization-manager.js'; import type { CardConfigAPI } from '../types.js'; import { ConfigParseError } from './error.js'; import { setAutomationsFromConfig } from './load-automations.js'; import { setRemoteControlEntityFromConfig } from './load-control-entities.js'; import { setFoldersFromConfig } from './load-folders.js'; import { setKeyboardShortcutsFromConfig } from './load-keyboard-shortcuts.js'; import { OverridesManager } from './overrides-manager.js'; export class ConfigManager { private _api: CardConfigAPI; // The main base configuration object. For most usecases use getConfig() to // get the correct configuration (which will return overrides as appropriate). // This variable must be called `_config` or `config` to be compatible with // card-mod. private _config: AdvancedCameraCardConfig | null = null; private _overriddenConfig: AdvancedCameraCardConfig | null = null; private _rawConfig: RawAdvancedCameraCardConfig | null = null; private _cardWideConfig: CardWideConfig | null = null; private _overridesManager = new OverridesManager(() => this._processOverrideConfig()); constructor(api: CardConfigAPI) { this._api = api; } public hasConfig(): boolean { return !!this.getConfig(); } public getConfig(): AdvancedCameraCardConfig | null { return this._overriddenConfig ?? this._config; } public getCardWideConfig(): CardWideConfig | null { return this._cardWideConfig; } public getNonOverriddenConfig(): AdvancedCameraCardConfig | null { return this._config; } public getRawConfig(): RawAdvancedCameraCardConfig | null { return this._rawConfig; } public setConfig(inputConfig?: RawAdvancedCameraCardConfig): void { if (!inputConfig) { throw new ConfigParseError(localize('error.invalid_configuration')); } const parseResult = advancedCameraCardConfigSchema.safeParse(inputConfig); if (!parseResult.success) { const hint = getParseError(parseResult.error); let upgradeMessage = ''; if (isConfigUpgradeable(inputConfig)) { upgradeMessage = `${localize('error.upgrade_available')}. `; } throw new ConfigParseError( upgradeMessage + `${localize('error.invalid_configuration')}: ` + (hint ?? localize('error.invalid_configuration_no_hint')), ); } // The config is cloned here because Zod 4 returns shared constant // defaults by reference. Since setProfiles() mutates the configuration // in-place, those mutations would "pollute" the global defaults and break // test isolation if we didn't use a fresh clone here. const config = setProfiles( inputConfig, copyConfig(parseResult.data), parseResult.data.profiles, ); this._rawConfig = inputConfig; if (isEqual(this._config, config)) { return; } this._config = config; this._cardWideConfig = { performance: config.performance, debug: config.debug, }; this._overridesManager.set( this._api.getConditionStateManager(), this._config.overrides, ); this._api.getConditionStateManager().setState({ view: undefined, displayMode: undefined, camera: undefined, }); this._api.getMediaLoadedInfoManager().clear(); this._api.getInitializationManager().uninitialize(InitializationAspect.VIEW); this._api.getViewManager().reset(); this._api.getStatusBarItemManager().removeAllDynamicStatusBarItems(); this._processOverrideConfig(); this._api.getCardElementManager().update(); } private _processOverrideConfig(): void { /* istanbul ignore if: No (current) way to reach this code -- @preserve */ if (!this._config) { return; } let overriddenConfig: AdvancedCameraCardConfig; try { overriddenConfig = this._overridesManager.getConfig(this._config); } catch (ev) { this._api.getIssueManager().trigger('config_error', { error: ev }); return; } // Clear any prior config error on success. This method runs both from // setConfig() and from condition-change-driven override recomputations; // resetting here ensures a transient override error is cleared when // conditions change to produce a valid config again. this._api.getIssueManager().reset('config_error'); // Save on Lit re-rendering costs by only updating the configuration if it // actually changes. if (isEqual(overriddenConfig, this._overriddenConfig)) { return; } const previousConfig = this._overriddenConfig; this._overriddenConfig = overriddenConfig; setFoldersFromConfig(this._api); this._api.getStyleManager().updateFromConfig(); // Only re-run side-effect callbacks when their relevant config section // changed. As an example of why: Automation loaders delete then recreate // automations, which destroys associated ConditionsManagers. If a condition // transition (e.g. microphone connect) triggers both a user automation and // an unrelated override, an unconditional reload would delete the // automation mid-transition -- the freshly created replacement has no prior // state, treats the current condition as its baseline, and never fires the // action. const runIfChanged = ( extract: (config: AdvancedCameraCardConfig) => T, callback: () => void, skipFirst?: boolean, ): void => { if (!previousConfig) { if (!skipFirst) { callback(); } return; } if (!isEqual(extract(previousConfig), extract(overriddenConfig))) { callback(); } }; runIfChanged( (config) => config.view.keyboard_shortcuts, () => setKeyboardShortcutsFromConfig(this._api), ); runIfChanged( (config) => config.remote_control, () => setRemoteControlEntityFromConfig(this._api), ); runIfChanged( (config) => config.automations, () => setAutomationsFromConfig(this._api), ); runIfChanged( (config) => [config.cameras, config.cameras_global], () => { this._api.getInitializationManager().uninitialize(InitializationAspect.CAMERAS); void this._api.getCameraManager().destroy(); }, true, ); runIfChanged( (config) => config.live.microphone.always_connected, () => { this._api .getInitializationManager() .uninitialize(InitializationAspect.MICROPHONE_CONNECT); }, true, ); void this._initializeBackgroundAndUpdate(previousConfig); } /** * Initialize config dependent items in the background. For items that the * card hard requires, use InitializationManager instead. */ private async _initializeBackgroundAndUpdate( previousConfig: AdvancedCameraCardConfig | null, ): Promise { await this._api.getDefaultManager().initializeIfNecessary(previousConfig); await this._api.getMediaPlayerManager().initializeIfNecessary(previousConfig); // The config is only set in the state if the card is already fully // initialized. If not, the config will be set post initialization in the // InitializationManager. if ( this._overriddenConfig && this._api.getInitializationManager().isInitializedMandatory() ) { this._api.getConditionStateManager().setState({ config: this._overriddenConfig, }); } this._api.getCardElementManager().update(); } }