feat: Triggers should fire on startup if entities are in an active state (#2095)

- Closes: #2087
This commit is contained in:
Dermot Duffy
2025-06-08 16:01:26 -07:00
committed by GitHub
parent 2b92f84665
commit 3b4752b257
7 changed files with 166 additions and 5 deletions
+2 -3
View File
@@ -115,9 +115,8 @@ to wait prior to the acting (see `untrigger_seconds`). By default, triggering is
only allowed when there is no ongoing human interaction with the card. This only allowed when there is no ongoing human interaction with the card. This
behavior can be controlled by the `interaction_mode` parameter. behavior can be controlled by the `interaction_mode` parameter.
Triggers based on Home Assistant entities require state _changes_ -- when the If the card starts when a trigger entity is already in a triggered state, the
card is first started, it takes an active change in state to trigger (i.e. an action will be taken on card startup.
already occupied room will not trigger, but a newly occupied room will).
| Option | Default | Description | | Option | Default | Description |
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | | ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
@@ -10,6 +10,10 @@ export enum InitializationAspect {
CAMERAS = 'cameras', CAMERAS = 'cameras',
MICROPHONE_CONNECT = 'microphone-connect', MICROPHONE_CONNECT = 'microphone-connect',
VIEW = 'view', VIEW = 'view',
// The initial triggering must happen after both the config is set (and
// cameras initialized), and hass is set.
INITIAL_TRIGGER = 'initial-trigger',
} }
// ========================================================================= // =========================================================================
@@ -60,6 +64,7 @@ export class InitializationManager {
? [InitializationAspect.MICROPHONE_CONNECT] ? [InitializationAspect.MICROPHONE_CONNECT]
: []), : []),
InitializationAspect.VIEW, InitializationAspect.VIEW,
InitializationAspect.INITIAL_TRIGGER,
]); ]);
} }
@@ -131,6 +136,21 @@ export class InitializationManager {
return; return;
} }
if (
!(await this._initializer.initializeIfNecessary(
InitializationAspect.INITIAL_TRIGGER,
async (): Promise<boolean> => {
await this._api.getTriggersManager().handleInitialCameraTriggers();
// Force a card update to continue the initialization.
this._api.getCardElementManager().update();
return true;
},
))
) {
return;
}
this._everInitialized = true; this._everInitialized = true;
// When the card is initialized, both the initialization state (will never // When the card is initialized, both the initialization state (will never
+26
View File
@@ -1,5 +1,6 @@
import { orderBy, throttle } from 'lodash-es'; import { orderBy, throttle } from 'lodash-es';
import { CameraEvent } from '../camera-manager/types'; import { CameraEvent } from '../camera-manager/types';
import { isTriggeredState } from '../ha/is-triggered-state';
import { Timer } from '../utils/timer'; import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types'; import { CardTriggersAPI } from './types';
@@ -34,6 +35,31 @@ export class TriggersManager {
return sorted.length ? sorted[0][0] : null; return sorted.length ? sorted[0][0] : null;
} }
public handleInitialCameraTriggers = async (): Promise<boolean> => {
const hass = this._api.getHASSManager().getHASS();
let triggered = false;
for (const [cameraID, camera] of this._api
.getCameraManager()
.getStore()
.getCameras()) {
if (
camera
.getConfig()
.triggers.entities.some((entityID) =>
isTriggeredState(hass?.states[entityID]?.state),
)
) {
triggered = true;
await this.handleCameraEvent({
cameraID,
type: 'new',
});
}
}
return triggered;
};
public async handleCameraEvent(ev: CameraEvent): Promise<void> { public async handleCameraEvent(ev: CameraEvent): Promise<void> {
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers; const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
const selectedCameraID = this._api.getViewManager().getView()?.camera; const selectedCameraID = this._api.getViewManager().getView()?.camera;
+2
View File
@@ -199,6 +199,7 @@ export interface CardInitializerAPI {
getMessageManager(): MessageManager; getMessageManager(): MessageManager;
getQueryStringManager(): QueryStringManager; getQueryStringManager(): QueryStringManager;
getResolvedMediaCache(): ResolvedMediaCache; getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
@@ -272,6 +273,7 @@ export interface CardTriggersAPI {
getConditionStateManager(): ConditionStateManager; getConditionStateManager(): ConditionStateManager;
getCardElementManager(): CardElementManager; getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager; getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager; getInteractionManager(): InteractionManager;
getViewManager(): ViewManager; getViewManager(): ViewManager;
} }
+2 -2
View File
@@ -1,2 +1,2 @@
export const STATES_OFF = ['closed', 'locked', 'off']; export const STATES_OFF = ['off', 'closed', 'locked'];
export const STATES_ON = ['open', 'unlocked', 'on']; export const STATES_ON = ['on', 'open', 'unlocked'];
@@ -123,6 +123,7 @@ describe('InitializationManager', () => {
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeTruthy(); expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy(); expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeTruthy(); expect(manager.isInitialized(InitializationAspect.VIEW)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.INITIAL_TRIGGER)).toBeTruthy();
}); });
it('successfully with microphone if configured', async () => { it('successfully with microphone if configured', async () => {
@@ -174,6 +175,8 @@ describe('InitializationManager', () => {
initializer.initializeMultipleIfNecessary.mockResolvedValue(false); initializer.initializeMultipleIfNecessary.mockResolvedValue(false);
await manager.initializeMandatory(); await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
}); });
it('with cameras in progress', async () => { it('with cameras in progress', async () => {
@@ -188,6 +191,27 @@ describe('InitializationManager', () => {
.mockResolvedValueOnce(false); .mockResolvedValueOnce(false);
await manager.initializeMandatory(); await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
});
it('with triggers in progress', async () => {
const api = createCardAPI();
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
vi.mocked(api.getConfigManager().getConfig).mockReturnValue(createConfig());
const initializer = mock<Initializer>();
const manager = new InitializationManager(api, initializer);
initializer.initializeMultipleIfNecessary
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(true);
initializer.initializeIfNecessary
.mockResolvedValueOnce(true)
.mockResolvedValueOnce(false);
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
}); });
}); });
@@ -9,6 +9,8 @@ import {
createCameraManager, createCameraManager,
createCardAPI, createCardAPI,
createConfig, createConfig,
createHASS,
createStateEntity,
createStore, createStore,
createView, createView,
flushPromises, flushPromises,
@@ -623,4 +625,92 @@ describe('TriggersManager', () => {
expect(manager.isTriggered()).toBeTruthy(); expect(manager.isTriggered()).toBeTruthy();
}); });
}); });
describe('should handle initial camera triggers', () => {
it('should not trigger if no cameras have trigger entities', async () => {
const api = createTriggerAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera_1',
config: createCameraConfig({
triggers: {
entities: [],
},
}),
},
]),
);
const manager = new TriggersManager(api);
const result = await manager.handleInitialCameraTriggers();
expect(result).toBeFalsy();
expect(manager.isTriggered()).toBeFalsy();
});
it('should not trigger if no cameras have trigger state', async () => {
const api = createTriggerAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera_1',
config: createCameraConfig({
triggers: {
entities: ['binary_sensor.motion', 'binary_sensor.occupancy'],
},
}),
},
]),
);
const hass = createHASS({
'binary_sensor.motion': createStateEntity({
state: 'off',
}),
'binary_sensor.occupancy': createStateEntity({
state: 'off',
}),
});
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new TriggersManager(api);
const result = await manager.handleInitialCameraTriggers();
expect(result).toBeFalsy();
expect(manager.isTriggered()).toBeFalsy();
});
it('should trigger if cameras are triggered', async () => {
const api = createTriggerAPI();
vi.mocked(api.getCameraManager().getStore).mockReturnValue(
createStore([
{
cameraID: 'camera_1',
config: createCameraConfig({
triggers: {
entities: ['binary_sensor.motion', 'binary_sensor.occupancy'],
},
}),
},
]),
);
const hass = createHASS({
'binary_sensor.motion': createStateEntity({
state: 'off',
}),
'binary_sensor.occupancy': createStateEntity({
state: 'on',
}),
});
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new TriggersManager(api);
const result = await manager.handleInitialCameraTriggers();
expect(result).toBeTruthy();
expect(manager.isTriggered()).toBeTruthy();
});
});
}); });