feat: Add support for template conditions (#1995)

- Closes:  #1988
This commit is contained in:
Dermot Duffy
2025-04-05 22:25:46 +01:00
committed by GitHub
parent 36783855a2
commit e7a9e20c9e
10 changed files with 184 additions and 67 deletions
+22
View File
@@ -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). 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` ## `triggered`
Matches based on whether the selected camera has been triggered. Matches based on whether the selected camera has been triggered.
+1 -2
View File
@@ -47,8 +47,7 @@ export class HASSManager {
this._hass = hass; this._hass = hass;
this._api.getConditionStateManager().setState({ this._api.getConditionStateManager().setState({
state: this._hass.states, hass: this._hass,
user: this._hass.user,
}); });
// Theme may depend on HASS. // Theme may depend on HASS.
+21 -8
View File
@@ -1,3 +1,4 @@
import { TemplateRenderer } from '../card-controller/templates';
import { getConfigValue } from '../config/management'; import { getConfigValue } from '../config/management';
import { AdvancedCameraCardCondition } from '../config/schema/conditions/types'; import { AdvancedCameraCardCondition } from '../config/schema/conditions/types';
import { isCompanionApp } from '../utils/companion'; import { isCompanionApp } from '../utils/companion';
@@ -23,6 +24,7 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface {
protected _listeners: ConditionsListener[] = []; protected _listeners: ConditionsListener[] = [];
protected _mediaQueries: MediaQueryList[] = []; protected _mediaQueries: MediaQueryList[] = [];
protected _evaluation: ConditionsEvaluationResult = { result: false }; protected _evaluation: ConditionsEvaluationResult = { result: false };
protected _templateRenderer: TemplateRenderer = new TemplateRenderer();
constructor( constructor(
conditions: AdvancedCameraCardCondition[], conditions: AdvancedCameraCardCondition[],
@@ -127,8 +129,8 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface {
switch (condition.condition) { switch (condition.condition) {
case undefined: case undefined:
case 'state': { case 'state': {
const fromState = oldState?.state?.[condition.entity]?.state; const fromState = oldState?.hass?.states?.[condition.entity]?.state;
const toState = newState?.state?.[condition.entity]?.state; const toState = newState?.hass?.states?.[condition.entity]?.state;
return { return {
result: result:
@@ -207,17 +209,18 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface {
case 'numeric_state': case 'numeric_state':
return { return {
result: result:
!!newState?.state && !!newState?.hass?.states &&
condition.entity in newState.state && condition.entity in newState.hass?.states &&
newState.state[condition.entity].state !== undefined && newState.hass.states[condition.entity].state !== undefined &&
(condition.above === undefined || (condition.above === undefined ||
Number(newState.state[condition.entity].state) > condition.above) && Number(newState.hass.states[condition.entity].state) > condition.above) &&
(condition.below === undefined || (condition.below === undefined ||
Number(newState.state[condition.entity].state) < condition.below), Number(newState.hass.states[condition.entity].state) < condition.below),
}; };
case 'user': case 'user':
return { return {
result: !!newState?.user && condition.users.includes(newState.user.id), result:
!!newState?.hass?.user && condition.users.includes(newState.hass.user.id),
}; };
case 'media_loaded': case 'media_loaded':
return { return {
@@ -341,6 +344,16 @@ export class ConditionsManager implements ConditionsManagerReadonlyInterface {
).result, ).result,
}; };
} }
case 'template':
return {
result:
!!newState?.hass &&
this._templateRenderer.renderRecursively(
newState.hass,
condition.value_template,
{ conditionState: newState },
) === true,
};
} }
} }
} }
+2 -4
View File
@@ -1,8 +1,7 @@
import { HassEntities } from 'home-assistant-js-websocket';
import { KeysState, MicrophoneState } from '../card-controller/types'; import { KeysState, MicrophoneState } from '../card-controller/types';
import { ViewDisplayMode } from '../config/schema/common/display'; import { ViewDisplayMode } from '../config/schema/common/display';
import { AdvancedCameraCardConfig } from '../config/schema/types'; import { AdvancedCameraCardConfig } from '../config/schema/types';
import { CurrentUser } from '../ha/types'; import { HomeAssistant } from '../ha/types';
import { MediaLoadedInfo } from '../types'; import { MediaLoadedInfo } from '../types';
export interface ConditionState { export interface ConditionState {
@@ -16,9 +15,8 @@ export interface ConditionState {
keys?: KeysState; keys?: KeysState;
mediaLoadedInfo?: MediaLoadedInfo | null; mediaLoadedInfo?: MediaLoadedInfo | null;
microphone?: MicrophoneState; microphone?: MicrophoneState;
state?: HassEntities; hass?: HomeAssistant;
triggered?: Set<string>; triggered?: Set<string>;
user?: CurrentUser;
userAgent?: string; userAgent?: string;
view?: string; view?: string;
} }
@@ -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(),
});
+2
View File
@@ -15,6 +15,7 @@ import { viewConditionSchema } from './custom/view';
import { numericStateConditionSchema } from './stock/numeric'; import { numericStateConditionSchema } from './stock/numeric';
import { screenConditionSchema } from './stock/screen'; import { screenConditionSchema } from './stock/screen';
import { stateConditionSchema } from './stock/state'; import { stateConditionSchema } from './stock/state';
import { templateConditionSchema } from './stock/template';
import { usersConditionSchema } from './stock/users'; import { usersConditionSchema } from './stock/users';
// https://www.home-assistant.io/docs/scripts/conditions/#or-condition // https://www.home-assistant.io/docs/scripts/conditions/#or-condition
@@ -65,6 +66,7 @@ export const advancedCameraCardConditionSchema = z.union([
orConditionSchema, orConditionSchema,
andConditionSchema, andConditionSchema,
notConditionSchema, notConditionSchema,
templateConditionSchema,
// Custom conditions: // Custom conditions:
cameraConditionSchema, cameraConditionSchema,
@@ -9,7 +9,6 @@ import {
createHASS, createHASS,
createStateEntity, createStateEntity,
createStore, createStore,
createUser,
createView, createView,
} from '../../test-utils'; } from '../../test-utils';
@@ -50,17 +49,13 @@ describe('HASSManager', () => {
it('should set condition manager state', () => { it('should set condition manager state', () => {
const api = createCardAPI(); const api = createCardAPI();
const manager = new HASSManager(api); const manager = new HASSManager(api);
const hass = createHASS();
const states = { 'switch.foo': createStateEntity() };
const user = createUser({ id: 'user_1' });
const hass = createHASS(states, user);
manager.setHASS(hass); manager.setHASS(hass);
expect(api.getConditionStateManager().setState).toBeCalledWith( expect(api.getConditionStateManager().setState).toBeCalledWith(
expect.objectContaining({ expect.objectContaining({
state: states, hass: hass,
user: user,
}), }),
); );
}); });
+114 -34
View File
@@ -1,9 +1,12 @@
import { HassEntities } from 'home-assistant-js-websocket';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { MicrophoneState } from '../../src/card-controller/types'; import { MicrophoneState } from '../../src/card-controller/types';
import { ConditionsManager } from '../../src/conditions/conditions-manager'; import { ConditionsManager } from '../../src/conditions/conditions-manager';
import { ConditionStateManager } from '../../src/conditions/state-manager'; import { ConditionStateManager } from '../../src/conditions/state-manager';
import { HomeAssistant } from '../../src/ha/types';
import { import {
createConfig, createConfig,
createHASS,
createMediaLoadedInfo, createMediaLoadedInfo,
createStateEntity, createStateEntity,
createUser, createUser,
@@ -193,7 +196,9 @@ describe('ConditionsManager', () => {
manager.addListener(listener); manager.addListener(listener);
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'on' }) }, hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'on' }),
}),
}); });
expect(listener).toBeCalledWith({ expect(listener).toBeCalledWith({
result: true, result: true,
@@ -207,7 +212,9 @@ describe('ConditionsManager', () => {
expect(listener).toBeCalledTimes(1); expect(listener).toBeCalledTimes(1);
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
}); });
expect(listener).toBeCalledWith({ expect(listener).toBeCalledWith({
result: true, result: true,
@@ -238,11 +245,13 @@ describe('ConditionsManager', () => {
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity() }, hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
}); });
expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
}); });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
}); });
@@ -262,15 +271,19 @@ describe('ConditionsManager', () => {
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity() }, hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
}); });
expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'active' }),
}),
}); });
expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
}); });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
}); });
@@ -292,11 +305,13 @@ describe('ConditionsManager', () => {
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity() }, hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
}); });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
}); });
expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().result).toBeTruthy();
}); });
@@ -315,15 +330,21 @@ describe('ConditionsManager', () => {
stateManager, stateManager,
); );
expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ state: { 'binary_sensor.foo': createStateEntity() } });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'active' }) }, hass: createHASS({ 'binary_sensor.foo': createStateEntity() }),
}); });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ 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(); expect(manager.getEvaluation().result).toBeTruthy();
}); });
@@ -341,10 +362,14 @@ describe('ConditionsManager', () => {
); );
expect(manager.getEvaluation().result).toBeFalsy(); 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(); expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: 'off' }) }, hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }),
}),
}); });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
}); });
@@ -363,10 +388,10 @@ describe('ConditionsManager', () => {
manager.addListener(listener); manager.addListener(listener);
stateManager.setState({ stateManager.setState({
state: { hass: createHASS({
'switch.one': createStateEntity({ state: 'on' }), 'switch.one': createStateEntity({ state: 'on' }),
'switch.two': createStateEntity({ state: 'off' }), 'switch.two': createStateEntity({ state: 'off' }),
}, }),
}); });
expect(listener).toHaveBeenLastCalledWith({ expect(listener).toHaveBeenLastCalledWith({
result: true, result: true,
@@ -380,10 +405,10 @@ describe('ConditionsManager', () => {
}); });
stateManager.setState({ stateManager.setState({
state: { hass: createHASS({
'switch.one': createStateEntity({ state: 'off' }), 'switch.one': createStateEntity({ state: 'off' }),
'switch.two': createStateEntity({ state: 'on' }), 'switch.two': createStateEntity({ state: 'on' }),
}, }),
}); });
expect(listener).toHaveBeenLastCalledWith({ expect(listener).toHaveBeenLastCalledWith({
@@ -411,10 +436,11 @@ describe('ConditionsManager', () => {
const listener = vi.fn(); const listener = vi.fn();
manager.addListener(listener); manager.addListener(listener);
const hass = createHASS({
'switch.one': createStateEntity({ state: 'on' }),
});
stateManager.setState({ stateManager.setState({
state: { hass,
'switch.one': createStateEntity({ state: 'on' }),
},
}); });
expect(listener).toBeCalledTimes(1); expect(listener).toBeCalledTimes(1);
expect(manager.getEvaluation()).toEqual({ expect(manager.getEvaluation()).toEqual({
@@ -428,11 +454,7 @@ describe('ConditionsManager', () => {
}, },
}); });
stateManager.setState({ stateManager.setState({ hass });
state: {
'switch.one': createStateEntity({ state: 'on' }),
},
});
expect(listener).toBeCalledTimes(1); expect(listener).toBeCalledTimes(1);
}); });
}); });
@@ -453,11 +475,11 @@ describe('ConditionsManager', () => {
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
state: { 'sensor.foo': createStateEntity({ state: '11' }) }, hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }),
}); });
expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ stateManager.setState({
state: { 'binary_sensor.foo': createStateEntity({ state: '9' }) }, hass: createHASS({ 'binary_sensor.foo': createStateEntity({ state: '9' }) }),
}); });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
}); });
@@ -477,16 +499,74 @@ describe('ConditionsManager', () => {
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
state: { 'sensor.foo': createStateEntity({ state: '11' }) }, hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }),
}); });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
state: { 'sensor.foo': createStateEntity({ state: '9' }) }, hass: createHASS({ 'sensor.foo': createStateEntity({ state: '9' }) }),
}); });
expect(manager.getEvaluation().result).toBeTruthy(); 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', () => { it('should not call listeners for HA state changes without relevant condition', () => {
const stateManager = new ConditionStateManager(); const stateManager = new ConditionStateManager();
const manager = new ConditionsManager( const manager = new ConditionsManager(
@@ -503,7 +583,7 @@ describe('ConditionsManager', () => {
manager.addListener(listener); manager.addListener(listener);
stateManager.setState({ stateManager.setState({
state: { 'sensor.foo': createStateEntity({ state: '11' }) }, hass: createHASS({ 'sensor.foo': createStateEntity({ state: '11' }) }),
}); });
expect(listener).not.toBeCalled(); expect(listener).not.toBeCalled();
@@ -524,11 +604,11 @@ describe('ConditionsManager', () => {
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
stateManager.setState({ stateManager.setState({
user: createUser({ id: 'user_1' }), hass: createHASS({}, createUser({ id: 'user_1' })),
}); });
expect(manager.getEvaluation().result).toBeTruthy(); expect(manager.getEvaluation().result).toBeTruthy();
stateManager.setState({ stateManager.setState({
user: createUser({ id: 'user_WRONG' }), hass: createHASS({}, createUser({ id: 'user_WRONG' })),
}); });
expect(manager.getEvaluation().result).toBeFalsy(); expect(manager.getEvaluation().result).toBeFalsy();
}); });
+12 -12
View File
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConditionStateManager } from '../../src/conditions/state-manager'; import { ConditionStateManager } from '../../src/conditions/state-manager';
import { createStateEntity } from '../test-utils'; import { createHASS, createStateEntity } from '../test-utils';
describe('ConditionStateManager', () => { describe('ConditionStateManager', () => {
afterEach(() => { afterEach(() => {
@@ -46,9 +46,9 @@ describe('ConditionStateManager', () => {
expect(listener).toBeCalledTimes(1); expect(listener).toBeCalledTimes(1);
manager.setState({ manager.setState({
state: { hass: createHASS({
'binary_sensor.foo': createStateEntity(), 'binary_sensor.foo': createStateEntity(),
}, }),
}); });
expect(listener).toBeCalledTimes(2); expect(listener).toBeCalledTimes(2);
@@ -56,24 +56,24 @@ describe('ConditionStateManager', () => {
expect(listener).toBeCalledTimes(2); expect(listener).toBeCalledTimes(2);
manager.setState({ manager.setState({
state: { hass: createHASS({
'binary_sensor.foo': createStateEntity(), 'binary_sensor.foo': createStateEntity(),
}, }),
}); });
expect(listener).toBeCalledTimes(2);
manager.setState({ fullscreen: false });
expect(listener).toBeCalledTimes(3); expect(listener).toBeCalledTimes(3);
manager.setState({ fullscreen: false }); manager.setState({ fullscreen: false });
expect(listener).toBeCalledTimes(3); expect(listener).toBeCalledTimes(4);
manager.setState({ fullscreen: false });
expect(listener).toBeCalledTimes(4);
manager.setState({ manager.setState({
state: { hass: createHASS({
'binary_sensor.foo': createStateEntity({ state: 'off' }), 'binary_sensor.foo': createStateEntity({ state: 'off' }),
}, }),
}); });
expect(listener).toBeCalledTimes(4); expect(listener).toBeCalledTimes(5);
}); });
}); });
+1
View File
@@ -96,6 +96,7 @@ export const createHASS = (states?: HassEntities, user?: CurrentUser): HomeAssis
hass.user = user; hass.user = user;
} }
hass.connection.subscribeMessage = vi.fn(); hass.connection.subscribeMessage = vi.fn();
hass.connection.sendMessagePromise = vi.fn();
return hass; return hass;
}; };