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
behavior can be controlled by the `interaction_mode` parameter.
Triggers based on Home Assistant entities require state _changes_ -- when the
card is first started, it takes an active change in state to trigger (i.e. an
already occupied room will not trigger, but a newly occupied room will).
If the card starts when a trigger entity is already in a triggered state, the
action will be taken on card startup.
| Option | Default | Description |
| ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
@@ -10,6 +10,10 @@ export enum InitializationAspect {
CAMERAS = 'cameras',
MICROPHONE_CONNECT = 'microphone-connect',
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.VIEW,
InitializationAspect.INITIAL_TRIGGER,
]);
}
@@ -131,6 +136,21 @@ export class InitializationManager {
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;
// 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 { CameraEvent } from '../camera-manager/types';
import { isTriggeredState } from '../ha/is-triggered-state';
import { Timer } from '../utils/timer';
import { CardTriggersAPI } from './types';
@@ -34,6 +35,31 @@ export class TriggersManager {
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> {
const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers;
const selectedCameraID = this._api.getViewManager().getView()?.camera;
+2
View File
@@ -199,6 +199,7 @@ export interface CardInitializerAPI {
getMessageManager(): MessageManager;
getQueryStringManager(): QueryStringManager;
getResolvedMediaCache(): ResolvedMediaCache;
getTriggersManager(): TriggersManager;
getViewManager(): ViewManager;
}
@@ -272,6 +273,7 @@ export interface CardTriggersAPI {
getConditionStateManager(): ConditionStateManager;
getCardElementManager(): CardElementManager;
getConfigManager(): ConfigManager;
getHASSManager(): HASSManager;
getInteractionManager(): InteractionManager;
getViewManager(): ViewManager;
}
+2 -2
View File
@@ -1,2 +1,2 @@
export const STATES_OFF = ['closed', 'locked', 'off'];
export const STATES_ON = ['open', 'unlocked', 'on'];
export const STATES_OFF = ['off', 'closed', 'locked'];
export const STATES_ON = ['on', 'open', 'unlocked'];
@@ -123,6 +123,7 @@ describe('InitializationManager', () => {
expect(manager.isInitialized(InitializationAspect.CAMERAS)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.MICROPHONE_CONNECT)).toBeFalsy();
expect(manager.isInitialized(InitializationAspect.VIEW)).toBeTruthy();
expect(manager.isInitialized(InitializationAspect.INITIAL_TRIGGER)).toBeTruthy();
});
it('successfully with microphone if configured', async () => {
@@ -174,6 +175,8 @@ describe('InitializationManager', () => {
initializer.initializeMultipleIfNecessary.mockResolvedValue(false);
await manager.initializeMandatory();
expect(manager.wasEverInitialized()).toBeFalsy();
});
it('with cameras in progress', async () => {
@@ -188,6 +191,27 @@ describe('InitializationManager', () => {
.mockResolvedValueOnce(false);
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,
createCardAPI,
createConfig,
createHASS,
createStateEntity,
createStore,
createView,
flushPromises,
@@ -623,4 +625,92 @@ describe('TriggersManager', () => {
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();
});
});
});