Add new method of casting live cameras based on dashboards.

This commit is contained in:
Dermot Duffy
2023-10-09 22:04:59 -07:00
parent aa9d061331
commit bc71709cc8
10 changed files with 355 additions and 62 deletions
+49 -3
View File
@@ -1,9 +1,11 @@
import { CameraConfig } from '../config/types';
import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const'; import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const';
import { ViewMedia } from '../view/media'; import { localize } from '../localize/localize';
import { ViewMediaClassifier } from '../view/media-classifier';
import { errorToConsole } from '../utils/basic'; import { errorToConsole } from '../utils/basic';
import { Entity } from '../utils/ha/entity-registry/types'; import { Entity } from '../utils/ha/entity-registry/types';
import { supportsFeature } from '../utils/ha/update'; import { supportsFeature } from '../utils/ha/update';
import { ViewMedia } from '../view/media';
import { ViewMediaClassifier } from '../view/media-classifier';
import { CardMediaPlayerAPI } from './types'; import { CardMediaPlayerAPI } from './types';
export class MediaPlayerManager { export class MediaPlayerManager {
@@ -76,11 +78,27 @@ export class MediaPlayerManager {
} }
public async playLive(mediaPlayer: string, cameraID: string): Promise<void> { public async playLive(mediaPlayer: string, cameraID: string): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
const cameraConfig = this._api const cameraConfig = this._api
.getCameraManager() .getCameraManager()
.getStore() .getStore()
.getCameraConfig(cameraID); .getCameraConfig(cameraID);
if (!cameraConfig) {
return;
}
if (cameraConfig.cast?.method === 'dashboard') {
await this._playLiveDashboard(mediaPlayer, cameraConfig);
} else {
await this._playLiveStandard(mediaPlayer, cameraID, cameraConfig);
}
}
protected async _playLiveStandard(
mediaPlayer: string,
cameraID: string,
cameraConfig: CameraConfig,
): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
const cameraEntity = cameraConfig?.camera_entity ?? null; const cameraEntity = cameraConfig?.camera_entity ?? null;
if (!hass || !cameraEntity) { if (!hass || !cameraEntity) {
@@ -102,6 +120,34 @@ export class MediaPlayerManager {
}); });
} }
protected async _playLiveDashboard(
mediaPlayer: string,
cameraConfig: CameraConfig,
): Promise<void> {
const hass = this._api.getHASSManager().getHASS();
if (!hass) {
return;
}
const dashboardConfig = cameraConfig.cast?.dashboard;
if (!dashboardConfig?.dashboard_path || !dashboardConfig?.view_path) {
this._api.getMessageManager().setMessageIfHigherPriority({
type: 'error',
icon: 'mdi:cast',
message: localize('error.no_dashboard_or_view'),
});
return;
}
// When this bug is closed, a query string could be included:
// https://github.com/home-assistant/core/issues/98316
await hass.callService('cast', 'show_lovelace_view', {
entity_id: mediaPlayer,
dashboard_path: dashboardConfig.dashboard_path,
view_path: dashboardConfig.view_path,
});
}
public async playMedia(mediaPlayer: string, media?: ViewMedia | null): Promise<void> { public async playMedia(mediaPlayer: string, media?: ViewMedia | null): Promise<void> {
const hass = this._api.getHASSManager().getHASS(); const hass = this._api.getHASSManager().getHASS();
+39 -2
View File
@@ -23,16 +23,53 @@ export class QueryStringManager {
public executeNonViewRelated = (): void => { public executeNonViewRelated = (): void => {
this._executeNonViewRelated(this._calculateIntent()); this._executeNonViewRelated(this._calculateIntent());
} };
public executeViewRelated = (): void => { public executeViewRelated = (): void => {
this._executeViewRelated(this._calculateIntent()); this._executeViewRelated(this._calculateIntent());
} };
public executeAll = (): void => { public executeAll = (): void => {
const intent = this._calculateIntent(); const intent = this._calculateIntent();
this._executeViewRelated(intent); this._executeViewRelated(intent);
this._executeNonViewRelated(intent); this._executeNonViewRelated(intent);
};
public generateQueryString(action: FrigateCardCustomAction): string | null {
const baseKey =
'frigate-card-action.' + (action.card_id ? `${action.card_id}.` : '');
switch (action.frigate_card_action) {
case 'camera_select':
case 'live_substream_select':
return new URLSearchParams([
[baseKey + action.frigate_card_action, action.camera],
]).toString();
case 'camera_ui':
case 'clip':
case 'clips':
case 'default':
case 'diagnostics':
case 'download':
case 'expand':
case 'image':
case 'live':
case 'menu_toggle':
case 'recording':
case 'recordings':
case 'snapshot':
case 'snapshots':
case 'timeline':
return new URLSearchParams([
[baseKey + action.frigate_card_action, ''],
]).toString();
default:
console.warn(
`Frigate card cannot convert unsupported action to query string:`,
action,
);
}
return null;
} }
protected _executeViewRelated(intent: QueryStringViewIntent): void { protected _executeViewRelated(intent: QueryStringViewIntent): void {
+2
View File
@@ -163,6 +163,8 @@ export interface CardMediaPlayerAPI {
getHASSManager(): HASSManager; getHASSManager(): HASSManager;
getCameraManager(): CameraManager; getCameraManager(): CameraManager;
getEntityRegistryManager(): EntityRegistryManager; getEntityRegistryManager(): EntityRegistryManager;
getMessageManager(): MessageManager;
getQueryStringManager(): QueryStringManager;
} }
export interface CardMessageAPI { export interface CardMessageAPI {
+26
View File
@@ -32,6 +32,8 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [
'image', 'image',
'timeline', 'timeline',
] as const; ] as const;
export type FrigateCardUserSpecifiedView =
(typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED)[number];
const FRIGATE_CARD_VIEWS = [ const FRIGATE_CARD_VIEWS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED, ...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
@@ -213,6 +215,7 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [
'screenshot', 'screenshot',
'unmute', 'unmute',
] as const; ] as const;
export type FrigateCardGeneralAction = (typeof FRIGATE_CARD_GENERAL_ACTIONS)[number];
const FRIGATE_CARD_ACTIONS = [ const FRIGATE_CARD_ACTIONS = [
...FRIGATE_CARD_VIEWS_USER_SPECIFIED, ...FRIGATE_CARD_VIEWS_USER_SPECIFIED,
@@ -237,6 +240,9 @@ const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.e
frigate_card_action: z.literal('camera_select'), frigate_card_action: z.literal('camera_select'),
camera: z.string(), camera: z.string(),
}); });
export type FrigateCardCameraSelectAction = z.infer<
typeof frigateCardCameraSelectActionSchema
>;
const frigateCardLiveDependencySelectActionSchema = const frigateCardLiveDependencySelectActionSchema =
frigateCardCustomActionsBaseSchema.extend({ frigateCardCustomActionsBaseSchema.extend({
@@ -920,6 +926,24 @@ const liveOverridesSchema = z
.optional(); .optional();
export type LiveOverrides = z.infer<typeof liveOverridesSchema>; export type LiveOverrides = z.infer<typeof liveOverridesSchema>;
// *************************************************************************
// Cast Configuration
// *************************************************************************
const castConfigDefault = {
method: 'standard' as const,
};
export const castSchema = z.object({
method: z.enum(['standard', 'dashboard']).default(castConfigDefault.method).optional(),
dashboard: z
.object({
dashboard_path: z.string().optional(),
view_path: z.string().optional(),
})
.optional(),
});
// ************************************************************************* // *************************************************************************
// Camera Configuration // Camera Configuration
// ************************************************************************* // *************************************************************************
@@ -1035,6 +1059,8 @@ export const cameraConfigSchema = z
image: liveImageConfigSchema.default(cameraConfigDefault.image), image: liveImageConfigSchema.default(cameraConfigDefault.image),
jsmpeg: jsmpegConfigSchema.optional(), jsmpeg: jsmpegConfigSchema.optional(),
webrtc_card: webrtcCardConfigSchema.optional(), webrtc_card: webrtcCardConfigSchema.optional(),
cast: castSchema.optional(),
}) })
.default(cameraConfigDefault); .default(cameraConfigDefault);
export type CameraConfig = z.infer<typeof cameraConfigSchema>; export type CameraConfig = z.infer<typeof cameraConfigSchema>;
+1
View File
@@ -444,6 +444,7 @@
"no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers", "no_camera_entity_for_triggers": "A camera entity is required in order to autodetect triggers",
"no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually", "no_camera_id": "Could not determine camera id for the following camera, may need to set 'id' parameter manually",
"no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'", "no_camera_name": "Could not determine a Frigate camera name for camera (or one of its dependents), please specify either 'camera_entity' or 'camera_name'",
"no_dashboard_or_view": "Both 'dashboard_path' and 'view_path' parameters are required for the 'dashboard' cast method",
"no_live_camera": "The camera_entity parameter must be set and valid for this live provider", "no_live_camera": "The camera_entity parameter must be set and valid for this live provider",
"no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera", "no_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera",
"reconnecting": "Reconnecting", "reconnecting": "Reconnecting",
+1
View File
@@ -436,6 +436,7 @@
"no_camera_entity_for_triggers": "È necessaria un'entità telecamera per rilevare automaticamente i trigger", "no_camera_entity_for_triggers": "È necessaria un'entità telecamera per rilevare automaticamente i trigger",
"no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'", "no_camera_id": "Impossibile determinare l'ID della telecamera , potrebbe essere necessario impostare manualmente il parametro 'ID'",
"no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'", "no_camera_name": "Impossibile determinare un nome della telecamera in Frigate, si prega di specificare 'camera_enty' o 'camera_name'",
"no_dashboard_or_view": "",
"no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live", "no_live_camera": "Il parametro fotocamera_enty deve essere impostato e valido per questo provider live",
"no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta", "no_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta",
"reconnecting": "Riconnessione", "reconnecting": "Riconnessione",
+1
View File
@@ -443,6 +443,7 @@
"no_camera_entity_for_triggers": "Uma entidade de câmera é necessária para detectar automaticamente os gatilhos", "no_camera_entity_for_triggers": "Uma entidade de câmera é necessária para detectar automaticamente os gatilhos",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
"no_dashboard_or_view": "",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo", "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este provedor ativo",
"no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta", "no_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta",
"reconnecting": "Reconectando", "reconnecting": "Reconectando",
+1
View File
@@ -429,6 +429,7 @@
"no_camera_entity_for_triggers": "Não existe camera para a acção", "no_camera_entity_for_triggers": "Não existe camera para a acção",
"no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente", "no_camera_id": "Não foi possível determinar o ID da câmera para a câmera a seguir, pode ser necessário definir o parâmetro 'id' manualmente",
"no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir", "no_camera_name": "Não foi possível determinar o nome da câmera da Frigate, especifique 'camera_entity' ou 'camera_name' para a câmera a seguir",
"no_dashboard_or_view": "",
"no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo", "no_live_camera": "O parâmetro camera_entity deve ser definido e válido para este serviço ativo",
"no_visible_cameras": "Sem camaras visiveis", "no_visible_cameras": "Sem camaras visiveis",
"reconnecting": "A voltar a ligar", "reconnecting": "A voltar a ligar",
@@ -143,52 +143,10 @@ describe('MediaPlayerManager', () => {
describe('should play', () => { describe('should play', () => {
describe('live', () => { describe('live', () => {
it('successfully', async () => { it('without camera config', async () => {
const api = createCardAPI(); const api = createCardAPI();
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(null);
createCameraConfig({
camera_entity: 'camera.foo',
}),
);
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'camera title',
icon: 'icon',
});
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
const hass = createHASS({
'camera.foo': createStateEntity({
attributes: {
entity_picture: 'http://thumbnail',
},
}),
});
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new MediaPlayerManager(api);
await manager.playLive('media_player.foo', 'camera');
expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
'media_player',
'play_media',
{
entity_id: 'media_player.foo',
media_content_id: 'media-source://camera/camera.foo',
media_content_type: 'application/vnd.apple.mpegurl',
extra: {
title: 'camera title',
thumb: 'http://thumbnail',
},
},
);
});
it('without camera_entity', async () => {
const api = createCardAPI();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(
createCameraConfig({}),
);
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new MediaPlayerManager(api); const manager = new MediaPlayerManager(api);
@@ -198,30 +156,176 @@ describe('MediaPlayerManager', () => {
expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled(); expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled();
}); });
it('without title and thumbnail', async () => { describe('using standard method', () => {
it('successfully', async () => {
const api = createCardAPI();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(
createCameraConfig({
camera_entity: 'camera.foo',
}),
);
vi.mocked(cameraManager.getCameraMetadata).mockReturnValue({
title: 'camera title',
icon: 'icon',
});
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
const hass = createHASS({
'camera.foo': createStateEntity({
attributes: {
entity_picture: 'http://thumbnail',
},
}),
});
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(hass);
const manager = new MediaPlayerManager(api);
await manager.playLive('media_player.foo', 'camera');
expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
'media_player',
'play_media',
{
entity_id: 'media_player.foo',
media_content_id: 'media-source://camera/camera.foo',
media_content_type: 'application/vnd.apple.mpegurl',
extra: {
title: 'camera title',
thumb: 'http://thumbnail',
},
},
);
});
it('without camera_entity', async () => {
const api = createCardAPI();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(
createCameraConfig({}),
);
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new MediaPlayerManager(api);
await manager.playLive('media_player.foo', 'camera');
expect(api.getHASSManager().getHASS()?.callService).not.toBeCalled();
});
it('without title and thumbnail', async () => {
const api = createCardAPI();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(
createCameraConfig({
camera_entity: 'camera.foo',
}),
);
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new MediaPlayerManager(api);
await manager.playLive('media_player.foo', 'camera');
expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
'media_player',
'play_media',
{
entity_id: 'media_player.foo',
media_content_id: 'media-source://camera/camera.foo',
media_content_type: 'application/vnd.apple.mpegurl',
extra: {},
},
);
});
});
describe('using dashboard method', () => {
it('successfully', async () => {
const api = createCardAPI();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(
createCameraConfig({
camera_entity: 'camera.foo',
cast: {
method: 'dashboard',
dashboard: {
dashboard_path: 'dashboard_path',
view_path: 'view_path',
},
},
}),
);
vi.mocked(api.getQueryStringManager().generateQueryString).mockReturnValue('');
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new MediaPlayerManager(api);
await manager.playLive('media_player.foo', 'camera');
expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith(
'cast',
'show_lovelace_view',
{
entity_id: 'media_player.foo',
dashboard_path: 'dashboard_path',
view_path: 'view_path',
},
);
});
it('without hass', async () => {
const api = createCardAPI();
const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(
createCameraConfig({
camera_entity: 'camera.foo',
cast: {
method: 'dashboard',
dashboard: {
dashboard_path: 'dashboard_path',
view_path: 'view_path',
},
},
}),
);
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(null);
const manager = new MediaPlayerManager(api);
await manager.playLive('media_player.foo', 'camera');
// No actual test can be performed here as nothing observable happens.
// This test serves only as code-coverage long-tail.
});
});
it('without required configuration', async () => {
const api = createCardAPI(); const api = createCardAPI();
const cameraManager = createCameraManager(); const cameraManager = createCameraManager();
vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue(
createCameraConfig({ createCameraConfig({
camera_entity: 'camera.foo', camera_entity: 'camera.foo',
cast: {
method: 'dashboard',
},
}), }),
); );
vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); vi.mocked(api.getCameraManager).mockReturnValue(cameraManager);
vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS());
const manager = new MediaPlayerManager(api); const manager = new MediaPlayerManager(api);
await manager.playLive('media_player.foo', 'camera'); await manager.playLive('media_player.foo', 'camera');
expect(api.getHASSManager().getHASS()?.callService).toBeCalledWith( expect(
'media_player', vi.mocked(api.getMessageManager().setMessageIfHigherPriority),
'play_media', ).toBeCalledWith({
{ type: 'error',
entity_id: 'media_player.foo', icon: 'mdi:cast',
media_content_id: 'media-source://camera/camera.foo', message:
media_content_type: 'application/vnd.apple.mpegurl', "Both 'dashboard_path' and 'view_path' parameters are required " +
extra: {}, "for the 'dashboard' cast method",
}, });
);
}); });
}); });
@@ -1,7 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { createCardAPI } from '../test-utils';
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import { mock } from 'vitest-mock-extended'; import { mock } from 'vitest-mock-extended';
import { QueryStringManager } from '../../src/card-controller/query-string-manager';
import {
FrigateCardGeneralAction,
FrigateCardUserSpecifiedView,
} from '../../src/config/types';
import { createCardAPI } from '../test-utils';
const setQueryString = (qs: string): void => { const setQueryString = (qs: string): void => {
const location: Location = mock<Location>(); const location: Location = mock<Location>();
@@ -295,4 +299,74 @@ describe('QueryStringManager', () => {
expect(api.getActionsManager().executeAction).not.toBeCalled(); expect(api.getActionsManager().executeAction).not.toBeCalled();
}); });
}); });
describe('should generate query string', () => {
describe('that require no arguments', () => {
it.each([
['camera_ui' as const],
['clip' as const],
['clips' as const],
['default' as const],
['diagnostics' as const],
['download' as const],
['expand' as const],
['image' as const],
['live' as const],
['menu_toggle' as const],
['recording' as const],
['recordings' as const],
['snapshot' as const],
['snapshots' as const],
['timeline' as const],
])('%s', (actionName: FrigateCardGeneralAction | FrigateCardUserSpecifiedView) => {
const manager = new QueryStringManager(createCardAPI());
expect(
manager.generateQueryString({
action: 'fire-dom-event',
frigate_card_action: actionName,
}),
).toBe(`frigate-card-action.${actionName}=`);
});
});
describe('that require camera argument', () => {
it.each([['camera_select' as const], ['live_substream_select' as const]])(
'%s',
(actionName: 'camera_select' | 'live_substream_select') => {
const manager = new QueryStringManager(createCardAPI());
expect(
manager.generateQueryString({
action: 'fire-dom-event',
frigate_card_action: actionName,
camera: 'camera',
}),
).toBe(`frigate-card-action.${actionName}=camera`);
},
);
});
it('that include a card_id', () => {
const manager = new QueryStringManager(createCardAPI());
expect(
manager.generateQueryString({
action: 'fire-dom-event',
frigate_card_action: 'clips',
card_id: 'card-id',
}),
).toBe(`frigate-card-action.card-id.clips=`);
});
it('that include an unsupported action', () => {
const spy = vi.spyOn(global.console, 'warn').mockReturnValue(undefined);
const manager = new QueryStringManager(createCardAPI());
expect(
manager.generateQueryString({
action: 'fire-dom-event',
frigate_card_action: 'microphone_unmute',
}),
).toBeNull();
expect(spy).toBeCalled();
});
});
}); });