Merge pull request #1293 from dermotduffy/engine-detection

Improve engine identification and id generation
This commit is contained in:
Dermot Duffy
2023-10-08 15:03:01 -07:00
committed by GitHub
12 changed files with 222 additions and 142 deletions
+5
View File
@@ -96,6 +96,11 @@ export class CameraManagerEngineFactory {
// Frigate technically does not need an entity, if the camera name is // Frigate technically does not need an entity, if the camera name is
// manually set the camera is assumed to be Frigate. // manually set the camera is assumed to be Frigate.
engine = Engine.Frigate; engine = Engine.Frigate;
} else if (
cameraConfig.webrtc_card?.url ||
(cameraConfig.go2rtc?.url && cameraConfig.go2rtc?.stream)
) {
engine = Engine.Generic;
} }
} }
+5 -2
View File
@@ -31,7 +31,7 @@ import {
RecordingSegmentsQuery, RecordingSegmentsQuery,
RecordingSegmentsQueryResultsMap, RecordingSegmentsQueryResultsMap,
} from '../types'; } from '../types';
import { getDefaultGo2RTCEndpoint } from '../utils.js'; import { getCameraEntityFromConfig, getDefaultGo2RTCEndpoint } from '../utils.js';
export class GenericCameraManagerEngine implements CameraManagerEngine { export class GenericCameraManagerEngine implements CameraManagerEngine {
public getEngineType(): Engine { public getEngineType(): Engine {
@@ -159,6 +159,7 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
hass: HomeAssistant, hass: HomeAssistant,
cameraConfig: CameraConfig, cameraConfig: CameraConfig,
): CameraManagerCameraMetadata { ): CameraManagerCameraMetadata {
const cameraEntity = getCameraEntityFromConfig(cameraConfig);
return { return {
title: title:
cameraConfig.title ?? cameraConfig.title ??
@@ -166,7 +167,9 @@ export class GenericCameraManagerEngine implements CameraManagerEngine {
getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ?? getEntityTitle(hass, cameraConfig.webrtc_card?.entity) ??
cameraConfig.id ?? cameraConfig.id ??
'', '',
icon: cameraConfig?.icon ?? getEntityIcon(hass, cameraConfig.camera_entity), icon:
cameraConfig?.icon ??
(cameraEntity ? getEntityIcon(hass, cameraEntity, 'mdi:video') : 'mdi:video'),
}; };
} }
+3
View File
@@ -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); this._store.addCamera(id, result.initializedConfig, result.engine);
}); });
+1 -1
View File
@@ -91,7 +91,7 @@ export class MenuButtonController {
const action = createFrigateCardCustomAction('camera_select', { const action = createFrigateCardCustomAction('camera_select', {
camera: cameraID, camera: cameraID,
}); });
const metadata = cameraManager.getCameraMetadata(cameraID) ?? undefined; const metadata = cameraManager.getCameraMetadata(cameraID);
return { return {
enabled: true, enabled: true,
+8 -1
View File
@@ -14,8 +14,15 @@ export function getCameraID(
(typeof config?.camera_entity === 'string' && config.camera_entity) || (typeof config?.camera_entity === 'string' && config.camera_entity) ||
(typeof config?.webrtc_card === 'object' && (typeof config?.webrtc_card === 'object' &&
config.webrtc_card && config.webrtc_card &&
typeof config.webrtc_card['entity'] === 'string' && ((typeof config.webrtc_card['entity'] === 'string' &&
config.webrtc_card['entity']) || 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' && (typeof config?.frigate === 'object' &&
config.frigate && config.frigate &&
typeof config?.frigate['camera_name'] === 'string' && typeof config?.frigate['camera_name'] === 'string' &&
+19 -6
View File
@@ -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 { HassEntity, MessageBase } from 'home-assistant-js-websocket';
import { StyleInfo } from 'lit/directives/style-map.js'; import { StyleInfo } from 'lit/directives/style-map.js';
import { ZodSchema } from 'zod'; import { ZodSchema } from 'zod';
@@ -11,7 +11,7 @@ import {
signedPathSchema, signedPathSchema,
StateParameters, StateParameters,
} from '../../types.js'; } from '../../types.js';
import { stateIcon } from '../icons/state-icon.js'; import { domainIcon } from '../icons/domain-icon.js';
import { getParseErrorKeys } from '../zod.js'; import { getParseErrorKeys } from '../zod.js';
/** /**
@@ -234,7 +234,7 @@ export function refreshDynamicStateParameters(
params.style = { ...computeStyle(state), ...params.style }; params.style = { ...computeStyle(state), ...params.style };
} }
params.title = params.title ?? (state?.attributes?.friendly_name || params.entity); 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; const domain = state ? computeStateDomain(state) : undefined;
params.data_domain = params.data_domain =
@@ -262,12 +262,25 @@ export function getEntityTitle(
/** /**
* Get the icon of an entity. * Get the icon of an entity.
* @param entity The entity id. * @param entityID The entity id.
* @param hass The Home Assistant object. * @param hass The Home Assistant object.
* @returns The icon or undefined. * @returns The icon or undefined.
*/ */
export function getEntityIcon(hass?: HomeAssistant, entity?: string): string { export function getEntityIcon(
return stateIcon(entity ? hass?.states[entity] : null); 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,
);
} }
/** /**
+2 -3
View File
@@ -52,7 +52,7 @@ const FIXED_DOMAIN_ICONS = {
zone: 'mdi:map-marker-radius', 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) { switch (domain) {
case 'alarm_control_panel': case 'alarm_control_panel':
return alarmPanelIcon(state); return alarmPanelIcon(state);
@@ -195,6 +195,5 @@ export function domainIcon(domain: string, entity?: HassEntity, state?: string):
return FIXED_DOMAIN_ICONS[domain]; return FIXED_DOMAIN_ICONS[domain];
} }
console.warn(`Unable to find icon for domain: ${domain}`); return defaultIcon ?? DEFAULT_DOMAIN_ICON;
return DEFAULT_DOMAIN_ICON;
} }
-18
View File
@@ -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);
}
+68 -44
View File
@@ -29,26 +29,16 @@ const createFactory = (options?: {
); );
}; };
describe('CameraManagerEngineFactory.getEngineForCamera()', () => { describe('getEngineForCamera()', () => {
it('should get frigate engine from config', async () => { describe('should get a frigate camera', () => {
it('from manually set engine', async () => {
const config = createCameraConfig({ engine: 'frigate' }); const config = createCameraConfig({ engine: 'frigate' });
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe( expect(await createFactory().getEngineForCamera(createHASS(), config)).toBe(
Engine.Frigate, Engine.Frigate,
); );
}); });
it('should get motionEye engine from config', async () => {
const config = createCameraConfig({ engine: 'motioneye' }); it('from auto detection', async () => {
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 config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(new EntityCache()); const entityRegistryManager = new EntityRegistryManager(new EntityCache());
@@ -64,7 +54,26 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
}).getEngineForCamera(createHASS(), config), }).getEngineForCamera(createHASS(), config),
).toBe(Engine.Frigate); ).toBe(Engine.Frigate);
}); });
it('should get motioneye engine from auto config', async () => {
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,
);
});
});
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 config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(new EntityCache()); const entityRegistryManager = new EntityRegistryManager(new EntityCache());
@@ -80,7 +89,17 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
}).getEngineForCamera(createHASS(), config), }).getEngineForCamera(createHASS(), config),
).toBe(Engine.MotionEye); ).toBe(Engine.MotionEye);
}); });
it('should get generic engine from auto config', async () => { });
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 config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(new EntityCache()); const entityRegistryManager = new EntityRegistryManager(new EntityCache());
@@ -96,32 +115,8 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
}).getEngineForCamera(createHASS(), config), }).getEngineForCamera(createHASS(), config),
).toBe(Engine.Generic); ).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,
);
});
it('should get no engine from config with insufficient details', async () => {
const config = createCameraConfig({});
expect(await createFactory().getEngineForCamera(createHASS(), config)).toBeNull();
});
it('should throw error on invalid entity', async () => { it('from entity not in registry but with state', 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,
}).getEngineForCamera(createHASS(), config),
).rejects.toThrow();
});
it('should treat entity not in registry but with state as generic', async () => {
const config = createCameraConfig({ const config = createCameraConfig({
engine: 'auto', engine: 'auto',
webrtc_card: { entity: 'camera.foo' }, webrtc_card: { entity: 'camera.foo' },
@@ -141,7 +136,36 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
), ),
).toBe(Engine.Generic); ).toBe(Engine.Generic);
}); });
it('should get engine from webrtc-card configuration', async () => {
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();
});
it('should throw error on invalid entity', async () => {
const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' }); const config = createCameraConfig({ engine: 'auto', camera_entity: 'camera.foo' });
const entityRegistryManager = new EntityRegistryManager(new EntityCache()); const entityRegistryManager = new EntityRegistryManager(new EntityCache());
@@ -155,7 +179,7 @@ describe('CameraManagerEngineFactory.getEngineForCamera()', () => {
}); });
}); });
describe('CameraManagerEngineFactory.createEngine()', () => { describe('createEngine()', () => {
it('should create generic engine', async () => { it('should create generic engine', async () => {
expect(await createFactory().createEngine(Engine.Generic)).toBeInstanceOf( expect(await createFactory().createEngine(Engine.Generic)).toBeInstanceOf(
GenericCameraManagerEngine, GenericCameraManagerEngine,
@@ -192,11 +192,23 @@ describe('GenericCameraManagerEngine', () => {
expect( expect(
createEngine().getCameraMetadata(createHASS(), createGenericCameraConfig()), createEngine().getCameraMetadata(createHASS(), createGenericCameraConfig()),
).toEqual({ ).toEqual({
icon: 'mdi:bookmark', icon: 'mdi:video',
title: '', 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 () => { it('with configured title', async () => {
expect( expect(
createEngine().getCameraMetadata( createEngine().getCameraMetadata(
@@ -206,7 +218,7 @@ describe('GenericCameraManagerEngine', () => {
}), }),
), ),
).toEqual({ ).toEqual({
icon: 'mdi:bookmark', icon: 'mdi:video',
title: 'My Camera', title: 'My Camera',
}); });
}); });
@@ -225,7 +237,7 @@ describe('GenericCameraManagerEngine', () => {
}), }),
), ),
).toEqual({ ).toEqual({
icon: 'mdi:bookmark', icon: 'mdi:video',
title: 'My Entity Camera', title: 'My Entity Camera',
}); });
}); });
@@ -245,7 +257,7 @@ describe('GenericCameraManagerEngine', () => {
}), }),
), ),
).toEqual({ ).toEqual({
icon: 'mdi:bookmark', icon: 'mdi:video',
title: 'My Entity Camera', title: 'My Entity Camera',
}); });
}); });
+8
View File
@@ -20,6 +20,14 @@ describe('getCameraID', () => {
const config = createCameraConfig({ webrtc_card: { entity: 'foo' } }); const config = createCameraConfig({ webrtc_card: { entity: 'foo' } });
expect(getCameraID(config)).toBe('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', () => { it('should get camera id with frigate camera_name', () => {
const config = createCameraConfig({ const config = createCameraConfig({
frigate: { client_id: 'bar', camera_name: 'foo' }, frigate: { client_id: 'bar', camera_name: 'foo' },
+26 -2
View File
@@ -1,7 +1,10 @@
import { HomeAssistant } from 'custom-card-helpers'; import { HomeAssistant } from 'custom-card-helpers';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { hasHAConnectionStateChanged } from '../../../src/utils/ha/index.js'; import {
import { createHASS } from '../../test-utils.js'; getEntityIcon,
hasHAConnectionStateChanged,
} from '../../../src/utils/ha/index.js';
import { createHASS, createStateEntity } from '../../test-utils.js';
const createConnected = (connected: boolean): HomeAssistant => { const createConnected = (connected: boolean): HomeAssistant => {
const hass = createHASS(); const hass = createHASS();
@@ -43,3 +46,24 @@ describe('hasHAConnectionStateChanged', () => {
expect(hasHAConnectionStateChanged(null, null)).toBeFalsy(); 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');
});
});