diff --git a/docs/configuration/conditions.md b/docs/configuration/conditions.md index e47786db..9d4343b5 100644 --- a/docs/configuration/conditions.md +++ b/docs/configuration/conditions.md @@ -272,6 +272,28 @@ almost certainly not useful / reliable as a condition. See [Home Assistant conditions documentation](https://www.home-assistant.io/dashboards/conditional/#state). +## `template` + +Matches based on a template. + +```yaml +conditions: + - condition: template + # [...] +``` + +| Parameter | Description | +| ---------------- | ---------------------------------------------------------------------------------- | +| `condition` | Must be `template`. | +| `value_template` | The Home Assistant template to check, e.g. `{{ states('switch.office') == 'on' }}` | + +See [Home Assistant conditions documentation](https://www.home-assistant.io/docs/scripts/conditions/#template-condition). + +?> The Advanced Camera Card uses +[ha-nunjucks](https://github.com/Nerwyn/ha-nunjucks) to process templates. +Consult its documentation for the wide variety of different template values +supported. + ## `triggered` Matches based on whether the selected camera has been triggered. diff --git a/src/card-controller/hass/hass-manager.ts b/src/card-controller/hass/hass-manager.ts index c26cc71a..b98486a5 100644 --- a/src/card-controller/hass/hass-manager.ts +++ b/src/card-controller/hass/hass-manager.ts @@ -47,8 +47,7 @@ export class HASSManager { this._hass = hass; this._api.getConditionStateManager().setState({ - state: this._hass.states, - user: this._hass.user, + hass: this._hass, }); // Theme may depend on HASS. diff --git a/src/conditions/conditions-manager.ts b/src/conditions/conditions-manager.ts index e862bf44..6ca004b8 100644 --- a/src/conditions/conditions-manager.ts +++ b/src/conditions/conditions-manager.ts @@ -1,3 +1,4 @@ +import { TemplateRenderer } from '../card-controller/templates'; import { getConfigValue } from '../config/management'; import { AdvancedCameraCardCondition } from '../config/schema/conditions/types'; import { isCompanionApp } from '../utils/companion'; @@ -23,6 +24,7 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { protected _listeners: ConditionsListener[] = []; protected _mediaQueries: MediaQueryList[] = []; protected _evaluation: ConditionsEvaluationResult = { result: false }; + protected _templateRenderer: TemplateRenderer = new TemplateRenderer(); constructor( conditions: AdvancedCameraCardCondition[], @@ -127,8 +129,8 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { switch (condition.condition) { case undefined: case 'state': { - const fromState = oldState?.state?.[condition.entity]?.state; - const toState = newState?.state?.[condition.entity]?.state; + const fromState = oldState?.hass?.states?.[condition.entity]?.state; + const toState = newState?.hass?.states?.[condition.entity]?.state; return { result: @@ -207,17 +209,18 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { case 'numeric_state': return { result: - !!newState?.state && - condition.entity in newState.state && - newState.state[condition.entity].state !== undefined && + !!newState?.hass?.states && + condition.entity in newState.hass?.states && + newState.hass.states[condition.entity].state !== undefined && (condition.above === undefined || - Number(newState.state[condition.entity].state) > condition.above) && + Number(newState.hass.states[condition.entity].state) > condition.above) && (condition.below === undefined || - Number(newState.state[condition.entity].state) < condition.below), + Number(newState.hass.states[condition.entity].state) < condition.below), }; case 'user': return { - result: !!newState?.user && condition.users.includes(newState.user.id), + result: + !!newState?.hass?.user && condition.users.includes(newState.hass.user.id), }; case 'media_loaded': return { @@ -341,6 +344,16 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface { ).result, }; } + case 'template': + return { + result: + !!newState?.hass && + this._templateRenderer.renderRecursively( + newState.hass, + condition.value_template, + { conditionState: newState }, + ) === true, + }; } } } diff --git a/src/conditions/types.ts b/src/conditions/types.ts index e5375d70..fdcbd3c4 100644 --- a/src/conditions/types.ts +++ b/src/conditions/types.ts @@ -1,8 +1,7 @@ -import { HassEntities } from 'home-assistant-js-websocket'; import { KeysState, MicrophoneState } from '../card-controller/types'; import { ViewDisplayMode } from '../config/schema/common/display'; import { AdvancedCameraCardConfig } from '../config/schema/types'; -import { CurrentUser } from '../ha/types'; +import { HomeAssistant } from '../ha/types'; import { MediaLoadedInfo } from '../types'; export interface ConditionState { @@ -16,9 +15,8 @@ export interface ConditionState { keys?: KeysState; mediaLoadedInfo?: MediaLoadedInfo | null; microphone?: MicrophoneState; - state?: HassEntities; + hass?: HomeAssistant; triggered?: Set; - user?: CurrentUser; userAgent?: string; view?: string; } diff --git a/src/config/schema/conditions/stock/template.ts b/src/config/schema/conditions/stock/template.ts new file mode 100644 index 00000000..a4fabf10 --- /dev/null +++ b/src/config/schema/conditions/stock/template.ts @@ -0,0 +1,7 @@ +import { z } from 'zod'; + +// https://www.home-assistant.io/docs/scripts/conditions/#template-condition +export const templateConditionSchema = z.object({ + condition: z.literal('template'), + value_template: z.string(), +}); diff --git a/src/config/schema/conditions/types.ts b/src/config/schema/conditions/types.ts index 17c6881f..4ab7ffc6 100644 --- a/src/config/schema/conditions/types.ts +++ b/src/config/schema/conditions/types.ts @@ -15,6 +15,7 @@ import { viewConditionSchema } from './custom/view'; import { numericStateConditionSchema } from './stock/numeric'; import { screenConditionSchema } from './stock/screen'; import { stateConditionSchema } from './stock/state'; +import { templateConditionSchema } from './stock/template'; import { usersConditionSchema } from './stock/users'; // https://www.home-assistant.io/docs/scripts/conditions/#or-condition @@ -65,6 +66,7 @@ export const advancedCameraCardConditionSchema = z.union([ orConditionSchema, andConditionSchema, notConditionSchema, + templateConditionSchema, // Custom conditions: cameraConditionSchema, diff --git a/tests/card-controller/hass/hass-manager.test.ts b/tests/card-controller/hass/hass-manager.test.ts index 34f74817..900c2d5e 100644 --- a/tests/card-controller/hass/hass-manager.test.ts +++ b/tests/card-controller/hass/hass-manager.test.ts @@ -9,7 +9,6 @@ import { createHASS, createStateEntity, createStore, - createUser, createView, } from '../../test-utils'; @@ -50,17 +49,13 @@ describe('HASSManager', () => { it('should set condition manager state', () => { const api = createCardAPI(); const manager = new HASSManager(api); - - const states = { 'switch.foo': createStateEntity() }; - const user = createUser({ id: 'user_1' }); - const hass = createHASS(states, user); + const hass = createHASS(); manager.setHASS(hass); expect(api.getConditionStateManager().setState).toBeCalledWith( expect.objectContaining({ - state: states, - user: user, + hass: hass, }), ); }); diff --git a/tests/conditions/conditions-manager.test.ts b/tests/conditions/conditions-manager.test.ts index 99e9e844..265f9e90 100644 --- a/tests/conditions/conditions-manager.test.ts +++ b/tests/conditions/conditions-manager.test.ts @@ -1,9 +1,12 @@ +import { HassEntities } from 'home-assistant-js-websocket'; 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 { HomeAssistant } from '../../src/ha/types'; import { createConfig, + createHASS, createMediaLoadedInfo, createStateEntity, createUser, @@ -193,7 +196,9 @@ describe('ConditionsManager', () => { manager.addListener(listener); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'on' }) }, + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'on' }), + }), }); expect(listener).toBeCalledWith({ result: true, @@ -207,7 +212,9 @@ describe('ConditionsManager', () => { expect(listener).toBeCalledTimes(1); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }), }); expect(listener).toBeCalledWith({ result: true, @@ -238,11 +245,13 @@ describe('ConditionsManager', () => { expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity() }, + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), }); expect(manager.getEvaluation().result).toBeTruthy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }), }); expect(manager.getEvaluation().result).toBeFalsy(); }); @@ -262,15 +271,19 @@ describe('ConditionsManager', () => { expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity() }, + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), }); expect(manager.getEvaluation().result).toBeTruthy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'active' }), + }), }); expect(manager.getEvaluation().result).toBeTruthy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }), }); expect(manager.getEvaluation().result).toBeFalsy(); }); @@ -292,11 +305,13 @@ describe('ConditionsManager', () => { expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity() }, + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), }); expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }), }); expect(manager.getEvaluation().result).toBeTruthy(); }); @@ -315,15 +330,21 @@ describe('ConditionsManager', () => { stateManager, ); - expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), }); expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'active' }), + }), + }); + expect(manager.getEvaluation().result).toBeFalsy(); + stateManager.setState({ + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }), }); expect(manager.getEvaluation().result).toBeTruthy(); }); @@ -341,10 +362,14 @@ describe('ConditionsManager', () => { ); expect(manager.getEvaluation().result).toBeFalsy(); - stateManager.setState({ state: { 'binary_sensor.foo': createStateEntity() } }); + stateManager.setState({ + hass: createHASS({ 'binary_sensor.foo': createStateEntity() }), + }); expect(manager.getEvaluation().result).toBeTruthy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, + hass: createHASS({ + 'binary_sensor.foo': createStateEntity({ state: 'off' }), + }), }); expect(manager.getEvaluation().result).toBeFalsy(); }); @@ -363,10 +388,10 @@ describe('ConditionsManager', () => { manager.addListener(listener); stateManager.setState({ - state: { + hass: createHASS({ 'switch.one': createStateEntity({ state: 'on' }), 'switch.two': createStateEntity({ state: 'off' }), - }, + }), }); expect(listener).toHaveBeenLastCalledWith({ result: true, @@ -380,10 +405,10 @@ describe('ConditionsManager', () => { }); stateManager.setState({ - state: { + hass: createHASS({ 'switch.one': createStateEntity({ state: 'off' }), 'switch.two': createStateEntity({ state: 'on' }), - }, + }), }); expect(listener).toHaveBeenLastCalledWith({ @@ -411,10 +436,11 @@ describe('ConditionsManager', () => { const listener = vi.fn(); manager.addListener(listener); + const hass = createHASS({ + 'switch.one': createStateEntity({ state: 'on' }), + }); stateManager.setState({ - state: { - 'switch.one': createStateEntity({ state: 'on' }), - }, + hass, }); expect(listener).toBeCalledTimes(1); expect(manager.getEvaluation()).toEqual({ @@ -428,11 +454,7 @@ describe('ConditionsManager', () => { }, }); - stateManager.setState({ - state: { - 'switch.one': createStateEntity({ state: 'on' }), - }, - }); + stateManager.setState({ hass }); expect(listener).toBeCalledTimes(1); }); }); @@ -453,11 +475,11 @@ describe('ConditionsManager', () => { expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'sensor.foo': createStateEntity({ state: '11' }) }, + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), }); expect(manager.getEvaluation().result).toBeTruthy(); stateManager.setState({ - state: { 'binary_sensor.foo': createStateEntity({ state: '9' }) }, + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: '9' }) }), }); expect(manager.getEvaluation().result).toBeFalsy(); }); @@ -477,16 +499,74 @@ describe('ConditionsManager', () => { expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'sensor.foo': createStateEntity({ state: '11' }) }, + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), }); expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - state: { 'sensor.foo': createStateEntity({ state: '9' }) }, + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '9' }) }), }); expect(manager.getEvaluation().result).toBeTruthy(); }); }); + describe('with template condition', () => { + const createHASSForTemplateCondition = (states: HassEntities): HomeAssistant => { + const hass = createHASS(states); + vi.mocked(hass.connection.sendMessagePromise).mockResolvedValue([]); + return hass; + }; + + it('should evaluate true when template evalutes to true', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'template' as const, + value_template: '{{ is_state("sensor.foo", "on") }}', + }, + ], + stateManager, + ); + + expect(manager.getEvaluation().result).toBeFalsy(); + + stateManager.setState({ + hass: createHASSForTemplateCondition({ + 'sensor.foo': createStateEntity({ state: 'on' }), + }), + }); + expect(manager.getEvaluation().result).toBeTruthy(); + + stateManager.setState({ + hass: createHASSForTemplateCondition({ + 'sensor.foo': createStateEntity({ state: 'off' }), + }), + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + + it('should evaluate false when template evalutes to non-boolean', () => { + const stateManager = new ConditionStateManager(); + const manager = new ConditionsManager( + [ + { + condition: 'template' as const, + // This does not result in a boolean. + value_template: '{{ hass.states["light.office"].state }}', + }, + ], + stateManager, + ); + + stateManager.setState({ + hass: createHASSForTemplateCondition({ + 'light.office': createStateEntity({ state: 'on' }), + }), + }); + expect(manager.getEvaluation().result).toBeFalsy(); + }); + }); + it('should not call listeners for HA state changes without relevant condition', () => { const stateManager = new ConditionStateManager(); const manager = new ConditionsManager( @@ -503,7 +583,7 @@ describe('ConditionsManager', () => { manager.addListener(listener); stateManager.setState({ - state: { 'sensor.foo': createStateEntity({ state: '11' }) }, + hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }), }); expect(listener).not.toBeCalled(); @@ -524,11 +604,11 @@ describe('ConditionsManager', () => { expect(manager.getEvaluation().result).toBeFalsy(); stateManager.setState({ - user: createUser({ id: 'user_1' }), + hass: createHASS({}, createUser({ id: 'user_1' })), }); expect(manager.getEvaluation().result).toBeTruthy(); stateManager.setState({ - user: createUser({ id: 'user_WRONG' }), + hass: createHASS({}, createUser({ id: 'user_WRONG' })), }); expect(manager.getEvaluation().result).toBeFalsy(); }); diff --git a/tests/conditions/state-manager.test.ts b/tests/conditions/state-manager.test.ts index ed4259a8..b1c6b837 100644 --- a/tests/conditions/state-manager.test.ts +++ b/tests/conditions/state-manager.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ConditionStateManager } from '../../src/conditions/state-manager'; -import { createStateEntity } from '../test-utils'; +import { createHASS, createStateEntity } from '../test-utils'; describe('ConditionStateManager', () => { afterEach(() => { @@ -46,9 +46,9 @@ describe('ConditionStateManager', () => { expect(listener).toBeCalledTimes(1); manager.setState({ - state: { + hass: createHASS({ 'binary_sensor.foo': createStateEntity(), - }, + }), }); expect(listener).toBeCalledTimes(2); @@ -56,24 +56,24 @@ describe('ConditionStateManager', () => { expect(listener).toBeCalledTimes(2); manager.setState({ - state: { + hass: createHASS({ 'binary_sensor.foo': createStateEntity(), - }, + }), }); - expect(listener).toBeCalledTimes(2); - - manager.setState({ fullscreen: false }); expect(listener).toBeCalledTimes(3); manager.setState({ fullscreen: false }); - expect(listener).toBeCalledTimes(3); + expect(listener).toBeCalledTimes(4); + + manager.setState({ fullscreen: false }); + expect(listener).toBeCalledTimes(4); manager.setState({ - state: { + hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: 'off' }), - }, + }), }); - expect(listener).toBeCalledTimes(4); + expect(listener).toBeCalledTimes(5); }); }); diff --git a/tests/test-utils.ts b/tests/test-utils.ts index 57057a78..529f2cb8 100644 --- a/tests/test-utils.ts +++ b/tests/test-utils.ts @@ -96,6 +96,7 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis hass.user = user; } hass.connection.subscribeMessage = vi.fn(); + hass.connection.sendMessagePromise = vi.fn(); return hass; };