diff --git a/src/card-controller/media-player-manager.ts b/src/card-controller/media-player-manager.ts index 34dabdec..e6af01bc 100644 --- a/src/card-controller/media-player-manager.ts +++ b/src/card-controller/media-player-manager.ts @@ -1,9 +1,11 @@ +import { CameraConfig } from '../config/types'; import { MEDIA_PLAYER_SUPPORT_BROWSE_MEDIA } from '../const'; -import { ViewMedia } from '../view/media'; -import { ViewMediaClassifier } from '../view/media-classifier'; +import { localize } from '../localize/localize'; import { errorToConsole } from '../utils/basic'; import { Entity } from '../utils/ha/entity-registry/types'; import { supportsFeature } from '../utils/ha/update'; +import { ViewMedia } from '../view/media'; +import { ViewMediaClassifier } from '../view/media-classifier'; import { CardMediaPlayerAPI } from './types'; export class MediaPlayerManager { @@ -76,11 +78,27 @@ export class MediaPlayerManager { } public async playLive(mediaPlayer: string, cameraID: string): Promise { - const hass = this._api.getHASSManager().getHASS(); const cameraConfig = this._api .getCameraManager() .getStore() .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 { + const hass = this._api.getHASSManager().getHASS(); const cameraEntity = cameraConfig?.camera_entity ?? null; if (!hass || !cameraEntity) { @@ -102,6 +120,34 @@ export class MediaPlayerManager { }); } + protected async _playLiveDashboard( + mediaPlayer: string, + cameraConfig: CameraConfig, + ): Promise { + 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 { const hass = this._api.getHASSManager().getHASS(); diff --git a/src/card-controller/query-string-manager.ts b/src/card-controller/query-string-manager.ts index 533352c5..faf1f813 100644 --- a/src/card-controller/query-string-manager.ts +++ b/src/card-controller/query-string-manager.ts @@ -23,16 +23,53 @@ export class QueryStringManager { public executeNonViewRelated = (): void => { this._executeNonViewRelated(this._calculateIntent()); - } + }; public executeViewRelated = (): void => { this._executeViewRelated(this._calculateIntent()); - } + }; public executeAll = (): void => { const intent = this._calculateIntent(); this._executeViewRelated(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 { diff --git a/src/card-controller/types.ts b/src/card-controller/types.ts index 28f0a115..95776b50 100644 --- a/src/card-controller/types.ts +++ b/src/card-controller/types.ts @@ -163,6 +163,8 @@ export interface CardMediaPlayerAPI { getHASSManager(): HASSManager; getCameraManager(): CameraManager; getEntityRegistryManager(): EntityRegistryManager; + getMessageManager(): MessageManager; + getQueryStringManager(): QueryStringManager; } export interface CardMessageAPI { diff --git a/src/config/types.ts b/src/config/types.ts index e5d44251..a6140877 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -32,6 +32,8 @@ export const FRIGATE_CARD_VIEWS_USER_SPECIFIED = [ 'image', 'timeline', ] as const; +export type FrigateCardUserSpecifiedView = + (typeof FRIGATE_CARD_VIEWS_USER_SPECIFIED)[number]; const FRIGATE_CARD_VIEWS = [ ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, @@ -213,6 +215,7 @@ const FRIGATE_CARD_GENERAL_ACTIONS = [ 'screenshot', 'unmute', ] as const; +export type FrigateCardGeneralAction = (typeof FRIGATE_CARD_GENERAL_ACTIONS)[number]; const FRIGATE_CARD_ACTIONS = [ ...FRIGATE_CARD_VIEWS_USER_SPECIFIED, @@ -237,6 +240,9 @@ const frigateCardCameraSelectActionSchema = frigateCardCustomActionsBaseSchema.e frigate_card_action: z.literal('camera_select'), camera: z.string(), }); +export type FrigateCardCameraSelectAction = z.infer< + typeof frigateCardCameraSelectActionSchema +>; const frigateCardLiveDependencySelectActionSchema = frigateCardCustomActionsBaseSchema.extend({ @@ -920,6 +926,24 @@ const liveOverridesSchema = z .optional(); export type LiveOverrides = z.infer; +// ************************************************************************* +// 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 // ************************************************************************* @@ -1035,6 +1059,8 @@ export const cameraConfigSchema = z image: liveImageConfigSchema.default(cameraConfigDefault.image), jsmpeg: jsmpegConfigSchema.optional(), webrtc_card: webrtcCardConfigSchema.optional(), + + cast: castSchema.optional(), }) .default(cameraConfigDefault); export type CameraConfig = z.infer; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index a5e860f4..3bf33baa 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -444,6 +444,7 @@ "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_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_visible_cameras": "No visible cameras found, you must configure at least one non-hidden camera", "reconnecting": "Reconnecting", diff --git a/src/localize/languages/it.json b/src/localize/languages/it.json index fa1b9a97..3d66105a 100644 --- a/src/localize/languages/it.json +++ b/src/localize/languages/it.json @@ -436,6 +436,7 @@ "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_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_visible_cameras": "Nessuna telecamera visibile trovata, è necessario configurare almeno una telecamera non nascosta", "reconnecting": "Riconnessione", diff --git a/src/localize/languages/pt-BR.json b/src/localize/languages/pt-BR.json index 827c014d..394ce8a4 100644 --- a/src/localize/languages/pt-BR.json +++ b/src/localize/languages/pt-BR.json @@ -443,6 +443,7 @@ "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_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_visible_cameras": "Nenhuma câmera visível encontrada, você deve configurar pelo menos uma câmera não oculta", "reconnecting": "Reconectando", diff --git a/src/localize/languages/pt-PT.json b/src/localize/languages/pt-PT.json index 5ec59b7f..438bfb40 100644 --- a/src/localize/languages/pt-PT.json +++ b/src/localize/languages/pt-PT.json @@ -429,6 +429,7 @@ "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_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_visible_cameras": "Sem camaras visiveis", "reconnecting": "A voltar a ligar", diff --git a/tests/card-controller/media-player-manager.test.ts b/tests/card-controller/media-player-manager.test.ts index 389cc149..411cd046 100644 --- a/tests/card-controller/media-player-manager.test.ts +++ b/tests/card-controller/media-player-manager.test.ts @@ -143,52 +143,10 @@ describe('MediaPlayerManager', () => { describe('should play', () => { describe('live', () => { - it('successfully', async () => { + it('without camera config', 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(cameraManager.getStore().getCameraConfig).mockReturnValue(null); vi.mocked(api.getCameraManager).mockReturnValue(cameraManager); vi.mocked(api.getHASSManager().getHASS).mockReturnValue(createHASS()); const manager = new MediaPlayerManager(api); @@ -198,30 +156,176 @@ describe('MediaPlayerManager', () => { 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 cameraManager = createCameraManager(); vi.mocked(cameraManager.getStore().getCameraConfig).mockReturnValue( createCameraConfig({ camera_entity: 'camera.foo', + cast: { + method: 'dashboard', + }, }), ); 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: {}, - }, - ); + expect( + vi.mocked(api.getMessageManager().setMessageIfHigherPriority), + ).toBeCalledWith({ + type: 'error', + icon: 'mdi:cast', + message: + "Both 'dashboard_path' and 'view_path' parameters are required " + + "for the 'dashboard' cast method", + }); }); }); diff --git a/tests/card-controller/query-string-manager.test.ts b/tests/card-controller/query-string-manager.test.ts index 330662d4..d453b518 100644 --- a/tests/card-controller/query-string-manager.test.ts +++ b/tests/card-controller/query-string-manager.test.ts @@ -1,7 +1,11 @@ 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 { 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 location: Location = mock(); @@ -295,4 +299,74 @@ describe('QueryStringManager', () => { 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(); + }); + }); });