diff --git a/docs/configuration/view.md b/docs/configuration/view.md index e6b2a7a0..f8401ce5 100644 --- a/docs/configuration/view.md +++ b/docs/configuration/view.md @@ -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 | | ------------------------ | ------- | ---------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/card-controller/initialization-manager.ts b/src/card-controller/initialization-manager.ts index 7e82ff18..48b53c99 100644 --- a/src/card-controller/initialization-manager.ts +++ b/src/card-controller/initialization-manager.ts @@ -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 => { + 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 diff --git a/src/card-controller/triggers-manager.ts b/src/card-controller/triggers-manager.ts index a30b8fa0..48cdfc8d 100644 --- a/src/card-controller/triggers-manager.ts +++ b/src/card-controller/triggers-manager.ts @@ -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 => { + 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 { const triggersConfig = this._api.getConfigManager().getConfig()?.view.triggers; const selectedCameraID = this._api.getViewManager().getView()?.camera; diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 04f35e49..3728f061 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -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; } diff --git a/src/ha/const.ts b/src/ha/const.ts index 1df670cf..60ec1c79 100644 --- a/src/ha/const.ts +++ b/src/ha/const.ts @@ -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']; diff --git a/tests/card-controller/initialization-manager.test.ts b/tests/card-controller/initialization-manager.test.ts index 904b6661..cd312207 100644 --- a/tests/card-controller/initialization-manager.test.ts +++ b/tests/card-controller/initialization-manager.test.ts @@ -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(); + 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(); }); }); diff --git a/tests/card-controller/triggers-manager.test.ts b/tests/card-controller/triggers-manager.test.ts index 836ba0c0..3198df9c 100644 --- a/tests/card-controller/triggers-manager.test.ts +++ b/tests/card-controller/triggers-manager.test.ts @@ -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(); + }); + }); });