From b33c0348108fdf6b255155c312cb486346d3d554 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 24 Jun 2026 20:05:20 +0200 Subject: [PATCH] feat: Add card picker suggestion for camera entities (#2517) Selecting a camera entity in the dashboard card picker now suggests Advanced Camera Card under the Community section, so you can add it in one click without searching for it manually. Requires Home Assistant 2026.6 or later. Docs: https://developers.home-assistant.io/docs/frontend/custom-ui/custom-card#suggesting-your-card-for-an-entity --------- Co-authored-by: dermotduffy --- src/card-controller/config/config-manager.ts | 33 ++++++++++++- src/card-controller/controller.ts | 15 ------ src/card.ts | 11 +++-- src/config/types.ts | 12 +++++ .../config/config-manager.test.ts | 46 ++++++++++++++++++- tests/card-controller/controller.test.ts | 16 ------- 6 files changed, 97 insertions(+), 36 deletions(-) diff --git a/src/card-controller/config/config-manager.ts b/src/card-controller/config/config-manager.ts index dbfb2af9..dcb16331 100644 --- a/src/card-controller/config/config-manager.ts +++ b/src/card-controller/config/config-manager.ts @@ -7,7 +7,12 @@ import { type AdvancedCameraCardConfig, type CardWideConfig, } from '../../config/schema/types.js'; -import type { RawAdvancedCameraCardConfig } from '../../config/types.js'; +import type { + PartialAdvancedCameraCardConfig, + RawAdvancedCameraCardConfig, +} from '../../config/types.js'; +import { computeDomain } from '../../ha/compute-domain.js'; +import type { HomeAssistant } from '../../ha/types.js'; import { localize } from '../../localize/localize.js'; import { getParseError } from '../../utils/zod/parse-errors.js'; import { InitializationAspect } from '../initialization-manager.js'; @@ -36,6 +41,32 @@ export class ConfigManager { this._api = api; } + public static getEntitySuggestion( + hass: HomeAssistant, + entityId: string, + ): { config: PartialAdvancedCameraCardConfig } | null { + if (computeDomain(entityId) !== 'camera' || !hass.states[entityId]) { + return null; + } + return { + config: { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: entityId }], + }, + }; + } + + public static getStubConfig(entities: string[]): PartialAdvancedCameraCardConfig { + const cameraEntity = entities.find((entity) => computeDomain(entity) === 'camera'); + return { + cameras: [ + { + camera_entity: cameraEntity ?? 'camera.demo', + }, + ], + }; + } + public hasConfig(): boolean { return !!this.getConfig(); } diff --git a/src/card-controller/controller.ts b/src/card-controller/controller.ts index e9b3b3ea..3d43e300 100644 --- a/src/card-controller/controller.ts +++ b/src/card-controller/controller.ts @@ -2,7 +2,6 @@ import type { ReactiveController } from 'lit'; import { CameraManager } from '../camera-manager/manager'; import { ConditionStateManager } from '../condition-trigger/conditions/state-manager'; -import type { AdvancedCameraCardConfig } from '../config/schema/types'; import { DeviceRegistryManager } from '../ha/registry/device'; import { DeviceCache } from '../ha/registry/device/types'; import { EntityRegistryManagerLive } from '../ha/registry/entity'; @@ -303,20 +302,6 @@ export class CardController return this._statusBarItemManager; } - public static getStubConfig(entities: string[]): AdvancedCameraCardConfig { - const cameraEntity = entities.find((element) => element.startsWith('camera.')); - return { - cameras: [ - { - camera_entity: cameraEntity ?? 'camera.demo', - }, - ], - // Need to use 'as unknown' to convince Typescript that this really isn't a - // mistake, despite the miniscule size of the configuration vs the full type - // description. - } as unknown as AdvancedCameraCardConfig; - } - public getStyleManager(): StyleManager { return this._styleManager; } diff --git a/src/card.ts b/src/card.ts index eeafe07d..ae687d53 100644 --- a/src/card.ts +++ b/src/card.ts @@ -14,6 +14,7 @@ import { styleMap } from 'lit/directives/style-map.js'; import 'web-dialog'; import { actionHandler } from './action-handler-directive.js'; +import { ConfigManager } from './card-controller/config/config-manager'; import { CardController } from './card-controller/controller'; import type { IssueKey, IssueTriggerEventData } from './card-controller/issues/types.js'; import { resolveAutoHideState, type AutoHideState } from './components-lib/auto-hide.js'; @@ -47,7 +48,10 @@ import type { ConditionStateManagerGetEvent } from './condition-trigger/conditio import type { StatusBarItem } from './config/schema/actions/types.js'; import type { MenuItem } from './config/schema/elements/custom/menu/types.js'; import type { AdvancedCameraCardConfig } from './config/schema/types.js'; -import type { RawAdvancedCameraCardConfig } from './config/types.js'; +import type { + PartialAdvancedCameraCardConfig, + RawAdvancedCameraCardConfig, +} from './config/types.js'; import { REPO_URL } from './const.js'; import type { HomeAssistant, LovelaceCardEditor } from './ha/types.js'; import { localize } from './localize/localize.js'; @@ -97,6 +101,7 @@ console.info( description: localize('common.advanced_camera_card_description'), preview: true, documentationURL: REPO_URL, + getEntitySuggestion: ConfigManager.getEntitySuggestion, }); // Expose currently-connected card instances on `window.advancedCameraCards` for @@ -172,8 +177,8 @@ class AdvancedCameraCard extends LitElement { public static getStubConfig( _: HomeAssistant, entities: string[], - ): AdvancedCameraCardConfig { - return CardController.getStubConfig(entities); + ): PartialAdvancedCameraCardConfig { + return ConfigManager.getStubConfig(entities); } public setConfig(config: RawAdvancedCameraCardConfig): void { diff --git a/src/config/types.ts b/src/config/types.ts index ca5a5d6e..d7feac16 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -1,2 +1,14 @@ +import type { PartialDeep } from 'type-fest'; + +import type { AdvancedCameraCardConfig } from './schema/types'; + export type RawAdvancedCameraCardConfig = Record; export type RawAdvancedCameraCardConfigArray = RawAdvancedCameraCardConfig[]; + +// A partial config used for pre-parsed configs (e.g. a stub config). Nested +// fields are optional because their defaults are applied when the config is +// parsed. +export type PartialAdvancedCameraCardConfig = PartialDeep< + AdvancedCameraCardConfig, + { recurseIntoArrays: true } +>; diff --git a/tests/card-controller/config/config-manager.test.ts b/tests/card-controller/config/config-manager.test.ts index 41b50ac0..397eaabd 100644 --- a/tests/card-controller/config/config-manager.test.ts +++ b/tests/card-controller/config/config-manager.test.ts @@ -11,7 +11,13 @@ import type { Automation } from '../../../src/config/schema/automations'; import type { Trigger } from '../../../src/config/schema/condition-trigger/triggers/types'; import { advancedCameraCardConfigSchema } from '../../../src/config/schema/types'; import { createGeneralAction } from '../../../src/utils/action'; -import { createCardAPI, createConfig, flushPromises } from '../../test-utils'; +import { + createCardAPI, + createConfig, + createHASS, + createStateEntity, + flushPromises, +} from '../../test-utils'; /** * Create a ConfigManager test setup with real AutomationsManager and ConditionStateManager. @@ -75,6 +81,44 @@ describe('ConfigManager', () => { vi.restoreAllMocks(); }); + describe('getEntitySuggestion', () => { + it('should suggest the card for a camera entity', () => { + const hass = createHASS({ 'camera.office': createStateEntity() }); + expect(ConfigManager.getEntitySuggestion(hass, 'camera.office')).toEqual({ + config: { + type: 'custom:advanced-camera-card', + cameras: [{ camera_entity: 'camera.office' }], + }, + }); + }); + + it('should not suggest the card for a non-camera entity', () => { + const hass = createHASS({ 'binary_sensor.motion': createStateEntity() }); + expect(ConfigManager.getEntitySuggestion(hass, 'binary_sensor.motion')).toBeNull(); + }); + + it('should not suggest the card for an unknown entity', () => { + const hass = createHASS({}); + expect(ConfigManager.getEntitySuggestion(hass, 'camera.office')).toBeNull(); + }); + }); + + describe('getStubConfig', () => { + it('should handle with camera entities', () => { + expect( + ConfigManager.getStubConfig(['camera.office', 'binary_sensor.motion']), + ).toEqual({ + cameras: [{ camera_entity: 'camera.office' }], + }); + }); + + it('should handle without camera entities', () => { + expect(ConfigManager.getStubConfig(['binary_sensor.motion'])).toEqual({ + cameras: [{ camera_entity: 'camera.demo' }], + }); + }); + }); + describe('should handle error when', () => { it('should handle no input', () => { const manager = new ConfigManager(createCardAPI()); diff --git a/tests/card-controller/controller.test.ts b/tests/card-controller/controller.test.ts index 854ec6be..952718f3 100644 --- a/tests/card-controller/controller.test.ts +++ b/tests/card-controller/controller.test.ts @@ -302,22 +302,6 @@ describe('CardController', () => { ); }); - describe('getStubConfig', () => { - it('should handle with camera entities', () => { - expect( - CardController.getStubConfig(['camera.office', 'binary_sensor.motion']), - ).toEqual({ - cameras: [{ camera_entity: 'camera.office' }], - }); - }); - - it('should handle without camera entities', () => { - expect(CardController.getStubConfig(['binary_sensor.motion'])).toEqual({ - cameras: [{ camera_entity: 'camera.demo' }], - }); - }); - }); - it('should return getQueryStringManager', () => { expect(createController().getQueryStringManager()).toBe( vi.mocked(QueryStringManager).mock.instances[0],