diff --git a/src/camera-manager/engine-factory.ts b/src/camera-manager/engine-factory.ts index 99dca886..4f1e8de4 100644 --- a/src/camera-manager/engine-factory.ts +++ b/src/camera-manager/engine-factory.ts @@ -96,6 +96,11 @@ export class CameraManagerEngineFactory { // Frigate technically does not need an entity, if the camera name is // manually set the camera is assumed to be Frigate. engine = Engine.Frigate; + } else if ( + cameraConfig.webrtc_card?.url || + (cameraConfig.go2rtc?.url && cameraConfig.go2rtc?.stream) + ) { + engine = Engine.Generic; } } diff --git a/src/camera-manager/generic/engine-generic.ts b/src/camera-manager/generic/engine-generic.ts index c7d38e78..7332fd92 100644 --- a/src/camera-manager/generic/engine-generic.ts +++ b/src/camera-manager/generic/engine-generic.ts @@ -31,7 +31,7 @@ import { RecordingSegmentsQuery, RecordingSegmentsQueryResultsMap, } from '../types'; -import { getDefaultGo2RTCEndpoint } from '../utils.js'; +import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js'; export class GenericCameraManagerEngine implements CameraManagerEngine { public getEngineType(): Engine { @@ -159,6 +159,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { hass: HomeAssistant, cameraConfig: CameraConfig, ): CameraManagerCameraMetadata { + const cameraEntity = getCameraEntityFromConfig(cameraConfig); return { title: cameraConfig.title ?? @@ -166,7 +167,9 @@ export class GenericCameraManagerEngine implements CameraManagerEngine { getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? cameraConfig.id ?? '', - icon: cameraConfig?.icon ?? getEntityIcon(hass, cameraConfig.camera_entity), + icon: + cameraConfig?.icon ?? + (cameraEntity ? getEntityIcon(hass, cameraEntity, 'mdi:video') : 'mdi:video'), }; } diff --git a/src/camera-manager/manager.ts b/src/camera-manager/manager.ts index 86df2f06..347eb346 100644 --- a/src/camera-manager/manager.ts +++ b/src/camera-manager/manager.ts @@ -259,6 +259,9 @@ export class CameraManager { ); } + // Always ensure the actual ID used in the card is in the configuration itself. + result.initializedConfig.id = id; + this._store.addCamera(id, result.initializedConfig, result.engine); }); diff --git a/src/components-lib/menu-controller.ts b/src/components-lib/menu-controller.ts index 79efe5e6..58beb41b 100644 --- a/src/components-lib/menu-controller.ts +++ b/src/components-lib/menu-controller.ts @@ -91,7 +91,7 @@ export class MenuButtonController { const action = createFrigateCardCustomAction('camera_select', { camera: cameraID, }); - const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined; + const metadata = cameraManager.getCameraMetadata(cameraID); return { enabled: true, diff --git a/src/utils/camera.ts b/src/utils/camera.ts index cd824e05..a72a507a 100644 --- a/src/utils/camera.ts +++ b/src/utils/camera.ts @@ -14,8 +14,15 @@ export function getCameraID( (typeof config?.camera_entity === 'string' && config.camera_entity) || (typeof config?.webrtc_card === 'object' && config.webrtc_card && - typeof config.webrtc_card['entity'] === 'string' && - config.webrtc_card['entity']) || + ((typeof config.webrtc_card['entity'] === 'string' && + config.webrtc_card['entity']) || + (typeof config.webrtc_card['url'] === 'string' && config.webrtc_card['url']))) || + (typeof config?.go2rtc === 'object' && + config.go2rtc && + typeof config.go2rtc['url'] === 'string' && + typeof config.go2rtc['stream'] === 'string' && + // Artifical identifier that includes both url / stream. + `${config.go2rtc['url']}#${config.go2rtc['stream']}`) || (typeof config?.frigate === 'object' && config.frigate && typeof config?.frigate['camera_name'] === 'string' && diff --git a/src/utils/ha/index.ts b/src/utils/ha/index.ts index 5f8e4e75..2b8d4ff4 100644 --- a/src/utils/ha/index.ts +++ b/src/utils/ha/index.ts @@ -1,4 +1,4 @@ -import { computeStateDomain, HomeAssistant } from 'custom-card-helpers'; +import { computeDomain, computeStateDomain, HomeAssistant } from 'custom-card-helpers'; import { HassEntity, MessageBase } from 'home-assistant-js-websocket'; import { StyleInfo } from 'lit/directives/style-map.js'; import { ZodSchema } from 'zod'; @@ -11,7 +11,7 @@ import { signedPathSchema, StateParameters, } from '../../types.js'; -import { stateIcon } from '../icons/state-icon.js'; +import { domainIcon } from '../icons/domain-icon.js'; import { getParseErrorKeys } from '../zod.js'; /** @@ -234,7 +234,7 @@ export function refreshDynamicStateParameters( params.style = { ...computeStyle(state), ...params.style }; } params.title = params.title ?? (state?.attributes?.friendly_name || params.entity); - params.icon = params.icon ?? stateIcon(state); + params.icon = params.icon ?? getEntityIcon(hass, params.entity); const domain = state ? computeStateDomain(state) : undefined; params.data_domain = @@ -262,12 +262,25 @@ export function getEntityTitle( /** * Get the icon of an entity. - * @param entity The entity id. + * @param entityID The entity id. * @param hass The Home Assistant object. * @returns The icon or undefined. */ -export function getEntityIcon(hass?: HomeAssistant, entity?: string): string { - return stateIcon(entity ? hass?.states[entity] : null); +export function getEntityIcon( + hass: HomeAssistant, + entityID: string, + defaultIcon?: string, +): string { + const entityState = hass.states[entityID]; + if (entityState && entityState.attributes.icon) { + return entityState.attributes.icon; + } + return domainIcon( + computeDomain(entityID), + entityState, + entityState?.state, + defaultIcon, + ); } /** diff --git a/src/utils/icons/domain-icon.ts b/src/utils/icons/domain-icon.ts index e79bbf54..12dbdc99 100644 --- a/src/utils/icons/domain-icon.ts +++ b/src/utils/icons/domain-icon.ts @@ -52,7 +52,7 @@ const FIXED_DOMAIN_ICONS = { zone: 'mdi:map-marker-radius', }; -export function domainIcon(domain: string, entity?: HassEntity, state?: string): string { +export function domainIcon(domain: string, entity?: HassEntity, state?: string, defaultIcon?: string): string { switch (domain) { case 'alarm_control_panel': return alarmPanelIcon(state); @@ -195,6 +195,5 @@ export function domainIcon(domain: string, entity?: HassEntity, state?: string): return FIXED_DOMAIN_ICONS[domain]; } - console.warn(`Unable to find icon for domain: ${domain}`); - return DEFAULT_DOMAIN_ICON; + return defaultIcon ?? DEFAULT_DOMAIN_ICON; } diff --git a/src/utils/icons/state-icon.ts b/src/utils/icons/state-icon.ts deleted file mode 100644 index 0e45a66d..00000000 --- a/src/utils/icons/state-icon.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { computeDomain } from 'custom-card-helpers'; -import { HassEntity } from 'home-assistant-js-websocket'; -import { DEFAULT_DOMAIN_ICON, domainIcon } from './domain-icon'; - -export function stateIcon(entity?: HassEntity | null): string { - if (!entity) { - return DEFAULT_DOMAIN_ICON; - } - if (entity.attributes.icon) { - return entity.attributes.icon; - } - - const domain = computeDomain(entity.entity_id); - - const state = entity.state; - - return domainIcon(domain, entity, state); -} diff --git a/tests/camera-manager/engine-factory.test.ts b/tests/camera-manager/engine-factory.test.ts index 95e1db69..647352f9 100644 --- a/tests/camera-manager/engine-factory.test.ts +++ b/tests/camera-manager/engine-factory.test.ts @@ -29,81 +29,137 @@ const createFactory = (options?: { ); }; -describe('CameraManagerEngineFactory.getEngineForCamera()', () => { - it('should get frigate engine from config', async () => { - const config = createCameraConfig({ engine: 'frigate' }); - expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( - Engine.Frigate, - ); - }); - it('should get motionEye engine from config', async () => { - const config = createCameraConfig({ engine: 'motioneye' }); - expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( - Engine.MotionEye, - ); - }); - it('should get generic engine from config', async () => { - const config = createCameraConfig({ engine: 'generic' }); - expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( - Engine.Generic, - ); - }); - it('should get frigate engine from auto config', async () => { - const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager(new EntityCache()); +describe('getEngineForCamera()', () => { + describe('should get a frigate camera', () => { + it('from manually set engine', async () => { + const config = createCameraConfig({ engine: 'frigate' }); + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( + Engine.Frigate, + ); + }); - entityRegistryManager.getEntity = vi - .fn() - .mockResolvedValue( - createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }), + it('from auto detection', async () => { + const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); + const entityRegistryManager = new EntityRegistryManager(new EntityCache()); + + entityRegistryManager.getEntity = vi + .fn() + .mockResolvedValue( + createRegistryEntity({ entity_id: 'camera.foo', platform: 'frigate' }), + ); + + expect( + await createFactory({ + entityRegistryManager: entityRegistryManager, + }).getEngineForCamera(createHASS(), config), + ).toBe(Engine.Frigate); + }); + + it('from config with camera_name', async () => { + const config = createCameraConfig({ + frigate: { client_id: 'bar', camera_name: 'foo' }, + }); + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( + Engine.Frigate, ); - - expect( - await createFactory({ - entityRegistryManager: entityRegistryManager, - }).getEngineForCamera(createHASS(), config), - ).toBe(Engine.Frigate); - }); - it('should get motioneye engine from auto config', async () => { - const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager(new EntityCache()); - - entityRegistryManager.getEntity = vi - .fn() - .mockResolvedValue( - createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }), - ); - - expect( - await createFactory({ - entityRegistryManager: entityRegistryManager, - }).getEngineForCamera(createHASS(), config), - ).toBe(Engine.MotionEye); - }); - it('should get generic engine from auto config', async () => { - const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager(new EntityCache()); - - entityRegistryManager.getEntity = vi - .fn() - .mockResolvedValue( - createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }), - ); - - expect( - await createFactory({ - entityRegistryManager: entityRegistryManager, - }).getEngineForCamera(createHASS(), config), - ).toBe(Engine.Generic); - }); - it('should get frigate engine from config with camera_name', async () => { - const config = createCameraConfig({ - frigate: { client_id: 'bar', camera_name: 'foo' }, }); - expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( - Engine.Frigate, - ); }); + + describe('should get a motionEye camera', () => { + it('from manually set engine', async () => { + const config = createCameraConfig({ engine: 'motioneye' }); + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( + Engine.MotionEye, + ); + }); + + it('from auto detection', async () => { + const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); + const entityRegistryManager = new EntityRegistryManager(new EntityCache()); + + entityRegistryManager.getEntity = vi + .fn() + .mockResolvedValue( + createRegistryEntity({ entity_id: 'camera.foo', platform: 'motioneye' }), + ); + + expect( + await createFactory({ + entityRegistryManager: entityRegistryManager, + }).getEngineForCamera(createHASS(), config), + ).toBe(Engine.MotionEye); + }); + }); + + describe('should get a generic camera', () => { + it('from manually set engine', async () => { + const config = createCameraConfig({ engine: 'generic' }); + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( + Engine.Generic, + ); + }); + + it('from auto detection', async () => { + const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); + const entityRegistryManager = new EntityRegistryManager(new EntityCache()); + + entityRegistryManager.getEntity = vi + .fn() + .mockResolvedValue( + createRegistryEntity({ entity_id: 'camera.foo', platform: 'generic' }), + ); + + expect( + await createFactory({ + entityRegistryManager: entityRegistryManager, + }).getEngineForCamera(createHASS(), config), + ).toBe(Engine.Generic); + }); + + it('from entity not in registry but with state', async () => { + const config = createCameraConfig({ + engine: 'auto', + webrtc_card: { entity: 'camera.foo' }, + }); + const entityRegistryManager = new EntityRegistryManager(new EntityCache()); + + entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error()); + + expect( + await createFactory({ + entityRegistryManager: entityRegistryManager, + }).getEngineForCamera( + createHASS({ + 'camera.foo': createStateEntity(), + }), + config, + ), + ).toBe(Engine.Generic); + }); + + it('from webrtc-card url', async () => { + const config = createCameraConfig({ + engine: 'auto', + webrtc_card: { url: 'camera.foo' }, + }); + + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( + Engine.Generic, + ); + }); + + it('from go2rtc url and stream', async () => { + const config = createCameraConfig({ + engine: 'auto', + go2rtc: { url: 'https://my-go2rtc', stream: 'office' }, + }); + + expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( + Engine.Generic, + ); + }); + }); + it('should get no engine from config with insufficient details', async () => { const config = createCameraConfig({}); expect(await createFactory().getEngineForCamera(createHASS(), config)).toBeNull(); @@ -115,38 +171,6 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => { entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error()); - await expect( - createFactory({ - entityRegistryManager: entityRegistryManager, - }).getEngineForCamera(createHASS(), config), - ).rejects.toThrow(); - }); - it('should treat entity not in registry but with state as generic', async () => { - const config = createCameraConfig({ - engine: 'auto', - webrtc_card: { entity: 'camera.foo' }, - }); - const entityRegistryManager = new EntityRegistryManager(new EntityCache()); - - entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error()); - - expect( - await createFactory({ - entityRegistryManager: entityRegistryManager, - }).getEngineForCamera( - createHASS({ - 'camera.foo': createStateEntity(), - }), - config, - ), - ).toBe(Engine.Generic); - }); - it('should get engine from webrtc-card configuration', async () => { - const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); - const entityRegistryManager = new EntityRegistryManager(new EntityCache()); - - entityRegistryManager.getEntity = vi.fn().mockRejectedValue(new Error()); - await expect( createFactory({ entityRegistryManager: entityRegistryManager, @@ -155,7 +179,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => { }); }); -describe('CameraManagerEngineFactory.createEngine()', () => { +describe('createEngine()', () => { it('should create generic engine', async () => { expect(await createFactory().createEngine(Engine.Generic)).toBeInstanceOf( GenericCameraManagerEngine, diff --git a/tests/camera-manager/generic/engine-generic.test.ts b/tests/camera-manager/generic/engine-generic.test.ts index b6fe7cf5..3ee4b706 100644 --- a/tests/camera-manager/generic/engine-generic.test.ts +++ b/tests/camera-manager/generic/engine-generic.test.ts @@ -192,11 +192,23 @@ describe('GenericCameraManagerEngine', () => { expect( createEngine().getCameraMetadata(createHASS(), createGenericCameraConfig()), ).toEqual({ - icon: 'mdi:bookmark', + icon: 'mdi:video', title: '', }); }); + it('with id', async () => { + expect( + createEngine().getCameraMetadata( + createHASS(), + createGenericCameraConfig({ id: 'https://go2rtc#stream' }), + ), + ).toEqual({ + icon: 'mdi:video', + title: 'https://go2rtc#stream', + }); + }); + it('with configured title', async () => { expect( createEngine().getCameraMetadata( @@ -206,7 +218,7 @@ describe('GenericCameraManagerEngine', () => { }), ), ).toEqual({ - icon: 'mdi:bookmark', + icon: 'mdi:video', title: 'My Camera', }); }); @@ -225,7 +237,7 @@ describe('GenericCameraManagerEngine', () => { }), ), ).toEqual({ - icon: 'mdi:bookmark', + icon: 'mdi:video', title: 'My Entity Camera', }); }); @@ -245,7 +257,7 @@ describe('GenericCameraManagerEngine', () => { }), ), ).toEqual({ - icon: 'mdi:bookmark', + icon: 'mdi:video', title: 'My Entity Camera', }); }); diff --git a/tests/utils/camera.test.ts b/tests/utils/camera.test.ts index 5b885861..a69f5432 100644 --- a/tests/utils/camera.test.ts +++ b/tests/utils/camera.test.ts @@ -20,6 +20,14 @@ describe('getCameraID', () => { const config = createCameraConfig({ webrtc_card: { entity: 'foo' } }); expect(getCameraID(config)).toBe('foo'); }); + it('should get camera id with webrtc url', () => { + const config = createCameraConfig({ webrtc_card: { url: 'foo' } }); + expect(getCameraID(config)).toBe('foo'); + }); + it('should get camera id with go2rtc url and stream', () => { + const config = createCameraConfig({ go2rtc: { url: 'https://foo', stream: 'office' } }); + expect(getCameraID(config)).toBe('https://foo#office'); + }); it('should get camera id with frigate camera_name', () => { const config = createCameraConfig({ frigate: { client_id: 'bar', camera_name: 'foo' }, diff --git a/tests/utils/ha/index.test.ts b/tests/utils/ha/index.test.ts index 15f5675a..02903b8f 100644 --- a/tests/utils/ha/index.test.ts +++ b/tests/utils/ha/index.test.ts @@ -1,7 +1,10 @@ import { HomeAssistant } from 'custom-card-helpers'; import { describe, expect, it } from 'vitest'; -import { hasHAConnectionStateChanged } from '../../../src/utils/ha/index.js'; -import { createHASS } from '../../test-utils.js'; +import { + getEntityIcon, + hasHAConnectionStateChanged, +} from '../../../src/utils/ha/index.js'; +import { createHASS, createStateEntity } from '../../test-utils.js'; const createConnected = (connected: boolean): HomeAssistant => { const hass = createHASS(); @@ -43,3 +46,24 @@ describe('hasHAConnectionStateChanged', () => { expect(hasHAConnectionStateChanged(null, null)).toBeFalsy(); }); }); + +describe('getEntityIcon', () => { + it('should get icon from attributes', () => { + expect( + getEntityIcon( + createHASS({ + 'camera.test': createStateEntity({ + attributes: { + icon: 'mdi:cow', + }, + }), + }), + 'camera.test', + ), + ).toBe('mdi:cow'); + }); + + it('should get icon from domain', () => { + expect(getEntityIcon(createHASS(), 'camera.test')).toBe('mdi:video'); + }); +});