feat: Add support for config and initialized conditions (#1894)

These conditions are probably not hugely useful for most card users, but
allows the inbuilt automation systems to be used for card internal
operations.

[skip ci]
This commit is contained in:
Dermot Duffy
2025-02-16 14:17:04 -08:00
committed by GitHub
parent 7fb710d45d
commit da79664a67
10 changed files with 313 additions and 35 deletions
+34
View File
@@ -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
@@ -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();
}
}
@@ -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();
}
+1
View File
@@ -176,6 +176,7 @@ export interface CardInitializerAPI {
getMicrophoneManager(): MicrophoneManager;
getCardElementManager(): CardElementManager;
getConditionStateManager(): ConditionStateManager;
getConfigManager(): ConfigManager;
getDefaultManager(): DefaultManager;
getEntityRegistryManager(): EntityRegistryManager;
+30 -2
View File
@@ -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 };
}
}
}
+9 -1
View File
@@ -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;
+39 -30
View File
@@ -683,43 +683,33 @@ export type StatusBarItem = z.infer<typeof statusBarItemSchema>;
// 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
@@ -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() } }),
);
});
});
});
@@ -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 () => {
+146 -1
View File
@@ -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', () => {