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:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -176,6 +176,7 @@ export interface CardInitializerAPI {
|
||||
getMicrophoneManager(): MicrophoneManager;
|
||||
|
||||
getCardElementManager(): CardElementManager;
|
||||
getConditionStateManager(): ConditionStateManager;
|
||||
getConfigManager(): ConfigManager;
|
||||
getDefaultManager(): DefaultManager;
|
||||
getEntityRegistryManager(): EntityRegistryManager;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user