diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index ecb8d7ce..937436ea 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -30,6 +30,21 @@ conditions: | `condition` | Must be `camera`. | | `cameras` | An optional list of camera IDs in which this condition is satisfied. If not specified, any camera change will satisy the condition. See the camera [id](cameras/README.md) parameter. | +## `config` + +Matches when card configuration changes (e.g. on startup, or when [Configuration Overrides](./overrides.md) are applied). + +```yaml +conditions: + - condition: config + # [...] +``` + +| Parameter | Description | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `condition` | Must be `config`. | +| `paths` | An optional array of configuration paths (e.g. `menu.style`). If provided condition matches if _ANY_ of the provided configuration paths has changed. | + ## `expand` Matches based on whether the card is in "expanded" mode. @@ -60,6 +75,21 @@ conditions: | `condition` | Must be `fullscreen`. | | `fullscreen` | If `true` the condition is satisfied if the card is in fullscreen mode. If `false` the condition is satisfied if the card is **NOT** in fullscreen mode. | +## `initialized` + +Matches when the card is first initialized. + +```yaml +conditions: + - condition: initialized +``` + +| Parameter | Description | +| ----------- | ---------------------- | +| `condition` | Must be `initialized`. | + +?> This is exclusively useful for running [automations](./automations.md) on card start. + ## `interaction` Matches based on whether the card has been interacted with. @@ -258,10 +288,14 @@ conditions: - condition: camera cameras: - camera.office + - condition: config + paths: + - "menu.style" - condition: expand expand: true - condition: fullscreen fullscreen: true + - condition: initialized - condition: interaction interaction: true - condition: key diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index 69da0e49..806dcded 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -184,6 +184,18 @@ export class ConfigManager { 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(); } } diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 211a10ea..58f4d01e 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -128,6 +128,18 @@ export class InitializationManager { } this._everInitialized = true; + + // When the card is initialized, both the initialization state (will never + // change again), and the config are set in the condition state. The + // config is set here, rather than in the ConfigManager, in order to + // ensure actions (that trigger on config change) are not run before hass + // is available and the card is initialzied (the first config is set in + // the card *before* hass is set in the card). + this._api.getConditionStateManager().setState({ + config: config, + initialized: this._everInitialized, + }); + this._api.getCardElementManager().update(); } diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 06525b05..e5aae2e8 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -176,6 +176,7 @@ export interface CardInitializerAPI { getMicrophoneManager(): MicrophoneManager; getCardElementManager(): CardElementManager; + getConditionStateManager(): ConditionStateManager; getConfigManager(): ConfigManager; getDefaultManager(): DefaultManager; getEntityRegistryManager(): EntityRegistryManager; diff --git a/src/conditions/conditions-manager.ts b/src/conditions/conditions-manager.ts index 11e4a88b..085aff63 100644 --- a/src/conditions/conditions-manager.ts +++ b/src/conditions/conditions-manager.ts @@ -1,4 +1,5 @@ import { isEqual } from 'lodash-es'; +import { getConfigValue } from '../config/management'; import { AdvancedCameraCardCondition } from '../config/types'; import { isCompanionApp } from '../utils/companion'; import { @@ -146,8 +147,12 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { return { result: !!newState?.state && - ((!condition.state && !condition.state_not) || - (condition.entity in newState.state && + ((!condition.state && + !condition.state_not && + newState.state[condition.entity]?.state !== + oldState?.state?.[condition.entity]?.state) || + ((!!condition.state || !!condition.state_not) && + condition.entity in newState.state && (!condition.state || (Array.isArray(condition.state) ? condition.state.includes(newState.state[condition.entity].state) @@ -281,6 +286,29 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { (condition.user_agent_re === undefined || new RegExp(condition.user_agent_re).test(newState.userAgent)), }; + case 'config': { + return { + result: + !!newState?.config && + newState.config !== oldState?.config && + (!condition.paths?.length || + condition.paths.some( + (key) => + getConfigValue(newState.config!, key) !== + (oldState?.config ? getConfigValue(oldState?.config, key) : undefined), + )), + data: { + config: { + ...((oldState?.config || newState?.config) && { + ...(oldState?.config && { from: oldState?.config }), + ...(newState?.config && { to: newState?.config }), + }), + }, + }, + }; + } + case 'initialized': + return { result: !!newState?.initialized }; } } } diff --git a/src/conditions/types.ts b/src/conditions/types.ts index 7a9c942c..b3f7f182 100644 --- a/src/conditions/types.ts +++ b/src/conditions/types.ts @@ -1,14 +1,16 @@ import { CurrentUser } from '@dermotduffy/custom-card-helpers'; import { HassEntities } from 'home-assistant-js-websocket'; -import { ViewDisplayMode } from '../config/types'; +import { AdvancedCameraCardConfig, ViewDisplayMode } from '../config/types'; import { MediaLoadedInfo } from '../types'; import { KeysState, MicrophoneState } from '../card-controller/types'; export interface ConditionState { camera?: string; + config?: AdvancedCameraCardConfig; displayMode?: ViewDisplayMode; expand?: boolean; fullscreen?: boolean; + initialized?: boolean; interaction?: boolean; keys?: KeysState; mediaLoadedInfo?: MediaLoadedInfo | null; @@ -43,10 +45,16 @@ interface ConditionsEvaluationDataState extends ConditionsEvaluationDataFromTo { entity: string; } +interface ConditionsEvaluationDataConfig { + from?: AdvancedCameraCardConfig; + to?: AdvancedCameraCardConfig; +} + export interface ConditionsEvaluationData { camera?: ConditionsEvaluationDataFromTo; view?: ConditionsEvaluationDataFromTo; state?: ConditionsEvaluationDataState; + config?: ConditionsEvaluationDataConfig; } export interface ConditionsEvaluationResult { result: boolean; diff --git a/src/config/types.ts b/src/config/types.ts index b02a4ec9..ad3e1de9 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -683,43 +683,33 @@ export type StatusBarItem = z.infer; // Custom Element Configuration: Conditions // ************************************************************************* -const viewConditionSchema = z.object({ - condition: z.literal('view'), - views: z.string().array().optional(), -}); -const fullscreenConditionSchema = z.object({ - condition: z.literal('fullscreen'), - fullscreen: z.boolean(), -}); -const expandConditionSchema = z.object({ - condition: z.literal('expand'), - expand: z.boolean(), -}); const cameraConditionSchema = z.object({ condition: z.literal('camera'), cameras: z.string().array().optional(), }); -const mediaLoadedConditionSchema = z.object({ - condition: z.literal('media_loaded'), - media_loaded: z.boolean(), +const configConditionSchema = z.object({ + condition: z.literal('config'), + paths: z.string().array().optional(), }); const displayModeConditionSchema = z.object({ condition: z.literal('display_mode'), display_mode: viewDisplayModeSchema, }); -const triggeredConditionSchema = z.object({ - condition: z.literal('triggered'), - triggered: z.string().array(), +const expandConditionSchema = z.object({ + condition: z.literal('expand'), + expand: z.boolean(), +}); +const fullscreenConditionSchema = z.object({ + condition: z.literal('fullscreen'), + fullscreen: z.boolean(), +}); +const initializedConditionSchema = z.object({ + condition: z.literal('initialized'), }); const interactionConditionSchema = z.object({ condition: z.literal('interaction'), interaction: z.boolean(), }); -const microphoneConditionSchema = z.object({ - condition: z.literal('microphone'), - connected: z.boolean().optional(), - muted: z.boolean().optional(), -}); const keyConditionSchema = z.object({ condition: z.literal('key'), key: z.string(), @@ -729,6 +719,19 @@ const keyConditionSchema = z.object({ alt: z.boolean().optional(), meta: z.boolean().optional(), }); +const mediaLoadedConditionSchema = z.object({ + condition: z.literal('media_loaded'), + media_loaded: z.boolean(), +}); +const microphoneConditionSchema = z.object({ + condition: z.literal('microphone'), + connected: z.boolean().optional(), + muted: z.boolean().optional(), +}); +const triggeredConditionSchema = z.object({ + condition: z.literal('triggered'), + triggered: z.string().array(), +}); const userAgentConditionSchema = z.object({ condition: z.literal('user_agent'), user_agent: z.string().optional(), @@ -748,26 +751,32 @@ const userAgentConditionSchema = z.object({ .optional(), companion: z.boolean().optional(), }); +const viewConditionSchema = z.object({ + condition: z.literal('view'), + views: z.string().array().optional(), +}); export const advancedCameraCardConditionSchema = z.discriminatedUnion('condition', [ // Stock conditions: - stateConditionSchema, numericStateConditionSchema, screenConditionSchema, + stateConditionSchema, usersConditionSchema, // Custom conditions: - viewConditionSchema, - fullscreenConditionSchema, - expandConditionSchema, cameraConditionSchema, - mediaLoadedConditionSchema, + configConditionSchema, displayModeConditionSchema, - triggeredConditionSchema, + expandConditionSchema, + fullscreenConditionSchema, + initializedConditionSchema, interactionConditionSchema, - microphoneConditionSchema, keyConditionSchema, + mediaLoadedConditionSchema, + microphoneConditionSchema, + triggeredConditionSchema, userAgentConditionSchema, + viewConditionSchema, ]); export type AdvancedCameraCardCondition = z.infer< typeof advancedCameraCardConditionSchema diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index f435fffb..f6dacdf8 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -349,6 +349,8 @@ describe('ConfigManager', () => { it('should initialize background items', async () => { const api = createCardAPI(); const stateManager = new ConditionStateManager(); + const listener = vi.fn(); + stateManager.addListener(listener); vi.mocked(api.getConditionStateManager).mockReturnValue(stateManager); const manager = new ConfigManager(api); @@ -371,13 +373,24 @@ describe('ConfigManager', () => { expect(api.getDefaultManager().initializeIfNecessary).toBeCalledTimes(1); expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledTimes(1); + expect(listener).not.toBeCalledWith( + expect.objectContaining({ change: { config: expect.anything() } }), + ); + vi.mocked(api.getInitializationManager().isInitializedMandatory).mockReturnValue( + true, + ); stateManager.setState({ fullscreen: true }); await flushPromises(); expect(api.getDefaultManager().initializeIfNecessary).toBeCalledTimes(2); expect(api.getMediaPlayerManager().initializeIfNecessary).toBeCalledTimes(2); + + // Should set the config condition state. + expect(listener).toBeCalledWith( + expect.objectContaining({ change: { config: expect.anything() } }), + ); }); }); }); diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index 62f3ed51..07563280 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -4,6 +4,7 @@ import { InitializationAspect, InitializationManager, } from '../../src/card-controller/initialization-manager'; +import { ConditionStateManager } from '../../src/conditions/state-manager'; import { loadLanguages } from '../../src/localize/localize'; import { sideLoadHomeAssistantElements } from '../../src/utils/ha'; import { Initializer } from '../../src/utils/initializer/initializer'; @@ -67,9 +68,15 @@ describe('InitializationManager', () => { }); it('successfully', async () => { + const stateListener = vi.fn(); + const stateMananger = new ConditionStateManager(); + stateMananger.addListener(stateListener); + const api = createCardAPI(); + vi.mocked(api.getConditionStateManager).mockReturnValue(stateMananger); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); - vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig()); + const config = createConfig(); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue(config); vi.mocked(api.getMessageManager().hasMessage).mockReturnValue(false); vi.mocked(api.getQueryStringManager().hasViewRelatedActionsToRun).mockReturnValue( false, @@ -93,6 +100,15 @@ describe('InitializationManager', () => { expect(api.getCardElementManager().update).toBeCalled(); expect(manager.wasEverInitialized()).toBeTruthy(); + + expect(stateListener).toBeCalledWith( + expect.objectContaining({ + change: { + initialized: true, + config, + }, + }), + ); }); it('successfully with microphone if configured', async () => { diff --git a/tests/conditions/conditions-manager.test.ts b/tests/conditions/conditions-manager.test.ts index 4713562a..9207aae8 100644 --- a/tests/conditions/conditions-manager.test.ts +++ b/tests/conditions/conditions-manager.test.ts @@ -2,7 +2,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { MicrophoneState } from '../../src/card-controller/types'; import { ConditionsManager } from '../../src/conditions/conditions-manager'; import { ConditionStateManager } from '../../src/conditions/state-manager'; -import { createMediaLoadedInfo, createStateEntity, createUser } from '../test-utils'; +import { + createConfig, + createMediaLoadedInfo, + createStateEntity, + createUser, +} from '../test-utils'; // @vitest-environment jsdom describe('ConditionsManager', () => { @@ -841,6 +846,146 @@ describe('ConditionsManager', () => { expect(manager.getEvaluation().result).toBeFalsy(); }); }); + + describe('with config condition', () => { + const config_1 = createConfig({ + // Default is: + // + // view: { + // default: live, + // }, + }); + const config_2 = createConfig({ + view: { + default: 'clips', + }, + }); + const config_3 = createConfig({ + view: { + default: 'clips', + default_cycle_camera: true, + }, + }); + const config_4 = createConfig({ + view: { + default: 'clips', + default_cycle_camera: true, + dim: true, + }, + }); + + it('should match any config change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'config' as const }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ config: config_1 }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + data: { + config: { + to: config_1, + }, + }, + }); + + stateManager.setState({ config: config_2 }); + expect(listener).toHaveBeenLastCalledWith({ + result: true, + data: { + config: { + from: config_1, + to: config_2, + }, + }, + }); + + expect(listener).toBeCalledTimes(2); + }); + + it('should match specific config change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'config' as const, paths: ['view.default'] }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ config: config_1 }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ config: config_2 }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ config: config_3 }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('should match multiple config change', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'config' as const, + paths: ['view.default', 'view.default_cycle_camera'], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ config: config_1 }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ config: config_2 }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ config: config_3 }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ config: config_4 }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('should not match unrelated changes', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'config' as const }], + stateManager, + ); + + const listener = vi.fn(); + manager.addListener(listener); + + stateManager.setState({ config: config_1 }); + expect(listener).toBeCalledTimes(1); + + // On the next state set, the condition won't match anymore (as the + // config is the same), the listener will still be called to indicate + // the condition evaluation has changed. + stateManager.setState({ expand: true }); + expect(listener).toBeCalledTimes(2); + expect(listener).toHaveBeenLastCalledWith({ result: false }); + + // Future unrelated state changes won't call the listener. + stateManager.setState({ fullscreen: true }); + expect(listener).toBeCalledTimes(2); + }); + }); + + it('with initialized condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [{ condition: 'initialized' as const }], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ initialized: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ initialized: false }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); }); describe('should handle listeners correctly', () => {