diff --git a/docs/configuration/actions/templates.md b/docs/configuration/actions/templates.md index a0af4476..fa4a813e 100644 --- a/docs/configuration/actions/templates.md +++ b/docs/configuration/actions/templates.md @@ -50,6 +50,9 @@ Trigger template values must be proceeded by `advanced_camera_card.trigger` (or conditions](../conditions.md?id=state), only data from the last listed state condition is available. +!> If you use an [`or`](../conditions.md?id=or) condition, only the trigger data +for the first matching trigger will be included. + Please [request](https://github.com/dermotduffy/advanced-camera-card/issues) if you need data from additional conditions. diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index 0f25cfab..e47786db 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -14,6 +14,21 @@ certain configurations (in `overrides`) or to display "picture elements" (in - [condition_2] ``` +## `and` + +Evaluates to `true` if _all_ embedded conditions evaluate to `true`. + +```yaml +conditions: + - condition: and + # [...] +``` + +| Parameter | Description | +| ------------ | -------------------------------------------------------------------------------------------------------------- | +| `condition` | Must be `and`. | +| `conditions` | A list of other conditions _all_ of which must evaluate `true` in order for this condition to evaluate `true`. | + ## `camera` Matches based on the selected camera. Does not match other cameras (whether @@ -174,6 +189,21 @@ conditions: When multiple parameters are specified they must all match for the condition to match. +## `not` + +Evaluates to `true` if _all_ embedded conditions evaluate to `false`. + +```yaml +conditions: + - condition: not + # [...] +``` + +| Parameter | Description | +| ------------ | --------------------------------------------------------------------------------------------------------------- | +| `condition` | Must be `not`. | +| `conditions` | A list of other conditions _all_ of which must evaluate `false` in order for this condition to evaluate `true`. | + ## `numeric_state` Matches based on numeric Home Assistant state. @@ -186,6 +216,21 @@ conditions: See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#numeric-state). +## `or` + +Evaluates to `true` if _any_ embedded condition evaluates to `true`. + +```yaml +conditions: + - condition: or + # [...] +``` + +| Parameter | Description | +| ------------ | -------------------------------------------------------------------------------------------------------- | +| `condition` | Must be `or`. | +| `conditions` | A list of conditions _any_ of which must evaluate `true` in order for this condition to evaluate `true`. | + ## `screen` Matches based on [media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/Media_Queries/Using_media_queries). diff --git a/src/conditions/conditions-manager.ts b/src/conditions/conditions-manager.ts index e0dd2bf0..e862bf44 100644 --- a/src/conditions/conditions-manager.ts +++ b/src/conditions/conditions-manager.ts @@ -22,7 +22,6 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { protected _listeners: ConditionsListener[] = []; protected _mediaQueries: MediaQueryList[] = []; - protected _hasHAStateConditions = false; protected _evaluation: ConditionsEvaluationResult = { result: false }; constructor( @@ -30,13 +29,6 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { stateManager?: ConditionStateManagerReadonlyInterface | null, ) { this._conditions = conditions; - - this._hasHAStateConditions = conditions.some( - (condition) => - !condition.condition || - ['state', 'numeric_state', 'user'].includes(condition.condition), - ); - conditions.forEach((condition) => { if (condition.condition === 'screen') { const mql = window.matchMedia(condition.media_query); @@ -82,17 +74,6 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { protected _mediaQueryHandler = () => this._evaluate(); protected _stateManagerHandler = (stateChange: ConditionStateChange): void => { - // As a performance optmization, if only Home Assistant state has changed - // (very frequent), and there aren't any related conditions, don't bother - // calling for the evealuation / listeners. - if ( - Object.keys(stateChange.change).length === 1 && - 'state' in stateChange.change && - !this._hasHAStateConditions - ) { - return; - } - this._evaluate({ stateChange }); }; @@ -324,6 +305,42 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { } case 'initialized': return { result: !!newState?.initialized }; + case 'or': + for (const subCondition of condition.conditions) { + const evaluation = this._evaluateCondition(subCondition, newState, oldState); + if (evaluation.result) { + return evaluation; + } + } + return { result: false }; + case 'and': { + let triggerData: ConditionsTriggerData = {}; + for (const subCondition of condition.conditions) { + const evaluation = this._evaluateCondition(subCondition, newState, oldState); + if (!evaluation.result) { + return { result: false }; + } + triggerData = { + ...triggerData, + ...evaluation.triggerData, + }; + } + return { result: true, triggerData }; + } + case 'not': { + // "Not" is just an inversed `and`. There is no trigger data for "not + // triggering". + return { + result: !this._evaluateCondition( + { + ...condition, + condition: 'and', + }, + newState, + oldState, + ).result, + }; + } } } } diff --git a/src/config/schema/conditions/types.ts b/src/config/schema/conditions/types.ts index cd17dd99..17c6881f 100644 --- a/src/config/schema/conditions/types.ts +++ b/src/config/schema/conditions/types.ts @@ -17,12 +17,54 @@ import { screenConditionSchema } from './stock/screen'; import { stateConditionSchema } from './stock/state'; import { usersConditionSchema } from './stock/users'; -export const advancedCameraCardConditionSchema = z.discriminatedUnion('condition', [ +// https://www.home-assistant.io/docs/scripts/conditions/#or-condition +type OrCondition = { + condition: 'or'; + conditions: AdvancedCameraCardCondition[]; +}; +const orConditionSchema: z.ZodSchema = z.object({ + condition: z.literal('or'), + conditions: z + .lazy(() => advancedCameraCardConditionSchema) + .array() + .min(1), +}); + +// https://www.home-assistant.io/docs/scripts/conditions/#and-condition +type AndCondition = { + condition: 'and'; + conditions: AdvancedCameraCardCondition[]; +}; +const andConditionSchema: z.ZodSchema = z.object({ + condition: z.literal('and'), + conditions: z + .lazy(() => advancedCameraCardConditionSchema) + .array() + .min(1), +}); + +// https://www.home-assistant.io/docs/scripts/conditions/#not-condition +type NotCondition = { + condition: 'not'; + conditions: AdvancedCameraCardCondition[]; +}; +const notConditionSchema: z.ZodSchema = z.object({ + condition: z.literal('not'), + conditions: z + .lazy(() => advancedCameraCardConditionSchema) + .array() + .min(1), +}); + +export const advancedCameraCardConditionSchema = z.union([ // Stock conditions: numericStateConditionSchema, screenConditionSchema, stateConditionSchema, usersConditionSchema, + orConditionSchema, + andConditionSchema, + notConditionSchema, // Custom conditions: cameraConditionSchema, diff --git a/tests/conditions/conditions-manager.test.ts b/tests/conditions/conditions-manager.test.ts index 8d7bf99b..99e9e844 100644 --- a/tests/conditions/conditions-manager.test.ts +++ b/tests/conditions/conditions-manager.test.ts @@ -1072,6 +1072,188 @@ describe('ConditionsManager', () => { stateManager.setState({ initialized: false }); expect(manager.getEvaluation().result).toBeFalsy(); }); + + it('with simple OR condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'or' as const, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + { + condition: 'expand' as const, + expand: true, + }, + ], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ fullscreen: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ expand: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ fullscreen: false }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ expand: false }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('with triggered OR condition', () => { + const stateManager = new ConditionStateManager(); + + // This is not a terribly realistic example, but chosen so that trigger + // data for both camera and view should be returned. + const manager = new ConditionsManager( + [ + { + condition: 'or' as const, + conditions: [ + { condition: 'camera' as const }, + { condition: 'view' as const }, + ], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + + stateManager.setState({ camera: 'camera-1' }); + expect(manager.getEvaluation().result).toBeTruthy(); + expect(manager.getEvaluation().triggerData).toEqual({ + camera: { to: 'camera-1' }, + }); + + stateManager.setState({ view: 'view-1' }); + expect(manager.getEvaluation().result).toBeTruthy(); + expect(manager.getEvaluation().triggerData).toEqual({ + view: { to: 'view-1' }, + }); + + stateManager.setState({ camera: 'camera-2', view: 'view-2' }); + expect(manager.getEvaluation().result).toBeTruthy(); + expect(manager.getEvaluation().triggerData).toEqual({ + camera: { to: 'camera-2', from: 'camera-1' }, + + // View data will not be here as the view condition is not evaluated, + // since the camera one will evaluate to true first. + }); + }); + + it('with simple AND condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'and' as const, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + { + condition: 'expand' as const, + expand: true, + }, + ], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ fullscreen: true }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ expand: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + stateManager.setState({ fullscreen: false }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ expand: false }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('with triggered AND condition', () => { + const stateManager = new ConditionStateManager(); + + // This is not a terribly realistic example, but chosen so that trigger + // data for both camera and view should be returned. + const manager = new ConditionsManager( + [ + { + condition: 'and' as const, + conditions: [ + { condition: 'camera' as const }, + { condition: 'view' as const }, + ], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + + stateManager.setState({ camera: 'camera-1' }); + expect(manager.getEvaluation().result).toBeFalsy(); + + stateManager.setState({ view: 'view-1' }); + expect(manager.getEvaluation().result).toBeFalsy(); + + stateManager.setState({ camera: 'camera-2', view: 'view-2' }); + expect(manager.getEvaluation().result).toBeTruthy(); + expect(manager.getEvaluation().triggerData).toEqual({ + camera: { from: 'camera-1', to: 'camera-2' }, + view: { from: 'view-1', to: 'view-2' }, + }); + + stateManager.setState({ view: 'view-3' }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('with not condition', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'not' as const, + conditions: [ + { + condition: 'fullscreen' as const, + fullscreen: true, + }, + { + condition: 'expand' as const, + expand: true, + }, + ], + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeTruthy(); + + stateManager.setState({ fullscreen: true }); + expect(manager.getEvaluation().result).toBeTruthy(); + + stateManager.setState({ expand: true }); + expect(manager.getEvaluation().result).toBeFalsy(); + + stateManager.setState({ fullscreen: false }); + expect(manager.getEvaluation().result).toBeTruthy(); + + stateManager.setState({ expand: false }); + expect(manager.getEvaluation().result).toBeTruthy(); + + // `not` conditions never have trigger data (as nothing is triggering). + expect(manager.getEvaluation().triggerData).toEqual({}); + }); }); describe('should handle listeners correctly', () => { diff --git a/tests/config/types.test.ts b/tests/config/types.test.ts index 726fcb7c..6c986735 100644 --- a/tests/config/types.test.ts +++ b/tests/config/types.test.ts @@ -696,6 +696,7 @@ describe('config defaults', () => { it('should include all conditions', () => { const conditions = [ + { condition: 'and', conditions: [{ condition: 'initialized' }] }, { condition: 'camera', cameras: ['camera.office'] }, { condition: 'config', paths: ['menu.style'] }, { condition: 'display_mode', display_mode: 'single' }, @@ -714,12 +715,14 @@ describe('config defaults', () => { }, { condition: 'media_loaded', media_loaded: true }, { condition: 'microphone', connected: true, muted: true }, + { condition: 'not', conditions: [{ condition: 'initialized' }] }, { condition: 'numeric_state', entity: 'sensor.office_temperature', above: 10, below: 20, }, + { condition: 'or', conditions: [{ condition: 'initialized' }] }, { condition: 'screen', media_query: '(orientation: landscape)' }, { condition: 'state',