diff --git a/src/card-controller/config/load-control-entities.ts b/src/card-controller/config/load-control-entities.ts index 2f3c444b..ebdc7d10 100644 --- a/src/card-controller/config/load-control-entities.ts +++ b/src/card-controller/config/load-control-entities.ts @@ -1,10 +1,6 @@ import { isEqual } from 'lodash-es'; import { RemoteControlEntityPriority } from '../../config/schema/remote-control'; -import { - createCameraAction, - createInternalCallbackAction, - createSelectOptionAction, -} from '../../utils/action'; +import { createCameraAction, createInternalCallbackAction } from '../../utils/action'; import { CardActionsAPI, CardConfigLoaderAPI, TaggedAutomation } from '../types'; export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { @@ -48,11 +44,15 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { }, ], actions: [ - // When the camera changes, update the entity to match. - createSelectOptionAction( - 'input_select', - cameraControlEntity, - '{{ advanced_camera_card.trigger.camera.to }}', + // When the camera changes, update the entity to match (only if different + // to avoid race conditions when multiple cards share the same entity). + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2244 + createInternalCallbackAction((api: CardActionsAPI) => + selectOptionOnEntityIfDifferent( + cameraControlEntity, + api.getViewManager().getView()?.camera, + api, + ), ), ], tag: automationTag, @@ -76,10 +76,12 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { `{{ hass.states["${cameraControlEntity}"].state }}`, ) : // Set the selected option in the entity to the current camera ID. - createSelectOptionAction( - 'input_select', - cameraControlEntity, - '{{ advanced_camera_card.camera }}', + createInternalCallbackAction((api: CardActionsAPI) => + selectOptionOnEntityIfDifferent( + cameraControlEntity, + api.getViewManager().getView()?.camera, + api, + ), ), ], tag: automationTag, @@ -105,6 +107,33 @@ export const setRemoteControlEntityFromConfig = (api: CardConfigLoaderAPI) => { api.getAutomationsManager().addAutomations(automations); }; +const selectOptionOnEntityIfDifferent = async ( + entity: string, + option: string | undefined, + api: CardActionsAPI, +): Promise => { + const hass = api.getHASSManager().getHASS(); + const currentState = hass?.states[entity]?.state; + + // Only update if the option is defined and different from current state. + // This prevents race conditions when multiple cards share the same entity. + // See: https://github.com/dermotduffy/advanced-camera-card/issues/2244 + if (!option || option === currentState) { + return; + } + + await hass?.callService( + 'input_select', + 'select_option', + { + option: option, + }, + { + entity_id: entity, + }, + ); +}; + const setCameraOptionsOnEntity = async (entity: string, api: CardActionsAPI) => { const hass = api.getHASSManager().getHASS(); const cameraIDs = api.getCameraManager().getStore().getCameraIDs(); diff --git a/tests/card-controller/config/load-control-entities.test.ts b/tests/card-controller/config/load-control-entities.test.ts index e2180a47..9459a5d1 100644 --- a/tests/card-controller/config/load-control-entities.test.ts +++ b/tests/card-controller/config/load-control-entities.test.ts @@ -1,6 +1,9 @@ -import { assert, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { setRemoteControlEntityFromConfig } from '../../../src/card-controller/config/load-control-entities'; -import { INTERNAL_CALLBACK_ACTION } from '../../../src/config/schema/actions/custom/internal'; +import { + INTERNAL_CALLBACK_ACTION, + InternalCallbackActionConfig, +} from '../../../src/config/schema/actions/custom/internal'; import { isAdvancedCameraCardCustomAction } from '../../../src/utils/action'; import { createCardAPI, @@ -8,6 +11,7 @@ import { createHASS, createStateEntity, createStore, + createView, } from '../../test-utils'; describe('setRemoteControlEntityFromConfig', () => { @@ -55,14 +59,9 @@ describe('setRemoteControlEntityFromConfig', () => { { actions: [ { - action: 'perform-action', - data: { - option: '{{ advanced_camera_card.trigger.camera.to }}', - }, - perform_action: 'input_select.select_option', - target: { - entity_id: 'input_select.camera', - }, + action: 'fire-dom-event', + advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__', + callback: expect.any(Function), }, ], conditions: [ @@ -75,14 +74,9 @@ describe('setRemoteControlEntityFromConfig', () => { { actions: [ { - action: 'perform-action', - data: { - option: '{{ advanced_camera_card.camera }}', - }, - perform_action: 'input_select.select_option', - target: { - entity_id: 'input_select.camera', - }, + action: 'fire-dom-event', + advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__', + callback: expect.any(Function), }, ], conditions: [ @@ -147,14 +141,9 @@ describe('setRemoteControlEntityFromConfig', () => { { actions: [ { - action: 'perform-action', - data: { - option: '{{ advanced_camera_card.trigger.camera.to }}', - }, - perform_action: 'input_select.select_option', - target: { - entity_id: 'input_select.camera', - }, + action: 'fire-dom-event', + advanced_camera_card_action: '__INTERNAL_CALLBACK_ACTION__', + callback: expect.any(Function), }, ], conditions: [ @@ -225,9 +214,12 @@ describe('setRemoteControlEntityFromConfig', () => { setRemoteControlEntityFromConfig(api); const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock - .calls[0][0][0].actions?.[0]; - assert(addOptionsAction && isAdvancedCameraCardCustomAction(addOptionsAction)); - assert(addOptionsAction.advanced_camera_card_action === INTERNAL_CALLBACK_ACTION); + .calls[0][0][0].actions?.[0] as InternalCallbackActionConfig; + expect(addOptionsAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(addOptionsAction)).toBeTruthy(); + expect(addOptionsAction.advanced_camera_card_action).toBe( + INTERNAL_CALLBACK_ACTION, + ); addOptionsAction.callback(api); expect(hass.callService).toBeCalledWith( @@ -272,12 +264,306 @@ describe('setRemoteControlEntityFromConfig', () => { setRemoteControlEntityFromConfig(api); const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock - .calls[0][0][0].actions?.[0]; - assert(addOptionsAction && isAdvancedCameraCardCustomAction(addOptionsAction)); - assert(addOptionsAction.advanced_camera_card_action === INTERNAL_CALLBACK_ACTION); + .calls[0][0][0].actions?.[0] as InternalCallbackActionConfig; + expect(addOptionsAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(addOptionsAction)).toBeTruthy(); + expect(addOptionsAction.advanced_camera_card_action).toBe( + INTERNAL_CALLBACK_ACTION, + ); addOptionsAction.callback(api); expect(hass.callService).not.toBeCalled(); }); + + it('should not throw when hass is undefined setting options', () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + }, + }, + }), + ); + const store = createStore([ + { + cameraID: 'camera.one', + }, + { + cameraID: 'camera.two', + }, + ]); + vi.mocked(api.getCameraManager().getStore).mockReturnValue(store); + + setRemoteControlEntityFromConfig(api); + + const addOptionsAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][0].actions?.[0] as InternalCallbackActionConfig; + expect(addOptionsAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(addOptionsAction)).toBeTruthy(); + expect(addOptionsAction.advanced_camera_card_action).toBe( + INTERNAL_CALLBACK_ACTION, + ); + + // Should not throw + addOptionsAction.callback(api); + }); + }); + + describe('should select option on entity', () => { + it('should select option when camera differs from entity state', () => { + const hass = createHASS({ + 'input_select.camera': createStateEntity({ + state: 'camera.one', + }), + }); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'card', + }, + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.two', + view: 'live', + }), + ); + + setRemoteControlEntityFromConfig(api); + + // Get the camera sync callback (automation index 1) + const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][1].actions?.[0] as InternalCallbackActionConfig; + expect(cameraSyncAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy(); + expect(cameraSyncAction.advanced_camera_card_action).toBe( + INTERNAL_CALLBACK_ACTION, + ); + + cameraSyncAction.callback(api); + expect(hass.callService).toBeCalledWith( + 'input_select', + 'select_option', + { + option: 'camera.two', + }, + { + entity_id: 'input_select.camera', + }, + ); + }); + + it('should not select option when camera matches entity state', () => { + const hass = createHASS({ + 'input_select.camera': createStateEntity({ + state: 'camera.one', + }), + }); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'card', + }, + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.one', + view: 'live', + }), + ); + + setRemoteControlEntityFromConfig(api); + + // Get the camera sync callback (automation index 1) + const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][1].actions?.[0] as InternalCallbackActionConfig; + expect(cameraSyncAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy(); + expect(cameraSyncAction.advanced_camera_card_action).toBe( + INTERNAL_CALLBACK_ACTION, + ); + + cameraSyncAction.callback(api); + + // Should NOT call select_option since entity already shows camera.one + expect(hass.callService).not.toBeCalled(); + }); + + it('should not select option when camera is undefined', () => { + const hass = createHASS({ + 'input_select.camera': createStateEntity({ + state: 'camera.one', + }), + }); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'card', + }, + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue(null); + + setRemoteControlEntityFromConfig(api); + + // Get the camera sync callback (automation index 1) + const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][1].actions?.[0] as InternalCallbackActionConfig; + expect(cameraSyncAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy(); + expect(cameraSyncAction.advanced_camera_card_action).toBe( + INTERNAL_CALLBACK_ACTION, + ); + + cameraSyncAction.callback(api); + + // Should NOT call select_option since camera is undefined + expect(hass.callService).not.toBeCalled(); + }); + + it('should not throw when hass is undefined', () => { + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'card', + }, + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.two', + view: 'live', + }), + ); + + setRemoteControlEntityFromConfig(api); + + const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][1].actions?.[0] as InternalCallbackActionConfig; + expect(cameraSyncAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy(); + expect(cameraSyncAction.advanced_camera_card_action).toBe( + INTERNAL_CALLBACK_ACTION, + ); + + // Should not throw and obviously not call service (as hass is null) + cameraSyncAction.callback(api); + }); + + it('should select option when entity state is undefined', () => { + const hass = createHASS({}); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'card', + }, + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.two', + view: 'live', + }), + ); + + setRemoteControlEntityFromConfig(api); + + const cameraSyncAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][1].actions?.[0] as InternalCallbackActionConfig; + expect(cameraSyncAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(cameraSyncAction)).toBeTruthy(); + expect(cameraSyncAction.advanced_camera_card_action).toBe( + INTERNAL_CALLBACK_ACTION, + ); + + cameraSyncAction.callback(api); + expect(hass.callService).toBeCalledWith( + 'input_select', + 'select_option', + { + option: 'camera.two', + }, + { + entity_id: 'input_select.camera', + }, + ); + }); + + it('should select option on initialization with card priority', () => { + const hass = createHASS({ + 'input_select.camera': createStateEntity({ + state: 'camera.one', + }), + }); + const api = createCardAPI(); + vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass); + vi.mocked(api.getConfigManager().getConfig).mockReturnValue( + createConfig({ + remote_control: { + entities: { + camera: 'input_select.camera', + camera_priority: 'card', + }, + }, + }), + ); + vi.mocked(api.getViewManager().getView).mockReturnValue( + createView({ + camera: 'camera.two', + view: 'live', + }), + ); + + setRemoteControlEntityFromConfig(api); + + // Get the initialization callback (automation index 2) + const initAction = vi.mocked(api.getAutomationsManager().addAutomations).mock + .calls[0][0][2].actions?.[0] as InternalCallbackActionConfig; + expect(initAction).toBeTruthy(); + expect(isAdvancedCameraCardCustomAction(initAction)).toBeTruthy(); + expect(initAction.advanced_camera_card_action).toBe(INTERNAL_CALLBACK_ACTION); + + initAction.callback(api); + expect(hass.callService).toBeCalledWith( + 'input_select', + 'select_option', + { + option: 'camera.two', + }, + { + entity_id: 'input_select.camera', + }, + ); + }); }); });