feat: Add or, and and not condition types (#1980)

[skip ci]
This commit is contained in:
Dermot Duffy
2025-03-23 21:04:15 -07:00
committed by GitHub
parent f140f6a653
commit a3b17d8cb2
6 changed files with 312 additions and 20 deletions
+3
View File
@@ -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.
+45
View File
@@ -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).
+36 -19
View File
@@ -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,
};
}
}
}
}
+43 -1
View File
@@ -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<OrCondition> = 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<AndCondition> = 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<NotCondition> = 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,
+182
View File
@@ -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', () => {
+3
View File
@@ -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',